Compare commits

..

35 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
92 changed files with 7600 additions and 136 deletions

View File

@@ -1692,7 +1692,7 @@ Plans:
3. The crawler reports SEO issues per URL, and each issue can be fixed through MCP.
4. The new code has unit tests, delivered in the phase's last plan.
**Plans:** 13/16 plans executed
**Plans:** 16/16 plans executed
Plans:
**Wave 1**
@@ -1725,7 +1725,7 @@ Plans:
- [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)*
- [ ] 33-14-PLAN.md — unit tests, check-phase33.sh --all, release tags in order, app re-pins, changelog post
- [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
@@ -1819,7 +1819,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
| 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 | 13/14 | In Progress| |
| 33. SEO output, audit and content | 16/16 | In Progress| |
| 34. Plugin marketplace | 6/6 | In Progress| |
## Backlog
@@ -1844,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

@@ -1068,6 +1068,11 @@ Recent decisions affecting current work:
| 261028-pq8 | Fix the four internal/build scaffolder todos (make:plugin vendor path, make:admin-controller output, scaffold header and make:command deps, same-second migration order) | 2026-10-28 | 8bc18c9 | [261028-pq8-fix-the-four-internal-build-scaffolder-t](./quick/261028-pq8-fix-the-four-internal-build-scaffolder-t/) |
| 261028-r16 | Fix the two lagoon todos: stored-upload extension from sniffed MIME (tests + deploy note) and the lagoon README after-commit callback order | 2026-10-28 | 36092f5 | [261028-r16-fix-the-two-lagoon-todos-derive-stored-u](./quick/261028-r16-fix-the-two-lagoon-todos-derive-stored-u/) |
| fast | ensureToolchain keeps an existing toolchain line instead of adding a second one | 2026-10-28 | 6ea2cb7 | — |
| 261106-dv0 | shared plugins log boot details at Debug: paymentgateway operators registered, paymentgatewaysubscriptions registries collected through diary.For (todo 2026-10-28-plugins-log-boot-details-at-debug.md) | 2026-11-06 | 8a88b86, 1df6a26 | [261106-dv0-plugin-boot-logs-at-debug](./quick/261106-dv0-plugin-boot-logs-at-debug/) |
| 261106-e5v | purge token rename leftovers: grzybyfunkcjonalne check-deploy refuses NUXT_SITE_* keys, journal/menu/faq READMEs name NUXT_CACHE_PURGE_TOKEN (todo 2026-10-20-rename-site-cache-purge-env-in-consumers.md) | 2026-11-06 | 4e2cf0c, f61c90a, 128a33d, a53511b | [261106-e5v-purge-token-rename-leftovers](./quick/261106-e5v-purge-token-rename-leftovers/) |
| 261106-jhk | wristband MCP authorize drops OIDC identity scopes (openid, profile, email) so the ChatGPT connector can connect | 2026-11-06 | aba678b | [261106-jhk-wristband-mcp-authorize-ignores-oidc-ide](./quick/261106-jhk-wristband-mcp-authorize-ignores-oidc-ide/) |
| 261106-kt1 | pier MCP 401 challenge names only resource_metadata, so clients request the advertised scopes (write included) | 2026-11-06 | 609caa4 | [261106-kt1-pier-mcp-401-challenge-drops-the-fixed-r](./quick/261106-kt1-pier-mcp-401-challenge-drops-the-fixed-r/) |
| 261106-kt2 | deferred:purge, migrate* and queue:clear reuse the app's published database, so the in-server scheduler no longer fails with duplicate provider for *sql.DB | 2026-11-06 | 99abe88 | [261106-kt2-lagoon-deferred-purge-fails-in-the-sched](./quick/261106-kt2-lagoon-deferred-purge-fails-in-the-sched/) |
## Deferred Items

View File

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

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

View File

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

@@ -5,7 +5,7 @@ titles: json
findings:
- id: CR-01
severity: critical
disposition: open
disposition: fixed
title: "An in-flight MCP allow recreates a consent the user just revoked"
- id: WR-01
severity: warning
@@ -31,7 +31,7 @@ findings:
severity: warning
disposition: open
title: "`/connect` can allow a stale request after `?request=` changes"
open: 7
open: 6
total: 7
recorded: 2026-10-28T16:03:08.042Z
---
@@ -40,7 +40,7 @@ recorded: 2026-10-28T16:03:08.042Z
| Finding | Severity | Disposition | Source |
|---------|----------|-------------|--------|
| CR-01 | critical | open | - |
| CR-01 | critical | fixed | sm-oauthserver-plugin 21f5985 (unreleased; ships with the next plugin tag) |
| WR-01 | warning | open | - |
| WR-02 | warning | open | - |
| WR-03 | warning | open | - |

View File

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

View File

@@ -53,3 +53,14 @@ Jakub runs these on https://grzybyfunkcjonalne.pl after the 33-14 deploy (steps
6. **The visual check of the 0.8 card and title markup.** Look at the home page, `/blog`, a category, a post and `/autor/admin`, on desktop and mobile. The post cards are now clickable as a whole and have no "more" link. Check the post title markup, the breadcrumbs at the top of the journal pages, and the footer link "Mapa strony".
7. **Resubmit the sitemap in Search Console.** Submit `https://grzybyfunkcjonalne.pl/sitemap.xml` (same address, now an index) and check that Google reads its files.
8. **libwebp on the server.** Run `ls /usr/lib/x86_64-linux-gnu/libwebp.so*`. If present, the Journal's WebP thumbnails use the fast encoder; if absent, the slower built-in one. Either works with the dynamically linked binary, unless 33-14 builds with `-tags nodynamic`.
## 33-14 deploy (2026-11-05, production, tagged build)
Steps 1 to 11 of DEPLOY.md "SEO pages and sitemaps (Phase 33)" ran on the tagged build (summercms v0.6.1, sm-journal-plugin v0.4.0, sm-sitemap-plugin v0.2.0, sm-seo-plugin v0.2.0, vue-journal-layer v0.8.1, vue-menu-layer v0.5.1, vue-faq-layer v0.5.1, vue-seo-layer v0.2.0; sm-grzybyfunkcjonalne-app 75736b4, static binary with `-tags nodynamic`). Step 5: `en` was already disabled. Step 10: Additional addresses `/` and `/blog` removed. Step 12: `smoke.sh --url … --also urls-production.txt` failed one line, `/autor/admin` expecting the rehearsal author's name "admin" in the title; production shows "Wpisy autora Dawid P. | Grzyby Funkcjonalne". `smoke-seo.sh` and `check-deploy.sh --live` ok. Not rolled back; details and the checklist results in 33-release-log.md, steps 9d and 9e. Open for Jakub: items 3 (SEO › Pages in /gfadmin), 5 (a crawl), 6 (visual check), 7 (Search Console resubmit), and the 404-log cleanup of the smoke entries.
### Step 12 completed (Jakub's answer A: keep the deploy)
- `scripts/urls.txt` checks the `/autor/admin` title by its fixed parts only (sm-grzybyfunkcjonalne-app 0554f3e); the local rehearsal passes (`smoke: ok (129 urls)`).
- Production smoke rerun on the server, as before: `smoke: ok (149 urls)`.
- The 14 smoke entries in the 404 log (first seen since 01:12Z on 2026-11-05) are deleted; older entries are untouched.
- Still Jakub's: check-list items 3, 5, 6 and 7 above.

View File

@@ -0,0 +1,181 @@
---
phase: 33-seo-output-audit-and-content
plan: 14
subsystem: release
tags: [tests, gate, release, tags, changelog, deploy, seo, sitemap, journal, nuxt-layers, nodynamic]
status: complete
requires:
- phase: 33-01..33-13
provides: the phase's framework seams, plugins, layers, app patches and the release checkpoint
- phase: 33-15, 33-16, quick-261104-xae
provides: the gap closure re-verified in 33-VERIFICATION.md (human_needed, no gaps)
provides:
- "Unit tests for all new phase 33 code (SC-4) and the full check-phase33.sh gate (Tasks 1-2)"
- "Tags: summercms v0.6.1; sm-journal-plugin v0.4.0; sm-sitemap-plugin v0.2.0; sm-seo-plugin v0.2.0 (requires sitemap v0.2.0); vue-seo-layer v0.2.0; vue-journal-layer v0.8.0 and v0.8.1; vue-menu-layer v0.5.0 and v0.5.1; vue-faq-layer v0.5.0 and v0.5.1"
- "Content layers declare vue-seo-layer as a ^0.2.0 peer dependency plus a git devDependency (v0.8.1 / v0.5.1); apps list vue-seo-layer#v0.2.0 directly"
- "Apps pinned to the tags and patched: sm-demo-app (Polish under /pl/), sm-summercmsio-app, sm-grzybyfunkcjonalne-app; static release binaries with -tags nodynamic"
- "Changelog posts for the framework and the three Go plugins"
- "Deploys: summercms.io, demo.summercms.io, bm.jakubzych.com, grzybyfunkcjonalne.pl (production)"
affects: [phase-34, v0.6.2-backlog]
actuals:
tokens: 40000
tasks: 4
commits: 7
plan_head_before: 9c11b70
plan_head_after: 6875627
plan_heads_task4:
summercms.go: { before: 9c11b70, after: 6875627, commits: 7, note: "6d14f71 style: gofmt plus six planning-doc commits; this SUMMARY and the ROADMAP commit follow" }
sm-journal-plugin: { before: cf13c60, after: 6823dcc, commits: 2 }
sm-sitemap-plugin: { before: b2b3a1d, after: fdbe593, commits: 1 }
sm-seo-plugin: { before: c71b58e, after: 22f108b, commits: 1 }
vue-journal-layer: { before: 761c7fc, after: f5dc9b2, commits: 2 }
vue-menu-layer: { before: a7602c9, after: dc38f79, commits: 2 }
vue-faq-layer: { before: 5e5950a, after: a092363, commits: 2 }
sm-summercmsio-app: { before: ca06049, after: 395ed78, commits: 5 }
vue-summercmsio-app: { before: f36cbf1, after: 16ab519, commits: 1 }
sm-demo-app: { before: da481c9, after: da03c06, commits: 9 }
vue-demo-app: { before: 9a12701, after: fe5d38d, commits: 1 }
sm-grzybyfunkcjonalne-app: { before: 35d3664, after: 0554f3e, commits: 10 }
vue-grzybyfunkcjonalne-app: { before: e45ffe8, after: 0771c14, commits: 1 }
sm-bm-app: { before: bd7ee84, after: 43f2fc9, commits: 1, note: "push also carried 552cdce, c2a7823, 9dd0fe4 of other sessions (approved)" }
jz.bm: { before: 0dd0185, after: a27a162, commits: 1 }
tech-stack:
added: []
patterns:
- "A Nuxt layer that builds on another git-installed layer declares it as a semver peer dependency and a git devDependency; the app lists both (pnpm 12 refuses git subdependencies by default, blockExoticSubdeps)"
- "Apps that mount the Journal build their release binary with -tags nodynamic, so gen2brain/webp does not link glibc through purego"
- "Smoke expectations that run against production check only the fixed parts of data-dependent text"
key-files:
created:
- .planning/phases/33-seo-output-audit-and-content/33-14-SUMMARY.md
modified:
- .planning/phases/33-seo-output-audit-and-content/33-release-log.md
- .planning/phases/33-seo-output-audit-and-content/33-13-deploy-log.md
- .planning/phases/33-seo-output-audit-and-content/33-14-notes-vue-journal-layer-v0.8.0.md
- .planning/phases/33-seo-output-audit-and-content/33-14-notes-vue-menu-layer-v0.5.0.md
- .planning/phases/33-seo-output-audit-and-content/33-14-notes-vue-faq-layer-v0.5.0.md
- ../vue-journal-layer/scripts/check-git-install.sh
- ../vue-journal-layer/package.json
- ../vue-menu-layer/package.json
- ../vue-faq-layer/package.json
- ../sm-grzybyfunkcjonalne-app/scripts/urls.txt
- ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh
decisions:
- "Content layers v0.8.1 / v0.5.1 take vue-seo-layer as a ^0.2.0 peer dependency (Jakub, option B); v0.8.0 and v0.5.0 stay published but do not install with pnpm 12 defaults"
- "WebP build option B: every app mounting the Journal builds static with -tags nodynamic"
- "The demo keeps /subscriptions as an Additional address; the route report's noindex list leaves it out (G1 confirmed live)"
- "grzybyfunkcjonalne.pl step 12: keep the deploy and fix the fixture-bound /autor/admin expectation (Jakub, option A)"
- "W5 production SQL length check skipped (Jakub: no production fonoteka)"
---
# Phase 33 Plan 14: Unit tests, the phase gate and the release Summary
**Phase 33 shipped: SummerCMS v0.6.1 with sm-journal-plugin v0.4.0, sm-sitemap-plugin v0.2.0, sm-seo-plugin v0.2.0 and the four SEO-aware Nuxt layers (installable as v0.2.0 / v0.8.1 / v0.5.1 / v0.5.1). Four changelog posts are live. The demo, summercms.io, bm and production grzybyfunkcjonalne.pl run the tagged, statically linked builds, and every smoke ends ok.**
## Performance
- Tasks: 4 of 4. Tasks 1 and 2 (unit tests, gate, VALIDATION) and the Task 3 checkpoint ran earlier. Task 4 (the release) ran on 2026-11-05, about 00:30–01:35 UTC, with two checkpoints in between: the layer dependency and the grzyby smoke.
- Task 4 commits: 7 in summercms.go before this SUMMARY, plus the per-repository commits listed in the frontmatter.
## Accomplishments
Every step is in 33-release-log.md, steps 0 to 10 (TAG lines for all 11 tags).
1. **Pre-checks.**
- gofmt fixes: framework 6d14f71 (15 files, several unformatted since v0.6.0) and sm-journal-plugin b8d98ce.
- `check-phase33.sh --all` green with Docker.
- Every repository is on its default branch, and the patches apply.
2. **Framework v0.6.1** (tag 5db8c61 on 6d14f71), master and tag pushed.
3. **Go plugins.** Each got the framework bump, a regenerated plugin.yaml, its README dependency lines, a GOWORK=off build, vet and `test -short`, then a commit, a tag and a push:
- journal v0.4.0;
- sitemap v0.2.0;
- seo v0.2.0, which also requires sitemap v0.2.0.
4. **Layers.**
- vue-seo-layer v0.2.0.
- journal v0.8.0, menu v0.5.0 and faq v0.5.0 after the link: swap.
- `check-git-install.sh v0.8.0` then failed with ERR_PNPM_EXOTIC_SUBDEP (checkpoint). Under option B the patch releases v0.8.1, v0.5.1 and v0.5.1 make vue-seo-layer a peer dependency.
- `check-git-install.sh v0.8.1` passes, and a scratch app on pnpm defaults installs and builds with the menu and FAQ tags too.
5. **Changelog posts.**
- summercms-0-6-1 (id 66);
- sm-sitemap-plugin-0-2-0 (67);
- sm-seo-plugin-0-2-0 (68);
- sm-journal-plugin-0-4-0 (69).
- All four answer 200, and the plugin posts are listed on /changelog/plugins.
6. **Apps.**
- **sm-summercmsio-app:** framework and journal bumps, the 33-12 changelog patch with the layer pins, and nodynamic. Built in a clean worktree: release v0.6.1, static. Smoke ok.
- **sm-demo-app:** three plugin bumps, the 33-12 patches, then the 33-14 locale patches (Polish under /pl/), nodynamic, and a roadmap sync. Docker tests and check-pins passed; the release build is static; smoke ok.
- **sm-grzybyfunkcjonalne-app:** three plugin bumps and the 33-13 patches, with the Binary row saying "static". nodynamic and a check-deploy fix. Full rehearsal `smoke: ok (128 urls)`, later 129.
- **sm-bm-app:** the frontend build with the robots meta and robots.txt went into jz.bm, then the pointer bump, then the push.
7. **Deploys.**
- **summercms.io:** pg_dump; Journal migrations (2); `journal:image-variants`; public checks ok.
- **Demo:**
- pg_dump; the SEO tokens added without printing them; 15 migrations; the vhost reinstalled after `nginx -t`.
- The "After" block with `/subscriptions` kept. G1 confirmed live: it is left out as noindex.
- `journal:image-variants`, then `check-live` ok and strict `smoke-seo` ok.
- **bm:** pg_dump, binary swap, and the X-Robots-Tag on the live vhost, `/pliki/` included. Verified.
- **grzybyfunkcjonalne.pl:** DEPLOY steps 1 to 12.
- Backups first, then 15 migrations.
- `en` was already disabled.
- seo-settings: 3 changed.
- Route report: 4 routes and 1 noindex path.
- New vhost installed.
- Pages renamed, CSV imported, Additional addresses emptied.
- Rebuild: 74 addresses. 285 WebP variants written.
- Production smoke `ok (149 urls)` after the urls.txt fix; `smoke-seo` ok.
- The smoke's 14 entries in the 404 log removed.
## Deviations from Plan
### Auto-fixed issues
1. **[Rule 1 - Bug] Content layers could not be installed (ERR_PNPM_EXOTIC_SUBDEP).** I stopped at the checkpoint, and Jakub chose B: v0.8.1, v0.5.1 and v0.5.1 declare vue-seo-layer as a peer dependency. check-git-install's scratch app now lists vue-seo-layer at the tag's dev pin.
2. **[Rule 3 - Blocking] grzyby check-deploy.sh looked for the old exact build tag list.** It now requires `summercms_mcp,nodynamic` (75736b4).
3. **[Rule 1 - Bug] grzyby urls.txt `/autor/admin` expected the rehearsal author's name.** Production failed 1 of 148 lines. I stopped without rolling back, and Jakub chose A. The line now checks only the fixed parts of the title (0554f3e). The production rerun is ok.
4. **[Rule 3 - Blocking] The sm-summercmsio-app live checkout carries another session's untracked binary,** which `require_clean` refuses. The release build ran in a clean worktree instead. The journal changelog post was published from a clean worktree at v0.4.0 for the same reason (an untracked go.work.sum).
5. **[Rule 2 - Missing] bm's live vhost location `^~ /pliki/` sets its own add_header.** The header was added there as well, not only in the topics location.
6. **Grzyby Nuxt upload.** The build went to `.output.new` and was swapped at the restart instead of being rsynced over the running `.output`.
7. **Demo DEPLOY "After" block.** It no longer removes `/subscriptions` by hand (G1), per Jakub's instruction.
8. **33-13 patch.** The `plugins/grzyby/go.mod` hunk conflicted only with the v0.6.1 bump and was applied by hand (sitemap require and replace).
### Known follow-ups (not fixed here)
- **`check-phase33.sh --consumers` needs a post-release mode.** It checks the release patches against unpatched app HEADs, so after the release it refuses (`33-12-sm-demo-app.patch does not apply`). Compat passes, and the apps' builds, tests and smokes ran directly.
- **Posts now send `X-Robots-Tag: index, follow`.** vue-seo-layer sends the CMS robots value as a header. The 2026-10-31 cutover check expected no X-Robots-Tag on a post. This is harmless, but the DEPLOY "Verify" line should be updated.
- **The G2 journal-tag drift warning still appears at every Nuxt start** on the demo and on grzyby. It is deferred to v0.6.2.
- **Some gofmt fixes landed in the framework** without a docs change. The docs checker passes.
## Human checks still open (33-VERIFICATION.md)
1. Production grzybyfunkcjonalne.pl, from the 33-13-deploy-log.md check list:
- item 3: SEO › Pages in /gfadmin;
- item 5: a crawl from SEO › Site audit;
- item 6: the visual check on desktop and mobile;
- item 7: resubmit the sitemap in Search Console.
2. SC3 live MCP workflow: a crawl, then dry run and apply from a real client.
3. D-46: Google's Rich Results Test for a post, a category and an FAQ page.
4. Visual checks:
- grzyby;
- the demo on /pl/: the LanguageSwitcher, /pl/mapa-strony and /sitemap, the footer link;
- the new admin screens;
- the Blog Editor outline at 360px (J2).
5. W5 (33-14-risk-max.sql) was skipped by decision.
## For the orchestrator
- **g15office:** post a comment on the phase 33 task. Text: "Phase 33 released: SummerCMS v0.6.1, journal v0.4.0, sitemap v0.2.0, seo v0.2.0, layers seo v0.2.0 / journal v0.8.1 / menu v0.5.1 / faq v0.5.1; demo, summercms.io, bm and grzybyfunkcjonalne.pl deployed; open: human checks listed in 33-14-SUMMARY". Close g15office task w8kupqi3 if it is still open.
- **STATE.md:** updated through the handlers only and not committed. The grzyby blocker is resolved.
## Self-Check: PASSED
- Files: 33-release-log.md, 33-13-deploy-log.md and this SUMMARY exist.
- Tags: all 11 are on their origins (the plan's verify loop printed "tags-ok").
- Package files: no content layer package.json has `link:`.
- Commits:
- summercms.go: 6d14f71, 8edc869, 143b4db, ef164dd, 211687f, 201fd0f and 6875627 are present.
- Every repository: HEAD equals its origin default branch after the pushes. The exception is summercms.go, whose planning-doc commits after the v0.6.1 push are local (6 before this SUMMARY), and it is not pushed.
- Changelog: the four post URLs answer 200.

View File

@@ -13,4 +13,4 @@ Excerpt: FAQ blocks add a FAQPage node to the page's one JSON-LD graph and can r
- **Breaking:** the layer extends vue-seo-layer, and the copied site cache purge route, library and plugin are removed (vue-seo-layer provides them with the same environment variables).
- **Upgrade:** set `NUXT_SEO_API_BASE`, or `seo: { cms: false }` for a site without the SEO plugin. The layer brings vue-seo-layer's HTML sitemap pages (pl `/mapa-strony`, en `/sitemap` by default). An app that renders its pages through a root catch-all (`[...slug].vue`) gets none, so a CMS page at such a path stays visible, unless it sets `seo.htmlSitemap.overCatchAll: true`; an app with its own page at such a path keeps it; `seo: { htmlSitemap: false }` turns them off. When a page shows several FAQs, set `schema` false on all but one.
- Requires vue-seo-layer v0.2.0.
- **Install v0.5.1, not v0.5.0.** vue-seo-layer v0.2.0 is a peer dependency (`^0.2.0`): list `"vue-seo-layer": "git+https://git.golem15.com/golem15/vue-seo-layer.git#v0.2.0"` in the app next to `"vue-faq-layer": "git+https://git.golem15.com/golem15/vue-faq-layer.git#v0.5.1"`. v0.5.0 listed vue-seo-layer as its own git dependency, which pnpm refuses by default (`ERR_PNPM_EXOTIC_SUBDEP`); v0.5.1 is otherwise the same release.

View File

@@ -19,7 +19,7 @@ Excerpt: The journal layer now runs on vue-seo-layer: one H1 per post page, brea
- **Breaking: post cards have one link.** The read-more link and the image link are removed; the whole card is clickable. The post title may be a `p.journal-post__title`, related post cards are `h3`. Adjust app CSS that targets the old elements.
- The shared site cache purge route moved to vue-seo-layer with the same path and variables.
- **Upgrade:** The layer brings vue-seo-layer's HTML sitemap pages (pl `/mapa-strony`, en `/sitemap` by default). An app that renders its pages through a root catch-all (`[...slug].vue`) gets none, so a CMS page at such a path stays visible, unless it sets `seo.htmlSitemap.overCatchAll: true`; an app with its own page at such a path keeps it; `seo: { htmlSitemap: false }` turns them off. Post pages keep `og:type` article and their publish and update times next to the CMS head.
- Requires vue-seo-layer v0.2.0.
- **Install v0.8.1, not v0.8.0.** vue-seo-layer v0.2.0 is a peer dependency (`^0.2.0`): list `"vue-seo-layer": "git+https://git.golem15.com/golem15/vue-seo-layer.git#v0.2.0"` in the app next to `"vue-journal-layer": "git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.8.1"`. v0.8.0 listed vue-seo-layer as its own git dependency, which pnpm refuses by default (`ERR_PNPM_EXOTIC_SUBDEP`); v0.8.1 is otherwise the same release.
## Fixed

View File

@@ -8,4 +8,4 @@ Excerpt: The menu layer now extends vue-seo-layer, so every page of a menu-only
- **Breaking:** `server/api/__site__/cache/purge.post.ts`, `server/lib/siteCachePurge.ts` and `server/plugins/site-cache-purge.ts` are removed from this layer; vue-seo-layer provides the same route, library and plugin with the same environment variables. An app that imported the library from this layer imports it from vue-seo-layer.
- **Upgrade:** set `NUXT_SEO_API_BASE` when the backend is not on `http://127.0.0.1:8096`, or `seo: { cms: false }` for a site without the SEO plugin. The layer brings vue-seo-layer's HTML sitemap pages (pl `/mapa-strony`, en `/sitemap` by default). An app that renders its pages through a root catch-all (`[...slug].vue`) gets none, so a CMS page at such a path stays visible, unless it sets `seo.htmlSitemap.overCatchAll: true`; an app with its own page at such a path keeps it; `seo: { htmlSitemap: false }` turns them off.
- Requires vue-seo-layer v0.2.0.
- **Install v0.5.1, not v0.5.0.** vue-seo-layer v0.2.0 is a peer dependency (`^0.2.0`): list `"vue-seo-layer": "git+https://git.golem15.com/golem15/vue-seo-layer.git#v0.2.0"` in the app next to `"vue-menu-layer": "git+https://git.golem15.com/golem15/vue-menu-layer.git#v0.5.1"`. v0.5.0 listed vue-seo-layer as its own git dependency, which pnpm refuses by default (`ERR_PNPM_EXOTIC_SUBDEP`); v0.5.1 is otherwise the same release.

View File

@@ -0,0 +1,56 @@
---
status: testing
phase: 33-seo-output-audit-and-content
source: [33-VERIFICATION.md]
started: 2026-11-05T01:26:27.413Z
updated: 2026-11-05T01:26:27.413Z
---
## Current Test
number: 1
name: 33-14 Task 4 release steps, in the release-log order: framework v0.6.1, sm-journal-plugin v0.4.0, sm-sitemap-plugin v0.2.0, sm-seo-plugin v0.2.0 (its go.mod must require sm-sitemap-plugin v0.2.0: today it requires v0.1.2, which has no contract package, so GOWORK=off builds fail), vue-seo-layer v0.2.0, then the link:../vue-seo-layer swap in vue-journal-layer, vue-menu-layer and vue-faq-layer, `check-git-install.sh --check-links` over the four layers (must exit 0) and `check-git-install.sh <tag>`, then their tags; app pins and patches (33-12, 33-13, then 33-14-*-locale.patch on the demo); -tags nodynamic in the WebP-linking app builds with the 33-13 DEPLOY.md 'Binary' row corrected; recreate the 33-12 and 33-13 rehearsal databases from their source dumps before the rehearsal (migration 202610280002 was edited); changelog posts
expected: |
Every step logged in 33-release-log.md; release binaries statically linked; nothing tagged off the default branch
awaiting: user response
## Tests
### 1. 33-14 Task 4 release steps, in the release-log order: framework v0.6.1, sm-journal-plugin v0.4.0, sm-sitemap-plugin v0.2.0, sm-seo-plugin v0.2.0 (its go.mod must require sm-sitemap-plugin v0.2.0: today it requires v0.1.2, which has no contract package, so GOWORK=off builds fail), vue-seo-layer v0.2.0, then the link:../vue-seo-layer swap in vue-journal-layer, vue-menu-layer and vue-faq-layer, `check-git-install.sh --check-links` over the four layers (must exit 0) and `check-git-install.sh <tag>`, then their tags; app pins and patches (33-12, 33-13, then 33-14-*-locale.patch on the demo); -tags nodynamic in the WebP-linking app builds with the 33-13 DEPLOY.md 'Binary' row corrected; recreate the 33-12 and 33-13 rehearsal databases from their source dumps before the rehearsal (migration 202610280002 was edited); changelog posts
expected: Every step logged in 33-release-log.md; release binaries statically linked; nothing tagged off the default branch
result: [pending]
### 2. W5 before the v0.6.1 tag: run 33-14-risk-max.sql (read-only) against every production database whose app takes v0.6.1
expected: No row longer than its rule's max, or each listed row shortened or accepted; results logged in 33-release-log.md
result: [pending]
### 3. SC1 production, after the tags (33-13 after-tags decision): deploy grzybyfunkcjonalne.pl per its DEPLOY.md, first the scripts/urls.txt check on the tagged build, then the 33-13-deploy-log.md checklist items 1-8
expected: Every urls.txt line passes; /sitemap.xml is a sitemapindex of sitemap-posts-1, -categories-1, -authors-1, -pages-1 and -images-1; the posts file lists the 62 posts once each with lastmod and no /en/ or hreflang; /sitemap.xsl 404; a post has one canonical, one JSON-LD script, no hreflang=en, breadcrumbs; SEO › Pages and Settings › Sitemaps as listed; a crawl finishes without /en/ 404s or duplicates; GSC reads the index
result: [pending]
### 4. G1 on a deployed site (demo after its patches): check the server log line 'route report: N routes and M noindex paths sent', then that the migrated Additional address /subscriptions (noindex in Nuxt) is excluded with reason noindex on Settings › Sitemaps and absent from sitemap-pages-1.xml without the manual DEPLOY.md removal
expected: The noindex address leaves the XML and HTML sitemaps after the debounced rebuild; a crawl reports no sitemap_noindex for it
result: [pending]
### 5. SC3 live workflow: run a crawl from SEO › Crawls on a deployed site, then from a real Claude client linked over MCP ask it to fix the reported issues with dry_run, review the preview, apply
expected: Every data issue names a tool the client can call (sitemap_update_settings for a noindex Additional address); dry runs write nothing; applying fixes the data issues; fix: code issues are named as template work
result: [pending]
### 6. D-46: Google's Rich Results Test for a post, a category and an FAQ page from the admin's Rich Results link
expected: No errors for BlogPosting, CollectionPage/ItemList and FAQPage
result: [pending]
### 7. Visual check after the deploys: grzyby home, /blog, a category, a post, /autor/admin on desktop and mobile; the demo on prefix_except_default (/pl/... addresses, LanguageSwitcher, /pl/mapa-strony and /sitemap each in its own locale, footer link to the visitor's locale); the new admin screens (SEO tab, Site audit, Crawls with the cancelling state, Auto-links, Pages, Settings › Sitemaps with the headings left empty) including the Blog Editor outline with long headings at 360px (33-02 J2)
expected: Layouts render as in 33-UI-SPEC.md; long outline headings wrap with a hanging indent and are not truncated
result: [pending]
## Summary
total: 7
passed: 0
issues: 0
pending: 7
skipped: 0
blocked: 0
## Gaps

View File

@@ -1,8 +1,8 @@
---
phase: 33-seo-output-audit-and-content
verified: 2026-11-04T23:53:40Z
verified: 2026-11-05T01:29:36Z
status: human_needed
score: "Roadmap success criteria 3/4 verified (SC2, SC3, SC4); SC1 verified at code level, its production part (grzybyfunkcjonalne.pl on the new format) pending the 33-14 Task 4 release. Plan must-haves 226/235 truths verified (202 of 33-01..33-14 plus 23 of 33-15 and 10 of 33-16): 0 failed, 5 pending the release, 3 superseded by Jakub's 2026-11-04 decisions, 1 human (visual)"
score: "Roadmap success criteria 4/4 verified (SC1's production part now evidenced by the release and deploy logs and re-checked live). Plan must-haves 231/235 truths verified: 0 failed, 0 pending the release, 3 superseded by Jakub's 2026-11-04 decisions, 1 human (J2 at 360px, in human item 3)"
covered_files:
- .planning/phases/33-seo-output-audit-and-content/33-01-PLAN.md
- .planning/phases/33-seo-output-audit-and-content/33-01-SUMMARY.md
@@ -30,11 +30,22 @@ covered_files:
- .planning/phases/33-seo-output-audit-and-content/33-12-SUMMARY.md
- .planning/phases/33-seo-output-audit-and-content/33-13-PLAN.md
- .planning/phases/33-seo-output-audit-and-content/33-13-SUMMARY.md
- .planning/phases/33-seo-output-audit-and-content/33-13-deploy-log.md
- .planning/phases/33-seo-output-audit-and-content/33-14-PLAN.md
- .planning/phases/33-seo-output-audit-and-content/33-14-SUMMARY.md
- .planning/phases/33-seo-output-audit-and-content/33-14-notes-sm-journal-plugin-v0.4.0.md
- .planning/phases/33-seo-output-audit-and-content/33-14-notes-sm-seo-plugin-v0.2.0.md
- .planning/phases/33-seo-output-audit-and-content/33-14-notes-sm-sitemap-plugin-v0.2.0.md
- .planning/phases/33-seo-output-audit-and-content/33-14-notes-summercms-v0.6.1.md
- .planning/phases/33-seo-output-audit-and-content/33-14-notes-vue-faq-layer-v0.5.0.md
- .planning/phases/33-seo-output-audit-and-content/33-14-notes-vue-journal-layer-v0.8.0.md
- .planning/phases/33-seo-output-audit-and-content/33-14-notes-vue-menu-layer-v0.5.0.md
- .planning/phases/33-seo-output-audit-and-content/33-14-notes-vue-seo-layer-v0.2.0.md
- .planning/phases/33-seo-output-audit-and-content/33-15-PLAN.md
- .planning/phases/33-seo-output-audit-and-content/33-15-SUMMARY.md
- .planning/phases/33-seo-output-audit-and-content/33-16-PLAN.md
- .planning/phases/33-seo-output-audit-and-content/33-16-SUMMARY.md
- .planning/phases/33-seo-output-audit-and-content/33-release-log.md
- admin/openapi/admin.json
- admin/src/api/schema.d.ts
- docs/backend/field-types.md
@@ -61,41 +72,44 @@ covered_files:
- modules/phrasebook/lang/pl/validation.yaml
- modules/signpost/README.md
- scripts/check-phase33.sh
covered_digest: "v2:sha256:9f0a6381baf9ae704a9f2f164e838bafb0b226dbe0b2b7461fe028436b0532d3"
covered_digest: "v2:sha256:5894be73f12c8d469b5e95fdd10da818daf2a59e0d37d8e9d6e19078eafcf5e9"
behavior_unverified: 0
overrides_applied: 2
overrides_applied: 3
overrides:
- must_have: "G2 (33-08 drift check): no false journal-tag drift warning while golem15.journal.tag_pages is false"
reason: "Log noise only, no must-have violated. The fix needs a framework change: signpost.Activate refuses a menu item type whose DefaultPattern does not start with /, so sm-journal-plugin cannot drop the journal-tag pattern while tag pages are off (an additive pact field such as ContentDescriptor.Routable, or a signpost rule). Deferred to v0.6.2 (deferred-items.md, 33-12 entry and 'From 33-15')"
reason: "Log noise only, no must-have violated. The fix needs a framework change: signpost.Activate refuses a menu item type whose DefaultPattern does not start with /, so sm-journal-plugin cannot drop the journal-tag pattern while tag pages are off (an additive pact field such as ContentDescriptor.Routable, or a signpost rule). Deferred to v0.6.2 (deferred-items.md, 33-12 entry and 'From 33-15'). Still seen at every Nuxt start on the demo and on grzybyfunkcjonalne.pl after the release (33-release-log.md steps 9b and 9d)"
accepted_by: "Jakub Zych"
accepted_at: "2026-11-04"
- must_have: "D-106 / W5: framework v0.6.1 is strictly additive (patch); lagoon.Validate's stricter min/max length check ships in it"
reason: "Kept in v0.6.1 with an explicit 'Changed' entry in 33-14-notes-summercms-v0.6.1.md (a stored value longer than its max fails its next admin save) and a production SQL check (33-14-risk-max.sql) on every production database before the tag"
reason: "Kept in v0.6.1 with an explicit 'Changed' entry in 33-14-notes-summercms-v0.6.1.md (a stored value longer than its max fails its next admin save). The entry is on the published post https://summercms.io/changelog/summercms-0-6-1 (checked 2026-11-05). The production SQL check that was its second condition is waived by the override below"
accepted_by: "Jakub Zych"
accepted_at: "2026-11-04"
- must_have: "W5 before the v0.6.1 tag: run 33-14-risk-max.sql (read-only) against every production database whose app takes v0.6.1"
reason: "Waived by Jakub on 2026-11-05 ('don't mind it'; no production fonoteka, the app the check was written for). Not run on the databases that did take v0.6.1 (summercms.io, demo.summercms.io, grzybyfunkcjonalne.pl); the residual risk is that a stored value longer than its max fails its next admin save until it is shortened, which the published v0.6.1 notes state. Recorded in 33-release-log.md (Task 3 answer) and 33-14-SUMMARY.md"
accepted_by: "Jakub Zych"
accepted_at: "2026-11-05"
re_verification:
previous_status: gaps_found
previous_verified: 2026-11-04T20:20:34Z
previous_commit: 97abc77
previous_score: "Roadmap SC 2/4; plan must-haves 191/202 (2 failed: G3)"
gaps_closed:
- "D-57 / SC2 hreflang: a page without a translation has no hreflang for that locale (G3; sm-journal-plugin 0a62090, TestContentDescriptorsTranslatedLocales on the real descriptor path)"
- "SC1 / D-24: the sitemaps stay consistent with noindex (G1; sm-seo-plugin 9de6b2e, sm-sitemap-plugin 6b67ab7, vue-seo-layer 7a26692; TestNoindexPathsTracer, TestSitemapNoindexTool, routes.test.ts, smoke routereport noindex=)"
previous_status: human_needed
previous_verified: 2026-11-04T23:53:40Z
previous_commit: 9c11b70
previous_score: "Roadmap SC 3/4 (SC1 production part pending the release); plan must-haves 226/235 (5 pending the release, 3 superseded, 1 human)"
gaps_closed: []
gaps_remaining: []
regressions: []
human_items_settled:
- item: 1
test: "33-14 Task 4 release steps"
disposition: "satisfied: 33-release-log.md steps 0-10 and 6b-9f; 11 tags on their origins and on the default branches, checked with git ls-remote in this run"
- item: 2
test: "W5 before the v0.6.1 tag: 33-14-risk-max.sql"
disposition: "accepted override (waived by Jakub, 2026-11-05)"
- item: 3
test: "SC1 production grzybyfunkcjonalne.pl"
disposition: "satisfied for deploy-log checklist items 1, 2, 4 (public part) and 8 and the urls.txt check (production smoke ok, 149 urls; smoke-seo ok); items 1 and 2 re-checked live in this run. Checklist items 3, 5, 6 and 7 move to human item 3 below"
- item: 4
test: "G1 on a deployed site (demo)"
disposition: "satisfied: /subscriptions left out with reason noindex without the manual removal (33-release-log.md step 9b); re-checked live: absent from sitemap-pages-1.xml and from the HTML sitemap's page group, page answers noindex, nofollow"
human_verification:
- test: "33-14 Task 4 release steps, in the release-log order: framework v0.6.1, sm-journal-plugin v0.4.0, sm-sitemap-plugin v0.2.0, sm-seo-plugin v0.2.0 (its go.mod must require sm-sitemap-plugin v0.2.0: today it requires v0.1.2, which has no contract package, so GOWORK=off builds fail), vue-seo-layer v0.2.0, then the link:../vue-seo-layer swap in vue-journal-layer, vue-menu-layer and vue-faq-layer, `check-git-install.sh --check-links` over the four layers (must exit 0) and `check-git-install.sh <tag>`, then their tags; app pins and patches (33-12, 33-13, then 33-14-*-locale.patch on the demo); -tags nodynamic in the WebP-linking app builds with the 33-13 DEPLOY.md 'Binary' row corrected; recreate the 33-12 and 33-13 rehearsal databases from their source dumps before the rehearsal (migration 202610280002 was edited); changelog posts"
expected: "Every step logged in 33-release-log.md; release binaries statically linked; nothing tagged off the default branch"
why_human: "Pending the release by design: Task 4 runs after this verification and needs Jakub's go-ahead for tags, pushes and deploys"
- test: "W5 before the v0.6.1 tag: run 33-14-risk-max.sql (read-only) against every production database whose app takes v0.6.1"
expected: "No row longer than its rule's max, or each listed row shortened or accepted; results logged in 33-release-log.md"
why_human: "Condition of the accepted W5 override; needs production database access"
- test: "SC1 production, after the tags (33-13 after-tags decision): deploy grzybyfunkcjonalne.pl per its DEPLOY.md, first the scripts/urls.txt check on the tagged build, then the 33-13-deploy-log.md checklist items 1-8"
expected: "Every urls.txt line passes; /sitemap.xml is a sitemapindex of sitemap-posts-1, -categories-1, -authors-1, -pages-1 and -images-1; the posts file lists the 62 posts once each with lastmod and no /en/ or hreflang; /sitemap.xsl 404; a post has one canonical, one JSON-LD script, no hreflang=en, breadcrumbs; SEO › Pages and Settings › Sitemaps as listed; a crawl finishes without /en/ 404s or duplicates; GSC reads the index"
why_human: "Pending the release (production deploy after the tags), not a failure"
- test: "G1 on a deployed site (demo after its patches): check the server log line 'route report: N routes and M noindex paths sent', then that the migrated Additional address /subscriptions (noindex in Nuxt) is excluded with reason noindex on Settings › Sitemaps and absent from sitemap-pages-1.xml without the manual DEPLOY.md removal"
expected: "The noindex address leaves the XML and HTML sitemaps after the debounced rebuild; a crawl reports no sitemap_noindex for it"
why_human: "Proven by TestNoindexPathsTracer and the layer smoke on fixtures; the live round trip needs the released versions on a running site"
- test: "SC3 live workflow: run a crawl from SEO › Crawls on a deployed site, then from a real Claude client linked over MCP ask it to fix the reported issues with dry_run, review the preview, apply"
expected: "Every data issue names a tool the client can call (sitemap_update_settings for a noindex Additional address); dry runs write nothing; applying fixes the data issues; fix: code issues are named as template work"
why_human: "External agent-driven workflow (33-VALIDATION.md manual-only row)"
@@ -103,12 +117,115 @@ human_verification:
expected: "No errors for BlogPosting, CollectionPage/ItemList and FAQPage"
why_human: "External Google tool (33-VALIDATION.md manual-only row)"
- test: "Visual check after the deploys: grzyby home, /blog, a category, a post, /autor/admin on desktop and mobile; the demo on prefix_except_default (/pl/... addresses, LanguageSwitcher, /pl/mapa-strony and /sitemap each in its own locale, footer link to the visitor's locale); the new admin screens (SEO tab, Site audit, Crawls with the cancelling state, Auto-links, Pages, Settings › Sitemaps with the headings left empty) including the Blog Editor outline with long headings at 360px (33-02 J2)"
expected: "Layouts render as in 33-UI-SPEC.md; long outline headings wrap with a hanging indent and are not truncated"
why_human: "Visual rendering; element tests run under happy-dom without layout"
expected: "Layouts render as in 33-UI-SPEC.md; long outline headings wrap with a hanging indent and are not truncated. This item also carries the open grzybyfunkcjonalne.pl checklist items of 33-13-deploy-log.md: item 3, SEO › Pages in /gfadmin lists /, /blog (Artykuły), /kontakt and /trwaja-prace (Trwają prace), none Not on the site, SEO tabs with the CSV descriptions (and, from item 4, Settings › Sitemaps shows the five files with their counts); item 5, a crawl from SEO › Site audit finishes with no /en/ 404s and no duplicate sitemap entries; item 6, the 0.8 card and title markup on desktop and mobile (whole-card links, no more link, breadcrumbs, footer Mapa strony); item 7, resubmit https://grzybyfunkcjonalne.pl/sitemap.xml in Search Console and check that Google reads its files"
why_human: "Visual rendering (element tests run under happy-dom without layout), logged-in admin screens on production, and Google Search Console"
---
# Phase 33: SEO output, audit and content Verification Report
**Phase Goal:** As a site editor and as an SEO specialist auditing a SummerCMS site, I want to have the rendered sites emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, so that sites are indexed correctly and issues can be fixed through MCP.
**Verified:** 2026-11-05T01:29:36Z
**Status:** human_needed
**Re-verification:** Yes, the second one, after the 33-14 release (Task 4). It covers 33-14-SUMMARY.md, 33-release-log.md, 33-13-deploy-log.md and the 33-14 release notes. The gap-closure re-verification (2026-11-04T23:53:40Z, human_needed, commit 9c11b70) and the initial verification (2026-11-04T20:20:34Z, gaps_found, commit 97abc77) are kept below.
**Mode:** mvp.
## Release re-verification result (2026-11-05T01:29:36Z)
The release happened as the SUMMARY says, as far as read-only checks can see. All 11 tags are on their origins, and each tagged commit is on its repository's default branch. The four changelog posts answer 200. Production grzybyfunkcjonalne.pl serves the new sitemap format and the CMS head. The demo leaves the noindex `/subscriptions` out of both sitemaps, with no manual removal. The framework HEAD has no code change since v0.6.1. `go vet ./...` and `go test -short ./...` pass, and gofmt is clean.
No gap is open. Four of the seven human items in 33-UAT.md are settled here: three by evidence and one by an accepted override. Three stay open for a human: the live MCP session, the Rich Results Test, and the visual and admin checks. The third of these now also carries the open grzybyfunkcjonalne.pl checklist items 3, 5, 6 and 7. Status: human_needed.
### User Flow Coverage
| Step | Expected | Evidence | Status |
|------|----------|----------|--------|
| Editor publishes content | Sitemap index with per-type files, real lastmod, stable numbering | Unchanged from the gap-closure run. Live: grzybyfunkcjonalne.pl/sitemap.xml is a sitemapindex of authors, categories, pages, posts and images; demo.summercms.io/sitemap.xml is an index of 4 files | ✓ |
| Crawler/visitor opens a page | SSR head from the CMS, one JSON-LD @graph, hreflang for translated locales only, breadcrumbs | Live on /blog/blogchaga-wprowadzenie: 1 canonical on the apex, 1 ld+json script (WebSite, Organization, WebPage, BlogPosting, BreadcrumbList, Person, ImageObject), robots `index, follow`, 0 hreflang (site is Polish only), `nav.seo-breadcrumbs` Strona główna › Chaga › post, og:type article | ✓ |
| Nuxt-only noindex page | Leaves the XML and HTML sitemaps | Live on the demo: /subscriptions answers `noindex, nofollow` (header and meta); it is absent from sitemap-pages-1.xml (/, /blog, /chat, /contact, /faq, /forum, /quizzes) and from the "Page" group of /sitemap. Its only link there is the header menu | ✓ |
| SEO specialist runs the audit | Issues per URL, each with an MCP tool or fix: code | Automated as before; live MCP session is human item 1 | ✓ (live MCP session: human) |
| Outcome | "sites are indexed correctly and issues can be fixed through MCP" | Production grzybyfunkcjonalne.pl now runs the tagged build: production smoke `ok (149 urls)`, smoke-seo ok, and the live checks above. Search Console reading the index is human item 3 | ✓ (GSC and the MCP fix loop: human) |
### Roadmap Success Criteria
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| SC1 | Sitemap index with per-type files, configurable size, stable numbering, real lastmod, consistent with noindex and robots.txt; grzybyfunkcjonalne.pl on the new format | ✓ VERIFIED | Code level as before. Production, checked live in this run: /sitemap.xml is a `<sitemapindex>` of sitemap-authors-1, -categories-1, -pages-1, -posts-1 and -images-1 on the apex. sitemap-posts-1.xml has 62 `<loc>` entries, 62 unique, 62 `<lastmod>`, 0 `/en/`, 0 hreflang. sitemap-pages-1.xml lists /, /blog, /kontakt and /trwaja-prace once each, so the Additional addresses step held. /sitemap.xsl answers 404. /en/blog answers 404. robots.txt has `Sitemap: https://grzybyfunkcjonalne.pl/sitemap.xml`. /mapa-strony answers 200 with 74 links. http and www redirect 301 to the apex. Noindex consistency is proven live on the demo (row above). Logged only, not re-run: the production smoke `ok (149 urls)` after the `/autor/admin` expectation fix (0554f3e), smoke-seo ok and `check-deploy.sh --live` ok (33-release-log.md 9e and 9f) |
| SC2 | Server-side head, one JSON-LD @graph, hreflang, breadcrumbs from the CMS; build fails on an indexable ssr:false route | ✓ VERIFIED | As before, plus the live production post above. The demo strict `smoke-seo` passed after its deploy: 5 hreflang links on a post and a /pl/ alternate answering 200 (33-release-log.md 9b, logged) |
| SC3 | Crawler reports issues per URL, each fixable through MCP | ✓ VERIFIED (automated) | Unchanged. Live client session: human item 1 |
| SC4 | New code has unit tests in the phase's last plan | ✓ VERIFIED | Unchanged. The release pre-check reran `PHASE33_REQUIRE_DOCKER=1 check-phase33.sh --all` green on 6d14f71 (logged, step 0). This run reran the framework's `go vet ./...` and `go test -short ./...` at HEAD: both exit 0 |
**Score:** 4/4 roadmap criteria verified. Plan must-haves: 231/235 truths verified, 0 failed, 0 pending, 3 superseded, 1 human, 0 present-but-behaviour-unverified.
### Truths that were pending the release
| Plan | Truth | Now | Evidence |
|------|-------|-----|----------|
| 33-13 #1 | urls.txt answers every line as before, repeated on the tagged build | ✓ VERIFIED | Tagged-build rehearsal `smoke: ok (128 urls)`, then `ok (129 urls)` after the `/autor/admin` fix. On production, `ok (149 urls)` with urls-production.txt. One line had failed on production because it was bound to the rehearsal's author name ("admin"); the site was fine (release log 9e). The fix checks only the fixed parts of the title (sm-grzybyfunkcjonalne-app 0554f3e, on origin/master) |
| 33-14 #6 | Release order framework → journal → sitemap → seo → vue-seo-layer → content layers → apps; layers pin the tag instead of link: | ✓ VERIFIED | `git ls-remote --tags` in this run found all 11 tags: summercms v0.6.1 (5db8c61 → 6d14f71), sm-journal-plugin v0.4.0 (48aa3ff → 6823dcc), sm-sitemap-plugin v0.2.0 (1188efd → fdbe593), sm-seo-plugin v0.2.0 (adda8a9 → 22f108b), vue-seo-layer v0.2.0 (b69647f → 3868f3d), vue-journal-layer v0.8.0 (7d93f06 → 0a77e6e) and v0.8.1 (5c26b5d → f5dc9b2), vue-menu-layer v0.5.0 (7e90962 → c605384) and v0.5.1 (47b022e → dc38f79), vue-faq-layer v0.5.0 (cae1502 → 9cc7f91) and v0.5.1 (8d46b31 → a092363). Each tagged commit is an ancestor of its origin default branch, and each origin default branch head is the newest tag's commit, so nothing is tagged off the default branch. The tagged go.mod files require summercms v0.6.1, and sm-seo-plugin also requires sm-sitemap-plugin v0.2.0. The tagged v0.8.1/v0.5.1 package.json files have 0 `link:`: vue-seo-layer is a `^0.2.0` peer plus a `#v0.2.0` git devDependency. This is the option-B deviation Jakub chose after v0.8.0 failed `check-git-install.sh` (ERR_PNPM_EXOTIC_SUBDEP); v0.8.0 and v0.5.0 remain published but do not install with pnpm 12 defaults |
| 33-14 #7 | Patches applied after the tags, apps build and test on the tagged versions, demo check-pins | ✓ VERIFIED (logged; pins checked) | Logged in release log 7a–7d: the builds, tests, `check-pins ok (25 modules)`, static `-tags nodynamic` binaries and smokes. Checked in this run: origin/master of sm-summercmsio-app (395ed78), sm-demo-app (da03c06), sm-grzybyfunkcjonalne-app (0554f3e) and sm-bm-app (43f2fc9) equals the logged heads. The three Journal apps require summercms v0.6.1 and journal v0.4.0, and the demo and grzyby also require seo and sitemap v0.2.0. summer.yaml carries `nodynamic`. The frontend pointers are vue-demo-app fe5d38d, vue-grzybyfunkcjonalne-app 0771c14 and vue-summercmsio-app 16ab519 |
| 33-14 #8 | Framework changelog post the same day; token never printed | ✓ VERIFIED (token: judgment, logged) | In this run, https://summercms.io/changelog/summercms-0-6-1, sm-sitemap-plugin-0-2-0, sm-seo-plugin-0-2-0 and sm-journal-plugin-0-4-0 each answer 200 with matching titles. The three plugin posts are linked from /changelog/plugins. The v0.6.1 post carries the W5 "Changed" text and the nodynamic note |
| 33-14 #9 | Every executed release step appended to 33-release-log.md | ✓ VERIFIED | Steps 0–10 plus 6b, 6c, 6d, 7a–7d, 9a–9f are present, with TAG lines for all 11 tags. The failed 6b and 9e steps are recorded with their checkpoint answers |
Totals (235): 226 from the gap-closure run + 5 above = 231 verified. Superseded: 3 (the two uat truths of 33-13 and the uat check of 33-14; Jakub's 2026-11-04 decision, no uat). Human: 1 (J2 at 360px, in human item 3).
Prohibitions, judgment tier (non-authoritative): D-106 "no push, tag, deploy or post without Jakub's go-ahead" holds per the recorded Task 3 answer and the two checkpoint answers (6c, 9f). "The changelog token is never printed" is supported only by the log ("read by the script only, never printed"), and this run cannot observe it.
### Human items of 33-UAT.md
| UAT row | Item | Disposition here |
|---------|------|------------------|
| 1 | 33-14 Task 4 release steps | **Satisfied** by 33-release-log.md and the tag, branch, pin and changelog checks above. One deviation is accepted at a checkpoint: the layers ship as v0.8.1/v0.5.1 with a peer dependency (option B). The rehearsal databases were rebuilt from scratch rather than recreated from dumps: grzyby used a fresh throwaway database with the PHP export, and the demo uses migrate plus demo:seed. That meets the intent, since the edited migration ran on a clean database |
| 2 | W5 SQL check | **Accepted override** (Jakub, 2026-11-05: "don't mind it"; no production fonoteka). Residual risk recorded in the override |
| 3 | SC1 production grzybyfunkcjonalne.pl | **Satisfied** for the urls.txt check and checklist items 1, 2, the public part of 4, and 8 (nodynamic, so the built-in WebP encoder; 285 variants written). Items 1 and 2 were re-checked live. Items 3, 5, 6 and 7 (and the admin files table of item 4) need a human with /gfadmin or Search Console access and move to human item 3 |
| 4 | G1 on the demo | **Satisfied**: the route report sent 7 routes and 20 noindex paths; Settings › Sitemaps reported left_out `{"noindex":1}` (logged, step 9b). Re-checked live as in the flow table. The row's "a crawl reports no sitemap_noindex" follows, because the address is not in any sitemap, and the live crawl itself is part of human item 1 |
| 5 | SC3 live MCP workflow | Open: human item 1 |
| 6 | D-46 Rich Results Test | Open: human item 2 |
| 7 | Visual and admin checks | Open: human item 3, now including grzyby checklist items 3, 5, 6 and 7 |
33-UAT.md is not edited here. Rows 1–4 stay `[pending]` until `/msd-verify-work 33` records them: pass for rows 1, 3 and 4 with this report as evidence, and skipped with the override as reason for row 2. Row 7's text in the UAT file does not list the grzyby checklist items. They are in this report's `human_verification` item 3 and in 33-13-deploy-log.md.
### Behavioral spot-checks (this run, read-only)
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| 11 tags on origin | `git ls-remote --tags origin refs/tags/<t> refs/tags/<t>^{}` per repository | All 11 present; tag objects and peeled commits as in the table above | ✓ PASS |
| Tags on the default branch | `git merge-base --is-ancestor <commit> origin/<default>`; `ls-remote` of the default branch | All 11 ancestors; each default branch head equals its newest tag's commit | ✓ PASS |
| Changelog posts | `curl` of the four post URLs and /changelog/plugins | 200 ×5; the three plugin posts are linked from /changelog/plugins | ✓ PASS |
| grzyby sitemaps | `curl` /sitemap.xml, /sitemap-posts-1.xml, /sitemap-pages-1.xml, /sitemap.xsl, /robots.txt, /mapa-strony, /en/blog | index of 5 files; posts 62/62 unique/62 lastmod, 0 /en/, 0 hreflang; pages 4; xsl 404; Sitemap: line; HTML sitemap 200; /en/blog 404 | ✓ PASS |
| grzyby post head | `curl` /blog/blogchaga-wprowadzenie | 200; 1 canonical (apex), 1 ld+json, 0 hreflang, robots index, follow, breadcrumbs, og:type article; header `X-Robots-Tag: index, follow` | ✓ PASS |
| Demo noindex page | `curl` /sitemap-pages-1.xml, /sitemap, /subscriptions | /subscriptions absent from both sitemaps' page lists; page answers `noindex, nofollow` | ✓ PASS |
| Framework unchanged since the tag | `git diff --quiet v0.6.1 HEAD -- ':!.planning'` | no diff; 6d14f71 is formatting only (`git show -w` shows one function literal split over lines) | ✓ PASS |
| Framework regression | `go vet ./...`; `go test -short ./...` | exit 0 and exit 0 | ✓ PASS |
| gofmt leftovers of the previous run | `gofmt -l` over tracked Go files (framework, sm-journal-plugin) | 0 and 0; the two Info rows on sm-journal-plugin formatting are resolved (b8d98ce) | ✓ PASS |
| No debt markers in the release commits | added-line grep for TBD/FIXME/XXX/TODO/HACK over each repository's release diff | none; the one hit is a `mktemp` template `XXXXXX` in vue-journal-layer's check-git-install.sh | ✓ PASS |
Not re-run here: the Docker gate, the app builds and smokes, and the server-side steps (migrations, `journal:image-variants`, 404-log cleanup). They are logged in 33-release-log.md with their outputs.
### Anti-patterns and notes from the release
| Item | Severity | Impact |
|------|----------|--------|
| `check-phase33.sh --consumers` refuses after the release (`33-12-sm-demo-app.patch does not apply`): it checks the release patches against unpatched app HEADs | ⚠️ Warning (follow-up) | `--all` no longer passes as-is on the released state. The script needs a post-release mode or the stage retired; recorded as a known follow-up in 33-14-SUMMARY.md. Not a goal gap: the apps' own builds, tests and smokes ran |
| Posts now send `X-Robots-Tag: index, follow` (seen live) | ℹ️ Info | Harmless; the 2026-10-31 cutover check in grzyby DEPLOY.md expected no header and should be updated |
| G2 journal-tag drift line at every Nuxt start (demo, grzyby) | ℹ️ Info (override) | Deferred to v0.6.2 |
| The tagged plugin go.mod files keep `replace git.golem15.com/golem15/summercms => ../summercms.go` | ℹ️ Info | Same as at their earlier tags; a replace in a dependency's go.mod is ignored by consumers |
| vue-journal-layer v0.8.0, vue-menu-layer v0.5.0 and vue-faq-layer v0.5.0 stay published but fail to install with pnpm 12 defaults | ℹ️ Info | READMEs and notes direct users to v0.8.1/v0.5.1 |
| sm-bm-app stays on framework v0.6.0 | ℹ️ Info | bm mounts no Journal; its noindex deploy was the D-72 part |
| summercms.go planning commits after the v0.6.1 push are local | ℹ️ Info | Planning docs only, not pushed |
| covered_files leaves out the 15 files of 6d14f71 `style: gofmt` | ℹ️ Info | Formatting of files outside the phase's features (cmd/summer, cabana token tests, tide, compass…). They are not covered, so later edits there do not mark this phase stale |
### Gaps Summary
None. The release that the previous run left pending is done and evidenced. W5's SQL check is an accepted override. Three human items remain: the live MCP crawl, dry run and apply; the Rich Results Test; and the visual and admin checks, including grzybyfunkcjonalne.pl checklist items 3, 5, 6 and 7 and the Search Console resubmit.
---
# Gap-closure re-verification (2026-11-04T23:53:40Z, human_needed, commit 9c11b70)
The report below is kept unchanged, with its headings moved down one level. Its human items 1–4 are settled above, and its items 5–7 are the current human items 1–3.
## Phase 33: SEO output, audit and content Verification Report
**Phase Goal:** As a site editor and as an SEO specialist auditing a SummerCMS site, I want to have the rendered sites emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, so that sites are indexed correctly and issues can be fixed through MCP.
**Verified:** 2026-11-04T23:53:40Z
**Status:** human_needed
@@ -117,11 +234,11 @@ human_verification:
Repository HEADs checked (none tagged, nothing pushed; commits since the base tag): summercms.go 6e99b18 (84 since v0.6.0, planning docs included), sm-sitemap-plugin b2b3a1d (23 since v0.1.2), sm-seo-plugin c71b58e (37 since v0.1.1), sm-journal-plugin cf13c60 (21 since v0.3.1), vue-seo-layer 3868f3d (24 since v0.1.1), vue-journal-layer 761c7fc (16 since v0.7.1), vue-menu-layer a7602c9 (2 since v0.4.0), vue-faq-layer 5e5950a (4 since v0.4.0).
## Re-verification result (2026-11-04T23:53:40Z)
### Re-verification result (2026-11-04T23:53:40Z)
Both gaps are closed in code, each with a test on the production path. No earlier must-have regressed. The full gate passed again in this run. G2 and W5 are recorded as accepted overrides, following Jakub's decisions of 2026-11-04. What remains is the release (33-14 Task 4) and the checks that can only run after it, so the status is human_needed.
### User Flow Coverage
#### User Flow Coverage
| Step | Expected | Evidence | Status |
|------|----------|----------|--------|
@@ -131,7 +248,7 @@ Both gaps are closed in code, each with a test on the production path. No earlie
| SEO specialist runs the audit | Issues per URL, each with an MCP tool or fix: code | sitemap_noindex on an Additional address names sitemap_update_settings (TestSitemapNoindexTool); the crawl lifecycle is fixed (TestCrawlTimeout, TestCrawlCancelRunning, TestCrawlStaleRecovery, TestCrawlStoreTimeout) | ✓ (live MCP session: human) |
| Outcome | "sites are indexed correctly and issues can be fixed through MCP" | Holds in code and on the fixture and rehearsal sites; production grzybyfunkcjonalne.pl is pending the release | ⚠ pending the release (human) |
### Roadmap Success Criteria
#### Roadmap Success Criteria
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
@@ -142,14 +259,14 @@ Both gaps are closed in code, each with a test on the production path. No earlie
**Score:** 3/4 roadmap criteria verified, SC1's production part pending the release. Plan must-haves: 226/235 truths verified, 0 failed, 0 present-but-behaviour-unverified.
### Previously failed truths
#### Previously failed truths
| Plan | Truth | Now | Evidence |
|------|-------|-----|----------|
| 33-03 #4 | "…xhtml:link alternates for each translated locale…; a locale without a URL gets no alternate…" | ✓ VERIFIED | The Journal descriptors now produce URL "" for untranslated locales on the real path (TestContentDescriptorsTranslatedLocales passed in this run). TestHreflangAlternates (sm-sitemap-plugin b2b3a1d) now uses Journal-shaped records with every enabled locale present; it passed in this run. The fixture-only reliance recorded in the first verification is gone |
| 33-06 #7 | "hreflang comes from one source…; a locale without a translation is absent…" | ✓ VERIFIED | Same descriptor rule; `translatedLocales` (sm-seo-plugin classes/alternates.go:65-79) drops URL ""; TestAlternates (sm-seo-plugin 2a288a9, Journal-shaped records) passed in this run |
### Plan must-haves of the gap-closure plans
#### Plan must-haves of the gap-closure plans
| Plan | Truths | Result | Notes |
|------|--------|--------|-------|
@@ -158,14 +275,14 @@ Both gaps are closed in code, each with a test on the production path. No earlie
Totals (235): 191 earlier + 2 previously failed (now verified) + 23 + 10 = 226 verified. Still pending the release: 5 (33-13 truth 1 repeated on the tags; 33-14 release order, patches with app builds and check-pins, changelog post, release-log steps). Superseded by Jakub's 2026-11-04 decisions: 3 (two uat truths of 33-13, the uat check of 33-14). Human: 1 (J2 at 360px).
### Overrides applied
#### Overrides applied
| Item | Kind | Disposition |
|------|------|-------------|
| G2 false journal-tag drift warning | warning (no must-have violated) | Accepted deferral to v0.6.2 (Jakub, 2026-11-04). It needs a framework change in signpost.Activate or an additive pact field. Not a gap |
| W5 stricter min/max length check in v0.6.1 (D-106 "strictly additive") | judgment-tier prohibition | Accepted for v0.6.1 (Jakub, 2026-11-04). The "Changed" note is present in 33-14-notes-summercms-v0.6.1.md:19, and 33-14-risk-max.sql exists. Running it on production before the tag is human item 2 |
### Review findings in scope
#### Review findings in scope
**33-REVIEW.md, as filtered by the 33-15 and 33-16 plans.** In scope were:
- L1: CR-01, CR-02, WR-01, WR-02 (docs), WR-03, WR-05;
@@ -197,7 +314,7 @@ Out of scope, deferred to the v0.6.2 backlog by the plans (deferred-items.md "Fr
- the info items;
- the verifier warnings W1 to W4.
### Behavioral spot-checks, gate and patches
#### Behavioral spot-checks, gate and patches
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
@@ -208,7 +325,7 @@ Out of scope, deferred to the v0.6.2 backlog by the plans (deferred-items.md "Fr
| Demo locale patches stacked | scratch worktrees: apply the 33-12 patch, then `git apply --check` of 33-14-*-locale.patch | OK for both; vue-demo-app ends on `strategy: 'prefix_except_default'` with the `/pl/mapa-strony` sitemap path; worktrees removed | ✓ PASS |
| Journal stays additive | `git diff v0.3.1..HEAD` of non-test Go files: removed or changed exported `func` lines | 0; menu_item_types_test.go and the smoke test unchanged since 311c28a | ✓ PASS |
### Anti-patterns and remaining notes
#### Anti-patterns and remaining notes
| File | Pattern | Severity | Impact |
|------|---------|----------|--------|
@@ -224,7 +341,7 @@ Out of scope, deferred to the v0.6.2 backlog by the plans (deferred-items.md "Fr
The first verification's info rows are settled. The stale "Saving Settings › Journal answers 500" entry is now marked resolved (deferred-items.md:14). The W5 warning became the override above.
### Human Verification Required
#### Human Verification Required
These are listed in the frontmatter `human_verification`. Everything except item 7 depends on the release.
1. 33-14 Task 4 release steps: tags in order, the sm-seo-plugin pin, the link: swap with `--check-links`, app pins and patches, `-tags nodynamic` with the DEPLOY.md row fixed, rehearsal databases recreated, changelog posts.
@@ -235,7 +352,7 @@ These are listed in the frontmatter `human_verification`. Everything except item
6. D-46 Rich Results Test.
7. Visual checks (grzyby, demo on prefix_except_default, admin screens, J2 at 360px).
### Gaps Summary
#### Gaps Summary
None. G1 and G3 are closed in code, each with a test on the production path. G2 and W5 are accepted overrides. The phase can proceed to the release step (33-14 Task 4), followed by the human items above.
@@ -455,3 +572,8 @@ _Verifier: Claude (msd-verifier)_
_Re-verified: 2026-11-04T23:53:40Z_
_Verifier: Claude (msd-verifier)_
---
_Re-verified after the release: 2026-11-05T01:29:36Z_
_Verifier: Claude (msd-verifier)_

View File

@@ -2,6 +2,15 @@
The phase's release ledger. Plan 33-01 wrote the planned contents; plan 33-14 executes the releases and appends each step to "Executed steps" as it finishes, so a resumed run confirms each `step <n> done` row and skips it: no tag is created twice, no push is forced, a changelog post is published once. Nothing is pushed or tagged before 33-14 (2026-10-28 release cadence: all tags in 33-14).
## Task 3 answer (Jakub, 2026-11-04/05): release
- **Go-ahead:** release after verification (33-VERIFICATION.md re-verified 9c11b70: human_needed, no gaps).
- **Versions:** summercms v0.6.1, sm-journal-plugin v0.4.0, sm-sitemap-plugin v0.2.0, sm-seo-plugin v0.2.0, vue-seo-layer v0.2.0, vue-journal-layer v0.8.0, vue-menu-layer v0.5.0, vue-faq-layer v0.5.0 (layer package.json versions already bumped).
- **WebP build:** option B. Every app that mounts the Journal (demo, summercms.io, grzyby) builds with `-tags nodynamic` (static); DEPLOY.md keeps or restores "static"; the 33-13 grzyby patch's "dynamically linked" Binary row is reverted.
- **Deploys, all four:** summercms.io (Journal migrations, `journal:image-variants`); the demo on rome (33-12 patches plus the 33-14 locale patches, DEPLOY "Before"/"After" blocks, `journal:image-variants`, G1 check on `/subscriptions`); bm (push sm-bm-app incl. 3 commits from other sessions, deploy per DEPLOY.md, nginx header on the live vhost after `nginx -t`); production grzybyfunkcjonalne.pl last (DEPLOY.md steps 1–12, pg_dump first, step 5 `en` off and step 10 Additional addresses emptied confirmed; urls.txt check on the tagged build first; 33-13-deploy-log.md check list after; `journal:image-variants` once).
- **W5 production SQL check (33-14-risk-max.sql):** skipped by Jakub (no production fonoteka; "don't bother").
- **Changelog:** framework v0.6.1 on /changelog; sm-journal-plugin v0.4.0, sm-sitemap-plugin v0.2.0, sm-seo-plugin v0.2.0 on /changelog/plugins; no layer posts.
## Version base (Jakub, 2026-11-04)
The plan text was written against v0.5.1 / v0.5.2. Tags v0.5.2, v0.5.3 and v0.6.0 were released in the meantime, so:
@@ -66,3 +75,25 @@ Each plugin tag gets a post on summercms.io/changelog/plugins the same day.
| Step | Repository | Action | Result |
|------|------------|--------|--------|
| 0 | all | pre-checks | step 0 done: `gofmt -w` (toolchain go1.27.2) on 15 framework files (several unformatted since v0.6.0), commit 6d14f71 `style: gofmt`, `go build`, `go vet`, `go test -short ./...`, `summer docs:build --check` green; sm-journal-plugin `gofmt -w` controllers/api/posts.go and classes/blog_editor_test.go, commit b8d98ce `style: gofmt`; sitemap and seo `gofmt -l` clean. `PHASE33_REQUIRE_DOCKER=1 scripts/check-phase33.sh --all` exit 0, "Phase 33 gate passed" (self-test, go, plugins, layers, consumers, admin, docs, marker, docker, additive, compat; compat refused sm-feedback, sm-presentations and sm-quizzes at HEAD and at the base alike, as before). Every repository on its default branch, 0 behind origin, its newest tag an ancestor of HEAD. All five app patches pass `git apply --check` on the app HEADs |
| 1 | summercms.go | framework v0.6.1 | step 1 done: annotated tag v0.6.1 (tag object 5db8c61, commit 6d14f71, contains v0.6.0); `git push origin master` 76288cd..6d14f71 (fast-forward, 0 behind after fetch); `git push origin v0.6.1` new tag; `git ls-remote --tags origin v0.6.1` prints 5db8c61. TAG ../summercms.go v0.6.1 |
| 2 | sm-journal-plugin | v0.4.0 | step 2 done: `GOWORK=off GOPRIVATE=git.golem15.com/* go get git.golem15.com/golem15/summercms@v0.6.1` (v0.6.0 => v0.6.1; go.sum unchanged, the module keeps its `replace => ../summercms.go` as at v0.3.1; tracked go.work unchanged since v0.3.1); `summer plugin:manifest` (framework: v0.6.1), `--check` up to date; README dependency line already v0.6.1; `GOWORK=off go build ./... && go vet ./... && go test -short ./...` green; commit 6823dcc `chore: require SummerCMS v0.6.1` (go.mod, plugin.yaml) after b8d98ce `style: gofmt`; annotated tag v0.4.0 (tag object 48aa3ff, commit 6823dcc, contains v0.3.1); `git push origin main` f7bfb33..6823dcc; `git push origin v0.4.0`; ls-remote prints 48aa3ff. TAG ../sm-journal-plugin v0.4.0 |
| 3 | sm-sitemap-plugin | v0.2.0 | step 3 done: `go get git.golem15.com/golem15/summercms@v0.6.1` (GOWORK=off, v0.6.0 => v0.6.1, replace => ../summercms.go kept as at v0.1.2), `summer plugin:manifest` (framework: v0.6.1, `--check` ok), README dependency line (dropped "Until v0.6.1 is tagged…"); `GOWORK=off go build && go vet && go test -short ./...` green; commit fdbe593 `chore: require SummerCMS v0.6.1` (go.mod, plugin.yaml, README.md); annotated tag v0.2.0 (tag object 1188efd, commit fdbe593, contains v0.1.2); `git push origin main` 049f3ee..fdbe593; `git push origin v0.2.0`; ls-remote prints 1188efd. TAG ../sm-sitemap-plugin v0.2.0 |
| 4 | sm-seo-plugin | v0.2.0 | step 4 done: `GOWORK=off go get git.golem15.com/golem15/summercms@v0.6.1 git.golem15.com/golem15/sm-sitemap-plugin@v0.2.0` (sitemap fetched from the pushed tag) and `go mod tidy` (go.sum: v0.2.0 in, v0.1.2 out); `summer plugin:manifest` (framework v0.6.1, golem15.sitemap v0.2.0, `--check` ok); README dependency lines (v0.6.1, sm-sitemap-plugin v0.2.0) and the go.mod/go.work replace sentence corrected; `GOWORK=off go build ./... && go vet ./... && go test -short ./...` green (the GOWORK=off build failed before on v0.1.2's missing contract package); commit 22f108b `chore: require SummerCMS v0.6.1 and sm-sitemap-plugin v0.2.0` (go.mod, go.sum, plugin.yaml, README.md); annotated tag v0.2.0 (tag object adda8a9, commit 22f108b, contains v0.1.1); `git push origin main` 55f5e5d..22f108b; `git push origin v0.2.0`; ls-remote prints adda8a9. TAG ../sm-seo-plugin v0.2.0 |
| 5 | vue-seo-layer | v0.2.0 | step 5 done: package.json version 0.2.0 (bumped earlier), tree clean, layers stage of the gate green; annotated tag v0.2.0 (tag object b69647f, commit 3868f3d, contains v0.1.1); `git push origin main` 7d52c5d..3868f3d; `git push origin v0.2.0`; ls-remote prints b69647f. TAG ../vue-seo-layer v0.2.0 |
| 6 | vue-journal-layer, vue-menu-layer, vue-faq-layer | link: swap and tags | step 6 done (tags), check failed: `"vue-seo-layer": "link:../vue-seo-layer"` replaced by `git+https://git.golem15.com/golem15/vue-seo-layer.git#v0.2.0` in each package.json, `pnpm install` (lockfile pins 3868f3d); `check-git-install.sh --check-links vue-seo-layer vue-journal-layer vue-menu-layer vue-faq-layer` exit 0 ("no local dependencies"); `npm run test` journal 202/0, menu 24/0, faq 19/0 and `build:playground` green in each. Commits `chore: pin vue-seo-layer v0.2.0` (package.json, pnpm-lock.yaml): vue-journal-layer 0a77e6e, vue-menu-layer c605384, vue-faq-layer 9cc7f91. Annotated tags: vue-journal-layer v0.8.0 (tag object 7d93f06, commit 0a77e6e, contains v0.7.1), vue-menu-layer v0.5.0 (7e90962, commit c605384, contains v0.4.0), vue-faq-layer v0.5.0 (cae1502, commit 9cc7f91, contains v0.4.0); main pushed (1dd3176..0a77e6e, ef3a090..c605384, 1f23340..9cc7f91) and the tags pushed, ls-remote prints each. TAG ../vue-journal-layer v0.8.0 TAG ../vue-menu-layer v0.5.0 TAG ../vue-faq-layer v0.5.0 |
| 6b | vue-journal-layer | `check-git-install.sh v0.8.0` | **FAILED, release stopped here.** `pnpm install` in the scratch app ends `ERR_PNPM_EXOTIC_SUBDEP: Exotic dependency "vue-seo-layer" (resolved via git-repository) is not allowed in subdependencies when blockExoticSubdeps is enabled` (pnpm 12.14.0 default). Reproduced in a scratch app that also lists vue-seo-layer#v0.2.0 directly: same error. So every site app that installs vue-journal-layer v0.8.0, vue-menu-layer v0.5.0 or vue-faq-layer v0.5.0 with pnpm 12 defaults fails to install. 33-12/33-13 never saw it (pnpm `overrides` with `link:`). `blockExoticSubdeps: false` in the app's pnpm-workspace.yaml makes the install pass (lockfile-only probe). The established convention for a layer on a layer (vue-chatvideo-layer → vue-chat-layer) is `peerDependencies: "^0.1.0"` plus a `devDependencies` git pin, with the app listing both layers directly, which avoids an exotic subdependency. Decision needed (see 33-14 report): (A) keep the tags and set `blockExoticSubdeps: false` in each app (turns off a pnpm supply-chain guard for all transitive deps), or (B) patch releases vue-journal-layer v0.8.1, vue-menu-layer v0.5.1, vue-faq-layer v0.5.1 with vue-seo-layer as a `^0.2.0` peer and git devDependency (apps add vue-seo-layer#v0.2.0 directly; check-git-install's scratch app lists it too). Pushed tags are not moved or deleted. Not started: app pins and patches, changelog posts, deploys |
| 6c | — | checkpoint answer (Jakub, 2026-11-05) | Layer dependency: option B (patch releases vue-journal-layer v0.8.1, vue-menu-layer v0.5.1, vue-faq-layer v0.5.1 with vue-seo-layer as a `^0.2.0` peer and a git devDependency; apps list vue-seo-layer#v0.2.0 directly). Changelog posts: publish now, before the layer fix |
| 8 | sm-summercmsio-app | changelog posts | step 8 done: `scripts/changelog-post.py publish` with the 33-14 notes (the `Excerpt:` line stripped and passed as `--excerpt`): framework v0.6.1 id=66 https://summercms.io/changelog/summercms-0-6-1 (published_at 2026-11-05T00:17:50Z, the tag's date); sm-sitemap-plugin v0.2.0 id=67 https://summercms.io/changelog/sm-sitemap-plugin-0-2-0; sm-seo-plugin v0.2.0 id=68 https://summercms.io/changelog/sm-seo-plugin-0-2-0; sm-journal-plugin v0.4.0 id=69 https://summercms.io/changelog/sm-journal-plugin-0-4-0 (posted from a detached worktree at v0.4.0, because the live checkout has another session's untracked go.work.sum and the script requires a clean tree; worktree removed). Each `plugin.yaml matches the sources`. All four URLs answer 200; the three plugin posts are listed on /changelog/plugins. No registry token file, so the ingest request was skipped (hourly ingest picks the tags up). The journal token was read by the script only, never printed |
| 6d | vue-journal-layer, vue-menu-layer, vue-faq-layer | patch releases (option B) | step 6d done: vue-seo-layer moved from `dependencies` to `peerDependencies: "^0.2.0"` plus `devDependencies: "git+https://git.golem15.com/golem15/vue-seo-layer.git#v0.2.0"` (the vue-chatvideo-layer convention, checked in that repo), versions 0.8.1 / 0.5.1 / 0.5.1, `pnpm install` (lockfile pins 3868f3d as a dev dependency); READMEs: the install line lists vue-seo-layer#v0.2.0 and the layer tag, a peer-dependency paragraph, an upgrade bullet ("use 0.8.1 / 0.5.1"), the Releasing steps; vue-journal-layer `scripts/check-git-install.sh`: the scratch app lists vue-seo-layer at the tag's devDependencies pin (`SEO_LAYER_SPEC` overrides; a tag without the peer gets none, so v0.8.0 still fails as it should), `--self-test` ok. `--check-links` over the four layers exit 0; `check-phase33.sh --layers` exit 0 (seo 156, journal 202, menu 24, faq 19 TAP pass, 0 fail; four playground builds; smokes; check-git-install self-test). Commits `fix: vue-seo-layer as a peer dependency`: vue-journal-layer f5dc9b2, vue-menu-layer dc38f79, vue-faq-layer a092363. Annotated tags vue-journal-layer v0.8.1 (tag object 5c26b5d, contains v0.8.0), vue-menu-layer v0.5.1 (47b022e, contains v0.5.0), vue-faq-layer v0.5.1 (8d46b31, contains v0.5.0); main pushed (0a77e6e..f5dc9b2, c605384..dc38f79, 9cc7f91..a092363) and the tags pushed; ls-remote prints each. v0.8.0 and v0.5.0 stay as published. `check-git-install.sh v0.8.1` (run from the scratchpad) ends `smoke-ssr: ok (89 checks)`, `check-git-install: ok v0.8.1 f5dc9b2`. Scratch app with pnpm 12.14.0 defaults listing nuxt, vue, vue-seo-layer#v0.2.0, vue-journal-layer#v0.8.1, vue-menu-layer#v0.5.1 and vue-faq-layer#v0.5.1, extending the three: `pnpm install` ok (one vue-seo-layer in the store), `nuxt build` ok. Release-note drafts 33-14-notes-vue-{journal,menu,faq}-layer-*.md name v0.8.1 / v0.5.1 as the versions to install. TAG ../vue-journal-layer v0.8.1 TAG ../vue-menu-layer v0.5.1 TAG ../vue-faq-layer v0.5.1 |
| 7a | sm-summercmsio-app | pins, patch, nodynamic | step 7a done: c96108d `chore: require SummerCMS v0.6.1` (go.mod, go.sum; tidy pruned indirects the framework no longer needs); a40076c `chore: bump journal to v0.4.0` (plugins/golem15/journal 5b38a3b → 6823dcc at v0.4.0, require v0.4.0, gen2brain/webp and purego indirect); vue-summercmsio-app 16ab519 `chore: vue-journal-layer v0.8.1 with vue-seo-layer v0.2.0` (33-12-vue-summercmsio-app.patch applied: changelog `seo: { cms: false, htmlSitemap: false }` and 16 expectation lines; package.json pins; lockfile), pushed f36cbf1..16ab519, then pointer 2e2b881; 90eb7bc `chore: regenerate plugins.gen.go (SummerCMS v0.6.1, journal v0.4.0)`; 395ed78 `build: static binary with -tags nodynamic` (scripts/build.sh both go builds, summer.yaml `build.tags: [nodynamic]`, DEPLOY.md Binary row "static" with the reason). The 33-12-sm-summercmsio-app.patch is the empty note (nothing to apply). Checks: `pnpm test` 83/0 (17 tests read the build output and fail before a build; green after it), build:changelog, `test:changelog` smoke-ssr ok (89 checks), `generate`, `test:marketplace` ok (30 checks); `go vet`, `go test ./...` green. Release build in a clean worktree of 395ed78 with submodules from origin (the live checkout has an untracked self-named binary that `require_clean` refuses; not touched): `scripts/build.sh` → "release v0.6.1", `bin/summercms-io` statically linked, `-tags=nodynamic`; `scripts/smoke.sh` ok; `check-deploy.sh` ok. `git push origin master` ca06049..395ed78 |
| 7b | sm-demo-app | pins, patches, nodynamic | step 7b done: 6cc710e `chore: require SummerCMS v0.6.1`; 51b1d76 `chore: bump journal to v0.4.0`, ca29cd6 `chore: bump sitemap to v0.2.0`, 8113bc3 `chore: bump seo to v0.2.0` (each: submodule at the tag, `GOWORK=off go get` + `go mod tidy`); vue-demo-app fe5d38d `feat: SEO from the CMS and Polish under /pl/` (33-12-vue-demo-app.patch then 33-14-vue-demo-app-locale.patch, pins vue-journal-layer v0.8.1, vue-menu-layer v0.5.1, vue-faq-layer v0.5.1, vue-seo-layer v0.2.0; `pnpm build` ok (build guard passes), `pnpm test` 225/225), pushed 9a12701..fe5d38d; 6686216 pointer bump; 49ae08d (33-12-sm-demo-app.patch then 33-14-sm-demo-app-locale.patch, 18 paths; DEPLOY "After" no longer removes `/subscriptions` by hand: G1 leaves it out as noindex, per Jakub); cc1d71c plugins.gen.go; d2bd3ec `build: static binary with -tags nodynamic` (GO_TAGS and summer.yaml `summercms_mcp,nodynamic`, dev.sh, DEPLOY Binary row "static" with the reason); da03c06 roadmap snapshot (framework 143b4db). Checks: `go vet`, `go test -short`, `go test -tags summercms_mcp -count=1 ./...` (Docker) green; `check-pins` ok (25 modules); `scripts/build.sh` release in the clean live checkout "build: ok release 31f99588d1c9", `bin/demo` statically linked, journal v0.4.0, seo v0.2.0, sitemap v0.2.0, summercms v0.6.1, `-tags=summercms_mcp,nodynamic`; `scripts/smoke.sh bin/demo` ok; `check-deploy.sh` ok. (A scratch worktree build failed on worktree submodule cloning, not on the code; removed.) `git push origin master` da481c9..da03c06. Rehearsal databases: the demo's smoke and tests use fresh databases (migrate + demo:seed), no dump involved |
| 7c | sm-grzybyfunkcjonalne-app | pins, patches, nodynamic | step 7c done: 65d64f6 `chore: require SummerCMS v0.6.1` (go.mod and plugins/grzyby/go.mod); 7f57068 journal v0.4.0, aa1b925 sitemap v0.2.0, c60731b seo v0.2.0 (submodule at the tag, require bumped, tidy; the seo submodule was already mounted at v0.1.1); vue-grzybyfunkcjonalne-app 0771c14 (33-13-vue-grzybyfunkcjonalne-app.patch, pins vue-journal-layer v0.8.1, vue-menu-layer v0.5.1, vue-faq-layer v0.5.1, vue-seo-layer v0.2.0; `pnpm test` 30/0 after the build (3 tests read the build output), `pnpm build`, `smoke:ssr` ok (67 checks), `smoke:ga` ok), pushed e45ffe8..0771c14; 2628e38 pointer; 9eb921b (33-13-sm-grzybyfunkcjonalne-app.patch, 25 paths; its plugins/grzyby/go.mod hunk conflicted only with the v0.6.1 bump and was applied by hand: sitemap require + replace; the DEPLOY Binary row says static with -tags nodynamic instead of "dynamically linked"); bcb4be1 `build: static binary with -tags nodynamic` (build.sh vet and build `-tags summercms_mcp,nodynamic`, summer.yaml); 745b549 plugins.gen.go; 75736b4 `fix(deploy): check-deploy requires the nodynamic build tag` (check-deploy.sh looked for the old exact tag list). Checks: `go vet`, `go test ./... ./plugins/grzyby/...`, `go test -tags summercms_mcp ./...` (Docker) green; `scripts/build.sh` (framework working tree = v0.6.1 code, no non-planning diff) → `bin/grzybyfunkcjonalne` statically linked, summercms v0.6.1, `-tags=summercms_mcp,nodynamic`; `check-deploy.sh` ok; full `scripts/smoke.sh` rehearsal on a fresh throwaway database (migrations including the edited 202610280002, the PHP export, the release steps 4-11) `smoke: ok (128 urls)` (every urls.txt line on the tagged build), then `smoke-seo: ok` (index of 5 files, posts 63, categories 8, authors 1, pages 4). `git push origin master` 35d3664..75736b4 |
| 7d | sm-bm-app | push | step 7d done: the frontend build (`npm ci && npm run build` in frontend/, into plugins/jz/bm/public) adds the robots meta to index.html and robots.txt (assets unchanged); jz.bm a27a162 `build: robots meta and robots.txt (noindex, Disallow)`, pushed a075963..a27a162 (with the earlier unpushed 0dd0185 of another session, which sm-bm-app 9dd0fe4 points at); sm-bm-app 43f2fc9 `chore: bump jz.bm (robots meta and robots.txt in the frontend build)`; `scripts/check-noindex.sh` ok; `go vet`, `go test -short` green; `scripts/build.sh` → "framework v0.6.0 @ c8241c8, app 43f2fc9", statically linked (bm mounts no Journal; it stays on v0.6.0). `git push origin master` pushed 552cdce, c2a7823, 9dd0fe4 (other sessions, approved), bd7ee84 and 43f2fc9 |
| 9a | summercms.io (rome) | deploy | step 9a done: release build of sm-summercmsio-app 395ed78 (clean worktree, static, release v0.6.1, sha256 dfd70d65…); on rome `pg_dump -Fc summercms_io` → /var/backups/summercms/summercms_io-pre-v0.6.1-2026-11-05.dump (root, 0700 dir); upload per DEPLOY.md (config/, bin/summercms-io.new (sha256 matches), changelog/.output.new); `./bin/summercms-io.new migrate` "golem15.journal: 2 applied"; previous binary kept as bin/summercms-io.prev and changelog/.output.prev; swap; `supervisorctl restart summercms-io summercms-io-changelog` both RUNNING; loopback 200 on :8095/, :3095/changelog, :8095/api/registry/v1/plugins, :3095/marketplace. `journal:image-variants`: "Every featured image has its variants." Public checks: 200 for /, /docs/, /changelog, /changelog/plugins, /changelog/rss.xml, the v0.6.1 and journal v0.4.0 posts, /_journal/api/v1/posts, /api/registry/v1/plugins, /marketplace; /backend 404; no purge key in the page; one canonical, one ld+json on a post; /changelog/sitemap 404 (htmlSitemap off) |
| 9b | demo.summercms.io (rome) | deploy | step 9b done: `pg_dump -Fc summercms_demo` → /var/backups/summercms/summercms_demo-pre-v0.6.1-2026-11-05.dump; .env and site.env copied to /root/*.bak-pre-seo; DEPLOY "Before the first release with Phase 33" block as root (0 SEO keys before; 2 tokens added to .env, 4 NUXT_SEO_ lines to site.env, values never printed; NUXT_PUBLIC_SITE_URL/SITEMAP_URL dropped). Upload (config/, bin/demo.new sha256 31f99588… = local release build of da03c06, site/.output.new, /tmp/seo-pages.csv, /tmp/demo.summercms.io.conf). `bin/demo.new migrate`: journal 2, sitemap 5, seo 8 applied; `demo:seed` (decks updated, the rest exists); bin/demo.prev and site/.output.prev kept; swap; restart summercms-demo and summercms-demo-site (RUNNING); `websockets:health` "Configuration OK". Vhost: backup /root/demo.summercms.io.nginx.bak-2026-11-05, install, `nginx -t` ok, reload. Site log: `[vue-seo-layer] route report: 7 routes and 20 noindex paths sent` (and the known G2 journal-tag drift line). "After" block as root, with the amended list: extra_urls before `/`, `/blog`, `/faq`, `/subscriptions`; PUT keeps only `/subscriptions` (200); `sitemap:rebuild` 22 addresses in 4 files; seo-pages.csv import applied (changed 2, refused 0). **G1 live: `/subscriptions` is left out without manual removal** (Settings › Sitemaps left_out `{"noindex":1}`; sitemap-pages-1.xml lists /, /blog, /chat, /contact, /faq, /forum, /quizzes only), so no manual removal. `journal:image-variants`: "Every featured image has its variants." `scripts/check-live.sh https://demo.summercms.io`: check-live: ok. `SMOKE_SEO_STRICT=1 scripts/smoke-seo.sh https://demo.summercms.io` over an SSH tunnel (admin password in a 0600 scratch file, deleted after; tunnel closed): smoke-seo: ok (5 hreflang links on a post, /pl/ alternate 200, 22 sitemap addresses, 0 not 200) |
| 9c | bm.jakubzych.com (rome) | deploy | step 9c done: per the DEPLOY.md "Live install" note (/home/debian/bm, user debian): `pg_dump -Fc bm` → /var/backups/summercms/bm-pre-noindex-2026-11-05.dump; release build of 43f2fc9 (sha256 4712a4cb…, static, framework v0.6.0) uploaded as bin/bm.new with config/ and data/*.json; `migrate` "Nothing to migrate"; bin/bm.prev kept; swap; `supervisorctl restart bm` RUNNING; loopback 200. Live vhost: dated backup /root/bm.jakubzych.com.nginx.bak-2026-11-05, `add_header X-Robots-Tag "noindex, nofollow" always;` added to both server blocks and to the two locations with their own add_header (`= /prezentacje/AW_tematy-prezentacji.html` and also `^~ /pliki/`, which the repository file does not have), `nginx -t` ok, reload. Verify: X-Robots-Tag noindex, nofollow on https /, on the http 301, on the topics page and on /pliki/; /robots.txt `Disallow: /`; the SPA carries the robots meta; /_bm/api/v1/dashboard 401; /bmadmin 200 |
| 9d | grzybyfunkcjonalne.pl (alexandria, production) | deploy, DEPLOY.md "SEO pages and sitemaps (Phase 33)" steps 1-12 | steps 1-11 done, **step 12 smoke: 1 of 148 lines failed (see 9e); stopped, no rollback**. Pre-check: the full local `scripts/smoke.sh` on the tagged build passed (step 7c, `smoke: ok (128 urls)`). 1: root `pg_dump -Fc grzybyfunkcjonalne` → /root/grzybyfunkcjonalne-pre-seo-2026-11-05.dump (58 TABLE DATA entries), copies go/bin/grzybyfunkcjonalne.pre-seo-2026-11-05, nuxt/.output.prev-2026-11-05, go/.env.pre-seo-2026-11-05, nuxt/.env.pre-seo-2026-11-05, vhost /root/grzybyfunkcjonalne.pl.summercms.bak-2026-11-05. 2: upload (binary as .new, sha256 4ecc24a8… matches the local build; config/, scripts/, Nuxt build to nuxt/.output.new instead of over the running .output; deploy/ incl. seo-pages.csv and both vhosts). 3: go/.env `SUMMER_GOLEM15__SITEMAP__SITE_URL` = the SEO site URL; nuxt/.env `NUXT_SEO_API_BASE`, `NUXT_SEO_BACKEND_BASE`; both 404 tokens present (checked by name only, values never printed). 4: `migrate` with the new binary: journal 2, sitemap 5, seo 8; then bin swap (.prev kept). 5: SELECT showed `en` already disabled (en f f, pl t t); the UPDATE ran (2 rows, no change). 6: `grzyby:seo-settings --dry-run` listed exactly settings.page_title_suffix and the two journal-author templates; applied (3 changed). 7: Nuxt build swapped in, both programs restarted (RUNNING), Nuxt log `[vue-seo-layer] route report: 4 routes and 1 noindex paths sent` (plus the known G2 journal-tag drift line); new production vhost installed (sha256 157a5e0b… = deploy file; the live file had matched the previous repository file), `nginx -t` ok, reload. 8-10 through the management API with a temporary `*` token (created for info@golem15.com, never printed, revoked, id 2): /blog → Artykuły, /trwaja-prace → Trwają prace; seo-pages.csv preview changed 4 refused 0, applied; Additional addresses ["/","/blog"] → []. 11: `sitemap:rebuild` 74 addresses; `journal:image-variants` checked 95 featured images, wrote 285 variants, skipped 0 |
| 9e | grzybyfunkcjonalne.pl | step 12 smoke | On the server: `scripts/smoke.sh --url https://grzybyfunkcjonalne.pl --also scripts/urls-production.txt` → `smoke: 1 of 148 urls failed`: `200 /autor/admin contains=<title>Wpisy autora admin | Grzyby Funkcjonalne</title>`; the page answers 200 with `<title>Wpisy autora Dawid P. | Grzyby Funkcjonalne</title>`. Cause: the urls.txt line added by the 33-13 patch assumes the rehearsal's author name ("admin", from `--author-login admin`); in production the author's name is "Dawid P." (also on every post page), and the title follows the applied template `Wpisy autora %title% | %site%`, the PHP site's format. A test expectation bound to fixture data, not a site fault. `scripts/smoke-seo.sh https://grzybyfunkcjonalne.pl` ok; `check-deploy.sh --live` ok. **Not rolled back** (the site is healthy; rollback materials in 9d stay ready); the 404-log cleanup of the smoke entries and the remaining steps wait for Jakub's decision. Read-only checklist (33-13-deploy-log.md): item 1 ok (index of posts, categories, authors, pages, images; posts 62, each once, all with lastmod, no /en/, no hreflang; /sitemap.xsl 404); item 2 ok (CMS title, one canonical on the apex, `index, follow`, one JSON-LD script, no hreflang="en", breadcrumbs Strona główna › Chaga); item 4 partly (HTML sitemap /mapa-strony 200, footer link present, Additional addresses empty, IndexNow not enabled); item 8: libwebp.so.7 is installed, but the binary is built with nodynamic, so the built-in encoder runs. http and www answer 301 to the apex; /backend 404; /gfadmin 200. Note: posts now send `X-Robots-Tag: index, follow` (vue-seo-layer), where the cutover check expected no header |
| 10 | summercms.go | plan verify | The plan's tag check: every TAG line's tag is on its origin ("tags-ok", 7 TAG lines covering 11 tags); no content layer package.json has `link:`. `check-phase33.sh --compat --consumers`: compat PASS (sm-feedback, sm-presentations and sm-quizzes refused at HEAD and base as before); consumers FAIL with `refuse: 33-12-sm-demo-app.patch does not apply to …/sm-demo-app`. That is expected after the release: the stage checks the release patches against unpatched app HEADs, and sm-demo-app now carries the patch (its new files are present). The apps' own builds, tests and smokes ran directly in steps 7a-7c. The consumers stage needs a post-release mode (or retirement) in a follow-up |
| 9f | sm-grzybyfunkcjonalne-app, grzybyfunkcjonalne.pl | Jakub's answer A: keep the deploy, fix the check | step 9f done: scripts/urls.txt `/autor/admin` now checks only the fixed parts of the title (`contains=<title>Wpisy autora` and `contains=| Grzyby Funkcjonalne</title>`, with a comment on why); local full rehearsal `smoke: ok (129 urls)`, `smoke-seo: ok`; commit 0554f3e `fix(smoke): check the author page title without the fixture's author name`, pushed 75736b4..0554f3e; scripts/ rsynced to the server. Production smoke rerun exactly as before (`scripts/smoke.sh --url https://grzybyfunkcjonalne.pl --also scripts/urls-production.txt`): `smoke: ok (149 urls)`. 404 log: the 14 entries first seen since the deploy began (01:12Z onwards, all the smoke's and the checks' negative cases: /sitemap.xsl, /sitemap-tags-1.xml, /blog?page=7, /blog?page=abc, /chaga?page=2, /szukaj?q=cordyceps&page=999, /404, /error, /nieistnieje, /rss, /feed, /blog/badania-nad-cordycepsem, /en/blog, /autor/nobody) deleted through `DELETE /_seo/api/v1/notfound/{id}` (each 200) with a temporary `*` token (never printed, revoked, ids 3 and 4); 0 entries from today remain; older entries (bot probes before the release) left alone. Step 12 of the grzyby deploy is done except the Search Console resubmit (Jakub) |

View File

@@ -0,0 +1,130 @@
---
phase: "34"
slug: "plugin-marketplace"
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 34 — Security
> Per-phase security contract: threat register, accepted risks, and audit trail. Evidence roots: FW = `summercms.go` (master 7b8d205), SITE = `../sm-summercmsio-app/plugins/golem15/summercms` (v0.1.0, 0b3fbf4), APP = `../sm-summercmsio-app`, VUE = `../sm-summercmsio-app/vue-summercmsio-app`. T-34-SC is declared in all six plans and counted once (37 unique threats).
---
## Trust Boundaries
| Boundary | Description | Data Crossing |
|----------|-------------|---------------|
| plugin source → kiosk reader | plugin.go, go.mod and README.md of any directory the CLI is pointed at | Go source, module metadata (high) |
| CLI args → go/git subprocesses | dir and module paths reach `go mod edit`, `go list`, `git describe` | paths, module paths (high) |
| plugin.yaml (module cache / submodule) → summer build | third-party text baked into generated Go source | manifest strings (high) |
| plugin repository → registry and installers | plugin.yaml in a tag is read by summercms.io and `summer plugin:install` | manifest (high) |
| curated git repository → ingester | repository content controlled by plugin maintainers | git objects, README, manifests (high) |
| ingested README → marketplace HTML | third-party markdown rendered on summercms.io | HTML (high) |
| public internet → GET /api/registry/v1 | anonymous, cross-origin readers | listing JSON (low) |
| release script (token) → POST /api/registry/v1/ingest | authenticated, scoped | API token, repo id (high) |
| summercms.io admin → presentation fields and repository URLs | trusted administrators behind the SSH tunnel | URLs, text (medium) |
| registry JSON → CLI → go/git | module paths, versions and repository URLs from a registry | install plan (high) |
| user-supplied git URL → git | private or third-party repositories | URL (high) |
| third-party plugin code → app binary | compiled in after confirmation | Go code (high) |
| CLI → app directory | summer.yaml, go.mod, go.work, .gitmodules, plugins/ | app files (medium) |
| browser (admin SPA) → cabana /plugins endpoints | authenticated administrators | installed list, registry data (medium) |
| cabana → registry | outbound call to an operator-configured URL | registry JSON (medium) |
| registry JSON → admin UI | third-party text shown to administrators | names, descriptions, icons (medium) |
| developer workstation → shared remotes | pushes and tags (user-gated) | commits, tags (medium) |
| tests → developer environment / gate script → sibling repositories | module cache, sibling checkouts, Docker; read-only checks in other repos | test state (low) |
---
## Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|-----------|----------|-----------|----------|-------------|------------|--------|
| T-34-01 | Elevation of privilege | kiosk.ReadIdentity | high | mitigate | FW/modules/kiosk/identity.go:229 AST-only `parser.ParseFile`; `str()` :157-189 accepts constants only; only `go list -m`/`mod download`/`mod edit` run. Test manifest_smoke_test.go:172 | closed |
| T-34-02 | Tampering | exec of go/git with paths/modules | high | mitigate | no shell (derive.go:193-212, plugindirs.go:64,292,335); `--` before positionals (identity.go:443,487; plugindirs.go:63); leading "-" refused (build/manifest.go:497-503, kiosk/manifest.go:145-150) | closed |
| T-34-03 | Tampering | plugin.yaml text in plugins.gen.go | high | mitigate | `strconv.Quote` on every string (build.go:175-251), `format.Source` :254-259; malformed manifest ignored with warning (plugindirs.go:216-236). Test plugininfo_smoke_test.go:142 | closed |
| T-34-04 | Information disclosure | plugins.gen.go | low | mitigate | party.Info has no Dir (party/info.go:26-34) | closed |
| T-34-05 | Denial of service | summer build | medium | mitigate | resolveBuildMeta never fails, ID-only fallback with warning (plugindirs.go:94-109,183-186; build.go:39-42) | closed |
| T-34-06 | Repudiation | stale plugin.yaml | medium | mitigate | kiosk/check.go:77-91 `bytes.Equal` → StaleError, CheckT :107; `--check` at cmd/summer/main.go:474-478 | closed |
| T-34-SC | Tampering | npm/pip/cargo installs (all plans) | high | accept | AR-34-01 | closed |
| T-34-07 | Tampering | hand-edited/stale plugin.yaml | medium | mitigate | plugin.yaml + manifest_test.go calling `kiosk.CheckT` in all 25 plugin repos and sm-pgstripe-plugin/subscriptions | closed |
| T-34-08 | Spoofing | manifest claiming another module/ID | medium | mitigate | module from go.mod, package from dir, ID from plugin.go (derive.go:75-103); ingester re-checks (SITE/registry/ingest.go:283,315-327) | closed |
| T-34-09 | Tampering | app pointers to unpushed commits | high | mitigate | bump commits are gitlinks only (sm-demo-app 5ec5941, APP 668a773); every target SHA on origin default branch with its release tag | closed |
| T-34-10 | Denial of service | consumers resolving unreleased framework minimum | medium | accept | AR-34-02 | closed |
| T-34-11 | Elevation of privilege | ingester git with repo URLs | high | mitigate | SITE/registry/git.go: no shell :87, `GIT_ALLOW_PROTOCOL=https` :21,:75, no system/global config :76-77, no prompt, throwaway HOME; `--` before URLs (ingest.go:191,229,233); https-without-credentials enforced at every write path (models/repository.go:88-136) | closed |
| T-34-12 | Denial of service | malicious repo content | high | mitigate | bare `--filter=blob:none` clone (ingest.go:229); 64 KiB / 512 KiB read caps via LimitReader; per-command timeouts (git.go:85); `RemoveAll(tmp)`; ≤20 manifests per tag | closed |
| T-34-13 | Tampering (XSS) | README on /marketplace | high | mitigate | `breakwater.Render` + Purify at ingest (ingest.go:478; breakwater.go:90-99); page re-sanitizes before v-html and drops markdown (VUE/…/[name].vue:8,24-35,152); smoke asserts `notcontains=alert(` | closed |
| T-34-14 | Spoofing | repo claiming another plugin's ID/module | high | mitigate | curated repos only; identity check ingest.go:315-327; first-listed owner under advisory lock (:428,:449) + UNIQUE plugin_id index. Test registry_smoke_test.go:562 | closed |
| T-34-15 | Elevation of privilege | POST /api/registry/v1/ingest | high | mitigate | `cabana.ResolveBackend` + `Allows(PermissionIngest)` (SITE/controllers/api/registry.go:132-144); uncurated → 404; scope narrowed (token_scopes.go:22); nginx POST-only, 8k body (APP/deploy/nginx/summercms.io.conf:162-177); 4 KiB body cap | closed |
| T-34-16 | Denial of service | ingest-now flooding | medium | mitigate | `throttle:registry-ingest` 10/min (routes_registry.go:37,60-67); unique job by args (ingest.go:569); ls-remote digest before clone | closed |
| T-34-17 | Information disclosure | open CORS on registry API | low | mitigate | `*` origins, GET/HEAD, no credentials (APP/config/http.yaml:16-21); surf refuses credentials with `*` (cors.go:50-51); wire type has no last_error/enabled | closed |
| T-34-18 | Denial of service | public API abuse | medium | mitigate | `throttle:registry-api` 300/min; Cache-Control/ETag/304 (registry.go:208-231); q ≤100, category regex, escaped LIKE (registry/query.go:24-57) | closed |
| T-34-19 | Information disclosure | release script token | medium | mitigate | `read_secret_file` refuses group/world-readable file, never prints (APP/scripts/changelog-post.py:320-328); no journal-token fallback (:338-343); failures report status only | closed |
| T-34-20 | Tampering | stale manifest in published tag | medium | mitigate | `check_plugin_release` (changelog-post.py:354-380): HEAD exactly the tag, clean tree, `plugin:manifest --check`; called before posting (:428) | closed |
| T-34-21 | Denial of service | jobs never run in prod | medium | mitigate | `work_in_serve: true` (APP/config/queue.yaml:5); hourly `--queue` schedule; DEPLOY.md:89,93 checks; smoke.sh:243-249 | closed |
| T-34-22 | Elevation of privilege | registry strings → go get / git clone | high | mitigate | validateStep: `kiosk.ValidVersion`, validateModulePath, package within module (install.go:576-604), called in plan/apply/update/source; https-only no-userinfo repo URLs (source.go:135-152); `go get --`, git `--`; no shell | closed |
| T-34-23 | Elevation of privilege | ext::/file:: via git URL | high | mitigate | `cliGitProtocols = "https:ssh"` (source.go:26) forced in runCLIGit (:46-53); validateGitURL refuses leading "-", `::`, control chars, other schemes, credentials (:83-131) | closed |
| T-34-24 | Tampering | malicious plugin code compiled in | high | accept | AR-34-03 | closed |
| T-34-25 | Tampering | writes outside app root | medium | mitigate | ValidatePluginID / repoNameRe + `underRoot` before move and `submodule add` (source.go:386-474; scaffold.go:581-590) | closed |
| T-34-26 | Denial of service | slow/hostile registry | medium | mitigate | fetchguard TrustedMode, 4 MiB, 10 s (kiosk/client.go:28-29,84-88); no redirects (fetchguard/fetch.go:112); `--offline` | closed |
| T-34-27 | Spoofing | registry pointing at a different module | medium | mitigate | ingester ties module to repo; CLI re-reads plugin.yaml and go.mod from checkout and refuses mismatch (source.go:445-457) | closed |
| T-34-28 | Tampering | summer.yaml damaged by edit | low | mitigate | appendManifestPlugins re-parses and restores original on mismatch (build/manifest.go:206-252) | closed |
| T-34-29 | Elevation of privilege | GET /plugins, /plugins/marketplace | medium | mitigate | backend group (cabana/http.go:345-346); `pluginsPrincipal` 401/403 on `PermissionViewPlugins` (plugins.go:400-438); in security_coverage_test.go:56-57,312-313 | closed |
| T-34-30 | Information disclosure | installed plugin list | low | mitigate | gated by pluginsPrincipal; party.Info has no path | closed |
| T-34-31 | Denial of service | registry slow/down (admin) | medium | mitigate | cabana/plugins.go:132-140 4 s timeout, TTL cache (5m/15m, 1m failures), 4 concurrent, 256 cap; failure answers 200 `unavailable` | closed |
| T-34-32 | Tampering (XSS) | registry text in SPA | medium | mitigate | PluginsView.vue has no v-html, text interpolation only; icons https-only with `referrerpolicy="no-referrer"`; ID regex (plugins.go:147,472,536) | closed |
| T-34-33 | Server-side request forgery | plugins.registry_url | low | accept | AR-34-04 | closed |
| T-34-34 | Tampering | tests writing into siblings/module cache | medium | mitigate | `t.TempDir()` throughout; temp GOMODCACHE (fixtures_test.go:118-122; derive_test.go:283); no `../sm-*` paths in tests | closed |
| T-34-35 | Repudiation | gate passing on silently skipped tests | medium | mitigate | scripts/check-phase34.sh go test -json detector with required names (:142-300); Docker-not-run fails `--all` (:49-51); self-test plants each failure (:416-504) | closed |
| T-34-36 | Information disclosure | release ledger | low | mitigate | 34-release-log.md contains no token values, secrets or Bearer strings | 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-34-01 | T-34-SC | No package is installed or added: Go modules already required, no new npm dependency, reka-ui already a dependency. Confirmed: FW go.mod/go.sum/admin package files unchanged v0.5.3→v0.6.0; SITE's new requires are all in FW v0.6.0. | 34-01..06 PLAN | 2026-11-05 |
| AR-34-02 | T-34-10 | Plugins are not tagged until v0.6.0 exists (release order in 34-06's ledger); existing tags are never moved. Confirmed by tag timestamps (framework v0.6.0 before every plugin tag). | 34-02 PLAN | 2026-11-05 |
| AR-34-03 | T-34-24 | Inherent to compiled plugins; every addition is printed and needs confirmation or `--yes` (D-09); sumdb verification stays on (GONOSUMDB/GOSUMDB/GOPRIVATE/GOINSECURE never set in non-test code). | 34-04 PLAN | 2026-11-05 |
| AR-34-04 | T-34-33 | `plugins.registry_url` is an operator-configured trusted setting (fetchguard TrustedMode by design), no redirects, size-capped; administrators cannot change it from the admin. | 34-05 PLAN | 2026-11-05 |
*Accepted risks do not resurface in future audit runs.*
---
## Informational (review Info items, not in the register, non-blocking)
34-REVIEW-FIX covered CR and WR findings only. These remain in the code:
- IN-01 (near T-34-15): the ingest endpoint does not require `Content-Type: application/json` (SITE/controllers/api/registry.go:145-149).
- IN-02 (near T-34-22/26/33): `kiosk.NewClient` accepts `http://` registries without a warning (FW/modules/kiosk/client.go:74-76).
- IN-04 (near T-34-14): plugin ID ownership goes to the first repo and is never released.
- IN-06 (near T-34-13): the marketplace detail page binds `:href` from `plugin.repository` without a scheme check (VUE/changelog/app/pages/marketplace/[vendor]/[name].vue:135); every write path canonicalizes to https, so exploiting it needs a direct database write.
- IN-05, IN-07: the Nitro SWR cache is keyed by query string; the admin loads marketplace icons from third-party hosts.
---
## Security Audit Trail
| Audit Date | Threats Total | Closed | Open | Run By |
|------------|---------------|--------|------|--------|
| 2026-11-05 | 37 | 37 | 0 | msd-security-auditor (ASVS L1, block_on high) |
---
## Sign-Off
- [x] All threats have a disposition (mitigate / accept / transfer)
- [x] Accepted risks documented in Accepted Risks Log
- [x] `threats_open: 0` confirmed
- [x] `status: verified` set in frontmatter
**Approval:** verified 2026-11-05

View File

@@ -0,0 +1,187 @@
# Phase 35: Mail templates and layouts editor - Context
**Gathered:** 2026-11-06
**Status:** Ready for planning
<domain>
## Phase Boundary
Administrators edit the mail the application sends from the admin panel, as in WinterCMS's Mail templates screen:
- Every template registered through `pact.HasMailTemplates` (and postcard's built-in mail) is listed.
- Each template, layout and partial can get a database override, and the override can be reset to the file version.
- Admins can create custom layouts.
- A Branding page holds structured colours and a logo.
- A shared image library holds uploaded images.
- Drafts can be previewed and test-sent.
postcard renders the override when one exists and the plugin's file otherwise, so mail that already works keeps working with no change to apps or plugins.
Not in this phase: mail driver/SMTP settings screens, MailBlocker, newsletters/bulk mail, a WYSIWYG mail body.
</domain>
<decisions>
## Implementation Decisions
### Placement and permissions
- **D-01:** The screens live **inside cabana**, as an internal pseudo-plugin. This is the same pattern as Administrators/Roles in 19.6.1 (`modules/cabana/backend_screens.go`, `modules/cabana/backendscreens/`). Every app gets them with no setup. postcard stays free of admin and cabana code; cabana (or a thin glue layer) supplies postcard with the DB-backed layer through an interface postcard defines.
- **D-02:** **Navigation: a "Mail" section in Settings**, with entries for Mail templates, Mail layouts, Mail partials, Mail branding and Mail images. Links use `pact.HasSettings`, as in 19.6.1 D-02. There is no top-level nav item.
- **D-03:** **Two permission codes**, registered by the cabana pseudo-plugin:
- `system.manage_mail_templates` covers the templates list/form, template preview and test send.
- `system.manage_mail_layouts` covers layouts, partials, Branding and the image library, because these change every mail.
Neither code is granted to any role by default (as in 19.6.1 D-04). Only superusers reach the screens until someone grants a code. The image library and its insert picker must also be usable from the template form by holders of `system.manage_mail_templates`, either read/pick only or with upload too (planner's call, stated in the plan). — **Reversibility:** costly — the codes end up stored in `backend_user_roles.permissions` / `backend_users.permissions` JSON on every install that grants them.
### Editing model: templates
- **D-04:** **A template override stores subject + Markdown body + layout.**
- The body stays Markdown with Go `html/template` variables (`{{ .name }}`), the same format as the file.
- The plain-text part is still derived from the body; there is no hand-written text part.
- The layout is a dropdown over all layouts (file and custom).
- Description stays file-owned and read-only.
- **D-05:** **Locale variants stay separate templates.** `activate.htm` and `activate-en.htm` are their own rows. The list has a locale column, parsed from the suffix convention postcard already uses, plus a locale filter. There is no grouping and no new locale model.
- **D-06:** **Reset to the file version** deletes the override row, on templates, file layouts and file partials alike.
- **D-07:** **Drift notice.** When an override is saved, it stores a hash of the file content it was based on. If the plugin file later changes (for example after a plugin upgrade), the override keeps winning, but the list and the form flag "file changed since override". The form offers a way to view the current file version (a diff if cheap) and the reset button. — **Reversibility:** costly — the hash column is part of the override table schema (migration).
### Editing model: layouts, Branding, partials
- **D-08:** **Layouts use the same override model as templates.**
- Every file layout is listed: postcard's built-in default plus plugin layouts such as sm-user-plugin `layouts/user.htm` and paymentgateway `layouts/paymentgateway.htm`.
- Each file layout can get a DB override with reset.
- Admins can also **create custom layouts** that exist only in the DB. Custom layouts can be deleted (not reset); deleting one still assigned to template overrides is refused, with a count of those templates.
- "Seeded predefined layouts" means a couple of framework-shipped file layouts, for example the existing default plus one or two more such as system/minimal (the planner picks the set). They are not rows copied into the DB at boot.
- **D-09:** **Layouts are edited in a new `codeeditor` form field type**, Winter-style, with syntax highlighting for HTML/CSS/text. It is added to cabana's field registry and to the admin SPA. The layout form has tabs for the HTML wrapper, the text wrapper and the CSS/header. The field type is reusable by later phases. — **Reversibility:** costly — a published field type name in `fields.yaml` that plugins may start using.
- **D-10:** **Branding page**, as in Winter's mail brand settings.
- It is a settings page (cabana settings model) with Winter's brand colour set: body/content background, text, link, the button primary/positive/negative colours, and header/footer colours.
- It also has a **logo** upload, exposed to layouts as `{{ .logoUrl }}`.
- The colours compile into the brand CSS slot that postcard already inlines.
- **D-11:** **Precedence: Branding replaces config brandCss.** While no Branding record has been saved, `mail.brandCss` from config is used, so existing apps are unchanged. Once Branding is saved, its compiled CSS replaces `mail.brandCss`. `mail.css` is unaffected and still applied.
- **D-12:** **Mail partials are in scope, with file + DB override like templates.**
- Files live at `views/mail/partials/<name>.htm` and are named `<plugin>::mail.partials.<name>`.
- Plugins declare them through a **new additive optional pact capability**. `pact.HasMailTemplates` is not changed: core plugins must not break.
- Partials are used from template bodies and layouts through a Go template call (the planner picks the exact syntax or helper).
- A few built-in partials ship with postcard (button, panel, table).
- Admins override and reset partials; editing them needs `system.manage_mail_layouts`.
— **Reversibility:** costly — a published pact capability and naming convention that plugins implement.
### Validation and send-time safety
- **D-13:** **Saves are validated.** Saving a template, layout or partial override parses it, test-renders it with the sample vars (D-15) and runs postcard's existing safety checks (script/iframe/object/embed, inline handlers, `javascript:`/`vbscript:`/`data:` URLs, CR/LF in the subject). Failures are field validation errors.
- **D-14:** **Fallback at send.** If an override still fails to render at send time (for example a variable missing at runtime), postcard logs the failure and sends the file version, so mail delivery doesn't stop. A template with no file (impossible for templates; custom layouts only) falls back to the default layout.
### Preview and test send
- **D-15:** **Sample variables**: plugins may add optional sample values to a template file. One option is a sample section in the INI header; another is an additive capability (planner's call). Variables without a sample render as a visible placeholder such as `[name]`. Existing template files work unchanged.
- **D-16:** **Live preview of the unsaved draft.** The template, layout and partial forms render the current draft through postcard with sample vars. The HTML part is shown in a **sandboxed iframe**, and a tab shows the plain-text part. Rendering happens on the server through postcard, so preview matches real sends.
- **D-17:** **Test send to my own address only.** A button sends the current draft (with sample vars) to the signed-in admin's email through the configured driver. It needs the same permission as editing that record and is rate-limited per admin. There is no free-text recipient field.
### Images
- **D-18:** **A shared mail image library.** A "Mail images" list stores images through `lagoon/attach` on the public disk. Any template, layout or partial can use them, and the Branding logo also uses this storage.
- **D-19:** **Delivery via absolute public URLs.** `<img src>` uses the attachment's absolute public URL, built from `storage.uploads.public_url` / `app.url`. No CID inline parts; postcard's `RenderedMessage` and drivers are unchanged.
- **D-20:** **An insert button in the editors.** A picker next to the markdown editor (template bodies) and the codeeditor (layouts/partials) inserts `![alt](url)` or `<img src="url" alt="">` for a chosen library image.
### Quality gates
- **D-21:** Unit tests are the last plan (project rule). They cover:
- override precedence and reset;
- drift detection;
- fallback at send;
- save validation;
- Branding precedence over config;
- the partial capability (boot errors, namespace rule);
- the permission split;
- the test-send recipient restriction and rate limit.
The postcard and cabana READMEs and the affected `docs/` pages are updated in the same commits as API/config/permission changes. The `admin/` changes regenerate `admin/openapi/admin.json` + `schema.d.ts` (`scripts/check-admin-openapi.sh`) and rebuild the committed `modules/boardwalk/dist` (`scripts/check-admin-dist.sh`).
### Claude's Discretion
- Table names and schema (one overrides table per kind, or a shared one), as long as it is Postgres/GORM with gormigrate migrations owned by the cabana pseudo-plugin, or by postcard if the planner justifies it.
- Exact partial call syntax and the built-in partial set beyond button/panel/table.
- Which extra predefined file layouts ship (D-08).
- Whether the image picker on the template form needs `manage_mail_layouts` for uploads (D-03 note).
- How postcard learns about overrides: a lookup interface checked per send, or a cache invalidated on save. It must be correct with several app instances, so per-send lookup or explicit invalidation, never a stale boot-time snapshot.
- How the SPA codeeditor is built (the library choice needs a STACK.md/decision note per the dependency rule).
- The plan split (lean planning: present the count before writing PLAN.md files).
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Mail layer
- `modules/postcard/README.md` covers the current template/layout format, catalog, drivers, safety checks and config keys (`mail.css`, `mail.brandCss`).
- `modules/postcard/templates.go` and `modules/postcard/mailer.go` hold `Catalog.Register`, `Mailer.Send`, `Activate` and `BootPlugin`, which are the integration point for the override layer.
- `modules/postcard/assets/default.htm` is the built-in default layout (header `==` text `==` HTML).
- `modules/pact/capabilities.go` §`HasMailTemplates` (around line 1271) must stay unchanged; partials and samples go in new additive capabilities.
### Admin patterns
- `.planning/phases/19.6.1-backend-administrators-screen/19.6.1-CONTEXT.md` covers the cabana internal pseudo-plugin, permission codes registered by cabana, Settings placement via `pact.HasSettings`, and no default role grant.
- `modules/cabana/backend_screens.go` and `modules/cabana/backendscreens/` hold the internal pseudo-plugin and the YAML controllers to mirror.
- `modules/cabana/settings.go` holds the settings model pattern for the Branding page.
- `modules/cabana/field_markdown.go`, `modules/cabana/field_file.go` and `modules/cabana/field_type.go` are where the new `codeeditor` field and the image picker hook in.
- `admin/src/components/form/fields/` holds the SPA field widgets (MarkdownField, FileuploadField); the new CodeEditorField goes here.
- `.planning/notes/2026-10-07-embedded-admin-release-gate.md` is the dist rebuild gate for `admin/` changes.
### Storage and URLs
- `modules/lagoon/attach/bucket.go` provides `PublicURLBase` (`storage.uploads.public_url`) for absolute image URLs.
- `.planning/research/STACK.md` holds the decided dependency set; a code-editor JS library must be added here or in a decision note.
### Reference implementation (WinterCMS)
- `../examples/golem15-wintercms-starter` covers Winter's System → Mail templates/layouts/partials/brand settings (`modules/system/models/MailTemplate.php`, `MailLayout.php`, `MailPartial.php`, `MailBrandSetting.php`) for field sets and behaviour parity.
### Existing mail consumers (must keep working unchanged)
- `../sm-user-plugin/views/mail/` (including `layouts/user.htm` and the locale variants) and `../sm-paymentgateway-plugin/views/mail/` (including `layouts/paymentgateway.htm`).
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `postcard.Catalog` already parses the INI header + `==` file format and resolves layout aliases. Overrides should reuse the same parser so DB content and files share one format.
- postcard's safety checker (rendered-HTML rejection, header CR/LF) is reused for save validation (D-13) and preview.
- The cabana internal pseudo-plugin (19.6.1) has permission registration, Settings links and YAML controllers.
- The cabana settings model plus `settings_secret.go` patterns cover the Branding page.
- `lagoon/attach` handles uploads, public disk storage and `PublicURLBase`.
- The SPA's `PreviewView.vue` and `FormView.vue` are where the preview pane and test-send action go.
### Established Patterns
- Plugins can only register names in their own namespace (`<plugin>::mail.*`). Partials follow the same rule.
- Boot errors for missing files, duplicates and unknown aliases. The new partial capability fails the same way.
- Postgres only, GORM + per-plugin gormigrate migrations; YAML via `goccy/go-yaml`.
- Standard library first; any new dependency (code editor in the SPA) needs a research/decision note.
- A docs checker enforces that README/docs identifiers exist.
### Integration Points
- postcard's render path (`Mailer.Send`) consults an override source before the file catalog, with fallback per D-14.
- `postcard.Activate` / brand CSS resolution consults the Branding record (D-11).
- The cabana field registry plus the SPA `FieldRenderer.vue` gain `codeeditor`.
- `pact` gets an additive capability for partials (and possibly sample vars).
</code_context>
<specifics>
## Specific Ideas
- The user explicitly wants **both** Winter's structured Branding page (colours, logo) **and** codeeditor-based layouts, "as WinterCMS kind of has it".
- Winter parity is the guide for field sets (brand colours, template/layout/partial forms) and for keeping `system.manage_mail_templates` as the code name.
</specifics>
<deferred>
## Deferred Ideas
- Hand-written plain-text template parts (Winter's `content_text`), not needed now.
- A WYSIWYG/rich-editor mail body.
- CID-embedded inline images (only absolute URLs in v1).
- Test send to an arbitrary address.
- Mail driver/SMTP configuration screen in the admin.
### Reviewed Todos (not folded)
- **Port MailBlocker and the block-mail checkbox** (`.planning/todos/pending/mailblocker-with-mailing.md`): kept separate. It is a send-path feature owned by sm-user-plugin (MailBlocker table, check before send, `block_mail` checkbox) and stays a todo for later mail work.
</deferred>
---
*Phase: 35-mail-templates-and-layouts-editor*
*Context gathered: 2026-11-06*

View File

@@ -0,0 +1,91 @@
# Phase 35: Mail templates and layouts editor - 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-11-06
**Phase:** 35-mail-templates-and-layouts-editor
**Areas discussed:** Placement & permissions, Editing model, Preview & test send, Images
---
## Todo cross-reference
| Option | Description | Selected |
|--------|-------------|----------|
| Keep it separate | MailBlocker stays a todo for the later mail work | ✓ |
| Fold it in | Widens the scope to sm-user-plugin and the send path | |
## Placement & permissions
| Option | Description | Selected |
|--------|-------------|----------|
| Inside cabana | Internal pseudo-plugin, the 19.6.1 pattern | ✓ |
| A new sm-mail-plugin | Opt-in plugin repo | |
| Inside postcard | postcard would depend on cabana | |
| Option | Description | Selected |
|--------|-------------|----------|
| Settings → Mail section | Winter-like, through pact.HasSettings | ✓ |
| Settings → System section | Next to Administrators/Roles | |
| Top-level nav item | | |
| Option | Description | Selected |
|--------|-------------|----------|
| One code | system.manage_mail_templates | |
| Two codes | templates + layouts | ✓ |
| Option | Description | Selected |
|--------|-------------|----------|
| system.manage_mail_templates + system.manage_mail_layouts | Winter-compatible | ✓ |
| mail.manage_templates + mail.manage_layouts | New namespace | |
## Editing model
| Option | Description | Selected |
|--------|-------------|----------|
| Subject + Markdown body + layout | Same as the file format | ✓ |
| Also a hand-written plain-text part | | |
| HTML body (rich editor) | | |
**Layout editing:** the options were a codeeditor field, a plain textarea, or structured fields. **User's choice (free text):** "I wonder if we could have both 3 and 1. WinterCMS kind of has it - it has 'Branding' section with structured fields for colors etc and codeeditor-based layouts." This became a Branding page plus codeeditor layouts.
| Option | Description | Selected |
|--------|-------------|----------|
| Defer partials | | |
| Include partials, file + DB | | ✓ |
| Question | Options | Selected |
|----------|---------|----------|
| Branding contents | Winter colours + logo / colours only / + free CSS | Winter colours + logo |
| Branding vs config | Branding replaces brandCss once saved / config wins / concatenate | Branding replaces once saved |
| Partials shape | Same pattern as templates / built-in only | Same pattern as templates |
| Branding & partials perms | Under manage_mail_layouts / own branding code | Under manage_mail_layouts |
| Plugin file layouts | Same override model / imported to DB at boot | Same override model |
| Drift after file change | Override wins + notice / silently | Override wins + notice |
| Locale variants | Own rows + locale column / grouped tabs | Own rows + locale column |
| Validation | On save + fallback at send / save only, strict at send | On save + fallback at send |
## Preview & test send
| Question | Options | Selected |
|----------|---------|----------|
| Preview | Live draft in a sandboxed iframe + text tab / saved only | Live draft in a sandboxed iframe |
| Sample vars | Optional file samples + placeholders / placeholders only / admin JSON | Optional file samples + placeholders |
| Test send | Own address / any address / none | Own address |
## Images
| Question | Options | Selected |
|----------|---------|----------|
| Storage | Shared mail image library / per-record attachments | Shared library |
| Delivery | Absolute public URLs / inline CID / per-image toggle | Absolute URLs |
| Placing | Insert button in the editor / copy URL | Insert button |
## Claude's Discretion
- Schema layout, partial call syntax, extra predefined layouts, the image picker permission detail, the override lookup/caching mechanism (must be multi-instance safe), the SPA code-editor library (needs a decision note), the plan split.
## Deferred Ideas
- Plain-text template parts, a WYSIWYG body, CID images, test send to any address, a mail driver settings screen.

View File

@@ -0,0 +1,964 @@
---
phase: "35"
slug: "mail-templates-and-layouts-editor"
status: approved
reviewed_at: "2026-11-06"
shadcn_initialized: false
preset: none
created: "2026-11-06"
---
# Phase 35 — UI Design Contract
> Visual and interaction contract for frontend phases. Generated by msd-ui-researcher, verified by msd-ui-checker.
Most of this phase is **YAML-declared cabana screens** rendered by the existing generic views in `admin/` (`ListView.vue`, `FormView.vue`, `SettingsFormView.vue`, `SettingsIndexView.vue`). This is the same approach as 19.6.1 (Administrators/Roles). The contract fixes six things:
1. **What the YAML declares** for the five Mail screens: columns, filters, form tabs and field order, record actions, settings entries.
2. **Four new SPA field controls**:
- `codeeditor` (D-09, a published type);
- `colorpicker` (Branding colours, Winter parity);
- `mailimage` (Branding logo, picked from the library);
- `mailpreview` (D-16 live preview, D-17 test send).
Plus one shared dialog, `MailImagePicker` (D-20), opened from the markdown body, the code editors and `mailimage`.
3. **Narrow additions to existing SPA code**:
- FormView renders record actions in its footer and hides Delete when the record says so;
- partial fields reload after a save or a record action;
- SettingsFormView provides the form values;
- RowStateBadges gets two more states;
- MarkdownField gets the image-insert button;
- main.css gets a `.summer-diff` kit.
4. **All copy** (en + pl) for settings entries, permissions, lists, forms, preview, test send, picker, Branding and every refusal (D-03, D-06, D-07, D-08, D-13, D-17).
5. **Where each refusal or failure shows up** in the existing UI (field line, banner, toast, inline status).
6. **The code-editor library**: CodeMirror 6. It needs a `.planning/research/STACK.md` row and a decision note (project dependency rule).
The SPA already has a complete design system, Direction C v2 (`.planning/phases/10-admin-vue-spa/design/README.md`). It is implemented as Tailwind 4 `@theme` tokens in `admin/src/styles/main.css` on Reka UI primitives. **This contract adds no colour tokens, fonts, radii or shadows.** The new CSS is the `.summer-diff` component kit (existing tokens only) and the CodeMirror theme (existing CSS variables only). Class strings below are copied from existing components unless marked **new**.
Sources:
- 35-CONTEXT.md D-01..D-21 (locked);
- the approved 19.6.1 UI-SPEC (house conventions: phrasebook keys, CLDR plurals, refusal shapes, accent reserve);
- WinterCMS `modules/system/models/{mailtemplate,maillayout,mailpartial,mailbrandsetting}/{fields,columns}.yaml`, `MailBrandSetting.php` defaults and `lang/{en,pl}/lang.php` (`mail_templates.*`, `mail_brand.*`), for field sets and wording;
- a codebase scan (listed per section).
---
## Design System
| Property | Value |
|----------|-------|
| Tool | none. There is no shadcn: this is a Vue 3 SPA with its own Direction C system, so the shadcn gate does not apply (the same ruling as 10.1, 12.1, 12.2, 16.3, 19.6.1) |
| Preset | not applicable |
| Component library | Reka UI 2.9.10 (headless primitives) plus the local components in `admin/src/components/` |
| Icon library | `@lucide/vue` 1.17.0, named imports only. Settings tiles use registry names already in `admin/src/app/icons.ts`: `mail`, `layout-grid`, `puzzle`, `palette`, `image`. New controls import `ImagePlus`, `Send`, `TriangleAlert`, `Info`, `Search`, `Upload`, `Check` and `X` directly, at 14px for inline notes and 16px for toolbar buttons |
| Font | DM Sans 400/600 (700 only in inherited headings), self-hosted `@fontsource/dm-sans`. **DM Mono 400** (`font-mono`, already self-hosted) for code: the code editor, plain-text preview, diff, hex values and code snippets in info partials |
| Code editor | **CodeMirror 6**, lazily loaded in its own chunk like the TipTap richeditor. Packages and pins are in Registry Safety. It needs a STACK.md "Admin SPA (npm) additions" row and a decision note `.planning/notes/2026-11-xx-admin-code-editor-codemirror.md`, written before the first plan that installs it |
**Why CodeMirror 6.**
- It is MIT and modular: state, view, language and the html/css grammars are separate packages, so the bundle carries only what the field uses.
- It needs no web workers, so the admin CSP is unchanged.
- It has keyboard and screen-reader support (a contenteditable with an ARIA textbox), and it lazy-loads cleanly behind a skeleton.
Rejected alternatives:
- **Monaco:** multi-MB, needs workers and `worker-src`, desktop-only feel.
- **Ace** (Winter's choice): older API, weaker accessibility.
- **A textarea with a highlight overlay:** fragile caret and scroll sync, no bracket matching.
- **TipTap source mode:** it is a rich-text tool, not a code editor.
---
## Component Inventory
Enumerated by `node --input-type=module -e "import * as r from 'reka-ui'; console.log(Object.keys(r).length)"` (run in `admin/`) — 478 exports — reka-ui@2.9.10 — 2026-11-06.
Local components were enumerated by `find admin/src/components -name '*.vue' | wc -l`: 63 components in summercms-admin@unversioned (a private workspace package with no `version` field in `admin/package.json`, pinned by repo commit 928ae00), on 2026-11-06.
Icons were verified on 2026-11-06 by running `node --input-type=module -e "import * as l from '@lucide/vue'; for (const n of ['Mail','Puzzle','Palette','Image','ImagePlus','Send','TriangleAlert','Info','Search','Upload','Check','X','Code']) console.log(n, n in l)"` in `admin/` against @lucide/vue@1.17.0. All are exported. The tile names `mail`, `layout-grid`, `puzzle`, `palette` and `image` are already mapped in `admin/src/app/icons.ts`, so no registry change is needed.
This table is a **non-exhaustive** list of known-good components for this phase, not a closed allowlist. Checking for another Reka export or local component is the expected path.
| Component | Import path | Notes |
|-----------|-------------|-------|
| ListView, DataTable, FilterBar | `admin/src/views/ListView.vue`, `admin/src/components/list/*` | The Templates, Layouts and Partials lists. No change except the two row-state labels (C9) |
| RowStateBadges | `admin/src/components/list/RowStateBadges.vue` | Gains the `customized` and `outdated` states (C9) |
| FormView | `admin/src/views/FormView.vue` | The Template, Layout, Partial and Mail images (singleton) forms. Gains footer record actions, `meta.deletable` and the partial reload (C8) |
| SettingsFormView | `admin/src/views/SettingsFormView.vue` | The Branding page. Gains `provide(FORM_VALUES)` and `provide(FORM_LOCALE)` so `mailpreview` reads the draft (C8) |
| SettingsIndexView | `admin/src/views/SettingsIndexView.vue` | Five tiles in the new "Mail" category. No change |
| FormTabs | `admin/src/components/form/FormTabs.vue` | Form tabs, and its class strings for the preview's HTML/Plain text switch |
| FormGrid, FormField, FieldRenderer, `registry.ts` | `admin/src/components/form/*` | Register `codeeditor`, `colorpicker`, `mailimage`, `mailpreview`. `mailpreview` is valueless and group-labelled; `mailimage` is group-labelled |
| MarkdownField | `admin/src/components/form/fields/MarkdownField.vue` | Template body. Gains the "Insert image" button. Its own Preview toggle is hidden when the image option is on (C3) |
| RichEditorField (patterns only) | `admin/src/components/form/fields/RichEditorField.vue` | Lazy-load skeleton/failed pattern, toolbar class strings and the `font-mono text-[13px]` source textarea, copied for `codeeditor` |
| FileuploadField + FileCaptionModal | `admin/src/components/form/fields/FileuploadField.vue` | Mail images library (`mode: image`, attachMany tile grid, caption = alt text). No change |
| PartialField + PartialHost + `.summer-callout` kit | `admin/src/components/form/fields/PartialField.vue`, `main.css` | Info/drift partial and File version partial (server-rendered html/template) |
| RecordActions | `admin/src/components/form/RecordActions.vue` | "Reset to file version", now also in the FormView footer. No change to the component |
| RelationPickerModal (patterns only) | `admin/src/components/relation/RelationPickerModal.vue` | Dialog shell, search input and chosen-state class strings, copied for `MailImagePicker` |
| ConfirmDialog + `useConfirm` | `admin/src/components/ui/ConfirmDialog.vue`, `confirm.ts` | Reset, delete-layout and leave-with-changes confirmations |
| Button | `admin/src/components/ui/Button.vue` | `outline` / `ghost` / `danger` / `primary`, `size: sm` for in-field buttons |
| `showToast` | `admin/src/state/useToasts.ts` | Save, reset, test-send results and delete refusals |
| FormErrorBanner | `admin/src/components/form/FormErrorBanner.vue` | 422 count banner and 403 banner. No change |
| `FORM_VALUES`, `FORM_PATCH`, `FORM_SESSION` | `admin/src/components/form/formContext.ts` | `mailpreview` reads the draft; `mailimage` writes its value through the normal `update:modelValue` |
New `.vue` files:
- `admin/src/components/form/fields/CodeEditorField.vue`;
- `admin/src/components/form/fields/ColorPickerField.vue`;
- `admin/src/components/form/fields/MailImageField.vue`;
- `admin/src/components/form/fields/MailPreviewField.vue`;
- `admin/src/components/mail/MailImagePicker.vue`.
New `.ts` files:
- `admin/src/components/form/codeeditor/editor.ts` (CodeMirror setup, the lazy import target);
- `admin/src/components/mail/insertImage.ts` (the pure snippet builder, C4).
---
## Spacing Scale
Declared values for new markup in this phase (all multiples of 4):
| Token | Value | Tailwind | Usage |
|-------|-------|----------|-------|
| xs | 4px | `gap-1`, `p-1` | Editor and preview toolbar padding and button gap (copied from the richeditor toolbar) |
| sm | 8px | `gap-2` | Icon-to-text gaps, colour swatch to hex input, picker tile grid gap, test-send row gap |
| md | 16px | `px-4`, `gap-4` | Preview subject row horizontal padding, picker dialog stack (copied from RelationPickerModal `gap-4`), `.summer-diff` horizontal padding |
| lg | 24px | `gap-x-6`, `p-6` | Form grid column gap and dialog padding (existing, unchanged) |
| xl | 32px | `px-8` | Sticky footer padding (existing, unchanged) |
| 2xl | 48px | — | not used |
| 3xl | 64px | — | not used |
Fixed component heights (multiples of 16):
- **Preview frame:** 640px (`h-[640px]`).
- **Code editor by `size`:** `tiny` 128px, `small` 192px, `large` 320px (also the default when no size is set), `huge` 480px, `giant` 640px.
- **Plain-text preview and File version blocks:** at most 480px (`max-h-[480px]`), scrolling inside.
Exceptions, all inherited from Direction C. They were approved as locked in 16.3 and re-used in 19.6.1, and none is new:
- label-to-control gap 6px (`gap-1.5` in `FormField`);
- form row gap 22px (`gap-y-[22px]` in `FormGrid`);
- card padding 28px (`p-7`);
- page stack 20px (`gap-5`);
- footer button gap 10px (`gap-2.5`);
- callout padding 14×18 (`.summer-callout`);
- 12px vertical padding (`py-3`), used by the preview subject row and `.summer-diff`, as in the permission rows;
- control height 44px (`h-input`), used by the colour swatch and the picker search;
- toolbar button height 32px (`h-8`, richeditor toolbar);
- tab pill height 34px (`h-[34px]`, FormTabs);
- input content padding 14×10 (`px-3.5 py-2.5`, copied from the inputs), used by the C1 CodeMirror content and the read-only `pre`.
---
## Typography
New markup uses exactly these styles. Everything else is rendered by existing components unchanged.
| Role | Size | Weight | Line Height | Used by |
|------|------|--------|-------------|---------|
| Body | 14px | 400 | 1.5 | Field values, preview subject value, picker alt input, callout text, info partial text, toasts |
| Label | 14px | 600 | 1.5 | Field labels, tab pills, button text, callout titles, info partial `strong` lines, preview "Subject" label |
| Meta | 13px | 400 | 1.5 | Comments, error lines, preview status/notes, test-send hint, picker tile filenames, toolbar text buttons ("Insert image") |
| Code (Meta size, mono) | 13px DM Mono | 400 | 1.5 | **New:** CodeMirror content and gutter, plain-text preview `pre`, `.summer-diff`, hex text in `colorpicker`, `code` in info partials |
| Dialog heading (inherited) | 20px | 700 | 1.2 (`tracking-[-0.01em]`) | `MailImagePicker` title, copied from RelationPickerModal |
| Page heading (inherited) | 24px | 700 | 1.2 (`tracking-[-0.02em]`) | Record title in `FormView`/`SettingsFormView` header, unchanged |
Weights: new content uses 400 and 600 only. 700 appears only in the two inherited headings (the locked system-level exception from 16.3 and 19.6.1). Sizes in new markup are 13 and 14; 20 and 24 are inherited. No 12px text is added; the 12px row-state badge is reused unchanged. The 12px badge, the 20/24px headings and weight 700 are inherited exceptions from 16.3 and 19.6.1: the effective scale for new markup in this phase is 13/14px at 400/600, and the planner must not use 12, 20 or 24 in new markup.
---
## Color
All admin UI colours are existing CSS variables (light / dark from `main.css`). Hard-coded hex values are forbidden in new admin markup, with two exceptions:
1. the chosen-tile border `border-[#e0b020]`, copied verbatim from `RelationPickerModal`;
2. the preview canvas, explained below.
**Mail content colours are not UI tokens.** The Branding defaults below (Winter's `MailBrandSetting` values) are data that the server compiles into mail CSS; they never style the admin.
| Role | Value (light / dark) | Usage |
|------|----------------------|-------|
| Dominant (60%) | `bg` #f4f6f9 / #111726, `surface` #ffffff / #182033 | Page, form card, list table, code editor content area, picker dialog |
| Secondary (30%) | `subtle` #f3f5f8 / #1f283d, `border` #e6e9ef / #29334b, `border-strong` #d2d8e2 / #3a4661, `muted` #566175 / #a9b3c6 | Editor and preview toolbars, the CodeMirror gutter, plain-text preview and `.summer-diff` background, template-action highlight background, the `customized` badge outline, comments, notes and `Info` icons |
| Accent (10%) | `primary` #22304d / #fcd34d; `sel` #fdf3cf / #3a3622; `ring` | See the reserved list |
| Destructive | `danger` #c62828 / #f58a8a, `danger-soft` #fdf0f0 / #3a1d24 | The "Reset to file version" confirm button, the custom-layout Delete (danger outline) and its confirm, field error lines, the preview/test-send error line, `.summer-diff__del` lines, danger toasts |
| Semantic OK | `ok-bg` / `ok-text` | `.summer-diff__add` lines only. Code-editor strings and attribute values use `ok-text` (syntax colour, see below) |
| Preview canvas | `#ffffff` in both themes | The `mailpreview` iframe element background (`bg-white`). Mail clients render on white; without this, dark mode shows mail with a transparent body on the dark surface. It is not an admin UI colour |
**Code editor syntax colours** (CodeMirror `HighlightStyle` and `EditorView.theme`, all through `var(--c-*)`, so light and dark mode swap):
| Token | Variable |
|-------|----------|
| tag names, CSS properties, keywords | `--c-primary` |
| attribute values, strings | `--c-ok-text` |
| attribute names, CSS selectors, everything else | `--c-text` |
| comments, punctuation, brackets | `--c-muted` |
| Go template actions `{{ … }}` (a `MatchDecorator`, class `cm-summer-action`) | `--c-primary` on `--c-subtle`, radius 4px |
| gutter | `--c-subtle` background, `--c-muted` numbers |
| active line | `--c-hover` |
| selection | `--c-sel` |
| cursor | `--c-text` |
Weights stay 400; there is no italic.
Accent reserved for (exhaustive for this phase):
1. `primary` fill on the single primary button per screen:
- footer **Save** on every form and the Branding page (existing);
- **New layout** in the Layouts list toolbar;
- **Insert image** / **Use this image** in the picker dialog.
"Send test to me" and "Reset to file version" are not primary.
2. `primary` text: tag/property/keyword tokens and Go template actions in the code editor (syntax, above).
3. `sel` tint:
- the `outdated` row-state badge ("File changed");
- the drift callout (`.summer-callout--warning`);
- the chosen picker tile (with the copied `#e0b020` border);
- code-editor selection.
4. `ring` (3px): focus-visible on every focusable element, including the editor wrapper (`:focus-within`), colour swatches and picker tiles. Never removed.
Accent is never used for: record action buttons (outline), the test-send button (outline), the `customized` badge (neutral outline), comments, links, settings tiles, or the preview toolbar.
---
## Copywriting Contract
All copy lives in `modules/phrasebook/backend/lang/{en,pl}/lang.yaml` (`backend::lang.*`).
New groups:
- `mail:`, with nested `template:`, `layout:`, `partial:`, `brand:`, `images:`, `preview:`, `picker:` (names follow Winter's `mail_templates.*` / `mail_brand.*` meaning);
- `codeeditor:`, `colorpicker:`, `mailimage:` for the generic field controls.
Additions to existing groups:
- `settings.category_mail`;
- `permissions.manage_mail_templates[_comment]`, `permissions.manage_mail_layouts[_comment]`;
- `list.*` is not touched.
Conventions:
- Placeholders use `:name` / `:count` / `:email` / `:seconds` / `:message` / `:query`.
- Plural texts are CLDR maps (`one`/`other` in en; `one`/`few`/`many`/`other` in pl), never pipe strings.
- Polish is the design language; English values carry the same meaning.
- English spells "colour".
- Existing keys are reused unchanged and are not restated: `form.save`, `form.saving`, `form.save_and_close`, `form.cancel`, `form.delete`, `messages.form.saved`, `messages.form.deleted`, `form.load_failed`, `list.load_failed`, `messages.list.empty_search`, `list.empty_value`, the validation messages (`required`, `max.string`), `fileupload.*` and `form.tab_errors`.
Literal `{{ .Name }}` / `{{ .Content }}` / `{{ .logoUrl }}` in copy are shown as text (the SPA renders phrases as text). In YAML they must be quoted so the phrasebook does not treat them as placeholders.
### Primary CTAs and key states
| Element | Key | EN | PL |
|---------|-----|----|----|
| Templates list title | `mail.template.list_title` | Mail templates | Szablony wiadomości |
| Templates: record name (`config_form.yaml` `name`) / form subtitle | `mail.template.name` | Mail template | Szablon wiadomości |
| Templates: primary CTA | — | none in the list (templates come from plugin files; no create, no delete); the form's primary is the existing **Save** | — |
| Layouts list title | `mail.layout.list_title` | Mail layouts | Układy wiadomości |
| Layouts: record name / form subtitle | `mail.layout.name` | Mail layout | Układ wiadomości |
| Layouts: primary CTA (toolbar create; create-form title) | `mail.layout.new` | New layout | Nowy układ |
| Partials list title | `mail.partial.list_title` | Mail partials | Fragmenty wiadomości |
| Partials: record name / form subtitle | `mail.partial.name` | Mail partial | Fragment wiadomości |
| Partials: primary CTA | — | none in the list (partials come from files); the form's primary is **Save** | — |
| Branding page title (settings entry label) | `mail.brand.menu_label` | Mail branding | Wygląd wiadomości |
| Mail images page title (singleton `name`) | `mail.images.menu_label` | Mail images | Obrazy w wiadomościach |
| Empty state, Templates (`noRecordsMessage`) | `mail.template.list_empty` | No mail templates in this view. Clear the search and filters. | Brak szablonów wiadomości w tym widoku. Wyczyść wyszukiwanie i filtry. |
| Empty state, Layouts | `mail.layout.list_empty` | No layouts in this view. Clear the search, or create a layout. | Brak układów w tym widoku. Wyczyść wyszukiwanie lub utwórz układ. |
| Empty state, Partials | `mail.partial.list_empty` | No partials in this view. Clear the search. | Brak fragmentów w tym widoku. Wyczyść wyszukiwanie. |
| Error state, list or form load failed | existing `list.load_failed`, `form.load_failed` | unchanged | unchanged |
| Saved toast | existing `messages.form.saved` | unchanged | unchanged |
### Settings entries and permission catalog (D-02, D-03)
| Element | Key | EN | PL |
|---------|-----|----|----|
| Settings category | `settings.category_mail` | Mail | Poczta e-mail |
| Templates tile label / description | `mail.template.menu_label` / `mail.template.menu_description` | Mail templates / Edit the subject and content of the mail the application sends. | Szablony wiadomości / Edytuj temat i treść wiadomości wysyłanych przez aplikację. |
| Layouts tile label / description | `mail.layout.menu_label` / `mail.layout.menu_description` | Mail layouts / Edit the layouts that wrap every message, or create your own. | Układy wiadomości / Edytuj układy otaczające każdą wiadomość lub utwórz własne. |
| Partials tile label / description | `mail.partial.menu_label` / `mail.partial.menu_description` | Mail partials / Edit the reusable blocks used in mail, such as buttons and panels. | Fragmenty wiadomości / Edytuj bloki wielokrotnego użytku w wiadomościach, np. przyciski i panele. |
| Branding tile label / description | `mail.brand.menu_label` / `mail.brand.menu_description` | Mail branding / Set the colours and logo used by every message. | Wygląd wiadomości / Ustaw kolory i logo używane we wszystkich wiadomościach. |
| Images tile label / description | `mail.images.menu_label` / `mail.images.menu_description` | Mail images / Upload images to use in mail templates, layouts and partials. | Obrazy w wiadomościach / Prześlij obrazy do użycia w szablonach, układach i fragmentach wiadomości. |
| `system.manage_mail_templates` label | `permissions.manage_mail_templates` | Manage mail templates | Zarządzanie szablonami wiadomości |
| … comment | `permissions.manage_mail_templates_comment` | Edit mail templates, preview them and send test messages to yourself. | Edycja szablonów wiadomości, ich podgląd i wysyłanie wiadomości testowych do siebie. |
| `system.manage_mail_layouts` label | `permissions.manage_mail_layouts` | Manage mail layouts and branding | Zarządzanie układami i wyglądem wiadomości |
| … comment | `permissions.manage_mail_layouts_comment` | Edit mail layouts, partials, branding and the mail image library. These change every message the application sends. | Edycja układów, fragmentów i wyglądu wiadomości oraz biblioteki obrazów. Zmiany dotyczą każdej wiadomości wysyłanej przez aplikację. |
Both codes go in the existing permission tab `permissions.tab_system` ("System"); there is no new tab. This follows Winter, which lists mail permissions under System.
### Lists (C3b, C5, C6, C9)
| Element | Key | EN | PL |
|---------|-----|----|----|
| Column / search: code | `mail.code` | Code | Kod |
| Column: subject | `mail.template.subject` | Subject | Temat |
| Column: description | `mail.template.description` | Description | Opis |
| Column / filter: locale | `mail.template.locale` | Locale | Język |
| Locale value for a file without a suffix | `mail.template.locale_default` | Default | Domyślny |
| Column: layout | `mail.template.layout` | Layout | Układ |
| Column (invisible): customized at | `mail.customized_at` | Customized | Dostosowano |
| Column: name (layouts, partials) | `mail.name_field` | Name | Nazwa |
| Column: source (layouts, partials) | `mail.source` | Source | Źródło |
| Source value of a database-only layout | `mail.source_custom` | Custom | Własny |
| Filter: status (scope) | `mail.filter_status` | Status | Status |
| Filter option | `mail.filter_customized` | Customized | Dostosowane |
| Filter option | `mail.filter_not_customized` | Not customized | Niedostosowane |
| Filter option | `mail.filter_outdated` | File changed | Plik zmieniony |
| Row state `customized` (`messages.rowStateCustomized`) | `mail.state_customized` | Customized | Dostosowany |
| Row state `outdated` (`messages.rowStateOutdated`) | `mail.state_outdated` | File changed | Plik zmieniony |
The source value of a file-backed record is the owning plugin's ID as text (for example `golem15.user`). It is not translated.
### Template form (C3b)
| Element | Key | EN | PL |
|---------|-----|----|----|
| Tab 1 | `mail.template.tab_template` | Template | Szablon |
| Tab 2 (update only) | `mail.tab_file_version` | File version | Wersja z pliku |
| Subject label / comment | `mail.template.subject` / `mail.template.subject_comment` | Subject / One line. Variables such as {{ .Name }} work here too. | Temat / Jeden wiersz. Zmienne, np. {{ .Name }}, działają także tutaj. |
| Layout label / comment | `mail.template.layout` / `mail.template.layout_comment` | Layout / The layout wraps the message. Custom layouts are listed too. | Układ / Układ otacza wiadomość. Na liście są też układy własne. |
| Body label / comment | `mail.template.body` / `mail.template.body_comment` | Message / Markdown with variables such as {{ .Name }}. The plain-text version is made from it automatically. | Treść / Markdown ze zmiennymi, np. {{ .Name }}. Wersja tekstowa powstaje z niej automatycznie. |
| Preview label | `mail.preview.label` | Preview | Podgląd |
### Layout form (C5)
| Element | Key | EN | PL |
|---------|-----|----|----|
| Tabs | `mail.layout.tab_html` / `mail.layout.tab_text` / `mail.layout.tab_css` / `mail.tab_file_version` | HTML / Plain text / CSS / File version | HTML / Zwykły tekst / CSS / Wersja z pliku |
| Name label | `mail.name_field` | Name | Nazwa |
| Code label / comment (create only) | `mail.code` / `mail.layout.code_comment` | Code / Templates refer to the layout by this code. It cannot be changed later. | Kod / Szablony odwołują się do układu przez ten kod. Później nie można go zmienić. |
| HTML wrapper label / comment | `mail.layout.html` / `mail.layout.html_comment` | HTML layout / Wraps the HTML message. Put {{ .Content }} where the message goes. | Układ HTML / Otacza wiadomość HTML. Wstaw {{ .Content }} w miejscu treści. |
| Text wrapper label / comment | `mail.layout.text` / `mail.layout.text_comment` | Plain-text layout / Wraps the plain-text message. Put {{ .Content }} where the message goes. | Układ tekstowy / Otacza wiadomość tekstową. Wstaw {{ .Content }} w miejscu treści. |
| CSS label / comment | `mail.layout.css` / `mail.layout.css_comment` | CSS / Styles for this layout. They are inlined into the HTML message after the branding styles. | CSS / Style tego układu. Są wstawiane do wiadomości HTML po stylach wyglądu. |
| Delete confirm (custom layouts only, danger) | `mail.layout.delete_confirm` | Delete this layout? This cannot be undone. A layout that customized templates still use cannot be deleted. | Usunąć ten układ? Tej operacji nie można cofnąć. Nie można usunąć układu, którego używają dostosowane szablony. |
### Partial form (C6)
| Element | Key | EN | PL |
|---------|-----|----|----|
| Tabs | `mail.partial.tab_html` / `mail.partial.tab_text` / `mail.tab_file_version` | HTML / Plain text / File version | HTML / Zwykły tekst / Wersja z pliku |
| HTML label / comment | `mail.partial.html` / `mail.partial.html_comment` | HTML / Used in the HTML part of messages. | HTML / Używany w części HTML wiadomości. |
| Text label / comment | `mail.partial.text` / `mail.partial.text_comment` | Plain text / Used in the plain-text part of messages. | Zwykły tekst / Używany w części tekstowej wiadomości. |
### Info partial and drift notice (C7, D-06, D-07)
| Element | Key | EN | PL |
|---------|-----|----|----|
| Line: description (templates) | `mail.info_description` | Description: :text | Opis: :text |
| Line: file | `mail.info_file` | File: :path in :plugin | Plik: :path we wtyczce :plugin |
| Status: not customized | `mail.info_not_customized` | Not customized. The form shows the file version; saving creates your version. | Nie dostosowano. Formularz pokazuje wersję z pliku; zapisanie utworzy Twoją wersję. |
| Status: customized | `mail.info_customized` | Customized. Your version is sent instead of the file. | Dostosowano. Zamiast pliku wysyłana jest Twoja wersja. |
| Status: custom layout | `mail.info_custom_layout` | Custom layout. It exists only in the database and can be deleted. | Układ własny. Istnieje tylko w bazie danych i można go usunąć. |
| Variables line (samples declared) | `mail.info_variables` | Variables with sample values: | Zmienne z przykładowymi wartościami: |
| Variables line (no samples) | `mail.info_no_samples` | This file declares no sample values. Variables show as [name] in the preview. | Ten plik nie deklaruje przykładowych wartości. W podglądzie zmienne są pokazane jako [nazwa]. |
| Partial usage line | `mail.info_partial_usage` | Use it in a template or layout with: | Użyj go w szablonie lub układzie tak: |
| Drift callout title | `mail.drift_title` | The file changed after this was customized | Plik zmienił się po dostosowaniu |
| Drift callout text | `mail.drift_text` | A plugin update changed the file. Your version is still sent. Compare both on the File version tab, then save your version again to mark the change as reviewed, or reset to the file version. | Aktualizacja wtyczki zmieniła plik. Nadal wysyłana jest Twoja wersja. Porównaj obie w zakładce Wersja z pliku, a potem zapisz swoją wersję ponownie, aby oznaczyć zmianę jako sprawdzoną, albo przywróć wersję z pliku. |
| File version: changes heading | `mail.file_changes` | Changes from the file | Zmiany względem pliku |
| File version: legend | `mail.file_legend` | Lines starting with + are only in your version. Lines starting with − are only in the file. | Wiersze zaczynające się od + są tylko w Twojej wersji. Wiersze zaczynające się od − są tylko w pliku. |
| File version: content heading | `mail.file_content` | File version | Wersja z pliku |
| File version: identical | `mail.file_identical` | Your version matches the file. | Twoja wersja jest zgodna z plikiem. |
| File version: not customized | `mail.file_not_customized` | Not customized: the form already shows this version. | Nie dostosowano: formularz pokazuje już tę wersję. |
### Record actions and destructive confirmations (D-06, D-08)
| Element | Key | EN | PL |
|---------|-----|----|----|
| Record action: Reset | `mail.reset` | Reset to file version | Przywróć wersję z pliku |
| Reset confirm, template (danger) | `mail.template.reset_confirm` | Reset this template to the file version? Your subject, message and layout choice are deleted, and unsaved edits on this page are discarded. This cannot be undone. | Przywrócić wersję szablonu z pliku? Twój temat, treść i wybór układu zostaną usunięte, a niezapisane zmiany na tej stronie odrzucone. Tej operacji nie można cofnąć. |
| Reset confirm, layout (danger) | `mail.layout.reset_confirm` | Reset this layout to the file version? Your HTML, plain-text and CSS changes are deleted, and unsaved edits on this page are discarded. This cannot be undone. | Przywrócić wersję układu z pliku? Twoje zmiany HTML, tekstu i CSS zostaną usunięte, a niezapisane zmiany na tej stronie odrzucone. Tej operacji nie można cofnąć. |
| Reset confirm, partial (danger) | `mail.partial.reset_confirm` | Reset this partial to the file version? Your HTML and plain-text changes are deleted, and unsaved edits on this page are discarded. This cannot be undone. | Przywrócić wersję fragmentu z pliku? Twoje zmiany HTML i tekstu zostaną usunięte, a niezapisane zmiany na tej stronie odrzucone. Tej operacji nie można cofnąć. |
| Reset success toast | `mail.reset_done` | Reset to the file version. | Przywrócono wersję z pliku. |
| Delete refused: layout in use (403 `Message`, toast) | `mail.layout.delete_in_use` | one: "The layout “:name” is used by :count customized template. Choose another layout for it first." other: "The layout “:name” is used by :count customized templates. Choose another layout for them first." | one: "Układ „:name” jest używany przez :count dostosowany szablon. Najpierw wybierz dla niego inny układ." few: "Układ „:name” jest używany przez :count dostosowane szablony. Najpierw wybierz dla nich inny układ." many: "Układ „:name” jest używany przez :count dostosowanych szablonów. Najpierw wybierz dla nich inny układ." other: "Układ „:name” jest używany przez :count dostosowanego szablonu. Najpierw wybierz dla nich inny układ." |
### Preview and test send (C10, D-15, D-16, D-17)
| Element | Key | EN | PL |
|---------|-----|----|----|
| Format switch | `mail.preview.html` / `mail.preview.text` | HTML / Plain text | HTML / Zwykły tekst |
| Subject row label | `mail.preview.subject` | Subject | Temat |
| iframe title | `mail.preview.frame_title` | Preview of the HTML message | Podgląd wiadomości HTML |
| Rendering status | `mail.preview.rendering` | Rendering preview… | Renderowanie podglądu… |
| Empty draft | `mail.preview.empty` | Nothing to preview yet. Write the message first. | Nie ma jeszcze czego podglądać. Najpierw napisz treść wiadomości. |
| Render error (keeps the last good render) | `mail.preview.failed` | The draft cannot be rendered: :message The preview shows the last version that rendered. | Nie można wyrenderować wersji roboczej: :message Podgląd pokazuje ostatnią wersję, która się wyrenderowała. |
| Render error, nothing rendered yet | `mail.preview.failed_first` | The draft cannot be rendered: :message | Nie można wyrenderować wersji roboczej: :message |
| Preview unavailable (network/5xx) | `mail.preview.unavailable` | Preview unavailable. It updates again with your next edit. | Podgląd niedostępny. Odświeży się przy następnej zmianie. |
| Sample note (templates) | `mail.preview.sample_note` | Variables are filled with sample values. A variable without a sample shows as [name]. | Zmienne są wypełnione przykładowymi wartościami. Zmienna bez przykładu jest pokazana jako [nazwa]. |
| Sample note (layouts, partials, branding) | `mail.preview.sample_content_note` | Previewed with sample content. | Podgląd z przykładową treścią. |
| Test-send button | `mail.preview.test_send` | Send test to me | Wyślij test do mnie |
| Test-send busy | `mail.preview.test_sending` | Sending… | Wysyłanie… |
| Test-send hint | `mail.preview.test_hint` | Sends the current draft, unsaved changes included, to :email. | Wysyła bieżącą wersję roboczą, razem z niezapisanymi zmianami, na adres :email. |
| Test-send success toast | `mail.preview.test_sent` | Test message sent to :email. | Wysłano wiadomość testową na adres :email. |
| Test-send rate limited (429) | `mail.preview.test_limited` | You have sent too many test messages. Try again in :seconds s. | Wysłano zbyt wiele wiadomości testowych. Spróbuj ponownie za :seconds s. |
| Test-send refused (422, draft invalid) | `mail.preview.test_invalid` | Fix the draft before sending a test: :message | Popraw wersję roboczą przed wysłaniem testu: :message |
| Test-send driver failure (5xx) | `mail.preview.test_failed` | The test message could not be sent. Check the mail settings and the application log. | Nie udało się wysłać wiadomości testowej. Sprawdź ustawienia poczty i log aplikacji. |
### Image picker and image fields (C4, C11, D-18, D-20)
| Element | Key | EN | PL |
|---------|-----|----|----|
| Editor toolbar button | `mail.picker.insert_button` | Insert image | Wstaw obraz |
| Dialog title, insert mode | `mail.picker.insert_title` | Insert an image from the library | Wstaw obraz z biblioteki |
| Dialog title, choose mode (`mailimage`) | `mail.picker.choose_title` | Choose an image | Wybierz obraz |
| Search label / placeholder | `mail.picker.search` | Search images | Szukaj obrazów |
| Alt text label / comment | `mail.picker.alt` / `mail.picker.alt_comment` | Alternative text / Describes the image for people who cannot see it. Leave it empty for a decorative image. | Tekst alternatywny / Opisuje obraz osobom, które go nie widzą. Zostaw puste dla obrazu dekoracyjnego. |
| Primary, insert mode | `mail.picker.insert` | Insert image | Wstaw obraz |
| Primary, choose mode | `mail.picker.use` | Use this image | Użyj tego obrazu |
| Upload button | `mail.picker.upload` | Upload image | Prześlij obraz |
| Uploading | `mail.picker.uploading` | Uploading… | Przesyłanie… |
| Empty library heading | `mail.picker.empty_title` | No images yet | Brak obrazów |
| Empty library body (may upload) | `mail.picker.empty_upload` | Upload an image to use it in mail. | Prześlij obraz, aby użyć go w wiadomościach. |
| Empty library body (pick only) | `mail.picker.empty_pick_only` | Images are added in Settings, Mail images, by an administrator who manages mail layouts. | Obrazy dodaje w Ustawieniach, w sekcji Obrazy w wiadomościach, administrator zarządzający układami wiadomości. |
| No search results | `mail.picker.no_results` | No images match “:query”. | Brak obrazów pasujących do „:query”. |
| Load failed | `mail.picker.load_failed` | The image library could not be loaded. Close this window and try again. | Nie udało się wczytać biblioteki obrazów. Zamknij to okno i spróbuj ponownie. |
| Close (icon button aria-label) | existing `form.cancel` | unchanged | unchanged |
| `mailimage`: nothing chosen | `mailimage.empty` | No image chosen | Nie wybrano obrazu |
| `mailimage`: choose / change / remove | `mailimage.choose` / `mailimage.change` / `mailimage.remove` | Choose image / Change image / Remove image | Wybierz obraz / Zmień obraz / Usuń obraz |
| `mailimage`: image gone from library | `mailimage.missing` | This image is no longer in the library. Choose another one. | Tego obrazu nie ma już w bibliotece. Wybierz inny. |
| Mail images field label / comment | `mail.images.field` / `mail.images.field_comment` | Images / Stored on the public disk and linked from mail by their public address. The caption title is the image's alternative text. Removing an image breaks every template, layout or partial that still shows it. | Obrazy / Przechowywane na dysku publicznym i linkowane z wiadomości przez publiczny adres. Tytuł podpisu to tekst alternatywny obrazu. Usunięcie obrazu psuje każdy szablon, układ lub fragment, który nadal go pokazuje. |
### Code editor and colour picker (C1, C2)
| Element | Key | EN | PL |
|---------|-----|----|----|
| Editor failed to load (fallback textarea note) | `codeeditor.load_failed` | The code editor could not be loaded. You can still edit the text here. | Nie udało się wczytać edytora kodu. Nadal możesz edytować tekst tutaj. |
| Toolbar aria-label | `codeeditor.toolbar` | Code editor tools | Narzędzia edytora kodu |
| Colour swatch aria-label | `colorpicker.pick` | Pick a colour for “:field” | Wybierz kolor: „:field” |
| Empty hex placeholder | `colorpicker.not_set` | Not set | Nie ustawiono |
| Invalid colour (422 field line) | `colorpicker.invalid` | Enter a colour as #rrggbb, for example #d66829. | Podaj kolor w formacie #rrggbb, na przykład #d66829. |
### Branding page (C12, D-10, D-11)
| Field | Key | EN | PL | Default (Winter) |
|-------|-----|----|----|------------------|
| `logo` (mailimage) label / comment | `mail.brand.logo` / `mail.brand.logo_comment` | Logo / Layouts show the logo where they use {{ .logoUrl }}. | Logo / Układy pokazują logo tam, gdzie używają {{ .logoUrl }}. | none |
| `body_bg` | `mail.brand.body_bg` | Body background | Główne tło | #f5f8fa |
| `content_bg` | `mail.brand.content_bg` | Content background | Tło treści | #ffffff |
| `header_color` | `mail.brand.header_color` | Header colour | Kolor górnego nagłówka | #bbbfc3 |
| `footer_color` | `mail.brand.footer_color` | Footer colour | Kolor stopki | #aeaeae |
| `heading_color` | `mail.brand.heading_color` | Headings colour | Kolor nagłówków | #2f3133 |
| `text_color` | `mail.brand.text_color` | Text colour | Kolor tekstu | #74787e |
| `link_color` | `mail.brand.link_color` | Link colour | Kolor linków | #2da7c7 |
| `button_text_color` | `mail.brand.button_text_color` | Button text colour | Kolor tekstu przycisków | #ffffff |
| `button_primary_bg` | `mail.brand.button_primary_bg` | Primary button background | Tło głównego przycisku | #d66829 |
| `button_positive_bg` | `mail.brand.button_positive_bg` | Positive button background | Tło pozytywnego przycisku | #52a838 |
| `button_negative_bg` | `mail.brand.button_negative_bg` | Negative button background | Tło negatywnego przycisku | #e01346 |
| `panel_bg` | `mail.brand.panel_bg` | Panel background | Tło panelu | #edeff2 |
| `table_border_color` | `mail.brand.table_border_color` | Table border colour | Kolor ramki tabeli | #edeff2 |
| `_preview` (mailpreview) comment | `mail.brand.preview_comment` | Until this page is saved for the first time, mail uses the brand CSS from the configuration (mail.brandCss). After that, these colours replace it. | Dopóki ta strona nie zostanie zapisana po raz pierwszy, wiadomości używają stylów wyglądu z konfiguracji (mail.brandCss). Potem zastępują je te kolory. | — |
The set is D-10's list plus `panel_bg` and `table_border_color`, the colours of the built-in `panel` and `table` partials (D-12). Winter's promotion and subcopy colours are left out, because no built-in partial uses them. The planner may extend the set; each added colour follows the same row shape.
### Save validation (D-13): server copy for 422 field lines
| Rule | Key | EN | PL | Field |
|------|-----|----|----|-------|
| Parse error | `mail.invalid_parse` | This cannot be read as a template: :message | Nie można odczytać tego jako szablonu: :message | the edited content field (`body`, `content_html`, `content_text`, `content_css`) |
| Test render failed | `mail.invalid_render` | This does not render with the sample values: :message | To się nie renderuje z przykładowymi wartościami: :message | the field whose content failed |
| Unsafe content | `mail.invalid_unsafe` | Mail cannot contain scripts, frames, embedded objects, inline event handlers or javascript:, vbscript: or data: addresses. Found: :message | Wiadomość nie może zawierać skryptów, ramek, osadzonych obiektów, atrybutów zdarzeń ani adresów javascript:, vbscript: lub data:. Znaleziono: :message | the field whose content produced it (`body` for templates) |
| Subject with a line break | `mail.invalid_subject_line` | The subject must be a single line. | Temat musi mieścić się w jednym wierszu. | `subject` |
| Unknown layout | `mail.invalid_layout` | Choose a layout from the list. | Wybierz układ z listy. | `layout` |
| Unknown partial referenced | `mail.invalid_partial` | The partial “:name” does not exist. | Fragment „:name” nie istnieje. | the field that references it |
| Layout without {{ .Content }} | `mail.invalid_no_content` | Put {{ .Content }} where the message goes. | Wstaw {{ .Content }} w miejscu treści. | `content_html` or `content_text` |
| Custom layout code taken | `mail.layout.code_taken` | This code is already used by another layout. | Ten kod jest już używany przez inny układ. | `code` |
| Custom layout code format | `mail.layout.code_format` | Use lowercase letters, digits and dashes only. | Używaj tylko małych liter, cyfr i myślników. | `code` |
`:message` is the parser/renderer/safety-checker text from postcard, passed through as plain text. It names the line when the parser reports one.
---
## Focal Points
- **Lists** (templates, layouts, partials): the code/name column with its row-state badges.
- **Template, layout and partial forms:** the body editor, with the preview directly under it.
- **Branding page:** the live preview.
- **Mail images page:** the image tile grid.
- **Image picker:** the tile grid; the primary button stays disabled until a tile is chosen.
---
## Component Contracts
Field names in the YAML below are recommendations (`body`, `content_html`, …). The planner may rename them, but the rendering, ordering and copy rules are fixed. New YAML keys are recommendations too: `language`, `mailImages`, and `mode` on `mailpreview` (`mode` is an existing key). Each new key goes into `formFieldKeys` (or type-specific options), the swag annotations, `admin/openapi/admin.json`, `schema.d.ts`, the cabana README and the docs.
### C1. `codeeditor` field (D-09) — `CodeEditorField.vue`
**YAML.**
- `type: codeeditor`;
- `language: html | css | text` (required; anything else is a boot error naming controller, field and value);
- optional `size` (`tiny|small|large|huge|giant`, heights in Spacing);
- optional `mailImages: true` (C4).
The value is a string. It is part of the save body and sent unchanged until edited, like `richeditor`.
**Loading.**
- CodeMirror is imported lazily from `codeeditor/editor.ts`.
- While it loads, the control shows `<div data-codeeditor-loading aria-busy="true" class="rounded-control bg-skel" :style="{height}">`, copied from the richeditor loading box, at the size's height.
- If the import fails, it shows the note `codeeditor.load_failed` (13px muted, `Info` 14px, `gap-2`), then a plain `<textarea>` with `font-mono text-[13px]`, `spellcheck="false"`, the same height, `controlClass(invalid)` and `resize-y`. The form keeps working.
**Markup when ready.**
- The wrapper is `rounded-control border border-border-strong bg-surface overflow-hidden`. It gets `focus-within:border-primary focus-within:shadow-[0_0_0_3px_var(--c-ring)]`, the same focus look as inputs. When invalid it gets `border-danger`, the same as `controlClass(true)`.
- Inside the wrapper:
1. When `mailImages` is set, a toolbar `flex flex-wrap items-center gap-1 border-b border-border bg-subtle p-1` (copied from RichEditorField). It holds one text button with `ImagePlus` 16px and `mail.picker.insert_button`, using the richeditor "source" button classes `inline-flex h-8 items-center gap-1.5 rounded-pager px-2 text-[13px] text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text`. With no toolbar buttons, the toolbar is not rendered.
2. The CodeMirror `EditorView` at the size's height (scrolling inside):
- `font-mono` 13px with 1.5 line height and `px-3.5 py-2.5` content padding (copied from inputs);
- line-number gutter;
- bracket matching;
- the `language` grammar (`html` → `@codemirror/lang-html`, `css` → `@codemirror/lang-css`, `text` → no grammar);
- the Go-template action decorator;
- history (undo/redo);
- the theme from the Color section.
Line wrapping is off for `html`/`css` and on for `text`.
**Accessibility and keys.**
- The content element gets `id = controlId`, `aria-labelledby` set to the field label's id, `aria-describedby` set to describedBy, `aria-invalid` and `aria-multiline="true"`.
- **Tab is not bound to indent.** Tab and Shift+Tab move focus out of the editor (WCAG 2.1.2). Indent and outdent stay on the CodeMirror defaults Mod+] and Mod+[.
- The form hotkeys keep working inside the editor. The form's hotkey filter already ignores plain keys (Esc, /, ?) in `[contenteditable]`, so Esc does not trigger Cancel there. Mod+S and Mod+Enter still save, so CodeMirror must not bind them.
**Read-only rendering** (PreviewField / `readOnly`): `<pre class="max-h-[480px] overflow-auto rounded-control border border-border bg-subtle px-3.5 py-2.5 font-mono text-[13px] whitespace-pre-wrap">` with the text, or `list.empty_value` when empty. It loads no CodeMirror.
**Dirty state:** each document change emits `update:modelValue` with the full string. The existing snapshot comparison marks the form dirty; there is no extra rule.
### C2. `colorpicker` field — `ColorPickerField.vue`
**YAML:** `type: colorpicker`. Winter's `availableColors` is not supported; as an unknown key it is a boot error. The value is a `#rrggbb` string (lower-case on save) or empty. The server validates with `colorpicker.invalid`; the client does no validation beyond the native control.
**Markup:** `flex items-center gap-2`.
1. A native `<input type="color">` swatch:
- `size-input shrink-0 cursor-pointer rounded-control border border-border-strong bg-surface p-1`;
- `aria-label` = `colorpicker.pick` with `:field` = the label;
- value = the field value, or `#ffffff` when empty.
Changing it sets the text value.
2. A text input:
- `controlClass(invalid)` plus `font-mono text-[13px]`, `maxlength="7"`, `spellcheck="false"`, `placeholder` = `colorpicker.not_set`;
- `id = controlId` (the label's `for`), `aria-describedby`, `aria-invalid`.
Typing a valid `#rrggbb` updates the swatch. An invalid or partial value leaves the swatch at its last valid value.
Works on settings forms (it must not be in the settings refusal list in `modules/cabana/settings.go`).
### C3. Template body: MarkdownField with the image button
- `mailImages: true` on a `markdown` field adds, above the textarea, the same text button as C1 (`ImagePlus` 16px, `mail.picker.insert_button`). It sits in a `flex items-center gap-2` row next to where the existing "Preview" toggle sits.
- **With `mailImages` set, MarkdownField hides its own "Preview" toggle.** That toggle renders goldmark without the layout or variables, and on a mail body it would mislead. The mail body is previewed by `mailpreview` (C10).
- Without `mailImages` the field is unchanged.
### C4. Image insertion (D-19, D-20) — `MailImagePicker.vue` + `insertImage.ts`
**Trigger:** the "Insert image" button of C1/C3 opens the picker in **insert mode**. The `mailimage` field (C11) opens it in **choose mode**.
**Dialog shell** (copied from RelationPickerModal):
- Reka `DialogRoot`/`DialogPortal`;
- overlay `fixed inset-0 z-40 bg-overlay`;
- content `fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100vh-32px)] w-[calc(100%-32px)] max-w-[560px] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 rounded-modal bg-surface p-6 text-text shadow-pop`;
- title `text-[20px] font-bold tracking-[-0.01em]`;
- close icon button `flex size-[34px] shrink-0 items-center justify-center rounded-tab bg-subtle text-muted hover:bg-hover hover:text-text` with `X` 16px.
**Body, top to bottom:**
1. **Search:** the RelationPickerModal search input (`h-input w-full rounded-control border border-border-strong bg-surface pr-3.5 pl-10`, `Search` icon). It filters by filename and caption title on the server, with a 300ms debounce.
2. **Upload row** (only when the library answers `canUpload: true`): `Button variant="outline" size="sm"` with `Upload` 16px and `mail.picker.upload`, backed by a hidden `<input type="file" accept="image/jpeg,image/png,image/gif,image/webp">`.
- The upload goes straight into the library; no form Save is needed.
- While it runs, the button shows `mail.picker.uploading` and is disabled.
- On success the new image is first in the grid and selected.
- A 422 shows the server message as a 13px danger line under the row (`role="alert"`).
3. **Grid:** `grid grid-cols-4 gap-2 overflow-y-auto min-h-0`, `role="radiogroup"` labelled by the dialog title.
- Each tile is a `role="radio"` button: `flex flex-col gap-1 rounded-inner border p-1 text-left transition-colors duration-150 ease-out`. Not chosen: `border-border hover:bg-hover`. Chosen: `border-[#e0b020] bg-sel` (copied).
- Inside: an `aspect-square w-full rounded-pager bg-subtle object-cover` thumbnail (`alt` = caption title or filename), then the filename `truncate text-[13px] text-muted`. A chosen tile also shows a `Check` 14px in a `size-5 rounded-full bg-primary text-on-primary` badge at the thumbnail's top-right.
- Keys: arrow keys move within the group, Space/Enter chooses.
- Paging is "load more on scroll", 40 per page.
4. **Alt text** (insert mode only, shown once an image is chosen): a text input labelled `mail.picker.alt` with comment `mail.picker.alt_comment`, prefilled with the image's caption title.
5. **Footer:** `flex justify-end gap-2.5`, with `Button ghost` `form.cancel` and `Button primary` (`mail.picker.insert` or `mail.picker.use`). The primary is disabled until an image is chosen.
**States:**
- Loading: three rows of `aspect-square rounded-inner bg-skel` tiles.
- Empty library: `mail.picker.empty_title` (14/600), then `mail.picker.empty_upload` or `mail.picker.empty_pick_only` (13px muted), centred in the grid area.
- No search results: `mail.picker.no_results`.
- Load failed: `mail.picker.load_failed` (13px danger, `role="alert"`).
**Insertion** (`insertImage.ts`, pure function `(language, url, alt) → string`). `url` is the attachment's **absolute public URL** from the server (D-19); the SPA never builds it.
| Target | Inserted text |
|--------|---------------|
| markdown | `![alt](url)`, with `\`, `[` and `]` in alt escaped by a backslash |
| codeeditor `html` | `<img src="url" alt="alt">`, with `&`, `"`, `<` and `>` in alt and url entity-escaped |
| codeeditor `css` | `url("url")` |
| codeeditor `text` | `url` |
The text replaces the current selection (the textarea `selectionStart/End`, or the CodeMirror main selection). If the editor never had focus, it goes at the end. Focus then returns to the editor with the caret after the inserted text. The form becomes dirty through the normal `update:modelValue`.
**Permission (D-03, planner's call):**
- The library's list endpoint answers holders of either mail permission.
- `canUpload` is true only for holders of `system.manage_mail_layouts` (recommended).
- The UI renders both states from the flag and never infers it.
### C5. Layout form (D-08, D-09)
`config_form.yaml`:
- `name: mail.layout.name`;
- `recordActions: [reset]` (C8);
- create redirects to the new layout's update form; Save and close returns to the list;
- messages `create: mail.layout.new`, `update: mail.layout.name`, `saved: messages.form.saved`, `deleteConfirm: mail.layout.delete_confirm`, `deleted: messages.form.deleted`.
Record title: the layout name. Header subtitle: `mail.layout.name`.
| Field | Type | Span | Tab | Context | Notes |
|-------|------|------|-----|---------|-------|
| `_info` | partial `_info.htm` | full | `mail.layout.tab_html` | update | C7 |
| `name` | text | left | `mail.layout.tab_html` | all | Required. On a file layout it starts from the file header's `name` |
| `code` | text, `preset: { type: slug, field: name }` | right | `mail.layout.tab_html` | create | Required. Comment `mail.layout.code_comment`. Shown read-only in `_info` on update |
| `content_html` | codeeditor `language: html`, `size: giant`, `mailImages: true` | full | `mail.layout.tab_html` | all | Label `mail.layout.html` + comment |
| `_preview_html` | mailpreview `mode: layout` | full | `mail.layout.tab_html` | all | Starts on the HTML view |
| `content_text` | codeeditor `language: text`, `size: huge` | full | `mail.layout.tab_text` | all | Label `mail.layout.text` + comment |
| `_preview_text` | mailpreview `mode: layout` | full | `mail.layout.tab_text` | all | Starts on the Plain text view |
| `content_css` | codeeditor `language: css`, `size: huge`, `mailImages: true` | full | `mail.layout.tab_css` | all | Label `mail.layout.css` + comment |
| `_preview_css` | mailpreview `mode: layout` | full | `mail.layout.tab_css` | all | Starts on the HTML view |
| `_file_version` | partial `_file_version.htm` | full | `mail.tab_file_version` | update | C7. Its tab therefore does not exist on create |
- Tab order is fixed: HTML, Plain text, CSS, File version.
- Each tab ends with its own preview, so the preview is always under the editor being typed in. The three `mailpreview` fields are valueless and render the same draft.
- **Create** (custom layout): the HTML, text and CSS fields are prefilled with postcard's built-in default layout content (server `default` values), so a new layout starts working.
- **Footer:**
- Delete (danger outline) only on custom layouts (`meta.deletable: true`).
- "Reset to file version" (outline, a record action) only on a file layout that has an override.
- A custom layout never offers Reset; a file layout never offers Delete.
Layouts list (`config_list.yaml`):
- `title: mail.layout.list_title`, `recordUrl` → update form, `toolbar.buttons: [create]` (label `mail.layout.new`), no checkboxes, no bulk actions;
- `noRecordsMessage: mail.layout.list_empty`, search prompt existing;
- row states `customized`, `outdated` (C9), with messages `rowStateCustomized: mail.state_customized` and `rowStateOutdated: mail.state_outdated`.
| Column | Type | Visible | Searchable / sortable | Notes |
|--------|------|---------|----------------------|-------|
| `name` | text | yes | searchable, sortable | First column; carries the row-state badges |
| `code` | text | yes | searchable, sortable | Layout alias, for example `golem15.user::mail.layouts.user`, or the custom code |
| `source` | text | yes | sortable | The plugin ID, or `mail.source_custom` |
| `customized_at` | datetime | invisible | sortable | Override `updated_at`; dash when not customized |
Default sort: postcard's default layout first, then `name` ascending.
### C6. Partial form (D-12)
`config_form.yaml`: `name: mail.partial.name`, `recordActions: [reset]`, no create, messages `update: mail.partial.name`, `saved: messages.form.saved`. Record title: the partial code (`<plugin>::mail.partials.<name>`).
| Field | Type | Span | Tab | Notes |
|-------|------|------|-----|-------|
| `_info` | partial `_info.htm` | full | `mail.partial.tab_html` | C7, including the usage snippet |
| `content_html` | codeeditor `language: html`, `size: huge`, `mailImages: true` | full | `mail.partial.tab_html` | Label `mail.partial.html` + comment |
| `_preview_html` | mailpreview `mode: partial` | full | `mail.partial.tab_html` | HTML view |
| `content_text` | codeeditor `language: text`, `size: large` | full | `mail.partial.tab_text` | Label `mail.partial.text` + comment |
| `_preview_text` | mailpreview `mode: partial` | full | `mail.partial.tab_text` | Plain text view |
| `_file_version` | partial `_file_version.htm` | full | `mail.tab_file_version` | C7 |
- Footer: no Delete (`meta.deletable: false`). "Reset to file version" appears when an override exists.
Partials list: `title: mail.partial.list_title`, `toolbar.buttons: []`, no checkboxes, `noRecordsMessage: mail.partial.list_empty`, row states as C5.
| Column | Type | Visible | Searchable / sortable |
|--------|------|---------|----------------------|
| `code` | text | yes | searchable, sortable |
| `source` | text | yes | sortable |
| `customized_at` | datetime | invisible | sortable |
### C3b. Template form (D-04, D-05, D-07)
`config_form.yaml`:
- `name: mail.template.name`;
- `recordActions: [reset]`;
- no create;
- messages `update: mail.template.name`, `saved: messages.form.saved`;
- Save and close returns to the list.
Record title (h1, breadcrumb, `:name`): the template code, for example `golem15.user::mail.activate-en` (truncated). Subtitle: `mail.template.name`.
| Field | Type | Span | Tab | Notes |
|-------|------|------|-----|-------|
| `_info` | partial `_info.htm` | full | `mail.template.tab_template` | C7: description, file, locale, status, variables, drift callout |
| `subject` | text | left | `mail.template.tab_template` | Required. Comment `mail.template.subject_comment` |
| `layout` | dropdown over every layout (file and custom, by name), no empty option | right | `mail.template.tab_template` | Required. Comment `mail.template.layout_comment` |
| `body` | markdown, `size: giant`, `mailImages: true` | full | `mail.template.tab_template` | Label `mail.template.body` + comment. C3 |
| `_preview` | mailpreview `mode: template` | full | `mail.template.tab_template` | Label `mail.preview.label`. C10 |
| `_file_version` | partial `_file_version.htm` | full | `mail.tab_file_version` | C7 |
- **Description is file-owned and never editable.** It shows only as the `_info` line, never as an input (D-04). Cabana has no `readOnly` key, and adding one is out of scope.
- A template without an override opens the same form filled from the file. The first Save creates the override, and the record then carries the `customized` state.
- Footer: Cancel, Save and close, Save (existing), and "Reset to file version" when customized. No Delete (`meta.deletable: false`).
Templates list (`config_list.yaml`):
- `title: mail.template.list_title`, `recordUrl` → update form, `toolbar.buttons: []`, no checkboxes, no bulk actions, `recordsPerPage: 20`;
- `noRecordsMessage: mail.template.list_empty`;
- row states and messages as C5.
| Column | Type | Visible | Searchable / sortable | Notes |
|--------|------|---------|----------------------|-------|
| `code` | text | yes | searchable, sortable | First column; carries the badges. The default sort is `code` ascending, so a base template and its locale variants sit together (D-05: separate rows, no grouping) |
| `subject` | text | yes | searchable | The effective subject (the override's, if any) |
| `description` | text | yes | searchable | From the file |
| `locale` | text | yes | sortable | The suffix (`en`), or `mail.template.locale_default` for a file without a suffix |
| `layout` | text | yes | — | The effective layout's name |
| `customized_at` | datetime | invisible | sortable | |
Filters (existing `scope` type with options, like the 19.6.1 `trashed` filter):
| Scope | Label | Options |
|-------|-------|---------|
| `locale` | `mail.template.locale` | `default` (`mail.template.locale_default`) plus one option per suffix found in the catalog, labelled with the suffix |
| `status` | `mail.filter_status` | `customized`, `not_customized`, `outdated` (labels `mail.filter_customized`, `mail.filter_not_customized`, `mail.filter_outdated`) |
### C7. Info and File version partials (server-rendered, `.summer-partial`)
Partials are html/template files in the cabana pseudo-plugin. They are rendered through the existing allowlist, so they contain no buttons and no forms. Markup uses only allowlisted tags and existing classes.
**`_info.htm`**, top of the first tab. In order:
1. Templates: `<p>` with `mail.info_description` (`:text` = the file description).
2. `<p class="text-muted">` with `mail.info_file`, where `:path` is in `<code class="font-mono">` (for example `views/mail/activate-en.htm`) and `:plugin` is the plugin ID. A custom layout shows `mail.info_custom_layout` instead, with its code in `<code>`.
3. `<p><strong>` with the status line: `mail.info_customized` or `mail.info_not_customized`.
4. Templates: `mail.info_variables` followed by the sample variable names, each `<code class="font-mono">`, separated by ", ". When no samples are declared, `mail.info_no_samples`.
5. Partials: `mail.info_partial_usage` followed by `<code class="font-mono">` with the exact call for this partial, in the syntax the planner picks (D-12).
6. When the record is `outdated` (D-07): `<div class="summer-callout summer-callout--warning" role="status">` with `<p class="summer-callout__title">mail.drift_title</p><p class="summer-callout__text">mail.drift_text</p>`.
Paragraphs are spaced by the existing `.summer-partial` rules.
**`_file_version.htm`** (update only):
- **Customized and different from the file:**
1. `<p><strong>mail.file_changes</strong></p>` and `<p class="text-muted">mail.file_legend</p>`.
2. A unified line diff (3 lines of context), override vs current file, of the serialized format (INI header + sections, the same format the DB stores): `<pre class="summer-diff">`, one `<span>` per line:
- `summer-diff__add` with the text prefixed `+ `;
- `summer-diff__del` prefixed `− `;
- `summer-diff__ctx` prefixed two spaces.
The prefixes are text, so the meaning never depends on colour (WCAG 1.4.1).
3. `<p><strong>mail.file_content</strong></p>` and `<pre class="summer-diff">` with the whole file, every line `summer-diff__ctx`.
- **Customized and identical:** `<p>mail.file_identical</p>`, then the file content block.
- **Not customized:** `<p class="text-muted">mail.file_not_customized</p>`, then the file content block.
- **Fallback:** if the planner ships no diff in this phase, the "Changes" block is omitted and the file content block stays. The rest of this contract is unchanged.
**New CSS kit in `main.css`** (`@layer components`, existing variables only):
```css
.summer-diff { margin: 0; max-height: 480px; overflow: auto; padding: 12px 16px; border: 1px solid var(--c-border); border-radius: 12px; background: var(--c-subtle); font-family: var(--font-mono); font-size: 13px; line-height: 1.5; white-space: pre; color: var(--c-text); }
.summer-diff__add, .summer-diff__del, .summer-diff__ctx { display: block; }
.summer-diff__add { background: var(--c-ok-bg); color: var(--c-ok-text); }
.summer-diff__del { background: var(--c-danger-soft); color: var(--c-danger); }
.summer-diff__ctx { color: var(--c-text); }
```
The partial must reload after a save and after a record action (C8), so the status, drift and diff are never stale.
### C8. Additions to FormView and SettingsFormView
1. **Footer record actions on the update form.** When the record response carries `meta.actions` (the same data PreviewView reads), FormView renders the existing `RecordActions` component in the footer's left group, after Delete (`flex items-center gap-2.5`).
- Buttons are outline. The confirm uses the action's `danger` flag: `reset` is `Danger: true`, so its confirm button is the danger variant with the action label "Reset to file version".
- While an action runs, every footer button is disabled (the existing `busy` emit).
- On success: the `mail.reset_done` toast, then the record reloads in place. Values are replaced by the file version, the form is clean (snapshot reset), and partial fields reload.
- 409: the existing stale toast and a reload. 404: the existing load-failed state. 403: a danger toast with the server message.
- On create there are no actions.
- Generic rule: other controllers get the same footer only if they declare `recordActions` and have no preview screen. Controllers with a preview keep their actions on the preview, unchanged.
2. **`meta.deletable`.** FormView hides Delete when the record response has `meta.deletable === false`. When the key is absent, behaviour is unchanged (`true`), so existing controllers see no difference. Templates, partials and file layouts send `false`; custom layouts send `true`.
3. **Partial fields reload** after every successful save and record action. The partial field's `PartialHost` gets a `reloadKey` bumped by FormView, the same mechanism as PreviewView's `hintKey`.
4. **SettingsFormView** adds `provide(FORM_VALUES, computed(() => values.value))` and `provide(FORM_LOCALE, …)`, so `mailpreview` on the Branding page reads the draft. It provides no `FORM_PATCH` or session. The cabana settings refusal list (`settings.go`) must allow `colorpicker`, `mailimage` and `mailpreview`.
### C9. Row states `customized` and `outdated`
- `pact` gains two **additive** constants: `RowStateCustomized RowState = "customized"` and `RowStateOutdated RowState = "outdated"`. The existing three are unchanged and plugins are not affected. Names are a recommendation; the planner may rename, but the rendering is fixed.
- `RowStateBadges` order becomes `deleted, negative, disabled, customized, outdated`. New styles:
- `customized`: `border border-border-strong text-text`;
- `outdated`: `bg-sel text-text`.
Same pill markup (`h-6 rounded-pill px-2.5 text-[12px] font-semibold`).
- `DataTable`'s `stateTextClass` is unchanged for the new states: the row title stays `text-text` and is not struck through.
- `ListView` reads two more labels: `messages.rowStateCustomized` and `messages.rowStateOutdated`.
- A drifted override carries both badges ("Customized", then "File changed"). A non-customized row carries none.
### C10. `mailpreview` field (D-15, D-16, D-17) — `MailPreviewField.vue`
**YAML:** `type: mailpreview`, `mode: template | layout | partial | branding`. It is valueless (never in the save body) and group-labelled. It renders on create and update.
**Data flow.**
- It reads the whole draft from `FORM_VALUES` and POSTs it with the mode and the record id (0 on create) to one cabana preview route.
- The server renders through postcard with sample vars (D-15). Layouts and partials are rendered around postcard's sample content; Branding uses a built-in sample message with the draft colours and logo.
- The answer is `{ subject, html, text }` or a 422 with `:message`.
- Requests re-run **500ms after the last draft change** and on mount, with a sequence guard so only the latest answer is applied (the MarkdownField pattern). Hidden previews (on an inactive tab) do not request; they render when their tab becomes active.
**Markup.**
- Outer: `flex flex-col gap-2`.
- **Frame:** `rounded-inner border border-border overflow-hidden`.
1. **Toolbar:** `flex flex-wrap items-center gap-2 border-b border-border bg-subtle p-1`.
- The format switch `role="tablist"` uses FormTabs' class strings at the same 34px height: track `inline-flex gap-1 rounded-inner border border-border bg-subtle p-1`, active pill `bg-surface text-text shadow-tab`, inactive `text-muted hover:text-text`. Tabs: `mail.preview.html`, `mail.preview.text`.
- The status `ml-auto px-2 text-[13px] text-muted`, `role="status"`: `mail.preview.rendering` while a request runs, otherwise empty.
2. **Subject row:** `flex gap-2 border-b border-border px-4 py-3` with `<span class="font-semibold">mail.preview.subject</span>` and the rendered subject as text (`min-w-0 break-words`). It is hidden in `branding` mode.
3. **HTML view:** `<iframe sandbox="" referrerpolicy="no-referrer" :srcdoc="html" :title="t('mail.preview.frame_title')" class="block h-[640px] w-full border-0 bg-white">`.
- `sandbox` is empty: no scripts, no same-origin, no forms, no popups, no top navigation.
- `srcdoc` is the server's rendered HTML, already passed through postcard's safety checks. The SPA never builds or modifies it. Admin CSP `frame-src 'none'` does not block a srcdoc frame (see the `boardwalk.go` comment), and no CSP change is needed.
- Images load from their absolute public URLs.
4. **Plain-text view:** `<pre class="m-0 max-h-[640px] overflow-auto bg-surface px-4 py-3 font-mono text-[13px] whitespace-pre-wrap">` with the text, as text.
- **Notes under the frame** (`text-[13px] text-muted`):
- `mail.preview.sample_note` (template mode), or `mail.preview.sample_content_note` (other modes);
- the field `comment` if declared (Branding).
- **Error line** (`text-[13px] text-danger`, `role="alert"`), between the toolbar and the frame content:
- `mail.preview.failed` with `:message`, when a previous render exists (that render stays visible);
- `mail.preview.failed_first` when none exists (the frame shows the empty state);
- `mail.preview.unavailable` on a network error or 5xx.
- **Empty draft:** when the content field is blank, the frame area shows `mail.preview.empty` centred (`px-4 py-3 text-[13px] text-muted`) instead of the iframe, and no request is sent.
- **Test send** (template, layout and partial modes; not branding): a row `flex flex-wrap items-center gap-2`.
- `Button variant="outline" size="sm"` with `Send` 16px and `mail.preview.test_send`; busy label `mail.preview.test_sending`.
- Then the hint `mail.preview.test_hint` (13px muted), with `:email` = the signed-in admin's email from the session.
- It POSTs the same draft payload to the test-send route. The server sends only to the signed-in admin's address (D-17), with no recipient in the request.
- Results:
- 2xx: success toast `mail.preview.test_sent`.
- 429: inline 13px danger line `mail.preview.test_limited`, with `:seconds` from `Retry-After`. The button is disabled until that time passes.
- 422: inline danger line `mail.preview.test_invalid` with `:message`.
- 5xx or driver error: danger toast `mail.preview.test_failed`.
- 403: danger toast with the server message.
- No confirm dialog: the recipient is always yourself, and the hint names the address.
### C11. `mailimage` field (Branding logo) — `MailImageField.vue`
**YAML:** `type: mailimage`. The value is the library image's id (number) or `null`; it is in the save body. It is allowed on settings forms. The server resolves it to `{{ .logoUrl }}` (the absolute public URL) at render time.
**Markup:** `flex items-center gap-4`.
1. A thumbnail box `size-[96px] shrink-0 overflow-hidden rounded-inner border border-border bg-subtle` showing the image (`object-contain`, alt = caption title). When empty it shows the `Image` icon (24px muted, centred).
2. A `flex flex-col gap-2` column:
- the filename (14px), or `mailimage.empty` (13px muted);
- a row `flex gap-2` with:
- `Button outline sm` `mailimage.choose` / `mailimage.change`, which opens C4 in choose mode;
- when set, `Button ghost sm` `mailimage.remove`, which sets `null`. There is no confirm; Save confirms.
3. If the stored id no longer resolves (the image was removed from the library), a `text-[13px] text-danger` line shows `mailimage.missing`, and the thumbnail box shows the empty icon.
The group is labelled by the field label (`groupLabelledTypes`).
### C12. Branding page (D-10, D-11) — settings model
- A cabana settings entry `mail_brand`: category `settings.category_mail`, label `mail.brand.menu_label`, icon `palette`, permission `system.manage_mail_layouts`, order 230.
- Rendered by `SettingsFormView`, with no tabs and one card.
- The first open is prefilled with the Winter defaults from the copy table. Saving stores the record; from then on its compiled CSS replaces `mail.brandCss` (D-11), as the preview comment says.
| Row | Left | Right |
|-----|------|-------|
| 1 | `logo` (mailimage, span full) | — |
| 2 | `body_bg` | `content_bg` |
| 3 | `header_color` | `footer_color` |
| 4 | `heading_color` | `text_color` |
| 5 | `link_color` | `button_text_color` |
| 6 | `button_primary_bg` | `button_positive_bg` |
| 7 | `button_negative_bg` | `panel_bg` |
| 8 | `table_border_color` | — |
| 9 | `_preview` (mailpreview `mode: branding`, span full, label `mail.preview.label`, comment `mail.brand.preview_comment`) | — |
- The colours are `colorpicker` (C2), with no comments; their labels are self-describing.
- The footer is unchanged: Cancel, Save and close, Save.
- The logo picker's upload follows C4 `canUpload`. Every admin who can open this page holds `system.manage_mail_layouts`, so they can upload a logo from here.
### C13. Mail images library (D-18) — singleton form
- A cabana `pact.AdminSingleton` controller, `name: mail.images.menu_label`. It is linked from Settings (category `settings.category_mail`, icon `image`, permission `system.manage_mail_layouts`, order 240) and rendered by `FormView` in singleton mode. There is no list and no Delete.
- One field, `images`: `fileupload`, `mode: image`, attachMany, public disk, `useCaption: true`, `fileTypes: jpg,jpeg,png,gif,webp`, `maxFilesize` set by the planner. Label `mail.images.field`, comment `mail.images.field_comment`.
- It renders the existing tile grid. The caption title is the alt text the picker pre-fills.
- Uploads and removals follow the existing deferred binding: they take effect on Save. The picker's own upload (C4) is immediate and shows up here on the next load.
- Removing an image follows the existing fileupload removal UI. The field comment carries the warning; there is no extra dialog.
### C14. Settings placement (D-02)
Five `pact.SettingsItem` entries in category `backend::lang.settings.category_mail`, rendered by the existing `SettingsIndexView` tiles with no SPA change:
| Code | Label / description | Icon | Controller or settings | Permission | Order |
|------|---------------------|------|------------------------|------------|-------|
| `mail_templates` | `mail.template.menu_label` / `_description` | `mail` | templates controller | `system.manage_mail_templates` | 200 |
| `mail_layouts` | `mail.layout.menu_label` / `_description` | `layout-grid` | layouts controller | `system.manage_mail_layouts` | 210 |
| `mail_partials` | `mail.partial.menu_label` / `_description` | `puzzle` | partials controller | `system.manage_mail_layouts` | 220 |
| `mail_brand` | `mail.brand.menu_label` / `_description` | `palette` | settings model | `system.manage_mail_layouts` | 230 |
| `mail_images` | `mail.images.menu_label` / `_description` | `image` | singleton controller | `system.manage_mail_layouts` | 240 |
- The order within the group is fixed. A tile appears only when the principal may open it (the existing filter), so a holder of only `manage_mail_templates` sees one tile.
- There is no top-level nav item and no flyout entry.
### C15. Where each refusal or failure surfaces
| Rule | Path | Status / error | Renders as | Copy |
|------|------|----------------|-----------|------|
| D-13 parse / render / unsafe / subject line / layout / partial / {{ .Content }} | save (template, layout, partial) | 422, `Details.<field>` | Field line under the named field, count banner, the first invalid field's tab activated with its count badge (existing) | `mail.invalid_*` |
| Custom layout code taken / format | create | 422, `Details.code` | Field line | `mail.layout.code_taken`, `mail.layout.code_format` |
| Invalid colour | Branding save | 422, `Details.<colour>` | Field line | `colorpicker.invalid` |
| D-08 delete a layout still assigned | form delete | 403, `Message` | Danger toast; the record stays | `mail.layout.delete_in_use` with `:name`, `:count` |
| Delete or reset where not offered (stale tab) | delete / record action | 409 | Existing stale toast + reload | existing |
| D-03 missing permission | any route | 403 | Existing forbidden banner (save) or danger toast (actions); tiles hidden beforehand | existing `form.forbidden` |
| Preview render failure | preview route | 422, `Details.message` (or `Message`) | Inline danger line in the preview (C10) | `mail.preview.failed[_first]` |
| Preview unavailable | preview route | network / 5xx | Inline danger line | `mail.preview.unavailable` |
| D-17 rate limit | test send | 429 + `Retry-After` | Inline danger line, button disabled until the time passes | `mail.preview.test_limited` |
| D-17 draft invalid | test send | 422 | Inline danger line | `mail.preview.test_invalid` |
| D-17 driver failure | test send | 5xx | Danger toast | `mail.preview.test_failed` |
| Picker upload refused (type, size) | picker upload | 422 | Danger line under the upload row | server message as is |
| Library load failed | picker list | network / 5xx | Danger line in the dialog | `mail.picker.load_failed` |
Note: the delete refusal must be a `ForbiddenError` with `Message` set. The SPA delete toast reads `error.message`, and a `ValidationError` would show "Validation failed" (the same finding as 19.6.1 C8).
---
## UI Considerations
Applicable state considerations resolved: 48 rows (42 covered, 6 backstop, 0 unresolved; probe-checked 2026-11-06 against 80 applicable element×category pairs, the remaining pairs covered by the shared "All lists, forms" rows). The elements are the three lists, the three forms, the Branding page, the Mail images page, the settings tiles, `codeeditor`, `colorpicker`, `mailimage`, `mailpreview`, the picker, the info/file-version partials, the footer record actions and the refusal surfaces. Copy for empty and error rows lives in the Copywriting Contract.
| Category | Element(s) | Status | Resolution / Reason |
|----------|------------|--------|---------------------|
| empty | Templates list | ✅ covered | A filtered empty view shows `mail.template.list_empty`; an empty search shows the existing `messages.list.empty_search`. Unfiltered it is never empty, because postcard registers built-in mail |
| empty | Layouts list | ✅ covered | `mail.layout.list_empty`; postcard's default layout means the unfiltered list is never empty |
| empty | Partials list | ✅ covered | `mail.partial.list_empty`; the built-in button/panel/table partials mean the unfiltered list is never empty |
| loading | All lists, forms, Branding, Mail images | ✅ covered | Existing skeletons and disabled footers in ListView/FormView/SettingsFormView; no new loading UI |
| error | All lists, forms, Branding, Mail images | ✅ covered | Existing `list.load_failed` / `form.load_failed` alerts |
| populated | Templates list | ✅ covered | Code, subject, description, locale, layout; base and locale variants adjacent by code sort; badges after the code |
| partial | Templates list (mixed states) | ✅ covered | Not-customized rows have no badge; customized rows show "Customized"; drifted rows show "Customized" + "File changed", in fixed order, as text (WCAG 1.4.1) |
| zero-one-many | Locale filter | ✅ covered | The options are `Default` plus one per suffix in the catalog. An app with no locale variants shows only `Default` |
| overflow | All lists | ✅ covered | Existing pagination (20 per page) and horizontal table scroll; long codes truncate in the first column, with the badges kept (`shrink-0`) |
| long-text | Template code in the form header and breadcrumb | ✅ covered | The h1 truncates (existing `truncate`); the full code also appears in `_info` as `code`, which wraps (`overflow-wrap:anywhere` inherited from `.summer-partial`) |
| populated | Template form, not customized | ✅ covered | Filled from the file; `_info` says "Not customized…"; no Reset in the footer; Save creates the override |
| populated | Template form, customized | ✅ covered | `_info` says "Customized…"; footer shows "Reset to file version"; File version tab shows the diff |
| partial | Template form, drifted (D-07) | ✅ covered | The warning callout at the top of the first tab names both ways out (save again or reset). File version tab shows the diff against the new file; saving again clears the badge after the partial reloads |
| empty | Template body blank | ✅ covered | Preview shows `mail.preview.empty` and sends no request; Save returns the `required` / `mail.invalid_render` field line |
| error | Save invalid (D-13) | ✅ covered | 422 field lines under subject/body/editor fields; the count banner; the first invalid field's tab activated (existing). The draft is kept |
| error | Reset refused or stale | ✅ covered | 409 existing stale toast + reload; 403 danger toast |
| populated | Reset success | 🧪 backstop | SPA unit test: FormView with `meta.actions: [reset]` renders the outline button in the footer; confirm is the danger variant; after a 2xx the values are replaced, the form is clean and the partial field's `reloadKey` increments |
| partial | FormView without `meta.deletable` | 🧪 backstop | SPA unit test: absent key → Delete shown (unchanged behaviour); `false` → hidden; `true` → shown |
| populated | Layout form, file layout | ✅ covered | HTML / Plain text / CSS / File version tabs; Reset when customized; no Delete |
| populated | Layout form, custom layout create | ✅ covered | Name + code (slug preset); editors prefilled with the default layout; no File version tab; Save redirects to update |
| error | Delete custom layout in use (D-08) | ✅ covered | 403 danger toast `mail.layout.delete_in_use` with name and count (CLDR plural); the record stays |
| populated | Partial form | ✅ covered | HTML / Plain text / File version; `_info` shows the usage snippet; Reset when customized |
| loading | `codeeditor` lazy load | ✅ covered | A `bg-skel` box at the size's height, `aria-busy="true"` |
| error | `codeeditor` load failure | ✅ covered | `codeeditor.load_failed` note plus a monospace textarea; the value still saves |
| populated | `codeeditor` keyboard | 🧪 backstop | Browser test (`test:browser`): Tab from inside the editor moves focus to the next control (no trap); Mod+S inside the editor triggers the form save; Esc does not trigger Cancel |
| long-text | `codeeditor` long lines / long files | ✅ covered | HTML/CSS scroll horizontally inside the editor (no wrap); text wraps; the editor height is fixed by `size` and scrolls vertically |
| populated | Image insertion | 🧪 backstop | SPA unit test of `insertImage.ts`: markdown alt escaping of `]`; html attribute escaping of `"` and `<`; css `url("…")`; text bare URL; insertion replaces the selection and the caret lands after it |
| empty | Picker, empty library | ✅ covered | `mail.picker.empty_title` + `empty_upload` or `empty_pick_only`, chosen by `canUpload` |
| empty | Picker, no search result | ✅ covered | `mail.picker.no_results` with the query |
| error | Picker load / upload failure | ✅ covered | `mail.picker.load_failed`; the 422 upload message under the upload row |
| zero-one-many | Picker grid | ✅ covered | 1..n tiles in a 4-column grid; more than 40 loads on scroll; exactly one choosable (radiogroup); primary disabled until a choice |
| long-text | Picker filenames | ✅ covered | `truncate`; the full name is in the thumbnail's `alt` when there is no caption |
| populated | `mailpreview` HTML / Plain text | ✅ covered | Sandboxed srcdoc iframe at 640px on a white canvas; text in a mono `pre`; the switch keeps its choice while the draft changes |
| error | `mailpreview` render failure | 🧪 backstop | SPA unit test: after a good render, a 422 answer shows `mail.preview.failed` and keeps the previous `srcdoc`; a 422 before any good render shows `failed_first`; an out-of-order older answer is ignored (sequence guard) |
| populated | `mailpreview` sandbox | 🧪 backstop | SPA unit test: the iframe has `sandbox=""` (empty), `srcdoc` bound only to `data.html` from a 2xx, never to the draft; no `src` attribute |
| error | Test send failures | ✅ covered | 429 inline with seconds and the button disabled until the time passes; 422 inline; 5xx toast; 403 toast |
| populated | Test send success | ✅ covered | Toast `mail.preview.test_sent` with the admin's email |
| populated | Branding page first open | ✅ covered | Winter defaults prefilled; the preview comment explains the `mail.brandCss` precedence (D-11) |
| error | Branding invalid colour | ✅ covered | `colorpicker.invalid` field line; the swatch keeps its last valid value |
| partial | Branding logo removed from library | ✅ covered | `mailimage.missing` danger line and the empty thumbnail; Save with the missing id is refused by the server, or cleared (planner's choice), with the field line |
| populated | Layouts and partials lists | ✅ covered | Code/name column first with badges after it; file and custom layouts mixed, custom ones without a Customized badge (C9) |
| partial | Layouts and partials lists (mixed states) | ✅ covered | Same badge rules as the templates list: no badge, "Customized", or "Customized" + "File changed" (C9) |
| zero-one-many | Settings Mail section by permission | ✅ covered | Tiles are filtered per permission (C14): `manage_mail_templates` only → one tile; `manage_mail_layouts` only → four tiles; neither → no Mail category; superuser → five |
| loading | `mailpreview` request in flight | ✅ covered | `mail.preview.rendering` in the `role="status"` line while a request runs; the previous render stays visible (C10) |
| loading | Picker list | ✅ covered | Three rows of `aspect-square bg-skel` tiles (C4) |
| empty | Mail images page, no images | ✅ covered | The existing fileupload empty state of the singleton form; the field comment explains what the library is for (C13) |
| overflow | `mailpreview` long message | ✅ covered | The 640px iframe scrolls inside itself; the plain-text `pre` is capped at 480px and scrolls (Spacing Scale) |
| populated | Mail images page | ✅ covered | The existing image tile grid with captions; the comment warns about removing images still in use |
---
## Registry Safety
| Registry | Blocks Used | Safety Gate |
|----------|-------------|-------------|
| shadcn official | none (shadcn not used) | not applicable |
| third-party registries | none | not applicable |
| npm (new runtime deps, admin SPA) | `@codemirror/state` 6.7.6 (published 2026-09-22), `@codemirror/view` 6.43.15 (2026-10-19), `@codemirror/language` 6.13.1 (2026-10-07), `@codemirror/commands` 6.11.1 (2026-09-15), `@codemirror/lang-html` 6.4.12 (2026-08-04), `@codemirror/lang-css` 6.3.1 (2024-11-26). All MIT. Versions and dates were resolved on 2026-11-06 with `npm view <pkg> version` / `npm view <pkg> time`. All are past the project's 7-day cooldown. Transitive packages come from `@lezer/*`, `@codemirror/autocomplete` and `@codemirror/lang-javascript` (via lang-html), `style-mod`, `w3c-keyname` and `crelt` | Required before install: a STACK.md "Admin SPA (npm) additions" row and a decision note, exact pins in `admin/package.json`, the committed lockfile, `npm ci`. Do **not** use the `codemirror` meta package (`basicSetup` pulls in the search, lint and autocomplete UI). CodeMirror is lazily imported, so the main bundle is unchanged; the dist rebuild gate (`scripts/check-admin-dist.sh`) applies |
---
## Checker Sign-Off
- [x] Dimension 1 Copywriting: PASS
- [x] Dimension 2 Visuals: PASS
- [x] Dimension 3 Color: PASS
- [x] Dimension 4 Typography: PASS
- [x] Dimension 5 Spacing: PASS
- [x] Dimension 6 Registry Safety: PASS
- [x] Dimension 7 Inventory Provenance: PASS
**Approval:** approved 2026-11-06 (msd-ui-checker; flags on dimensions 1, 2, 4 and 5 applied)

View File

@@ -0,0 +1,189 @@
{
"schema_version": 1,
"batch_id": "261105-oc4",
"created_at": "2026-11-05T16:31:21.798Z",
"options": {},
"base_revision": "5aa846c5e85368b534e03a1d19f441bb34de193c",
"items": [
{
"quick_id": "261105-oc5",
"client_id": null,
"description": "2FA hardening (todos 2026-10-26-activate-by-code-second-factor.md and 2026-10-26-mfa-factor-changes-need-reauth.md): sm-user-plugin ActivateByCode runs CheckLoginGates after VerifyActivationCode (finding F-1); sm-mfa-plugin requires password confirmation on factor-changing routes (WR-05) and vue-demo-app security.vue asks for the password",
"status": "complete",
"depends_on": [],
"planned_files": [
"../sm-user-plugin/controllers/api_controller.go",
"../sm-user-plugin/controllers/login_gates.go",
"../sm-user-plugin/classes/login_gate.go",
"../sm-user-plugin/login_gate_test.go",
"../sm-user-plugin/README.md",
"../sm-mfa-plugin/go.work",
"../sm-mfa-plugin/go.work.sum",
"../sm-mfa-plugin/classes/config.go",
"../sm-mfa-plugin/config/config.yaml",
"../sm-mfa-plugin/controllers/management.go",
"../sm-mfa-plugin/controllers/webauthn.go",
"../sm-mfa-plugin/factor_password_test.go",
"../sm-mfa-plugin/login_paths_test.go",
"../sm-mfa-plugin/settings_test.go",
"../sm-mfa-plugin/tracer_smoke_test.go",
"../sm-mfa-plugin/webauthn_smoke_test.go",
"../sm-mfa-plugin/webauthn_test.go",
"../sm-mfa-plugin/php_bodies_test.go",
"../sm-mfa-plugin/challenge_smoke_test.go",
"../sm-mfa-plugin/recovery_test.go",
"../sm-mfa-plugin/email_test.go",
"../sm-mfa-plugin/gate_test.go",
"../sm-mfa-plugin/trusted_test.go",
"../sm-mfa-plugin/passwordless_smoke_test.go",
"../sm-mfa-plugin/README.md",
"../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/vue-demo-app/i18n/locales/en.json",
"../sm-demo-app/vue-demo-app/i18n/locales/pl.json",
"../sm-demo-app/vue-demo-app"
],
"directory": null,
"worktree": null,
"dispatched_worktree": null,
"dispatched_branch": null,
"dispatched_base": null,
"wave": 0,
"commit": "b179e6b",
"failure_reason": null
},
{
"quick_id": "261105-oc6",
"client_id": null,
"description": "Chatchannels pin policy (todo chatchannels-pin-policy.md): sm-chatchannels-plugin PinMessage/UnpinMessage require CanManageContext or an optional contracts.PinPolicy, and verify the message belongs to the channel conversation; sm-demo-app answers the policy from its moderator group",
"status": "complete",
"depends_on": [],
"planned_files": [
"../sm-chatchannels-plugin/contracts/pin_policy.go",
"../sm-chatchannels-plugin/contracts/access_resolver.go",
"../sm-chatchannels-plugin/classes/channel_service.go",
"../sm-chatchannels-plugin/api/pins.go",
"../sm-chatchannels-plugin/pins_test.go",
"../sm-chatchannels-plugin/testdata/PHP_METHOD_MAP.md",
"../sm-chatchannels-plugin/README.md",
"../sm-demo-app/plugins/demo/classes/chat_context.go",
"../sm-demo-app/plugins/demo/classes/chat_context_test.go",
"../sm-demo-app/plugins/demo/chat.go",
"../sm-demo-app/reset_test.go",
"../sm-demo-app/README.md",
"../sm-demo-app/plugins/golem15/chatchannels",
"../sm-demo-app/chat_test.go"
],
"directory": null,
"worktree": null,
"dispatched_worktree": null,
"dispatched_branch": null,
"dispatched_base": null,
"wave": 0,
"commit": "a042536",
"failure_reason": null
},
{
"quick_id": "261105-oc7",
"client_id": null,
"description": "Chatvideo hardening (todos chatvideo-live-membership.md, chatvideo-dm-only-host-hook.md, chatvideo-incoming-handle-leak.md): sm-chatvideo-plugin honours LiveMembership and removes banned participants from the LiveKit room, adds an optional CanStartCall host hook for a DM-only rule; vue-chatvideo-layer useIncomingCalls closes stale session handles",
"status": "complete",
"depends_on": [],
"planned_files": [
"../sm-chatvideo-plugin/classes/video_service.go",
"../sm-chatvideo-plugin/classes/authorizer.go",
"../sm-chatvideo-plugin/classes/livekit.go",
"../sm-chatvideo-plugin/classes/revoked_access.go",
"../sm-chatvideo-plugin/boot.go",
"../sm-chatvideo-plugin/live_membership_test.go",
"../sm-chatvideo-plugin/contracts/call_policy.go",
"../sm-chatvideo-plugin/contracts/call_policy_test.go",
"../sm-chatvideo-plugin/api/errors.go",
"../sm-chatvideo-plugin/lang/en/lang.yaml",
"../sm-chatvideo-plugin/lang/pl/lang.yaml",
"../sm-chatvideo-plugin/call_policy_test.go",
"../sm-chatvideo-plugin/README.md",
"../sm-chatvideo-plugin/plugin.yaml",
"../sm-demo-app/plugins/golem15/chatvideo",
"../sm-demo-app/plugins/demo/video.go",
"../sm-demo-app/plugins/demo/plugin.go",
"../sm-demo-app/video_test.go",
"../sm-demo-app/config/golem15.chatvideo.yaml",
"../sm-demo-app/README.md",
"../vue-chatvideo-layer/app/composables/useIncomingCalls.ts",
"../vue-chatvideo-layer/vitest.config.ts",
"../vue-chatvideo-layer/tests/useIncomingCalls.test.ts",
"../vue-chatvideo-layer/README.md"
],
"directory": null,
"worktree": null,
"dispatched_worktree": null,
"dispatched_branch": null,
"dispatched_base": null,
"wave": 1,
"commit": "f996156",
"failure_reason": null
},
{
"quick_id": "261105-oc8",
"client_id": null,
"description": "lagoon/attach StaticHandler and StaticHandlerPublic send the stored Content-Disposition and X-Content-Type-Options nosniff (todo 2026-10-28-attach-static-handler-disposition.md)",
"status": "complete",
"depends_on": [],
"planned_files": [
"modules/lagoon/attach/static.go",
"modules/lagoon/attach/static_test.go",
"modules/lagoon/attach/lifecycle_test.go",
"modules/lagoon/README.md",
"docs/services/storage.md",
"docs/database/attachments.md",
"modules/surf/uploads.go"
],
"directory": null,
"worktree": null,
"dispatched_worktree": null,
"dispatched_branch": null,
"dispatched_base": null,
"wave": 0,
"commit": "27c98bf",
"failure_reason": null
},
{
"quick_id": "261105-oc9",
"client_id": null,
"description": "sm-demo-app hardening (todos check-local-jwt-out-of-argv.md, demo-chat-members-read-only.md, demo-nginx-livekit-rtc-only.md, demo-seed-moderation-lists-once.md, demo-vapid-runbook-pipefail.md)",
"status": "complete",
"depends_on": [],
"planned_files": [
"../sm-demo-app/plugins/demo/chat_api.go",
"../sm-demo-app/plugins/demo/classes/members.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_friends.go",
"../sm-demo-app/plugins/demo/plugin.go",
"../sm-demo-app/plugins/demo/updates/updates.go",
"../sm-demo-app/go.mod",
"../sm-demo-app/chat_test.go",
"../sm-demo-app/chat_smoke_test.go",
"../sm-demo-app/seed_chat_test.go",
"../sm-demo-app/README.md",
"../sm-demo-app/scripts/check-moderation.sh",
"../sm-demo-app/scripts/check-local.sh",
"../sm-demo-app/deploy/nginx/demo.summercms.io.conf",
"../sm-demo-app/scripts/check-deploy.sh",
"../sm-demo-app/scripts/check-live.sh",
"../sm-demo-app/DEPLOY.md"
],
"directory": null,
"worktree": null,
"dispatched_worktree": null,
"dispatched_branch": null,
"dispatched_base": null,
"wave": 2,
"commit": "9595606",
"failure_reason": null
}
]
}

View File

@@ -0,0 +1,224 @@
---
phase: quick-261105-din
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/classes/mcp_backend_binding.go
- /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/classes/mcp_store.go
- /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/controllers/mcp_consent_api.go
- /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/mcp_consent_race_test.go
- /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/mcp_consent_smoke_test.go
- /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/README.md
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-REVIEW-DISPOSITION.md
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-SECURITY.md
autonomous: true
requirements:
- quick-261105-din
- CR-01
estimate:
tokens: 70000
raw_tokens: 70000
tasks: 3
confidence: low
must_haves:
truths:
- "A revoke (DELETE /oauth/authorizations/{client_id}) that lands while an MCP allow is in flight is never undone. The allow's code issue (MarkIssued + MarkConsented inside srv.IssueCode) and its consent write (TouchConsent or replaceMCPConsent) run in ONE transaction that first takes the user's exclusive grants lock (classes.LockUserGrants), the same lock revoke takes. A revoke therefore either precedes both writes, and the allow reads the post-revoke state, or follows both and deletes the issued code and the consent."
- "While another transaction holds the user's grants lock, POST /oauth/mcp/requests/{request} with decision allow does not answer and writes nothing. The pending row keeps its request_id with a NULL code_hash, and the consent row's updated_at is unchanged."
- "The silent re-grant path (a consent row already covers the request: consent_required false, TouchConsent) runs inside the same locked transaction. After allow then revoke, the pair has no consent row and no MCP code, the code from the allow does not exchange, and a fresh authorize shows consent_required true."
- "A consent write that fails rolls the code issue back. The answer is 500 server_error, the request stays pending (request_id set, code_hash NULL), the client's consented_at stays NULL, and once the failure is gone the same request id can be allowed and its code exchanged."
- "No change in summercms.go (framework). No exported plugin signature changes. The plugin only adds classes.WithinUserGrantsLock. The OIDC flow, revoke, cascade, code exchange and refresh code paths are untouched."
- "Both new tests failed on the code before the fix, for the reason CR-01 describes. go vet ./... and the full non-short plugin suite are green at the commit."
artifacts:
- path: /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/classes/mcp_backend_binding.go
provides: "exported WithinUserGrantsLock(ctx, db, userIDs, fn); unexported grantsTxKey and grantsTxFrom(ctx)"
- path: /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/classes/mcp_store.go
provides: "MCPStore.WithinTx runs fn on the grants-lock transaction carried in ctx instead of opening its own"
- path: /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/controllers/mcp_consent_api.go
provides: "MCPConsentDecide allow path: IssueCode + recordMCPConsent inside WithinUserGrantsLock; response written after commit"
- path: /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/mcp_consent_race_test.go
provides: "TestMCPConsentAllowSerializesWithRevoke, TestMCPConsentFailureRollsBackCode (testcontainers Postgres, skip under -short)"
key_links:
- from: "controllers/mcp_consent_api.go MCPConsentDecide"
to: "classes.WithinUserGrantsLock"
via: "allow path wraps srv.IssueCode(txCtx, ...) and recordMCPConsent(txCtx, tx, ...)"
pattern: "WithinUserGrantsLock"
- from: "wristband.Server.IssueCode (framework, unchanged)"
to: "classes.MCPStore.WithinTx"
via: "s.backend.WithinTx(ctx, ...) with the ctx WithinUserGrantsLock handed to fn (LazyMCPStore delegates to NewMCPStore(db).WithinTx)"
pattern: "grantsTxFrom"
---
<objective>
Fix 32.1 review finding CR-01 in sm-oauthserver-plugin: an in-flight MCP allow can recreate a consent the user just revoked.
Root cause (verified against current code): `MCPConsentDecide` (controllers/mcp_consent_api.go:227-241) calls `srv.IssueCode`, which commits `MarkIssued` + `MarkConsented` in its own `s.backend.WithinTx` transaction. Only after that does it write the consent through `recordMCPConsent` / `replaceMCPConsent` on the pool `db`, and neither step takes `classes.LockUserGrants`. Revoke (controllers/authorizations.go:138-165) takes that exclusive advisory lock, then deletes the consent and the pair's AuthCode, AccessToken, RefreshToken and MCPCode rows. A revoke that commits between IssueCode and the consent write is undone: `Take` finds no row, so `replaceMCPConsent` inserts one. The next authorize then answers `consent_required:false` and /connect auto-allows.
Chosen design (smallest correct; no framework change). wristband's `IssueCode` does all its DB work through `s.backend.WithinTx(ctx, fn)` with the caller's ctx, and on the MCP server that backend is the plugin's `classes.LazyMCPStore`, which delegates to `classes.MCPStore.WithinTx`. So the plugin can make IssueCode's writes join a caller-owned transaction through a plugin-private context key:
1. A new `classes.WithinUserGrantsLock(ctx, db, userIDs, fn)` opens a transaction. Its first statement takes `lockUserGrants` (exclusive, ascending ids). It then calls `fn(txCtx, tx)`, where txCtx carries tx under an unexported key.
2. `MCPStore.WithinTx` checks that key. When present, it runs fn on the carried tx without BEGIN or SAVEPOINT, and the owner commits or rolls back.
3. The allow path runs `srv.IssueCode(txCtx, ...)` and `recordMCPConsent(txCtx, tx, ...)` inside that function and writes the response only after the commit.
Rejected alternative: "consent + lock first in its own transaction, IssueCode after". A revoke landing between the two commits deletes the consent but cannot see the not-yet-issued code. That leaves a live code without a consent, which is worse.
Purpose: revoke is final again. The silent re-grant (`TouchConsent`) path and the ban/password/link cascade (`revokeUserGrants`, which takes the same exclusive lock) also stop racing the allow, because the whole allow unit now holds the lock.
Output: plugin code fix + two deterministic regression tests + plugin README, in one plugin commit; 32.1 disposition and security docs updated (the orchestrator commits the docs).
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/CLAUDE.md
@/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/STATE.md
@/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-REVIEW.md
Repository facts the executor must respect:
- The plugin repo is /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin (own git repo, branch master, HEAD d600e2c, latest tag v0.1.6). Its `go.work` has an UNRELATED uncommitted change (sm-user-plugin replace pointing at ../sm-demo-app/plugins/golem15/user). Do not stage, edit, revert or stash it. Tests run with it as it is. Never use `git stash` or `git add -A` / `git add .` in that repo.
- Framework code used (read-only, do not change): /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/wristband/consent.go `IssueCode` (:78-105) calls `lookupOwnedPending` (its own `s.backend.WithinTx`, :134-) and then one `s.backend.WithinTx` holding `MarkIssued` + `MarkConsented`, always passing the caller's ctx. No other DB access.
- Interfaces in play: `wristband.Backend.WithinTx(ctx, fn func(wristband.Tx) error) error`; plugin `classes.MCPStore{db *gorm.DB}`, `mcpTx{db *gorm.DB; backendUserID uint}`; `classes.LazyMCPStore.WithinTx` → `NewMCPStore(db).WithinTx(ctx, fn)` (classes/lazy.go:62-71); `classes.LockUserGrants(ctx, db, userIDs []uint)` / unexported `lockUserGrants` (classes/mcp_backend_binding.go:89-111); `classes.ErrDatabaseUnavailable` (classes/lazy.go:15); `classes.NewStore(db).WithinOIDCTx` + `oidcTx.TouchConsent` (classes/oidc_store.go:48-55, :364-367, an UPDATE by id).
- Lock takers today: revoke (exclusive), cascade `revokeUserGrants` (exclusive, classes/cascade.go:108), code exchange `ByCodeHashForUpdate` and refresh `ByTokenHashForUpdate` (shared, classes/mcp_store.go:195, :336). Each takes the advisory lock before any row lock it holds.
- Model fields: `models.MCPCode.RequestID *string`, `CodeHash *string`, table `golem15_oauthserver_mcp_codes`; `models.Consent` table `golem15_oauthserver_consents` (UNIQUE user_id, client_id; FK users ON DELETE CASCADE); `models.Client.ConsentedAt *time.Time`.
- Test harness (package oauthserver_test): `bootOAuth(t, envs...)` boots a fresh per-test database (testmain_test.go CREATE DATABASE per boot) and skips under -short; `env.gdb` is the app's own *gorm.DB (no pool cap); helpers `registerMCPClient`, `user`, `sessionToken`, `mcpPending` (mcp_consent_smoke_test.go:62), `mcpPendingPKCE` + `exchangeMCPCode` (mcp_backend_binding_test.go:22, :46), `consentShow` / `consentDecide` (mcp_consent_smoke_test.go:86, :99), `jsonCall` (pages_smoke_test.go:240), `browser()` (harness_test.go:212), `consentScopes` / `issuedCode` (mcp_consent_test.go:216, :226), `sha256Hex` (import_cutover_smoke_test.go:26). These helpers call t.Fatal, so never call them from a goroutine. MCP env for every MCP test: SUMMER_GOLEM15__OAUTHSERVER__MCP__ENABLED=true and SUMMER_GOLEM15__OAUTHSERVER__MCP__RESOURCE=+mcpVerifierResource (copy from TestMCPConsent).
- Docker: the tests need Docker. If the socket is denied, run the same command through `sudo -u golem bash -c '...'` (CLAUDE.md). A skipped test is not a pass: every verify below greps for the explicit `--- PASS:` lines.
</context>
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: Failing regression tests, then serialize the MCP allow with revoke under the user's grants lock</name>
<files>/media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/mcp_consent_race_test.go, /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/mcp_consent_smoke_test.go, /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/classes/mcp_backend_binding.go, /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/classes/mcp_store.go, /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/controllers/mcp_consent_api.go</files>
<read_first>
- sm-oauthserver-plugin/controllers/mcp_consent_api.go (whole file; allow path :185-242, recordMCPConsent :348-364, replaceMCPConsent :366-390)
- sm-oauthserver-plugin/controllers/authorizations.go :136-165 (revoke: lock, SELECT consent FOR UPDATE, deletes)
- sm-oauthserver-plugin/classes/mcp_backend_binding.go :71-111 and classes/mcp_store.go :34-65 (WithinTx, mcpTx)
- sm-oauthserver-plugin/mcp_consent_test.go (style, helpers) and mcp_consent_smoke_test.go :25-107 (mcpConsentView, helpers)
- summercms.go/modules/wristband/consent.go :78-160 (IssueCode, lookupOwnedPending)
</read_first>
<precondition>In /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin, `git status --short` shows only ` M go.work` and HEAD is d600e2c or a descendant that leaves controllers/mcp_consent_api.go and classes/mcp_store.go unchanged. `docker info` succeeds (else use sudo -u golem).</precondition>
<behavior>
- TestMCPConsentAllowSerializesWithRevoke. The user already holds a consent [read write], and the second request "read" (via mcpPendingPKCE) shows consent_required false, so this is the silent TouchConsent path. A separate transaction holds classes.LockUserGrants for the user. In that state the async allow does not answer, and pg_locks shows exactly one ungranted advisory waiter on the holder's lock. The pending row still has request_id = the id and a NULL code_hash, and the consent row's updated_at is unchanged. A real async DELETE /oauth/authorizations/{client} queues as the second waiter. After the holder rolls back, the allow answers 200 with a redirect_uri carrying a code, and the revoke answers 200 {"revoked":true}. Afterwards: no consent row for (user, client); zero MCP code rows with user_id = user and client_id = client; exchangeMCPCode of the allow's code is not 200 and carries an error; a fresh mcpPending(client, "read") shows consent_required true.
- TestMCPConsentFailureRollsBackCode. A fresh MCP client with no consent row, so the allow takes the replace path. A test-installed BEFORE INSERT OR UPDATE trigger on golem15_oauthserver_consents raises an exception. The allow of a "read" request (mcpPendingPKCE) answers 500 {"error":"server_error"}. The MCP code row for that request id still has request_id set and code_hash NULL, the client's consented_at is NULL, and no consent row exists. After the trigger is dropped, the same request id allows with 200, the consent row is [read], and exchangeMCPCode returns 200 with an access_token.
- On the pre-fix code, test 1 fails: the allow answers while the lock is held. Test 2 also fails: code_hash is set and the retry answers 404 request_not_found.
</behavior>
<action>
RED first: write the tests, run them, confirm both fail on the current code, and keep the failure output for the SUMMARY. Do not commit the red state, because CLAUDE.md requires `go test ./...` green at every commit.
Tests (new file mcp_consent_race_test.go, package oauthserver_test, imports the plugin's classes and models packages):
- In mcp_consent_smoke_test.go add the field ConsentRequired bool with json tag consent_required to mcpConsentView. The change is additive; no existing assertion changes.
- Add goroutine-safe helpers in the new file that never touch t. First, an async JSON call that builds the request like jsonCall (Accept and Content-Type application/json, Bearer token, env.issuer + path), sends it with env.browser(), and delivers status, body and error on a buffered channel. Second, a waiter counter that runs, on env.gdb (a different pool connection), a COUNT over pg_locks w where w.locktype is advisory and w.granted is false, and a granted advisory row h exists with the same classid, objid and objsubid and h.pid equal to the holder's backend pid. Third, a wait helper that polls the counter every 10 ms until it reaches n, with a 15 s deadline. While polling it selects on the in-flight result channels: a result arriving first fails with "allow answered <status> <body> while the user's grants lock was held", and the deadline fails with the current count.
- Holder: begin a transaction on env.gdb with t.Context(), call classes.LockUserGrants(ctx, holder, []uint{user.ID}), and read SELECT pg_backend_pid() inside it. Register a t.Cleanup that rolls it back and ignores the error when it is already rolled back.
- TestMCPConsentAllowSerializesWithRevoke follows the first behavior bullet in order. First allow: mcpPending(client, "read write") with scopes [read, write]; assert consentScopes is [read write]. Second request: mcpPendingPKCE(client, "read"). Assert consentShow is 200 with ConsentRequired false, which proves the silent path. Take the consent row's updated_at. Start the holder, start the async allow (decision allow, scopes [read]), and wait for 1 waiter. Assert the MCPCode row with request_id = id2 has a nil CodeHash and the consent updated_at is unchanged. Start the async DELETE /oauth/authorizations/{client} and wait for 2 waiters. Roll back the holder, then collect both results with a 30 s timeout per channel. Then run the final assertions. Postgres grants queued conflicting advisory requests in queue order, so the allow, which waited first, runs before the revoke. That makes the "revoke follows both" outcome deterministic.
- TestMCPConsentFailureRollsBackCode follows the second behavior bullet. Install the trigger with plain Exec on env.gdb: a plpgsql function that RAISEs EXCEPTION, and a BEFORE INSERT OR UPDATE FOR EACH ROW trigger on models.Consent{}.TableName(). The database is per test, so no WHEN clause or client-id interpolation is needed. A t.Cleanup drops the trigger and the function IF EXISTS. Drop them explicitly before the retry.
- Never log a code or token (32.1 rule: tests read code rows by sha256Hex, as issuedCode does).
GREEN: the fix, per the chosen design in the objective.
- classes/mcp_backend_binding.go: add an unexported context key type grantsTxKey and an unexported grantsTxFrom(ctx) returning (*gorm.DB, bool); it is nil-safe on ctx and returns false for a nil tx. Add the exported WithinUserGrantsLock(ctx context.Context, db *gorm.DB, userIDs []uint, fn func(ctx context.Context, tx *gorm.DB) error) error. A nil db returns ErrDatabaseUnavailable. Otherwise it runs db.WithContext(ctx).Transaction, the same style as revoke. Inside, the first statement is lockUserGrants(ctx, tx, userIDs), which is exclusive and in ascending id order. Then it calls fn with context.WithValue(ctx, grantsTxKey{}, tx) and tx. The doc comment must say all of the following. The lock is the exclusive one revoke and the cascade take. The ctx handed to fn makes MCPStore.WithinTx, and therefore wristband.Server.IssueCode on the MCP server, run on tx instead of opening its own transaction, so code issue and any other write in fn commit or roll back together. ctx and tx are valid only until fn returns. Everything in fn must use tx: a query on a second pool connection that takes the same user's grants lock would wait on this transaction for good. Exclusive rather than shared is deliberate. It matches the review's fix, also serializes two allows of the same pair, and costs only a few milliseconds of that one user's exchanges and refreshes. Record this choice in the SUMMARY.
- classes/mcp_store.go MCPStore.WithinTx: keep the nil-store check. Then, when grantsTxFrom(ctx) reports a transaction, return fn of a new mcpTx bound to that tx directly, with no nested Transaction call and no savepoint. An error from fn propagates, and the owner rolls the whole unit back. Otherwise keep today's own-transaction path. Update the doc comment to name WithinUserGrantsLock. Do not change mcpTx methods, lock takers, exchange or refresh.
- controllers/mcp_consent_api.go MCPConsentDecide: keep everything up to and including the computation of issueScopes, the offline flag and the WithGrantedOfflineAccess / WithConsentedBackend ctx values. Compute stored (issueScopes plus offline_access when granted) before the transaction. Replace the separate IssueCode call and recordMCPConsent call with one classes.WithinUserGrantsLock(ctx, db, []uint{userID}, ...) whose function does three things in order. It calls srv.IssueCode with the function's ctx, which already carries the backend and offline values because it derives from ctx, and returns its error unchanged. It then calls recordMCPConsent with the function's ctx and tx, returning its error. Finally it assigns the redirect target to a variable declared outside. After the transaction returns, call writeDecision(w, target, err). That keeps today's mapping: ErrPendingNotFound 404, ErrNoGrantableScopes 422, any other error 500 server_error, and nil 200 {redirect_uri}. The answer is written only after the commit. Leave the pre-lock reads (PendingRequest, mcpPendingClientID, linkedBackend and the 409 check) where they are, because IssueCode re-validates the pending request inside the lock through lookupOwnedPending.
- recordMCPConsent keeps its signature. Its db parameter now receives the locked tx. Rename the parameter to tx and update its doc to say it runs inside the caller's grants-lock transaction. TouchConsent decision: the touch branch needs no lock of its own, because an UPDATE by id cannot recreate a deleted row. Its covers-check read and the replace both must see the post-revoke state, and they now do because they run in the locked tx. Keep the classes.NewStore(tx).WithinOIDCTx call, which nests as a GORM savepoint on tx. replaceMCPConsent is unchanged apart from receiving tx. Update the MCPConsentDecide doc comment: the allow issues the code and records the consent in one transaction under the user's grants lock, so a revoke precedes or follows both, and a failed consent write leaves the request pending.
- Do not touch summercms.go, the OIDC decide path (controllers/consent_api.go / wristband oidc_consent.go), revoke, cascade or any exported signature. The only API addition is WithinUserGrantsLock.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin && go vet ./... && go test -count=1 -v -run 'TestMCPConsentAllowSerializesWithRevoke|TestMCPConsentFailureRollsBackCode|TestMCPConsent$|TestMCPConsentReplace|TestMCPConsentSmoke|TestMCPOfflineAccessUntick|TestMCPRevokeDeletesCode|TestMCPBackendBinding' . 2>&1 | grep -E -- '^(--- (PASS|FAIL|SKIP)|ok|FAIL|panic)'</automated>
</verify>
<done>The RED run, recorded in the SUMMARY, shows TestMCPConsentAllowSerializesWithRevoke failing with "allow answered ... while the user's grants lock was held". It shows TestMCPConsentFailureRollsBackCode failing on a non-NULL code_hash or on the 404 retry. After the fix, the verify output lists `--- PASS:` for both new tests and for TestMCPConsent, TestMCPConsentReplace, TestMCPConsentSmoke, TestMCPOfflineAccessUntick, TestMCPRevokeDeletesCode and TestMCPBackendBinding, with no FAIL or SKIP line, and `go vet ./...` is clean. Nothing is committed yet; Task 2 commits the unit together with the README.</done>
</task>
<task type="auto">
<name>Task 2: Plugin README, full suite, one plugin commit</name>
<files>/media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/README.md</files>
<read_first>
- sm-oauthserver-plugin/README.md lines 30, 33, 78 and the Testing paragraph at line 205 (grep for "After the code is issued", "LockUserGrants(ctx, db, userIDs)" and "TestMCPBackendBinding pins")
</read_first>
<action>
README (the plugin README is the module README; CLAUDE.md requires an exported API change and its README in the same commit):
- Line 33, MCP consent passage: replace the sentence beginning "After the code is issued, a consent row that already covers the request is only touched" so it says the following. The allow runs in one transaction that first takes the user's grants lock (classes.WithinUserGrantsLock, the exclusive lock revoke and the grant cascade take). In it the code is issued (IssueCode, whose MCP store joins that transaction) and a consent row that already covers the request is only touched (TouchConsent); otherwise the stored scopes become the granted set. Keep the existing offline_access, replace-not-union and SaveConsent wording. Add one sentence: a revoke therefore lands before or after both writes, so it is never undone by an allow in flight, and a failed consent write leaves the request pending with no code.
- Line 30, authorized-apps passage: after the LockUserGrants sentence, add that an MCP allow takes the same lock, so a revoke and an allow of the same user never interleave.
- Line 78, classes API list: after LockUserGrants(ctx, db, userIDs), add WithinUserGrantsLock(ctx, db, userIDs, fn). Every identifier written must exist in the package.
- Testing paragraph: append one sentence naming TestMCPConsentAllowSerializesWithRevoke (with another transaction holding the user's grants lock, the allow waits and writes nothing, and a revoke queued behind it removes the code and the consent it then wrote) and TestMCPConsentFailureRollsBackCode (a failing consent write rolls the code back and leaves the request decidable).
Gates, in this order, in the plugin dir: `go vet ./...`, then `go test -short ./...`, then the full non-short `go test -count=1 ./...` (use sudo -u golem bash -c '...' if the Docker socket is denied). Fix any failure your change caused. A failure that also reproduces at HEAD without your change is not yours to fix: record it in the SUMMARY and stop for the orchestrator.
Commit in the plugin repo with explicit paths only, never go.work. Stage classes/mcp_backend_binding.go, classes/mcp_store.go, controllers/mcp_consent_api.go, mcp_consent_race_test.go, mcp_consent_smoke_test.go and README.md. Commit as one logical change with the message "fix(mcp): serialize the consent allow with revoke under the user grants lock". The body should say: CR-01 of the 32.1 review; IssueCode and the consent write now commit in one transaction holding LockUserGrants, so a revoke can no longer be undone by an allow in flight, and a failed consent write rolls the code back. No co-author or attribution trailer (user CLAUDE.md overrides). Do not push or tag. Afterwards `git status --short` must show only ` M go.work`. Record the short hash (git rev-parse --short HEAD) for Task 3.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin && go vet ./... && go test -short ./... && go test -count=1 ./... 2>&1 | tail -5 && git status --short && git log -1 --format='%h %s' && git show --stat --format= HEAD</automated>
</verify>
<done>go vet is clean, and both the -short and the full non-short suites print ok with no FAIL. HEAD in sm-oauthserver-plugin is the new fix commit touching exactly the six listed files, with no attribution trailer, unpushed and untagged. `git status --short` shows only ` M go.work`. The README names WithinUserGrantsLock and both new tests.</done>
</task>
<task type="auto">
<name>Task 3: Mark CR-01 fixed in the 32.1 disposition and security docs (edit only; the orchestrator commits)</name>
<files>/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-REVIEW-DISPOSITION.md, /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-SECURITY.md</files>
<read_first>
- 32.1-REVIEW-DISPOSITION.md (whole file, 48 lines)
- 32.1-SECURITY.md lines 38-48 (register rows T-32.1-06, T-32.1-07) and 66-100 (Residual Risk, Sign-Off)
</read_first>
<action>
Use the plugin short hash from Task 2, called HASH below. Use Edit with scoped replacements, not Write.
32.1-REVIEW-DISPOSITION.md:
- Frontmatter: in the CR-01 finding entry, change disposition from open to fixed. Change the top-level open count from 7 to 6. Leave total, recorded and the other findings unchanged.
- Table: the CR-01 row becomes critical, fixed, with the Source cell "sm-oauthserver-plugin HASH (unreleased; ships with the next plugin tag)".
32.1-SECURITY.md:
- Residual Risk table, CR-01 row: keep the Finding, Severity and Touches cells. Replace the Gap cell with: "Fixed in sm-oauthserver-plugin HASH (2026-11-05, unreleased): `MCPConsentDecide` issues the code and writes the consent in one transaction under `classes.WithinUserGrantsLock` (exclusive `LockUserGrants`, the lock revoke takes); a failed consent write rolls the code back. Tests `TestMCPConsentAllowSerializesWithRevoke`, `TestMCPConsentFailureRollsBackCode`."
- The intro sentence under the Residual Risk heading: append "CR-01 has since been fixed (sm-oauthserver-plugin HASH)."
- Register: in T-32.1-06's evidence cell, change "Residual: CR-01, WR-03, WR-04" to "Residual: WR-03, WR-04 (CR-01 fixed in HASH)". In T-32.1-07's cell, change "Closed at L1 only — see CR-01" to "CR-01 fixed in HASH: the MCP allow takes the same lock". Keep both statuses as closed.
- Sign-Off: tick the checkbox and make it read "[x] CR-01 given a disposition in 32.1-REVIEW-DISPOSITION.md (fixed: sm-oauthserver-plugin HASH)". Change the Approval line to "verified 2026-11-05 (ASVS L1; CR-01 fixed in sm-oauthserver-plugin HASH, unreleased)".
- Do not change frontmatter status, threats_open or the audit trail table.
Do not commit and do not stage. The orchestrator makes the docs commit in summercms.go. Leave the other sessions' uncommitted files (.planning/STATE.md, state.json, G15OFFICE.md and the untracked phase dirs) untouched.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con && H=$(git -C /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin rev-parse --short HEAD) && grep -n "^open: 6$" 32.1-REVIEW-DISPOSITION.md && grep -n "| CR-01 | critical | fixed | sm-oauthserver-plugin $H" 32.1-REVIEW-DISPOSITION.md && grep -c "$H" 32.1-SECURITY.md && grep -n "\[x\] CR-01 given a disposition" 32.1-SECURITY.md && git -C /media/nvme/dev/golem15/summercms.io/summercms/summercms.go status --short -- .planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con</automated>
</verify>
<done>The disposition file shows CR-01 fixed with the plugin hash in both frontmatter and table, and open: 6. SECURITY.md carries the hash in the residual row, the intro, both register cells, the ticked sign-off box and the approval line (grep -c reports at least 6). Both files show as modified and unstaged in summercms.go, with no other file there touched by this task.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser (signed-in user) → POST /oauth/mcp/requests/{request} | the allow decision; the consent page may auto-post it when consent_required is false |
| browser (signed-in user) → DELETE /oauth/authorizations/{client_id} | the revoke that must be final |
| plugin context key → wristband.Server.IssueCode (framework) | IssueCode passes the caller's ctx to the plugin store; the store joins the carried transaction |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-261105-01 | Elevation of privilege | MCPConsentDecide allow path vs revoke (CR-01) | critical | mitigate | IssueCode (MarkIssued, MarkConsented) and recordMCPConsent run in one transaction opened by classes.WithinUserGrantsLock, whose first statement takes the exclusive per-user advisory lock revoke takes; MCPStore.WithinTx joins it via the unexported ctx key; proven by TestMCPConsentAllowSerializesWithRevoke |
| T-261105-02 | Tampering | partial allow (code issued, consent write fails) | high | mitigate | the consent error is returned from fn so the whole transaction, including MarkIssued and MarkConsented, rolls back; the answer is written only after the commit; proven by TestMCPConsentFailureRollsBackCode |
| T-261105-03 | Denial of service | grants lock contention / self-wait | medium | mitigate | lock is per user and held only for two UPDATEs and one upsert; IssueCode's store calls run on the joined tx (no second connection); WithinUserGrantsLock doc forbids pool access inside fn. No new lock cycle: every taker (revoke, cascade, exchange, refresh, allow) takes the advisory lock before any row lock it holds. The cascade's user-event transaction may hold a NO KEY UPDATE lock on the users row, and that is compatible with the KEY SHARE lock the consent insert's foreign key check takes |
| T-261105-04 | Information disclosure | transaction handle leaking through ctx | low | mitigate | key type unexported, set only by WithinUserGrantsLock, documented valid only inside fn; the handler answers with the outer ctx after the transaction returns |
| T-261105-SC | Tampering | npm/pip/cargo installs | low | accept | this task installs no packages and adds no dependency |
</threat_model>
<verification>
- Task 1 verify output: `--- PASS:` for TestMCPConsentAllowSerializesWithRevoke and TestMCPConsentFailureRollsBackCode plus the existing MCP consent, revoke and backend-binding tests; no SKIP (a skip means Docker did not run; rerun via sudo -u golem).
- The RED evidence for both new tests is recorded in the SUMMARY (output from the pre-fix run).
- Task 2: full non-short plugin suite green; one plugin commit with six explicit files; go.work still unstaged and modified; nothing pushed or tagged.
- No file under /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules changed (`git -C .../summercms.go status --short -- modules` is empty).
- Task 3: the disposition and security docs show CR-01 fixed with the plugin hash, uncommitted.
</verification>
<success_criteria>
- A revoke can no longer be undone by an MCP allow in flight: allow code issue + consent write are atomic and serialized with revoke (and with the grant cascade) under the user's exclusive grants lock.
- A failed consent write leaves no live code.
- Deterministic regression tests (lock holder + pg_locks waiter checks, DB trigger) that fail on the old code and pass on the new.
- No framework change and no breaking plugin API change; one plugin commit; 32.1 docs updated for the orchestrator's docs commit.
</success_criteria>
<output>
Create /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/quick/261105-din-fix-32-1-cr-01-serialize-mcp-consent-all/261105-din-SUMMARY.md when done. Include the plugin commit hash, the RED and GREEN test output lines, the exclusive-vs-shared lock choice, and a note for the orchestrator: wristband's OIDC decide path (oidc_consent.go:66-120, SaveConsent and mintCode in one WithinOIDCTx) is atomic but does not take LockUserGrants, so it may have a related revoke race. That is unverified, outside CR-01 and not changed here; it is a candidate follow-up.
</output>

View File

@@ -0,0 +1,114 @@
---
phase: quick-261105-din
plan: 01
subsystem: sm-oauthserver-plugin (MCP consent)
status: complete
tags: [security, oauth, mcp, concurrency, CR-01]
requires: [32.1 MCP consent JSON, classes.LockUserGrants]
provides: [classes.WithinUserGrantsLock, atomic MCP allow serialized with revoke]
affects: [controllers.MCPConsentDecide, classes.MCPStore.WithinTx]
tech-stack:
added: []
patterns: [caller-owned transaction joined via an unexported context key]
key-files:
created:
- /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/mcp_consent_race_test.go
modified:
- /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/classes/mcp_backend_binding.go
- /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/classes/mcp_store.go
- /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/controllers/mcp_consent_api.go
- /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/mcp_consent_smoke_test.go
- /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/README.md
- .planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-REVIEW-DISPOSITION.md
- .planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-SECURITY.md
decisions:
- "The MCP allow takes the user's grants lock exclusively (not shared), which matches the review's fix, also serializes two allows of the same pair, and costs only a few ms of that user's exchanges and refreshes"
- "IssueCode joins the caller's transaction through an unexported ctx key read by MCPStore.WithinTx, so there is no framework change"
metrics:
completed: 2026-11-05
actuals:
tokens: 11961
tasks: 3
commits: 1
plugin_commit: 21f5985
plan_head_before: d600e2c
plan_head_after: 21f5985
---
# Quick 261105-din: Fix 32.1 CR-01, serialize the MCP consent allow with revoke
**What changed:** the MCP consent allow now issues the code and writes the consent in a single Postgres transaction. Its first statement takes the user's exclusive grants advisory lock, which is the same lock revoke takes. A revoke therefore lands before both writes or after both, and can no longer be undone by an allow in flight. If the consent write fails, the code issue rolls back with it.
**Plugin commit:** `21f5985` in sm-oauthserver-plugin, on master: "fix(mcp): serialize the consent allow with revoke under the user grants lock". It has no co-author trailer and is not pushed or tagged. After the commit, `git status --short` shows only ` M go.work`, the unrelated change that was already there.
## What was built
- `classes.WithinUserGrantsLock(ctx, db, userIDs, fn)` is new in `classes/mcp_backend_binding.go`. It opens a transaction, takes `lockUserGrants` first (exclusive, in ascending id order), then calls `fn(txCtx, tx)`. `txCtx` carries `tx` under the unexported `grantsTxKey`. `grantsTxFrom(ctx)` is nil-safe. A nil db returns `ErrDatabaseUnavailable`.
- `MCPStore.WithinTx`: when ctx carries a grants-lock transaction, `fn` runs on it directly, with no nested transaction and no savepoint. Every other case keeps the store's own transaction. `LazyMCPStore` delegates here, so `wristband.Server.IssueCode` (`lookupOwnedPending`, `MarkIssued`, `MarkConsented`) joins the caller's transaction with no framework change.
- `MCPConsentDecide` allow path: one `WithinUserGrantsLock` runs `srv.IssueCode(txCtx, ...)` and then `recordMCPConsent(txCtx, tx, ...)`. The handler calls `writeDecision` only after the commit, so the status mapping is unchanged (404, 422, 500, 200). The reads before the lock stay where they were, because IssueCode checks the pending request again inside the lock.
- `recordMCPConsent` keeps its signature; its parameter was renamed to `tx`. Its covers-check, `TouchConsent` (nested `WithinOIDCTx`, which becomes a GORM savepoint) and `replaceMCPConsent` all run inside the locked transaction.
- README: the MCP consent passage, the authorized-apps passage, the `classes` API list (`WithinUserGrantsLock(ctx, db, userIDs, fn)`) and the Testing paragraph, which now names both new tests.
- 32.1 docs, edited but not committed: in `32.1-REVIEW-DISPOSITION.md`, CR-01 is `fixed` (`open: 6`) and its table row cites `21f5985`. In `32.1-SECURITY.md`, the residual row, intro, T-32.1-06 and T-32.1-07 cells, sign-off checkbox and approval line now cite `21f5985`.
## Lock choice: exclusive vs shared
The allow takes the exclusive lock, the one revoke and the cascade (`revokeUserGrants`) take. A shared lock would also have serialized the allow against revoke. Exclusive was chosen deliberately for three reasons:
- It matches the review's proposed fix.
- It serializes two allows of the same pair as well.
- It costs only a few milliseconds of that one user's code exchanges and refresh rotations, which take the lock shared while the allow holds it.
The lock order has no new cycle: every lock taker takes the advisory lock before any row lock.
## TDD evidence
**RED, pre-fix code (tests written first; nothing committed in the red state):**
```
mcp_consent_race_test.go:189: allow answered 200 (redirect withheld: it carries a code) while the user's grants lock was held (err <nil>)
--- FAIL: TestMCPConsentAllowSerializesWithRevoke (0.56s)
mcp_consent_race_test.go:301: after the failed consent write: code issued true, request id kept false
--- FAIL: TestMCPConsentFailureRollsBackCode (0.43s)
FAIL
```
Test 1 failed for the CR-01 reason: the allow answered while another transaction held the lock. Test 2 failed because the code was issued (`code_hash` set, `request_id` cleared by `MarkIssued`) even though the consent write failed. The test stops at that assertion, before reaching the 404 retry.
**GREEN, after the fix (Task 1 verify, Docker-backed and not skipped):**
```
--- PASS: TestMCPBackendBinding (1.15s)
--- PASS: TestMCPConsentAllowSerializesWithRevoke (0.67s)
--- PASS: TestMCPConsentFailureRollsBackCode (0.45s)
--- PASS: TestMCPConsentSmoke (0.78s)
--- PASS: TestMCPConsent (0.65s)
--- PASS: TestMCPConsentReplace (0.56s)
--- PASS: TestMCPOfflineAccessUntick (0.73s)
--- PASS: TestMCPRevokeDeletesCode (0.60s)
ok git.golem15.com/golem15/sm-oauthserver-plugin 10.351s
```
I also ran the two new tests with `-count=10`; all ten runs passed (ok, 15.97s), so they are stable.
**Plugin gates before the commit:** `go vet ./...` is clean. `go test -short ./...` passes (ok, 1.45s). The full non-short `go test -count=1 ./...` passes (ok, 96.35s). Docker was available directly, so `sudo -u golem` was not needed.
## Deviations from Plan
1. **[Rule 2 - Security] The early-answer failure message withholds the body on a 200.** The first RED run printed the allow's redirect body, which contains an authorization code. That breaks the 32.1 rule that tests never log a code. `waitForGrantsWaiters` now prints `(redirect withheld: it carries a code)` in place of a 200 body; any other status still prints its body. The plan's message shape, "allow answered <status> ... while the user's grants lock was held", is unchanged. The recorded RED output above comes from the redacted rerun. The leaked code belonged to a throwaway per-test database and was only ever printed to the local terminal.
2. **Test 2 reads the code row by client id, not by request id.** `MarkIssued` sets `request_id` to NULL, so on the pre-fix code a lookup by request id finds nothing. The test reads the client's single MCP code row and asserts `request_id == id` and `code_hash IS NULL`. It checks the same thing and gives a clearer RED message.
3. **The plugin commit is on the plugin repo's `master`.** The orchestrator's constraints and the plan say to commit there, and that is the project's normal workflow for sibling plugin repos. The executor's protected-branch halt does not apply.
## Follow-up flag for the orchestrator
wristband's OIDC decide path (`modules/wristband/oidc_consent.go:66-120`) runs `SaveConsent` and `mintCode` in one `WithinOIDCTx`, so it is atomic. It does not take `LockUserGrants`, though, so a revoke landing during an OIDC allow may race in a similar way. This is unverified and outside CR-01; nothing was changed there. It is a candidate follow-up.
## Known Stubs
None.
## Threat Flags
None. No new endpoint or trust boundary was added. The transaction handle in ctx is unexported, is set only by `WithinUserGrantsLock`, and is valid only inside `fn` (T-261105-04).
## Self-Check: PASSED
- FOUND: sm-oauthserver-plugin/mcp_consent_race_test.go
- FOUND: commit 21f5985 in sm-oauthserver-plugin (6 files, no attribution trailer)
- FOUND: 32.1-REVIEW-DISPOSITION.md with `open: 6` and the CR-01 row fixed at 21f5985; 32.1-SECURITY.md has 6 references to 21f5985 and the ticked sign-off box
- `git -C summercms.go status --short -- modules` is empty (no framework change)

View File

@@ -0,0 +1,280 @@
---
phase: quick-261105-eab
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/oidc_grants_race_test.go
- /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/mcp_consent_race_test.go
- /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/classes/oidc_store.go
- /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/classes/mcp_backend_binding.go
- /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/README.md
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-SECURITY.md
autonomous: true
requirements:
- quick-261105-eab
estimate:
tokens: 90000
raw_tokens: 90000
tasks: 3
confidence: low
must_haves:
truths:
- "Silent resume vs revoke: if a revoke lands while GET /oauth/authorize/resume/{request} is in flight for the same user and client (the consent covers the request), then once both have answered there is no consent row and no unconsumed auth code for that user and client, and the code the resume issued does not exchange (400)."
- "Refresh vs revoke: if a revoke lands while an OIDC refresh rotation of the same pair is in flight, then once both have answered (refresh 200, revoke 200) the pair has zero access-token and zero refresh-token rows. The new refresh token answers invalid_grant and the new access token gets 401 at /oauth/userinfo."
- "Code exchange vs revoke: if a revoke lands while an OIDC code exchange of the same pair is in flight, both answer 200 with no deadlock and no 500, and afterwards the pair has no tokens."
- "Consent decision vs revoke (pinning): if a revoke lands while an OIDC allow is blocked just before it issues its code, the allow answers 302 with a code and the revoke answers 200. Afterwards there is no consent row, no live code and no token for the pair. This test already passes on the code before the fix, which is the evidence that the decide path was consistent."
- "The plugin's OIDC store (classes/oidc_store.go oidcTx) takes the user's grants lock in shared mode before it takes the first row lock or foreign-key lock on that user's grant rows. SubjectFor, ConsentFor and SaveConsent take it on entry. ConsumeCode and RefreshTokenByHashForUpdate take it after they read the row's user_id by hash. Revoke and the grant cascade take this lock exclusively."
- "summercms.go does not change (nothing under modules/), so no framework tag is needed. No exported plugin identifier is added or changed, and the MCP code paths are untouched. go vet ./... and the full non-short plugin suite are green at the commit. Nothing blocks the plugin's v0.1.7 tag: go.mod still requires summercms v0.6.0."
artifacts:
- path: /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/oidc_grants_race_test.go
provides: "TestOIDCResumeSerializesWithRevoke, TestOIDCRefreshSerializesWithRevoke, TestOIDCExchangeSerializesWithRevoke (red before the fix), TestOIDCConsentAllowThenRevoke (pinning); goroutine-safe asyncDo, lock-waiter polling, gate trigger and pair-count helpers"
- path: /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/classes/oidc_store.go
provides: "oidcTx.SubjectFor, ConsentFor, SaveConsent, ConsumeCode, RefreshTokenByHashForUpdate take lockUserGrantsShared first; Store doc states the invariant"
key_links:
- from: "wristband.Server.Resume / decideConsent / redeemCode / rotateOIDCRefresh / UserInfo (framework, unchanged)"
to: "classes.oidcTx (plugin) via classes.LazyStore -> Store.WithinOIDCTx"
via: "SetOIDCBackend(&classes.LazyStore{...}) in servers.go:154-155"
pattern: "lockUserGrantsShared"
- from: "controllers/authorizations.go revoke (exclusive LockUserGrants)"
to: "classes/oidc_store.go oidcTx (shared lockUserGrantsShared)"
via: "the same pg_advisory key hashtext('golem15_oauthserver_user_grants:<id>')"
pattern: "userGrantsLockKey"
---
<objective>
Investigate the OIDC sibling of CR-01 in sm-oauthserver-plugin and fix it in the plugin only. The investigation is done (findings below). Task 1 turns the findings into deterministic tests that fail on the current code before Task 2 changes anything.
Findings (from reading the current code; the plugin is at HEAD 21f5985, the framework at summercms.go master):
Wiring. The plugin's servers.go:154-155 gives wristband's OIDC server a `classes.LazyStore`, which runs `classes.Store.WithinOIDCTx`: one GORM transaction per call, over `classes.oidcTx`. OIDC codes are stored in `golem15_oauthserver_auth_codes`, consents in `_consents`, and tokens in `_access_tokens` and `_refresh_tokens`. Revoke (controllers/authorizations.go:137-165) deletes rows from exactly these tables after it takes the exclusive grants lock. No oidcTx method takes the grants lock. Only the MCP store's `ByCodeHashForUpdate` and `ByTokenHashForUpdate` take it (shared; classes/mcp_store.go:201, :342). The plugin's JSON consent endpoints (controllers/consent_api.go) call `srv.ConsentRequest` and `srv.DecideConsent`, so they go through the same store.
1. Decide (`wristband.decideConsent`, oidc_consent.go:60-129, used by the HTML POST and by ConsentDecide) is CONSISTENT. `SaveConsent` (oidc_store.go:328-362) and `mintCode` run in one transaction. Two orderings are possible:
- The revoke has already deleted the consent but not committed. The INSERT ... ON CONFLICT DO NOTHING conflict check uses a dirty snapshot, so it waits for the revoke, then inserts a fresh row. That is the order revoke, then allow, and it ends with a consent and a code.
- SaveConsent locks the existing row FOR UPDATE before the revoke's SELECT FOR UPDATE. The revoke then waits for the commit, and its later DELETEs (READ COMMITTED takes a new snapshot per statement) see the issued code. That is the order allow, then revoke, and it ends with nothing.
The only defect: if a revoke commits between the DO NOTHING and the FOR UPDATE, Take returns ErrRecordNotFound, the transaction rolls back and the allow answers 500. That fails closed and leaves nothing behind. Test D pins the consistent outcome.
2. Silent resume (`wristband.Server.Resume`, oidc_authorize.go:223-256) is a RACE. `ConsentFor` is a plain read. If a revoke commits (or holds the deleted row) before `TouchConsent`, the UPDATE by id hits 0 rows without an error, and `mintCode` inserts a code after the revoke's DELETE snapshot. The result is a live code with no consent. The exchange does not require a consent (oidc_token.go:273-280 touches it only when present), so the client gets tokens that the authorized-apps list cannot show, and `DELETE /oauth/authorizations/{client}` answers 404 for them.
3. Refresh (`rotateOIDCRefresh`, oidc_token.go:352-430) is a RACE. `RefreshTokenByHashForUpdate` locks R1. The revoke's DELETE on access_tokens and refresh_tokens uses statement snapshots that do not see the successor A2 and R2. Its DELETE on refresh_tokens waits on R1 and deletes R1 after the rotation commits, so A2 and R2 survive the revoke. access_tokens has no foreign keys, so nothing cascades. The OIDC half of the grant cascade has the same hole, although the comment on revokeUserGrants claims otherwise.
4. Code exchange (`redeemCode`, oidc_token.go:197-301) can DEADLOCK. `ConsumeCode` row-locks the code. The revoke deletes the consent and then waits on the code row. The exchange's `TouchConsent` then waits on the deleted consent row. Postgres detects a deadlock (40P01) and one side answers 500.
Chosen design (plugin only, no framework change, no new exported identifier): make the OIDC store follow the rule the MCP store already follows. Every oidcTx entry point that touches a user's grant rows first calls `lockUserGrantsShared(ctx, t.db, userID)`, before any row lock or foreign-key lock on them:
- `SubjectFor`, `ConsentFor` and `SaveConsent` take the lock on entry.
- `ConsumeCode` and `RefreshTokenByHashForUpdate` first read the row's user_id by hash, take the lock, and only then run their guarded UPDATE or SELECT FOR UPDATE. This is the same pattern as MCP's ByCodeHashForUpdate.
The lock is shared because shared already serializes these flows with revoke and the cascade, which take it exclusively. OIDC flows of the same user do not need to exclude each other: the upsert and the guarded UPDATEs already handle that.
SubjectFor needs the lock because it is the first user-keyed statement in Resume, ConsentRequest and userinfo. Its INSERT takes a KEY SHARE lock on the users row through the foreign key. A user delete takes the exclusive grants lock in the cascade and then runs DELETE on users, so taking the lock after SubjectFor would let a delete and a resume deadlock. In userinfo, SubjectFor also comes before TouchAccessToken's row lock.
Alternatives considered and rejected:
- Wrapping wristband's handlers in WithinUserGrantsLock, as din did for MCPConsentDecide. The plugin mounts wristband's Resume, authorize and token handlers directly, and the token endpoint does not know the user until it reads the code or the token.
- Any framework hook. Not needed, because the framework passes every grant access through the plugin's oidcTx.
Framework change: none. The v0.1.7 plugin tag is not blocked, because go.mod keeps requiring summercms v0.6.0. Tagging stays with the orchestrator.
Purpose: a revoke of an OIDC client is final, and the "ban / password change ends every grant" cascade also catches a rotation that is in flight.
Output: one plugin commit with the four tests, the store fix and the README. A residual-risk row in 32.1-SECURITY.md, which the orchestrator commits.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/CLAUDE.md
@/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/STATE.md
@/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/quick/261105-din-fix-32-1-cr-01-serialize-mcp-consent-all/261105-din-SUMMARY.md
Repository facts the executor must respect:
- The plugin repo is /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin: its own git repo, branch master, HEAD 21f5985 (the CR-01 fix, unpushed), latest tag v0.1.6. `go.work` has an UNRELATED uncommitted change. Never stage, edit, revert or stash it. Never use `git stash`, `git add -A` or `git add .` in that repo.
- Framework code, read-only. Do not change anything in summercms.go/modules:
- summercms.go/modules/wristband/oidc_authorize.go: Resume :188-269, mintCode :413-436.
- oidc_consent.go: decideConsent :60-129.
- oidc_token.go: redeemCode :197-301, rotateOIDCRefresh :349-430.
- oidc_userinfo.go :33-63. Its order is AccessTokenByHash, client, IdentityClaims, subjectFor, TouchAccessToken.
- oidc_subject.go:62-68: subjectFor calls tx.SubjectFor.
- Plugin store: classes/oidc_store.go. Store.WithinOIDCTx is at :46-55. The methods to change are ConsumeCode :200-219, ConsentFor :316-326, SaveConsent :328-362, SubjectFor :371-389 and RefreshTokenByHashForUpdate :426-449. The lock helper `lockUserGrantsShared(ctx, db, userID)` is in classes/mcp_backend_binding.go:77-87: it is a no-op for userID 0 and uses pg_advisory_xact_lock_shared(hashtext(key)). The pattern to copy is MCP `ByCodeHashForUpdate` in classes/mcp_store.go:191-215: it reads user_id by hash with Model/Select/Where/Limit(1)/Scan, takes the lock, then locks the row. Model user_id types: AuthCode.UserID and RefreshToken.UserID are `uint`.
- Schema (updates/202610190001): auth_codes, consents, subjects and refresh_tokens have `user_id REFERENCES users ON DELETE CASCADE`. access_tokens has no foreign key at all.
- Test harness (package oauthserver_test, every Postgres test skips under -short):
- `bootOAuth(t, envs...)` boots a fresh database per test. `env.gdb` is the app's *gorm.DB, and `env.issuer` is the base URL.
- Identity: `env.user(t)`, `env.sessionToken(t, user)`, `env.oidcClient(t, name, mutate...)` (returns clientID and secret, allowed openid profile email at `testRedirect`).
- Flow helpers: `pkce(t)`, `authorizeQuery(clientID, challenge, scope, state, nonce)`, `env.consentRedirect(t, token, clientID, challenge, scope, state, nonce)` (drives authorize, resume and allow, and returns the redirect URL; it handles the silent 302), `hiddenFields(page)`, `env.do(t, method, path, cookieToken, form, header)` (fatal only on a transport error), `env.exchangeCode` (fatal unless 200), `env.signIn(t, token, clientID, secret)`, `env.userInfo(t, accessToken)` (authorize_test.go:148), `env.browser()`.
- From mcp_consent_race_test.go: `asyncResult{status, body, err}`, `env.asyncJSON` (sends a Bearer token, so use it for the revoke DELETE), `receive(t, what, ch)` (30 s).
- A refresh-enabled boot needs the env value SUMMER_GOLEM15__OAUTHSERVER__REFRESH__ENABLED=true (discovery_test.go:196) and a client mutated to OfflineAccess=true with scopes openid, profile, email, offline_access.
- The token endpoint uses client_secret_basic. Copy the Basic header that exchangeCode builds (oidc_flow_smoke_test.go:32-38).
- Never call a t-using helper from a goroutine. Never log a code or a token: on a 200 or 302, withhold the body or Location in failure messages, as din's waitForGrantsWaiters does.
- Docker: if the Docker socket is denied, run the same command through `sudo -u golem bash -c '...'`. A skipped test is not a pass, so every verify greps for explicit `--- PASS:` or `--- FAIL:` lines.
</context>
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: Reproduce first, with deterministic OIDC-vs-revoke race tests (RED on the current code, D pins the decide path)</name>
<files>/media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/oidc_grants_race_test.go, /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/mcp_consent_race_test.go</files>
<read_first>
- sm-oauthserver-plugin/mcp_consent_race_test.go (whole file: helper style, how failure messages withhold the body)
- sm-oauthserver-plugin/harness_test.go :150-345 (user, sessionToken, oidcClient, do, consentRedirect)
- sm-oauthserver-plugin/oidc_flow_smoke_test.go :22-54 (tokenResponse, exchangeCode Basic header)
- sm-oauthserver-plugin/controllers/authorizations.go :137-165 (revoke statement order)
- summercms.go/modules/wristband/oidc_authorize.go :223-256 and oidc_token.go :197-301, :352-430 (the blocking points the tests rely on)
</read_first>
<precondition>In /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin, `git status --short` shows only ` M go.work`, and classes/oidc_store.go is unchanged since 21f5985. `docker info` succeeds; if it does not, use sudo -u golem.</precondition>
<behavior>
- TestOIDCResumeSerializesWithRevoke (A, RED today). The user signs in once, so a consent [openid profile email] exists. A second authorize with the same scopes parks request R. A holder transaction locks R's auth_requests row FOR UPDATE. An async GET of the resume path blocks on that row after it has read the consent; the test waits until 1 backend waits on a lock. An async revoke follows. The test waits until either the revoke answers or 2 backends wait on a lock, then rolls the holder back and collects both answers. Expected: resume 302 with a code, revoke 200. Afterwards: 0 consent rows and 0 unconsumed auth codes for the pair, and exchanging the resume's code does not return 200. Today the revoke completes while the resume is parked, and the resume then issues a code after the revoke: 1 live code, 0 consents.
- TestOIDCRefreshSerializesWithRevoke (B, RED today). Boot with refresh enabled and an OfflineAccess client. Sign in with "openid offline_access" and parse refresh_token R1 from the exchange body. Install the gate trigger (BEFORE INSERT on access_tokens) and hold the gate key. An async refresh with R1 blocks in the trigger, after it has locked R1 (1 waiter). An async revoke then blocks (2 waiters): today on R1's row, after the fix on the grants lock. Release the gate. Expected: refresh 200 with a new access token and refresh token, revoke 200. Afterwards: 0 access-token and 0 refresh-token rows for the pair, the new refresh token answers 400 at /oauth/token, and the new access token gets 401 at /oauth/userinfo. Today the new access and refresh tokens survive (counts 1 and 1).
- TestOIDCExchangeSerializesWithRevoke (C, RED today). Sign in once, then get a second code C2 through the silent consentRedirect. Install the gate trigger on access_tokens and hold the gate key. An async exchange of C2 blocks in the trigger after ConsumeCode (1 waiter). An async revoke blocks (2 waiters). Release the gate. Expected: exchange 200 and revoke 200, then 0 tokens and 0 unconsumed codes for the pair, and the exchanged access token gets 401 at /oauth/userinfo. Today the exchange's TouchConsent and the revoke's code DELETE deadlock, and one side answers 500 after about 1 s.
- TestOIDCConsentAllowThenRevoke (D, PINNING; passes today and after the fix). Sign in with "openid profile". A second authorize with "openid profile email" shows the incremental consent page; take request_id and consent_token from hiddenFields. Install the gate trigger (BEFORE INSERT on auth_codes) and hold the gate key. An async POST /oauth/authorize allow blocks in the trigger after SaveConsent (1 waiter). An async revoke blocks (2 waiters): today on the consent row, after the fix on the grants lock. Release the gate. Expected: allow 302 with a code, revoke 200. Afterwards: 0 consents, 0 unconsumed codes and 0 tokens for the pair, and the allow's code does not exchange.
</behavior>
<action>
Write the tests only. Do not change production code in this task, and do not commit: CLAUDE.md requires `go test ./...` to be green at every commit, so Task 2 commits the tests together with the fix.
In mcp_consent_race_test.go, add one field to asyncResult: `location string`. This is additive; asyncJSON leaves it empty.
New file oidc_grants_race_test.go (package oauthserver_test; imports the plugin's models package). Every helper below must be goroutine-safe or run only on the test goroutine, as noted for each.
- `env.asyncDo(ctx, method, path, cookieToken string, form url.Values, header http.Header) <-chan asyncResult`. It mirrors env.do without t. It sends from a goroutine with env.browser(), adds the token cookie (name "token") when cookieToken is set, and sets the form Content-Type when a form is given. It delivers status, body, the Location header and the error on a buffered channel of size 1.
- `env.lockWaiters(ctx) (int64, error)`. On env.gdb it counts pg_stat_activity rows where datname = current_database(), wait_event_type = 'Lock' and pid <> pg_backend_pid(). This one counter covers waits on advisory locks, transaction ids and tuples, so the same test works on the old and the new code.
- `env.waitForLockWaiters(t, n, what string, inflight ...<-chan asyncResult)`. Test goroutine only. Poll every 10 ms with a 15 s deadline. Return once the count reaches n. If an inflight channel delivers first, fail with "<what> answered <status> before <n> backends waited on a lock"; for a 200 or 302, print "(withheld: it carries a code or token)" in place of the body. On the deadline, fail with the current count.
- `env.waitForAnswerOrLockWaiters(t, ch, n) (asyncResult, bool)`. Test goroutine only. It is the same poll, but a delivery on ch is a valid outcome: it returns (result, true). Reaching n waiters returns (zero, false). The deadline fails the test.
- `env.holdGate(t) *gorm.DB`. Begin a transaction on env.gdb with t.Context() and run SELECT pg_advisory_xact_lock(861105, 1). The two-int form is used because it can never collide with the grants lock's single-bigint hashtext key. Register a Cleanup that rolls the transaction back and ignores the error.
- `env.installGateTrigger(t, table string)`. Use plain Exec on env.gdb. Create a plpgsql function oas_test_gate() RETURNS trigger whose body does PERFORM pg_advisory_xact_lock(861105, 1) and RETURN NEW. Create a trigger oas_test_gate BEFORE INSERT ON <table> FOR EACH ROW EXECUTE FUNCTION oas_test_gate(). Register a Cleanup that drops the trigger (IF EXISTS ... ON table) and the function (IF EXISTS). The database is per test, so fixed names are safe. Take table names from models.AccessToken{}.TableName() and models.AuthCode{}.TableName().
- `env.holdRequestRow(t, requestID) *gorm.DB`. Begin a transaction and run SELECT id FROM models.AuthRequest{}.TableName() WHERE request_id = ? FOR UPDATE. Assert that it found the row. Register a rollback Cleanup.
- `env.pairRows(t, userID uint, clientID string) map[string]int64`. Count the consents rows, the auth_codes rows with consumed_at IS NULL, the access_tokens rows and the refresh_tokens rows where user_id and client_id match.
- `env.tokenCall(t, clientID, secret string, form url.Values) (int, string)`. A non-fatal POST /oauth/token through env.do, with the Basic header built exactly as exchangeCode builds it. Also add `env.asyncTokenCall(ctx, clientID, secret, form) <-chan asyncResult`, built on asyncDo with the same header.
- The revoke runs on env.asyncJSON(ctx, DELETE, "/oauth/authorizations/"+clientID, sessionToken, nil), the Bearer path din already uses.
Tests. Follow the behavior bullets exactly. Each test starts with bootOAuth (B adds the refresh env value), env.user, env.sessionToken and env.oidcClient. Wait details:
- A: after starting the async resume, call waitForLockWaiters(1, "resume", resumeCh). After starting the revoke, call waitForAnswerOrLockWaiters(revokeCh, 2): today the revoke answers here, after the fix 2 waiters appear. Then roll back the holder and receive whatever has not been delivered yet.
- B, C, D: waitForLockWaiters(1, ...) with the flow's channel, then waitForLockWaiters(2, ...) with both channels, then roll back the gate holder and receive both answers.
Extract codes with url.Parse(location).Query().Get("code"). Assert that each one is non-empty without printing it. Final assertions use pairRows, tokenCall and env.userInfo; failure messages print only statuses and counts. Doc comment of each test: what it races, what today's code does (A, B, C) or that it pins a consistent ordering (D), and "Skips under -short".
RED run, on the unchanged production code:
cd /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin && go test -count=1 -v -run 'TestOIDC(Resume|Refresh|Exchange)SerializesWithRevoke|TestOIDCConsentAllowThenRevoke' .
Keep the --- FAIL and --- PASS lines and the failure messages for the SUMMARY.
Gate. These decisions control Task 2:
- Expected result: A, B and C fail for the reasons in the behavior bullets, and D passes. Continue to Task 2.
- If one of A, B or C passes: rerun it with -count=5. If it still passes, the race on that path does not reproduce as analysed; record that in the SUMMARY. If all three pass, the outcome of this task is "not reproducible": Task 2 commits the tests as pinning tests with no change to classes/oidc_store.go, and Task 3 uses the not-reproducible wording. If at least one fails, apply the whole Task 2 fix anyway: it is one uniform invariant.
- If D fails today: record that the decide path is racy after all, with the failure message. Task 2's SaveConsent lock covers it; continue.
- If a test fails for a reason other than the one predicted (a helper bug, a timeout before the expected waiter count), fix the test until the failure is the predicted one. Do not weaken the assertions.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin && go vet ./... && go test -count=1 -v -run 'TestOIDC(Resume|Refresh|Exchange)SerializesWithRevoke|TestOIDCConsentAllowThenRevoke' . 2>&1 | grep -E -- '^(--- (PASS|FAIL|SKIP)|ok|FAIL|panic)|oidc_grants_race_test.go:[0-9]+:'</automated>
</verify>
<done>go vet passes. The output shows `--- FAIL:` for TestOIDCResumeSerializesWithRevoke (a live code with no consent), TestOIDCRefreshSerializesWithRevoke (successor tokens survive) and TestOIDCExchangeSerializesWithRevoke (a 500 from the deadlock), `--- PASS:` for TestOIDCConsentAllowThenRevoke, and no SKIP. The failure lines show no code or token. Nothing is committed, and production code is untouched. Any deviation from the gate's expected result is recorded with its evidence.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Take the user's grants lock (shared) in the plugin's OIDC store, update the README, run the full suite, make one plugin commit</name>
<files>/media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/classes/oidc_store.go, /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/classes/mcp_backend_binding.go, /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/README.md, /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/oidc_grants_race_test.go, /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/mcp_consent_race_test.go</files>
<read_first>
- sm-oauthserver-plugin/classes/oidc_store.go (whole file)
- sm-oauthserver-plugin/classes/mcp_store.go :187-215 (the ByCodeHashForUpdate pattern to copy)
- sm-oauthserver-plugin/classes/mcp_backend_binding.go :71-87 (userGrantsLockKey, lockUserGrantsShared doc)
- sm-oauthserver-plugin/README.md lines 23, 30, 32 and the Testing paragraph at 205 (grep for "**Stores.**", "An MCP allow takes the same lock", "which the MCP code exchange and refresh rotation take too", "TestMCPConsentFailureRollsBackCode")
</read_first>
<behavior>
- After the fix, all four tests from Task 1 pass. Existing OIDC, consent, token, authorized-apps, cascade and MCP tests stay green.
</behavior>
<action>
Skip the production-code part if Task 1's gate concluded "not reproducible" on all three paths. In that case, commit only the tests and the README Testing sentence, with the commit message "test(oidc): pin OIDC grant flows against a concurrent revoke".
Fix in classes/oidc_store.go, per the design in the objective:
- `SubjectFor`: the first statement is lockUserGrantsShared(ctx, t.db, userID); return its error. It runs before the INSERT ... ON CONFLICT, because that insert's foreign key takes KEY SHARE on the users row. Taking the advisory lock after it would let a user delete deadlock with a resume, since the cascade holds the exclusive lock and then runs DELETE on users.
- `ConsentFor` and `SaveConsent`: take the same lock as the first statement. In Resume and the code exchange the lock is already held by then; taking it again is a cheap re-entry. SaveConsent is the first grant-row access of the consent decision. Before it, the decision holds only its auth_requests row, which no exclusive lock holder touches.
- `ConsumeCode`: keep the empty-hash early return. Next, read user_id from models.AuthCode by code_hash, without a row lock (Model, Select("user_id"), Where, Limit(1), Scan into a struct with a uint field). When the user id is non-zero, call lockUserGrantsShared. Then run the existing guarded UPDATE unchanged. Because the read does not filter on consumed_at, the replay branch of redeemCode (CodeByHash followed by RevokeAccessTokensForCode) also runs under the lock.
- `RefreshTokenByHashForUpdate`: same pattern on models.RefreshToken by token_hash, before the existing SELECT ... FOR UPDATE.
- Do not add the lock to any other method. TouchConsent, CreateCode, CreateAccessToken, CreateRefreshToken, MarkRefreshRotated, RevokeRefreshLineage and TouchAccessToken always run after one of the five methods above in the same transaction (userinfo calls SubjectFor before TouchAccessToken). AccessTokenByHash stays a plain read.
- Doc comments. On `Store`, add a paragraph stating the invariant: every OIDC transaction that touches a user's grant rows takes that user's grants lock in shared mode before its first row or foreign-key lock on them, so revoke and the grant cascade (exclusive) either wait for a resume, consent decision, code exchange, refresh rotation or userinfo call in flight and then see what it wrote, or run first and leave it the post-revoke state. Each of the five methods gets one sentence saying it takes the lock and why. Extend the lockUserGrantsShared doc in classes/mcp_backend_binding.go: the MCP code exchange and refresh rotation take it, and so do the OIDC store's SubjectFor, ConsentFor, SaveConsent, ConsumeCode and RefreshTokenByHashForUpdate.
- Shared, not exclusive, is deliberate. It is enough to serialize with revoke and the cascade, OIDC flows of one user do not need to exclude each other, and it matches the MCP exchange and refresh. Record this choice in the SUMMARY.
- Do not touch: summercms.go, controllers/*, cascade.go, mcp_store.go, any exported identifier. Do not touch go.work.
Run the four Task 1 tests (GREEN), then run them with -count=10 for stability. A flaky test is a test bug: fix the wait condition, not the assertions.
README (the plugin's module README; behaviour and tests change in the same commit):
- Line 23, **Stores.** bullet: append one sentence. Every OIDC transaction that touches a user's grant rows first takes that user's grants lock (shared). SubjectFor, ConsentFor and SaveConsent take it on entry; ConsumeCode and RefreshTokenByHashForUpdate take it after reading the row's user. A revoke or the grant cascade therefore either waits for a resume, consent decision, code exchange, refresh rotation or userinfo call in flight and then deletes what it issued, or runs first.
- Line 30, after "An MCP allow takes the same lock, so a revoke and an allow of the same user never interleave.": add "The OIDC store takes it too (shared), so a revoke never leaves behind a code or a successor token that a silent resume, a code exchange or a refresh rotation in flight issued."
- Line 32: change "which the MCP code exchange and refresh rotation take too (shared, before they lock their row)" to "which the MCP and OIDC code exchanges and refresh rotations take too (shared, before they lock their row)".
- Testing paragraph: after the TestMCPConsentFailureRollsBackCode sentence, add one sentence. It names TestOIDCResumeSerializesWithRevoke, TestOIDCRefreshSerializesWithRevoke and TestOIDCExchangeSerializesWithRevoke: a revoke that lands while a silent resume, a refresh rotation or a code exchange is parked mid-transaction leaves no code or token for the pair, and the exchange does not deadlock. It also names TestOIDCConsentAllowThenRevoke: a revoke queued behind a consent decision removes the consent and the code that decision issued.
- Every identifier you write must exist. SubjectFor and the other method names are wristband.OIDCTx methods that oidcTx implements.
Gates, run in the plugin directory in this order: go vet ./..., then go test -short ./..., then the full non-short go test -count=1 ./... (use sudo -u golem bash -c '...' if the Docker socket is denied). Fix any failure your change caused. If a failure also reproduces at 21f5985 without your change, it is not yours: record it and stop for the orchestrator.
Commit in the plugin repo, staging explicit paths only and never go.work: classes/oidc_store.go, classes/mcp_backend_binding.go, oidc_grants_race_test.go, mcp_consent_race_test.go, README.md. Commit message "fix(oidc): serialize OIDC grant flows with revoke under the user grants lock". The body says three things. First, this is the OIDC sibling of 32.1 CR-01. Second, a silent resume could issue a code after a revoke, a refresh rotation's successor tokens survived a revoke, and a code exchange could deadlock with one. Third, the OIDC store now takes the user's grants lock (shared) before its first grant row, as the MCP store does, and the decide path was already consistent (pinned). No co-author or attribution trailer: the user's CLAUDE.md overrides that. Do not push or tag. Afterwards `git status --short` must show only ` M go.work`. Record the short hash for Task 3.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin && go vet ./... && go test -count=1 -v -run 'TestOIDC(Resume|Refresh|Exchange)SerializesWithRevoke|TestOIDCConsentAllowThenRevoke|TestOIDCFlowSmoke|TestConsentOnce|TestIncrementalConsent|TestTokenEndpoint|TestRevokeAuthorization|TestAuthorizationsSmoke|TestUserCascadeSmoke|TestMCPConsentAllowSerializesWithRevoke|TestMCPBackendBinding|TestMCPGrantRevocation' . 2>&1 | grep -E -- '^(--- (PASS|FAIL|SKIP)|ok|FAIL|panic)' && go test -short ./... && go test -count=1 ./... 2>&1 | tail -3 && git status --short && git log -1 --format='%h %s' && git show --stat --format= HEAD</automated>
</verify>
<done>Every test named in the verify run prints `--- PASS:`, with no FAIL or SKIP; this includes the four new tests, and the -count=10 run also passed. go vet is clean, and both the -short and the full non-short suites print ok. HEAD in sm-oauthserver-plugin is one new commit touching exactly the five listed files (or, in the not-reproducible branch, the tests and the README only). It has no attribution trailer and is unpushed and untagged. `git status --short` shows only ` M go.work`. `git -C /media/nvme/dev/golem15/summercms.io/summercms/summercms.go status --short -- modules` is empty.</done>
</task>
<task type="auto">
<name>Task 3: Add a one-line note on the OIDC sibling to 32.1-SECURITY.md (edit only; the orchestrator commits)</name>
<files>/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-SECURITY.md</files>
<read_first>
- 32.1-SECURITY.md lines 69-82 (Residual Risk intro and table)
</read_first>
<action>
HASH below is the plugin short hash from Task 2. Use Edit with a scoped replacement, never Write.
In the Residual Risk table, insert one row directly after the CR-01 row. Pick the variant that matches Task 1's outcome.
- Fixed variant: | CR-01 sibling (OIDC) | high | T-32.1-07 | Fixed in sm-oauthserver-plugin HASH (2026-11-05, unreleased): the OIDC decide was already consistent (consent upsert and code in one transaction, ordered against revoke by the consent row lock; pinned by `TestOIDCConsentAllowThenRevoke`), but a silent resume could issue a code after a revoke, a refresh rotation's successor tokens survived a revoke and a code exchange could deadlock with one. The OIDC store now takes the user's grants lock (shared) before its first grant row. Tests `TestOIDCResumeSerializesWithRevoke`, `TestOIDCRefreshSerializesWithRevoke`, `TestOIDCExchangeSerializesWithRevoke`. |
- Not-reproducible variant: | CR-01 sibling (OIDC) | n/a | T-32.1-07 | Not reproducible: the OIDC decide, silent resume, code exchange and refresh paths stay consistent under a concurrent revoke; pinned by tests in sm-oauthserver-plugin HASH. |
- For a partial outcome, name only the paths that reproduced, and say the uniform fix covers all of them.
Change nothing else: not the frontmatter, the register, the sign-off or the audit trail. Do not stage or commit. Leave the files of other sessions untouched (.planning/STATE.md, state.json, G15OFFICE.md and the untracked phase dirs).
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && H=$(git -C /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin rev-parse --short HEAD) && grep -n "| CR-01 sibling (OIDC) |.*$H" .planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-SECURITY.md && git status --short -- .planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con && git diff --stat -- .planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-SECURITY.md</automated>
</verify>
<done>32.1-SECURITY.md has exactly one new Residual Risk row for the OIDC sibling, and it carries the plugin hash. The file shows as modified and unstaged with a one-line insertion. This task touched no other file.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser (signed-in user) → GET /oauth/authorize/resume/{request}, POST /oauth/authorize, POST/GET /oauth/authorize/requests/{request} | resume (silent re-grant) and consent decision for an OIDC client |
| relying party → POST /oauth/token | code exchange and refresh rotation, authenticated with the client secret |
| browser (signed-in user) → DELETE /oauth/authorizations/{client_id} | the revoke that must be final |
| wristband (framework) → classes.oidcTx (plugin) | every OIDC grant read and write goes through the plugin's store, which takes the lock |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-261105-eab-01 | Elevation of privilege | OIDC silent resume vs revoke | high | mitigate | oidcTx.SubjectFor and ConsentFor take lockUserGrantsShared before the consent read, so a revoke cannot commit between the read and TouchConsent plus mintCode; proven by TestOIDCResumeSerializesWithRevoke |
| T-261105-eab-02 | Elevation of privilege | OIDC refresh rotation vs revoke and grant cascade | high | mitigate | RefreshTokenByHashForUpdate reads the token's user, then takes the shared lock before the row lock, so revoke and the cascade wait and their fresh statement snapshots see the successor tokens; proven by TestOIDCRefreshSerializesWithRevoke |
| T-261105-eab-03 | Denial of service | OIDC code exchange vs revoke deadlock | medium | mitigate | ConsumeCode takes the shared lock before the code's row lock, which removes the code-row / consent-row cycle; proven by TestOIDCExchangeSerializesWithRevoke |
| T-261105-eab-04 | Denial of service | new lock waits and cycles | medium | mitigate | the lock is shared only and is taken before any row or foreign-key lock on grant rows in every OIDC transaction. SubjectFor precedes the subject insert's KEY SHARE on users (user delete) and TouchAccessToken (userinfo). The decide holds only its auth_requests row, which no exclusive holder touches, before SaveConsent. No new cross-connection access inside a lock |
| T-261105-eab-05 | Tampering | OIDC consent decision vs revoke | low | accept | already consistent (one transaction, ordered by the consent row lock); pinned by TestOIDCConsentAllowThenRevoke. The fail-closed 500 window (revoke between DO NOTHING and FOR UPDATE) closes with SaveConsent's lock |
| T-261105-eab-SC | Tampering | npm/pip/cargo installs | low | accept | no packages installed, no dependency added |
</threat_model>
<verification>
- Task 1 RED evidence: A, B and C show `--- FAIL:` with the predicted reasons and D shows `--- PASS:` on 21f5985. Recorded in the SUMMARY.
- Task 2 GREEN: all four new tests and the regression set print `--- PASS:` with no SKIP, the -count=10 run is stable, the full non-short suite is green, and there is one plugin commit with explicit paths. go.work stays modified and unstaged. Nothing is pushed or tagged.
- No change under summercms.go/modules, so no framework release is needed. The plugin's go.mod is unchanged (summercms v0.6.0), so the orchestrator's v0.1.7 tag is not blocked.
- Task 3: one Residual Risk row in 32.1-SECURITY.md with the hash, left uncommitted.
</verification>
<success_criteria>
- A revoke of an OIDC client can no longer be outlived by a code from a silent resume, or by tokens from a refresh rotation, that was in flight. A code exchange no longer deadlocks with a revoke.
- The finding for the decide path is pinned by a deterministic test.
- The fix is plugin only: no framework change, no exported API change, MCP paths untouched.
- Deterministic tests: a parked transaction (row lock or gate trigger) plus a pg_stat_activity waiter count. They fail on the old code and pass on the new.
</success_criteria>
<output>
Create /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/quick/261105-eab-fix-oidc-consent-decide-race-with-revoke/261105-eab-SUMMARY.md when done. Include:
- the plugin commit hash, the RED and GREEN test lines, and the gate outcome;
- the shared-vs-exclusive choice;
- the finding per path (decide consistent; resume, refresh and exchange fixed);
- a note for the orchestrator: no framework change, v0.1.7 not blocked;
- an out-of-scope observation, not changed here: the grant cascade still filters tokens by created_at < Before, so a token a concurrent rotation issued just after Before is not revoked. MCP has the same behaviour.
</output>

View File

@@ -0,0 +1,133 @@
---
phase: quick-261105-eab
plan: 01
subsystem: sm-oauthserver-plugin (OIDC store)
status: complete
tags: [oidc, oauth, revoke, concurrency, advisory-lock, security, cr-01]
requires:
- quick-261105-din (CR-01 MCP fix, plugin 21f5985)
provides:
- OIDC store takes the user's grants lock (shared) before its first grant row
- deterministic OIDC-vs-revoke race tests (resume, refresh, exchange, decide)
affects:
- 32.1-SECURITY.md residual risk (CR-01 sibling row, uncommitted)
tech-stack:
added: []
patterns:
- "read row owner by hash, take pg_advisory_xact_lock_shared, then row-lock (copied from MCP ByCodeHashForUpdate)"
- "race tests: parked transaction (row lock or BEFORE INSERT gate trigger on a two-int advisory lock) plus pg_stat_activity lock-waiter count"
key-files:
created:
- /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/oidc_grants_race_test.go
modified:
- /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/classes/oidc_store.go
- /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/classes/mcp_backend_binding.go
- /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/mcp_consent_race_test.go
- /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/README.md
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-SECURITY.md (uncommitted)
decisions:
- "OIDC store takes the user grants lock in SHARED mode (not exclusive): shared already serializes with revoke and the grant cascade (exclusive); OIDC flows of one user need not exclude each other (upsert and guarded UPDATEs handle that); matches the MCP exchange and refresh."
- "Lock points: SubjectFor, ConsentFor, SaveConsent on entry; ConsumeCode and RefreshTokenByHashForUpdate after reading the row's user_id by hash. No other method locks."
- "Plugin-only fix: no framework (summercms.go/modules) change, no exported identifier added or changed, MCP paths untouched."
metrics:
duration: ~15min
completed: 2026-11-05
tasks: 3
actuals:
tokens: 13000
tasks: 3
commits: 1
plan_head_before: 21f59850d4f96789a363c52eeb02449c2d6498c1
plan_head_after: 7b9899f36f45a13daccf3ec6f7d5c729ebfcc5f1
---
# Quick 261105-eab: OIDC sibling of CR-01 (OIDC grant flows vs revoke) Summary
The plugin's OIDC store now takes the user's grants advisory lock (shared) before its first grant-row or foreign-key lock, so a revoke or the grant cascade can no longer be outlived by a code from a silent resume or by successor tokens from a refresh rotation in flight, and a code exchange no longer deadlocks with a revoke.
The repo of record for code is sm-oauthserver-plugin; `commits`, `plan_head_before` and `plan_head_after` above are measured there (`git rev-list --count 21f5985..HEAD` = 1).
## Plugin commit
- `7b9899f` fix(oidc): serialize OIDC grant flows with revoke under the user grants lock (sm-oauthserver-plugin, master, unpushed, untagged). Files: classes/oidc_store.go, classes/mcp_backend_binding.go, oidc_grants_race_test.go, mcp_consent_race_test.go, README.md. No attribution trailer. `git status --short` afterwards: only ` M go.work` (untouched).
## Gate outcome (Task 1)
Expected result, as predicted: A, B and C failed on 21f5985 for the analysed reasons, D passed. Task 2's full fix applied.
## RED evidence (21f5985, production code unchanged)
```
oidc_grants_race_test.go:322: after resume and revoke (revoke answered while the resume was parked: true): 0 consent rows, 1 live codes, want 0 and 0
oidc_grants_race_test.go:326: the resume's code exchanged (200) after the revoke
--- FAIL: TestOIDCResumeSerializesWithRevoke (0.75s)
oidc_grants_race_test.go:386: after refresh and revoke: 1 access tokens, 1 refresh tokens, want 0 and 0
oidc_grants_race_test.go:395: successor refresh token after the revoke = 200, error "", want 400 invalid_grant
oidc_grants_race_test.go:398: userinfo with the successor access token after the revoke = 200, want 401
--- FAIL: TestOIDCRefreshSerializesWithRevoke (0.63s)
oidc_grants_race_test.go:437: exchange = 200 (withheld: it carries a code or token), revoke = 500 {"error":"server_error"}, want 200 and 200
--- FAIL: TestOIDCExchangeSerializesWithRevoke (1.67s)
--- PASS: TestOIDCConsentAllowThenRevoke (0.81s)
FAIL
```
No code or token appears in any failure line (200/302 bodies are withheld). The 1.67 s on C is Postgres's deadlock_timeout (1 s) firing; the revoke was the victim in this run.
## GREEN evidence (after the fix)
```
--- PASS: TestOIDCResumeSerializesWithRevoke (0.89s)
--- PASS: TestOIDCRefreshSerializesWithRevoke (0.62s)
--- PASS: TestOIDCExchangeSerializesWithRevoke (0.62s)
--- PASS: TestOIDCConsentAllowThenRevoke (0.65s)
ok git.golem15.com/golem15/sm-oauthserver-plugin 7.594s
```
- `-count=10` stability run: 40/40 `--- PASS:`, no FAIL, `ok` (32.0 s).
- Regression set (verify command): `--- PASS:` for TestAuthorizationsSmoke, TestUserCascadeSmoke, TestRevokeAuthorization, TestConsentOnce, TestIncrementalConsent, TestMCPBackendBinding, TestMCPConsentAllowSerializesWithRevoke, TestMCPGrantRevocation, TestOIDCFlowSmoke, TestTokenEndpoint and the four new tests; no FAIL, no SKIP.
- `go vet ./...` clean; `go test -short ./...` ok; full non-short `go test -count=1 ./...` ok (101.3 s). A verbose full run printed 71 top-level `--- PASS:` lines, no `--- FAIL:`, and one pre-existing `--- SKIP: TestIDTokenShapeParity`: it skips without the PHP parity recordings (`requireParityRecordings`, parity_test.go), unrelated to this change. Docker was reachable directly (no sudo -u golem needed).
## Finding per path
| Path | Before | After |
|------|--------|-------|
| Consent decide (`decideConsent`: SaveConsent + mintCode) | Consistent (one tx; ordered against revoke by the consent row lock). Only defect: a fail-closed 500 if a revoke committed between ON CONFLICT DO NOTHING and FOR UPDATE. Pinned by D. | Same outcome, now ordered by the grants lock; the 500 window is closed by SaveConsent's lock. |
| Silent resume (`Server.Resume`) | RACE: revoke committed while resume was parked; resume then issued a live code with no consent (A). | Fixed: SubjectFor/ConsentFor hold the shared lock; the revoke waits and deletes the issued code. |
| Refresh rotation (`rotateOIDCRefresh`) | RACE: successor access and refresh tokens survived the revoke (B). | Fixed: RefreshTokenByHashForUpdate takes the lock before the row lock; the revoke's fresh statement snapshots see the successors. |
| Code exchange (`redeemCode`) | DEADLOCK: code row (exchange) vs consent row (revoke); one side 500 (C). | Fixed: ConsumeCode takes the lock before the code's row lock; both answer 200, no tokens left. |
## Shared vs exclusive
Shared, deliberately. Revoke and the grant cascade take the lock exclusively, so a shared holder already serializes every OIDC flow with them. OIDC flows of the same user do not need to exclude each other (the consent upsert and the guarded UPDATEs handle concurrent flows), and shared matches what the MCP code exchange and refresh rotation already take.
## Deviations from Plan
None in code or tests; the plan executed as written.
- Process note: the plugin commit landed on the plugin repo's `master`, as the plan and orchestrator constraints direct (that repo's convention, as for 21f5985). The executor's generic protected-branch pre-commit assertion was not applied to the sibling plugin repo.
- The orchestrator's constraint (and the user's CLAUDE.md) overrode the session's default co-author trailer: none was added.
## Notes for the orchestrator
- No framework change: `git -C summercms.go status --short -- modules` is empty. No framework tag needed.
- The plugin's go.mod is unchanged (still requires summercms v0.6.0), so nothing blocks the plugin's v0.1.7 tag. Tagging and pushing are left to the orchestrator.
- 32.1-SECURITY.md has one new Residual Risk row "CR-01 sibling (OIDC)" carrying hash 7b9899f; it is modified and unstaged (1 insertion), not committed.
- Not committed per instructions: this SUMMARY, STATE.md, the PLAN and the 32.1 docs. ROADMAP.md untouched.
## Out of scope (observed, not changed)
The grant cascade (`Cascade.OnAccessRevoked`) still filters tokens by `created_at < Before`. A token that a concurrent rotation issues just after `Before` is therefore not revoked by the cascade, even though the rotation now waits for the cascade's lock. MCP has the same behaviour. This is the cascade's cutoff semantics, not a lock-ordering gap, and was left as is.
## Threat surface
No new endpoints, auth paths or schema changes. The new test-only trigger and function (`oas_test_gate`) live only in the per-test database and are dropped in Cleanup.
## Known Stubs
None.
## Self-Check: PASSED
- FOUND: sm-oauthserver-plugin/oidc_grants_race_test.go
- FOUND: commit 7b9899f in sm-oauthserver-plugin
- FOUND: "| CR-01 sibling (OIDC) |" row with 7b9899f in 32.1-SECURITY.md

View File

@@ -0,0 +1,288 @@
---
phase: quick-261105-oc5
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- ../sm-user-plugin/controllers/api_controller.go
- ../sm-user-plugin/controllers/login_gates.go
- ../sm-user-plugin/classes/login_gate.go
- ../sm-user-plugin/login_gate_test.go
- ../sm-user-plugin/README.md
- ../sm-mfa-plugin/go.work
- ../sm-mfa-plugin/go.work.sum
- ../sm-mfa-plugin/classes/config.go
- ../sm-mfa-plugin/config/config.yaml
- ../sm-mfa-plugin/controllers/management.go
- ../sm-mfa-plugin/controllers/webauthn.go
- ../sm-mfa-plugin/factor_password_test.go
- ../sm-mfa-plugin/login_paths_test.go
- ../sm-mfa-plugin/settings_test.go
- ../sm-mfa-plugin/tracer_smoke_test.go
- ../sm-mfa-plugin/webauthn_smoke_test.go
- ../sm-mfa-plugin/webauthn_test.go
- ../sm-mfa-plugin/php_bodies_test.go
- ../sm-mfa-plugin/challenge_smoke_test.go
- ../sm-mfa-plugin/recovery_test.go
- ../sm-mfa-plugin/email_test.go
- ../sm-mfa-plugin/gate_test.go
- ../sm-mfa-plugin/trusted_test.go
- ../sm-mfa-plugin/passwordless_smoke_test.go
- ../sm-mfa-plugin/README.md
- ../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/vue-demo-app/i18n/locales/en.json
- ../sm-demo-app/vue-demo-app/i18n/locales/pl.json
- ../sm-demo-app/vue-demo-app
autonomous: true
requirements:
- quick-261105-oc5
must_haves:
truths:
- "With a login gate that challenges, POST /_user/api/v1/activate-by-code with a valid code answers the gate's status and body (for golem15.mfa: 200 {two_factor_required, challenge_token, available_methods, expires_at}) and no token or session cookie; the account is activated either way (F-1, per D-01)"
- "Without a gate, or when every gate allows, activate-by-code answers exactly as before ({message: Account activated, user, token} or the HttpOnly cookie with golem15.user.jwt.token_cookie); a gate error answers the opaque 500 with no token and the account stays activated (D-01)"
- "In sm-mfa-plugin, DELETE /2fa/totp, DELETE /2fa/email, DELETE /2fa/webauthn/{id}, POST /2fa/webauthn/register, and POST /2fa/totp/confirm for a user who already has an enabled second factor answer 403 {\"error\":\"Password confirmation required.\"} without the right password and change nothing (WR-05, per D-02, D-03)"
- "A wrong password on those routes is a failed login attempt in golem15.user's throttle, and a suspended or banned account is refused even the right password, exactly as on the recovery-code routes (D-04)"
- "The first totp/confirm of a user with no enabled factor, email/enable, totp/setup, webauthn/register/options and the trusted-device routes keep their current contract (no password); validation 422 bodies keep PHP's shape and order (D-03)"
- "golem15.mfa.factor_changes_require_password (default true) set to false restores PHP's password-free contract on all five routes (D-05)"
- "Two concurrent password-free totp/confirm calls of a user with no factor end with exactly one 200; the other answers 403 and the first secret stays stored (D-03)"
- "The demo site's /account/security asks for the password before turning the authenticator app off, sends it in the JSON body of DELETE /2fa/totp (never in the query string), shows a localized wrong-password message on the 403, and asks for it in the setup form when another factor is already on (D-06)"
artifacts:
- path: ../sm-user-plugin/controllers/api_controller.go
provides: "ActivateByCode consults CheckLoginGates after VerifyActivationCode and before mintFor"
- path: ../sm-user-plugin/login_gate_test.go
provides: "TestActivateByCodeGates (Postgres)"
- path: ../sm-mfa-plugin/controllers/management.go
provides: "password helper split (passwordFrom, checkPassword) reused by confirmPassword and the five factor-changing handlers"
- path: ../sm-mfa-plugin/classes/config.go
provides: "Config.FactorChangesRequirePassword (factor_changes_require_password, default true)"
- path: ../sm-mfa-plugin/factor_password_test.go
provides: "TestFactorChangesNeedPassword (Postgres)"
- path: ../sm-mfa-plugin/login_paths_test.go
provides: "activation_by_code_is_challenged subtest and the updated tokenMints reason"
- path: ../sm-demo-app/vue-demo-app/app/pages/account/security.vue
provides: "password step for turning TOTP off and for confirming TOTP beside another factor"
key_links:
- from: "sm-user-plugin controllers.ActivateByCode"
to: "controllers.CheckLoginGates with classes.LoginSourcePassword"
via: "the gate answer replaces the token response; sm-mfa-plugin's existing Gate challenges source password"
- from: "sm-mfa-plugin TOTPDisable/EmailDisable/WebAuthnRemove/WebAuthnRegister/TOTPConfirm"
to: "userclasses.CheckAndRecordLogin and userclasses.ThrottleStates"
via: "the shared checkPassword helper (the body of today's confirmPassword)"
- from: "vue-demo-app useTwoFactor.disableTotp(password)"
to: "DELETE /_user/api/v1/2fa/totp JSON body {password}"
via: "$fetch body"
---
<objective>
Close the two deferred 2FA hardening items: finding F-1 (sm-user-plugin's activate-by-code mints a session without asking the login gates, so with `require_activation: false` the mailed activation code stands in for an enabled second factor) and review finding WR-05 (sm-mfa-plugin lets a bare session JWT remove, replace or add second factors). Then the demo frontend asks for the password where the plugin now requires it.
Purpose: a stolen session or a mailbox alone must not get past or rewrite a user's second factor. Both plugins are shared core plugins (sm-demo-app and sm-bm-app mount golem15.mfa; fonoteka.go, sm-demo-app and sm-bm-app mount golem15.user), so the fix is additive where it can be, and the one contract change (WR-05) is explicit in the todo and comes with a config knob. The only frontend that calls the changed routes is sm-demo-app's vue-demo-app (checked at planning: sm-bm-app's frontend and fonoteka call none of them).
Output: one commit in sm-user-plugin, two in sm-mfa-plugin (workspace repoint, then the fix), one in vue-demo-app, one vue-demo-app pointer bump in sm-demo-app. No tag, push, deploy, go.mod version or plugin submodule bump.
Source todos (read them first): `.planning/todos/pending/2026-10-26-activate-by-code-second-factor.md` (g15office yfadd1mb) and `.planning/todos/pending/2026-10-26-mfa-factor-changes-need-reauth.md` (g15office krt3vrts). Background: `.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-SECURITY-REVIEW.md` "F-1" (option (a) is the one implemented here) and `19.6-REVIEW.md` "WR-05".
Decisions for this item (from the item description, the todos and the coordinator notes), cited below as D-01..D-07:
- D-01 (F-1): `ActivateByCode` calls `CheckLoginGates` after `VerifyActivationCode` succeeds, with `Source: classes.LoginSourcePassword` (the mailed code is the first factor here), and answers the gate's status and body instead of the token. The account stays activated. No new login source: sm-mfa-plugin's existing gate (every released version) already challenges source password, so the user-plugin patch alone closes F-1 in every app, and no cross-plugin compile dependency appears (19.6-SECURITY-REVIEW F-1 option (a)).
- D-02 (WR-05): password confirmation on `DELETE /2fa/totp`, `DELETE /2fa/email`, `DELETE /2fa/webauthn/{id}`, `POST /2fa/webauthn/register` (every registration, passkey or not) and `POST /2fa/totp/confirm` when it would replace or add beside an enabled factor. The refusal is the existing 403 `{"error":"Password confirmation required."}`.
- D-03: contract edges. Validation (422) and "method not available" (403) answers come before the password check, so PHP's bodies for malformed requests stay the same. Routes not named in D-02 are unchanged. Claude's discretion: `totp/confirm` asks for the password when the user has ANY enabled second factor (not only TOTP). The todo says "when TOTP is already on"; this is a superset of it. Adding a TOTP secret beside an enabled email or WebAuthn factor gives a session thief who also phished the password a factor they control, which is the same risk as replacing one. A user with no factor still enables the first one password-free, as before (their account is protected only by the password anyway). The decision and the factor write run in one transaction under a per-user row lock, so a race cannot slip a second secret past the check.
- D-04: the check is today's `confirmPassword` path: every non-empty password is recorded through `userclasses.CheckAndRecordLogin` for the client address, a banned or suspended account is refused even the right password, and a missing password is refused without recording (WR-06 behaviour, 0a08070).
- D-05 (config knob, Claude's discretion following the coordinator's "keep default behaviour where a config knob makes sense"): new config-only key `golem15.mfa.factor_changes_require_password`, default `true` (the todo asks for the requirement), env `SUMMER_GOLEM15__MFA__FACTOR_CHANGES_REQUIRE_PASSWORD`. `false` restores PHP's password-free contract for an application whose ported PHP frontend cannot send the password yet. The README states that this re-opens WR-05. It is not on the admin settings row (no migration), like `failure_cap.*`.
- D-06 (frontend): vue-demo-app is the submodule checkout `../sm-demo-app/vue-demo-app` (remote golem15/vue-demo-app, HEAD fe5d38d at planning). The sibling `../vue-demo-app` is a stale older checkout: do not edit it. The page asks for the password in the page, not through window.confirm, sends it only in the JSON body, and clears it after every attempt. Sending a password to today's released sm-mfa-plugin v0.1.3 is harmless (it ignores the field), so the frontend change is safe to ship ahead of the plugin release.
- D-07 (release discipline): each repo commits on its own, with explicit paths and no co-author trailer. Commit messages carry no planning IDs (they feed the changelog). No tag, push or deploy. sm-demo-app's README requires `go.mod` to pin tags and each plugin submodule to sit on its pinned tag (`scripts/check-pins.sh`), so the plugin submodule and go.mod bumps in sm-demo-app, sm-bm-app and fonoteka.go wait until the user tags the plugins. They are listed as follow-ups in the SUMMARY. Only the vue-demo-app gitlink is bumped here.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/CLAUDE.md
@/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/todos/pending/2026-10-26-activate-by-code-second-factor.md
@/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/todos/pending/2026-10-26-mfa-factor-changes-need-reauth.md
Repos (absolute; paths in files_modified are relative to summercms.go):
- U = /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin (master, HEAD 97f83cc at planning, clean)
- M = /media/nvme/dev/golem15/summercms.io/summercms/sm-mfa-plugin (master, HEAD 544017c at planning, clean)
- F = /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/vue-demo-app (master, HEAD fe5d38d, clean)
- D = /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app (clean; vue-demo-app is its submodule)
Several sessions work in these repos at once (batch items oc6 and oc9 also touch sm-demo-app). Run `git status --short` in a repo before editing it and again before staging, and stage only the paths this plan changes.
Facts gathered during planning (do not re-derive):
- U `controllers/api_controller.go` `ActivateByCode` (~line 594): sessionDeps, sessionCookie, readFields, validate code, splitCode, IsAlreadyActivated, `VerifyActivationCode(codeContext(...), gdb, id, code)` returns `(*models.User, bool)`, then `mintFor`, `apiArray`, `writeSession(w, app, cc, token, {"message":"Account activated","user":…})`. `Login` (~lines 133-160) shows the gate-call pattern to copy: `CheckLoginGates(ctx, app, classes.LoginAttempt{User, Source, Fields, Request})`; on error `pluginLog.Error("golem15.user: login gate", "user_id", …, "err", err)` and `writeOpaque500`; on a result, status 0 means 200 and `writeJSON(w, status, gate.Body)`.
- U `controllers/login_gates.go` `CheckLoginGates` doc says "Password Login calls it; a social-login plugin calls it". U `classes/login_gate.go` documents `LoginSourcePassword` as "POST /_user/api/v1/login" and `LoginAttempt.Fields` as "it holds the password on a password login".
- U test harness: `login_gate_test.go` has `countingGate` (records attempts, answers its result) and `gatedEnv(t, result) (*adminEnv, *countingGate)`; `login_gate_smoke_test.go` has `gateFunc` (a func implementing LoginGate, for an error gate). `adminEnv` (admin_harness_test.go) has `gdb`, `seedUser(t, label, mutate)` (password "correct-horse"), `call(t, method, path, token, body)`. `account_test.go TestActivateByCode` packs the code as `strconv.FormatUint(uint64(user.ID), 10) + "!" + code` from `classes.IssueActivationCode(ctx, db, user)`. `token_cookie_test.go TestTokenCookie` covers activate-by-code with the cookie.
- U baseline (planning run): `go vet ./...` clean; `go test -short -count=1 ./...` fails only `TestRegisterCORSPath` ("config/http.yaml not found"; it needs an application checkout, known). Docker works on this host; `-short` still runs some Postgres tests.
- M `go.work` replaces sm-user-plugin with `../fonoteka.go/plugins/golem15/user`, a stale checkout (a1b78b8). M's tests (`TestEveryLoginPathIsGated` compiles the user plugin and scans its source through `go list -m`) therefore never see a U change. At planning, M built, vetted and passed its full suite (`go test -count=1 ./...`, about 55 s with Docker) against U's current checkout through a temporary GOWORK file that replaced sm-user-plugin with ../sm-user-plugin.
- M `controllers/management.go`: `confirmPassword(w, r, app, db, user)` reads `password` from the query, else from `readFields(r)["password"].(string)` (raw, never trimmed), refuses an empty one with 403, refuses banned/suspended (`userclasses.ThrottleStates`), checks `bouncer.CheckPassword`, records through `userclasses.CheckAndRecordLogin(ctx, db, user, classes.ClientIP(app, r), ok)` with `ctx := userclasses.ContextWithThrottle(r.Context(), app)`. `readFields` consumes the body, so a handler that already read its fields must not call it again. `field()` trims, so never use it for the password. `disableHandler` serves TOTPDisable and EmailDisable. `TOTPConfirm` runs signedIn, readFields, validate(totpConfirmRules), MethodAvailable, VerifyTOTP, then a transaction with `classes.EnableMethod` (an upsert that overwrites an enabled secret), `EnsureRecoveryCodes`, `Fire(EnabledEvent)`.
- M `controllers/webauthn.go`: `WebAuthnRegister` (~line 125) runs signedIn, readFields, validate(webAuthnRegisterRules), MethodAvailable, jsonField attestation, cache, a transaction with FinishRegistration, EnableMethod, EnsureRecoveryCodes, Fire. `WebAuthnRemove` (~line 202) parses `{id}` (404 on bad id) then a transaction.
- M `classes/methods.go`: `EnabledMethods(ctx, db, userID) ([]string, error)`, `MethodEnabled(ctx, db, userID, method)`. M `classes/config.go`: `Config` struct plus `LoadConfig(app)`. Booleans are read with `c.Bool`, which gives false for a missing key, so a default-true key needs `c.Has` first. `Effective` embeds `Config`; `overlay` lays the settings row over it and must not touch the new field.
- M tests that call the five routes today (all must pass after the change): `tracer_smoke_test.go` (`enableTOTP` helper ~line 73, a direct confirm ~line 129), `webauthn_smoke_test.go` (`registerKey` helper ~line 173 and direct register/delete calls ~220-328), `webauthn_test.go` (~296-341, ~454), `php_bodies_test.go` (~116-154), `challenge_smoke_test.go:315`, `recovery_test.go` (~90-113: a second confirm and a confirm after email), `email_test.go:162`, `gate_test.go:171`, `trusted_test.go` (~388-426), `passwordless_smoke_test.go:143`. Harness users all have password `testPassword` (harness_test.go:38). `settings_test.go TestSettings` covers LoadConfig through `configApp(t, map)`.
- M `login_paths_test.go`: `tokenMints` has the entry `"controllers/api_controller.go ActivateByCode mintFor": "accepted debt of plan 01; see 19.6-SECURITY-REVIEW.md finding F-1"`; `TestEveryLoginPathIsGated` has subtests mint_sites, password_is_challenged, social_sign_in, other_sources_carry_their_own_proof, passkey_only_with_its_assertion, device_poll_only_after_an_authorize, registration_mints_without_a_gate. Helpers: `e.user(t)`, `e.enableTOTP(t, user)`, `e.call`, `e.verify(t, challenge, "totp", nextCode(t, secret))`, `freshIP()`, `mustStatus`.
- M README: Routes table rows ~60-70, Passkeys ~line 87 and Security notes ~line 164 say the management routes need only the session token (PHP's contract), Configuration table ~89-108 ("The settings screen overrides all of them but `issuer` and `failure_cap.*`"), Testing ~181-189.
- F: `app/composables/useTwoFactor.ts` (`confirmTotp(secret, code)`, `disableTotp()` with DELETE and no body), `app/lib/twoFactor.ts` (pure helpers, tests in `app/lib/twoFactor.test.ts`, 14 passing), `app/pages/account/security.vue` (turnOff uses `window.confirm(t('security.turnOffConfirm'))`; the setup form shows only while TOTP is off; errors go through `authErrors(e, t, fallback)` from `app/composables/useDemoAuth.ts`, which surfaces `data.error` verbatim, so the English 403 text would show on /pl), `i18n/locales/en.json` and `pl.json` "security" blocks (~line 174). `pnpm test` = vitest in Node (pure libs only); `pnpm build` = nuxt build.
- D README: "Push vue-demo-app before the pointer bump in this repository"; a pointer bump commit looks like `6686216 chore: bump vue-demo-app (SEO layers, Polish under /pl/)`.
</context>
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: sm-user-plugin: activate-by-code asks the login gates after the code verifies (F-1)</name>
<files>../sm-user-plugin/controllers/api_controller.go, ../sm-user-plugin/controllers/login_gates.go, ../sm-user-plugin/classes/login_gate.go, ../sm-user-plugin/login_gate_test.go, ../sm-user-plugin/README.md</files>
<read_first>U controllers/api_controller.go (Login ~40-185 and ActivateByCode ~590-645), controllers/login_gates.go, classes/login_gate.go, login_gate_test.go (countingGate, gatedEnv, TestLoginGateOrder), login_gate_smoke_test.go (gateFunc), account_test.go TestActivateByCode (~95-137), README.md bullets "Registration and activation" (line 19), "Login gates" (line 21), the classes/controllers API reference lines on CheckLoginGates and the LoginSource constants (~236, ~256) and the Testing paragraph on the login-gate seam (~360)</read_first>
<behavior>
- TestActivateByCodeGates (Postgres, skipped under -short like TestLoginGateOrder), each case with its own not-activated user (seedUser with IsActivated false) and a fresh activation code:
- challenge gate (gatedEnv with a {two_factor_required…} result): POST /_user/api/v1/activate-by-code answers 200 with exactly the gate body, no "token" key and no Set-Cookie; the gate ran once and saw Source == classes.LoginSourcePassword, the right User.ID, Fields["code"] == the packed code, a non-nil Request; the users row is_activated is true afterwards.
- gate with Status 401 and its own body: the answer carries that status and body, and the account is still activated.
- allowing gate (nil result): the answer is 200 {message: "Account activated", user, token} as before and the gate ran once.
- erroring gate (gateFunc returning an error): opaque 500, no token, account still activated, and a second activate-by-code with the same code answers the Winter error page because the account is already activated (unchanged IsAlreadyActivated behaviour).
- invalid or expired code: the gate never runs (count 0) and the 422 body is unchanged.
- existing TestActivateByCode, TestTokenCookie and TestLoginGateOrder stay green unchanged.
</behavior>
<action>
Write TestActivateByCodeGates in login_gate_test.go first, run it red, then implement (D-01).
In ActivateByCode, after `VerifyActivationCode` returns a verified user and before `mintFor`, call `CheckLoginGates(r.Context(), app, classes.LoginAttempt{User: user, Source: classes.LoginSourcePassword, Fields: fields, Request: r})` (the fields the handler already read; do not read the body again). On an error, log it with `pluginLog.Error("golem15.user: login gate", "user_id", user.ID, "err", err)` and answer `writeOpaque500`. On a non-nil result, answer `writeJSON(w, status, gate.Body)` with status 0 mapped to 200, the same as Login. On both paths, mint nothing and write no session cookie. The activation `VerifyActivationCode` already committed stays as it is (the account is activated whatever the gate answers). With no result, the handler continues exactly as today. Update ActivateByCode's doc comment: it activates, consults the login gates (as password Login does, with source password, because the mailed code is the first factor here), then mints; a gate answer replaces the token response.
Doc comments: in classes/login_gate.go, `LoginSourcePassword` covers POST /_user/api/v1/login and POST /_user/api/v1/activate-by-code (the mailed activation code), and `LoginAttempt.Fields` holds the password on a password login and the activation code on an activation. In controllers/login_gates.go, `CheckLoginGates` is called by password Login, activate-by-code and a social-login plugin.
README.md (same commit, per CLAUDE.md documentation rules): in "Registration and activation", `POST /activate-by-code` answers with a token unless a login gate holds the sign-in back (for example a second-factor challenge, which the user completes through the gate's plugin), and the account is activated either way. In "Login gates", list activate-by-code as a caller (after the code verifies, source password), and add the reason: with `require_activation: false` an account can enable a second factor before it activates, so the mailed code must not stand in for that factor. Update the CheckLoginGates API reference line and the Testing paragraph to name TestActivateByCodeGates.
Commit in U (D-07): `git -C U status --short` first, then stage the five paths explicitly and commit with a message like "fix: activate-by-code asks the login gates before it signs in". No planning IDs, no trailer.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin && go build ./... && go vet ./... && go test -count=1 -run 'TestActivateByCodeGates|TestActivateByCode$|TestTokenCookie|TestLoginGates$|TestLoginGateOrder|TestLoginGateSmoke|TestLoginGatesSmoke' . && N=$(go test -short -count=1 ./... 2>&1 | grep -E '^--- FAIL' | grep -vc 'TestRegisterCORSPath'); test "$N" -eq 0</automated>
</verify>
<done>TestActivateByCodeGates passes with Docker and covers the challenge, refusal, allow, error and bad-code cases. The existing activate-by-code, token-cookie and gate tests pass unchanged. `go test -short ./...` has no failure other than the known TestRegisterCORSPath. One commit in sm-user-plugin with code, doc comments and README.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: sm-mfa-plugin: factor changes ask for the password (WR-05) and activate-by-code is pinned as gated (F-1)</name>
<files>../sm-mfa-plugin/go.work, ../sm-mfa-plugin/go.work.sum, ../sm-mfa-plugin/classes/config.go, ../sm-mfa-plugin/config/config.yaml, ../sm-mfa-plugin/controllers/management.go, ../sm-mfa-plugin/controllers/webauthn.go, ../sm-mfa-plugin/factor_password_test.go, ../sm-mfa-plugin/login_paths_test.go, ../sm-mfa-plugin/settings_test.go, ../sm-mfa-plugin/tracer_smoke_test.go, ../sm-mfa-plugin/webauthn_smoke_test.go, ../sm-mfa-plugin/webauthn_test.go, ../sm-mfa-plugin/php_bodies_test.go, ../sm-mfa-plugin/challenge_smoke_test.go, ../sm-mfa-plugin/recovery_test.go, ../sm-mfa-plugin/email_test.go, ../sm-mfa-plugin/gate_test.go, ../sm-mfa-plugin/trusted_test.go, ../sm-mfa-plugin/passwordless_smoke_test.go, ../sm-mfa-plugin/README.md</files>
<read_first>M go.work, controllers/management.go (whole file), controllers/webauthn.go (WebAuthnRegister, WebAuthnRemove), controllers/helpers.go (readFields, field, signedIn, writeError), classes/config.go (Config, LoadConfig, Effective, overlay), classes/methods.go (EnabledMethods, EnableMethod), config/config.yaml, login_paths_test.go, settings_test.go TestSettings, harness_test.go (bootMFA, user, sessionToken, call), the confirm-password throttle test (grep TestPasswordConfirmationThrottle) for how a recorded failed attempt is asserted, README.md sections Routes, Passkeys, Configuration, Security notes, Testing</read_first>
<behavior>
- Workspace: after the repoint, `go list -m -f '{{.Dir}}' git.golem15.com/golem15/sm-user-plugin` in M prints the sm-user-plugin checkout, and the whole suite passes before any WR-05 change (baseline).
- TestSettings additions (no Docker): LoadConfig without the key gives FactorChangesRequirePassword true; key false gives false; env SUMMER_GOLEM15__MFA__FACTOR_CHANGES_REQUIRE_PASSWORD=false gives false; a settings row never changes it.
- TestFactorChangesNeedPassword (Postgres), one user per case, enabled through the existing helpers:
- DELETE /2fa/totp, DELETE /2fa/email and DELETE /2fa/webauthn/{id} without a password: 403 {"error":"Password confirmation required."}, and the method or credential is still there. With a wrong JSON-body password: the same 403, a failed attempt recorded for the user (assert as TestPasswordConfirmationThrottle does), and nothing changed. With the right password: the PHP 200 body ({message, status}) and the factor gone.
- POST /2fa/webauthn/register without a password: 403 and no new credential, also when the user has no factor at all and also with passwordless true. With the right password: 200 {credential, message, status}.
- POST /2fa/totp/confirm: a user with no factor enables TOTP without a password (200 with recovery_codes, unchanged). With TOTP on, a new secret without a password gives 403 and the stored secret is unchanged (decrypt or compare the ciphertext before and after); with the password gives 200. A user with only email on adding TOTP without a password gives 403, with the password 200.
- Ordering: a malformed totp/confirm or webauthn/register body without a password still answers the PHP 422 body. A method that is not available still answers its 403 "… is not available." body.
- Suspended account: after attempt_limit wrong passwords, the right password on DELETE /2fa/totp answers 403 and TOTP stays on.
- Race: two parallel password-free totp/confirm calls (different secrets, each with a valid code) for a user with no factor end with exactly one 200 and one 403, and the stored secret is the winner's.
- Knob: with golem15.mfa.factor_changes_require_password false (boot env or Config.Set), every route above works without a password (PHP contract).
- TestEveryLoginPathIsGated gains activation_by_code_is_challenged: a user with TOTP is set is_activated false and gets a code from userclasses.IssueActivationCode. POST /_user/api/v1/activate-by-code with "{id}!{code}" answers 200 two_factor_required true with no "token". The users row is activated. Completing the challenge with e.verify and nextCode mints a token. The tokenMints reason for ActivateByCode no longer says accepted debt.
</behavior>
<action>
Step A, workspace (own commit): `git -C M status --short`. In M go.work, change the sm-user-plugin replacement from ../fonoteka.go/plugins/golem15/user to ../sm-user-plugin, the plugin's own repository, which now holds Task 1. The fonoteka checkout is stale and would hide Task 1 from M's tests. Run `go build ./... && go vet ./... && go test -count=1 ./...` in M (the baseline must be green at this point, as it was at planning). Commit go.work (and go.work.sum only if Go changed it) as "chore: develop against the sm-user-plugin checkout".
Step B, tests first: add the TestSettings rows, write factor_password_test.go (TestFactorChangesNeedPassword) and the login_paths_test.go subtest and reason, and run them red.
Step C, implementation (D-02..D-05):
- classes/config.go: add `FactorChangesRequirePassword bool` to Config with a doc comment naming the key, its default and what false re-opens. LoadConfig defaults it to true and reads `c.Bool` only when `c.Has(ConfigPrefix + "factor_changes_require_password")`. Do not touch overlay (config-only key). config/config.yaml: add the key, default true, with a comment in the file's style ("(new)"; the env var name; false restores PHP's password-free factor changes for a frontend that cannot send the password, and re-opens the stolen-session exposure the README describes).
- controllers/management.go: split confirmPassword without changing its behaviour for the recovery-code routes. `passwordFrom(r, fields)` returns the query password, else the raw `fields["password"]` string (never trimmed). `checkPassword(w, r, app, db, user, password) bool` holds today's body: refuse empty, refuse banned or suspended, CheckPassword, CheckAndRecordLogin, refuse 403. confirmPassword becomes `checkPassword(w, r, app, db, user, passwordFrom(r, readFields(r)))`, keeping today's laziness: read the body only when the query has no password. Add a small helper `requiresPassword(eff)` that reports `eff.FactorChangesRequirePassword`.
- disableHandler (TOTP, email): after signedIn, if required, `confirmPassword`; then the existing transaction.
- TOTPConfirm: after validate and the availability check, and before VerifyTOTP: if required, load `classes.EnabledMethods(ctx, db, user.ID)`. When any is enabled, run checkPassword with passwordFrom(r, fields) (the fields already read) and remember confirmed. Inside the existing transaction, before EnableMethod, when required and not confirmed: lock the user's row (`SELECT id FROM users WHERE id = ? FOR UPDATE` on tx, the pattern the user plugin already uses) and re-read `EnabledMethods` on tx. If any method is now enabled, return a package-level sentinel error (for example errPasswordNeeded), roll back, and answer the 403 "Password confirmation required." (not a 500). When confirmed, take the same lock first too, so that factor writes of one user serialize.
- controllers/webauthn.go WebAuthnRegister: after validate and the availability check, and before attestation and FinishRegistration: if required, checkPassword with passwordFrom(r, fields). WebAuthnRemove: if required, confirmPassword right after signedIn, before the id parse.
- Update the handlers' doc comments: each names the password requirement and the knob.
- Existing tests: send `"password": testPassword` wherever a call now needs it. That is the `enableTOTP` and `registerKey` helpers and the direct calls listed in the context section, with the body field, never the query. `enableTOTP` may always send it, because a password the route does not need is ignored. Keep every PHP-shape assertion as it is. Only add the password field, and for the 422 cases leave it out so they still prove validation comes first.
- login_paths_test.go: set the ActivateByCode tokenMints reason to "activation code as first factor: consults the login gates first (F-1)" and add the activation_by_code_is_challenged subtest described in behavior.
README.md (same commit): Routes rows for the five routes gain `password` (JSON body; the query also works, as on the recovery-code routes) and the 403. Add a Configuration row for factor_changes_require_password and note it among the keys the settings screen does not override. Rewrite the Passkeys and Security notes paragraphs that say the management routes need only the session token: the five routes need the password (totp/confirm only when the user already has a second factor), the confirmations go through golem15.user's failed-login throttle, the knob restores PHP's contract and re-opens the exposure, and the remaining password-free paths are the first factor of an account with none, email/enable (codes go to the account's own address) and register/options (stores nothing). Add TestFactorChangesNeedPassword and the new subtest to Testing.
Commit (D-07): `git -C M status --short`, stage exactly the changed paths of this step, message like "fix: changing a second factor asks for the password". No planning IDs, no trailer. This is the contract change of the todo. The SUMMARY flags it for the release note and suggests a minor version (v0.2.0) when the user tags.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-mfa-plugin && test "$(go list -m -f '{{.Dir}}' git.golem15.com/golem15/sm-user-plugin)" = /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin && go build ./... && go vet ./... && go test -short -count=1 ./... && go test -count=1 ./...</automated>
</verify>
<done>M builds against ../sm-user-plugin. The full suite passes with Docker: TestFactorChangesNeedPassword, TestEveryLoginPathIsGated/activation_by_code_is_challenged, the TestSettings rows and every existing test (the PHP body tests unchanged except for the added password fields). Two commits in sm-mfa-plugin: the workspace repoint, then the fix with README.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: vue-demo-app: the security page asks for the password; bump the pointer in sm-demo-app</name>
<files>../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/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>F app/pages/account/security.vue, app/composables/useTwoFactor.ts, app/lib/twoFactor.ts, app/lib/twoFactor.test.ts, app/composables/useDemoAuth.ts (formErrors, authErrors), i18n/locales/en.json and pl.json "security" blocks, README.md "Test and build"; D README.md "Remotes" bullet</read_first>
<behavior>
- twoFactor.test.ts: a new pure helper `isPasswordRefusal(e)` is true for a fetch-error-like object with statusCode (or status) 403 and data.error "Password confirmation required." It is false for a 403 with another error (for example "TOTP is not available."), for a 401 or 422, for null, a string or an object without data.
- Existing 14 tests stay green.
</behavior>
<action>
Write the helper tests first, then implement (D-06). `git -C F status --short` before editing. Edit only F, never the stale sibling ../vue-demo-app.
app/lib/twoFactor.ts: export the constant PASSWORD_CONFIRMATION_REQUIRED (the plugin's exact 403 error text) and `isPasswordRefusal(e: unknown): boolean` as described in behavior. Read `statusCode ?? status ?? response?.status` and `data?.error` defensively, as ofetch's FetchError carries them.
app/composables/useTwoFactor.ts: `disableTotp(password: string)` sends DELETE with `body: { password }` (JSON body, never the query string). `confirmTotp(secret, code, password?: string)` adds `password` to the body only when it is given. Update the comments: golem15.mfa asks for the password to turn a factor off, and to confirm TOTP when another factor is already on.
app/pages/account/security.vue:
- Turning off: replace the browser confirm dialog in turnOff with an in-page step (no browser dialog at all). "Turn off" opens a small form showing the existing turnOffConfirm text, a password input (type password, autocomplete current-password, required), a submit button (turnOff) and a cancel link-button. Submit calls disableTotp(password).
- Setup: when `state.methods.length > 0` (another factor is on; the server asks for the password then), the setup form shows the same password input and passes it to confirmTotp.
- Errors: `isPasswordRefusal(e)` shows t('security.passwordWrong'); other errors keep authErrors.
- Password handling: clear the password ref after every attempt, success or failure, and on cancel. Never log it, never put it in the URL, never store it beyond the ref. Keep `busy` handling and the existing markup classes (form, button, link-button, muted, form__error).
- Update the header comment to mention the password step.
i18n: add to the security block of en.json and pl.json, with the same keys in both: `passwordLabel` ("Your password" / "Twoje hasło"), `passwordWrong` ("Wrong password. Several wrong attempts block the account for a while." / "Nieprawidłowe hasło. Kilka błędnych prób blokuje konto na jakiś czas."), `passwordForSetup` ("Your account already has a second factor: confirm the change with your password." / "Konto ma już drugi składnik: potwierdź zmianę hasłem."). Keep turnOffConfirm as the prompt text. Valid JSON, no other key changes.
Commit in F (D-07): stage the six paths, message like "feat: the security page asks for the password to change a second factor". Then, in D: `git -C D status --short`. Stage only the gitlink `vue-demo-app` (if D shows other sessions' changes, leave them alone) and commit "chore: bump vue-demo-app (password for second-factor changes)". Do not touch D's plugin submodules, go.mod, go.work or plugins.gen.go (D-07: they wait for the plugin tags).
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/vue-demo-app && pnpm test && node -e "const en=require('./i18n/locales/en.json').security, pl=require('./i18n/locales/pl.json').security; for (const k of ['passwordLabel','passwordWrong','passwordForSetup']) { if (!en[k]||!pl[k]) { console.error('missing', k); process.exit(1) } } if (JSON.stringify(Object.keys(en).sort())!==JSON.stringify(Object.keys(pl).sort())) process.exit(1)" && grep -q "isPasswordRefusal" app/pages/account/security.vue && ! grep -q "window.confirm" app/pages/account/security.vue && grep -q "body: { password }" app/composables/useTwoFactor.ts</automated>
</verify>
<done>vitest passes, including the new isPasswordRefusal cases. en/pl security keys match. security.vue opens no browser dialog and sends the password in the body. `pnpm build` completes. One commit in vue-demo-app and one gitlink-only bump commit in sm-demo-app.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| mailbox → activate-by-code | A mailed activation code is a public-route credential (untrusted holder) |
| session bearer → 2fa management routes | A JWT may be stolen (XSS, leaked token); it must not suffice to change factors |
| browser → password field | The password crosses the page into a JSON body |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-oc5-01 | Elevation of privilege | U ActivateByCode | medium | mitigate | CheckLoginGates after VerifyActivationCode with source password; a gate answer replaces the token (Task 1, TestActivateByCodeGates, M activation_by_code_is_challenged) |
| T-oc5-02 | Tampering | M TOTPDisable, EmailDisable, WebAuthnRemove, WebAuthnRegister, TOTPConfirm | high | mitigate | checkPassword on the five routes per D-02, factors unchanged on refusal (TestFactorChangesNeedPassword) |
| T-oc5-03 | Information disclosure | M checkPassword as a password oracle | high | mitigate | Every non-empty password is recorded through userclasses.CheckAndRecordLogin, and banned or suspended accounts are refused (D-04; suspended case in the test) |
| T-oc5-04 | Tampering | M TOTPConfirm race | medium | mitigate | Users-row FOR UPDATE and an EnabledMethods re-check inside the write transaction; sentinel answers 403 (parallel test) |
| T-oc5-05 | Information disclosure | password in query strings and access logs | low | mitigate | The frontend sends it only in the JSON body. The server's query fallback stays for parity with the existing recovery-code route and is documented |
| T-oc5-06 | Tampering | factor_changes_require_password false | medium | accept | Opt-out for ported PHP frontends only. Default true, and the README says it re-opens WR-05 |
| T-oc5-07 | Information disclosure | F password ref | low | mitigate | Cleared after every attempt and on cancel, autocomplete current-password, never logged or put in a URL |
| T-oc5-SC | Tampering | npm/pip/cargo installs | high | accept | No package is added or installed by this plan (no package.json, go.mod require or lockfile change) |
</threat_model>
<verification>
- U: `go vet ./...`; `go test -count=1 -run 'TestActivateByCodeGates|TestActivateByCode$|TestTokenCookie|TestLoginGate' .`; `go test -short -count=1 ./...` has only the known TestRegisterCORSPath failure.
- M: `go vet ./...`; `go test -count=1 ./...` (Docker) green against ../sm-user-plugin.
- F: `pnpm test`; `pnpm build` completes.
- Each repo's `git log -1 --format=%B` has no Co-Authored-By and no planning ID; `git status --short` shows nothing this plan left unstaged.
- SUMMARY records the commit hashes per repo, the tests that ran (and which needed Docker), and the follow-ups that need the user (D-07):
1. Push and tag sm-user-plugin (next patch, v0.1.20 if free) and sm-mfa-plugin (v0.2.0 suggested: contract change).
2. Push vue-demo-app before sm-demo-app's pointer bump.
3. Bump in D-17 order, separate commits each: sm-demo-app (go.mod plus the user and mfa submodules, check-pins), sm-bm-app (user and mfa), fonoteka.go (user only).
4. Changelog posts for the plugin tags.
5. Move both todos to done and update g15office yfadd1mb and krt3vrts.
6. Note F-1 and WR-05 as resolved in 19.6-SECURITY-REVIEW.md if the user wants that record.
</verification>
<success_criteria>
- With a challenging gate, activate-by-code no longer mints a token. The account is still activated, and every gate-free answer is unchanged.
- No second factor can be removed, replaced, added beside an existing one, or registered as a passkey with a session alone, unless the application opts out through factor_changes_require_password.
- Wrong confirmations count toward the login throttle, and malformed requests keep PHP's 422 bodies.
- The demo security page works against both the current v0.1.3 and the new sm-mfa-plugin.
- All suites named above are green, and every repo has only its own explicit-path commits.
</success_criteria>
<output>
Create `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/quick/261105-oc5-2fa-hardening/261105-oc5-SUMMARY.md` when done
</output>

View File

@@ -0,0 +1,156 @@
---
phase: quick-261105-oc5
plan: 01
subsystem: auth (sm-user-plugin, sm-mfa-plugin, vue-demo-app)
tags: [security, 2fa, mfa, activation, login-gates, webauthn, totp]
status: complete
requires:
- sm-user-plugin login gate registry (CheckLoginGates)
- sm-mfa-plugin Gate (challenges source password)
provides:
- activate-by-code consults the login gates before it mints (F-1 closed)
- password confirmation on the five factor-changing mfa routes (WR-05 closed)
- golem15.mfa.factor_changes_require_password config key (default true)
- vue-demo-app /account/security password step
affects:
- sm-demo-app, sm-bm-app, fonoteka.go (plugin bumps pending tags)
tech-stack:
added: []
patterns:
- "users-row FOR UPDATE + EnabledMethods re-check inside the factor write (race-safe first factor)"
- "password helper split: passwordFrom / checkPassword / confirmPassword"
key-files:
created:
- ../sm-mfa-plugin/factor_password_test.go
modified:
- ../sm-user-plugin/controllers/api_controller.go
- ../sm-user-plugin/controllers/login_gates.go
- ../sm-user-plugin/classes/login_gate.go
- ../sm-user-plugin/login_gate_test.go
- ../sm-user-plugin/README.md
- ../sm-mfa-plugin/go.work
- ../sm-mfa-plugin/classes/config.go
- ../sm-mfa-plugin/config/config.yaml
- ../sm-mfa-plugin/controllers/management.go
- ../sm-mfa-plugin/controllers/webauthn.go
- ../sm-mfa-plugin/login_paths_test.go
- ../sm-mfa-plugin/settings_test.go
- ../sm-mfa-plugin/README.md
- ../sm-mfa-plugin/{tracer_smoke,webauthn_smoke,webauthn,php_bodies,challenge_smoke,email,gate,trusted,passwordless_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/vue-demo-app/i18n/locales/en.json
- ../sm-demo-app/vue-demo-app/i18n/locales/pl.json
- ../sm-demo-app (vue-demo-app gitlink)
decisions:
- "F-1: ActivateByCode calls CheckLoginGates with LoginSourcePassword after VerifyActivationCode; a gate answer or error replaces the token, the activation stays committed"
- "WR-05: password on DELETE totp/email/webauthn/{id}, every webauthn/register, and totp/confirm when ANY factor is enabled; validation 422 and availability 403 come first"
- "golem15.mfa.factor_changes_require_password (default true, config-only, not on the settings row); false restores the PHP password-free contract"
- "sm-mfa-plugin go.work now replaces sm-user-plugin with ../sm-user-plugin instead of the stale fonoteka checkout"
metrics:
duration: ~40 min
completed: 2026-11-05
actuals:
tokens: 26000
tasks: 3
commits: 5
commits_by_repo:
sm-user-plugin: [b179e6b]
sm-mfa-plugin: [357e85d, 3aa52c6]
vue-demo-app: [848ee1e]
sm-demo-app: [7b75d2c]
plan_head_before: "n/a (no commits in summercms.go; sibling repos: sm-user-plugin 97f83cc, sm-mfa-plugin 544017c, vue-demo-app fe5d38d, sm-demo-app da03c06)"
plan_head_after: "sm-user-plugin b179e6b, sm-mfa-plugin 3aa52c6, vue-demo-app 848ee1e, sm-demo-app 7b75d2c"
---
# Quick 261105-oc5: 2FA hardening Summary
activate-by-code now goes through the login gates, so a mailed code no longer gets a user past an enabled second factor (F-1). golem15.mfa's five factor-changing routes now need the user's password and record wrong guesses in the throttle, with a config opt-out (WR-05). The demo security page asks for the password where the plugin now needs it.
## Commits
| Repo | Hash | Message |
|------|------|---------|
| sm-user-plugin | b179e6b | fix: activate-by-code asks the login gates before it signs in |
| sm-mfa-plugin | 357e85d | chore: develop against the sm-user-plugin checkout |
| sm-mfa-plugin | 3aa52c6 | fix: changing a second factor asks for the password |
| vue-demo-app (sm-demo-app/vue-demo-app) | 848ee1e | feat: the security page asks for the password to change a second factor |
| sm-demo-app | 7b75d2c | chore: bump vue-demo-app (password for second-factor changes) |
None of the commits has a co-author trailer or a planning ID. Nothing was pushed or tagged. None of the five repos has uncommitted changes from this plan. summercms.go has no commits; the orchestrator commits this SUMMARY.
## What changed
**Task 1, sm-user-plugin (F-1).** After `VerifyActivationCode`, `ActivateByCode` calls `CheckLoginGates` with `LoginSourcePassword`, the fields it already read and the request. If a gate answers, its status and body go back to the client (status 0 means 200), with no token and no session cookie. If a gate fails, the client gets the opaque 500. Either way the account stays activated. With no gate, or when every gate allows, the answer is the same as before. The doc comments on `LoginSourcePassword`, `LoginAttempt.Fields`, `LoginGates` and `CheckLoginGates` now cover activate-by-code. The README sections "Registration and activation", "Login gates", the API reference and Testing are updated.
**Task 2, sm-mfa-plugin (WR-05).**
- `go.work` now points sm-user-plugin at `../sm-user-plugin`, the plugin's own repo, instead of the stale fonoteka checkout. The full suite passed against it before any WR-05 change.
- `management.go` splits the password check into `passwordFrom`, `checkPassword` and `confirmPassword`, and adds `requiresPassword(eff)`. The recovery-code routes behave exactly as before.
- `disableHandler` (TOTP and email) and `WebAuthnRemove` call `confirmPassword` right after `signedIn`. In `WebAuthnRemove` that is before the id is parsed.
- `WebAuthnRegister` checks the password after validation and the availability check, and before it reads the attestation. This applies to every registration.
- `TOTPConfirm` loads `EnabledMethods` after validation and the availability check. If any factor is enabled, it checks the password before `VerifyTOTP`. Inside the write transaction it locks the user's row (`SELECT id FROM users WHERE id = ? FOR UPDATE`). On the path that skipped the password, it re-reads the enabled methods; if one exists now, `errPasswordNeeded` rolls back and the client gets the 403.
- New `Config.FactorChangesRequirePassword`, from `golem15.mfa.factor_changes_require_password`: default true, env `SUMMER_GOLEM15__MFA__FACTOR_CHANGES_REQUIRE_PASSWORD`, config-only (`overlay` is unchanged). It is documented in `config/config.yaml`.
- README: Features, the Routes table (password and 403 on the five routes, plus the order of answers), Passkeys, the Configuration row and the note that the settings screen does not override it, Security notes (contract, password-free paths, the knob re-opening WR-05, the activate-by-code challenge), and Testing.
**Task 3, vue-demo-app (D-06).**
- New `PASSWORD_CONFIRMATION_REQUIRED` constant and `isPasswordRefusal(e)` helper, with 5 new vitest cases.
- `disableTotp(password)` sends `DELETE` with the JSON body `{ password }`. `confirmTotp(secret, code, password?)` adds the password only when one is given.
- `security.vue`: "Turn off" opens a form on the page (the `turnOffConfirm` text, a password field with `current-password`, submit and cancel). `window.confirm` is gone. The setup form shows the password field when `state.methods.length > 0`. A password refusal shows the localized `passwordWrong` message. The password is cleared after every attempt and on cancel.
- New `security.passwordLabel`, `passwordWrong` and `passwordForSetup` strings in both en and pl.
## Verification run
- **sm-user-plugin:** `go vet ./...` is clean. `TestActivateByCodeGates` (new, Postgres), `TestActivateByCode`, `TestTokenCookie`, `TestLoginGates`, `TestLoginGateOrder`, `TestLoginGateSmoke` and `TestLoginGatesSmoke` pass. `go test -short -count=1 ./...` and the full `go test -count=1 ./...` (Docker) fail only the known `TestRegisterCORSPath` (it needs `config/http.yaml` from an application checkout).
- **sm-mfa-plugin:** `go list -m` reports `../sm-user-plugin`. `gofmt`, `go build` and `go vet` are clean. `go test -short -count=1 ./...` and the full `go test -count=1 ./...` (Docker, about 39 s) pass. `TestFactorChangesNeedPassword` also passed under `-race`, and its race subtest passed 3 more runs.
- New tests: `TestFactorChangesNeedPassword` (disable routes, register, totp_confirm, validation and availability first, suspended account, first-factor race, knob off), the `TestSettings` cases for the knob (default, config, env, and the settings row never changing it), and `TestEveryLoginPathIsGated/activation_by_code_is_challenged`.
- The new tests were red before the handler changes, apart from the activation subtest, which already passed because Task 1 was committed first.
- **vue-demo-app:** `pnpm test` passes 230 tests, including the 5 new ones. The plan's i18n/grep check passes. `pnpm build` completes.
- Docker worked directly; `sudo -u golem` was not needed.
## Deviations from Plan
**1. [Rule 1, test fixes beyond the files the plan listed] More existing calls needed the password.** In `php_bodies_test.go` (management_group), `webauthn_test.go` (registration and delete_scoped_to_the_owner) and `webauthn_smoke_test.go`, some calls now hit the password check before the answer they assert:
- `totp/confirm 422 code` (wrong code)
- `webauthn/register 422 failed` (unknown challenge)
- `DELETE webauthn/999999 404`, and the 404 for another user's key
- registrations whose attestation fails verification
These calls now send `"password": testPassword`. Every PHP body assertion is unchanged, and the pure validation 422 cases still send no password. All the files were already in the plan's list; only these extra call sites were new.
**2. [Discretion] Order of answers in the Routes table.** For `webauthn/register` and `webauthn/{id}`, the 403 for a missing password comes before the 422 "Registration failed: …" and before the 404 for an unknown id. That follows from D-03 (the password check runs after validation and availability, before the attestation or the id is read), and the README Routes table says so.
**3. [Discretion] mfa README states the user plugin version activate-by-code depends on.** The Security note says the challenge needs "sm-user-plugin after v0.1.19". go.mod still requires sm-user-plugin v0.1.12, because no code depends on the new behaviour; only the new `activation_by_code_is_challenged` test needs it (see follow-up 3).
Nothing else deviated. The plan ran as written.
## Contract change (for the release note)
sm-mfa-plugin's management API changes: DELETE `2fa/totp`, DELETE `2fa/email`, DELETE `2fa/webauthn/{id}`, every POST `2fa/webauthn/register`, and POST `2fa/totp/confirm` when the user already has a second factor now need `password` and answer 403 `{"error":"Password confirmation required."}` without it. To restore the old contract, set `golem15.mfa.factor_changes_require_password: false`. A **minor** version, **v0.2.0**, is suggested. sm-user-plugin's change is additive (the answer changes only when a gate challenges), so a patch, **v0.1.20**, fits.
## Follow-ups for the user (not done here: no push, tag, deploy or bumps)
1. Push and tag **sm-user-plugin** (next patch, `v0.1.20` if free) and **sm-mfa-plugin** (`v0.2.0` suggested, because of the contract change). Then post on https://summercms.io/changelog/plugins for both tags (`scripts/changelog-post.py publish … --repo ../sm-<name>-plugin`).
2. Push **vue-demo-app** (`848ee1e`) before pushing sm-demo-app's pointer bump (`7b75d2c`), as sm-demo-app's README requires.
3. After the tags, bump in this order, one commit each:
- **sm-demo-app:** go.mod plus the `user` and `mfa` plugin submodules, then `scripts/check-pins.sh`.
- **sm-bm-app:** user and mfa.
- **fonoteka.go:** user only.
- Optionally raise sm-mfa-plugin's own `go.mod` require of sm-user-plugin to the new tag (and its `plugin.yaml` `requires`), so `GOWORK=off` builds see the activate-by-code gate the new login-paths subtest pins.
4. Any application that mounts golem15.mfa with a frontend other than vue-demo-app must send `password` on these routes, or set `factor_changes_require_password: false` until it can. At planning, sm-bm-app's frontend and fonoteka called none of these routes.
5. Move both todos to done (`2026-10-26-activate-by-code-second-factor.md`, `2026-10-26-mfa-factor-changes-need-reauth.md`) and update g15office yfadd1mb and krt3vrts. The orchestrator owns these.
6. Optionally mark F-1 and WR-05 as resolved in `19.6-SECURITY-REVIEW.md` and `19.6-REVIEW.md`.
## Known Stubs
None.
## Threat Flags
None. No new endpoints were added. The changes tighten existing routes as the plan's threat register describes (T-oc5-01..07 mitigated as planned; T-oc5-06 accepted, documented in the README and config.yaml).
## Self-Check: PASSED
- Files exist: `../sm-mfa-plugin/factor_password_test.go`, and every modified path listed above.
- Commits found: sm-user-plugin b179e6b; sm-mfa-plugin 357e85d, 3aa52c6; vue-demo-app 848ee1e; sm-demo-app 7b75d2c.

View File

@@ -0,0 +1,297 @@
---
phase: quick-261105-oc6
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- ../sm-chatchannels-plugin/contracts/pin_policy.go
- ../sm-chatchannels-plugin/contracts/access_resolver.go
- ../sm-chatchannels-plugin/classes/channel_service.go
- ../sm-chatchannels-plugin/api/pins.go
- ../sm-chatchannels-plugin/pins_test.go
- ../sm-chatchannels-plugin/testdata/PHP_METHOD_MAP.md
- ../sm-chatchannels-plugin/README.md
- ../sm-demo-app/plugins/demo/classes/chat_context.go
- ../sm-demo-app/plugins/demo/classes/chat_context_test.go
- ../sm-demo-app/plugins/demo/chat.go
- ../sm-demo-app/reset_test.go
- ../sm-demo-app/README.md
- ../sm-demo-app/plugins/golem15/chatchannels
- ../sm-demo-app/chat_test.go
autonomous: true
requirements:
- quick-261105-oc6
- WR-02
must_haves:
truths:
- "In sm-chatchannels-plugin, a user whose installed resolver grants channel access but answers CanManageContext false (and either does not implement contracts.PinPolicy or answers CanPin false) gets 404 from POST /_chatchannels/api/v1/contexts/{type}/{id}/channels/{channelId}/pins and from DELETE .../pins/{messageId}. No pin row is written or removed. GET .../pins still answers 200 for them."
- "A user with CanManageContext, or whose resolver also implements contracts.PinPolicy and answers CanPin true, pins and unpins. A second pin of the same message is a no-op that keeps the first pinner, and an allowed user unpins a pin someone else made. A user without channel access is refused (ErrForbiddenAccess) even when CanPin would answer true, and CanPin is not consulted for them."
- "PinMessage refuses with ErrNotFound (HTTP 404, no row) a message id that is unknown, soft-deleted, or in another conversation (another channel or a DM), and refuses any message for a channel with no conversation_id."
- "Hosts that do not implement PinPolicy compile unchanged: contracts.AccessResolver and every existing exported signature in the plugin stay the same, go.mod and go.sum are unchanged, and the plugin's go vet, go test -short and Docker-backed go test pass."
- "In sm-demo-app, with the chatchannels submodule at the new plugin commit, a member of the demo-moderators group pins and unpins in #general, including a seeded welcome pin. A plain member gets 404 for both and the seeded pin survives. The moderator cannot pin an #announcements message or a DM message through #general's path. Nobody can create a channel (CanManageContext stays false)."
- "Commits: one on sm-chatchannels-plugin main, and three on sm-demo-app master in this order: host policy, then the pointer bump alone, then the end-to-end test. None has an attribution trailer. Nothing is pushed or tagged, and go.mod/go.sum are unchanged in both repos."
artifacts:
- path: "../sm-chatchannels-plugin/contracts/pin_policy.go"
provides: "contracts.PinPolicy, the optional host hook CanPin(ctx, userID, contextType, contextID, channelID) bool"
- path: "../sm-chatchannels-plugin/classes/channel_service.go"
provides: "CanPinInChannel, the pin gate used by PinMessage/UnpinMessage, and the message-in-channel check"
- path: "../sm-chatchannels-plugin/pins_test.go"
provides: "TestPinPolicy (Docker) and TestDenyAllHasNoPinPolicy (-short)"
- path: "../sm-demo-app/plugins/demo/classes/chat_context.go"
provides: "ChatAccess.CanPin answering from the moderator group"
- path: "../sm-demo-app/chat_test.go"
provides: "TestDemoPinPolicy end to end over HTTP"
key_links:
- from: "../sm-chatchannels-plugin/classes/channel_service.go PinMessage/UnpinMessage"
to: "contracts.PinPolicy"
via: "type assertion on s.Resolver() after requireChannelAccess and a false CanManageContext"
- from: "../sm-demo-app/plugins/demo/chat.go registerChat"
to: "../sm-demo-app/plugins/demo/classes/chat_context.go NewChatAccess"
via: "svc.SetAccessResolver(classes.NewChatAccess(p.db, p.chatModeratorGroup, nil))"
- from: "../sm-demo-app go.work use ./plugins/golem15/chatchannels"
to: "../sm-chatchannels-plugin main (Task 1 commit)"
via: "submodule gitlink bumped in its own commit"
---
<objective>
Pinning in chat channels becomes a manage action (todo `chatchannels-pin-policy.md`, phase 20 review WR-02). Today any visitor of the demo can unpin the seeded welcome pins or flood the pins panel, because sm-chatchannels-plugin gates PinMessage and UnpinMessage on channel access only, and the demo grants that access to every member. PinMessage also never checks that the message belongs to the channel's conversation.
Purpose: the server enforces who may pin, through an additive and optional host contract, so shared-plugin hosts keep compiling. The demo answers that contract from its existing moderator group (`golem15.demo.chat.moderator_group`, default `demo-moderators`).
Output:
- One commit on sm-chatchannels-plugin main: `contracts.PinPolicy`, the pin gate, the message-in-channel check, tests and README.
- Three commits on sm-demo-app master: the host policy, the submodule pointer bump on its own, and the end-to-end pin test.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@CLAUDE.md
@.planning/todos/pending/chatchannels-pin-policy.md
Repos and their state at planning time (2026-11-05). Use absolute paths in commands; the shell cwd resets between calls.
- Plugin: /media/nvme/dev/golem15/summercms.io/summercms/sm-chatchannels-plugin, branch `main` at 017843a (= origin/main = tag v0.1.0), clean. Leave the `backup/manifest-pre-rebase` branch alone. Its go.work resolves summercms to ../summercms.go, sm-chat-plugin to ../sm-chat-plugin and sm-user-plugin to ../fonoteka.go/plugins/golem15/user. If main has moved past 017843a, build on top of it and record `git log --oneline 017843a..main` in the SUMMARY. If the tree is dirty, stop: another session is working there.
- Demo: /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app, branch `master` at da03c06, clean (sibling batch items oc5/oc9 may have committed there before you; build on top). The submodule plugins/golem15/chatchannels is detached at 017843a (v0.1.0), and go.mod requires sm-chatchannels-plugin v0.1.0. go.work `use`s the submodule checkout, so development and tests compile the submodule. `go list ./...` at the demo root covers only the app's own 7 packages.
- Baselines measured during planning, all green: the plugin's `go vet ./...`, `go test -short ./...` and Docker-backed `go test -count=1 .` (~36 s); the demo's `go vet ./plugins/demo/...`, `go test -short ./plugins/demo/...` and Docker-backed `TestDemoModeratorDelete` + `TestResetKeepsPlans` (~35 s). Docker is reachable for this user. If the socket is denied, run the tests through `sudo -u golem` (summercms.go CLAUDE.md).
Plugin code facts (sm-chatchannels-plugin):
- contracts/access_resolver.go: the package doc says the package holds the ChannelAccessResolver seam. `AccessResolver` has `CanAccessContext` and `CanManageContext(ctx context.Context, userID uint, contextType string, contextID uint) bool`.
- classes/channel_service.go: the sentinels at :26-35 (`ErrForbidden` = "not allowed to manage this channel context", `ErrForbiddenAccess`, `ErrNotFound`). `Resolver()` at :101 never returns nil. `requireManage` at :293 and `requireAccessContext` at :303. `CanAccessChannel` at :463 checks resolver access plus membership for private channels. `requireChannelAccess` at :481. `PinMessage` at :1332 calls requireChannelAccess, then FirstOrCreate on (channel_id, message_id) with Attrs ConversationID/PinnedBy. `UnpinMessage` at :1348 calls requireChannelAccess, then deletes. `PinsFor` at :1356 orders by created_at DESC, id DESC. The file already imports `chatmodels "git.golem15.com/golem15/sm-chat-plugin/models"`.
- sm-chat-plugin models/message.go: `chatmodels.Message` (table golem15_chat_messages) has `ConversationID uint`, `SenderID`, `Type` (`chatmodels.TypeText` = "text"), `Content *string` and `DeletedAt gorm.DeletedAt`. A GORM query through `Model(&chatmodels.Message{})` therefore excludes soft-deleted rows.
- api/pins.go: `ListPins`, `PinMessage` and `UnpinMessage` call `channelActor` (api/auth.go:74). It looks the service up per request and returns 404 for an unknown, foreign or invisible channel. Errors go through `writeChannelError` (api/errors.go), which maps ErrForbidden, ErrForbiddenAccess and ErrNotFound to 404 `{"error":"Not found"}`. No mapping change is needed.
- Tests (package chatchannels_test): harness_test.go has `bootChannels(t, nil)` returning `*channelEnv` with fields app, gdb, plugins, svc and rt, plus `env.user(t)` and `env.count(t, table, where, args...)`. fixture_plugin_test.go:188 has `env.team(t, name, members...)`, which inserts acme_teams_* rows, and `teamsAlias` = "teams". http_tracer_test.go has `bootWeb(t)` returning `*webEnv` (a surf handler), `env.frontendToken(t, user)`, `env.call(t, method, path, token, body)` (path relative to chatchannels.APIPrefix), `decodeObject`, and `teamAccessResolver`, whose CanManageContext equals CanAccessContext. `channelActor` looks the service up per request, so `env.svc.SetAccessResolver(...)` after bootWeb applies to HTTP calls. TestMain (testmain_test.go) skips the container under -short.
- testdata/PHP_METHOD_MAP.md rows 37-42 map the six PHP pin tests to TestChannelHTTPTracer/TestAccessResolverDefault. TestPHPMethodMap requires status `ported` and an existing Go test name.
- The PHP original (wavepath ChannelService.php pinMessage/unpinMessage, D-57-02) let any member pin and unpin. This change deliberately tightens that per the todo (WR-02).
Demo code facts (sm-demo-app):
- plugins/demo/classes/chat_context.go: the interface assertions var block is at :18-26. `IsModerator(ctx, db, userID, group)` at :90 returns false without querying when the user is 0, the group is "" or db is nil, and otherwise calls userclasses.HasGroupCode. `ChatAccess` at :239-275 has the fields `db func() *gorm.DB` and `isMember MemberFunc`, plus `NewChatAccess(db, isMember)`, `CanAccessContext` (demo/1 + member) and `CanManageContext`, which is always false (D-07: channels come from demo:seed only, and TestDemoChannelsSeededOnly relies on it).
- plugins/demo/classes/forum_access.go is the pattern to mirror: `NewForumAccess(db func() *gorm.DB, moderatorGroup func() string, isMember MemberFunc)`, a `member` helper and a `moderator` helper.
- plugins/demo/chat.go:68: `svc.SetAccessResolver(classes.NewChatAccess(p.db, nil))`. `p.chatModeratorGroup` (:93) is a `func() string` method value.
- plugins/demo/classes/chat_context_test.go: TestDemoChatAccess (~:177) calls `NewChatAccess(someDB, m.fn)` four times. `someDB()` is an empty `&gorm.DB{}`: any real query on it panics, so unit tests must never reach IsModerator with a non-empty group for a community member.
- reset_test.go `addChatActivity` (:155-210) has the plain walker POST a pin in #general (:187, `ok("pin", ...)` fails on status >= 300). Under the new rule the walker must be a moderator for that step. `makeModerator(t, a *apiDemo, userID)` lives in chat_test.go:19 (same package main), and `a := &apiDemo{Demo: d, h: h}` already exists inside addChatActivity. demo:reset empties users_groups entirely (plugins/demo/console/reset.go:31), so the extra membership does not disturb the reset assertions.
- Root test helpers (package main): `bootAPI(t, nil, nil)` boots and seeds (the demo-moderators group exists). Also `a.member(t, email)` returning (id, token), `a.openChannel(t, token, slug)` returning the conversation id, `a.bootstrapChat(t, token).Channels` ({ID, Slug, ConversationID}), `a.postMessage(t, token, convID, content)` returning (status, id), `a.openDM(t, token, userID)` returning (status, convID), `a.call(t, method, path, token, body)` and `scalar[T](t, a.Demo, query, args...)`. The seed inserts one pin each in #general, #announcements and #ogólny by SQL (seed_chat.go:279). TestSeedChatSmoke counts 3 pinned channels, which is unaffected.
- README.md "Moderation." paragraph (~:186-188) lists what demo-moderators may do.
- scripts/check-pins.sh (run by build.sh and check-all.sh) requires each plugin submodule HEAD to equal the commit of the tag go.mod requires. After the untagged pointer bump it will report chatchannels until the user tags the plugin and bumps go.mod. That is expected; do not run it as a gate and do not change go.mod.
- Frontend: vue-chat-layer's useChat `togglePin` already hides the pin action for the session after a 4xx refusal (`pinAllowed`). No frontend change is in scope.
</context>
<tasks>
<task type="tracer">
<name>Task 1: sm-chatchannels-plugin: pin and unpin need CanManageContext or a host PinPolicy, and the pinned message must be in the channel</name>
<files>../sm-chatchannels-plugin/contracts/pin_policy.go, ../sm-chatchannels-plugin/contracts/access_resolver.go, ../sm-chatchannels-plugin/classes/channel_service.go, ../sm-chatchannels-plugin/api/pins.go, ../sm-chatchannels-plugin/pins_test.go, ../sm-chatchannels-plugin/testdata/PHP_METHOD_MAP.md, ../sm-chatchannels-plugin/README.md</files>
<precondition>/media/nvme/dev/golem15/summercms.io/summercms/sm-chatchannels-plugin is on branch main with a clean working tree, and `docker info` succeeds (or the tests run through sudo -u golem).</precondition>
<behavior>
- A member with channel access, but neither manage nor PinPolicy permission, gets ErrForbidden from PinMessage and UnpinMessage (HTTP 404). No pin row is written and an existing row survives. PinsFor still works for them (HTTP GET 200).
- A manager (CanManageContext true) pins: the row has pinned_by = manager, and a second pin of the same message keeps one row and the first pinner. A manager unpins, which removes the row.
- With a resolver that also implements contracts.PinPolicy and answers CanPin true for a non-manager, that user pins and unpins, including a pin another user made. CanPin receives the channel's context type, context id and channel id.
- A user without channel access gets ErrForbiddenAccess even though CanPin would answer true for them, and CanPin is not called for them.
- PinMessage returns ErrNotFound and writes no row for an unknown message id, a soft-deleted message, a message of another channel's conversation, and a channel copy whose ConversationID is nil. Over HTTP the foreign message is 404.
- PinsFor lists pins newest first and PinCountFor counts them (so the PHP map rows can point at this test).
- Under -short: classes.DenyAll does not implement contracts.PinPolicy, and CanPinInChannel on a nil service or a nil channel is false.
</behavior>
<action>
Work in /media/nvme/dev/golem15/summercms.io/summercms/sm-chatchannels-plugin. All changes are additive (a shared plugin used by several apps). No existing exported signature changes, contracts.AccessResolver stays as it is, and no dependency is added.
1. Create contracts/pin_policy.go with the optional interface `PinPolicy`. It has one method: `CanPin(ctx context.Context, userID uint, contextType string, contextID, channelID uint) bool`. Write the doc comment in the style of sm-chat-plugin's contracts.MessageModerator:
- It is an optional interface the host's AccessResolver may also implement.
- golem15.chatchannels consults it only for PinMessage and UnpinMessage, after the channel access check passed and only when CanManageContext answered false. True lets userID pin and unpin messages in channel channelID of that host context.
- It never grants access to the channel.
- A host that does not implement it lets only CanManageContext pin.
- Any member could pin before this rule (v0.1.0). A host that wants that behaviour back implements CanPin by answering its own CanAccessContext.
In contracts/access_resolver.go, update only the package doc comment so it names both seams: the required AccessResolver and the optional PinPolicy.
2. In classes/channel_service.go, next to the pin methods:
- Add an unexported helper `pinAllowed(ctx, actorID, ch) bool`. It is true when `s.Resolver().CanManageContext(ctx, actorID, ch.ContextType, ch.ContextID)` is true. Otherwise, when `s.Resolver()` also satisfies `contracts.PinPolicy` (type assertion), it returns `CanPin(ctx, actorID, ch.ContextType, ch.ContextID, ch.ID)`. Otherwise it is false.
- Add an exported `CanPinInChannel(ctx context.Context, actorID uint, ch *models.Channel) bool`. It is nil-safe: a nil service or channel is false, matching CanAccessChannel. It returns CanAccessChannel && pinAllowed. Hosts and bootstraps can use it to show or hide the pin action.
- Add an unexported `requirePin(ctx, actorID, ch) error`. It runs requireChannelAccess first and returns that error unchanged (ErrNotFound or ErrForbiddenAccess). It then returns ErrForbidden when pinAllowed is false.
- Add an unexported `requireMessageInChannel(ctx, ch, messageID) error`. It returns ErrNotFound when ch.ConversationID is nil or zero. Otherwise it counts `Model(&chatmodels.Message{})` rows where id = messageID and conversation_id = *ch.ConversationID, which excludes soft-deleted rows. Zero is ErrNotFound and a query error is returned as is.
- PinMessage: requirePin, then requireMessageInChannel, then the existing FirstOrCreate, unchanged.
- UnpinMessage: requirePin, then the existing delete, unchanged. Do not add a message check to unpin: a stale pin of a deleted message must stay removable.
- Update both doc comments to say "PHP successor, tightened by WR-02" and state the rule. PinsFor, IsPinned, PinCountFor and RemovePinsForMessage stay unchanged.
3. In api/pins.go, update only the doc comments of the PinMessage and UnpinMessage handlers. They now say: 404 also when the caller may not pin (CanManageContext or contracts.PinPolicy) and, for POST, when message_id is not a live message of this channel's conversation. Do not change the status mapping in api/errors.go. Forbidden and not-found both stay 404, the plugin's existing anti-enumeration rule.
4. Create pins_test.go (package chatchannels_test):
- A resolver type `pinAccess` with access for team members, using the acme_teams_members count in the same way as teamAccessResolver, and CanManageContext only for user ids in a managers set.
- A pointer type `pinAccessWithPolicy`. It embeds pinAccess, adds a pinners set, and records the arguments of each CanPin call plus a call count. Its CanPin deliberately ignores access, which proves the service checks access first.
- Compile-time assertions that both satisfy contracts.AccessResolver and that *pinAccessWithPolicy satisfies contracts.PinPolicy.
- `TestPinPolicy` (Docker; the harness skips it under -short):
- Use bootWeb(t). Create users manager, member, pinner and stranger. The team holds manager, member and pinner.
- Install pinAccess with managers = {manager}.
- CreateChannel "general" and "random" as manager with member and pinner as participants.
- Insert messages with env.gdb.Create(&chatmodels.Message{ConversationID, SenderID, Type: chatmodels.TypeText, Content}): two in general, one in random, and one in general that you then soft-delete with env.gdb.Delete.
- Cover every behavior bullet at the service level, checking errors with errors.Is and row counts with env.count on golem15_chatchannels_channel_pins.
- Cover over HTTP with env.call on /contexts/teams/{teamID}/channels/{channelID}/pins: member POST is 404, manager POST is 200 with message_id and channel_id, manager POST of the random message on general's path is 404, member GET is 200, member DELETE is 404, and manager DELETE is 200.
- Then install pinAccessWithPolicy with pinners = {pinner, stranger}. Cover: pinner pins and unpins (including the manager's pin); the recorded CanPin args are (teams, teamID, general.ID); member is still refused; stranger gets ErrForbiddenAccess and the CanPin call count does not change.
- Also cover newest-first PinsFor and PinCountFor with two pins.
- `TestDenyAllHasNoPinPolicy` (runs under -short): DenyAll does not satisfy contracts.PinPolicy, and `(*classes.ChannelService)(nil).CanPinInChannel(ctx, 1, nil)` is false.
5. In testdata/PHP_METHOD_MAP.md, re-point the six pin rows to TestPinPolicy: test_pin_message_creates_row_keyed_on_channel_and_message, test_second_pin_of_same_message_is_idempotent_noop_first_pinner_wins, test_any_member_can_unpin_regardless_of_who_pinned, test_pins_for_returns_newest_first, test_pin_unpin_list_throw_channel_forbidden_when_resolver_denies and test_pin_count_for_counts_distinct_pinned_messages. Keep status `ported`, and leave the row count and PHP names unchanged.
6. README.md: same commit, per the summercms.go documentation rules.
- Add a Features bullet "**Pin policy.**" stating the rule. Pin and unpin need channel access plus CanManageContext, or a contracts.PinPolicy the installed resolver also implements. A pinned message must be a live message of the channel's conversation (otherwise ErrNotFound, 404). Listing pins needs channel access only.
- Add a sentence in the same bullet that this tightens the v0.1.0 rule (any member pinned), and that a host that wants it back answers CanPin with its access check.
- In Usage, add a short Go example of a resolver that also implements CanPin.
- In the API reference, add a `contracts.PinPolicy` row and add `CanPinInChannel` to the ChannelService method list.
- In Testing, add the TestPinPolicy Docker line and TestDenyAllHasNoPinPolicy to the -short line.
7. Run gofmt, go vet, go test -short and the full Docker-backed go test. Then commit on main, staging the seven paths explicitly (never `git add -A`). Suggested subject: "fix: pinning needs CanManageContext or a host PinPolicy, and the message must be in the channel". The body has two short paragraphs, WR-02 and the additive contract. Add no attribution trailer of any kind: the user's global CLAUDE.md overrides any harness attribution reminder. Do not push or tag. Record the commit SHA for Task 3.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-chatchannels-plugin && test -z "$(gofmt -l .)" && go vet ./... && go test -short -count=1 ./... && go test -count=1 ./... && go test -count=1 -run '^TestPinPolicy$' -v . 2>&1 | grep -qF -- '--- PASS: TestPinPolicy' && go test -short -count=1 -run '^TestDenyAllHasNoPinPolicy$|^TestPHPMethodMap$' -v . 2>&1 | grep -qF -- '--- PASS: TestDenyAllHasNoPinPolicy' && grep -qF 'contracts.PinPolicy' README.md && grep -qF 'CanPinInChannel' README.md && awk -F'|' '$3 ~ /TestPinPolicy/ && $4 ~ /ported/ {n++} END {exit (n < 6)}' testdata/PHP_METHOD_MAP.md && S=$(git status --short) && test -z "$S" && test "$(git rev-parse --abbrev-ref HEAD)" = main && git diff --quiet 017843a main -- go.mod go.sum && M=$(git log -1 --format=%B main) && ! grep -qi 'co-authored-by' <<<"$M" && T=$(git tag --points-at main) && test -z "$T" && R=$(git branch -r --contains main) && test -z "$R"</automated>
</verify>
<done>sm-chatchannels-plugin main has one new commit with the contract, the gate, the message check, the handler docs, pins_test.go, the PHP map re-pointing and the README. TestPinPolicy passes against Postgres (not skipped) and TestDenyAllHasNoPinPolicy passes under -short. go vet, go test -short and the full go test are green. go.mod and go.sum are unchanged. The commit has no trailer, no tag and no remote branch. The SHA is recorded.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: sm-demo-app: demo moderators answer the pin policy (compatible with the current v0.1.0 pointer)</name>
<files>../sm-demo-app/plugins/demo/classes/chat_context.go, ../sm-demo-app/plugins/demo/classes/chat_context_test.go, ../sm-demo-app/plugins/demo/chat.go, ../sm-demo-app/reset_test.go, ../sm-demo-app/README.md</files>
<precondition>The sm-demo-app submodule plugins/golem15/chatchannels is still at 017843a (v0.1.0), and sm-demo-app has no uncommitted edits in the five files above.</precondition>
<behavior>
- NewChatAccess(db, moderatorGroup, isMember) keeps CanAccessContext as before (demo/1 members only), and CanManageContext stays false for everyone, moderators included.
- CanPin is false for: user 0, a non-member, any context other than demo/1, a nil db func, a nil database, a membership error, a nil moderatorGroup func, and an empty group code. The unit test never lets a community member reach IsModerator with a non-empty group, because someDB() is an empty handle.
- The walker in addChatActivity is made a moderator before the pin step, so TestResetKeepsPlans passes both before and after the pointer bump.
</behavior>
<action>
Work in /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app. Keep this commit compiling against the current submodule (v0.1.0, which has no contracts.PinPolicy). Therefore do NOT reference chancontracts.PinPolicy in this task. ChatAccess satisfies it structurally, and Task 3 adds the compile-time assertion after the bump.
1. plugins/demo/classes/chat_context.go:
- Give ChatAccess a `moderatorGroup func() string` field. Change the constructor to `NewChatAccess(db func() *gorm.DB, moderatorGroup func() string, isMember MemberFunc)`, mirroring NewForumAccess. db and moderatorGroup are resolved per check, so a config change needs no re-install.
- Factor the existing demo/1 membership check into a `member` helper like ForumAccess.member, and keep CanAccessContext's behaviour identical.
- Add `CanPin(ctx context.Context, userID uint, contextType string, contextID, channelID uint) bool`. It is true only for a community member (via the member helper) when moderatorGroup is non-nil and `IsModerator(ctx, db, userID, moderatorGroup())` returns true with no error. channelID is unused, because every demo channel lives in demo/1 and chatchannels has already resolved the channel in that context. Say so in the doc comment.
- CanManageContext stays always false, with its comment unchanged in meaning (D-07: channels come from demo:seed only).
- Update the ChatAccess type comment: members access demo/1; nobody manages it; members of the moderator group pin and unpin channel messages (golem15.chatchannels' optional PinPolicy, review WR-02).
2. plugins/demo/chat.go:68: install `classes.NewChatAccess(p.db, p.chatModeratorGroup, nil)`. Extend the chatModeratorGroup doc comment ("moderate the channels and the forum") to mention pins.
3. plugins/demo/classes/chat_context_test.go: update the four NewChatAccess calls in TestDemoChatAccess to the new signature, passing a group func returning "" for the table cases. Extend the test to assert CanPin is false in each case listed in the behavior block. Use groups "" and "g" only where the membership func denies, errors or is never reached (wrong context, user 0, nil db). Also cover a nil moderatorGroup func with a member (must be false without a query). Update the test's doc comment to mention pins. The positive moderator case needs a real database and lives in Task 3's TestDemoPinPolicy.
4. reset_test.go addChatActivity: right before the "pin" step, call makeModerator(t, a, userID). Add a one-line comment that pinning in the demo channels is a moderator action (chatchannels PinPolicy). Leave every other step as it is.
5. README.md "Moderation." paragraph: the moderator group now also pins and unpins channel messages; other members only read the pins. Keep the paragraph's style. No new config key is added.
6. Run gofmt, go vet ./..., go test -short ./..., and the Docker-backed TestResetKeepsPlans, TestDemoModeratorDelete and TestDemoChannelsSeededOnly. They pass on v0.1.0, where any member may still pin. Commit only these five paths, staged explicitly. Suggested subject: "feat: demo moderators answer chatchannels' pin policy". No trailer, no push.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app && test "$(git -C plugins/golem15/chatchannels rev-parse HEAD)" = 017843a6d8191d743a910ea20b4899f894aad837 && test -z "$(gofmt -l plugins/demo reset_test.go)" && go vet ./... && go test -short -count=1 ./... && go test -short -count=1 -run '^TestDemoChatAccess$' -v ./plugins/demo/classes 2>&1 | grep -qF -- '--- PASS: TestDemoChatAccess' && go test -count=1 -run '^TestResetKeepsPlans$|^TestDemoModeratorDelete$|^TestDemoChannelsSeededOnly$' . && git diff --quiet HEAD -- plugins/demo reset_test.go README.md && C=$(git rev-parse HEAD) && N=$(git diff --name-only "$C^" "$C") && test "$(sort <<<"$N" | tr '\n' ' ')" = "README.md plugins/demo/chat.go plugins/demo/classes/chat_context.go plugins/demo/classes/chat_context_test.go reset_test.go " && M=$(git log -1 --format=%B) && ! grep -qi 'co-authored-by' <<<"$M" && grep -qF 'NewChatAccess(p.db, p.chatModeratorGroup, nil)' plugins/demo/chat.go</automated>
</verify>
<done>sm-demo-app master has one new commit touching exactly the five files. ChatAccess.CanPin answers from the moderator group, CanManageContext is still false, and NewChatAccess takes the moderator group func. The walker is a moderator before pinning in the reset test. go vet, go test -short and the three Docker tests pass against the unchanged v0.1.0 submodule. No trailer, not pushed.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: sm-demo-app: bump the chatchannels submodule (own commit), then prove the moderator-only pin rule end to end</name>
<files>../sm-demo-app/plugins/golem15/chatchannels, ../sm-demo-app/plugins/demo/classes/chat_context.go, ../sm-demo-app/chat_test.go</files>
<precondition>Task 1's commit is on /media/nvme/dev/golem15/summercms.io/summercms/sm-chatchannels-plugin main (contracts/pin_policy.go exists there), and Task 2's commit is on sm-demo-app master.</precondition>
<behavior>
- With the submodule at the Task 1 commit, TestResetKeepsPlans, TestDemoModeratorDelete, TestDemoChannelsSeededOnly, TestDemoChatTracer and TestSeedChatSmoke stay green.
- TestDemoPinPolicy (Docker): a plain member's POST pin in #general is 404 and writes no row. A plain member's DELETE of the seeded #general pin is 404 and the row survives. A plain member's GET pins is 200.
- A demo-moderators member's POST pin of a #general message is 200 with pinned_by = moderator. Their POST of an #announcements message or of a DM message through #general's pins path is 404 with no row. Their DELETE of their own pin and of the seeded welcome pin is 200 and both rows are gone.
</behavior>
<action>
Work in /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app.
1. Pointer bump, as its own commit:
- Fetch the plugin commit from the local sibling clone, not from origin, since nothing is pushed: `git -C plugins/golem15/chatchannels fetch /media/nvme/dev/golem15/summercms.io/summercms/sm-chatchannels-plugin main`, then `git -C plugins/golem15/chatchannels checkout -q --detach FETCH_HEAD`.
- Check that the submodule HEAD equals `git -C /media/nvme/dev/golem15/summercms.io/summercms/sm-chatchannels-plugin rev-parse main`.
- Leave go.mod (still v0.1.0) and go.sum unchanged; tagging v0.1.1 and requiring it is the user's release step.
- Run go vet ./..., go test -short ./..., and the Docker-backed TestResetKeepsPlans, TestDemoModeratorDelete, TestDemoChannelsSeededOnly, TestDemoChatTracer and TestSeedChatSmoke. Task 2 makes these pass with the stricter plugin.
- Stage only the gitlink (`git add plugins/golem15/chatchannels`) and commit. Suggested subject: "chore: bump chatchannels to <short sha> (pin policy, untagged)". The body notes that check-pins.sh reports chatchannels until sm-chatchannels-plugin is tagged and go.mod requires the tag. No trailer.
2. End-to-end proof, as a second commit:
- In plugins/demo/classes/chat_context.go, add `_ chancontracts.PinPolicy = (*ChatAccess)(nil)` to the interface assertions var block. This guards against signature drift: a mismatched CanPin would otherwise silently fail closed.
- In chat_test.go, add `TestDemoPinPolicy` next to TestDemoModeratorDelete, with a doc comment naming WR-02.
- Setup: bootAPI(t, nil, nil); a plain member and a moderator via a.member, with makeModerator for the moderator. Both open #general with a.openChannel. The moderator opens #announcements. Take the channel ids from a.bootstrapChat(...).Channels by slug.
- Messages: the plain member posts a #general message. The moderator posts an #announcements message. The plain member opens a DM with the moderator and posts in it.
- Read the seeded #general pin's message_id with scalar from golem15_chatchannels_channel_pins.
- Assert every behavior bullet through a.call on /_chatchannels/api/v1/contexts/demo/1/channels/{generalID}/pins and .../pins/{messageID}, checking rows with scalar counts.
- Run gofmt, go vet ./..., go test -short ./..., TestDemoPinPolicy -v (must PASS, not SKIP) and the five Docker tests above. Commit chat_test.go and chat_context.go only. Suggested subject: "test: pinning in the demo channels is a moderator action". No trailer.
3. Do not run scripts/build.sh or scripts/check-pins.sh as gates: check-pins.sh is expected to flag the untagged chatchannels pointer. Do not push, tag or deploy.
4. Record these follow-ups in the SUMMARY for the user (do not act on them):
- Release: tag sm-chatchannels-plugin (v0.1.1) on main and push it, then bump sm-demo-app go.mod to that tag; check-pins then passes again. The release also needs a changelog post at /changelog/plugins.
- sm-bm-app vendors chatchannels too, and its ChatAccess.CanManageContext is false. When it bumps past this commit its members stop pinning unless it implements CanPin.
- vue-chat-layer could hide the pin action up front from the bootstrap's `moderator` flag; today it hides the action after the first refusal.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app && P=$(git -C /media/nvme/dev/golem15/summercms.io/summercms/sm-chatchannels-plugin rev-parse main) && test "$(git -C plugins/golem15/chatchannels rev-parse HEAD)" = "$P" && L=$(git ls-tree HEAD plugins/golem15/chatchannels) && test "$(awk '{print $3}' <<<"$L")" = "$P" && test -z "$(gofmt -l chat_test.go plugins/demo)" && go vet ./... && go test -short -count=1 ./... && go test -count=1 -run '^TestDemoPinPolicy$' -v . 2>&1 | grep -qF -- '--- PASS: TestDemoPinPolicy' && go test -count=1 -run '^TestResetKeepsPlans$|^TestDemoModeratorDelete$|^TestDemoChannelsSeededOnly$|^TestDemoChatTracer$|^TestSeedChatSmoke$' . && grep -qF 'chancontracts.PinPolicy' plugins/demo/classes/chat_context.go && git diff --quiet HEAD -- go.mod go.sum chat_test.go plugins/demo plugins/golem15/chatchannels && B=$(git log -1 --format=%H -- plugins/golem15/chatchannels) && test "$(git diff --name-only "$B^" "$B")" = "plugins/golem15/chatchannels" && git diff --quiet "$B^" HEAD -- go.mod go.sum && N=$(git diff --name-only HEAD^ HEAD) && test "$(sort <<<"$N" | tr '\n' ' ')" = "chat_test.go plugins/demo/classes/chat_context.go " && M=$(git log -2 --format=%B) && ! grep -qi 'co-authored-by' <<<"$M"</automated>
</verify>
<done>The sm-demo-app gitlink for plugins/golem15/chatchannels points at the Task 1 commit, in a commit that changes nothing else. A following commit adds the PinPolicy compile-time assertion and TestDemoPinPolicy, which passes against Postgres. The reset, moderator-delete, seeded-only, chat-tracer and seed-smoke tests stay green, and go.mod/go.sum are unchanged. No trailers, nothing pushed or tagged. The SUMMARY lists the three follow-ups (tag plus go.mod bump plus changelog, sm-bm-app impact, optional up-front hiding in the frontend).</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser -> /_chatchannels/api/v1 pins routes | An authenticated (jwt.auth) frontend user sends an untrusted channelId, messageId and body message_id |
| chatchannels -> host resolver (AccessResolver / PinPolicy) | The plugin trusts the host's answers; the host decides who manages and who pins |
| demo -> user groups (users_groups / user_groups) | The moderator role is read from the configured group code on every check |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-oc6-01 | Elevation of privilege | ChannelService.PinMessage / UnpinMessage (WR-02) | high | mitigate | requirePin: channel access, then CanManageContext or PinPolicy.CanPin, else ErrForbidden (404). TestPinPolicy and TestDemoPinPolicy prove a plain member can neither pin nor unpin a seeded pin |
| T-oc6-02 | Tampering | PinMessage message_id (cross-conversation pin) | medium | mitigate | requireMessageInChannel counts live golem15_chat_messages rows with conversation_id = channel.conversation_id. Foreign (other channel, DM), unknown and soft-deleted ids are ErrNotFound with no row (tests in both repos) |
| T-oc6-03 | Information disclosure | pins routes error responses | low | mitigate | Forbidden, not-found and foreign-message all answer the same 404 `{"error":"Not found"}` through the unchanged writeChannelError, so the routes are no oracle for message ids in other conversations |
| T-oc6-04 | Elevation of privilege | PinPolicy granting a non-member (host bug) | medium | mitigate | requireChannelAccess runs before PinPolicy and its error is returned unchanged. TestPinPolicy asserts the stranger gets ErrForbiddenAccess and CanPin is never called for them |
| T-oc6-05 | Denial of service | pins panel flooding | low | mitigate | Only managers or policy-approved users pin (T-oc6-01). The existing chatchannels-api bucket (120/min) still applies |
| T-oc6-06 | Elevation of privilege | demo ChatAccess.CanPin / CanManageContext | medium | mitigate | CanPin requires community membership plus the moderator group, and fails closed on an empty group, nil func, nil db or any error. CanManageContext stays false, so moderators still cannot create channels (TestDemoChannelsSeededOnly) |
| T-oc6-07 | Spoofing / Repudiation | pin attribution | low | accept | pinned_by keeps the first pinner (idempotent FirstOrCreate, PHP D-57-02). An unpin is not attributed, the same as before; only moderators can unpin now |
| T-oc6-SC | Tampering | Go module installs | low | accept | No dependency is added. go.mod and go.sum are unchanged in both repos (Task 1 and Task 3 verify). The submodule moves to a local commit fetched from the sibling clone, not from a remote |
</threat_model>
<verification>
- Plugin: gofmt clean; go vet ./...; go test -short ./...; full go test ./... with Docker; TestPinPolicy PASS (not SKIP); TestPHPMethodMap green with the six pin rows on TestPinPolicy.
- Demo: go vet ./...; go test -short ./...; Docker tests TestDemoPinPolicy, TestResetKeepsPlans, TestDemoModeratorDelete, TestDemoChannelsSeededOnly, TestDemoChatTracer and TestSeedChatSmoke green with the submodule at the plugin commit.
- Git: one plugin commit on main; three demo commits in the order host policy, then pointer bump alone, then e2e test; no trailers, no tags, no pushes; go.mod/go.sum untouched in both repos.
</verification>
<success_criteria>
- A channel member who is neither a manager nor approved by the host's PinPolicy cannot pin or unpin. The server enforces it and answers 404.
- A pin always references a live message of the channel's own conversation.
- Existing chatchannels hosts compile without change. The new rule is documented in the plugin README together with the way to restore the old behaviour.
- In the demo, only members of the configured moderator group pin and unpin. Every other member still reads the pins, and channel creation stays closed.
</success_criteria>
<output>
Create `.planning/quick/261105-oc6-chatchannels-pin-policy/261105-oc6-SUMMARY.md` in summercms.go when done. Include:
- the plugin commit SHA and base;
- the three demo commit SHAs;
- the test results, including that TestPinPolicy and TestDemoPinPolicy ran against Postgres;
- confirmation that nothing was pushed or tagged and that go.mod/go.sum are unchanged;
- the three follow-ups from Task 3 step 4.
Do not stage or commit anything in summercms.go; the batch coordinator commits the planning docs (SUMMARY, todo move, STATE).
</output>

View File

@@ -0,0 +1,126 @@
---
phase: quick-261105-oc6
plan: 01
subsystem: sm-chatchannels-plugin, sm-demo-app
tags: [chatchannels, pins, security, WR-02, demo]
status: complete
requires: []
provides:
- contracts.PinPolicy (optional CanPin host hook) in sm-chatchannels-plugin
- ChannelService.CanPinInChannel
- demo ChatAccess.CanPin answering from the moderator group
affects: [sm-bm-app (when it bumps chatchannels), vue-chat-layer (optional up-front pin hiding)]
tech-stack:
added: []
patterns: [optional host interface discovered by type assertion, like sm-chat-plugin contracts.MessageModerator]
key-files:
created:
- ../sm-chatchannels-plugin/contracts/pin_policy.go
- ../sm-chatchannels-plugin/pins_test.go
modified:
- ../sm-chatchannels-plugin/contracts/access_resolver.go
- ../sm-chatchannels-plugin/classes/channel_service.go
- ../sm-chatchannels-plugin/api/pins.go
- ../sm-chatchannels-plugin/testdata/PHP_METHOD_MAP.md
- ../sm-chatchannels-plugin/README.md
- ../sm-demo-app/plugins/demo/classes/chat_context.go
- ../sm-demo-app/plugins/demo/classes/chat_context_test.go
- ../sm-demo-app/plugins/demo/chat.go
- ../sm-demo-app/reset_test.go
- ../sm-demo-app/README.md
- ../sm-demo-app/plugins/golem15/chatchannels (gitlink)
- ../sm-demo-app/chat_test.go
decisions:
- Pinning in chatchannels is manage-only unless the host resolver also implements contracts.PinPolicy (user-confirmed; sm-bm-app behaviour change accepted as a follow-up).
- The sm-demo-app pointer bump is committed untagged; check-pins.sh flags it until the plugin is tagged.
completed: 2026-11-05
actuals:
tasks: 3
commits: 4
tokens: 9000
---
# Quick 261105-oc6: chatchannels pin policy Summary
Pinning and unpinning in chatchannels now need `CanManageContext` or an optional host `contracts.PinPolicy`, and a pinned message must be a live message of the channel's conversation. In the demo, only `demo-moderators` members pin and unpin. Every other member can still read the pins.
## Commits
Commits are in sibling repos, so the summercms.go ledger (`commits:` measured against summercms.go HEAD) does not apply. The hashes below are measured in each repo.
**sm-chatchannels-plugin** (`main`, base `017843a` = v0.1.0; main had not moved past it)
- `a042536` fix: pinning needs CanManageContext or a host PinPolicy, and the message must be in the channel
**sm-demo-app** (`master`, base `7b75d2c`, in this order)
- `ce800f2` feat: demo moderators answer chatchannels' pin policy (5 files)
- `7e4b9d3` chore: bump chatchannels to a042536 (pin policy, untagged) (gitlink only)
- `11d3afc` test: pinning in the demo channels is a moderator action (chat_test.go and chat_context.go)
No commit has an attribution trailer. Nothing was pushed or tagged: `git branch -r --contains` and `git tag --points-at` are empty in both repos. go.mod and go.sum are unchanged in both repos (`017843a..main` in the plugin, `7b75d2c..HEAD` in the demo).
## What changed
- **Plugin**
- `contracts.PinPolicy` adds `CanPin(ctx, userID, contextType, contextID, channelID)`. It is optional and found by type assertion on the installed resolver.
- `ChannelService.requirePin` checks channel access first and returns its error unchanged, so `CanPin` is never asked for a user without access. It then requires `CanManageContext` or `CanPin`, else `ErrForbidden` (404).
- `requireMessageInChannel` counts live `golem15_chat_messages` rows in the channel's conversation (soft-deleted rows excluded). An unknown, deleted or foreign message, or a channel with no conversation, gets `ErrNotFound`.
- Unpinning does not check the message, so a stale pin stays removable.
- New exported nil-safe `CanPinInChannel`. The `AccessResolver` interface and every existing signature are unchanged.
- Handler docs updated, the PHP map's six pin rows now point at `TestPinPolicy`, and the README has the rule, a Usage example, API reference rows and Testing lines.
- **Demo**
- `NewChatAccess(db, moderatorGroup, isMember)` mirrors `NewForumAccess`, with a shared `member` helper.
- `CanPin` is true only for a community member in the moderator group, and fails closed on any error, empty group, nil func or nil db. `CanManageContext` stays false (D-07).
- `chat.go` installs the resolver with `p.chatModeratorGroup`.
- The reset test's walker is made a moderator before it pins.
- The README "Moderation." paragraph is updated.
- After the pointer bump, a compile-time `chancontracts.PinPolicy` assertion was added.
## Test results
- **Plugin**
- `go vet ./...` passes.
- `go test -short -count=1 ./...` passes.
- The full Docker-backed `go test -count=1 ./...` passes (~19 s).
- `TestPinPolicy` passed against Postgres, not skipped (1.45 s). `TestDenyAllHasNoPinPolicy` and `TestPHPMethodMap` pass.
- Mutation check: with the message-in-channel check disabled, `TestPinPolicy` fails ("pin unknown message: <nil>, want ErrNotFound"). The code was restored before committing.
- **Demo, Task 2** (submodule still at v0.1.0)
- `go vet ./...` and `go test -short ./...` pass, and `TestDemoChatAccess` passes.
- Docker tests `TestResetKeepsPlans`, `TestDemoModeratorDelete` and `TestDemoChannelsSeededOnly` pass.
- **Demo, Task 3** (submodule at a042536)
- `go vet ./...` and `go test -short ./...` pass.
- `TestDemoPinPolicy` passed against Postgres (2.74 s).
- `TestResetKeepsPlans`, `TestDemoModeratorDelete`, `TestDemoChannelsSeededOnly`, `TestDemoChatTracer` and `TestSeedChatSmoke` pass.
- The full Docker-backed `go test -count=1 ./...` passes (~57 s).
## Deviations from Plan
**1. [Scope note] The plan's Task 1 verify gate `test -z "$(gofmt -l .)"` fails in the plugin because of a formatting issue that was already there.**
- `fixture_plugin_test.go` (lines 86-89) has mis-aligned one-line method bodies at base `017843a`. This plan did not touch the file.
- The changed and new files are all gofmt-clean. I left the existing issue alone because it is out of scope, and the plan stages exactly seven paths.
- Fixing it is a one-line `gofmt -w fixture_plugin_test.go` in a later plugin commit.
**2. [Test design] TestPinPolicy proves "first pinner wins" by briefly making `pinner` a manager for the second pin.**
- The first pinner is the manager. Then the base resolver is restored.
- The plan text did not fix who makes the second pin. Every other behaviour bullet is covered as written.
Otherwise the plan was executed as written.
## Follow-ups for the user (not acted on)
1. **Release:**
- Tag sm-chatchannels-plugin `v0.1.1` on `main` (a042536) and push it.
- Bump sm-demo-app `go.mod` to require that tag. `scripts/check-pins.sh` reports chatchannels until then.
- Publish a changelog post at /changelog/plugins (`scripts/changelog-post.py publish v0.1.1 notes.md --excerpt "…" --repo ../sm-chatchannels-plugin`).
- Push the three sm-demo-app commits after that.
2. **sm-bm-app** also vendors chatchannels, and its `ChatAccess.CanManageContext` is false. When it bumps past a042536, its members stop pinning unless it implements `CanPin`. To keep the old behaviour, answer `CanPin` with its own `CanAccessContext`.
3. **vue-chat-layer** could hide the pin action up front from the bootstrap's `moderator` flag. Today `togglePin` hides it only after the first 4xx refusal.
## Threat Flags
None: the change narrows existing surface and adds no endpoint.
## Self-Check: PASSED
- Files exist: `sm-chatchannels-plugin/contracts/pin_policy.go`, `sm-chatchannels-plugin/pins_test.go`, `sm-demo-app/chat_test.go` (TestDemoPinPolicy).
- Commits exist: plugin `a042536`; demo `ce800f2`, `7e4b9d3`, `11d3afc`.
- The demo gitlink at HEAD equals the plugin `main` (a0425366487b1c8e8a9566a74fe6b35613f529da).

View File

@@ -0,0 +1,440 @@
---
phase: quick-261105-oc7
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- ../sm-chatvideo-plugin/classes/video_service.go
- ../sm-chatvideo-plugin/classes/authorizer.go
- ../sm-chatvideo-plugin/classes/livekit.go
- ../sm-chatvideo-plugin/classes/revoked_access.go
- ../sm-chatvideo-plugin/boot.go
- ../sm-chatvideo-plugin/live_membership_test.go
- ../sm-chatvideo-plugin/contracts/call_policy.go
- ../sm-chatvideo-plugin/contracts/call_policy_test.go
- ../sm-chatvideo-plugin/api/errors.go
- ../sm-chatvideo-plugin/lang/en/lang.yaml
- ../sm-chatvideo-plugin/lang/pl/lang.yaml
- ../sm-chatvideo-plugin/call_policy_test.go
- ../sm-chatvideo-plugin/README.md
- ../sm-chatvideo-plugin/plugin.yaml
- ../sm-demo-app/plugins/golem15/chatvideo
- ../sm-demo-app/plugins/demo/video.go
- ../sm-demo-app/plugins/demo/plugin.go
- ../sm-demo-app/video_test.go
- ../sm-demo-app/config/golem15.chatvideo.yaml
- ../sm-demo-app/README.md
- ../vue-chatvideo-layer/app/composables/useIncomingCalls.ts
- ../vue-chatvideo-layer/vitest.config.ts
- ../vue-chatvideo-layer/tests/useIncomingCalls.test.ts
- ../vue-chatvideo-layer/README.md
autonomous: true
requirements:
- todo:chatvideo-live-membership
- todo:chatvideo-dm-only-host-hook
- todo:chatvideo-incoming-handle-leak
must_haves:
truths:
- "With a host chat context that implements golem15.chat's contracts.LiveMembership (RequireLiveMembership true), a conversation participant whose HasMember answers false gets 404 from POST /_chatvideo/api/v1/conversations/{id}/video/join, /leave, /end, GET /video/status and GET /call-links/{code}, and the registered video authorizer denies them video:session:{id}. When HasMember answers true again, access comes back. Start neither invites nor rings such a member: no participant row, and they are not in the Incoming recipient list"
- "A context that does not implement LiveMembership (or answers false) and a conversation with no context keep the participant-row behaviour unchanged. The existing tests (TestHTTPTracer, TestVideoAuthorizer, TestReconcileEmptyRoom, TestUserDeletionReassignsSession, TestGoldenEvents) pass without edits to their assertions"
- "A ban (golem15.user AccessRevokedEvent with Reason userclasses.AccessRevokedBan) or a deactivation (DeactivatingEvent) marks the user's joined participant rows of live sessions left, with left_at set, inside the event's transaction. Only after the commit does it remove the user's LiveKit identity from every live room where they were joined or had left, and publish participant-left for the rows that were joined. The call goes on for everyone else. A rolled-back ban changes nothing. AccessRevokedEvent with any other reason (password reset or change) leaves calls alone"
- "VideoService.SetCallPolicy installs an optional contracts.CallPolicy. With no policy installed, every member may start a call, as before. With contracts.DMOnly installed, POST .../video/start in a group conversation answers 403 {\"error\":\"Video calls are not available in this conversation\"}, and it creates no session, room or participant row and rings nobody. A DM start is unchanged. A policy error answers 500 and creates nothing. Joining a call that is already running (POST .../video/join) is never checked against the policy. A non-participant still gets 404, and the policy is not consulted for them"
- "sm-demo-app installs contracts.DMOnly at boot. TestDemoVideoStart asserts that a channel start is refused with 403 whether or not the channel fits max_participants: the rule no longer depends on seed data. DM calls behave as before, and the config comment and README say the server enforces the rule"
- "useIncomingCalls never keeps more than one session handle. A subscribe that resolves after a newer ring, a dismiss, accept, decline or the ring timeout has its handle closed at once. Only the latest ring's handle is kept, and clear() closes it. The new test fails on the code before the fix"
- "Additive: no exported sm-chatvideo-plugin identifier is removed or changes its signature (AuthorizeVideo, AuthorizeVideoCheck, AuthorizeVideoLookup, NewVideoService, VideoOptions, VideoService methods). No go.mod changes in any repo. No framework (summercms.go) code changes. Nothing is pushed or tagged. No commit has a co-author trailer"
artifacts:
- path: ../sm-chatvideo-plugin/classes/video_service.go
provides: "MembershipCheck type, VideoOptions.Membership and VideoOptions.CallPolicy, SetMembership, SetCallPolicy, ErrCallNotAllowed; requireParticipant goes through the membership check; Start filters invitees and applies the policy"
- path: ../sm-chatvideo-plugin/classes/authorizer.go
provides: "AuthorizeVideoMembership(getDB, member); AuthorizeVideoLookup delegates with a nil check"
- path: ../sm-chatvideo-plugin/classes/revoked_access.go
provides: "VideoService.RemoveFromCalls(ctx, tx, userIDs) with after-commit LiveKit removal"
- path: ../sm-chatvideo-plugin/contracts/call_policy.go
provides: "contracts.CallPolicy, contracts.CallPolicyFunc, contracts.DMOnly"
- path: ../sm-demo-app/plugins/demo/video.go
provides: "registerVideo installs contracts.DMOnly on the published *classes.VideoService"
- path: ../vue-chatvideo-layer/app/composables/useIncomingCalls.ts
provides: "a sequence number per watchSession, bumped by clear(); stale handles are closed"
key_links:
- from: "../sm-chatvideo-plugin/boot.go chatMembership(app)"
to: "golem15.chat classes.Service.IsParticipant (participant row + contracts.LiveMembership)"
via: "classes.MembershipCheck, used by bootService (VideoOptions.Membership) and bootRealtime (AuthorizeVideoMembership)"
- from: "../sm-chatvideo-plugin/boot.go bootListeners"
to: "classes.VideoService.RemoveFromCalls -> lagoon.AfterCommit -> RoomAPI.RemoveParticipant"
via: "app.Events.Listen for *userclasses.AccessRevokedEvent (reason ban) and *userclasses.DeactivatingEvent"
- from: "../sm-demo-app/plugins/demo/video.go"
to: "classes.VideoService.SetCallPolicy(contracts.DMOnly)"
via: "lagoon.OnDatabase after chatvideo publishes its service"
- from: "../sm-chatvideo-plugin/api/errors.go ErrCallNotAllowed -> 403"
to: "vue-chatvideo-layer callMachine failureReason(403) = 'forbidden'"
via: "existing frontend mapping; no layer change needed for the refusal"
---
<objective>
Close the three chatvideo todos from the phase 20 code review in three sibling repos, with additive changes only: IN-02 (`chatvideo-live-membership`), IN-01 (`chatvideo-dm-only-host-hook`) and IN-07 (`chatvideo-incoming-handle-leak`).
1. **Live membership and bans (sm-chatvideo-plugin).** chatvideo checks participant rows only. Change `requireParticipant` and the video authorizer to go through golem15.chat's `Service.IsParticipant`, which also asks the host context about current membership when the context implements `contracts.LiveMembership`. When a user is banned (or deactivated), chatvideo removes them from every live LiveKit room after the transaction commits.
2. **DM-only host hook (sm-chatvideo-plugin, then sm-demo-app).** Add an optional `contracts.CallPolicy` with `CanStartCall`, a ready-made `contracts.DMOnly` policy and `VideoService.SetCallPolicy`. The demo installs `DMOnly`, so the server refuses channel calls whatever the seeded participant count is.
3. **Stale handles (vue-chatvideo-layer).** `useIncomingCalls` keeps a sequence number per `watchSession` and closes every handle that is not the latest.
Live reads at planning time (all four repos clean; sm-chatvideo-plugin on `main` at 0f08c35 = v0.1.1, vue-chatvideo-layer on `main` at 2313a4e = v0.1.1, sm-demo-app on `master` at da03c06):
- `classes/video_service.go`: `requireParticipant` (lines 425-443) loads the conversation (missing: ErrNotFound) and then counts `golem15_chat_participants` rows (none: ErrNotParticipant). Start, Status, CallLink and `sessionForActor` (and through it Join, Leave and End) all call it. `Start` (84-168) plucks every participant's user_id into memberIDs, refuses with ErrTooMany when `len(memberIDs) > max_participants`, and passes memberIDs to `invite` and to `notify.Incoming`. `Leave` (207-228) shows the left pattern: Updates status `models.ParticipantLeft` and `left_at`, then calls `rooms.RemoveParticipant(ctx, room, strconvUint(actorID))`, then `notify.ParticipantLeft`.
- `classes/authorizer.go`: `AuthorizeVideoLookup(getDB)` (54-79) loads the session and counts participant rows of `session.ConversationID`. `bootRealtime` registers it under the namespace "video".
- `classes/user_deletion.go` is the pattern for work that has to wait for the commit: `q()` builds sessions on tx with `Session(&gorm.Session{NewDB: true})`; then `lagoon.AfterCommit(ctx, q(), fn)`, `context.WithoutCancel`, `roomCallTimeout` (10 s), and a `pluginLog.Warn` unless the error is ErrNotConfigured. A nil tx falls back to the service DB.
- golem15.chat: `(*classes.Service).IsParticipant(ctx, conversationID, userID) (bool, error)` exists in the required v0.1.3 and in the workspace checkout (`../sm-chat-plugin`, v0.1.5). It answers whether a participant row exists and, if the conversation's context implements `contracts.LiveMembership` and `RequireLiveMembership()` is true, whether `HasMember` holds. Chat publishes `*classes.Service` from its own `lagoon.OnDatabase` hook. Chat boots first (chatvideo `Requires()` golem15.chat), and OnDatabase hooks run in FIFO order.
- golem15.user: `AccessRevokedEvent{UserIDs []uint, Before time.Time, Reason string, Tx *gorm.DB}`, the `AccessRevokedBan` reason constant and `DeactivatingEvent{User *models.User, Tx *gorm.DB}` exist in the required v0.1.13 and in the workspace user plugin (fonoteka.go/plugins/golem15/user, v0.1.14). The admin ban action fires AccessRevokedEvent (reason "ban") inside its transaction (users_admin_controller.go:678). sm-mfa-plugin and sm-oauthserver-plugin already listen to it. No go.mod change is needed anywhere.
- lighthouse has no server-side unsubscribe or disconnect API. A Centrifugo subscription token expires (realtime.centrifugo.token_ttl, default 3600 s). Refreshing it needs the user's JWT (revoked by a ban) and runs the authorizer again.
- The production `sdkRooms.RemoveParticipant` already swallows a missing room or participant (`isMissingRoom`). `FakeRooms` has no way to put a named identity in a room: `SetCount` creates identities "0".."n-1".
- The HTTP error mapping (`api/errors.go` writeVideoError) has no 403 today. vue-chatvideo-layer `failureReason` maps 403 to the reason key 'forbidden' ("You can't join this call.") and 422 to 'tooMany'. A policy refusal must therefore be a 403 to read correctly in the existing UI.
- `TestFixtureAbsentFromProduction` fails when a production file (.go, .yaml or .md, including README.md) contains `acme.teams`, `teamsPlugin`, or any of the words queststream, family, parent, child, wavepath or fonoteka. Do not use those words in production comments or the README.
- sm-demo-app: `plugins/golem15/chatvideo` is a submodule detached at v0.1.1 (0f08c35), and `go.mod` requires sm-chatvideo-plugin v0.1.1. `go.work` uses the submodule checkout, so dev builds and `go test` compile the submodule. `scripts/check-pins.sh` and the release build (`scripts/build.sh`, GOWORK=off) use the go.mod version. `TestDemoVideoStart` (video_test.go:97) pins "no type rule on the server": a channel start is refused with 422 only because of `max_participants: 4`, and is accepted (201) after the channel is trimmed to 2 participants. The demo plugin installs other plugins' host policies from `lagoon.OnDatabase` hooks (chat.go registerChat/registerForum); Boot runs a list of steps in plugin.go.
- vue-chatvideo-layer `useIncomingCalls.ts` (88 lines): `IncomingRuntime {registered, channel, timer}` sits on `nuxtApp._chatvideoIncoming`. `watchSession` closes `rt.channel`, awaits `subscribe(videoSessionChannel(id), {onPublication})`, and keeps the handle when `pending.value?.sessionId === call.sessionId`. Two rings for the same session both pass that check, so the earlier handle leaks. Registration is guarded by `import.meta.client`, which is undefined under vitest. A scratchpad check confirmed that vitest 3.2.7 replaces it with `define: { 'import.meta.client': 'true' }`. The layer has no vitest config. Baseline: `pnpm test` gives 127 tests green; chatvideo `go vet` and `go test -short ./...` are green.
Commits (four, in this order, each staged by explicit path, with no co-author trailer and nothing pushed or tagged):
1. sm-chatvideo-plugin: `fix: honour chat live membership and remove banned users from live calls` (Task 1)
2. sm-chatvideo-plugin: `feat: optional CallPolicy host hook and a DM-only policy` (Task 2, first part)
3. sm-demo-app: `feat: video calls in DMs only, enforced on the server by chatvideo's CallPolicy` (Task 2, second part: submodule pointer bump and wiring in one commit, so the demo compiles at every commit)
4. vue-chatvideo-layer: `fix: the incoming-call runtime closes stale session handles` (Task 3)
Release state after this plan (put this in the SUMMARY): the demo's `plugins/golem15/chatvideo` pointer is ahead of its go.mod pin (still v0.1.1). Dev builds and tests compile through go.work, but `scripts/check-pins.sh` reports the mismatch and the GOWORK=off release build does not compile until the user tags sm-chatvideo-plugin (next v0.1.x), pushes it before the demo, and commits `chore: require sm-chatvideo-plugin vX` (go.mod and go.sum). The same goes for a vue-chatvideo-layer tag and a vue-demo-app bump. The executor does none of these.
Not in scope (the orchestrator records them as follow-ups):
- Ending an already open `video:session` Centrifugo subscription on a ban. lighthouse has no unsubscribe API (a framework change). The subscription's refresh needs the revoked JWT and re-runs the now live-aware authorizer.
- Revoking an already minted LiveKit join token. LiveKit has no revocation. A removed user could reconnect with the token until token_ttl (900 s) runs out. Hosts may lower `golem15.chatvideo.token_ttl`.
- Removing a user from live rooms when they leave a host context without a ban. There is no generic event for that. Live checks still refuse every new join, status, call link and subscribe.
- Ending a call whose last joined participant was banned. The reconciler already ends empty rooms within 5 minutes.
- The logout half of IN-07 (fixed in phase 20 plan 08).
- Tags, pushes, go.mod bumps, the vue-demo-app layer bump, moving the three todos to completed, and the g15office comment. The orchestrator's closing docs commit handles the todos and g15office.
Purpose: a ban ends call access at once, as it already ends chat access. The demo's DM-only promise holds on the server, not only in the seed data and the UI. The incoming-call prompt no longer leaks realtime subscriptions.
Output: four commits in three repos and 261105-oc7-SUMMARY.md.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@./CLAUDE.md
@.planning/todos/pending/chatvideo-live-membership.md
@.planning/todos/pending/chatvideo-dm-only-host-hook.md
@.planning/todos/pending/chatvideo-incoming-handle-leak.md
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-REVIEW.md
<interfaces>
Existing code, verified at planning time. Paths are relative to /media/nvme/dev/golem15/summercms.io/summercms.
sm-chatvideo-plugin (package classes)
- Errors: ErrNotConfigured, ErrNotFound, ErrNotParticipant, ErrAlreadyEnded, ErrTooMany (video_service.go:20-31).
- type VideoOptions struct { DB *gorm.DB; Rooms RoomAPI; Cfg *compass.Config; Now func() time.Time }
- type VideoService struct { db, rooms, cfg, now, notify } ; func NewVideoService(opt VideoOptions) *VideoService ; SetRooms(RoomAPI) ; SetNotifier(callNotify)
- func (s *VideoService) Start(ctx, actorID, conversationID uint) (*StartResult, error) ; Join / Leave / End / Status / CallLink / Reconcile / OnUserDeleting(ctx, tx *gorm.DB, userID uint) error
- func (s *VideoService) requireParticipant(ctx, conversationID, userID uint) error (unexported)
- func (s *VideoService) invite(ctx, sessionID, starter uint, memberIDs []uint, now time.Time) error (unexported)
- callNotify interface: Incoming(ctx, *models.Session, callerID uint, recipientIDs []uint), Accepted, Declined, Ended(ctx, session, endedBy), ParticipantJoined(ctx, session, userID), ParticipantLeft(ctx, session, userID)
- authorizer.go: type ConversationCheck func(ctx, userID, sessionID uint) bool ; AuthorizeVideo(db *gorm.DB) ; AuthorizeVideoCheck(check ConversationCheck) ; AuthorizeVideoLookup(getDB func() *gorm.DB) ; allowVideo(userID, sessionID) ; parseVideoSessionChannel(channel) (uint, bool)
- livekit.go: RoomAPI { CreateRoom(ctx, name) error; DeleteRoom(ctx, name) error; RemoveParticipant(ctx, room, identity string) error; ParticipantCount(ctx, name) (int, error); ActiveRoomNames(ctx) ([]string, error) } ; FakeRooms { mu sync.Mutex; rooms map[string]map[string]struct{}; created []string } with CreateRoom, DeleteRoom, RemoveParticipant, ParticipantCount, ActiveRoomNames, CreatedNames, SetCount(name, n, create), Drop(name)
- user_deletion.go: const roomCallTimeout = 10 * time.Second ; the AfterCommit pattern described in the objective.
- logger.go: var pluginLog = diary.For("chatvideo")
- models: StatusPending/StatusActive/StatusEnded ; ParticipantInvited/ParticipantJoined/ParticipantDeclined/ParticipantLeft ; Session{ID, ConversationID, ActiveConversationID *uint, LivekitRoomName, Status, StartedBy, StartedAt, ...} ; participant columns video_session_id, user_id, status, joined_at, left_at.
- strconvUint(n uint) string (video_service.go:564), the LiveKit identity of a user.
sm-chatvideo-plugin (package chatvideo, root)
- boot.go: init registers boot steps env, realtime, service, listeners. bootRealtime registers classes.AuthorizeVideoLookup(func() *gorm.DB { app.Lookup[*gorm.DB]() }) under "video". bootService (inside lagoon.OnDatabase) builds classes.NewVideoService(VideoOptions{DB, Rooms, Cfg}), SetNotifier, then publishes p and svc. bootListeners does app.Events.Listen[*userclasses.DeletingEvent](PluginID, p.onUserDeleting). Imports userclasses "git.golem15.com/golem15/sm-user-plugin/classes".
- api/errors.go: string constants errNotFound ... errTooMany ; writeVideoError(w, what, err) switch: NotFound/NotParticipant → 404, NotConfigured → 503, AlreadyEnded/TooMany → 422, default → 500.
- lang/en/lang.yaml and lang/pl/lang.yaml: errors.{auth_required, not_found, not_configured, not_participant, session_not_found, already_ended, too_many}.
- Tests (package chatvideo_test): ensurePostgres(t) ; bootVideo(t, ids []string, extraEnv ...string) *videoEnv{app, gdb, plugins, svc, rooms *classes.FakeRooms, rt *lighthouse.Service} ; (e) user(t) *usermodels.User (activated) ; (e) conversation(t, createdBy, participants ...uint) *chatmodels.Conversation (Type group, no context) ; (e) count(t, table, where, args...) int64 ; (e) session(t, id) models.Session ; const participantsTable ; bootWeb(t) *webEnv{*videoEnv, h} ; (e *webEnv) frontendToken(t, user) string ; (e *webEnv) call(t, method, path, token, body) *httptest.ResponseRecorder (path below chatvideo.APIPrefix) ; decodeObject(t, rec) map[string]any ; deleteUserLikeAdmin shows the lagoon.Transaction + app.Events.Fire pattern ; const fixturePluginID = "acme.teams".
golem15.chat (sm-chat-plugin)
- classes.Service: IsParticipant(ctx, conversationID, userID uint) (bool, error).
- contracts.Context: ID() uint ; Type() string ; Members(ctx) ([]uint, error) ; HasMember(ctx, userID) (bool, error) ; CanOverseeChats(ctx, userID) (bool, error) ; ChatEnabled() bool ; GroupName() string ; CanActFor(ctx, actorID, memberID) bool ; BroadcastChannel() string (two or three colon-separated segments).
- contracts.LiveMembership: RequireLiveMembership() bool.
- contracts.Registry (published as *contracts.Registry): Add(pluginID string, reg Registration) error ; Registration{Alias string; Loader Loader; LegacyPHPClass string} ; type Loader func(ctx, db *gorm.DB, id uint) (Context, error).
- models.Conversation{Type string; ContextType *string; ContextID *uint; ...} with columns context_type and context_id ; models.TypeDM = "dm", models.TypeGroup = "group".
golem15.user (sm-user-plugin classes)
- AccessRevokedEvent{UserIDs []uint; Before time.Time; Reason string; Tx *gorm.DB} ; const AccessRevokedBan = "ban", AccessRevokedPasswordChanged = "password_changed" (plus password_reset, password_set_by_admin).
- DeactivatingEvent{User *models.User; Tx *gorm.DB}.
lighthouse (framework, unchanged)
- (*lighthouse.Service).Registry() *Registry ; (*Registry).Get(namespace) (Authorizer, bool) ; Authorizer.Authorize(ctx, userID uint, channel string) Result ; Result.Allowed bool, Result.Reason() string ; lighthouse.Allowed(info) / lighthouse.Denied(reason).
sm-demo-app
- plugins/demo/plugin.go: Boot runs p.wireRealtime then the steps p.registerChat, p.registerForum, p.registerCommands, p.registerModeration. Requires() already lists golem15.chatvideo.
- plugins/demo/chat.go registerForum is the template: app.HasPlugin guard, lagoon.OnDatabase, app.Lookup of the service, error "golem15.demo: golem15.forum must be listed before golem15.demo in summer.yaml", then Set...(policy).
- video_test.go TestDemoVideoStart: bootAPI(t, env, set) ; a.member(t, email) (id, token) ; a.openDM(t, token, otherID) ; a.openChannel(t, token, "general") ; a.call(t, method, path, token, body) ; scalar[T](t, a.Demo, sql, args) ; exec(t, a.DB, sql, args) ; fakeRoomService.called("CreateRoom").
- config/golem15.chatvideo.yaml lines 1-15: a comment block ending "The server does not restrict calls by conversation type; the site offers them in DMs only." README.md lines 199-201: the "**Calls.**" paragraph says the same.
vue-chatvideo-layer
- app/composables/useIncomingCalls.ts: export const RING_MS = 45_000 ; interface IncomingRuntime { registered: boolean; channel: ChannelHandle | null; timer: ReturnType<typeof setTimeout> | null } ; export function useIncomingCalls() returning { pending, accept(), decline(), dismiss }. It imports onUserEvent, subscribe and ChannelHandle from 'vue-chat-layer/app/lib/realtime', parseIncomingCall, videoEvent and videoSessionChannel from '../lib/videoClient', and useVideoClient from './useCall' (which returns nuxtApp._chatvideoClient when set).
- ChannelHandle { close(): void; resubscribe(): void }. onUserEvent(handler) returns an unsubscribe function. parseIncomingCall reads {event: 'video:call:incoming', payload: {session_id, conversation_id, caller_name}}.
- tests/useCall.test.ts shows the stubbing style: vi.mock('vue-chat-layer/app/lib/realtime', ...), vi.stubGlobal('useNuxtApp', () => ({ _chatvideoClient: client })), vi.unstubAllGlobals() in afterEach.
- package.json: "test": "vitest run" ; devDependencies vitest 3.2.7, vue 3.5.35.
</interfaces>
</context>
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: chatvideo honours live membership end to end and removes banned users from live rooms</name>
<files>../sm-chatvideo-plugin/classes/video_service.go, ../sm-chatvideo-plugin/classes/authorizer.go, ../sm-chatvideo-plugin/classes/livekit.go, ../sm-chatvideo-plugin/classes/revoked_access.go, ../sm-chatvideo-plugin/boot.go, ../sm-chatvideo-plugin/live_membership_test.go, ../sm-chatvideo-plugin/README.md</files>
<read_first>
- ../sm-chatvideo-plugin/classes/video_service.go (whole file; requireParticipant, Start, Leave)
- ../sm-chatvideo-plugin/classes/authorizer.go
- ../sm-chatvideo-plugin/classes/user_deletion.go (the AfterCommit pattern to copy)
- ../sm-chatvideo-plugin/boot.go
- ../sm-chatvideo-plugin/harness_test.go, http_tracer_test.go (lines 1-80), user_deletion_test.go (lines 1-75)
- ../sm-chat-plugin/classes/service.go lines 480-560 (IsParticipant, liveHost, stillMember)
- ../sm-user-plugin/classes/access_revoked.go and classes/events.go lines 70-120
</read_first>
<behavior>
- Live context (RequireLiveMembership true, members {A, B}), conversation with participants A, B and C: A's HTTP start answers 201. A's row is joined, B's is invited, and C has no row (not invited, not rung).
- After B leaves the member set: B's POST join answers 404 and B's GET status answers 404. The registered "video" authorizer denies B on video:session:{id} and allows A. After B is added back: B's join answers 200 and the authorizer allows B.
- A context with RequireLiveMembership false: a participant outside the member set still starts, joins and is authorized (participant-row behaviour).
- A ban: A starts and B joins, and the fake room holds identities A and B. An AccessRevokedEvent{Reason: ban, UserIDs: [B]} fired inside a lagoon.Transaction leaves B in the room until the commit. After the commit B is gone from the room, A is still in it, B's row is left with left_at set, and the session is still active.
- AccessRevokedEvent with Reason password_changed for A: A stays in the room and A's row stays joined.
- A ban for A in a transaction that then returns an error (rolled back): A stays in the room and A's row stays joined.
- DeactivatingEvent{User: A} committed: A is removed from the room and A's row is left.
</behavior>
<action>
Work in /media/nvme/dev/golem15/summercms.io/summercms/sm-chatvideo-plugin. First run `git status --short`. If any file this task touches has uncommitted edits from another session, stop and report instead of editing.
RED first. Add two methods to `FakeRooms` in classes/livekit.go. `AddParticipant(room, identity string)` puts identity into an existing fake room (a missing room stays missing, as SetCount without create). `HasParticipant(room, identity string) bool`. Both take the mutex. Then write live_membership_test.go (package chatvideo_test, Docker-backed: call ensurePostgres(t) first so it skips under -short) with the cases in `<behavior>`, in three test functions:
- **TestVideoLiveMembership** (bootWeb). Add a test-only type implementing chat's contracts.Context and contracts.LiveMembership. It holds a mutex-guarded member set and a `live` flag; HasMember reads the set, RequireLiveMembership returns the flag, ChatEnabled is true, BroadcastChannel is "acme:team:1", CanActFor uses chatcontracts.DefaultCanActFor, CanOverseeChats is false. Look up `*chatcontracts.Registry` from env.app and Add(fixturePluginID, Registration{Alias "acme_team", Loader returning that context for id 1, nil otherwise}). Point the test conversation at it with an Updates of context_type "acme_team" and context_id 1. Check rows with env.count on participantsTable. Get the authorizer with `env.rt.Registry().Get("video")`.
- **TestVideoMembershipWithoutLiveCheck**: same setup with live false.
- **TestVideoBanRemovesFromCall** (bootVideo). Fire the events through env.app.Events.Fire inside lagoon.Transaction, as deleteUserLikeAdmin does. Seed the fake room with AddParticipant using the users' decimal ids as identities.
Run the tests against the unchanged service and record that they fail for the membership, ban and deactivation assertions, not only for compile errors.
GREEN, in classes/video_service.go:
- Add the exported type `MembershipCheck`: a func of (ctx context.Context, conversationID, userID uint) returning (bool, error). Its doc comment says golem15.chat's Service.IsParticipant has this shape.
- Add `Membership MembershipCheck` to VideoOptions and a `membership` field to VideoService, copied by NewVideoService. Add `SetMembership(m MembershipCheck)`, nil-receiver safe like SetNotifier, where nil restores the participant-row check.
- requireParticipant: keep the conversation load and its ErrNotFound. Then, when s.membership is set, call it. An error is returned as is, and false is ErrNotParticipant. When it is not set, keep today's participant-row count exactly. Status, CallLink, Join, Leave and End inherit this through requireParticipant and sessionForActor; do not change their signatures.
- Start: keep the ErrTooMany check on the raw memberIDs, unchanged and still before any per-member work. Then, when s.membership is set, build the recipient list from memberIDs in order. Keep actorID unconditionally (it already passed requireParticipant) and drop every other id for which the check answers false; a check error aborts Start with that error. Pass this list to invite and to notify.Incoming instead of memberIDs. With no membership set, the list is memberIDs.
In classes/authorizer.go, add the exported `AuthorizeVideoMembership(getDB func() *gorm.DB, member MembershipCheck) lighthouse.AuthorizerFunc`. It is AuthorizeVideoLookup's body, except that after loading the session it calls member(ctx, session.ConversationID, userID) when member is non-nil. An error or false is Denied("not a conversation participant"). With a nil member it keeps the participant-row count. AuthorizeVideoLookup becomes a one-line delegation with a nil member. AuthorizeVideo and AuthorizeVideoCheck stay as they are.
Create classes/revoked_access.go with the exported `(s *VideoService) RemoveFromCalls(ctx context.Context, tx *gorm.DB, userIDs []uint) error`. Its doc comment says it removes the users from every live call, for a ban or a deactivation. It mirrors OnUserDeleting's structure: nil and empty guards; a nil tx falls back to s.db and a nil ctx to Background; the q() helper on tx. Steps:
1. Load the live sessions (status pending or active, active_conversation_id NOT NULL) in which any of the users has a participant row with status joined or left.
2. Inside tx, update the users' rows with status joined in those sessions to status left with left_at now (s.now().UTC()).
3. Register one lagoon.AfterCommit callback on q(). It removes each (room, strconvUint(userID)) pair whose row was joined or left through s.rooms.RemoveParticipant, using roomCallTimeout per call and a Warn log with op remove_participant unless the error is ErrNotConfigured. It then calls s.notify.ParticipantLeft(base, session, userID) for the rows that were joined.
4. Return nil without registering a callback when there is nothing to do.
It never ends a session and never deletes rows.
In boot.go:
- Add an unexported `chatMembership(app *backpack.App) classes.MembershipCheck`. On each call it looks up `*chatclasses.Service` (import "git.golem15.com/golem15/sm-chat-plugin/classes" as chatclasses) and returns its IsParticipant answer. A missing or nil service returns false with an unexported error naming golem15.chat: fail closed, never fall back to the participant row.
- bootRealtime registers classes.AuthorizeVideoMembership with the existing getDB closure and chatMembership(app).
- bootService sets Membership: chatMembership(app) in VideoOptions.
- bootListeners adds two listeners next to the DeletingEvent one. `app.Events.Listen[*userclasses.AccessRevokedEvent](PluginID, p.onAccessRevoked)` calls p.service.RemoveFromCalls(ctx, ev.Tx, ev.UserIDs) only when ev.Reason == userclasses.AccessRevokedBan (other reasons return nil). `app.Events.Listen[*userclasses.DeactivatingEvent](PluginID, p.onUserDeactivating)` calls it with the single user id. Both return nil on a nil plugin, event, user or service, as onUserDeleting does.
Update README.md in the same commit:
- Overview: production registers AuthorizeVideoMembership with chat's Service.IsParticipant, so a host context implementing chat's contracts.LiveMembership ends call access as soon as the user leaves it.
- Features: a "Live membership." bullet (requireParticipant, the authorizer, invitees filtered at start) and a "Bans." bullet (AccessRevokedEvent with reason ban and DeactivatingEvent; rows left in the transaction; LiveKit removal and participant-left after the commit; the residual window up to token_ttl because LiveKit tokens cannot be revoked).
- API reference rows: classes.MembershipCheck, classes.VideoService.SetMembership, classes.VideoService.RemoveFromCalls, classes.AuthorizeVideoMembership, classes.FakeRooms.AddParticipant, classes.FakeRooms.HasParticipant.
- Dependencies: chat for Service.IsParticipant; the user plugin for AccessRevokedEvent and DeactivatingEvent (both in v0.1.13).
- Testing: the three new test names in the `go test -run` line.
Keep the forbidden fixture words out of every production file and the README (see objective). Run gofmt.
Commit in sm-chatvideo-plugin, staging these seven paths explicitly: `fix: honour chat live membership and remove banned users from live calls`. Add a short body naming IN-02. No co-author trailer.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-chatvideo-plugin && test -z "$(gofmt -l .)" && go vet ./... && go test -short ./... && go test -count=1 -run '^(TestVideoLiveMembership|TestVideoMembershipWithoutLiveCheck|TestVideoBanRemovesFromCall|TestHTTPTracer|TestUserDeletionReassignsSession|TestReconcileEmptyRoom|TestGoldenEvents|TestFixtureAbsentFromProduction)$' .</automated>
</verify>
<acceptance_criteria>
- The three new tests run (not skipped) and pass. They failed before the implementation for the membership, ban and deactivation assertions.
- `gofmt -l .` prints nothing. `go vet ./...` and `go test -short ./...` are green. The full `go test -count=1 ./...` (Docker; if the socket is denied, run it through `sudo -u golem` as CLAUDE.md says) is green.
- `git -C ../sm-chatvideo-plugin diff HEAD~1 --stat` lists exactly the seven task files. go.mod and go.sum are unchanged.
- `git -C ../sm-chatvideo-plugin log -1 --format=%B | grep -ci 'co-authored'` prints 0.
</acceptance_criteria>
<done>chatvideo consults chat's live membership for every participant check, the authorizer and the start invitees. A ban or deactivation removes the user from live rooms after the commit. Existing behaviour is unchanged for hosts without LiveMembership. One commit in sm-chatvideo-plugin.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: optional CallPolicy host hook with a DM-only policy, installed by the demo</name>
<files>../sm-chatvideo-plugin/contracts/call_policy.go, ../sm-chatvideo-plugin/contracts/call_policy_test.go, ../sm-chatvideo-plugin/classes/video_service.go, ../sm-chatvideo-plugin/api/errors.go, ../sm-chatvideo-plugin/lang/en/lang.yaml, ../sm-chatvideo-plugin/lang/pl/lang.yaml, ../sm-chatvideo-plugin/call_policy_test.go, ../sm-chatvideo-plugin/README.md, ../sm-chatvideo-plugin/plugin.yaml, ../sm-demo-app/plugins/golem15/chatvideo, ../sm-demo-app/plugins/demo/video.go, ../sm-demo-app/plugins/demo/plugin.go, ../sm-demo-app/video_test.go, ../sm-demo-app/config/golem15.chatvideo.yaml, ../sm-demo-app/README.md</files>
<read_first>
- ../sm-chatvideo-plugin/classes/video_service.go (after Task 1)
- ../sm-chatvideo-plugin/api/errors.go, lang/en/lang.yaml, lang/pl/lang.yaml
- ../sm-chatchannels-plugin/contracts/access_resolver.go (package doc and comment style of a host seam)
- ../sm-chat-plugin/contracts/moderator.go (comment style of an optional hook)
- ../sm-demo-app/plugins/demo/chat.go lines 73-89 (registerForum template) and plugins/demo/plugin.go lines 80-100
- ../sm-demo-app/video_test.go lines 88-200
</read_first>
<behavior>
- contracts (-short): DMOnly answers true for "dm" and false for "group" and "". CallPolicyFunc passes userID, conversationID and conversationType through unchanged.
- No policy installed: a group-conversation start answers 201 (unchanged).
- SetCallPolicy(contracts.DMOnly): a group start answers 403 with error "Video calls are not available in this conversation". The conversation has zero sessions and zero chatvideo participant rows, and env.rooms.CreatedNames() did not grow. A DM start (conversation Type "dm") answers 201.
- A recording CallPolicyFunc sees the actor id, the conversation id and "group".
- A policy that returns an error: start answers 500 and creates no session.
- A non-participant's start answers 404 and the recording policy is not called.
- A group session started before the policy was installed: with DMOnly installed, start answers 403 and another member's POST join answers 200.
- SetCallPolicy(nil): a group start answers 201 again.
- Demo: with the demo's boot, a start in #general answers 403 both with 5 participants and after trimming the channel to 2. No session exists for #general and CreateRoom was called once (for the DM only). DM starts and the stranger 404 are unchanged.
</behavior>
<action>
**Part A, sm-chatvideo-plugin** (/media/nvme/dev/golem15/summercms.io/summercms/sm-chatvideo-plugin; check `git status --short` first, as in Task 1).
RED: write contracts/call_policy_test.go (package contracts, -short) and call_policy_test.go (package chatvideo_test, Docker; ensurePostgres first; bootWeb) with the plugin cases in `<behavior>`. Create the DM conversation in the test with gdb.Create of a chatmodels.Conversation with Type chatmodels.TypeDM plus two chatmodels.Participant rows, the same way the harness's conversation helper does it. Install policies through env.svc.SetCallPolicy. Run them and record the failure: compile errors are expected until the identifiers exist, so add the contracts types first and then see the 403 assertions fail.
GREEN:
- Create contracts/call_policy.go: package contracts, with a package doc comment saying it holds golem15.chatvideo's host seams. Declare:
- The interface `CallPolicy` with the single method `CanStartCall(ctx context.Context, userID, conversationID uint, conversationType string) (bool, error)`. Its doc comment: consulted when a conversation participant starts a call, after the participant check; false refuses the start; never consulted for joining a running call.
- `CallPolicyFunc`, a func type with that signature implementing CallPolicy.
- The exported var `DMOnly CallPolicy`, a CallPolicyFunc answering conversationType == chatmodels.TypeDM (import the chat models package).
- classes/video_service.go:
- Add `ErrCallNotAllowed` (text "video calls are not allowed in this conversation"), `CallPolicy contracts.CallPolicy` in VideoOptions, a `policy` field copied by NewVideoService, and `SetCallPolicy(p contracts.CallPolicy)`, nil-receiver safe, where nil allows every start (today's behaviour).
- Change requireParticipant to return the conversation it loaded, `(*chatmodels.Conversation, error)`. Other callers discard it.
- In Start, right after requireParticipant and before activeSession, when a policy is set: call CanStartCall(ctx, actorID, conversationID, conv.Type). An error is returned as is; false returns ErrCallNotAllowed. Join, Leave, End, Status and CallLink do not consult the policy.
- api/errors.go: add the constant errCallNotAllowed = "Video calls are not available in this conversation" and a writeVideoError case mapping classes.ErrCallNotAllowed to 403. A 422 would show the frontend's "too many people" text.
- lang en and pl: add errors.not_allowed with "Video calls are not available in this conversation." and "Połączenia wideo nie są dostępne w tej rozmowie."
- README.md:
- Features: a "Call policy." bullet (optional, default allows all, DMOnly, 403 answer, join unaffected).
- Usage: a short paragraph saying a host installs a policy from a lagoon.OnDatabase hook by looking up *classes.VideoService and calling SetCallPolicy(contracts.DMOnly). Keep it in prose plus the existing code-block style.
- API reference rows: contracts.CallPolicy, contracts.CallPolicyFunc, contracts.DMOnly, classes.VideoService.SetCallPolicy, classes.ErrCallNotAllowed.
- Overview or Features: the start route's possible 403.
- Testing: add TestVideoCallPolicy to the run line.
- Run `go test -short -run TestPluginManifest .`. Only if it reports a mismatch, regenerate plugin.yaml with `go run git.golem15.com/golem15/summercms/cmd/summer plugin:manifest` (or the installed `summer plugin:manifest`) and include it. Otherwise leave plugin.yaml untouched.
Commit in sm-chatvideo-plugin, staging the changed plugin paths explicitly: `feat: optional CallPolicy host hook and a DM-only policy`. Add a body naming IN-01. No co-author trailer.
**Part B, sm-demo-app** (/media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app; check `git status --short` first; other batch items also commit here, so stage only these paths).
- Move the submodule to the new plugin HEAD with `git -C plugins/golem15/chatvideo fetch /media/nvme/dev/golem15/summercms.io/summercms/sm-chatvideo-plugin main`, then `git -C plugins/golem15/chatvideo checkout --detach FETCH_HEAD`. Confirm `git -C plugins/golem15/chatvideo rev-parse HEAD` equals `git -C ../sm-chatvideo-plugin rev-parse HEAD`.
- Create plugins/demo/video.go with `(p *Plugin) registerVideo(app *backpack.App) error`, modelled on registerForum:
- It returns nil without golem15.chatvideo.
- Inside lagoon.OnDatabase it looks up *chatvideoclasses.VideoService (import "git.golem15.com/golem15/sm-chatvideo-plugin/classes" and ".../contracts").
- A missing service returns the error "golem15.demo: golem15.chatvideo must be listed before golem15.demo in summer.yaml".
- Otherwise it calls SetCallPolicy(chatvideocontracts.DMOnly).
- Its doc comment cites D-23 (calls in DMs only), now enforced on the server.
- plugins/demo/plugin.go: append p.registerVideo to the Boot step list and mention the video call policy in Boot's doc comment.
- video_test.go: rewrite TestDemoVideoStart's doc comment ("the server refuses a call outside a DM through chatvideo's DM-only CallPolicy") and its channel section:
- The #general start answers 403 with the error text above, while participants is still 5.
- After the existing trim to 2 participants it still answers 403. Replace the old 201 assertion and its "no type rule" message.
- #general has 0 sessions, and fake.called("CreateRoom") has length 1.
- The DM part and the stranger 404 stay as they are.
- config/golem15.chatvideo.yaml: replace the last comment sentence with one saying golem15.demo installs chatvideo's DM-only CallPolicy, so the server refuses calls outside DMs (403) whatever max_participants allows. Leave the values unchanged.
- README.md "**Calls.**" paragraph: the same correction.
Run the demo gates in `<verify>`. Then commit, staging exactly plugins/golem15/chatvideo, plugins/demo/video.go, plugins/demo/plugin.go, video_test.go, config/golem15.chatvideo.yaml and README.md: `feat: video calls in DMs only, enforced on the server by chatvideo's CallPolicy`. The body names IN-01 and states that the chatvideo pointer is ahead of the go.mod pin (v0.1.1) until the plugin is tagged. No co-author trailer, no go.mod change.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-chatvideo-plugin && go vet ./... && go test -short ./... && go test -count=1 -run '^(TestVideoCallPolicy|TestVideoLiveMembership|TestHTTPTracer)$' . && cd ../sm-demo-app && go vet ./... && go test -short ./... && go test -count=1 -run '^(TestDemoVideoStart|TestBootAllPlugins|TestDemoDMs|TestDemoChatContextMembership|TestRealtimeMemberToken|TestGoModRequiresTags)$' .</automated>
</verify>
<acceptance_criteria>
- TestVideoCallPolicy and the contracts unit test pass. They failed on the 403 assertions before the Start change.
- The full `go test -count=1 ./...` in sm-chatvideo-plugin is green (Docker; `sudo -u golem` if the socket is denied).
- The demo's targeted Docker tests above pass, with TestDemoVideoStart asserting 403 for both channel starts. `go test ./...` in sm-demo-app is green (Docker); if a test unrelated to video fails, check against the base commit and report it rather than editing it.
- `git -C ../sm-demo-app show --stat HEAD` lists exactly the six demo paths. `git -C ../sm-demo-app diff HEAD~1 -- go.mod go.sum` is empty.
- Neither of the two new commits has a co-author trailer.
</acceptance_criteria>
<done>Hosts can install contracts.CallPolicy, and DMOnly ships with the plugin. The demo enforces calls in DMs only on the server, and its test proves it no longer depends on max_participants or seed data. Two commits: plugin, then demo (pointer bump and wiring).</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: useIncomingCalls closes stale session handles</name>
<files>../vue-chatvideo-layer/app/composables/useIncomingCalls.ts, ../vue-chatvideo-layer/vitest.config.ts, ../vue-chatvideo-layer/tests/useIncomingCalls.test.ts, ../vue-chatvideo-layer/README.md</files>
<read_first>
- ../vue-chatvideo-layer/app/composables/useIncomingCalls.ts
- ../vue-chatvideo-layer/tests/useCall.test.ts lines 1-140 (mock and stub style)
- ../vue-chatvideo-layer/app/lib/videoClient.ts lines 195-220 (parseIncomingCall)
- ../vue-chat-layer/vitest.config.ts (the sibling layer's config shape)
- ../vue-chatvideo-layer/README.md "Composables" and "Testing" sections
</read_first>
<behavior>
- Two video:call:incoming events for session 5 while both subscribes are pending. When the first resolves before the second, the first handle is closed and the second is kept (not closed). When the second resolves first, the first, resolving later, is closed and the second is kept.
- Rings for session 5 and then session 6 while both subscribes are pending: the session 5 handle is closed whatever the order; the session 6 handle is kept and pending is session 6.
- A subscribe still pending when dismiss() runs: its handle is closed when it resolves, and pending stays null.
- The kept handle is closed by dismiss(), by accept() (which returns the conversation id) and by decline() (which calls client.leave with the conversation id).
- A video:call:ended publication on the kept handle clears pending and closes it.
- With fake timers, after RING_MS the prompt clears and the kept handle is closed.
</behavior>
<action>
Work in /media/nvme/dev/golem15/summercms.io/summercms/vue-chatvideo-layer; check `git status --short` first.
RED:
- Add vitest.config.ts using defineConfig from 'vitest/config': `define` maps 'import.meta.client' to 'true'; test.environment is 'node'; test.include is ['tests/**/*.test.ts']. Add a short header comment saying the define lets the client-only registration in useIncomingCalls run under vitest. Confirm the existing 127 tests still pass with it.
- Write tests/useIncomingCalls.test.ts with the cases in `<behavior>`:
- vi.mock 'vue-chat-layer/app/lib/realtime'. onUserEvent records the handler and returns a no-op unsubscribe. subscribe returns a promise the test resolves by hand and records the channel, the hooks and a handle with vi.fn close and resubscribe.
- In beforeEach, stub the globals: useState backed by a Map of vue refs keyed by the state key, and useNuxtApp returning one fresh object per test that carries _chatvideoClient with a vi.fn leave. A fresh object per test means each test registers its own handler.
- Import the composable dynamically after the mocks, as useCall.test.ts does. Deliver rings by calling the recorded handler with {event: 'video:call:incoming', payload: {session_id, conversation_id, caller_name}}. Flush microtasks after resolving a subscribe.
- vi.unstubAllGlobals and vi.useRealTimers in afterEach.
- Run `pnpm test` and record that the same-session cases fail on the current code (a stale handle is never closed).
GREEN, in app/composables/useIncomingCalls.ts:
- Add `seq: number` to IncomingRuntime, initialised to 0 in the nuxtApp default object.
- clear() increments rt.seq, alongside its current work, so a subscribe in flight at that moment is invalidated.
- watchSession takes `const seq = ++rt.seq` after closing the current channel. After the await it keeps the handle only when seq still equals rt.seq and pending.value?.sessionId === call.sessionId; otherwise it calls handle.close() immediately.
- The onUserEvent handler keeps its order (clear, set pending, start the timer, watchSession). The ring timer and the ended-publication check stay as they are.
- Update the file's header comment: at most one session handle is open, and a handle that resolves after a newer ring or a dismiss is closed.
README.md:
- The useIncomingCalls() bullet: one clause saying a newer ring or a dismiss closes a session subscription that is still opening.
- Testing: add a `tests/useIncomingCalls.test.ts` bullet listing what it covers, and mention vitest.config.ts (the import.meta.client define) and the updated `pnpm test` comment.
Do not bump package.json.
Commit in vue-chatvideo-layer, staging the four paths explicitly: `fix: the incoming-call runtime closes stale session handles`. Add a body naming IN-07. No co-author trailer.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/vue-chatvideo-layer && pnpm test</automated>
</verify>
<acceptance_criteria>
- `pnpm test` is green: the 127 previous tests plus the new file. The new same-session tests failed on the unchanged composable.
- `git -C ../vue-chatvideo-layer show --stat HEAD` lists exactly the four paths. package.json is unchanged.
- The commit has no co-author trailer.
</acceptance_criteria>
<done>The incoming-call runtime keeps only the latest ring's session handle and closes every stale one. Covered by a dedicated vitest file. One commit in vue-chatvideo-layer.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser → chatvideo JSON API | A JWT-authenticated member asks to start, join, leave or end calls and reads status and call links. The actor id comes from the token |
| browser → lighthouse subscribe (video:session:{id}) | Subscription authorization runs the plugin's "video" authorizer |
| LiveKit join token holder → LiveKit SFU | A minted HS256 token (token_ttl 900 s) admits its holder to the room until it expires. The server can only remove a connected identity |
| host application → chatvideo | Host contexts (chat's contracts.Context, LiveMembership) and the new CallPolicy decide who takes part and where calls may start |
| user plugin admin actions → chatvideo | Ban and deactivation events are fired inside the admin transaction |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-q261105oc7-01 | Elevation of privilege | requireParticipant and the video authorizer accepted a stale participant row after the user left the host context | medium | mitigate | Task 1: both go through chat's Service.IsParticipant (LiveMembership). TestVideoLiveMembership covers join, status and authorizer denial and re-admission |
| T-q261105oc7-02 | Elevation of privilege | A banned or deactivated user keeps a live LiveKit connection | medium | mitigate | Task 1: RemoveFromCalls on AccessRevokedEvent (ban) and DeactivatingEvent removes the identity after the commit and marks rows left. TestVideoBanRemovesFromCall, including the rollback and password-reason cases |
| T-q261105oc7-03 | Elevation of privilege | A removed user reconnects with the still-valid LiveKit token | medium | accept | LiveKit has no token revocation. The window is bounded by golem15.chatvideo.token_ttl (900 s), which hosts can lower. The JWT is revoked, so no new token can be minted. Documented in the README Bans bullet and the SUMMARY follow-ups |
| T-q261105oc7-04 | Information disclosure | An open video:session subscription survives a ban until its token refresh | low | accept | lighthouse has no server-side unsubscribe (a framework follow-up). The refresh needs the revoked JWT and re-runs the live-aware authorizer. The channel carries call events and user ids only |
| T-q261105oc7-05 | Elevation of privilege | A member rings everyone in a channel once max_participants allows it (IN-01) | medium | mitigate | Task 2: contracts.CallPolicy with DMOnly; the demo installs it. TestVideoCallPolicy and TestDemoVideoStart assert 403 for both channel cases and no session or room |
| T-q261105oc7-06 | Elevation of privilege | A fail-open path when the chat service or the policy errors | medium | mitigate | chatMembership returns false with an error when chat's service is missing: the authorizer denies and the API answers 500, never a participant-row fallback. A policy error aborts Start with nothing created (asserted) |
| T-q261105oc7-07 | Spoofing | Ringing (video:call:incoming on user:{id}) of users who are no longer members | low | mitigate | Task 1: Start filters invitees through the membership check before invite and notify.Incoming; asserted by the missing participant row |
| T-q261105oc7-08 | Denial of service | Per-invitee membership queries at start | low | accept | The ErrTooMany check runs first on the raw list, so at most max_participants (default 8) checks run, and only when a membership check is installed |
| T-q261105oc7-09 | Denial of service | Leaked realtime subscriptions in the browser (IN-07) | low | mitigate | Task 3: a sequence number per watchSession; stale handles are closed; tests/useIncomingCalls.test.ts |
| T-q261105oc7-SC | Tampering | npm/pip/cargo installs | low | accept | No install and no new dependency in any repo. vitest/config comes from the existing vitest 3.2.7 devDependency; the Go imports are already required modules (chat v0.1.3, user v0.1.13) |
</threat_model>
<verification>
- sm-chatvideo-plugin: gofmt clean; `go vet ./...`, `go test -short ./...` and the full `go test -count=1 ./...` (Docker) green after each of its two commits.
- sm-demo-app: `go vet ./...`, `go test -short ./...` and `go test -count=1 ./...` (Docker) green, TestDemoVideoStart asserting 403 for channel starts. The submodule pointer equals the plugin HEAD. go.mod and go.sum are unchanged.
- vue-chatvideo-layer: `pnpm test` green with the new file.
- Four commits total (two plugin, one demo, one layer), each staging only its listed paths, none with a co-author trailer. Nothing pushed or tagged; no summercms.go code changed.
</verification>
<success_criteria>
- A ban ends call access at once: no join, status, call link or subscribe for a member who left a live-membership context; banned or deactivated users are removed from live LiveKit rooms after the commit.
- Hosts can restrict where calls start through an optional CallPolicy (default unchanged). The demo enforces calls in DMs only on the server, independent of seed data.
- The incoming-call runtime never leaks a session handle.
- The SUMMARY lists the release steps (tag and push sm-chatvideo-plugin before the demo, require the tag in the demo's go.mod so check-pins passes, tag vue-chatvideo-layer and bump vue-demo-app) and the Not-in-scope follow-ups.
</success_criteria>
<output>
Create `.planning/quick/261105-oc7-chatvideo-hardening/261105-oc7-SUMMARY.md` when done. Include the four commit hashes, the release state of the demo pointer against its go.mod pin, and the follow-ups from the objective's "Not in scope" list so the orchestrator can file them.
</output>

View File

@@ -0,0 +1,149 @@
---
phase: quick-261105-oc7
plan: 01
subsystem: chatvideo (sm-chatvideo-plugin, sm-demo-app, vue-chatvideo-layer)
tags: [chatvideo, security, live-membership, bans, call-policy, realtime]
status: complete
requirements:
- todo:chatvideo-live-membership
- todo:chatvideo-dm-only-host-hook
- todo:chatvideo-incoming-handle-leak
requires:
- golem15.chat Service.IsParticipant + contracts.LiveMembership (v0.1.3)
- golem15.user AccessRevokedEvent / DeactivatingEvent (v0.1.13)
provides:
- classes.MembershipCheck, VideoOptions.Membership, VideoService.SetMembership
- classes.AuthorizeVideoMembership
- classes.VideoService.RemoveFromCalls (ban / deactivation)
- contracts.CallPolicy, contracts.CallPolicyFunc, contracts.DMOnly, VideoService.SetCallPolicy, classes.ErrCallNotAllowed (403)
- demo registerVideo installing DMOnly
- useIncomingCalls sequence-numbered session handles
affects: [sm-demo-app go.mod pin of sm-chatvideo-plugin, vue-demo-app layer pin]
tech-stack:
added: []
patterns: [lagoon.AfterCommit for LiveKit side effects, optional host hook with nil default, fail-closed membership lookup]
key-files:
created:
- ../sm-chatvideo-plugin/classes/revoked_access.go
- ../sm-chatvideo-plugin/live_membership_test.go
- ../sm-chatvideo-plugin/contracts/call_policy.go
- ../sm-chatvideo-plugin/contracts/call_policy_test.go
- ../sm-chatvideo-plugin/call_policy_test.go
- ../sm-demo-app/plugins/demo/video.go
- ../vue-chatvideo-layer/vitest.config.ts
- ../vue-chatvideo-layer/tests/useIncomingCalls.test.ts
modified:
- ../sm-chatvideo-plugin/classes/video_service.go
- ../sm-chatvideo-plugin/classes/authorizer.go
- ../sm-chatvideo-plugin/classes/livekit.go
- ../sm-chatvideo-plugin/boot.go
- ../sm-chatvideo-plugin/api/errors.go
- ../sm-chatvideo-plugin/lang/en/lang.yaml
- ../sm-chatvideo-plugin/lang/pl/lang.yaml
- ../sm-chatvideo-plugin/README.md
- ../sm-demo-app/plugins/golem15/chatvideo (submodule pointer)
- ../sm-demo-app/plugins/demo/plugin.go
- ../sm-demo-app/video_test.go
- ../sm-demo-app/config/golem15.chatvideo.yaml
- ../sm-demo-app/README.md
- ../vue-chatvideo-layer/app/composables/useIncomingCalls.ts
- ../vue-chatvideo-layer/README.md
decisions:
- "chatvideo's membership check is golem15.chat's Service.IsParticipant, looked up per call; a missing chat service fails closed (false + error), never a participant-row fallback"
- "The CallPolicy runs right after the participant check and before the active-session lookup and max_participants, so a refused start creates nothing and a running group call cannot be re-started under DMOnly (joining it still works)"
- "A policy refusal is 403 so vue-chatvideo-layer's existing failureReason(403) = 'forbidden' reads correctly; 422 would show 'too many people'"
metrics:
duration: ~50 min
completed: 2026-11-05
actuals:
tasks: 3
commits: 4
---
# Quick 261105-oc7: chatvideo hardening Summary
chatvideo now checks every participant, the `video:session` authorizer and start invitees through chat's live-membership-aware `Service.IsParticipant`. Bans and deactivations drop the user from live LiveKit rooms after the commit. An optional `contracts.CallPolicy` with a ready-made `DMOnly` lets the demo enforce DM-only calls on the server. `useIncomingCalls` closes every stale session handle.
## Commits
| # | Repo | Hash | Message |
|---|------|------|---------|
| 1 | sm-chatvideo-plugin (`main`) | `f996156` | fix: honour chat live membership and remove banned users from live calls |
| 2 | sm-chatvideo-plugin (`main`) | `11a9607` | feat: optional CallPolicy host hook and a DM-only policy |
| 3 | sm-demo-app (`master`) | `fbfdcf6` | feat: video calls in DMs only, enforced on the server by chatvideo's CallPolicy |
| 4 | vue-chatvideo-layer (`main`) | `6d5784d` | fix: the incoming-call runtime closes stale session handles |
None has a co-author trailer. Nothing was pushed or tagged. No go.mod or go.sum changed in any repo, and no summercms.go code changed. Each commit staged only its listed paths. plugin.yaml was left alone because `TestPluginManifest` passed without regenerating it.
## What was built
**Task 1 (IN-02), live membership and bans.**
- `classes.MembershipCheck`, `VideoOptions.Membership` and `VideoService.SetMembership` were added.
- `requireParticipant` uses the membership check when one is set. With none set, the participant-row count is unchanged.
- `Start` keeps the raw `ErrTooMany` check. It then drops the participants who fail the check from the invite list and from `notify.Incoming`. The actor is always kept.
- `AuthorizeVideoMembership(getDB, member)` was added, and `AuthorizeVideoLookup` now delegates to it with a nil member.
- Boot installs `chatMembership(app)` in the service and the authorizer. It looks up `*chatclasses.Service` on each call and fails closed.
- Boot also listens for `AccessRevokedEvent`, but only acts on the reason `ban`, and for `DeactivatingEvent`. Both call `RemoveFromCalls`, which:
- marks the user's joined rows of live sessions left, with `left_at`, inside the transaction
- after the commit, removes the user's LiveKit identity from rooms where they were joined or had left, and publishes participant-left for the rows that were joined
- never ends a session or deletes a row
- `FakeRooms.AddParticipant` and `FakeRooms.HasParticipant` were added for tests.
**Task 2 (IN-01), CallPolicy.**
- New `contracts` package with `CallPolicy`, `CallPolicyFunc` and `DMOnly`.
- `VideoOptions.CallPolicy`, `VideoService.SetCallPolicy` and `ErrCallNotAllowed` were added. A refusal maps to 403 `"Video calls are not available in this conversation"`, with lang `errors.not_allowed` in en and pl.
- `requireParticipant` now also returns the conversation it loaded. It is unexported.
- The demo's `registerVideo` installs `DMOnly` from a `lagoon.OnDatabase` hook (D-23).
- `TestDemoVideoStart` now expects 403 for #general with 5 participants and again after trimming to 2. It also checks there are 0 sessions for #general and exactly 1 CreateRoom call (the DM's).
- The config comment and the README "Calls." paragraph now say the server enforces the rule.
**Task 3 (IN-07), stale handles.**
- `IncomingRuntime.seq` was added. `clear()` increments it, and `watchSession` takes `const seq = ++rt.seq`. When the subscribe resolves, the handle is kept only if `seq` still matches and the session is still the pending one. Otherwise it is closed.
- New `vitest.config.ts` (defines `import.meta.client`) and `tests/useIncomingCalls.test.ts` (10 tests).
## TDD evidence
- **Task 1.** On the unchanged service, `TestVideoLiveMembership` failed on "C (not a member) has 1 participant rows". `TestVideoBanRemovesFromCall` failed on "banned B still in the room after the commit" and "deactivated A still in the room after the commit". The first ban RED run was not valid, because the helper passed the event as `any` and the generic `festival.Fire[T]` matched no listener. After fixing the helper, I confirmed the ban and deactivation failures again with the new listeners commented out.
- **Task 2.** With the contracts types and SetCallPolicy in place but not used by `Start`, `TestVideoCallPolicy` failed four subtests. Group start under DMOnly gave 201. The recording policy was never called. A policy error gave 201. A start of a running call gave 200. `TestDemoVideoStart` with `registerVideo` taken out failed with 422, then 201, 1 session and 2 CreateRoom calls.
- **Task 3.** On the unchanged composable, both same-session tests failed with "expected spy to be called 1 times, but got 0 times". The other 8 already passed and now guard against regressions.
## Verification
- **sm-chatvideo-plugin:** `go vet ./...`, `GOWORK=off go vet ./...` (against the pinned chat v0.1.3 and user v0.1.13), `go test -short ./...` and the full `go test -count=1 ./...` (Docker) all pass after each commit. `TestFixtureAbsentFromProduction` and `TestPluginManifest` pass.
- **sm-demo-app:** `go vet ./...`, `go test -short ./...`, the targeted Docker tests from the plan and the full `go test -count=1 ./...` all pass. The submodule pointer equals the plugin HEAD (`11a9607`).
- **vue-chatvideo-layer:** `pnpm test` passes 137 tests (the 127 existing plus 10 new). package.json is unchanged.
## Deviations from Plan
1. **[Rule 1 - test bug] Event firing in the ban test.** `festival.Bus.Fire` is generic (`Fire[T]`), so firing through an `any`-typed helper reached no listener. `fireInTx` now takes a fire closure that calls `env.app.Events.Fire` with the concrete event type. The fix is in the test only.
2. **Skipping under -short.** The plan says to call `ensurePostgres(t)` "so it skips under -short". In this harness `ensurePostgres` starts a container even under `-short`, so the new Docker tests call `t.Skip` under `testing.Short()` first and then `ensurePostgres`.
3. **gofmt gate.** `gofmt -l .` in sm-chatvideo-plugin already listed `harness_test.go` and `http_tracer_test.go` before this plan (import order and map alignment). Those files are outside this task and were not changed, so the plan's `test -z "$(gofmt -l .)"` gate fails on the baseline. Every file this plan touched is gofmt-clean.
## Release state (for the orchestrator / user)
- sm-demo-app `plugins/golem15/chatvideo` is at `11a9607`, ahead of its go.mod pin (`sm-chatvideo-plugin v0.1.1` = `0f08c35`). `scripts/check-pins.sh` reports exactly this. It also reports the earlier chatchannels pointer from a sibling item.
- Dev builds and tests compile through go.work. The GOWORK=off release build (`scripts/build.sh`) does not compile until these steps are done in order:
1. Tag sm-chatvideo-plugin (the next v0.1.x, at `11a9607` or later on `main`).
2. Push that tag before the demo.
3. Commit `chore: require sm-chatvideo-plugin vX` in sm-demo-app (go.mod and go.sum).
- vue-chatvideo-layer also needs a tag at `6d5784d` or later, followed by a vue-demo-app layer bump.
- I did none of these.
## Follow-ups (not in scope)
- **Open subscriptions after a ban.** A ban does not end an already open `video:session` Centrifugo subscription, because lighthouse has no server-side unsubscribe (that is a framework change). Refreshing the subscription needs the revoked JWT and re-runs the now live-aware authorizer, so access lasts until `realtime.centrifugo.token_ttl` (3600 s by default).
- **Join tokens after a ban.** An already minted LiveKit join token cannot be revoked, so a removed user could reconnect until `golem15.chatvideo.token_ttl` (900 s) runs out. Hosts may lower it. This is documented in the README "Bans." bullet.
- **Leaving a host context without a ban.** The user is not removed from live rooms, because there is no generic event for it. The live checks still refuse every new join, status read, call link and subscribe.
- **Banned last participant.** A call whose last joined participant was banned is not ended at once. The reconciler ends empty rooms within 5 minutes.
- The logout half of IN-07 was already fixed in phase 20 plan 08.
- **Pre-existing gofmt drift.** `sm-chatvideo-plugin/harness_test.go` and `http_tracer_test.go` need gofmt.
- **Orchestrator tasks.** Tags, pushes, the go.mod bump, the vue-demo-app layer bump, moving the three todos to completed and the g15office comment.
## Threat Flags
None. The only new surface is the ban/deactivation listeners and the 403 path, both in the plan's threat model (T-q261105oc7-02, -05, -06).
## Self-Check: PASSED
- Files: every created file listed above exists on disk.
- Commits: `f996156` and `11a9607` (sm-chatvideo-plugin), `fbfdcf6` (sm-demo-app) and `6d5784d` (vue-chatvideo-layer) are in their repos' logs.

View File

@@ -0,0 +1,294 @@
---
phase: quick-261105-oc8
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- modules/lagoon/attach/static.go
- modules/lagoon/attach/static_test.go
- modules/lagoon/attach/lifecycle_test.go
- modules/lagoon/README.md
- docs/services/storage.md
- docs/database/attachments.md
- modules/surf/uploads.go
autonomous: true
requirements:
- todo:2026-10-28-attach-static-handler-disposition
must_haves:
truths:
- "attach.StaticHandler and attach.StaticHandlerPublic send X-Content-Type-Options: nosniff on every response, GET and HEAD, 200 and 404 alike"
- "A served blob whose stored Content-Disposition is non-empty and parses with mime.ParseMediaType gets that value verbatim (trimmed) as the response's Content-Disposition, on GET and on HEAD. A blob attach.Store wrote for text/html (stored disposition attachment) is therefore served with Content-Disposition: attachment by both handlers"
- "A stored disposition that mime.ParseMediaType rejects (for example a value carrying CR/LF or a broken parameter) is sent as the bare value attachment: the handler fails closed and never forwards malformed metadata"
- "Additive: a blob with no stored disposition (raster images, thumbnails, blobs written before quick-261028-rqe or outside attach.Store) is served exactly as before, with its stored Content-Type and no Content-Disposition. The handlers do not derive a disposition from the type and add no Content-Security-Policy; the surf uploads route keeps doing both"
- "Ordering and 404s are unchanged: the is_public gate still runs before any bucket access, a missing blob, an invalid path and a denied row still answer 404, and a 404 never carries a Content-Disposition"
- "Non-breaking: no exported identifier is added, removed or changes its signature in modules/lagoon or modules/surf. The existing static, lifecycle and surf uploads tests (TestStaticHandler, TestStaticHandlerServesThumbURL, TestStaticHandlerPublicGate, TestUploadsMount, TestUploadsResponseHeaders) pass without edits to their existing assertions"
- "The lagoon README, docs/services/storage.md and docs/database/attachments.md describe the new headers and what the handlers still do not do; the surf uploads.go doc comment no longer says StaticHandlerPublic sends only the stored type. go vet ./..., go test -short ./..., go test ./cmd/summer -run TestDocsTree and go run ./cmd/summer docs:build --check are green at the commit, and the Docker-backed TestStaticHandlerPublicLooksUpIsPublic passes"
artifacts:
- path: modules/lagoon/attach/static.go
provides: "servePublicBlobs reads the blob's Attributes after the gate, sets nosniff, the stored Content-Type and the validated stored Content-Disposition; unexported storedDisposition helper; updated StaticHandler and StaticHandlerPublic doc comments"
contains: "X-Content-Type-Options"
- path: modules/lagoon/attach/static_test.go
provides: "TestStoredDisposition (table) and TestStaticHandlerSendsStoredDisposition (memblob and fileblob sidecar, writeBlob round trip, GET and HEAD, ungated and gated), Docker-free"
contains: "TestStaticHandlerSendsStoredDisposition"
- path: modules/lagoon/attach/lifecycle_test.go
provides: "TestStaticHandlerPublicLooksUpIsPublic also asserts the headers through the real is_public gate on Postgres"
contains: "Content-Disposition"
- path: modules/lagoon/README.md
provides: "Features bullet and the attach.StaticHandler table row name the stored disposition and nosniff"
- path: docs/services/storage.md
provides: "Serving files (mounting the handler yourself) and the deploy note describe what the handlers send and what they do not"
- path: docs/database/attachments.md
provides: "The StaticHandler paragraph and the Store disposition paragraph name the handlers"
- path: modules/surf/uploads.go
provides: "mountUploads doc comment corrected; no code change"
key_links:
- from: modules/lagoon/attach/static.go servePublicBlobs
to: gocloud.dev/blob (*Bucket).Attributes
via: "bucket.Attributes(r.Context(), key) after the allow gate and before NewReader; its ContentDisposition goes through storedDisposition"
pattern: "bucket\\.Attributes\\("
- from: modules/lagoon/attach/static.go storedDisposition
to: modules/lagoon/attach/inline.go attachmentDisposition
via: "the fail-closed value for an unparsable stored disposition is the existing attachmentDisposition constant, the same bare value attach.Store stores"
pattern: "attachmentDisposition"
- from: modules/lagoon/attach/store.go writeBlob
to: modules/lagoon/attach/static.go servePublicBlobs
via: "TestStaticHandlerSendsStoredDisposition writes through writeBlob (Store's real writer) and reads the headers back through StaticHandler"
pattern: "writeBlob\\("
---
<objective>
Close the todo `2026-10-28-attach-static-handler-disposition` in the framework (summercms.go only). Quick 261028-rqe (ba8432c, 4957e5c) made `attach.Store` store `Content-Disposition: attachment` with every blob whose type `attach.IsInlineContentType` rejects, and the surf uploads route sends a disposition. `attach.StaticHandler` and `attach.StaticHandlerPublic` still send only the stored Content-Type, so an application that mounts either handler directly serves a stored HTML body inline. The fix, from the todo's Solution: both handlers send the stored disposition and `X-Content-Type-Options: nosniff`, additively.
Live reads at planning time:
- `modules/lagoon/attach/static.go` `servePublicBlobs` (lines 38-80): method check, nil bucket, prefix strip, `parsePublicBlobPath`, the optional `allow` gate, then `bucket.NewReader(r.Context(), key, nil)`. It sets Content-Type from `reader.ContentType()` (falling back to `defaultStaticContentType`), returns for HEAD, else `io.Copy`. Both exported handlers are thin wrappers over it.
- gocloud.dev v0.46.0: `*blob.Reader` exposes only `ContentType`, `ModTime`, `Size`, `As`; it does not expose the disposition. `(*blob.Bucket).Attributes(ctx, key)` returns `*blob.Attributes` with `ContentDisposition` and `ContentType`. memblob keeps the disposition, fileblob keeps it in its `.attrs` sidecar (the default behind `file://`), s3blob reads it with a HEAD. Reading it costs one `Attributes` call per request; the rqe plan named that cost and deferred it here.
- `modules/lagoon/attach/inline.go` already has the unexported constant `attachmentDisposition` (the bare value attachment) and the exported `IsInlineContentType`. inline.go is not edited.
- `net/http.Error` (Go 1.27) sets `X-Content-Type-Options: nosniff` and does not touch Content-Disposition, so every `http.NotFound` and 405 from the handler already carries nosniff; a disposition set before an error path would leak into the 404.
- `mime.ParseMediaType` accepts `attachment`, `inline`, `attachment; filename="a.png"` and RFC 5987 `filename*=` values; it rejects `attachment;;`, `attachment; filename` and a value containing CR/LF (verified in the scratchpad).
- `modules/surf/uploads.go` wraps `attach.StaticHandlerPublic`. `serveUploads` sets nosniff and the CSP before calling it, and `uploadsDispositionWriter.decide` sets `Content-Disposition: attachment` at WriteHeader/Write time for a non-inline 2xx, i.e. after the handler has set its headers, so the route's output for non-inline types is unchanged and its tests write blobs without a stored disposition. Only its doc comment (lines 36-40, "attach.StaticHandlerPublic sends only the stored type") becomes stale.
- `TestStaticHandlerPublicLooksUpIsPublic` (lifecycle_test.go:190, package attach_test, skips under -short) is the only test of `StaticHandlerPublic` with a real `is_public` lookup.
- No other caller of the handlers exists in the repo (examples/ included). None of the seven files has uncommitted edits from another session at planning time.
Decomposition: two tasks, one commit, run in order.
- Task 1 is the tracer: the header change in servePublicBlobs plus Docker-free tests that write through `writeBlob` (Store's real writer) and read the headers back through both handlers on memblob and fileblob.
- Task 2 adds the Postgres-backed gated assertion, updates the lagoon README, the two docs pages and the surf doc comment, and commits Tasks 1 and 2 together (CLAUDE.md: a change to a module's exported behaviour updates its README and the affected docs pages in the same commit).
Not in scope (the orchestrator records these as follow-ups; do not edit sibling repos or consumer apps):
- Blob writers that bypass `attach.Store` adopting `attach.IsInlineContentType`: fonoteka.go `storeUpload` and `StorePublicFile` (see todo `2026-10-28-fonoteka-uploads-use-disk-extension.md`), the sm-user-plugin avatar writer, the sm-feedback-plugin screenshot writer.
- Adding the disposition to objects already in a bucket: an operator task, already documented in `docs/services/storage.md#deploy-note`.
- A type-derived disposition or a CSP in the handlers. The todo asks for the stored disposition only; deciding from the type would change how existing blobs without a disposition are served (a PDF opened inline would start downloading), which is not additive. The surf uploads route keeps that role.
- Moving the todo to completed and the g15office comment (`knuu7fil`): the orchestrator's closing docs commit handles both.
Purpose: an application that mounts `attach.StaticHandler` or `attach.StaticHandlerPublic` directly gets the same protection a public bucket gives since rqe: an HTML, SVG, PDF or text upload stored by `attach.Store` downloads instead of rendering, and nothing is content-sniffed.
Output: one commit in summercms.go and 261105-oc8-SUMMARY.md.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@./CLAUDE.md
@.planning/todos/pending/2026-10-28-attach-static-handler-disposition.md
<interfaces>
Existing code, verified at planning time.
modules/lagoon/attach/static.go
- const defaultStaticContentType = "application/octet-stream"
- func StaticHandler(bucket *blob.Bucket, prefix string) http.Handler — signature stays.
- func StaticHandlerPublic(bucket *blob.Bucket, prefix string, db *gorm.DB) http.Handler — signature stays.
- func servePublicBlobs(bucket *blob.Bucket, prefix string, allow func(context.Context, string) (bool, error)) http.Handler — signature stays (TestStaticHandlerPublicGate calls it directly).
- Imports today: context, io, net/http, path, strings, gocloud.dev/blob, gorm.io/gorm. The change adds mime.
modules/lagoon/attach/inline.go (not edited)
- const attachmentDisposition = "attachment" (unexported, bare value, no filename parameter).
- func IsInlineContentType(contentType string) bool.
- func uploadWriterOptions(contentType string) *blob.WriterOptions — ContentType plus ContentDisposition attachment for non-inline types.
modules/lagoon/attach/store.go (not edited)
- func writeBlob(ctx context.Context, bucket *blob.Bucket, key string, r io.Reader, contentType string, limit int64) (int64, error) — writes through uploadWriterOptions. inline_test.go's TestWriteBlobDisposition already calls it with limit 0.
- func BlobKey(diskName string) string and func PartitionDirectory(diskName string) string: the partition is the first nine characters in three groups ("abc123xyz.jpg" -> "abc/123/xyz/").
modules/lagoon/attach/static_test.go (package attach): TestStaticHandler, TestStaticHandlerServesThumbURL, TestStaticHandlerPublicGate; imports context, io, net/http, net/http/httptest, testing, gocloud.dev/blob, gocloud.dev/blob/memblob. inline_test.go already imports gocloud.dev/blob/fileblob, so a test import adds no dependency.
modules/lagoon/attach/lifecycle_test.go (package attach_test): TestStaticHandlerPublicLooksUpIsPublic at line 190 creates a public jpeg row abc123xyz.jpg and a private one def456uvw.jpg via gdb.Create, writes their blobs and thumbs with bucket.WriteAll, builds h := attach.StaticHandlerPublic(bucket, "/storage/uploads", gdb) and checks status codes through a local get(path) int helper.
modules/surf/uploads.go: mountUploads doc comment lines 29-45; lines 36-40 read "attach.Store also stores the attachment disposition with the blob, but the route still decides from the type on every response: attach.StaticHandlerPublic sends only the stored type, and blobs written earlier or outside attach.Store carry no disposition." Code is not edited.
gocloud.dev v0.46.0 (go.mod)
- (*blob.Bucket).Attributes(ctx context.Context, key string) (*blob.Attributes, error); Attributes.ContentDisposition string.
- fileblob.OpenBucket(dir string, opts *fileblob.Options); nil opts writes the .attrs sidecar.
</interfaces>
</context>
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: StaticHandler and StaticHandlerPublic send nosniff and the stored Content-Disposition (tracer: Store's writer to the handler's response)</name>
<files>modules/lagoon/attach/static.go, modules/lagoon/attach/static_test.go</files>
<read_first>
- modules/lagoon/attach/static.go (whole file, 129 lines)
- modules/lagoon/attach/static_test.go (whole file, 174 lines)
- modules/lagoon/attach/inline.go (whole file, 44 lines: attachmentDisposition, uploadWriterOptions)
- modules/lagoon/attach/inline_test.go lines 40-120 (how TestWriteBlobDisposition builds memblob and fileblob buckets and calls writeBlob)
</read_first>
<behavior>
- storedDisposition("") and storedDisposition(" ") return "".
- storedDisposition returns its trimmed input for "attachment", "inline", `attachment; filename="a.png"` and " attachment ".
- storedDisposition returns "attachment" for "attachment;;", "attachment; filename" and "attachment\r\nX-Evil: 1".
- On memblob and on fileblob with sidecar metadata: a blob written with writeBlob(ctx, bucket, key, body, "text/html", 0) and fetched with GET through StaticHandler answers 200 with Content-Type text/html, Content-Disposition attachment, X-Content-Type-Options nosniff and the unchanged body.
- The same blob fetched with HEAD answers 200 with the same three headers and an empty body.
- A blob written with writeBlob(..., "image/png", 0) is served with Content-Type image/png, nosniff and no Content-Disposition header at all.
- On memblob, a blob written with bucket.WriteAll and ContentDisposition `attachment; filename="a.png"` (ContentType image/png) is served with that exact disposition; one written with ContentDisposition "attachment;;" (ContentType text/html) is served with Content-Disposition attachment.
- Through servePublicBlobs with an allow func that returns true, the text/html blob gets the same headers as through StaticHandler (the gated path shares the change).
- A missing blob answers 404 with nosniff and no Content-Disposition.
</behavior>
<action>
This task implements the todo's Solution, first bullet: both handlers send the stored disposition and X-Content-Type-Options nosniff, additively. Write the tests from the behavior block first and watch them fail to compile or fail, then implement.
1. In modules/lagoon/attach/static.go, add an unexported helper storedDisposition(raw string) string. It trims surrounding whitespace; an empty result returns "". If mime.ParseMediaType rejects the trimmed value, it returns attachmentDisposition (the existing constant in inline.go: fail closed, so malformed or injected metadata never reaches a response header). Otherwise it returns the trimmed value verbatim, parameters included, so the handler behaves like a public bucket that sends the stored header. Doc comment: the value of the stored Content-Disposition a handler sends; empty for none; attachment for a value mime.ParseMediaType rejects.
2. In servePublicBlobs, keep the method check, nil-bucket check, prefix strip, parsePublicBlobPath and the allow gate exactly where they are; the gate must still run before any bucket access (TestStaticHandlerPublicGate relies on it). Then:
- Right after the method check passes, set X-Content-Type-Options to nosniff on w.Header(), so every response the handler writes carries it (http.Error sets it on the error paths too; setting it up front keeps the success path explicit).
- After the gate and before NewReader, call bucket.Attributes(r.Context(), key). On error answer http.NotFound and return, the same as a NewReader error today.
- Keep the NewReader call, its 404 on error, the deferred Close and the Content-Type logic (reader.ContentType() with the defaultStaticContentType fallback) unchanged.
- Only after both Attributes and NewReader have succeeded, set Content-Disposition to storedDisposition(attrs.ContentDisposition) when that is non-empty, next to the Content-Type header and before the HEAD return, so GET and HEAD both carry it and no 404 ever does (http.Error does not remove a Content-Disposition set earlier).
- Leave stripStaticPrefix and parsePublicBlobPath untouched.
3. Update the doc comments. StaticHandler: after the existing description, say that every response carries X-Content-Type-Options: nosniff, and a served file gets its stored Content-Type and, when the blob has one, its stored Content-Disposition (attach.Store stores attachment for every type IsInlineContentType rejects; a stored value mime.ParseMediaType rejects is sent as attachment). Say that the handler derives no disposition from the type and sends no Content-Security-Policy, so a blob stored without a disposition is served inline with its type, and that reading the disposition costs one Attributes call per request. Keep the existing warning not to mount the ungated handler on the app origin. StaticHandlerPublic: add that it sends the same headers as StaticHandler. Signatures of StaticHandler, StaticHandlerPublic and servePublicBlobs do not change; no exported identifier is added.
4. In modules/lagoon/attach/static_test.go (package attach, Docker-free, must not skip under -short):
- TestStoredDisposition: a table over the storedDisposition cases in the behavior block, failure message naming the input (use %q).
- TestStaticHandlerSendsStoredDisposition: build memblob.OpenBucket(nil) and fileblob.OpenBucket(t.TempDir(), nil), each closed through t.Cleanup, and run the same subtests on both (t.Run per bucket name). Use disk names whose partition matches PartitionDirectory, for example "abc123xyz.html" and "def456uvw.png", keyed with BlobKey, and write them with writeBlob and the types text/html and image/png, limit 0, body such as <html><script>alert(1)</script></html> and a few bytes for the png. Serve through StaticHandler(bucket, "/storage/uploads") with httptest.NewRecorder for GET and HEAD, and through servePublicBlobs(bucket, "/storage/uploads", func that returns true, nil) for GET of the html blob. Assert the headers and bodies from the behavior block. For the png assert that w.Result().Header has no Content-Disposition key at all (Values returns an empty slice), not merely an empty string.
- On the memblob bucket only, write two more blobs with bucket.WriteAll and explicit blob.WriterOptions: an image/png with ContentDisposition `attachment; filename="a.png"` and a text/html with ContentDisposition "attachment;;", each under its own valid partition key, and assert the verbatim and the fail-closed value.
- A GET for a valid-shaped key that was never written (for example "/storage/uploads/mis/sin/g12/missing123.html" — check that its partition matches PartitionDirectory of its file name, or derive the path from BlobKey) answers 404 with nosniff and no Content-Disposition.
- Add the fileblob import; no new module dependency (inline_test.go already imports it).
Do not commit at the end of this task; Task 2 commits code, tests and docs together.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./modules/lagoon/... ./modules/surf/... && go test -short -count=1 -v -run 'TestStoredDisposition|TestStaticHandler' ./modules/lagoon/attach && go test -short -count=1 ./modules/lagoon/... ./modules/surf/...</automated>
</verify>
<acceptance_criteria>
- `grep -c 'func storedDisposition(raw string) string' modules/lagoon/attach/static.go` prints 1.
- `grep -c 'bucket.Attributes(' modules/lagoon/attach/static.go` prints 1.
- `grep -c '"X-Content-Type-Options", "nosniff"' modules/lagoon/attach/static.go` prints 1.
- `git diff --quiet -- modules/lagoon/attach/inline.go modules/lagoon/attach/store.go modules/surf/uploads_test.go` exits 0: the writer side and the surf tests are untouched.
- `git diff -U0 -- modules/lagoon/attach/static.go > "$SCRATCH/oc8-static.diff"` exits 0 ($SCRATCH is the session scratchpad), and `grep -E '^[-+]func ' "$SCRATCH/oc8-static.diff"` prints only the added storedDisposition line.
- The -v run reports PASS (not SKIP) for TestStoredDisposition, TestStaticHandlerSendsStoredDisposition with both bucket subtests, TestStaticHandler, TestStaticHandlerServesThumbURL and TestStaticHandlerPublicGate.
- `go test -short ./modules/surf/...` passes with no surf edit, so TestUploadsMount and TestUploadsResponseHeaders still hold.
</acceptance_criteria>
<done>Both handlers send nosniff on every response and the validated stored disposition on GET and HEAD of a served blob; blobs without one are served as before. Store's writer and the handler are proven end to end on memblob and fileblob under -short. Nothing is committed yet.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Gated Postgres assertion, lagoon README, storage and attachments docs, surf doc comment; commit Tasks 1 and 2</name>
<files>modules/lagoon/attach/lifecycle_test.go, modules/lagoon/README.md, docs/services/storage.md, docs/database/attachments.md, modules/surf/uploads.go</files>
<precondition>The Docker daemon is reachable for testcontainers (docker info succeeds). If the socket is denied although the user is in the docker group, run the Docker-backed test through sudo -u golem, as CLAUDE.md says.</precondition>
<read_first>
- modules/lagoon/attach/lifecycle_test.go lines 1-30 (imports) and 190-245 (TestStaticHandlerPublicLooksUpIsPublic)
- modules/lagoon/README.md line 44 (Features, attach bullet; the clause "static serving with an optional `is_public` gate (`attach.StaticHandlerPublic`)") and lines 207-208 (the two handler rows)
- docs/services/storage.md lines 98-165 (Serving files and Deploy note)
- docs/database/attachments.md lines 84-92 and 136-141
- modules/surf/uploads.go lines 29-45 (mountUploads doc comment)
</read_first>
<behavior>
- Through attach.StaticHandlerPublic with a real Postgres is_public lookup, a public row whose blob was written with ContentType text/html and ContentDisposition attachment answers GET 200 with Content-Disposition attachment and X-Content-Type-Options nosniff.
- Through the same handler, the existing public jpeg original answers 200 with nosniff and no Content-Disposition, and the private original still answers 404.
</behavior>
<action>
This task covers the gated half of the todo with a real database and the documentation rule from CLAUDE.md.
1. In modules/lagoon/attach/lifecycle_test.go, TestStaticHandlerPublicLooksUpIsPublic: keep every existing assertion. Add a third public row, for example DiskName "fed654cba.txt", FileName "note.txt", ContentType "text/html", IsPublic &pub, created with gdb.Create, and write its blob at attach.BlobKey of that disk name with bucket.WriteAll and blob.WriterOptions{ContentType: "text/html", ContentDisposition: "attachment"} (what attach.Store writes for an HTML body uploaded as note.txt). Serve GET for its URL and for the existing public jpeg original through h with httptest.NewRecorder and assert the headers from the behavior block (a small local helper returning the recorder is fine; leave the existing get helper's signature as it is). Add a one-line comment that the stored disposition and nosniff go through the is_public gate.
2. modules/lagoon/README.md:
- (a) Features, attach bullet: extend the clause about static serving so it says the handlers send each file's stored Content-Type and Content-Disposition with X-Content-Type-Options: nosniff, for example: static serving (`attach.StaticHandler`, with an optional `is_public` gate in `attach.StaticHandlerPublic`) that sends each file's stored Content-Type and Content-Disposition with `X-Content-Type-Options: nosniff`.
- (b) API table, `attach.StaticHandler` row: Serves stored files and thumbnails under a URL prefix with their stored Content-Type and Content-Disposition and `X-Content-Type-Options: nosniff`. The `attach.StaticHandlerPublic` row stays as it is (it already says "attach.StaticHandler plus").
3. docs/services/storage.md:
- Serving files, the paragraph that starts "Without the key, the application decides where files are served": after "mount `attach.StaticHandlerPublic` itself", add that it sends the stored Content-Type, the stored Content-Disposition (so a file `attach.Store` stored as an attachment downloads) and `X-Content-Type-Options: nosniff`, but not the route's sandboxing Content-Security-Policy, and it adds no disposition to a file stored without one (see the deploy note). Keep the separate-origin advice.
- Deploy note, the paragraph that starts "Objects written before this change": add that `attach.StaticHandler` and `attach.StaticHandlerPublic` send the stored disposition too, so the same caveat applies to them, while the built-in route also decides from the type.
- Deploy note, the file:// sentence at the end of the section: add that `attach.StaticHandler`, `attach.StaticHandlerPublic` and the built-in route read the sidecar, so the static-server case applies only to a web server reading the directory.
- Keep the `#deploy-note` heading and anchor, the nginx example and every existing fact.
4. docs/database/attachments.md:
- Line 87, after "`attach.StaticHandler` serves originals and thumbnails under that prefix; `attach.StaticHandlerPublic` does the same and answers 404 for a row whose `is_public` flag is false.": add that both send `X-Content-Type-Options: nosniff` and each file's stored Content-Type and Content-Disposition, and send no Content-Security-Policy, which is why the warning below still applies.
- Line 140 (the paragraph about attach.Store writing Content-Disposition: attachment): extend "downloads it instead of rendering it" to cover a browser that opens it from a public bucket or through `attach.StaticHandler` and `attach.StaticHandlerPublic`.
5. modules/surf/uploads.go, mountUploads doc comment only (no code change): replace the clause saying attach.StaticHandlerPublic sends only the stored type. The new wording: attach.Store also stores the attachment disposition with the blob and attach.StaticHandlerPublic sends it, but the route still decides from the type on every response, because blobs written earlier or outside attach.Store carry no disposition.
6. Run the docs checkers and fix any identifier, link or anchor they report. Use only identifiers that exist: attach.StaticHandler, attach.StaticHandlerPublic, attach.Store, attach.IsInlineContentType. Config keys are not checked automatically; keep storage.uploads.serve and storage.uploads.public_url spelled exactly as the pages already have them.
7. Commit Tasks 1 and 2 as one commit, staging exactly these paths by name:
- modules/lagoon/attach/static.go
- modules/lagoon/attach/static_test.go
- modules/lagoon/attach/lifecycle_test.go
- modules/lagoon/README.md
- docs/services/storage.md
- docs/database/attachments.md
- modules/surf/uploads.go
Message subject: fix(lagoon/attach): StaticHandler sends the stored Content-Disposition and nosniff (quick-261105-oc8). Body: a short bullet list (both handlers set X-Content-Type-Options nosniff; the stored disposition is read with one Attributes call after the is_public gate and sent on GET and HEAD, an unparsable value as attachment; blobs without one are served as before; README, storage and attachments pages, surf comment). Never stage everything at once. Leave other sessions' STATE.md, state.json, G15OFFICE.md and the untracked .gitkeep, .msd and quick-batches files alone. Add no co-author tag (user and project CLAUDE.md override the harness attribution reminder). Do not push or tag.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./... && go test -short ./... && go test ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check && go test -count=1 -v -run 'TestStaticHandlerPublicLooksUpIsPublic$' ./modules/lagoon/attach && go test -count=1 -run 'TestUploads' ./modules/surf</automated>
</verify>
<acceptance_criteria>
- `grep -c 'nosniff' modules/lagoon/README.md` prints at least 2 (Features bullet and the StaticHandler row).
- `grep -c 'attach.StaticHandler' docs/services/storage.md` prints at least 2, and `grep -c 'nosniff' docs/database/attachments.md` prints at least 2.
- `grep -c '^### Deploy note' docs/services/storage.md` prints 1 (the anchor survives).
- The Docker-backed -v run reports PASS, not SKIP, for TestStaticHandlerPublicLooksUpIsPublic. If the socket is denied, rerun through `sudo -u golem`.
- After committing, `SHA1=$(git log -1 --format=%H)` and `git show --name-only --format= "$SHA1"` list exactly the 7 paths above.
- `git show "$SHA1" -- modules/surf/uploads.go > "$SCRATCH/oc8-surf.diff"` exits 0, and every changed line in it (lines starting with a single + or -, excluding the +++/--- headers) starts with `//` after the sign: the surf change is comment-only.
- `git show "$SHA1" -- modules/lagoon/ modules/surf/ > "$SCRATCH/oc8-api.diff"` exits 0, and `grep -e '^[+-]func [A-Z]' -e '^[+-]type [A-Z]' -e '^[+-]var [A-Z]' -e '^[+-]const [A-Z]' "$SCRATCH/oc8-api.diff"` prints nothing: no exported identifier is added, removed or changed.
- `git log -1 --format=%B "$SHA1" > "$SCRATCH/oc8-msg.txt"` exits 0, and `grep -ci 'co-authored-by' "$SCRATCH/oc8-msg.txt"` prints 0.
</acceptance_criteria>
<done>One commit. Both handlers send nosniff and the stored disposition, proven Docker-free on memblob and fileblob and through the real is_public gate on Postgres. The lagoon README, both docs pages and the surf comment describe the behaviour and its limits. go vet, go test -short ./..., the docs checkers, the Docker-backed handler test and the surf uploads tests are green.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| bucket attributes → response headers | Blob metadata is written by attach.Store, but any process with bucket write access (or an S3 console user) can set an arbitrary Content-Disposition. The handler forwards it into a response header |
| attach.StaticHandler(Public) → browser | An application mounting a handler directly gets no CSP and no type-derived disposition. A rendering type served inline on a trusted origin is stored XSS |
| surf uploads route → attach.StaticHandlerPublic | The route wraps the handler and sets its own nosniff, CSP and type-derived disposition after the handler's headers |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-q261105oc8-01 | Elevation of privilege | An HTML, SVG or text upload stored by attach.Store and served inline by a directly mounted StaticHandler or StaticHandlerPublic | medium | mitigate | Task 1: servePublicBlobs sends the stored Content-Disposition (attachment for every non-inline type Store wrote). TestStaticHandlerSendsStoredDisposition writes through writeBlob and asserts the header on GET and HEAD; Task 2 asserts it through the real is_public gate |
| T-q261105oc8-02 | Tampering | Header injection or malformed values through a stored Content-Disposition | low | mitigate | storedDisposition sends a value only when mime.ParseMediaType accepts it and otherwise the bare attachment constant; a CR/LF value is a TestStoredDisposition case. Go's header writer also replaces CR/LF |
| T-q261105oc8-03 | Information disclosure | Content sniffing of a served blob turning a non-HTML type into a rendered document | low | mitigate | Task 1 sets X-Content-Type-Options: nosniff on every response of both handlers; asserted on 200 GET, 200 HEAD and 404 |
| T-q261105oc8-04 | Elevation of privilege | Blobs written before quick-261028-rqe or by writers that bypass attach.Store carry no disposition and are still served inline by the handlers | medium | transfer | Task 2's docs say the handlers send only the stored disposition, keep the separate-origin warning and the deploy-note backfill advice; the surf route keeps its type-derived disposition. Direct writers (fonoteka.go, sm-user-plugin avatar, sm-feedback-plugin screenshot) are recorded as follow-ups |
| T-q261105oc8-05 | Information disclosure | Probing private files through a new bucket call before the gate | low | mitigate | The Attributes call runs after the allow gate, so a denied or missing row still 404s without touching the bucket; TestStaticHandlerPublicGate (seen-before-NewReader) stays unmodified |
| T-q261105oc8-06 | Denial of service | One extra Attributes call per request (an S3 HEAD) on the handlers and the built-in route | low | accept | The reader does not expose the disposition in gocloud.dev v0.46.0; the cost is documented in the StaticHandler doc comment. Production deployments are advised to serve uploads from a separate origin or CDN |
| T-q261105oc8-SC | Tampering | npm/pip/cargo installs | low | accept | No new dependency. mime is stdlib; gocloud.dev/blob/fileblob is already a module dependency and already imported by tests in the package |
</threat_model>
<verification>
- go vet ./... and go test -short ./... are green at the commit.
- go test ./cmd/summer -run TestDocsTree and go run ./cmd/summer docs:build --check are green.
- Docker-backed: TestStaticHandlerPublicLooksUpIsPublic passes (not skipped), and go test -run 'TestUploads' ./modules/surf passes without -short.
- One commit containing exactly the 7 files_modified paths, no co-author tag, nothing pushed or tagged.
</verification>
<success_criteria>
- A blob attach.Store wrote for a non-inline type is served by attach.StaticHandler and attach.StaticHandlerPublic with Content-Disposition: attachment, on GET and HEAD.
- Every handler response carries X-Content-Type-Options: nosniff.
- A blob without a stored disposition is served exactly as before; no exported identifier changes; the surf route's observable headers are unchanged.
- The lagoon README, docs/services/storage.md, docs/database/attachments.md and the surf doc comment match the new behaviour.
</success_criteria>
<output>
Create `.planning/quick/261105-oc8-lagoon-attach-statichandler-and-statichandlerpublic-send-the/261105-oc8-SUMMARY.md` when done. List the follow-ups from the objective's "Not in scope" block in it so the orchestrator can file them.
</output>

View File

@@ -0,0 +1,75 @@
---
phase: quick-261105-oc8
plan: 01
subsystem: lagoon/attach
status: complete
tags: [security, uploads, headers, content-disposition, nosniff]
requires: [quick-261028-rqe]
provides:
- attach.StaticHandler and attach.StaticHandlerPublic send X-Content-Type-Options nosniff and the validated stored Content-Disposition
affects: [modules/surf uploads route (doc comment only)]
tech-stack:
added: []
patterns: ["fail-closed header forwarding: stored metadata validated with mime.ParseMediaType, else the bare attachment constant"]
key-files:
created: []
modified:
- modules/lagoon/attach/static.go
- modules/lagoon/attach/static_test.go
- modules/lagoon/attach/lifecycle_test.go
- modules/lagoon/README.md
- docs/services/storage.md
- docs/database/attachments.md
- modules/surf/uploads.go
decisions:
- "Handlers forward only the stored disposition; no type-derived disposition and no CSP (additive, the surf route keeps that role)"
- "Attributes call placed after the is_public gate and before NewReader; disposition set only after both succeed so a 404 never carries it"
requirements: [todo:2026-10-28-attach-static-handler-disposition]
duration: ~15min
completed: 2026-11-05
commits: 1
plan_head_before: 5aa846c5e85368b534e03a1d19f441bb34de193c
plan_head_after: 27c98bf22aa61add1b90904456dc37fec4310ca8
actuals:
tokens: 9000
tasks: 2
commits: 1
---
# Quick 261105-oc8: attach.StaticHandler sends stored Content-Disposition and nosniff Summary
Both `attach.StaticHandler` and `attach.StaticHandlerPublic` now set `X-Content-Type-Options: nosniff` on every response and forward the blob's stored Content-Disposition (read with one `bucket.Attributes` call after the `is_public` gate, validated by `mime.ParseMediaType`, fail-closed to `attachment`) on GET and HEAD, so an HTML/SVG/PDF/text upload stored by `attach.Store` downloads instead of rendering when an app mounts the handlers directly.
## Commit
- `27c98bf` fix(lagoon/attach): StaticHandler sends the stored Content-Disposition and nosniff (quick-261105-oc8) — exactly the 7 planned paths, no co-author tag, not pushed or tagged.
## Tasks
1. **Task 1 (tracer, TDD):** RED: `TestStoredDisposition` and `TestStaticHandlerSendsStoredDisposition` failed to compile (`undefined: storedDisposition`). GREEN: unexported `storedDisposition` helper, nosniff after the method check, `Attributes` after the gate, disposition set next to Content-Type before the HEAD return. Tests run on memblob and fileblob (sidecar) through `writeBlob`, GET and HEAD, ungated and gated (`servePublicBlobs` with an allow-all func), verbatim `attachment; filename="a.png"`, fail-closed `attachment;;`, png without any Content-Disposition key, 404 with nosniff and no disposition. Tracer gate re-run passed.
2. **Task 2:** `TestStaticHandlerPublicLooksUpIsPublic` adds a public `text/html` row (`fed654cba.txt`) with stored `attachment` and asserts disposition + nosniff through the real Postgres `is_public` gate; the public jpeg gets nosniff and no disposition; existing assertions untouched. Lagoon README (Features bullet, `attach.StaticHandler` row), `docs/services/storage.md` (Serving files paragraph, deploy-note caveat, `file://` sidecar sentence), `docs/database/attachments.md` (handler paragraph, Store disposition paragraph), surf `mountUploads` doc comment (comment-only diff).
## Verification
- `go vet ./...`, `go test -short ./...`: green.
- `go test ./cmd/summer -run TestDocsTree`, `go run ./cmd/summer docs:build --check`: green ("no problems found").
- Docker-backed `TestStaticHandlerPublicLooksUpIsPublic`: PASS (not skipped). `go test -run TestUploads ./modules/surf` (no -short): PASS with no surf test edit.
- `inline.go`, `store.go`, `uploads_test.go` untouched; static.go diff adds only `func storedDisposition`; surf diff is comment-only; no exported identifier added/removed/changed in modules/lagoon or modules/surf (the only `func [A-Z]` lines in the diff are the two new `Test*` functions in `static_test.go`).
## Deviations from Plan
None - plan executed exactly as written. (The missing-blob test uses `BlobKey("mis123sin.html")` so its partition matches.)
## Threat Flags
None beyond the plan's threat model. Note for the surf route: a blob stored with a disposition on an inline raster type (which `attach.Store` never writes) would now have that stored disposition forwarded through the route too; the route's own type-derived `attachment` for non-inline types is unchanged.
## Follow-ups (not in scope; for the orchestrator to file)
- Blob writers that bypass `attach.Store` should adopt `attach.IsInlineContentType` / store the disposition: fonoteka.go `storeUpload` and `StorePublicFile` (see todo `2026-10-28-fonoteka-uploads-use-disk-extension.md`), the sm-user-plugin avatar writer, the sm-feedback-plugin screenshot writer.
- Backfilling the disposition on objects already in a bucket is an operator task (documented in `docs/services/storage.md#deploy-note`).
- Move todo `2026-10-28-attach-static-handler-disposition` to completed and post the g15office comment (`knuu7fil`).
## Self-Check: PASSED
- All 7 modified files exist; commit `27c98bf` present in `git log`.

View File

@@ -0,0 +1,249 @@
---
phase: quick-261105-oc9
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- ../sm-demo-app/plugins/demo/chat_api.go
- ../sm-demo-app/plugins/demo/classes/members.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_friends.go
- ../sm-demo-app/plugins/demo/plugin.go
- ../sm-demo-app/plugins/demo/updates/updates.go
- ../sm-demo-app/go.mod
- ../sm-demo-app/chat_test.go
- ../sm-demo-app/chat_smoke_test.go
- ../sm-demo-app/seed_chat_test.go
- ../sm-demo-app/README.md
- ../sm-demo-app/scripts/check-moderation.sh
- ../sm-demo-app/scripts/check-local.sh
- ../sm-demo-app/deploy/nginx/demo.summercms.io.conf
- ../sm-demo-app/scripts/check-deploy.sh
- ../sm-demo-app/scripts/check-live.sh
- ../sm-demo-app/DEPLOY.md
autonomous: true
requirements:
- quick-261105-oc9
must_haves:
truths:
- "GET /_demo/api/v1/chat/members writes nothing: a member without a handle is listed with handle \"\" and its users.handle is still NULL after the call (no handle assignment in a GET, so two concurrent first loads cannot race on the same rows)."
- "GET /_demo/api/v1/chat/members answers 200 for a community of more than 65535 members: the query filters users with the community's membership condition as a subquery (WHERE id <> caller AND <memberWhere>), never an id list bound as parameters."
- "demo:seed has a step \"member handles\" right after \"chat personas\" that gives every user without a handle one (the visitor, rows created outside registration): \"updated\" when it assigned any, \"exists\" otherwise."
- "The default moderation lists are written once, by golem15.demo's own migration, which runs right after golem15.moderation's migration creates the settings row and fills the lists only while both are empty. demo:seed no longer touches golem15_moderation_settings, so an admin who empties both lists keeps them empty across demo:seed and demo:reset."
- "scripts/check-local.sh passes every member JWT to curl through a config on stdin (curl -K -) from request(); no curl argv carries a bearer token. The visitor's login body goes to curl from a file in the 0700 temp directory, not argv."
- "deploy/nginx/demo.summercms.io.conf proxies only /livekit/rtc (and the paths below it, /rtc/validate and /rtc/v1 included) to LiveKit's /rtc on 127.0.0.1:7890; every other /livekit/ path answers 404 from nginx, so LiveKit's Twirp RoomService is reachable on loopback only. check-deploy.sh enforces the shape, check-live.sh probes it. The vhost is committed only, not installed on rome."
- "DEPLOY.md's VAPID step runs with set -eo pipefail, generates the pair into a variable, refuses to write unless both keys are non-empty, and appends all three lines with one tee; no key is ever printed."
- "go vet ./... and go test -short ./... in sm-demo-app are green at every commit; the Docker-backed tests named in each task pass; nothing is pushed, tagged or deployed; commits carry no co-author tag."
artifacts:
- path: ../sm-demo-app/plugins/demo/classes/members.go
provides: "OtherMembers(ctx, db, exceptID): the community's members except one user, ordered by id, read-only, membership as a subquery"
- path: ../sm-demo-app/plugins/demo/chat_api.go
provides: "chatMembers reads through classes.OtherMembers; no MemberIDs id list, no handle writes"
- path: ../sm-demo-app/plugins/demo/updates/updates.go
provides: "All(): the golem15.demo migration that fills golem15.moderation's default lists once"
- path: ../sm-demo-app/plugins/demo/plugin.go
provides: "Plugin implements pact.HasMigrations (Migrations returns updates.All())"
- path: ../sm-demo-app/deploy/nginx/demo.summercms.io.conf
provides: "location ^~ /livekit/rtc to http://127.0.0.1:7890/rtc; location ^~ /livekit/ answers 404"
key_links:
- from: "GET /_demo/api/v1/chat/members (plugins/demo/chat_api.go chatMembers)"
to: "plugins/demo/classes/members.go OtherMembers"
via: "the unexported memberWhere condition of the classes package (chat_context.go, not edited)"
pattern: "OtherMembers"
- from: "lagoon.Migrate / summer migrate (party activation order: golem15.demo Requires golem15.moderation)"
to: "plugins/demo/updates All() via Plugin.Migrations()"
via: "history table summer_migrations_golem15_demo"
pattern: "HasMigrations"
- from: "scripts/check-local.sh request()"
to: "curl -K - (config on stdin)"
via: "printf of the header line piped into curl"
pattern: "--jwt"
---
<objective>
Close the five sm-demo-app todos from the phase 20 code review in the sibling repo /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app, inside that repo only:
1. demo-chat-members-read-only.md (IN-09): GET /_demo/api/v1/chat/members becomes read-only and selects members with the membership subquery instead of an id list; missing handles are assigned in demo:seed.
2. demo-seed-moderation-lists-once.md (IN-05): the default moderation lists are seeded once, by a golem15.demo migration, not on every demo:seed.
3. check-local-jwt-out-of-argv.md (IN-03): check-local.sh passes member JWTs to curl through a config on stdin.
4. demo-nginx-livekit-rtc-only.md (IN-06): nginx proxies only LiveKit's /rtc paths; check-deploy and check-live follow.
5. demo-vapid-runbook-pipefail.md (IN-08): the VAPID runbook step uses pipefail, generates into a variable, checks both keys and appends with one tee.
Purpose: remove a write-in-GET race and a 500 past Postgres' 65535 bind parameters on a growing demo, stop deploys from undoing an admin's moderation choice, keep tokens out of /proc/*/cmdline, shrink LiveKit's public surface and make the VAPID rebuild step fail safe.
Output: five sm-demo-app commits (one per todo), no framework change, no shared-plugin change, no frontend change (the members response shape is unchanged).
Claude's discretion choices (no CONTEXT.md exists for a quick item):
- "Seed only once" is implemented as a golem15.demo data migration (the todo's first option, "insert the lists when the settings row is created"): gormigrate records it in summer_migrations_golem15_demo, demo:reset keeps settings and migrations, and no flag table is needed. This is the demo plugin's first migration; the plugin doc comment and README change accordingly.
- The members helper goes into a NEW file plugins/demo/classes/members.go (same package as the unexported memberWhere), so this item does not edit plugins/demo/classes/chat_context.go, which item 261105-oc6 edits for its pin policy.
- The "member handles" seed step covers every user without a handle (as user:backfill-handles does), not only members.
- check-local.sh's visitor login body (it carries the local visitor password) also leaves argv, through a file in the script's 0700 temp directory: same leak class, same fix, two lines.
Closing the five todos (moving them to .planning/todos/done/ with a "## Closed" note and syncing g15office) is the orchestrator's closing docs step, not part of the executor's sm-demo-app commits.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@CLAUDE.md
@.planning/todos/pending/demo-chat-members-read-only.md
@.planning/todos/pending/demo-seed-moderation-lists-once.md
@.planning/todos/pending/check-local-jwt-out-of-argv.md
@.planning/todos/pending/demo-nginx-livekit-rtc-only.md
@.planning/todos/pending/demo-vapid-runbook-pipefail.md
@../sm-demo-app/README.md
@../sm-demo-app/plugins/demo/chat_api.go
@../sm-demo-app/plugins/demo/classes/chat_context.go
@../sm-demo-app/plugins/demo/console/seed.go
@../sm-demo-app/plugins/demo/console/seed_friends.go
Facts established while planning (sm-demo-app at da03c06, working tree clean):
- chat_api.go chatMembers (lines 319-367) calls classes.MemberIDs, binds every id into "id IN ? AND id <> ?", then calls the user plugin's handle assignment for each member lacking a handle. memberWhere (classes/chat_context.go) is an unexported const: activated, not deleted, no user_throttle row with is_banned. classes.MemberIDs stays (community.Members uses it).
- Registration already assigns handles (sm-user-plugin controllers/registration.go, used by social login too) and so does the admin create. Rows without a handle come from direct inserts: demo:seed's visitor (seed.go visitor.seed), legacy rows, and test helpers (demo_api_test.go apiDemo.member uses a.DB.Create without a handle).
- seed_chat.go seedPersonas already assigns persona handles at creation.
- golem15.moderation's migration 202610280001 creates golem15_moderation_settings and inserts row id 1 with NULL lists. lagoon runs each plugin's set in party activation order with history table summer_migrations_<id with dots as underscores>; golem15.demo Requires golem15.moderation. internal/demotest migrates a template database with every plugin (lagoon.Migrate), so test databases already carry the demo migration's effect.
- pact.HasMigrations is Migrations() []*gormigrate.Migration. gormigrate v2.1.7 is in sm-demo-app go.mod as indirect only.
- Tests that pin the current lists behaviour: seed_chat_test.go TestSeedChat (lines 181-219), chat_smoke_test.go chatSeedSteps (line 226-230, includes "moderation lists") and TestSeedChatSmoke (line 301). TestDemoChatMembers (chat_test.go 320-372) asserts every listed member has a handle, which members created by apiDemo.member only had because the GET wrote one.
- check-local.sh runs under set -euo pipefail; request() is at lines 124-130; 15 calls pass a bearer header literally and 12 more through local auth variables (check_quizzes, check_forum, check_call); visitor_jwt (lines 325-336) puts the login JSON in -d. check-moderation.sh request() (lines 80-93) is the pattern to copy.
- nginx: location ^~ /livekit/ at conf lines 355-375 proxies the whole LiveKit HTTP surface. check-deploy.sh lines 26-31 (header) and 329-341 (python block) pin that location; check-live.sh lines 40-42 (header) and 292-298 probe /livekit/rtc/validate. DEPLOY.md mentions /livekit/ at lines 19, 364, 370, 510, 839, 916; README lines 280 and 282 describe check-deploy and check-live.
- DEPLOY.md VAPID step: lines 533-547; other remote snippets in DEPLOY.md already use set -euo pipefail.
- Baseline: go vet ./... and go test -short ./... green; scripts/check-deploy.sh prints "check-deploy: ok"; shellcheck -S error is clean on check-local.sh, check-live.sh, check-deploy.sh; Docker is reachable for the testcontainers tests (if the socket is denied, run them through sudo -u golem, per the framework CLAUDE.md).
</context>
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: GET /_demo/api/v1/chat/members reads only, through the membership subquery; demo:seed assigns missing handles (IN-09)</name>
<files>../sm-demo-app/plugins/demo/classes/members.go, ../sm-demo-app/plugins/demo/chat_api.go, ../sm-demo-app/plugins/demo/console/seed.go, ../sm-demo-app/plugins/demo/console/seed_chat.go, ../sm-demo-app/chat_test.go, ../sm-demo-app/seed_chat_test.go, ../sm-demo-app/README.md</files>
<behavior>
- TestDemoChatMembers (rewritten): the four personas are listed with their handles (ada, ben, ola, tomek); the other member created by apiDemo.member (no handle) is listed with handle "" and, after the GET, SELECT handle FROM users for that id is still NULL; the caller and the banned user are not listed; a banned caller gets 403; after mustSeed runs again, the same GET lists the other member with a non-empty handle. Red on the current code: the GET writes the handle.
- TestDemoChatMembersManyMembers (new): after bootAPI, one INSERT ... SELECT FROM generate_series(1, 66000) adds 66000 activated users (email 'bulk-' || g || '@example.com', password 'x', is_activated true, created_at and updated_at now()); a member's GET answers 200 and lists at least 66000 members. Red on the current code: 500 from the bind-parameter limit.
- TestSeedChat: with the visitor configured, the first demo:seed reports "member handles" as updated and the visitor's users.handle is non-empty; the second run reports exists. TestSeedChatWithoutVisitor: "member handles" reports exists (personas get handles at creation).
</behavior>
<action>
Work in /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app. RED first: change the tests in the behavior block (TestDemoChatMembers and the new TestDemoChatMembersManyMembers in chat_test.go, the step assertions in seed_chat_test.go; do not change apiDemo.member in demo_api_test.go, its handle-less rows are what the test needs), run them and note the failures for the SUMMARY. Do not commit red tests: the repo keeps go test green at every commit.
GREEN:
1. New file plugins/demo/classes/members.go (package classes; do not edit chat_context.go, item 261105-oc6 edits it). Add OtherMembers(ctx context.Context, db *gorm.DB, exceptID uint) ([]usermodels.User, error): nil db gives an empty slice; otherwise query the user plugin's User model with Select of id, name, surname and handle, Where "users.id <> ?" exceptID, Where(memberWhere), Order "users.id", Find. It never writes. Doc comment: the community's members except one user, ordered by id, read-only; the membership condition stays in SQL, so no id list is bound however large the community grows.
2. plugins/demo/chat_api.go chatMembers: keep the caller resolution and the IsMember 403; replace the MemberIDs call, the id-list query and the per-member handle assignment with one OtherMembers call (500 on error); build chatMemberJSON from each row with deref as now (an absent handle is ""). Drop the imports that become unused. Rewrite its doc comment: every member but the caller, ordered by id, read-only; the handle is the user plugin's, assigned at registration or by demo:seed, empty for a row that has none yet; is_bot always false.
3. plugins/demo/console/seed_chat.go: add seedMemberHandles(ctx) returning func(*gorm.DB) (string, error). It finds every user whose handle IS NULL or trims to '' (the GORM User model, ordered by id) and calls the user plugin's handle assignment on each inside the step's transaction, exactly as seedPersonas does for a new persona; it returns "updated" when it assigned at least one handle, "exists" otherwise.
4. plugins/demo/console/seed.go: in runSeed's steps insert {"member handles", seedMemberHandles(ctx)} directly after the "chat personas" step. Add "member handles" to SeedCommand's Description (after "the chat personas").
5. README.md: the GET /_demo/api/v1/chat/members row (line 173) says the call reads only and a member without a handle shows an empty one until demo:seed assigns it; the Seed list (around line 123) mentions the handles for users without one; the Testing paragraph (lines 342-353) lists TestDemoChatMembersManyMembers next to TestDemoChatMembers.
Run gofmt on the touched Go files, go vet ./..., go test -short ./..., then the Docker tests below. Commit in sm-demo-app, staging these paths explicitly, message "fix: GET /_demo/api/v1/chat/members reads only and selects members with a subquery", body naming IN-09 and the new demo:seed step "member handles". No co-author tag.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app && test -z "$(gofmt -l plugins/demo *.go)" && go vet ./... && go test -short ./... && go test -count=1 -run 'TestDemoChatMembers|TestSeedChat|TestDemoDMs|TestDemoChatBootstrap|TestDemoChatTracer' . 2>&1 | tail -5 && git log -1 --format='%h %s' && git show --stat --format= HEAD</automated>
</verify>
<done>GET /_demo/api/v1/chat/members answers from one read-only query with the membership subquery: a handle-less member stays NULL in the database and shows "" in the answer, 66000 members answer 200; demo:seed's "member handles" step assigns the missing handles (updated, then exists); README updated; vet, short tests and the named Docker tests pass; one sm-demo-app commit.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: the default moderation lists are seeded once, by a golem15.demo migration (IN-05)</name>
<files>../sm-demo-app/plugins/demo/updates/updates.go, ../sm-demo-app/plugins/demo/plugin.go, ../sm-demo-app/plugins/demo/console/seed_friends.go, ../sm-demo-app/plugins/demo/console/seed.go, ../sm-demo-app/go.mod, ../sm-demo-app/seed_chat_test.go, ../sm-demo-app/chat_smoke_test.go, ../sm-demo-app/scripts/check-moderation.sh, ../sm-demo-app/README.md</files>
<behavior>
- TestSeedChat: before the first demo:seed, golem15_moderation_settings row 1 already holds the defaults (buy followers, viagra, bit.ly) and summer_migrations_golem15_demo has the migration's id; demo:seed's output has no "moderation lists" line.
- TestSeedChat: after an admin sets both lists to '' (and again with both NULL), two demo:seed runs leave both lists empty. Red on the current code: the seed refills them. The existing "an admin's own list survives a later seed" check stays.
- chatSeedSteps no longer lists "moderation lists"; TestSeedChatSmoke's "default moderation lists were filled" assertion still holds (the migration filled them) and its settings fingerprint across demo:reset still matches.
</behavior>
<action>
Work in /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app. RED first: change seed_chat_test.go and chatSeedSteps in chat_smoke_test.go as in the behavior block, run TestSeedChat and note the failure; do not commit red tests.
GREEN:
1. New package plugins/demo/updates, file updates.go. Move defaultBannedWords and defaultBannedLinks (same values, same comment about mild defaults) here from console/seed_friends.go. Define one gormigrate migration with ID "202611050001_seed_golem15_moderation_lists" whose Migrate runs the exact UPDATE statement seedModerationLists runs today (moderation settings row id 1, only while both lists trim to empty), moved here unchanged; its Rollback returns nil (from then on the lists belong to the admin). Export All() []*gormigrate.Migration returning it. Doc comment: golem15.demo's migrations; this one gives golem15.moderation's settings row the default lists once, right after golem15.moderation's own migration creates it (golem15.demo requires golem15.moderation, so its set runs later), and only while both lists are empty, so on an existing database whose lists were already seeded it changes nothing and is just recorded.
2. plugins/demo/plugin.go: add pact.HasMigrations to the interface assertion block and func (p *Plugin) Migrations() []*gormigrate.Migration returning updates.All(). Replace "It adds no admin screens or migrations of its own." in the package doc with: no admin screens; one migration, which fills golem15.moderation's default word and link lists once.
3. plugins/demo/console/seed_friends.go: delete seedModerationLists and the two constants. plugins/demo/console/seed.go: delete the {"moderation lists", ...} step and drop "the moderation lists" from SeedCommand's Description.
4. go.mod: gormigrate is now imported directly. Hand-move the github.com/go-gormigrate/gormigrate/v2 v2.1.7 line from the indirect block into the first require block without the indirect marker (keep the version; do not run go mod tidy, the workspace replaces would make it reach for the network). go.sum does not change.
5. scripts/check-moderation.sh header (line 10): the lists the controls guard against are those that differ from the demo migration's defaults, no longer demo:seed's. Comment only.
6. README.md: the Seed list (line 124) drops "the default moderation lists"; the Moderation paragraph (around line 192) says golem15.demo's migration fills a short default list once, when the settings row is new, and an admin who empties both lists keeps them empty; the Reset paragraph's "(the moderation lists included)" stays true.
Run gofmt, go vet ./..., go test -short ./..., then the Docker tests below. Commit in sm-demo-app, staging these paths explicitly, message "fix: seed the default moderation lists once, in a golem15.demo migration", body naming IN-05 and that an admin's emptied lists now survive deploys. No co-author tag.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app && test -z "$(gofmt -l plugins/demo *.go)" && go vet ./... && go test -short ./... && go test -count=1 -run 'TestSeedChat|TestDemoModeration|TestResetKeepsPlans|TestBootAllPlugins|TestGoModRequiresTags' . 2>&1 | tail -5 && test "$(grep -hv '^[[:space:]]*//' plugins/demo/console/*.go | grep -c 'golem15_moderation_settings')" = 0 && grep -q 'func (p \*Plugin) Migrations()' plugins/demo/plugin.go && git log -1 --format='%h %s' && git show --stat --format= HEAD</automated>
</verify>
<done>golem15.demo carries one migration that fills the default lists once; demo:seed and the console package no longer touch golem15_moderation_settings; lists an admin emptied stay empty after demo:seed; tests, check-moderation comment and README updated; vet, short tests and the named Docker tests pass; one sm-demo-app commit.</done>
</task>
<task type="auto">
<name>Task 3: check-local JWTs through a curl config (IN-03), nginx proxies only LiveKit's /rtc (IN-06), VAPID runbook with pipefail (IN-08)</name>
<files>../sm-demo-app/scripts/check-local.sh, ../sm-demo-app/deploy/nginx/demo.summercms.io.conf, ../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>
<action>
Work in /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app. Three commits, in this order, each staging only its own paths. No co-author tags. Nothing is pushed and nothing is installed on rome.
Commit A, scripts/check-local.sh (IN-03):
- request() takes an optional leading pair --jwt JWT before METHOD URL. With a non-empty JWT it pipes one config line, header = "Authorization: Bearer JWT" written with printf (a builtin, so the token never reaches an argv), into curl with -K - and otherwise the same curl flags, output files and the same fallback to 000 as now (the script runs under pipefail, so a curl failure still prints 000). Without --jwt, or with an empty JWT, it runs exactly as now. Copy the shape of check-moderation.sh's request() (lines 80-93). Update the comment above it to "request [--jwt JWT] METHOD URL [curl args...]" and say the token goes through a curl config on stdin, never argv.
- Every call that today passes a bearer header as a -H argument, literally or through the auth locals of check_quizzes, check_forum and check_call (27 calls: realtime, decks, quizzes, forum, friends, call, herald), passes --jwt with the matching variable ($jwt, $a_jwt, $b_jwt) instead. Remove the auth locals and their declarations.
- visitor_jwt: write the login JSON (the python3 one-liner's output, unchanged) to "$TMP/login.json" and pass -d @"$TMP/login.json", so the local visitor password leaves argv too ($TMP is the script's mktemp -d directory, mode 0700).
- If the local stack happens to be up (scripts/dev.sh up), also run scripts/check-local.sh realtime decks quizzes forum friends call herald and record the result; it is not required.
- Commit "test: check-local passes member JWTs to curl through a config on stdin" (body: IN-03).
Commit B, LiveKit signalling only (IN-06):
- deploy/nginx/demo.summercms.io.conf: replace the location ^~ /livekit/ block (lines 355-375) with two blocks. First location ^~ /livekit/rtc with proxy_pass http://127.0.0.1:7890/rtc; and every directive the old block had (proxy_http_version 1.1, Upgrade and Connection upgrade, Host, X-Real-IP, X-Forwarded-For, X-Forwarded-Proto, proxy_read_timeout 7d, proxy_send_timeout 7d, proxy_buffering off). Then location ^~ /livekit/ with only return 404;. Keep tab indentation and the closing brace on its own tab-indented line (check-deploy's regexes rely on it). Rewrite the comment: livekit-client appends /rtc (and /rtc/validate, /rtc/v1) to livekit_url wss://demo.summercms.io/livekit, so only that prefix reaches LiveKit's /rtc; the rest of LiveKit's HTTP surface, the Twirp RoomService included, stays on loopback for the Go binary (livekit_admin_url), and nginx answers 404 for any other /livekit/ path. Keep the media and timeout sentences.
- scripts/check-deploy.sh: in the python block (lines 329-341) require the location ^~ /livekit/rtc block to contain proxy_pass http://127.0.0.1:{lk_port}/rtc; and each WebSocket directive, require the location ^~ /livekit/ block to contain return 404; and no proxy_pass, and require exactly one proxy_pass to the LiveKit port in the whole file; every mismatch is its own problems line as now. Update the header comment (lines 28-30) to describe the two locations. The production livekit_url check stays wss://demo.summercms.io/livekit.
- scripts/check-live.sh: keep the /livekit/rtc/validate probe; add a probe that POSTs {} as JSON to /livekit/twirp/livekit.RoomService/ListRooms through the get helper (extra curl args go before the URL) and requires 404 (LiveKit itself would answer 401), with its own bad line. Update the header comment (lines 40-42). Until the new vhost is installed on rome this probe fails there; that is expected and documented below.
- DEPLOY.md: the Overview tree (line 19) shows /livekit/rtc to 127.0.0.1:7890 and the rest of /livekit/ as 404; the port table row for 7890 (line 364) says browsers reach only the signal server through nginx /livekit/rtc and the RoomService is reachable by the Go binary on loopback only; lines 510 and 839 name /livekit/rtc; the check-live description (around line 916) adds the 404 for the rest of /livekit/. Add a short pending note in the "When deploy/nginx/demo.summercms.io.conf changed" procedure (or next to the "Applied on rome" record): the /livekit/rtc-only vhost is committed but not applied on rome; installing it needs its own go-ahead; until then check-live.sh reports the RoomService probe. Keep "Disallow: /livekit" in robots.
- README.md lines 280 and 282: check-deploy covers "the LiveKit /rtc-only location", check-live covers "/livekit/rtc/validate from LiveKit, the rest of /livekit/ 404".
- Run scripts/check-deploy.sh (must print check-deploy: ok), bash -n and shellcheck -S error on check-deploy.sh and check-live.sh. Commit "fix(deploy): nginx proxies only LiveKit's /rtc paths" (body: IN-06; the vhost is not installed on rome).
Commit C, DEPLOY.md VAPID step (IN-08), lines 533-547:
- Keep the build-machine part (source the secrets file, printf the address into $RSH $ROME) and the remote script's single-quoted form. The remote script starts with set -eo pipefail (if rome's login shell lacked pipefail it would stop before writing anything, which is still safe), reads the address, keeps the empty-address guard and the "already in .env" guard, then assigns the output of the herald:vapid-generate --json -q run (same sudo -u summercms-demo SUMMER_ENV=production ./bin/demo.new) to a variable, extracts .public_key and .private_key with jq -r and a // empty fallback into two variables, exits with "herald:vapid-generate returned no key pair" unless both are non-empty, and only then appends all three lines (PUBLIC_KEY, PRIVATE_KEY, SUBJECT=mailto:address) with one printf piped into one sudo tee -a .en[v] >/dev/null. No echo, no set -x, nothing prints a key; the final grep -c check (expects 3) stays. One sentence under the block: a failed generation now writes nothing, so a re-run needs no hand edit.
- Dry-run it in the scratchpad before committing: copy the remote script text into a file, run it with bash against a temp directory holding an .env and a stub bin/demo.new, with a sudo shim on PATH that drops -u USER and runs the rest through env. Check: the stub printing a JSON pair appends exactly three lines; a failing stub, and a stub printing JSON without private_key, both exit non-zero and leave .env byte-identical; a second run stops at "already in .env". Record the outcome in the SUMMARY.
- Commit "docs: the VAPID step runs with pipefail and appends the keys only when both exist" (body: IN-08).
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app && bash -n scripts/check-local.sh && bash -n scripts/check-live.sh && bash -n scripts/check-deploy.sh && shellcheck -S error scripts/check-local.sh scripts/check-live.sh scripts/check-deploy.sh && test "$(grep -v '^[[:space:]]*#' scripts/check-local.sh | grep -c 'Authorization')" = 1 && test "$(grep -c -- '--jwt' scripts/check-local.sh)" -ge 27 && grep -q 'curl .*-K -' scripts/check-local.sh && grep -q 'location \^~ /livekit/rtc {' deploy/nginx/demo.summercms.io.conf && grep -q 'proxy_pass http://127.0.0.1:7890/rtc;' deploy/nginx/demo.summercms.io.conf && scripts/check-deploy.sh 2>&1 | tail -1 | grep -qx 'check-deploy: ok' && grep -q 'livekit.RoomService/ListRooms' scripts/check-live.sh && awk '/^The VAPID keys, after/,/^After the release, `GET \/_herald/' DEPLOY.md | grep -q 'set -eo pipefail' && test "$(awk '/^The VAPID keys, after/,/^After the release, `GET \/_herald/' DEPLOY.md | grep -c 'tee -a')" = 1 && go vet ./... && go test -short ./... && git log -3 --format='%h %s' && git status --short</automated>
</verify>
<done>check-local.sh carries no bearer token in any curl argv (one Authorization line, inside request()'s printf) and the visitor login body comes from a file; nginx proxies only /livekit/rtc to LiveKit and answers 404 for the rest of /livekit/, check-deploy enforces it and passes, check-live probes the RoomService 404; the VAPID step uses pipefail, a variable, a both-keys check and one tee, dry-run evidence recorded; DEPLOY.md and README describe the new shape and the pending vhost go-ahead; three sm-demo-app commits; working tree clean apart from other sessions' files.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| internet -> nginx (demo.summercms.io) | anonymous traffic reaches every proxied path, /livekit/* included |
| member JWT -> Go (/_demo/api/v1/chat/members) | any authenticated member triggers the query; the community grows without a reset |
| admin settings -> deploy (demo:seed) | release automation runs against admin-owned settings rows |
| local user -> /proc/*/cmdline | other local users read process arguments of check-local's curl calls |
| build machine -> rome over SSH | the VAPID step writes secrets into /srv/summercms-demo/.env |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-oc9-01 | D | chat_api.go chatMembers | medium | mitigate | membership as a SQL subquery (OtherMembers), no bound id list; 66000-member test answers 200 (Task 1) |
| T-oc9-02 | T | chat_api.go chatMembers | low | mitigate | the GET writes nothing; handles are assigned at registration and in demo:seed's "member handles" step; test proves the row stays NULL after a GET (Task 1) |
| T-oc9-03 | T | console/seed.go, updates/updates.go | medium | mitigate | lists seeded once by a recorded migration; demo:seed no longer writes golem15_moderation_settings; test proves emptied lists survive demo:seed (Task 2) |
| T-oc9-04 | I | scripts/check-local.sh | low | mitigate | JWTs to curl through a config on stdin (printf builtin), visitor login body from a 0700 temp file; gate: one non-comment Authorization line (Task 3) |
| T-oc9-05 | E | nginx /livekit/ (LiveKit Twirp RoomService) | medium | mitigate | only ^~ /livekit/rtc is proxied; the rest of /livekit/ answers 404; check-deploy enforces, check-live probes ListRooms for 404 (Task 3) |
| T-oc9-06 | I | DEPLOY.md VAPID step | medium | mitigate | keys only in shell variables and one printf piped to sudo tee; no echo or set -x; pipefail plus both-keys check means a failed generation writes nothing (Task 3) |
| T-oc9-07 | T | rome vhost | low | accept | the nginx change is committed, not installed; installation is a separate go-ahead, documented in DEPLOY.md |
| T-oc9-SC | T | package installs | low | accept | no install: gormigrate v2.1.7 is already in go.mod and go.sum, only its indirect marker moves |
</threat_model>
<verification>
- In /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app: go vet ./... and go test -short ./... green after each of the five commits; the Docker tests TestDemoChatMembers, TestDemoChatMembersManyMembers, TestSeedChat, TestSeedChatWithoutVisitor, TestSeedChatSmoke, TestDemoModeration, TestResetKeepsPlans pass.
- scripts/check-deploy.sh prints check-deploy: ok; shellcheck -S error clean on the three scripts.
- git log shows five new commits (Task 1, Task 2, Task 3 A/B/C), none with a co-author tag; git status shows no leftover sm-demo-app change from this item; nothing pushed or tagged; nothing applied on rome.
- No file outside sm-demo-app changed by the executor (the plan and SUMMARY under summercms.go/.planning/quick are the orchestrator's).
</verification>
<success_criteria>
- All five todos' Solution sections are implemented as written, with the discretion choices stated in the objective.
- The members endpoint keeps its response shape (no vue-demo-app or vue-chat-layer change needed) and works past 65535 members.
- An admin's moderation lists are never overwritten by a deploy.
- No bearer token in check-local's curl argv; LiveKit's public surface is signalling only; the VAPID rebuild step cannot leave a half-written .env.
</success_criteria>
<output>
Create `.planning/quick/261105-oc9-sm-demo-app-hardening/261105-oc9-SUMMARY.md` when done, listing the five sm-demo-app commit hashes, the red-test evidence for Tasks 1 and 2, the VAPID dry-run outcome, and that the /livekit/rtc vhost awaits a go-ahead on rome.
</output>

View File

@@ -0,0 +1,159 @@
---
phase: quick-261105-oc9
plan: 01
subsystem: sm-demo-app (golem15.demo plugin, deploy files, runbook)
status: complete
tags: [security, hardening, chat, moderation, nginx, livekit, vapid, scripts]
requires: []
provides:
- "classes.OtherMembers: read-only community member listing with the membership subquery"
- "demo:seed step \"member handles\""
- "golem15.demo migration 202611050001_seed_golem15_moderation_lists (Plugin implements pact.HasMigrations)"
- "check-local.sh request --jwt (token through curl -K - on stdin)"
- "nginx /livekit/rtc-only vhost (committed, not installed)"
- "VAPID runbook step with pipefail and both-keys guard"
affects: [sm-demo-app]
tech-stack:
added: []
patterns:
- "Data seeded once via a plugin gormigrate migration instead of an idempotent seed step"
- "Secrets to curl via -K - config on stdin, never argv"
key-files:
created:
- ../sm-demo-app/plugins/demo/classes/members.go
- ../sm-demo-app/plugins/demo/updates/updates.go
modified:
- ../sm-demo-app/plugins/demo/chat_api.go
- ../sm-demo-app/plugins/demo/plugin.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_friends.go
- ../sm-demo-app/go.mod
- ../sm-demo-app/chat_test.go
- ../sm-demo-app/chat_smoke_test.go
- ../sm-demo-app/seed_chat_test.go
- ../sm-demo-app/README.md
- ../sm-demo-app/DEPLOY.md
- ../sm-demo-app/scripts/check-local.sh
- ../sm-demo-app/scripts/check-moderation.sh
- ../sm-demo-app/scripts/check-deploy.sh
- ../sm-demo-app/scripts/check-live.sh
- ../sm-demo-app/deploy/nginx/demo.summercms.io.conf
decisions:
- "Default moderation lists live in a golem15.demo gormigrate migration (recorded in summer_migrations_golem15_demo); demo:seed no longer touches golem15_moderation_settings"
- "OtherMembers lives in a new file classes/members.go so chat_context.go (edited by 261105-oc6) stays untouched"
- "The member handles seed step covers every user without a handle, not only members"
- "check-local.sh's visitor login body also leaves argv (file in the 0700 temp dir)"
metrics:
duration: "~20 min"
completed: 2026-11-05
actuals:
tokens: 15396
tasks: 3
commits: 5
plan_head_before: fbfdcf6
plan_head_after: 054cda58485d9d7d96be794cf19f7af812050ef2
---
# Quick 261105-oc9: sm-demo-app hardening Summary
GET /_demo/api/v1/chat/members is now read-only and selects members through a SQL subquery (it answers 200 for 66000 members). The default moderation lists are written once, by a golem15.demo migration. check-local.sh passes JWTs to curl through a config on stdin. The nginx vhost proxies only LiveKit's /rtc. The VAPID runbook step uses pipefail and appends the keys only when both exist.
All work is in `/media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app` on `master`, starting from `fbfdcf6`. Nothing was pushed, tagged, deployed or installed on rome, and no ssh was used.
## Commits (sm-demo-app)
| # | Todo | Hash | Message |
|---|------|------|---------|
| 1 | IN-09 demo-chat-members-read-only | `9595606` | fix: GET /_demo/api/v1/chat/members reads only and selects members with a subquery |
| 2 | IN-05 demo-seed-moderation-lists-once | `5630d92` | fix: seed the default moderation lists once, in a golem15.demo migration |
| 3A | IN-03 check-local-jwt-out-of-argv | `55620dc` | test: check-local passes member JWTs to curl through a config on stdin |
| 3B | IN-06 demo-nginx-livekit-rtc-only | `6c52514` | fix(deploy): nginx proxies only LiveKit's /rtc paths |
| 3C | IN-08 demo-vapid-runbook-pipefail | `054cda5` | docs: the VAPID step runs with pipefail and appends the keys only when both exist |
None of the commits has a co-author tag (`git log fbfdcf6..HEAD | grep -ci co-authored` returns 0).
## Task 1: members endpoint is read-only (IN-09)
- New `plugins/demo/classes/members.go`: `OtherMembers(ctx, db, exceptID)` selects id, name, surname and handle with `users.id <> ?` plus the unexported `memberWhere`, ordered by id. It never writes. `chat_context.go` is not touched.
- `chatMembers` makes one `OtherMembers` call. The `MemberIDs` id list and the `AssignHandle` call inside the GET are gone, along with the now-unused user plugin imports. The response shape is unchanged.
- New demo:seed step `member handles`, placed right after `chat personas`, runs `AssignHandle` for every user whose handle is NULL or blank. It reports `updated` when it assigned at least one handle and `exists` otherwise. The SeedCommand description mentions it.
- README: the members row, the Seed list and the Testing paragraph now mention `TestDemoChatMembersManyMembers`.
**RED evidence (before the implementation):**
- `TestDemoChatMembers`: "the member without a handle is listed with \"member\", want empty"; "the GET wrote handles: 1 of the caller and the other member are still NULL, want 2"
- `TestDemoChatMembersManyMembers`: `members = 500 {"error":true,"message":"Internal server error"}` (bind parameter limit)
- `TestSeedChat` / `TestSeedChatWithoutVisitor`: "demo:seed printed no step \"member handles\""
**GREEN:** these tests pass, and so do TestDemoDMs, TestDemoChatBootstrap, vet and the short tests.
## Task 2: moderation lists seeded once (IN-05)
- New `plugins/demo/updates/updates.go` holds `defaultBannedWords` and `defaultBannedLinks` (moved here, same values) and the migration `202611050001_seed_golem15_moderation_lists`. Its UPDATE statement is the same one the seed step ran, and its Rollback is a no-op. `All()` returns it.
- `plugin.go` asserts `pact.HasMigrations` and adds `Migrations()`, which returns `updates.All()`. The package doc now says the plugin has one migration.
- `seedModerationLists` and the `moderation lists` step are removed, and the description no longer mentions them. No non-comment line in the console package refers to `golem15_moderation_settings` any more.
- `go.mod`: I moved gormigrate v2.1.7 by hand into the direct require block. I did not run `go mod tidy`, and go.sum is unchanged.
- I updated the `check-moderation.sh` header comment and the README's Seed list and Moderation paragraph.
**RED evidence:** `TestSeedChat` failed with "the migrated database has no default moderation lists" and `relation "summer_migrations_golem15_demo" does not exist`.
**GREEN:** TestSeedChat passes, including the new check that lists emptied to `''` and to `NULL` stay empty across two demo:seed runs. TestSeedChatSmoke (the lists are filled by the migration, and the settings fingerprint survives demo:reset), TestDemoModeration, TestResetKeepsPlans, TestBootAllPlugins and TestGoModRequiresTags also pass.
## Task 3
**A, check-local (IN-03).** The new signature is `request [--jwt JWT] METHOD URL [curl args...]`. With a JWT, the function pipes `printf 'header = "Authorization: Bearer %s"\n'` into `curl -K -`. Without one, it behaves as before. All 27 authenticated calls (15 literal, 12 through the `auth` locals) now use `--jwt`, and the `auth` locals are gone. The visitor login JSON is written to `$TMP/login.json` and sent with `-d @file`. Only one non-comment `Authorization` line remains.
- I checked request() against a throwaway local echo server. The header arrives with `--jwt`, there is no header without it or with an empty JWT, `-d @file` bodies arrive, and an unreachable URL gives the same fallback as before. The local stack was stopped (`scripts/dev.sh status`), so I did not do the optional full `check-local.sh` run.
**B, LiveKit /rtc only (IN-06).** The vhost now has `location ^~ /livekit/rtc`, which proxies to `http://127.0.0.1:7890/rtc` with all the WebSocket and timeout directives, and `location ^~ /livekit/`, which only does `return 404;`.
- check-deploy.sh enforces the /rtc block and its directives, a 404 block with no proxy_pass, and exactly one proxy_pass to the LiveKit port. It prints `check-deploy: ok`. As a negative check, I temporarily put the catch-all proxy back, and it produced three separate problem lines and failed. Then I restored the file.
- check-live.sh gains a probe: `POST /livekit/twirp/livekit.RoomService/ListRooms` must return 404.
- DEPLOY.md: I updated the overview tree, the 7890 port row, the chat-family list, the first-release note and the check-live description. I also added a note, **"Pending on rome: the `/livekit/rtc`-only vhost"**, after the "When `deploy/nginx/demo.summercms.io.conf` changed" procedure. README lines 280 and 282 are updated.
**C, VAPID step (IN-08).** The step now uses `set -eo pipefail`. The pair is generated into `pair`, and `jq -r '.x // empty'` fills `pub` and `priv`. The step exits with "herald:vapid-generate returned no key pair" unless both are non-empty. It then appends all three lines with one `printf | sudo tee -a`, and the final `grep -c` check (expects 3) is kept. A sentence below the block explains that a failed generation writes nothing.
**VAPID dry-run** (scratchpad, the remote script extracted from DEPLOY.md, a `sudo` shim that drops `-u USER` and runs `env "$@"`, stub `bin/demo.new`). Because a hook blocks reading files named `.env`, the copy under test wrote to `envfile` instead of `.en[v]`. The logic is otherwise identical.
| Case | Exit | Output | Env file |
|------|------|--------|----------|
| stub prints a JSON pair | 0 | `3` | exactly 3 lines appended (PUBLIC_KEY, PRIVATE_KEY, SUBJECT=mailto:) |
| stub fails (exit 3, partial JSON) | 3 | none | byte-identical |
| JSON without private_key | 1 | "herald:vapid-generate returned no key pair" | byte-identical |
| non-JSON output | 5 (jq) | jq parse error | byte-identical |
| second run after success | 1 | "VAPID keys are already in .env" | byte-identical |
| empty address | 1 | "DEMO_ADMIN_EMAIL is empty" | unchanged |
No case printed a key.
## Verification
- At each commit, gofmt is clean, `go vet ./...` passes and `go test -short ./...` is green.
- Final `go test -count=1 ./...` with Docker: every package is ok, root package 51s.
- `scripts/check-deploy.sh` prints `check-deploy: ok`. `shellcheck -S error` is clean on check-local.sh, check-live.sh, check-deploy.sh and check-moderation.sh.
- `scripts/check-pins.sh` reports only the chatchannels (`a042536`) and chatvideo (`11a9607`) submodules ahead of their go.mod pins. That was accepted up front and is not addressed here.
- The sm-demo-app working tree was clean after the last commit.
## Deviations from Plan
**1. [Rule 3 - Blocking] VAPID dry-run target file renamed.** A PreToolUse secret-read guard blocks any Bash command that references a path matching `.env`. The extracted copy under test therefore replaced `.en[v]` with `envfile`. The committed DEPLOY.md text is unchanged by this.
Otherwise the plan was executed as written.
## Follow-ups
- **The `/livekit/rtc`-only vhost is waiting for a go-ahead to install on rome.** It is committed only. Until it is installed, `scripts/check-live.sh https://demo.summercms.io` will report the RoomService probe (LiveKit answers 401, not 404). The install steps are in DEPLOY.md, "When `deploy/nginx/demo.summercms.io.conf` changed".
- The orchestrator's closing docs step still has to do these: move the five todos (demo-chat-members-read-only, demo-seed-moderation-lists-once, check-local-jwt-out-of-argv, demo-nginx-livekit-rtc-only, demo-vapid-runbook-pipefail) to done with a "## Closed" note, and sync g15office.
- On rome's existing database, the next `summer migrate` records the new golem15.demo migration. Since the lists there are already seeded, the UPDATE changes nothing.
- Pre-existing, not changed: check-local.sh's `request` prints `000000` for an unreachable URL (curl's own `000` plus the `|| echo 000` fallback). Callers compare against specific codes, so this is harmless.
## Known Stubs
None.
## Threat Flags
None. No new surface: the changes shrink the public LiveKit surface and remove a write from a GET.
## Self-Check: PASSED
- FOUND: plugins/demo/classes/members.go, plugins/demo/updates/updates.go
- FOUND commits: 9595606, 5630d92, 55620dc, 6c52514, 054cda5 (`git rev-list --count fbfdcf6..HEAD` = 5)

View File

@@ -0,0 +1,233 @@
---
phase: quick-261106-d20
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- modules/lagoon/attach/inline.go
- modules/lagoon/attach/store.go
- modules/lagoon/attach/inline_test.go
- modules/lagoon/README.md
- docs/database/attachments.md
- docs/services/storage.md
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collection_media_controller.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/store_upload_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_files.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_files_test.go
autonomous: true
requirements:
- todo:2026-10-28-fonoteka-uploads-use-disk-extension
- todo:2026-11-06-blob-writers-set-content-disposition
estimate:
tokens: 45000
raw_tokens: 45000
tasks: 2
confidence: low
must_haves:
truths:
- "The framework exports attach.UploadWriterOptions(contentType string) *blob.WriterOptions in modules/lagoon/attach/inline.go. It returns a new value on every call with ContentType set to contentType verbatim and ContentDisposition set to the bare value attachment exactly when attach.IsInlineContentType(contentType) is false; every other field is zero. JPEG, PNG, GIF, WebP and AVIF (parameters and case ignored) get no disposition; text/html, text/xml, image/svg+xml, application/pdf, text/plain and the empty string get attachment"
- "attach.Store keeps writing through the same options: writeBlob in store.go calls attach.UploadWriterOptions, the unexported helper no longer exists, and TestWriteBlobDisposition passes without edits to its assertions. The change is additive: no other exported identifier in modules/lagoon is added, removed or changes its signature"
- "modules/lagoon/README.md (Features bullet and API table row) and docs/database/attachments.md plus docs/services/storage.md (deploy note) describe attach.UploadWriterOptions for code that writes uploads without attach.Store, in the same commit as the code. go vet ./..., go test -short ./..., go test ./cmd/summer -run TestDocsTree and go run ./cmd/summer docs:build --check are green in summercms.go at that commit"
- "In fonoteka.go, storeUpload (collection media) and classes.StorePublicFile (album photos, covers) sniff the content type before naming the file, name the disk file as 22 lowercase hex characters plus a dot plus attach.DiskExtension(contentType, filepath.Ext(clientName)), keep the client name only in file_name, and write the blob with attach.UploadWriterOptions(contentType)"
- "Regression, per call site: a PNG body with a script payload uploaded as x.html is stored as <22 hex>.png with file_name x.html, content_type image/png and no stored Content-Disposition; an HTML body uploaded as x.html and an XML-prologue SVG body uploaded as logo.svg are stored with a .txt disk name and Content-Disposition: attachment; an upload without an extension still gets one (.png for a PNG, .txt for plain text). The blob bytes read back equal the input"
- "Existing fonoteka.go behaviour is otherwise unchanged: content_type stays the sniffed base type, file_size and is_public are as before, and the Docker-backed photo upload smoke tests (Cover.PNG with a PNG body stays .png) still pass. fonoteka.go's go.mod files are not edited (require stays v0.4.4; the new export resolves through the replace to ../summercms.go)"
- "Two commits, one per repository, no co-author tag, files staged by explicit path, nothing pushed or tagged; sm-user-plugin and sm-feedback-plugin are untouched"
artifacts:
- path: modules/lagoon/attach/inline.go
provides: "Exported UploadWriterOptions with a Go doc comment for callers that bypass Store"
contains: "func UploadWriterOptions(contentType string) *blob.WriterOptions"
- path: modules/lagoon/attach/store.go
provides: "writeBlob calls UploadWriterOptions"
contains: "UploadWriterOptions(contentType)"
- path: modules/lagoon/attach/inline_test.go
provides: "TestUploadWriterOptions table test, Docker-free"
contains: "TestUploadWriterOptions"
- path: modules/lagoon/README.md
provides: "Features bullet mention and an attach.UploadWriterOptions API table row"
contains: "attach.UploadWriterOptions"
- path: docs/database/attachments.md
provides: "Storing an upload: paragraph on writing uploads without attach.Store"
contains: "attach.UploadWriterOptions"
- path: docs/services/storage.md
provides: "Deploy note: code that bypasses attach.Store uses attach.DiskExtension and attach.UploadWriterOptions"
contains: "attach.UploadWriterOptions"
- path: ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collection_media_controller.go
provides: "storeUpload names by sniffed type and writes with the upload writer options"
contains: "attach.DiskExtension("
- path: ../fonoteka.go/plugins/golem15/fonoteka/classes/album_files.go
provides: "StorePublicFile names by sniffed type and writes with the upload writer options"
contains: "attach.UploadWriterOptions("
- path: ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/store_upload_test.go
provides: "TestStoreUploadNamesBySniffedType on memblob, Docker-free"
contains: "TestStoreUploadNamesBySniffedType"
- path: ../fonoteka.go/plugins/golem15/fonoteka/classes/album_files_test.go
provides: "TestStorePublicFileNamesBySniffedType on memblob, Docker-free"
contains: "TestStorePublicFileNamesBySniffedType"
key_links:
- from: modules/lagoon/attach/store.go writeBlob
to: modules/lagoon/attach/inline.go UploadWriterOptions
via: "Store and external writers share one definition of the stored type and disposition"
pattern: "bucket\\.NewWriter\\(writeCtx, key, UploadWriterOptions\\(contentType\\)\\)"
- from: ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collection_media_controller.go storeUpload
to: modules/lagoon/attach (DiskExtension, UploadWriterOptions)
via: "fonoteka plugin go.mod replace => ../../../../summercms.go (go.work workspace)"
pattern: "attach\\.UploadWriterOptions\\(contentType\\)"
- from: ../fonoteka.go/plugins/golem15/fonoteka/classes/album_files.go StorePublicFile
to: modules/lagoon/attach (DiskExtension, UploadWriterOptions)
via: "StoreAlbumPhoto (album photo upload, cover importer) goes through StorePublicFile"
pattern: "attach\\.DiskExtension\\(contentType, filepath\\.Ext\\(filename\\)\\)"
---
<objective>
Close two linked upload-security todos in one quick task across two repositories.
1. Framework (summercms.go): export the blob writer-options helper of `attach.Store` as `attach.UploadWriterOptions(contentType string) *blob.WriterOptions`, so code that writes an upload to the bucket itself stores the same Content-Type and `Content-Disposition: attachment` for non-inline types (todo 2026-11-06-blob-writers-set-content-disposition, g15office ifdz5o0m). Additive change in a core module, documented in the same commit.
2. fonoteka.go (sibling repo `../fonoteka.go`, its own git repository): its two writers that bypass `attach.Store`, `storeUpload` and `classes.StorePublicFile`, still name the disk file after the client extension (a PNG polyglot uploaded as `x.html` is stored as `<hex>.html` and rendered as HTML by a static server: stored XSS) and store no disposition. Name the file with `attach.DiskExtension` from the sniffed type and write with `attach.UploadWriterOptions` (todo 2026-10-28-fonoteka-uploads-use-disk-extension, g15office cf81chjk).
Purpose: a stored HTML/SVG/polyglot upload never renders on a trusted origin, whether served by a static server (extension-driven) or by a public bucket, CDN or `attach.StaticHandler` (stored-header-driven).
Output: one framework commit in summercms.go, one fix commit in fonoteka.go, Docker-free regression tests in both.
Out of scope (locked by the user): sm-user-plugin avatar and sm-feedback-plugin screenshot writers (image-only inputs, follow-up), bumping fonoteka.go's `require` of the framework, any tag or push. Existing blobs are not rewritten.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@CLAUDE.md
@../fonoteka.go/CLAUDE.md
@.planning/todos/pending/2026-10-28-fonoteka-uploads-use-disk-extension.md
@.planning/todos/pending/2026-11-06-blob-writers-set-content-disposition.md
@modules/lagoon/attach/inline.go
@modules/lagoon/attach/diskext.go
Interfaces the executor needs (already in the codebase):
- `attach.IsInlineContentType(contentType string) bool` (inline.go): true only for image/jpeg, image/png, image/gif, image/webp, image/avif, parameters and case ignored.
- Unexported `attachmentDisposition = "attachment"` (inline.go) and unexported `uploadWriterOptions(contentType string) *blob.WriterOptions` (inline.go, lines 36-44), used once by `writeBlob` in store.go (line 208).
- `attach.DiskExtension(contentType, clientExt string) string` (diskext.go): extension without the dot, never empty. Strips a leading dot and lowercases clientExt. Specific sniffed type: client ext if it belongs to the type, else canonical (PNG named .html becomes png). Generic sniff: client ext unless script-capable or not matching `^[a-z0-9]{1,10}$`, then txt for text/* and bin otherwise.
- `http.DetectContentType` sniffs `<html>...` as text/html, a body starting `<?xml` as text/xml, the PNG signature `\x89PNG\r\n\x1a\n` as image/png, short ASCII as text/plain; both fonoteka call sites already reduce it to the base type with `mime.ParseMediaType`.
- `lagoon.UploadedFile{Filename string; Size int64; Header textproto.MIMEHeader; Open func() (io.ReadCloser, error)}` (modules/lagoon/validate_request.go).
- fonoteka.go is a go.work workspace; the root `./...` does NOT include the plugin module `plugins/golem15/fonoteka` (its own go.mod, `require git.golem15.com/golem15/summercms v0.0.0` + `replace => ../../../../summercms.go`). Run vet/tests in both the root and the plugin directory. The `api` and `classes` test packages have a TestMain that starts Postgres only when not `-short`, so the new memblob tests run under `-short`.
- Existing Docker-backed smoke tests in the plugin root package (`albums_smoke_test.go` TestAlbumPhotoUpload, `collections_smoke_test.go`) upload `Cover.PNG` with a real PNG body and assert the disk name ends in `.png`; that must keep passing.
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Export attach.UploadWriterOptions with a unit test and docs (summercms.go commit)</name>
<files>modules/lagoon/attach/inline.go, modules/lagoon/attach/store.go, modules/lagoon/attach/inline_test.go, modules/lagoon/README.md, docs/database/attachments.md, docs/services/storage.md</files>
<behavior>
- UploadWriterOptions("image/png"), ("image/jpeg"), ("image/avif") and ("IMAGE/WEBP; charset=binary") return ContentType equal to the input verbatim and an empty ContentDisposition.
- UploadWriterOptions("text/html"), ("text/xml"), ("image/svg+xml"), ("application/pdf"), ("text/plain"), ("application/octet-stream") and ("") return ContentType equal to the input and ContentDisposition "attachment" (bare, no filename parameter).
- Each result is reflect.DeepEqual to a blob.WriterOptions literal with only those two fields set (CacheControl, ContentEncoding, ContentLanguage, ContentMD5, Metadata, BeforeWrite all zero), and two calls return distinct pointers (mutating one result does not change the next).
- The existing TestIsInlineContentType and TestWriteBlobDisposition pass unchanged.
</behavior>
<action>
Before editing, run git status --short on the six paths in this task and stop if another session has uncommitted edits there (CLAUDE.md concurrency rule).
In modules/lagoon/attach/inline.go rename the unexported helper uploadWriterOptions to the exported UploadWriterOptions with the unchanged signature (contentType string) *blob.WriterOptions and unchanged body (new WriterOptions with ContentType set, ContentDisposition set to the attachmentDisposition constant when IsInlineContentType is false). Rewrite its doc comment as exported Go doc starting with the identifier name: it returns the blob writer options of an upload, the content type plus Content-Disposition: attachment (no filename) for every type IsInlineContentType does not report as inline; Store writes with these options; code that writes an upload to the bucket itself, with NewWriter or WriteAll, passes the sniffed type (the value stored in content_type) and names the file with DiskExtension, so its blob is served like one Store wrote; each call returns a new value the caller may extend. Do not change IsInlineContentType, the inline type set or the constant.
In modules/lagoon/attach/store.go change the single call in writeBlob to UploadWriterOptions(contentType). If the Store or writeBlob doc comments name the old helper, update them; no behaviour change.
In modules/lagoon/attach/inline_test.go add TestUploadWriterOptions covering the behavior list (table test, reflect.DeepEqual against the expected blob.WriterOptions value, plus the distinct-pointer check). Write the test first and confirm it fails to compile before the rename (RED), then rename (GREEN).
Docs in the same commit (CLAUDE.md documentation rule, exported API change in a core module):
- modules/lagoon/README.md: in the Attachments Features bullet (line 44), after the Store disposition clause, add that attach.UploadWriterOptions gives code which writes an upload to the bucket without attach.Store the same content type and disposition. Add an API table row for attach.UploadWriterOptions directly after the attach.IsInlineContentType row: the blob writer options of an upload, the content type plus Content-Disposition: attachment for a type attach.IsInlineContentType does not report as inline; attach.Store writes with the same options; pair it with attach.DiskExtension when writing uploads yourself.
- docs/database/attachments.md, section "Storing an upload": after the paragraph that starts with attach.Store writes every file whose type (line 140), add a short paragraph: code that writes an upload to the bucket itself instead of calling attach.Store gets the same protection by sniffing the type first, naming the file with attach.DiskExtension(contentType, clientExt) and passing attach.UploadWriterOptions(contentType) to the bucket writer (NewWriter or WriteAll); file_name keeps the client's name. Do not touch the src= code samples in this page (they are synced from example_test.go, which this task does not change).
- docs/services/storage.md, "Deploy note": in the paragraph that starts Objects written before this change, or by code that bypasses attach.Store, carry no disposition, say that such code should write with attach.UploadWriterOptions (and name files with attach.DiskExtension), and that objects written earlier still need the disposition set on them or the CDN rule.
Keep the prose style of the surrounding text (plain sentences, no planning IDs). Every `pkg.Ident` code span must name an existing identifier; the checker only resolves framework packages, so `blob.WriterOptions` spans are fine.
Commit in summercms.go, staging exactly these six paths with git add followed by the explicit paths (never git add -A; leave .planning/STATE.md, .planning/state.json, .planning/G15OFFICE.md and other sessions' files alone). Message: feat(lagoon/attach): export UploadWriterOptions for blob writers that bypass Store (quick-261106-d20). No co-author tag (user's global CLAUDE.md overrides any attribution reminder).
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./... && go test -short ./modules/lagoon/attach -run 'TestUploadWriterOptions|TestIsInlineContentType|TestWriteBlobDisposition' -v && go test -short ./... && go test ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check && grep -c '^func UploadWriterOptions(contentType string) \*blob.WriterOptions' modules/lagoon/attach/inline.go && grep -c 'attach.UploadWriterOptions' modules/lagoon/README.md docs/database/attachments.md docs/services/storage.md</automated>
</verify>
<done>attach.UploadWriterOptions is exported with Go doc, Store uses it, TestUploadWriterOptions passes alongside the unchanged existing tests, the README and both docs pages name it, all docs checks pass, and one summercms.go commit contains exactly the six paths.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: fonoteka.go storeUpload and StorePublicFile name by sniffed type and store the disposition, with regression tests (fonoteka.go commit)</name>
<files>../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collection_media_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/store_upload_test.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/album_files.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/album_files_test.go</files>
<precondition>Task 1's attach.UploadWriterOptions exists in ../summercms.go/modules/lagoon/attach/inline.go (fonoteka.go resolves the framework through its replace directive).</precondition>
<behavior>
Same table for both writers, on memblob.OpenBucket(nil):
- PNG signature followed by a script payload, client name x.html: DiskName matches ^[0-9a-f]{22}\.png$, FileName x.html, ContentType image/png, blob Attributes ContentType image/png and empty ContentDisposition.
- JPEG bytes (or the JPEG magic prefix), client name photo.jpg: DiskName ends .jpg, empty ContentDisposition.
- An HTML body with a script tag, client name x.html: DiskName ends .txt, ContentType text/html, ContentDisposition attachment.
- A body starting with an XML prologue followed by an svg element with a script, client name logo.svg: DiskName ends .txt, ContentType text/xml, ContentDisposition attachment.
- A PNG with client name scan (no extension) ends .png; plain ASCII text with client name README ends .txt with attachment.
- For every case the bytes read back from attach.BlobKey(DiskName) equal the input, FileSize equals the input length and IsPublic is true. StorePublicFile with a nil bucket still returns an error.
</behavior>
<action>
Before editing, run git -C ../fonoteka.go status --short and stop if any of the four paths has uncommitted edits from another session.
Write both test files first (RED: the HTML and polyglot cases fail against the current code, which keeps the client extension and stores no disposition).
- controllers/api/store_upload_test.go, package api: TestStoreUploadNamesBySniffedType calls storeUpload(t.Context(), bucket, lagoon.UploadedFile{Filename, Size, Open returning io.NopCloser over a bytes.Reader}) for each behavior case and checks the returned *attach.File and bucket.Attributes on attach.BlobKey(f.DiskName).
- classes/album_files_test.go, package classes: TestStorePublicFileNamesBySniffedType calls StorePublicFile(t.Context(), bucket, name, data) with the same cases, plus the nil-bucket error case.
Both use only memblob, no DB, so they run under -short. Build test bodies inline (PNG signature bytes, a JPEG magic prefix or the existing testJPEG helper in classes, HTML/SVG/plain strings).
Then fix both writers (per todos cf81chjk and ifdz5o0m):
- controllers/api/collection_media_controller.go storeUpload: after reading data, sniff contentType first (keep the existing http.DetectContentType plus mime.ParseMediaType base reduction), then build diskName as the 22-character hex of 11 random bytes plus a dot plus attach.DiskExtension(contentType, filepath.Ext(upload.Filename)). The extension is never empty now, so drop the conditional append. Pass attach.UploadWriterOptions(contentType) to bucket.NewWriter instead of the inline blob.WriterOptions literal. FileName stays upload.Filename, ContentType stays the sniffed base type.
- classes/album_files.go StorePublicFile: same ordering and naming with attach.DiskExtension(contentType, filepath.Ext(filename)), and pass attach.UploadWriterOptions(contentType) to bucket.WriteAll.
- Update both doc comments: 22 hex characters plus the extension attach.DiskExtension derives from the sniffed type (the client name is kept only in file_name, so a PNG named x.html is stored as .png), and a type that is not an inline raster image is stored with Content-Disposition: attachment. Note in StorePublicFile's comment that this deliberately differs from WinterCMS File::fromData, which keeps the client extension.
- Remove imports that become unused (strings, and blob if only the literal used it; StorePublicFile still takes *blob.Bucket so blob likely stays). Let go vet decide.
Do not change response payloads, the system_files columns written, size limits, the image guard or any other call site. Do not touch go.mod, go.sum, go.work or the sm-user-plugin / sm-feedback-plugin code.
Run the Docker-backed photo upload smoke tests in the plugin root package as well (go test . -run 'Photo' in plugins/golem15/fonoteka without -short; if the Docker socket is denied, run through sudo -u golem per summercms.go CLAUDE.md). If Docker is unavailable entirely, record that in the summary instead of failing the task.
Commit in fonoteka.go only, staging exactly the four paths with git -C ../fonoteka.go add followed by the explicit paths (never stage submodule files). Message: fix(fonoteka): name uploads after the sniffed type and store non-inline types as attachments (quick-261106-d20). No co-author tag.
In the SUMMARY note that fonoteka.go's root go.mod still requires git.golem15.com/golem15/summercms v0.4.4 and resolves attach.UploadWriterOptions only through the replace to ../summercms.go; the require must be raised to the next framework tag that contains the export once one is cut. Also list the out-of-scope follow-up (sm-user-plugin avatar, sm-feedback-plugin screenshot writers) as open.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/fonoteka && go vet ./... && go test -short ./controllers/api ./classes -run 'TestStoreUploadNamesBySniffedType|TestStorePublicFileNamesBySniffedType' -v && go test -short ./... && cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && go vet ./... && go test -short ./... && grep -c 'attach.UploadWriterOptions(contentType)' plugins/golem15/fonoteka/controllers/api/collection_media_controller.go plugins/golem15/fonoteka/classes/album_files.go && git diff --quiet HEAD -- go.mod plugins/golem15/fonoteka/go.mod</automated>
</verify>
<done>Both fonoteka.go writers name files from the sniffed type and store attachment disposition for non-inline types; both regression tests pass under -short; go vet and go test -short are green in the fonoteka.go root and plugin module; the photo upload smoke tests pass (or Docker absence is recorded); one fonoteka.go commit contains exactly the four paths; go.mod files are unchanged.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client upload → fonoteka.go storeUpload / StorePublicFile | The client controls the file name and the bytes; only the sniffed type is trustworthy |
| uploads directory → static web server | nginx and similar pick the Content-Type from the disk extension, so an extension taken from the client decides how the bytes render |
| bucket object → public bucket / CDN / attach.StaticHandler | These send the headers stored with the object; a missing disposition lets HTML or SVG render inline on that origin |
| framework core module → consuming apps and plugins | lagoon/attach is used by many applications; changes must be additive |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-q261106d20-01 | Elevation of privilege | fonoteka.go storeUpload and StorePublicFile: PNG polyglot uploaded as x.html stored as <hex>.html and rendered as HTML by a static server (stored XSS) | high | mitigate | Task 2: disk name from attach.DiskExtension(sniffed, client ext); regression case asserts ^[0-9a-f]{22}\.png$ for the x.html PNG in both writers |
| T-q261106d20-02 | Elevation of privilege | HTML or SVG blob written by fonoteka.go served inline by a public bucket, CDN or attach.StaticHandler(Public) | medium | mitigate | Task 1 exports attach.UploadWriterOptions; Task 2 writes with it; regression cases assert Content-Disposition attachment for text/html and the text/xml SVG, and none for PNG/JPEG |
| T-q261106d20-03 | Information disclosure / Tampering | Client file name reaching a response header through the stored disposition | low | mitigate | The disposition is the bare attachment constant with no filename parameter; the client name stays only in the file_name column (unchanged). TestUploadWriterOptions asserts the exact value |
| T-q261106d20-04 | Tampering | Breaking change in the shared lagoon/attach module | medium | mitigate | Additive rename of an unexported helper to an exported one with the same body; Store behaviour unchanged, TestWriteBlobDisposition and TestIsInlineContentType unedited and green; go test -short ./... in summercms.go |
| T-q261106d20-05 | Elevation of privilege | Blobs already in fonoteka.go buckets with client extensions and no disposition, and the sm-user-plugin avatar / sm-feedback-plugin screenshot writers | low | accept | Out of scope by user decision: existing objects are covered by the storage deploy note (set the disposition or a CDN rule); the two plugin writers accept images only (defence-in-depth follow-up) |
| T-q261106d20-SC | Tampering | npm/pip/cargo/go module installs | low | accept | No new dependencies: gocloud.dev/blob is already required in both modules; no package-manager install task |
</threat_model>
<verification>
- summercms.go: go vet ./..., go test -short ./..., go test ./cmd/summer -run TestDocsTree and go run ./cmd/summer docs:build --check are green at the Task 1 commit; that commit contains exactly the six framework paths.
- fonoteka.go: go vet ./... and go test -short ./... green in both the repository root and plugins/golem15/fonoteka; the Docker-backed photo upload smoke tests pass (or Docker absence is recorded); the commit contains exactly the four paths and no go.mod/go.sum/go.work change.
- No co-author tag in either commit; nothing pushed or tagged; STATE.md, state.json, G15OFFICE.md and other sessions' files untouched by these commits.
</verification>
<success_criteria>
- A PNG uploaded to fonoteka.go as x.html is stored as <22 hex>.png through both storeUpload and StorePublicFile.
- An HTML or SVG upload to fonoteka.go is stored under a .txt disk name with Content-Disposition: attachment.
- attach.UploadWriterOptions is an exported, documented, unit-tested framework helper, and attach.Store's stored headers are unchanged.
- The summary records that fonoteka.go's framework require (v0.4.4) must be raised to the next framework tag before building without the replace, and that the sm-user-plugin / sm-feedback-plugin writers remain a follow-up.
</success_criteria>
<output>
Create `.planning/quick/261106-d20-fix-stored-xss-in-fonoteka-go-uploads-an/261106-d20-SUMMARY.md` when done
</output>

View File

@@ -0,0 +1,128 @@
---
phase: quick-261106-d20
plan: 01
subsystem: lagoon/attach, fonoteka.go uploads
tags: [security, xss, uploads, attachments, content-disposition]
status: complete
requires:
- attach.DiskExtension (existing)
- attach.IsInlineContentType (existing)
provides:
- attach.UploadWriterOptions (exported framework helper)
- fonoteka.go storeUpload / classes.StorePublicFile named by sniffed type with stored disposition
affects:
- ../fonoteka.go (collection media upload, album photo upload, cover importer)
tech-stack:
added: []
patterns:
- "Writers that bypass attach.Store: sniff type, name with attach.DiskExtension, write with attach.UploadWriterOptions"
key-files:
created:
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/store_upload_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_files_test.go
modified:
- modules/lagoon/attach/inline.go
- modules/lagoon/attach/store.go
- modules/lagoon/attach/inline_test.go
- modules/lagoon/README.md
- docs/database/attachments.md
- docs/services/storage.md
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collection_media_controller.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_files.go
decisions:
- "attach.UploadWriterOptions is the exported form of the former unexported uploadWriterOptions (same body); Store behaviour unchanged"
- "fonoteka.go StorePublicFile deliberately diverges from WinterCMS File::fromData: the disk extension follows the sniffed type, not the client name"
metrics:
duration: ~20m
completed: 2026-11-06
actuals:
tokens: 9000
tasks: 2
commits: 1
plan_head_before: 9fa740e8b79362cafcfc5fd1cbc6df03a51172e7
plan_head_after: 903859a1310e754ab96fe8226dcbc5622fec6803
---
# Quick 261106-d20: Fix stored XSS in fonoteka.go uploads and export attach.UploadWriterOptions Summary
The framework now exports `attach.UploadWriterOptions`, the stored Content-Type plus `Content-Disposition: attachment` for non-inline types. fonoteka.go's two writers that bypass `attach.Store` (`storeUpload`, `classes.StorePublicFile`) now name disk files with `attach.DiskExtension` from the sniffed type and write with those options. A PNG polyglot uploaded as `x.html` is stored as `<hex>.png`, and HTML/SVG bodies are stored as `.txt` with an attachment disposition.
## Commits
| Repo | Hash | Message |
|------|------|---------|
| summercms.go | 903859a | feat(lagoon/attach): export UploadWriterOptions for blob writers that bypass Store (quick-261106-d20) |
| fonoteka.go | 07e820d | fix(fonoteka): name uploads after the sniffed type and store non-inline types as attachments (quick-261106-d20) |
The ledger `commits: 1` counts summercms.go only (plan_head_before/after are summercms.go hashes). fonoteka.go moved from 0e1114e to 07e820d, which is one commit. Neither commit has a co-author tag. Files were staged by explicit path, and nothing was pushed or tagged.
## Task 1: framework (summercms.go)
- `modules/lagoon/attach/inline.go`: renamed `uploadWriterOptions` to `UploadWriterOptions` with an exported Go doc comment. The body is unchanged.
- `modules/lagoon/attach/store.go`: `writeBlob` calls `UploadWriterOptions(contentType)`.
- `modules/lagoon/attach/inline_test.go`: added `TestUploadWriterOptions`. It is a table test using `reflect.DeepEqual` against a `blob.WriterOptions` with only ContentType/ContentDisposition set, plus checks for distinct pointers and that mutating one result does not affect the next.
- Docs, in the same commit:
- `modules/lagoon/README.md`: a Features clause and an API table row after `attach.IsInlineContentType`.
- `docs/database/attachments.md`: a paragraph in "Storing an upload".
- `docs/services/storage.md`: the Deploy note now says bypassing code should use `attach.UploadWriterOptions` and `attach.DiskExtension`.
- TDD: I wrote the test first, and it failed to compile (`undefined: UploadWriterOptions`). After the rename it passed.
- Verification at 903859a, all green:
- `go vet ./...`
- `go test -short ./...`
- `TestUploadWriterOptions`, `TestIsInlineContentType` and `TestWriteBlobDisposition`, the last two with no edits
- `go test ./cmd/summer -run TestDocsTree`
- `go run ./cmd/summer docs:build --check` ("no problems found")
## Task 2: fonoteka.go
- `storeUpload` and `StorePublicFile` sniff the type first, then name the file as 22 hex characters plus `.` plus `attach.DiskExtension(contentType, filepath.Ext(<client name>))`, and write with `attach.UploadWriterOptions(contentType)`.
- What stays the same:
- `file_name` keeps the client name.
- `content_type` is still the sniffed base type.
- `file_size` and `is_public` are as before.
- I updated both doc comments. StorePublicFile's comment notes the deliberate divergence from `File::fromData`. I dropped the unused `strings` imports.
- New memblob regression tests, which run Docker-free under `-short`: `TestStoreUploadNamesBySniffedType` (package api) and `TestStorePublicFileNamesBySniffedType` (package classes, plus the nil-bucket error case).
- Test cases:
- PNG polyglot as `x.html` is stored as `.png` with no disposition.
- JPEG as `photo.jpg` is stored as `.jpg` with no disposition.
- HTML as `x.html` is stored as `.txt` with attachment.
- `<?xml` SVG as `logo.svg` is stored as `.txt`, with `text/xml` and attachment.
- PNG named `scan` is stored as `.png`.
- Plain text named `README` is stored as `.txt` with attachment.
- In every case the bytes read back are identical to the input.
- RED: before the fix, 5 of 6 cases failed in both tests (`.html`/`.svg`/no-extension disk names, missing disposition). GREEN: all of them pass after the fix.
- Verification:
- Plugin module (`plugins/golem15/fonoteka`): `go vet ./...` and `go test -short ./...` are green.
- Repo root: `go vet ./...` and `go test -short ./...` are green.
- `go.mod` and `plugins/golem15/fonoteka/go.mod` are unchanged.
- Docker-backed smoke tests: Docker was available and `sudo -u golem` was not needed. `go test . -run Photo` passed `TestAlbumPhotoUpload` and `TestCollectionPhotoUpload`, so Cover.PNG still ends in `.png`. Without `-short`, all subpackages passed: `classes`, `classes/auth`, `classes/csv`, `classes/discogs`, `console`, `controllers/api`, `middleware`, `models` and `updates`.
## go.mod note
fonoteka.go's root `go.mod` still has `require git.golem15.com/golem15/summercms v0.4.4`. It resolves `attach.UploadWriterOptions` only through `replace git.golem15.com/golem15/summercms => ../summercms.go`. Once a framework tag containing 903859a is cut, the require must be raised to that tag before fonoteka.go is built without the replace. No go.mod was edited in this task.
## Open follow-up (out of scope by user decision)
- The sm-user-plugin avatar writer and the sm-feedback-plugin screenshot writer still write blobs without `attach.Store`. Both accept images only. The follow-up is to switch them to `attach.DiskExtension` plus `attach.UploadWriterOptions` as defence in depth.
- Existing blobs in fonoteka.go buckets keep their client extensions and have no disposition. The storage deploy note covers this: set the disposition on the objects, or add a CDN rule.
## Deviations from Plan
None in the code: both tasks were executed as written.
## Deferred Issues (pre-existing, out of scope)
- In the full non-short run of the fonoteka.go plugin root package, 4 tests fail: `TestPhase8RedMCPMe`, `TestOAuthToolsMeRouteIsolation` (unexpected `GET /_user/api/v1/token/me` route from the sm-user-plugin bump), `TestMustChangePasswordLock` (login 401) and `TestPhase14Edges/prune-cutoff-90-days-then-one-second-older`. I checked that they are pre-existing: with the two writer files temporarily reverted to HEAD, the same 4 fail. None of them touches uploads.
- `classes/postgres_test.go` `testShort()` matches only `-test.short` exactly. Plain `go test -short` passes `-test.short=true`, so the classes package starts Postgres even under `-short`. CLAUDE.md warns about exactly this. The new test still passes, but it is not Docker-free in that package until `testShort` is fixed (`controllers/api` and `classes/auth` already handle `-test.short=true`).
## Threat Flags
None: no new network endpoints, auth paths or schema changes. T-q261106d20-01..04 are mitigated as planned, and T-05 is accepted as the follow-up above.
## Self-Check: PASSED
- FOUND: modules/lagoon/attach/inline.go (`func UploadWriterOptions(contentType string) *blob.WriterOptions`)
- FOUND: ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/store_upload_test.go
- FOUND: ../fonoteka.go/plugins/golem15/fonoteka/classes/album_files_test.go
- FOUND: summercms.go commit 903859a
- FOUND: fonoteka.go commit 07e820d

View File

@@ -0,0 +1,8 @@
---
quick_id: 261106-dv0
type: quick
---
# Plugin boot logs at Debug
Demote routine Info boot records in shared plugins to Debug through diary.For. Sweep all ../sm-*-plugin repos; keep warnings/errors. One commit per plugin repo, go vet + go test -short each. No tags, pushes or app pointer bumps.

View File

@@ -0,0 +1,12 @@
---
quick_id: 261106-dv0
status: complete
date: 2026-11-06
---
# Plugin boot logs at Debug — summary
- sm-paymentgateway-plugin 8a88b86: `paymentgateway: operators registered` at Debug (already through diary.For("paymentgateway")).
- sm-paymentgatewaysubscriptions-plugin 1df6a26: `registries collected` moves from slog.Default().Info to a new diary.For("paymentgatewaysubscriptions") logger at Debug; README Configuration names the log component.
- Sweep: no other shared plugin logs at Info in Register/Boot. Remaining Info calls are runtime job/webhook records (journal, seo, sitemap jobs; pgstripe webhooks and subscription sync) and console command output; left as is. sm-chatbots-plugin boot warning (deny-all BotAuthResolver) kept.
- go vet and go test -short green in both repos. Not tagged, not pushed, app pointers not bumped.

View File

@@ -0,0 +1,8 @@
---
quick_id: 261106-e5v
type: quick
---
# Purge token rename leftovers
Todo 2026-10-20-rename-site-cache-purge-env-in-consumers.md. Check what grzybyfunkcjonalne still lacks for the NUXT_CACHE_PURGE_TOKEN rename and finish it: layer pins, env templates and docs, deploy check, server rotation, plugin READMEs, deprecated names in the layers.

View File

@@ -0,0 +1,19 @@
---
quick_id: 261106-e5v
status: complete
date: 2026-11-06
---
# Purge token rename leftovers — summary
Already done before this task (found, not changed):
- vue-grzybyfunkcjonalne-app pins vue-journal-layer v0.8.1, vue-menu-layer v0.5.1, vue-faq-layer v0.5.1.
- sm-grzybyfunkcjonalne-app deploy templates, DEPLOY.md and README use NUXT_CACHE_PURGE_TOKEN (535d594), DEPLOY.md has the rotation recipe.
- Server (/srv/grzybyfunkcjonalne, counts only): nuxt/.env has 0 NUXT_SITE_CACHE_PURGE_TOKEN and 1 NUXT_CACHE_PURGE_TOKEN, go/.env 1 SUMMER_SITE_CACHE__PURGE_TOKEN; the UAT home page served on loopback has 0 cachePurgeToken.
- The three layers no longer read NUXT_SITE_CACHE_* in their sources (step 6).
Done here:
- sm-grzybyfunkcjonalne-app 4e2cf0c: scripts/check-deploy.sh refuses a NUXT_SITE_* key in deploy/nuxt.env.example, requires NUXT_CACHE_PURGE_TOKEN once and empty, and refuses a printf of a NUXT_SITE_* key in DEPLOY.md (demo/summercms.io guard). Passes; a planted old key fails it.
- sm-journal-plugin f61c90a, sm-menu-plugin 128a33d, sm-faq-plugin a53511b: README names NUXT_CACHE_PURGE_TOKEN.
Not pushed, not tagged, app pointers not bumped.

View File

@@ -0,0 +1,180 @@
---
phase: quick-261106-jhk
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- modules/wristband/authorize.go
- modules/wristband/authorize_test.go
- modules/wristband/mcp_scopes_test.go
- modules/wristband/oidc_flavour_test.go
- modules/wristband/README.md
- docs/services/oauth-server.md
autonomous: true
requirements:
- quick:261106-jhk-mcp-authorize-drops-oidc-identity-scopes
estimate:
tokens: 35000
raw_tokens: 35000
tasks: 2
confidence: low
must_haves:
truths:
- "On the MCP flavour, GET /oauth/mcp/authorize drops the exact, case-sensitive tokens openid, profile and email from the scope query value before the allow-list check (wristband.Server.Authorize -> parseAuthorizeScopes). The drop is unconditional: it happens even if a host lists one of the three in Options.ScopesSupported, so the MCP flavour never persists an identity scope on a pending row"
- "The request a hosted connector sends after reading the issuer's OIDC discovery document (response_type=code, scope=openid email read, S256 challenge, resource equal to Options.Resource, state, ui_locales=en-US) gets a 302 to <issuer>/connect?request=<id> with no error parameter, and the stored pending row has Scopes exactly [read], OfflineAccess false, the request's resource and state"
- "When only identity scopes are requested (openid profile, or openid alone) the existing empty-scope default applies and the pending row stores [read]. Only an empty list after dropping triggers the default; offline_access alone keeps its current behaviour"
- "Every other token outside the resolved allow-list is still refused with error=invalid_scope and error_description=scope contains an unsupported value, and no pending row is written: read bogus, openid bogus, read admin, OPENID read (case differs), and backend on DefaultOptions all stay refused. The client scope ceiling still runs after the drop"
- "Metadata scopes_supported is unchanged: identity scopes are not advertised (TestMCPScopesSmoke exact-slice assertions pass without edits). The OIDC flavour (oidc_authorize.go) is untouched. The MCP token endpoint still never emits an id_token (TestMCPRefusesOpenID token half unchanged), and its scope field already reports the narrowed grant"
- "TestAuthorizeScopeInvalidValueRedirectsInvalidScope (read bogus) and TestMCPScopesSmoke (read backend) keep their rejection assertions unedited; TestMCPScopes and TestMCPRefusesOpenID keep their names (scripts/check-phase19.4.sh and scripts/check-phase19.7.sh list them) but their openid cases now assert success with no identity scope persisted"
- "modules/wristband/README.md and docs/services/oauth-server.md describe the drop in the same commit as the code. One commit in summercms.go, files staged by explicit path, no co-author tag, nothing pushed or tagged. go vet ./..., go test -short ./..., TestDocsTree and summer docs:build --check are green at that commit"
artifacts:
- path: modules/wristband/authorize.go
provides: "parseAuthorizeScopes drops openid/profile/email before validating; doc comment states RFC 6749 section 3.3 and the PHP divergence"
contains: "mcpIgnoredIdentityScopes"
- path: modules/wristband/authorize_test.go
provides: "TestAuthorizeDropsOIDCIdentityScopes: the discovery-reading client's exact request, identity-only requests, ceiling interplay and the still-refused cases"
contains: "func TestAuthorizeDropsOIDCIdentityScopes"
- path: modules/wristband/mcp_scopes_test.go
provides: "TestMCPScopes subtest asserting identity scopes are dropped and unlisted scopes stay refused"
- path: modules/wristband/oidc_flavour_test.go
provides: "TestMCPRefusesOpenID: authorize cases succeed without an identity scope persisted; id_token half unchanged"
- path: modules/wristband/README.md
provides: "Features bullets (authorize endpoint, client kinds) describe the drop"
contains: "are dropped from the request"
- path: docs/services/oauth-server.md
provides: "Scope paragraph and client-kinds paragraph describe the drop"
contains: "are dropped from the request"
key_links:
- from: "wristband.Server.Authorize (authorize.go:109)"
to: "parseAuthorizeScopes"
via: "q.Get(\"scope\") and s.allowedAuthorizeScopes(); the drop lives inside parseAuthorizeScopes so the call site and the ceiling block after it are unchanged"
- from: "pending AuthCodeRecord.Scopes"
to: "consent view and token response"
via: "PendingRequest shows pending.Scopes and the token answer's scope field joins issued scopes, so both already reflect the narrowed grant with no further code change"
---
<objective>
Make the MCP authorization endpoint accept requests that carry the OpenID Connect identity scopes `openid`, `profile` and `email` by dropping them before validation, instead of redirecting with `invalid_scope`.
Purpose: a client that reads both `/.well-known/oauth-authorization-server` and the same issuer's `/.well-known/openid-configuration` (published by wristband's OIDC flavour) adds `openid email` to its MCP authorize request and is currently refused with "scope contains an unsupported value", so it cannot connect. RFC 6749 section 3.3 lets the authorization server grant fewer scopes than requested, and the MCP flavour issues no ID tokens, so dropping these three is safe. The root cause is verified (production nginx log, 2026-11-06 12:58); do not re-investigate the live server.
Investigation already done during planning (the executor does not repeat it):
- The token endpoint never reads a request `scope` parameter: the refresh grant reissues `issued.Scopes` (token.go:140). No change.
- The consent view (consent.go `PendingRequest`) shows the pending row's own scopes, which are filtered at authorize. No change.
- `TestAuthorizeScopeInvalidValueRedirectsInvalidScope` (authorize_test.go:465) requests `read bogus` and `TestMCPScopesSmoke` (mcp_scopes_smoke_test.go:69) requests `read backend` on DefaultOptions. Neither uses an identity scope, so both keep their rejection coverage unedited.
- Two tests DO break and must be rewritten: `TestMCPScopes` subtest "openid and unlisted scopes stay refused" (mcp_scopes_test.go:71-79) and the authorize half of `TestMCPRefusesOpenID` (oidc_flavour_test.go:201-218). Their function names stay because `scripts/check-phase19.4.sh` and `scripts/check-phase19.7.sh` require them by name.
- This narrows phase 19.4 D-07 ("MCP clients cannot request openid or receive an id_token") from refusal to dropping. The security intent holds: an MCP client never gets an identity scope or an `id_token`. Do not edit 19.4 planning files; the SUMMARY records the amendment.
- No parity fixture, consumer app or `sm-oauthserver-plugin` doc depends on the refusal message for identity scopes. The plugin is untouched.
Output: the code change, tests and docs in one framework commit.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@CLAUDE.md
@modules/wristband/authorize.go
@modules/wristband/mcp_scopes_test.go
</context>
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: MCP authorize drops openid/profile/email, end to end from query to pending row</name>
<files>modules/wristband/authorize.go, modules/wristband/authorize_test.go, modules/wristband/mcp_scopes_test.go, modules/wristband/oidc_flavour_test.go</files>
<read_first>modules/wristband/authorize.go (whole file, 295 lines), modules/wristband/authorize_test.go lines 1-135 (helpers insertAuthorizeTestClient, s256Pair, authorizeRequest, queryOf, authorizeTestRedirect, newAuthorizeTestServer, pendingCount) and 443-475 (the read-bogus rejection test, the style to follow), modules/wristband/mcp_scopes_test.go (whole), modules/wristband/oidc_flavour_test.go lines 200-240, modules/wristband/registration_test.go lines 274-283 (newTestServer sets Issuer https://issuer.example.test and Resource https://resource.example.test/mcp)</read_first>
<behavior>
- Discovery-reading client, exact query: client_id, redirect_uri=authorizeTestRedirect, response_type=code, scope="openid email read", S256 challenge, resource=srv.opts.Resource, state="xyz", ui_locales="en-US" -> 302; Location starts with srv.opts.Issuer + "/connect?request="; the Location query has no error key; pending count rises by exactly 1; that row's Scopes equal [read], OfflineAccess is false, Resource points at srv.opts.Resource, State points at "xyz"
- scope="openid profile" -> 302 to /connect, row Scopes [read]
- scope="openid" -> 302 to /connect, row Scopes [read]
- scope="openid profile email read write" -> row Scopes [read write] (order preserved)
- Ceiling still applies after the drop: a client with ceiling [read write] sending "openid email read ai" -> row Scopes [read]
- Still refused with invalid_scope / "scope contains an unsupported value" and no new pending row: "openid bogus", "read bogus", "OPENID read" (case-sensitive match, so the uppercase token is unknown)
</behavior>
<action>
RED first. In modules/wristband/authorize_test.go add TestAuthorizeDropsOIDCIdentityScopes right after TestAuthorizeScopeInvalidValueRedirectsInvalidScope. Make it table-driven, one t.Run per behavior bullet above, each case on a fresh newAuthorizeTestServer with its own client from insertAuthorizeTestClient (nil ceiling, except [read write] for the ceiling case). Read persisted rows under backend.mu as the neighbouring tests do, and compare slices with slices.Equal (add the slices import if the file lacks it). The first case reproduces the discovery-reading client's request verbatim, including resource and ui_locales. Its comment describes it neutrally as "a client that also reads the issuer's OpenID Connect discovery document". No product or application names in comments, test names or strings; the existing authorizeTestRedirect constant stays as it is. Run the test and confirm it fails before touching authorize.go.
GREEN. In modules/wristband/authorize.go add an unexported package-level set named mcpIgnoredIdentityScopes (a map[string]bool holding openid, profile and email) next to parseAuthorizeScopes. Keep the signature parseAuthorizeScopes(raw string, allowed map[string]bool) ([]string, string). Its new body: split with strings.Fields as now; build a filtered slice that skips every token found in mcpIgnoredIdentityScopes (exact, case-sensitive match, checked before and independently of allowed, so a host listing one of them in Options.ScopesSupported still never gets it persisted); if the filtered slice is empty return the existing [read] default with no error; otherwise validate each remaining token against allowed exactly as today, with the same "scope contains an unsupported value" message, and return the filtered slice. Do not special-case offline_access, do not dedupe, and do not change the Authorize call site, the ceiling block, allowedAuthorizeScopes, resolvedScopes, Metadata or anything in oidc_*.go. Rewrite the parseAuthorizeScopes doc comment so it says, in prose: it ports OAuthAuthorizeController::parseScopes with one deliberate divergence; the identity scopes openid, profile and email are dropped first, because clients that also read the issuer's OpenID Connect discovery document request them on the MCP endpoint, and RFC 6749 section 3.3 lets the server grant fewer scopes than requested; the MCP flavour issues no ID tokens and never grants identity scopes (phase 19.4 D-07, narrowed from refusal to dropping); an empty result falls back to [read]; any other token outside allowed is refused.
Fix the two tests the drop breaks, keeping both function names (the phase 19.4 and 19.7 gate scripts list them):
- mcp_scopes_test.go TestMCPScopes: rename the subtest to "identity scopes are dropped and unlisted scopes stay refused". On both servers "read openid" now succeeds and "read admin" stays invalid_scope. "openid backend" stays invalid_scope on the plain server (backend is not listed) and succeeds on withBackend; after that call, assert the newest row in that server's memory backend stores exactly [backend]. Update the function's doc comment, which currently says openid stays refused.
- oidc_flavour_test.go TestMCPRefusesOpenID: the loop over "openid", "openid read" and "read openid profile" now asserts a 302 with no error key in the Location query and a newest pending row whose Scopes are exactly [read] (none of the three identity names). Leave the token half (a code stored with openid read, no id_token in the answer) as it is. Update the doc comment: the MCP flavour never grants openid; authorize drops it, and its token answer never carries an id_token.
Do not commit in this task; Task 2 commits code and docs together.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./modules/wristband/ && go test -count=1 -short ./modules/wristband/... && OUT=$(go test -count=1 -short -run '^(TestAuthorizeDropsOIDCIdentityScopes|TestAuthorizeScopeInvalidValueRedirectsInvalidScope|TestMCPScopes|TestMCPScopesSmoke|TestMCPRefusesOpenID|TestPhase08Coverage)$' -v ./modules/wristband 2>&1) && MISSING=$(for n in TestAuthorizeDropsOIDCIdentityScopes TestAuthorizeScopeInvalidValueRedirectsInvalidScope TestMCPScopes TestMCPScopesSmoke TestMCPRefusesOpenID TestPhase08Coverage; do grep -q -- "^--- PASS: $n " <<<"$OUT" || echo "$n"; done) && test -z "$MISSING" && git diff --quiet -- modules/wristband/mcp_scopes_smoke_test.go modules/wristband/oidc_authorize.go modules/wristband/server.go modules/wristband/token.go modules/wristband/consent.go</automated>
</verify>
<done>TestAuthorizeDropsOIDCIdentityScopes failed before the authorize.go change and passes after it. The whole wristband package passes under -short. TestMCPScopesSmoke, the OIDC flavour files, server.go, token.go and consent.go have no diff. The exact discovery-reading client request stores [read] and redirects to /connect.</done>
</task>
<task type="auto">
<name>Task 2: Document the drop in the wristband README and the OAuth server guide, run the gates, commit</name>
<files>modules/wristband/README.md, docs/services/oauth-server.md</files>
<read_first>modules/wristband/README.md lines 28-40 (Features bullets for the authorization endpoint and client kinds), docs/services/oauth-server.md lines 40-44 and 215-219</read_first>
<action>
Update both documents in prose that matches the surrounding style: short declarative sentences, backticked identifiers, no new wristband.* identifiers (the docs checker requires every identifier to exist; parseAuthorizeScopes is unexported, so do not name it), and no application or product names.
modules/wristband/README.md:
- Authorization endpoint bullet (line 30): just before "Any other value is `invalid_scope`.", add a sentence containing the exact phrase "are dropped from the request". It should say that the OpenID Connect identity scopes `openid`, `profile` and `email` are dropped from the request before that check (RFC 6749 section 3.3 lets a server grant fewer scopes than requested), so a client that also reads the issuer's OpenID Connect discovery document still connects; that a request with nothing else left gets the default `read`; and that the three are never advertised.
- Client kinds bullet (line 39): replace the clause saying the MCP flavour continues to refuse the `openid` scope with one saying that the MCP flavour drops `openid`, `profile` and `email` at authorize and never issues an `id_token`. Keep the rest of the sentence, "so a dynamically registered client never receives identity scopes or an `id_token`", as it is.
docs/services/oauth-server.md:
- Scope paragraph (line 42): after "...still refuses `backend`.", add the same kind of sentence, also containing "are dropped from the request". Keep the final sentence that OpenID Connect discovery stays `openid`, `profile` and `email`.
- Client kinds paragraph (line 217): make the same replacement as in the README client-kinds bullet, ending "so a dynamically registered client can never obtain identity scopes or an `id_token`."
Leave docs/services/mcp.md alone: its line 141 only says OIDC discovery stays the three names, which is still true.
Run the full gates. Then make one commit in summercms.go, staging these six paths explicitly and nothing else (never git add -A; other sessions' STATE.md, state.json and G15OFFICE.md edits stay unstaged): modules/wristband/authorize.go, modules/wristband/authorize_test.go, modules/wristband/mcp_scopes_test.go, modules/wristband/oidc_flavour_test.go, modules/wristband/README.md, docs/services/oauth-server.md. Commit message subject: "fix(wristband): drop OIDC identity scopes at MCP authorize". The body says that requests carrying openid, profile or email (sent by clients that also read the issuer's OIDC discovery document) were refused with invalid_scope, that the three are now dropped per RFC 6749 section 3.3, that any other unknown scope is still refused, that the MCP flavour still never grants identity scopes or issues an id_token, and that metadata is unchanged. No co-author or Claude attribution lines (the user's global rule overrides the attribution reminder). Do not push or tag.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./... && go test -short ./... && go test -count=1 ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check && grep -q "are dropped from the request" modules/wristband/README.md && grep -q "are dropped from the request" docs/services/oauth-server.md && ! grep -q 'keeps refusing' modules/wristband/README.md docs/services/oauth-server.md && MSG=$(git log -1 --format=%B) && ! grep -qi 'co-authored-by' <<<"$MSG" && FILES=$(git show --name-only --format= HEAD) && test "$(grep -v '^$' <<<"$FILES" | sort | tr '\n' ' ')" = "docs/services/oauth-server.md modules/wristband/README.md modules/wristband/authorize.go modules/wristband/authorize_test.go modules/wristband/mcp_scopes_test.go modules/wristband/oidc_flavour_test.go "</automated>
</verify>
<done>Both documents describe the drop and no longer say the MCP flavour refuses openid. The docs checker, go vet ./... and go test -short ./... are green. HEAD is a single commit with exactly the six files and no co-author line. Nothing is pushed or tagged.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser/client -> GET /oauth/mcp/authorize | Untrusted query string (client_id, redirect_uri, scope, resource, state) from a dynamically registered MCP client |
| pending row -> /connect consent -> token | Scopes persisted at authorize become the user's consent choices and the issued token's scopes |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-q261106jhk-01 | Elevation of privilege | parseAuthorizeScopes / MCP pending row: an MCP client obtaining an identity scope or an id_token by asking for openid | medium | mitigate | Task 1 drops openid/profile/email unconditionally, before and independently of the allow-list, so they are never persisted even if a host lists them. TestMCPRefusesOpenID asserts the persisted Scopes contain none of the three, and its unchanged token half asserts no id_token in an MCP token answer |
| T-q261106jhk-02 | Elevation of privilege / Tampering | parseAuthorizeScopes: the tolerance accepting other unknown or look-alike scopes | medium | mitigate | Only the three exact, case-sensitive tokens are dropped. "openid bogus", "read bogus", "OPENID read", "read admin" and backend on DefaultOptions stay invalid_scope with no pending row (TestAuthorizeDropsOIDCIdentityScopes, TestMCPScopes, unedited TestAuthorizeScopeInvalidValueRedirectsInvalidScope and TestMCPScopesSmoke) |
| T-q261106jhk-03 | Elevation of privilege | Ceiling bypass through the drop | low | mitigate | The drop happens inside parseAuthorizeScopes, before the unchanged ceiling block; the ceiling case ("openid email read ai" with ceiling [read write] stores [read]) pins the ordering |
| T-q261106jhk-04 | Repudiation / Spoofing | Client assumes it was granted openid | low | accept | RFC 6749 section 3.3 allows a narrower grant; the token answer's scope field already reports the granted scopes (token.go) and the consent page lists only data scopes. The MCP flavour publishes no id_token support |
| T-q261106jhk-05 | Elevation of privilege | An identity-only request falls back to the read default | low | accept | Same as the existing empty-scope default (PHP parity); the user still consents at /connect before any code is issued |
| T-q261106jhk-06 | Tampering | Breaking change in shared wristband for consuming apps and sm-oauthserver-plugin | low | mitigate | No exported identifier, option, metadata field or error message changes; only requests that previously failed now succeed. Full go test -short ./... is green in Task 2 |
| T-q261106jhk-SC | Tampering | npm/pip/cargo/go module installs | low | accept | No new dependencies and no package-manager install task |
</threat_model>
<verification>
- go vet ./... and go test -short ./... green in summercms.go.
- go test -short -v for TestAuthorizeDropsOIDCIdentityScopes, TestAuthorizeScopeInvalidValueRedirectsInvalidScope, TestMCPScopes, TestMCPScopesSmoke, TestMCPRefusesOpenID and TestPhase08Coverage: 6 top-level PASS lines.
- go test ./cmd/summer -run TestDocsTree and go run ./cmd/summer docs:build --check green.
- Metadata scopes_supported unchanged (TestMCPScopesSmoke exact-slice assertions, file has no diff); OIDC flavour files have no diff.
- One commit with exactly the six listed files, no co-author line, not pushed or tagged.
</verification>
<success_criteria>
A client that sends scope=openid email read (plus resource and ui_locales) to the MCP authorize endpoint is redirected to /connect with a pending request for [read] instead of getting error=invalid_scope. Identity-only requests default to [read]. Every other unknown scope is still refused with the same error. The MCP flavour still never grants an identity scope or issues an id_token, metadata is unchanged, and the README and the OAuth server guide describe the new behaviour in the same commit.
</success_criteria>
<output>
Create `.planning/quick/261106-jhk-wristband-mcp-authorize-ignores-oidc-ide/261106-jhk-SUMMARY.md` when done. Record the commit hash, the six files, the RED-then-GREEN evidence for TestAuthorizeDropsOIDCIdentityScopes, and a note that phase 19.4 D-07 is narrowed from "MCP clients cannot request openid" to "the MCP flavour drops openid/profile/email and never grants them or issues an id_token".
</output>

View File

@@ -0,0 +1,94 @@
---
phase: quick-261106-jhk
plan: 01
subsystem: wristband
status: complete
tags: [oauth, mcp, scopes, oidc, rfc6749]
requires: []
provides:
- MCP authorize drops openid/profile/email instead of refusing them
affects:
- modules/wristband (MCP flavour authorize)
- phase 19.4 D-07 (narrowed)
tech-stack:
added: []
patterns:
- drop-before-validate for a fixed, case-sensitive identity-scope set
key-files:
created: []
modified:
- modules/wristband/authorize.go
- modules/wristband/authorize_test.go
- modules/wristband/mcp_scopes_test.go
- modules/wristband/oidc_flavour_test.go
- modules/wristband/oidc_authorize_smoke_test.go
- modules/wristband/README.md
- docs/services/oauth-server.md
decisions:
- "MCP authorize drops the OIDC identity scopes openid, profile and email (exact, case-sensitive) before the allow-list check, per RFC 6749 section 3.3; phase 19.4 D-07 narrowed from refusal to dropping; the MCP flavour still never grants identity scopes or issues an id_token"
metrics:
duration: ~15 min
completed: 2026-11-06
commits: 1
plan_head_before: 5db032f52f34789399928461ed8024108c7cba04
plan_head_after: aba678b
actuals:
tokens: 6000
tasks: 2
commits: 1
---
# Quick 261106-jhk: MCP authorize drops OIDC identity scopes Summary
The MCP flavour's `parseAuthorizeScopes` now drops `openid`, `profile` and `email` (exact match, before and independently of the allow-list) so a client that also reads the issuer's OIDC discovery document and sends `scope=openid email read` gets a 302 to `/connect` with a pending `[read]` row instead of `invalid_scope`.
## Commit
- `aba678b` fix(wristband): drop OIDC identity scopes at MCP authorize (7 files, code + tests + docs in one commit, on master, not pushed or tagged, no co-author line)
## What changed
- `modules/wristband/authorize.go`: new unexported `mcpIgnoredIdentityScopes` set; `parseAuthorizeScopes` filters it out first, an empty remainder falls back to `[read]`, every other token is validated against the allow-list as before with the same message. Doc comment states the PHP divergence, RFC 6749 section 3.3 and D-07. The call site, ceiling block, `allowedAuthorizeScopes`, `resolvedScopes`, Metadata and every `oidc_*.go` source file are unchanged.
- `modules/wristband/authorize_test.go`: `TestAuthorizeDropsOIDCIdentityScopes` (8 table cases: the discovery-reading client's exact request with `resource` and `ui_locales`, identity-only, openid alone, order preserved, ceiling after the drop, and the refused `openid bogus` / `read bogus` / `OPENID read`).
- `modules/wristband/mcp_scopes_test.go`: `TestMCPScopes` subtest renamed to "identity scopes are dropped and unlisted scopes stay refused"; `openid backend` on the listing server stores exactly `[backend]`.
- `modules/wristband/oidc_flavour_test.go`: `TestMCPRefusesOpenID` authorize half now asserts 302 with no error and a stored `[read]`; the id_token token half is unchanged.
- `modules/wristband/README.md`, `docs/services/oauth-server.md`: scope and client-kinds passages describe the drop ("are dropped from the request"); "keeps refusing" wording removed.
## TDD evidence
- RED: before the `authorize.go` change, `go test -short -run '^TestAuthorizeDropsOIDCIdentityScopes$' ./modules/wristband` failed in the 5 success cases (each redirected with `error=invalid_scope&error_description=scope%20contains%20an%20unsupported%20value`); the 3 refusal cases passed.
- GREEN: after the change all 8 cases pass.
## Verification
- `go vet ./...` green; `go test -short ./...` green.
- `-v` run: PASS for TestAuthorizeDropsOIDCIdentityScopes, TestAuthorizeScopeInvalidValueRedirectsInvalidScope, TestMCPScopes, TestMCPScopesSmoke, TestMCPRefusesOpenID, TestPhase08Coverage, TestKindIsolationSmoke.
- `go test ./cmd/summer -run TestDocsTree` and `summer docs:build --check` ("no problems found") green.
- No diff in mcp_scopes_smoke_test.go, oidc_authorize.go, server.go, token.go, consent.go (metadata `scopes_supported` unchanged).
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] A third test asserted the old refusal**
- **Found during:** Task 1 (full wristband package run)
- **Issue:** `TestKindIsolationSmoke` in `modules/wristband/oidc_authorize_smoke_test.go` asserted that MCP authorize with `openid read` redirects with `invalid_scope` (the D-07 refusal). The plan listed only two breaking tests.
- **Fix:** The block now asserts a 302 with no error and a stored `[read]` (no identity scope persisted). Function name kept; `scripts/check-phase19.4.sh` lists it.
- **Files modified:** modules/wristband/oidc_authorize_smoke_test.go
- **Commit:** aba678b. As a result the commit carries seven files, not the six the plan's Task 2 verify expects.
**2. Test fixture detail:** the discovery-reading case sends the literal `https://resource.example.test/mcp` and asserts it equals `srv.opts.Resource` before checking the stored row, rather than reading `srv.opts.Resource` into the request map. Same coverage.
## Deferred Issues (out of scope, not caused by this change)
I also ran the phase gate scripts that list the edited tests by name:
- `scripts/check-phase19.4.sh`: all 42 wristband tests (TestKindIsolationSmoke and TestMCPRefusesOpenID included) and all sm-oauthserver-plugin tests pass. The script then fails in its fonoteka.go docker stage on `TestPhase8RedMCPMe` and `TestOAuthToolsMeRouteIsolation`, both reporting `unexpected /me-suffixed route on another surface: GET /_user/api/v1/token/me`. That is a route-surface check against the user plugin's token routes, unrelated to authorize scope parsing.
- `scripts/check-phase19.7.sh`: every go stage passes (TestMCPScopesSmoke and TestMCPScopes included). Its "plugins" stage fails because `../sm-demo-app/plugins/golem15/user` resolves the framework `replace` to the wrong sibling path, a problem in that sibling checkout.
## Phase 19.4 D-07 amendment
D-07 is narrowed from "MCP clients cannot request openid" to "the MCP flavour drops openid/profile/email at authorize and never grants them or issues an id_token". 19.4 planning files were not edited.
## Self-Check: PASSED
- All seven modified files exist; commit `aba678b` is present in `git log`.

View File

@@ -0,0 +1,174 @@
---
phase: quick-261106-kt1
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- modules/pier/pier.go
- modules/pier/tracer_smoke_test.go
- modules/pier/README.md
- docs/services/mcp.md
- .planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md
autonomous: true
requirements:
- QUICK-261106-kt1
estimate:
tokens: 35000
raw_tokens: 35000
tasks: 2
confidence: low
must_haves:
truths:
- "A request to the MCP endpoint with a bad or missing bearer answers 401 whose WWW-Authenticate is `Bearer resource_metadata=\"<app.url>/.well-known/oauth-protected-resource<mcp.path>\"` and carries no scope parameter"
- "A spec-following MCP client therefore falls back to the PRM scopes_supported list when it requests authorization"
- "No 403 insufficient-scope path is introduced: RequireBearerTokenOptions.Scopes stays unset, so the SDK still never enforces a scope on every request"
- "SSE / streamable GET responses still flush: the SDK handler now receives the original ResponseWriter, which keeps its own http.Flusher"
- "pier's README, docs/services/mcp.md and the 19.7 security review row T-19.7-10 describe the challenge without a scope parameter"
artifacts:
- path: modules/pier/pier.go
provides: "bearer() wrapping auth.RequireBearerToken directly, no response-writer wrapper"
- path: modules/pier/tracer_smoke_test.go
provides: "bad-bearer and missing-bearer 401 assertions: resource_metadata present, no scope parameter"
key_links:
- from: modules/pier/pier.go bearer()
to: go-sdk auth.RequireBearerToken (v1.8.0 auth/auth.go:104-116)
via: "ResourceMetadataURL set, Scopes empty -> header is `Bearer resource_metadata=\"...\"` only"
---
<objective>
pier's 401 challenge on the MCP endpoint stops naming a fixed scope. Today the
response-writer wrapper in `modules/pier/pier.go` appends a read-only scope
parameter to every 401 `WWW-Authenticate`, and the MCP scope-selection rule makes
a client use that parameter instead of the PRM `scopes_supported`. A client that
follows the spec therefore asks only for the read scope, never receives a `write`
grant, and pier's read/write ceiling (`modules/pier/identity.go:80`, `writes()`)
hides every non-read-only tool with no step-up path. User decision (option 1):
the challenge carries only `resource_metadata` (RFC 6750 makes `scope` optional;
the MCP spec's server-side scope hint is a SHOULD, which is knowingly dropped here).
Purpose: clients request the advertised scope set and consent decides the grant.
Output: wrapper removed, smoke test pinned to the new header, README/docs and the
19.7 security review row updated.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@CLAUDE.md
Verified facts (do not re-investigate the live server):
- go-sdk is `github.com/modelcontextprotocol/go-sdk v1.8.0`. `auth.RequireBearerToken`
(`$(go list -m -f '{{.Dir}}' github.com/modelcontextprotocol/go-sdk)/auth/auth.go:97-125`)
writes `WWW-Authenticate: Bearer resource_metadata="<url>"` on 401/403 when
`ResourceMetadataURL` is set, and adds `scope=` ONLY when `opts.Scopes` is non-empty
(which would also enforce those scopes with 403 on every request). pier sets no
`Scopes`, so the SDK itself never emits a scope parameter.
- The wrapper type in `modules/pier/pier.go:288-310` exists only to append the scope;
its `Flush` and `Unwrap` methods only forward to the wrapped writer so the wrapper
does not hide the underlying `http.Flusher`. Removing the wrapper hands the SDK the
original writer, which keeps every capability it has. Nothing else in the repo
references the type (grep confirmed).
- `strings` stays imported in pier.go (used at lines ~166, ~220, ~222, ~332).
- `modules/pier/session.go:90` writes a bare 401 without `WWW-Authenticate` when no
identity is on the request (behind the bearer middleware, unreachable from outside);
it is out of scope and unchanged.
- `scripts/check-phase19.7.sh` has no grep on the challenge (only test file lists).
- `.planning/phases/19.7-generic-mcp-module/19.7-02-PLAN.md:252` mentions the old header;
it is a historical plan and is NOT edited.
- Another quick task (261106-kt2) is editing `modules/lagoon` concurrently: do not touch,
stage or format any lagoon file. Stage files explicitly by path.
- `summer` is not on PATH here; use `go run ./cmd/summer docs:build --check`.
</context>
<tasks>
<task type="tracer">
<name>Task 1: Drop the scope parameter from pier's 401 challenge (code, smoke test, README, docs)</name>
<files>modules/pier/pier.go, modules/pier/tracer_smoke_test.go, modules/pier/README.md, docs/services/mcp.md</files>
<read_first>
- modules/pier/pier.go lines 248-311 (bearer() and the wrapper type)
- modules/pier/tracer_smoke_test.go lines 99-115 (bad-bearer assertion)
- modules/pier/README.md line 47
- docs/services/mcp.md line 141 (last sentences of the PRM paragraph)
</read_first>
<!-- planner-discipline-allow: challengeWriter -->
<action>
Per the user decision (option 1), the MCP endpoint's 401 challenge carries only resource_metadata.
1. modules/pier/pier.go: delete the challengeWriter type and its three methods (WriteHeader, Flush, Unwrap; lines ~288-310). In bearer(), return the middleware from auth.RequireBearerToken(verify, &auth.RequireBearerTokenOptions{ResourceMetadataURL: p.prmURL, AllowMissingExpiration: true})(next) directly instead of wrapping it in an http.HandlerFunc that substitutes the writer. Do NOT add a Scopes option: it would make the SDK answer 403 to every token lacking those scopes. Keep the verify closure unchanged. Rewrite the bearer() doc comment (lines ~248-251): a failure is a 401 whose WWW-Authenticate names only the protected resource metadata URL; it carries no scope parameter, so a client requests the scopes the metadata document advertises and the authorization server's consent decides the grant (tools are then filtered per granted scope). Neutral wording only; no consuming application or client product is named in code comments. Confirm the strings import is still used (it is, elsewhere in the file); run gofmt on the file.
2. modules/pier/tracer_smoke_test.go: in the bad-bearer block (~lines 99-114) keep the 401 and resource_metadata checks and replace the positive scope check with a negative one: the challenge must not contain the substring scope= at all. Make the expected header exact: the challenge equals Bearer resource_metadata="http://acme.test/.well-known/oauth-protected-resource/mcp". Update the comment above the block to say the challenge names only the metadata URL. Add a second request in the same block with no Authorization header (same initialize body and headers otherwise) asserting the same 401 and the same exact challenge, so both SDK failure paths (no bearer token, invalid token) are pinned.
3. modules/pier/README.md line 47: the 401 bullet states the header as Bearer resource_metadata="<app.url>/.well-known/oauth-protected-resource<mcp.path>" with no scope parameter, followed by one clause: there is no scope parameter, so a client requests the scopes_supported of the protected resource metadata. Keep the rest of the bullet (the Origin / 403 clause) as is.
4. docs/services/mcp.md line 141: replace the sentence that begins "A 401 carries" so the quoted header is Bearer resource_metadata="…" only, and add: it carries no scope parameter, so a client requests the scopes listed in the metadata document and consent decides what is granted. Keep the "which is how a client finds the authorization server" meaning and the following mcp.resource sentence.
Commit these four files together as one code commit, staged by explicit path (never git add -A, never a lagoon file), message: fix(pier): MCP 401 challenge names only resource_metadata. No co-author tag.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && FMT=$(gofmt -l modules/pier/) && test -z "$FMT" && go vet ./modules/pier/... && go test -short ./modules/pier/... && go test ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check && test "$(grep -v '^\s*//' modules/pier/pier.go | grep -c challengeWriter)" = 0 && ! grep -n 'scope=\\"read\\"\|scope="read"' modules/pier/*.go modules/pier/README.md docs/services/mcp.md && grep -c 'resource_metadata' modules/pier/README.md docs/services/mcp.md</automated>
</verify>
<done>
bearer() wraps auth.RequireBearerToken with no writer substitution and no Scopes option; the wrapper type is gone; TestPierTracerSmoke asserts the exact header `Bearer resource_metadata="http://acme.test/.well-known/oauth-protected-resource/mcp"` for both a bad and a missing bearer; README and docs describe the header without a scope parameter; go vet, go test -short ./modules/pier/..., TestDocsTree and docs:build --check pass; one commit `fix(pier): ...` containing exactly the four files.
</done>
</task>
<task type="auto">
<name>Task 2: Update security review row T-19.7-10 (planning doc, not in the code commit)</name>
<files>.planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md</files>
<read_first>
- .planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md line 48 (row T-19.7-10)
</read_first>
<action>
Edit only row T-19.7-10 (line 48) with a scoped Edit. In the mitigation cell, replace the trailing clause about the RFC 9728 challenge so it reads: 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). Update the evidence cell's pier.go line range to the current bearer() lines after Task 1 (find them with grep -n 'func (p \*Pier) bearer' modules/pier/pier.go and the function's closing brace). Keep the test column (TestPierTracerSmoke bad bearer, now also missing bearer) and the status verified. Change no other row.
Do NOT commit this file in the code commit (planning docs and code are separate commits). Leave it modified and list it in the SUMMARY under files for the orchestrator's quick-task docs commit.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && test "$(grep 'T-19.7-10' .planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md | grep -c '261106-kt1')" = 1 && ! grep 'T-19.7-10' .planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md | grep -q 'scope="read"' && NS=$(git diff --numstat -- .planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md) && test "$(printf '%s\n' "$NS" | awk '{print $1+$2}')" = 2</automated>
</verify>
<done>Row T-19.7-10 states the challenge names only resource_metadata, cites quick 261106-kt1 and the current bearer() line range; exactly one line changed in the file; the file is uncommitted and listed in the SUMMARY for the orchestrator's docs commit.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| MCP client -> pier `/mcp` | Untrusted bearer and request; every request re-verified by `auth.RequireBearerToken` with pier's resolver |
| MCP client -> OAuth authorization server | Client chooses which scopes to request; the server's allow-list and consent page decide the grant |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-261106-kt1-01 | Elevation of Privilege | 401 challenge without scope -> clients request the full PRM list (`write`, `ai`, `backend`, ...) | low | accept | The challenge is advisory; grants are unchanged server-side: wristband's authorize allow-list (T-19.7-09), granted = submitted ∩ requested with `backend` only with a link (T-19.7-33), and pier's per-request read/write ceiling and tool filter (`identity.go` `writes()`, T-19.7-13) still bound what a token reaches. |
| T-261106-kt1-02 | Spoofing | bearer() after wrapper removal | medium | mitigate | verify closure and RequireBearerToken options (ResourceMetadataURL, AllowMissingExpiration) unchanged; TestPierTracerSmoke pins 401 for a bad and a missing bearer. |
| T-261106-kt1-03 | Information Disclosure | WWW-Authenticate content | low | accept | Header names only the PRM URL, which is public; no scope or identity data added. |
| T-261106-kt1-04 | Denial of Service | streaming responses lose http.Flusher | low | mitigate | Wrapper removed rather than altered, so the SDK gets the original writer with its own Flusher; existing stream tests (TestEndpointGuards) keep running in `go test ./modules/pier/...`. |
| T-261106-kt1-SC | Tampering | npm/pip/cargo installs | low | accept | No package installs or dependency changes in this plan. |
</threat_model>
<verification>
- `go vet ./...` and `go test -short ./modules/pier/...` green.
- `go test ./cmd/summer -run TestDocsTree` and `go run ./cmd/summer docs:build --check` green.
- No `scope="read"` left in modules/pier, its README or docs/services/mcp.md; T-19.7-10 updated.
- `git show --stat HEAD` lists exactly the four Task 1 files; no lagoon file touched.
</verification>
<success_criteria>
A 401 from the MCP endpoint carries `WWW-Authenticate: Bearer resource_metadata="…"` with no scope parameter for both bad and missing bearers, pinned by TestPierTracerSmoke; docs, README and the security review agree.
</success_criteria>
<output>
Create `.planning/quick/261106-kt1-pier-mcp-401-challenge-drops-the-fixed-r/261106-kt1-SUMMARY.md` when done. List `.planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md` as a planning-doc change for the orchestrator's docs commit.
</output>

View File

@@ -0,0 +1,79 @@
---
phase: quick-261106-kt1
plan: 01
subsystem: pier
tags: [mcp, oauth, rfc9728, www-authenticate]
status: complete
requires: []
provides:
- "pier MCP 401 challenge carrying only resource_metadata (no scope parameter)"
affects:
- modules/pier
- docs/services/mcp.md
tech-stack:
added: []
patterns:
- "bearer() returns auth.RequireBearerToken(...)(next) directly; no ResponseWriter wrapper"
key-files:
created: []
modified:
- modules/pier/pier.go
- modules/pier/tracer_smoke_test.go
- modules/pier/README.md
- docs/services/mcp.md
- .planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md
decisions:
- "MCP 401 challenge names only resource_metadata; the MCP spec's server-side scope hint (SHOULD) is knowingly dropped so clients request the PRM scopes_supported and consent decides the grant (user decision, option 1)"
metrics:
duration: ~10m
completed: 2026-11-06
estimate:
tokens: 35000
tasks: 2
actuals:
tokens: 3500
tasks: 2
commits: 1
plan_head_before: ef47baf0a14379e37afe1b96fa2f6cf604e3337f
plan_head_after: 609caa4f3f1f24f123696ab1d8fd8b6bfcef113a
---
# Quick 261106-kt1: pier MCP 401 challenge drops the fixed read scope
pier's MCP endpoint now answers a bad or missing bearer with `401` and `WWW-Authenticate: Bearer resource_metadata="<app.url>/.well-known/oauth-protected-resource<mcp.path>"`. The header has no scope parameter. The response-writer wrapper that appended `scope="read"` is gone, so a client that follows the spec requests the PRM `scopes_supported` and can receive `write`.
## What changed
- **`modules/pier/pier.go`**: removed the `challengeWriter` type and its `WriteHeader`/`Flush`/`Unwrap` methods. `bearer()` now returns `auth.RequireBearerToken(verify, &auth.RequireBearerTokenOptions{ResourceMetadataURL: p.prmURL, AllowMissingExpiration: true})(next)` directly. The verify closure is unchanged and no `Scopes` option is set, so there is still no 403 insufficient-scope path. The SDK now receives the original writer, which keeps its own `http.Flusher`. The doc comment says why the challenge carries no scope. `strings` is still imported and used elsewhere in the file.
- **`modules/pier/tracer_smoke_test.go`**: the bad-bearer check is now a two-case loop (bad bearer, missing bearer). Each case asserts 401, the exact challenge `Bearer resource_metadata="http://acme.test/.well-known/oauth-protected-resource/mcp"`, and no `scope=` substring.
- **`modules/pier/README.md`** and **`docs/services/mcp.md`**: the header is quoted without a scope parameter, and each says the client therefore requests the scopes the metadata document advertises.
- **`.planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md`**: one line changed, row T-19.7-10. It now says the challenge names only `resource_metadata` "since quick 261106-kt1", the evidence cites `pier/pier.go:256-274`, and the test column reads "bad bearer, missing bearer". **Left uncommitted for the orchestrator's docs commit.**
## Commits
| Task | Commit | Files |
| ---- | ------ | ----- |
| 1 | 609caa4 `fix(pier): MCP 401 challenge names only resource_metadata` | modules/pier/pier.go, modules/pier/tracer_smoke_test.go, modules/pier/README.md, docs/services/mcp.md |
| 2 | (uncommitted planning doc) | .planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md |
## Verification
- `gofmt -l modules/pier/` is empty. `go vet ./modules/pier/...` and `go vet ./...` pass.
- `go test -short ./modules/pier/...` passes, and so does the full `go test ./modules/pier/...` (Docker-backed, 10.3s).
- `go test ./cmd/summer -run TestDocsTree` passes. `go run ./cmd/summer docs:build --check` reports no problems.
- No `challengeWriter` or `scope="read"` is left in the code, the pier README or docs/services/mcp.md. The only remaining mention is the historical 19.7-02-PLAN.md, which was intentionally not edited.
- The Task 2 check passes: the row cites 261106-kt1, no `scope="read"` remains, and the numstat is 1+1.
- Tracer gate: the `<verify>` chain was re-run end to end and passed before Task 2.
## Deviations from Plan
None. The plan was executed as written. The full Docker-backed pier test run and the repo-wide `go vet ./...` were extra checks beyond the plan.
## Threat Flags
None. No new surface was added. T-261106-kt1-02 (verify closure and options unchanged, 401 pinned for bad and missing bearer) and T-261106-kt1-04 (original writer kept, so Flusher is preserved; stream tests pass) are mitigated as planned.
## Self-Check: PASSED
- FOUND: modules/pier/pier.go, modules/pier/tracer_smoke_test.go, modules/pier/README.md, docs/services/mcp.md, .planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md
- FOUND: commit 609caa4 (exactly the four Task 1 files; no lagoon/conga file touched)

View File

@@ -0,0 +1,222 @@
---
phase: quick-261106-kt2
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- modules/lagoon/commands.go
- modules/lagoon/purge_test.go
- modules/conga/schedule_test.go
- modules/conga/commands.go
- modules/conga/commands_test.go
autonomous: true
requirements:
- QUICK-261106-kt2
estimate:
tokens: 45000
raw_tokens: 45000
tasks: 2
confidence: low
must_haves:
truths:
- "When serve's in-process worker (or schedule:run) runs the framework entry summercms.lagoon[0]:deferred:purge, the command runs on the app's published database and succeeds; it no longer fails with `backpack: duplicate provider for *sql.DB`"
- "`summer deferred:purge` from the CLI (no database published) still opens, publishes and closes its own pool and purges exactly as before (TestPurgeDeferredCommand unchanged and green)"
- "A command run in-process never closes the shared pool it borrowed: after deferred:purge the published *sql.DB still answers PingContext and app.Lookup[*gorm.DB]() returns the same handle"
- "migrate, migrate:rollback and migrate:status share lagoon's withDB helper and therefore also work in-process with a published database"
- "queue:clear run in-process on an app with a published database (a plugin HasSchedule entry) clears the queue instead of failing with the duplicate-provider error"
artifacts:
- path: modules/lagoon/commands.go
provides: "withDB reuses the published *gorm.DB when present; otherwise the unchanged open -> Publish -> Close CLI path"
- path: modules/lagoon/purge_test.go
provides: "TestPurgeDeferredCommandPublishedDB: deferred:purge through bonfire.Call on an app whose pool is already published, real purge counts, pool still open afterwards"
- path: modules/conga/schedule_test.go
provides: "TestScheduledDeferredPurgeInProcess: callScheduled and schedule:run --once run deferred:purge from lagoon.RuntimeCommands on an app with a published pool"
- path: modules/conga/commands.go
provides: "queue:clear uses withAppDB"
- path: modules/conga/commands_test.go
provides: "TestQueueClear also clears through bonfire.Call on the published app"
key_links:
- from: modules/conga/scheduler.go runScheduled / callScheduled
to: modules/lagoon/commands.go deferred:purge Run -> withDB
via: "bonfire.Catalog.Call in-process; withDB must take app.Lookup[*gorm.DB]() instead of lagoon.Publish a second time"
- from: modules/lagoon/commands.go withDB
to: modules/backpack/services.go Registry.Publish
via: "the duplicate-provider error at services.go:36 is only reachable on the CLI-open branch, which now runs only when nothing is published"
---
<objective>
Fix the nightly framework schedule entry `deferred:purge`, which fails every night
in production since 2026-10-04 with
`level=ERROR msg="schedule: command failed" component=conga command=deferred:purge error="backpack: duplicate provider for *sql.DB"`
(framework v0.6.0; same code in v0.6.2). Root cause, verified by reading the code:
lagoon's unexported `withDB` (`modules/lagoon/commands.go:486`) always calls
`OpenFromApp` and then `lagoon.Publish`, which publishes `*sql.DB` on the app
(`modules/lagoon/connection.go:169`). From the CLI nothing is published yet, so it
works. Under serve the pool is already published (`modules/surf/serve.go:70-77`), and
serve's in-process worker runs the entry through `runScheduled` -> `callScheduled` ->
`bonfire.Catalog.Call` (`modules/conga/scheduler.go:148-182`), so the second Publish hits
`backpack: duplicate provider for *sql.DB` (`modules/backpack/services.go:36`). The same
happens under `schedule:run` (its `withDB` publishes first) and `schedule:run --once`
(`withAppDB` publishes first). Plugin commands such as `chat:prune-pending` succeed because
they take published services via `app.Lookup` and never publish a pool.
The fix follows the two guarded helpers that already exist: cabana's `withAdminDB`
(`modules/cabana/commands.go:218`) and conga's `withAppDB` (`modules/conga/commands.go:340`):
reuse the published handle when there is one, open/publish/close only for a CLI run.
Purpose: expired deferred bindings (abandoned uploads and records created under deferral)
get purged again by the scheduler; the CLI command keeps working unchanged.
Output: lagoon `withDB` fixed (deferred:purge plus the three migrate commands), conga
`queue:clear` switched to `withAppDB`, Docker-backed regression tests on both the lagoon
command and the conga scheduler path.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@CLAUDE.md
Verified facts (planner read these; do not re-investigate):
- Framework-registered `pact.ScheduledCommand` entries: only `lagoon.FrameworkSchedule`
(`modules/lagoon/schedule.go:57`, `deferred:purge` daily at `database.deferred_bindings.purge_at`,
default "03:00" in `app.timezone`, default UTC per `appLocation` in `modules/conga/schedule.go:91`).
No other module returns a `ScheduledCommand`.
- Sweep of command paths that open and publish a pool (`lagoon.OpenFromApp` + `lagoon.Publish`
outside tests): lagoon `withDB` (migrate, migrate:rollback, migrate:status, deferred:purge:
UNGUARDED, the bug); conga `withDB` (queue:work, schedule:run foreground, queue:clear:
unguarded); cabana `withAdminDB` (guarded); surf serve (the pool owner, intentionally opens).
`lagoon.Publish` is the only place that publishes `*sql.DB`.
- `lagoon.OpenFromApp` also calls `loadEncryptionKeysFromApp` -> `PublishEncryptionKeys`, which is
idempotent (`modules/lagoon/encrypted.go:130`). On the reuse branch the keys were already loaded
by whoever opened the pool (serve); deferred:purge does not touch Encrypted columns.
- `purgeBucket` (`modules/lagoon/commands.go:468`) already prefers the published `*blob.Bucket`.
With no bucket and no `storage.uploads.bucket_url` it returns nil, and `PurgeDeferred` refuses only
when an expired binding points at a file.
- Under `bonfire.Call` `confirmProduction` never asks (`modules/lagoon/commands.go:32-50`).
- Test helpers that exist: lagoon `newPurgeEnv`, `purgeConfig`, `purgePlugin`, `dedicatedDB`
(`modules/lagoon/purge_test.go`, `postgres_test.go`; Docker, skip under -short via `lagoonDB`);
conga `migratedDB`, `testApp` (publishes db + GORM like serve), `commandApp`, `testAppKey`
(`modules/conga/postgres_test.go:113,154`, `listen_test.go:274,295`), `onceRun`, `mustTime`,
`captureHandler` with `find(level, msg, key, value)` (`modules/conga/schedule_test.go:38-70,259`).
- Docker tests: if the Docker socket is denied although the user is in the docker group, run
them through `sudo -u golem` (CLAUDE.md).
- Concurrency: quick task 261106-kt1 is editing `modules/pier` and `docs/services/mcp.md`; do not
touch those. Stage files explicitly by path; leave `.planning/STATE.md`, `.planning/state.json`,
`.planning/G15OFFICE.md` and `examples/hello/plugins.gen.go` (other sessions' edits) alone.
- No README/docs change is expected: `withDB` and `withAppDB` are unexported, and the commands'
flags, output, config keys and CLI behaviour are unchanged. A grep of `modules/lagoon/README.md`,
`modules/conga/README.md`, `docs/plugins/scheduling.md` and `docs/console/` found no statement that
these commands always open their own connection.
@modules/lagoon/commands.go
@modules/conga/commands.go
@modules/conga/scheduler.go
</context>
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: Tracer - scheduler -> catalog -> deferred:purge runs on the app's published pool (test first, then fix lagoon withDB)</name>
<files>modules/lagoon/purge_test.go, modules/conga/schedule_test.go, modules/lagoon/commands.go</files>
<read_first>
- modules/lagoon/commands.go (lines 304-366 deferred:purge Run, 464-496 purgeBucket and withDB)
- modules/lagoon/purge_test.go (lines 49-73 newPurgeEnv, 386-400 purgeConfig, 444-503 purgePlugin and TestPurgeDeferredCommand)
- modules/conga/scheduler.go (lines 145-200 runScheduled, callScheduled, scheduledCatalog)
- modules/conga/schedule_test.go (lines 38-70 captureHandler, 259-310 onceRun and onceApp)
- modules/conga/postgres_test.go (lines 111-176 migratedDB and testApp)
- modules/cabana/commands.go (lines 218-234 withAdminDB, the pattern to mirror)
</read_first>
<behavior>
- lagoon TestPurgeDeferredCommandPublishedDB: an app whose pool was opened with OpenFromApp and published with Publish (as serve does) runs deferred:purge --days 2 through bonfire.Call and gets nil error and "purged 2 deferred bindings older than 2 days (1 files, 1 children, 0 skipped)"; afterwards the published *sql.DB still pings and app.Lookup[*gorm.DB]() is the same handle; migrate:status through bonfire.Call on the same app also returns nil.
- conga TestScheduledDeferredPurgeInProcess: with testApp's published pool and a published bonfire.NewCatalog(lagoon.RuntimeCommands(app, nil)), callScheduled(ctx, app, log, "deferred:purge", nil, &buf) returns skipped false and nil error, buf contains "purged 0 deferred bindings older than 5 days", the capture handler has an Info "schedule: command finished" record with command=deferred:purge, the pool still pings; schedule:run --once at 2026-03-10 03:00:00 UTC returns nil and prints "Ran 1 due entries (0 failed)".
- Before the fix both tests fail with an error containing "backpack: duplicate provider for *sql.DB" (the production error).
- Existing TestPurgeDeferredCommand (CLI path through bonfire.NewRoot, nothing published) stays green unchanged.
</behavior>
<action>
RED. In modules/lagoon/purge_test.go, directly after TestPurgeDeferredCommand, add TestPurgeDeferredCommandPublishedDB with a doc comment naming the bug (the in-process scheduler runs deferred:purge on an app whose pool serve already published). Build the environment the way TestPurgeDeferredCommand does: newPurgeEnv(t, "lagoon_purge_published"), one unattached file via e.file(t, ""), one created child via e.child, and two bindings backdated 3 days via e.bind (DeferredFileType for the file, "acme_purge_children" with DeferredEnvelope{Created: true} for the child). Create the app with backpack.New(purgeConfig(t, ...)) carrying database.dsn = e.dsn and app.key = base64 of 32 bytes of 7 (same as the existing test; app.key is required so that, before the fix, the failure is the duplicate-provider error and not the app.key error). Open the pool exactly as serve does: OpenFromApp(t.Context(), app), register t.Cleanup closing that sqlDB, then Publish(app, sqlDB, gdb); then attach.Publish(app, e.bucket). Run the command the way bonfire.Catalog.Call does for the scheduler: bonfire.Call(t.Context(), RuntimeCommands(app, plugins), "deferred:purge", []string{"--days", "2"}, &out) with plugins = a purgePlugin listing &purgeChild{}. Assert nil error, the output line above, that e.fileExists/e.blobExists/e.bindingIDs show the file, its blob and both bindings gone, that sqlDB.PingContext(t.Context()) still succeeds, and that app.Lookup[*gorm.DB]() returns the same gdb pointer. Then call bonfire.Call on the same app for "migrate:status" and assert nil error (the migrate commands share the helper).
In modules/conga/schedule_test.go add TestScheduledDeferredPurgeInProcess (Docker-backed through migratedDB, so it skips under -short). Use db, dsn := migratedDB(t) and app, _ := testApp(t, db, dsn, map with "app.key": testAppKey) — testApp publishes db and GORM on the app like serve. Publish slog.New over a fresh captureHandler on the app (as onceApp does), then publish bonfire.NewCatalog(lagoon.RuntimeCommands(app, nil)), the catalog the application binary publishes. Call callScheduled(t.Context(), app, loggerFromApp(app), "deferred:purge", nil, &buf) — the exact call runScheduled makes in serve's worker — and assert the behaviour above (skipped false, nil error, the "purged 0 deferred bindings older than 5 days" text in buf, the Info record via captureHandler.find, db.PingContext ok). Then run onceRun(t, app, nil, mustTime(t, time.UTC, "2026-03-10 03:00:00")) — the framework entry is due at the default 03:00 UTC — and assert nil error and "Ran 1 due entries (0 failed)" in the output. Add the lagoon import if schedule_test.go does not have it yet.
Run both new tests (commands in verify) BEFORE touching production code and confirm each fails with an error containing "backpack: duplicate provider for *sql.DB". Copy the failing lines into the SUMMARY as the RED evidence. Do not commit the failing tests on their own: CLAUDE.md requires go test ./... green at every commit, so tests and fix land in one commit.
GREEN. In modules/lagoon/commands.go change withDB: when app is non-nil and app.Lookup[*gorm.DB]() returns ok with a non-nil handle, call fn with that handle and return — no OpenFromApp, no Publish, and no Close, because the pool belongs to whoever published it (serve, schedule:run, queue:work, a test). Otherwise keep the current branch byte-for-byte: OpenFromApp, defer sqlDB.Close(), Publish, fn — so `summer deferred:purge` and the migrate commands from the CLI behave exactly as today. Mirror cabana's withAdminDB. Rewrite the doc comment of withDB in the repository's style: it runs fn on the app's published database when there is one (in-process runs: the scheduler in serve or schedule:run, bonfire.Catalog.Call), and otherwise opens and publishes a pool for the CLI run and closes it when fn returns. Do not change deferred:purge's Run body, purgeBucket, flags, output or Long text. This one change fixes deferred:purge and, through the shared helper, migrate, migrate:rollback and migrate:status.
Re-run: both new tests plus the existing TestPurgeDeferredCommand pass. Commit with message "fix(lagoon): run deferred:purge and the migrate commands on the app's published database (quick-261106-kt2)" and a body of two or three lines stating the production symptom and that the CLI path is unchanged. Stage exactly modules/lagoon/commands.go, modules/lagoon/purge_test.go and modules/conga/schedule_test.go by path. No Co-Authored-By trailer (user rule).
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./modules/lagoon/... ./modules/conga/... && L=$(go test ./modules/lagoon -run 'TestPurgeDeferredCommand' -count=1 -v 2>&1) && echo "$L" | grep -q -- '--- PASS: TestPurgeDeferredCommand ' && echo "$L" | grep -q -- '--- PASS: TestPurgeDeferredCommandPublishedDB' && C=$(go test ./modules/conga -run 'TestScheduledDeferredPurgeInProcess' -count=1 -v 2>&1) && echo "$C" | grep -q -- '--- PASS: TestScheduledDeferredPurgeInProcess' && grep -n 'Lookup\[\*gorm.DB\]' modules/lagoon/commands.go</automated>
</verify>
<done>RED evidence (duplicate provider for *sql.DB from both new tests before the fix) is recorded; after the fix TestPurgeDeferredCommand, TestPurgeDeferredCommandPublishedDB and TestScheduledDeferredPurgeInProcess pass against testcontainers Postgres; lagoon withDB reuses a published *gorm.DB and never closes it; the CLI branch is unchanged; one commit with the three files.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Same fix for conga queue:clear, sweep record and full checks</name>
<files>modules/conga/commands_test.go, modules/conga/commands.go</files>
<read_first>
- modules/conga/commands.go (lines 94-127 queue:clear, 338-345 withAppDB, 399-411 withDB)
- modules/conga/commands_test.go (lines 20-85 TestQueueClear)
</read_first>
<behavior>
- TestQueueClear additionally clears through bonfire.Call on the published app: while the blocking job still holds the single default worker slot, two more pending ping jobs are enqueued, and queue:clear via bonfire.Call(ctx, RuntimeCommands(app, nil), "queue:clear", nil, &out) returns nil with "Cleared 2 jobs" in the output; the existing final assertions (block completes, zero ping rows left, zero pings ran) still hold.
- Before the fix that new step fails with "backpack: duplicate provider for *sql.DB".
- The existing CLI-style runClear (commandApp, nothing published) is unchanged and still passes.
</behavior>
<action>
RED. In modules/conga/commands_test.go extend TestQueueClear: after the existing runClear assertions and BEFORE close(release) (the blocking job keeps the one default worker slot busy, so new pings stay pending), enqueue two more pingArgs jobs with m.Enqueue as the test already does (use a different Tag, for example "pending-in-process"), then run queue:clear through bonfire.Call(ctx, RuntimeCommands(app, nil), "queue:clear", nil, &out) on the test's own published `app` — the in-process path a plugin pact.HasSchedule entry naming queue:clear would take. Assert nil error and "Cleared 2 jobs" in the output. Leave the existing runClear (commandApp, the CLI path) and the final assertions as they are. Run it and confirm the new step fails with "backpack: duplicate provider for *sql.DB"; record it in the SUMMARY.
GREEN. In modules/conga/commands.go change only queue:clear's Run to call withAppDB instead of withDB. Leave queue:work and schedule:run (foreground) on withDB: both are long-running processes started from the CLI that own their pool, and running either as an in-process scheduled entry is not meaningful. Extend withAppDB's doc comment to say it serves schedule:run --once and queue:clear, which may run in-process on an app that already published its pool. Do not change flags, output, the confirmation question or the Long text.
Sweep record for the SUMMARY (no code change beyond this task): framework ScheduledCommand registrations are only lagoon.FrameworkSchedule's deferred:purge; remaining unguarded OpenFromApp + Publish paths are conga withDB for queue:work and schedule:run (CLI pool owners, intentionally) and surf serve (the pool owner); cabana withAdminDB was already guarded. Confirm with grep -rn 'lagoon.OpenFromApp' modules --include=*.go excluding _test.go.
Full checks (commands in verify): go vet ./..., go test -short ./... (CLAUDE.md keeps it green at every commit), then the Docker-backed go test ./modules/lagoon/... ./modules/conga/... -count=1. If the Docker socket is denied, rerun the Docker step through sudo -u golem per CLAUDE.md.
Commit with message "fix(conga): queue:clear reuses the app's published database (quick-261106-kt2)", staging exactly modules/conga/commands.go and modules/conga/commands_test.go by path. No Co-Authored-By trailer. Do not push or tag.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./... && go test -short ./... && Q=$(go test ./modules/conga -run 'TestQueueClear$' -count=1 -v 2>&1) && echo "$Q" | grep -q -- '--- PASS: TestQueueClear ' && grep -B1 'return clearQueue(ctx, app, queue, out)' modules/conga/commands.go | grep -q withAppDB && go test ./modules/lagoon/... ./modules/conga/... -count=1</automated>
</verify>
<done>queue:clear uses withAppDB; TestQueueClear passes both its CLI run and the new in-process run; go vet ./..., go test -short ./... and the Docker-backed lagoon and conga suites are green; sweep result recorded; one commit with the two conga files; nothing under modules/pier or docs/services/mcp.md touched.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| river_job rows -> scheduled command runner | A scheduled job row names an entry and command; runScheduled runs only an exact match of the compiled schedule table (T-11-09, unchanged) |
| in-process command -> app's shared database pool | A command run through bonfire.Catalog.Call borrows the pool serve owns and must not close or replace it |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-261106-kt2-01 | Denial of Service | lagoon withDB / conga withAppDB reuse branch closing the borrowed pool would take serve's database away | high | mitigate | Reuse branch calls fn only, with no defer Close and no Publish; TestPurgeDeferredCommandPublishedDB and TestScheduledDeferredPurgeInProcess assert the published *sql.DB still pings and the same *gorm.DB stays published after the command |
| T-261106-kt2-02 | Information Disclosure | deferred:purge failing nightly keeps abandoned pending uploads (files and records) past the configured retention (default 5 days) | medium | mitigate | The fix itself plus the scheduler-path regression test that runs the framework entry through callScheduled and schedule:run --once |
| T-261106-kt2-03 | Elevation of Privilege | scheduler executing an arbitrary command from a forged river_job row | low | accept | Unchanged: runScheduled matches entry, command and args against the compiled table (T-11-09, covered by TestScheduledEntryMismatchSkipped); this plan does not touch scheduler.go |
| T-261106-kt2-04 | Tampering | CLI branch regressions (migrate, deferred:purge from the terminal) | low | mitigate | CLI branch kept byte-for-byte; existing TestPurgeDeferredCommand (bonfire.NewRoot, nothing published) and TestQueueClear's runClear (commandApp) keep covering it |
| T-261106-kt2-SC | Tampering | npm/pip/cargo installs | low | accept | No package installs or dependency changes in this plan |
</threat_model>
<verification>
- `go vet ./...` and `go test -short ./...` green.
- Docker: `go test ./modules/lagoon/... ./modules/conga/... -count=1` green, including TestPurgeDeferredCommand, TestPurgeDeferredCommandPublishedDB, TestScheduledDeferredPurgeInProcess and TestQueueClear.
- SUMMARY holds the RED output (duplicate provider for *sql.DB) for the three new test steps and the sweep result.
- `git show --stat HEAD~1 HEAD` lists exactly the three Task 1 files and the two Task 2 files; nothing under modules/pier, docs/ or .planning/ in the code commits.
</verification>
<success_criteria>
The framework schedule entry deferred:purge succeeds when serve's worker, schedule:run or schedule:run --once runs it on an app whose database is already published, without closing that pool; `summer deferred:purge` and the migrate commands from the CLI behave as before; queue:clear works in-process too; all pinned by Docker-backed tests that failed with the production error before the fix.
</success_criteria>
<output>
Create `.planning/quick/261106-kt2-lagoon-deferred-purge-fails-in-the-sched/261106-kt2-SUMMARY.md` when done, with the RED evidence, the sweep result, the two commit hashes, and a note that no README/docs change was needed (unexported helpers, command behaviour from the CLI unchanged). Note for the user: the fix reaches production only with the next framework patch tag and an app bump; do not tag or push here.
</output>

View File

@@ -0,0 +1,106 @@
---
phase: quick-261106-kt2
plan: 01
subsystem: lagoon, conga
tags: [scheduler, deferred-bindings, database-pool, bugfix]
status: complete
requires: []
provides:
- "lagoon withDB reuses the published *gorm.DB (deferred:purge, migrate, migrate:rollback, migrate:status work in-process)"
- "conga queue:clear runs through withAppDB"
affects: [conga scheduler, serve in-process worker, schedule:run]
tech-stack:
added: []
patterns: ["reuse the published handle, open/publish/close only for a CLI run (same as cabana withAdminDB, conga withAppDB)"]
key-files:
created: []
modified:
- modules/lagoon/commands.go
- modules/lagoon/purge_test.go
- modules/conga/schedule_test.go
- modules/conga/commands.go
- modules/conga/commands_test.go
decisions:
- "lagoon withDB borrows the published *gorm.DB without Publish or Close; the pool belongs to whoever published it"
- "queue:work and foreground schedule:run stay on conga withDB: long-running CLI processes that own their pool"
metrics:
duration: ~15min
completed: 2026-11-06
actuals:
tokens: 2070
tasks: 2
commits: 3
plan_head_before: 609caa4f3f1f24f123696ab1d8fd8b6bfcef113a
plan_head_after: 99abe88b6a33275817ae3f90d33b2f8a50717a2f
---
# Quick 261106-kt2: deferred:purge fails in the scheduler, Summary
The nightly framework entry `deferred:purge` now runs on the app's published database pool under serve's in-process worker, `schedule:run` and `schedule:run --once`. It no longer fails with `backpack: duplicate provider for *sql.DB`. `queue:clear` got the same fix.
## What changed
- `modules/lagoon/commands.go` `withDB`: when the app already has a published `*gorm.DB`, `fn` runs on that handle. There is no `OpenFromApp`, no `Publish` and no `Close`. Otherwise the CLI branch (open, defer Close, Publish, fn) is unchanged. This fixes `deferred:purge`, `migrate`, `migrate:rollback` and `migrate:status`. It mirrors cabana's `withAdminDB`.
- `modules/conga/commands.go`: `queue:clear` calls `withAppDB` instead of `withDB`, and the `withAppDB` doc comment now names schedule:run --once and queue:clear.
## Commits
| Task | Commit | Message |
|------|--------|---------|
| 1 | 16a2008 | fix(lagoon): run deferred:purge and the migrate commands on the app's published database (quick-261106-kt2) |
| 2 | 99abe88 | fix(conga): queue:clear reuses the app's published database (quick-261106-kt2) |
`actuals.commits` reads 3 because the ledger range `609caa4..HEAD` also holds `8ebfedf`, a docs commit from the concurrent quick task 261106-kt1. This task made 2 commits.
## RED evidence (before the fix)
```
=== RUN TestPurgeDeferredCommandPublishedDB
purge_test.go:532: deferred:purge: backpack: duplicate provider for *sql.DB
--- FAIL: TestPurgeDeferredCommandPublishedDB (0.24s)
schedule_test.go:275: callScheduled: skipped=false err=conga: scheduled command deferred:purge: backpack: duplicate provider for *sql.DB
--- FAIL: TestScheduledDeferredPurgeInProcess (0.28s)
commands_test.go:86: in-process queue:clear: backpack: duplicate provider for *sql.DB
--- FAIL: TestQueueClear (10.38s)
```
TestPurgeDeferredCommand (the CLI path) passed both before and after the fix.
## GREEN / verification
- `go vet ./...`: ok
- `go test -short ./...`: ok
- Docker: `go test ./modules/lagoon/... ./modules/conga/... -count=1`: all ok (lagoon 22.2s, lagoon/attach 40.8s, lagoon/attach/webp, conga 31.5s). Includes TestPurgeDeferredCommand, TestPurgeDeferredCommandPublishedDB, TestScheduledDeferredPurgeInProcess and TestQueueClear.
- The new tests check that the published `*sql.DB` still answers PingContext after the command and that `app.Lookup[*gorm.DB]()` returns the same handle (T-261106-kt2-01).
## Sweep result
`grep -rn 'OpenFromApp(' modules --include=*.go` (non-test):
- `modules/lagoon/commands.go` withDB: now guarded (this task)
- `modules/cabana/commands.go` withAdminDB: already guarded
- `modules/conga/commands.go` withDB: used only by queue:work and foreground schedule:run. Both are long-running CLI processes that own their pool, so this is intentional. queue:clear moved to withAppDB.
- `modules/surf/serve.go`: serve owns the pool, so it opens one on purpose.
The only framework `ScheduledCommand` registration is `lagoon.FrameworkSchedule` (`deferred:purge`).
## Deviations from Plan
**1. [Rule 3 - Blocking] TestQueueClear's app needed `app.key`**
- **Found during:** Task 2 RED
- **Issue:** testApp in TestQueueClear had no `app.key`, so the in-process queue:clear first failed with `lagoon: app.key is empty or invalid` rather than the production error.
- **Fix:** added `"app.key": testAppKey` to that test's testApp config. The RED run then showed `backpack: duplicate provider for *sql.DB`. The CLI-style runClear and the final assertions are unchanged.
- **Files modified:** modules/conga/commands_test.go
- **Commit:** 99abe88
## Notes
- No README or docs change was needed: `withDB` and `withAppDB` are unexported, and the commands' flags, output, config keys and CLI behaviour are unchanged.
- The fix reaches production only with the next framework patch tag and an app bump. Nothing was tagged or pushed.
- No files under modules/pier or docs/services/mcp.md were touched.
## Self-Check: PASSED
- FOUND: modules/lagoon/commands.go, modules/lagoon/purge_test.go, modules/conga/schedule_test.go, modules/conga/commands.go, modules/conga/commands_test.go
- FOUND: 16a2008, 99abe88

View File

@@ -17,3 +17,7 @@ To fix later:
- Add a test in sm-user-plugin (a gate that challenges, then activate-by-code answers the challenge) and an end-to-end case in sm-mfa-plugin's `TestEveryLoginPathIsGated`, and update its `tokenMints` reason.
- Release sm-user-plugin as a patch and bump sm-demo-app, sm-bm-app and fonoteka.go in separate commits (D-17 order).
- Until then, `TestEveryLoginPathIsGated/mint_sites` lists `ActivateByCode` with a pointer to F-1, so any change to the mint sites fails until the list is updated.
## Closed 2026-11-05
Fixed in quick 261105-oc5: sm-user-plugin b179e6b (activate-by-code asks the login gates). Untagged; release sm-user-plugin v0.1.20 and bump the apps.

View File

@@ -14,3 +14,7 @@ Full fix (a contract change, user deferred it on 2026-10-26):
- Require a `password` field (or a current factor) on those routes, refused with the existing 403 `{"error":"Password confirmation required."}` and counted in the login throttle like the recovery-code routes (WR-06, 0a08070).
- Update the frontends that call them (sm-demo-app's vue-demo-app `pages/account/security.vue` and any app frontend) to ask for the password.
- Release sm-mfa-plugin as a minor/patch together with the frontend change, and bump the apps in separate commits.
## Closed 2026-11-05
Fixed in quick 261105-oc5: sm-mfa-plugin 3aa52c6 (golem15.mfa.factor_changes_require_password, default true), vue-demo-app 848ee1e, sm-demo-app 7b75d2c. Untagged; release sm-mfa-plugin v0.2.0.

View File

@@ -19,3 +19,7 @@ Follow-up from quick 261028-rqe (ba8432c, 4957e5c). `attach.Store` saves `Conten
- Make both handlers send the stored disposition (and `X-Content-Type-Options: nosniff`), additive.
- Blob writers that bypass `attach.Store` can adopt `attach.IsInlineContentType`: fonoteka.go `storeUpload`/`StorePublicFile` (see `2026-10-28-fonoteka-uploads-use-disk-extension.md`), the sm-user-plugin avatar writer, the sm-feedback-plugin screenshot writer.
- Adding the disposition to objects already in a bucket stays an operator task (documented in `docs/services/storage.md#deploy-note`).
## Closed 2026-11-05
Fixed in quick 261105-oc8: summercms.go 27c98bf. Writers that bypass attach.Store (fonoteka.go, sm-user avatar, sm-feedback screenshot) remain follow-ups.

View File

@@ -0,0 +1,27 @@
---
created: 2026-11-06T08:17:49.419Z
title: Blob writers that bypass attach.Store set Content-Disposition for non-inline types
area: framework
severity: minor
g15office: ifdz5o0m
files:
- modules/lagoon/attach/inline.go:31 (IsInlineContentType), :38 (uploadWriterOptions, unexported)
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collection_media_controller.go:258 (storeUpload)
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_files.go:42 (StorePublicFile)
- ../sm-user-plugin/controllers/api_controller.go:1559 (avatar upload)
- ../sm-feedback-plugin/classes/submission_store.go:131 (screenshot)
---
## Problem
Follow-up of g15office knuu7fil (closed by summercms.go 27c98bf). `attach.Store` stores every non-inline type (anything but JPEG/PNG/GIF/WebP/AVIF) with `Content-Disposition: attachment`, and `attach.StaticHandler` / `StaticHandlerPublic` now send the stored disposition plus `nosniff`. Writers that call `bucket.NewWriter` / `WriteAll` themselves pass only `ContentType`, so their blobs carry no disposition and an HTML or SVG blob is served inline by the static handlers (or by a web server reading the bucket directly).
Writers that bypass `attach.Store`:
- fonoteka.go `storeUpload` and `StorePublicFile`: accept arbitrary media, so this is the real gap. The disk-extension issue at the same call sites is the separate major todo `2026-10-28-fonoteka-uploads-use-disk-extension.md` (cf81chjk); do both together.
- sm-user-plugin avatar upload and sm-feedback-plugin screenshots: inputs are already limited to images, so this is defence in depth.
## Solution
- Framework (additive): export the writer-options helper, e.g. `attach.UploadWriterOptions(contentType) *blob.WriterOptions`, with README and docs updated in the same commit. Writers can also check `attach.IsInlineContentType` themselves, or switch to `attach.Store`.
- Each writer uses it. Shared plugins: additive change only, plugin commit and patch tag first, then pointer bumps in the apps as separate commits. A plugin using a new framework export needs the framework tag that has it, so raise the plugin's required version only if consumers are already on that version; otherwise inline the `IsInlineContentType` check.
- Regression test per writer: an upload whose sniffed type isn't an inline image is stored with `attachment` disposition.

View File

@@ -19,3 +19,7 @@ chatchannels gates PinMessage and UnpinMessage only with channel access, which t
Additively in chatchannels: PinMessage and UnpinMessage require CanManageContext, or an optional contracts.PinPolicy the host implements; the demo answers it from its moderator group. Verify message.conversation_id equals the channel's conversation before creating the pin. Meanwhile the demo layer can hide pin actions for non-moderators, but the server must enforce the rule.
Source: phase 20 code review (20-REVIEW.md WR-02).
## Closed 2026-11-05
Fixed in quick 261105-oc6: sm-chatchannels-plugin a042536, sm-demo-app ce800f2/7e4b9d3/11d3afc. Untagged; sm-bm-app needs CanPin before it bumps.

View File

@@ -18,3 +18,7 @@ The server has no DM-only rule; channel calls are refused today only because the
Add an optional host hook in chatvideo (for example CanStartCall(conversationType)), or check conversation.type == dm in the demo, so the rule does not depend on seed data.
Source: phase 20 code review (20-REVIEW.md IN-01).
## Closed 2026-11-05
Fixed in quick 261105-oc7: sm-chatvideo-plugin 11a9607 (contracts.CallPolicy, DMOnly), sm-demo-app fbfdcf6.

View File

@@ -17,3 +17,7 @@ Two video:call:incoming events for the same session, both pending in subscribe,
Keep a sequence number per watchSession and close the handle unless it is the latest.
Source: phase 20 code review (20-REVIEW.md IN-07).
## Closed 2026-11-05
Fixed in quick 261105-oc7: vue-chatvideo-layer 6d5784d. Untagged.

View File

@@ -18,3 +18,7 @@ chatvideo checks participant rows only. A ban revokes the user's JWTs, but an al
Have chatvideo honour LiveMembership (through chat's Service.IsParticipant) in requireParticipant and the authorizer, and remove the participant from the LiveKit room on a ban.
Source: phase 20 code review (20-REVIEW.md IN-02).
## Closed 2026-11-05
Fixed in quick 261105-oc7: sm-chatvideo-plugin f996156. Untagged.

View File

@@ -17,3 +17,7 @@ check-local still puts member JWTs into -H "Authorization: Bearer ..." arguments
Give request() an optional JWT that it writes as header = "Authorization: Bearer ..." into a curl config on stdin (curl -K -), as check-moderation.sh does, and drop every -H Authorization argument.
Source: phase 20 code review (20-REVIEW.md IN-03).
## Closed 2026-11-05
Fixed in quick 261105-oc9: sm-demo-app 55620dc.

View File

@@ -17,3 +17,7 @@ The GET assigns handles to members that lack one (two concurrent first loads rac
Select with the memberWhere subquery (WHERE id <> ? AND <memberWhere>) instead of an id list, and assign missing handles at registration or in demo:seed, not in a GET.
Source: phase 20 code review (20-REVIEW.md IN-09).
## Closed 2026-11-05
Fixed in quick 261105-oc9: sm-demo-app 9595606.

View File

@@ -18,3 +18,7 @@ Browsers need only /rtc and /rtc/validate, but the /livekit/ location also expos
Use location ^~ /livekit/rtc { proxy_pass http://127.0.0.1:7890/rtc; ... } and answer 404 for the rest of /livekit/; adjust check-deploy and check-live. A vhost change on rome needs its own go-ahead.
Source: phase 20 code review (20-REVIEW.md IN-06).
## Closed 2026-11-05
Fixed in quick 261105-oc9: sm-demo-app 6c52514. Committed only; installing the vhost on rome needs its own go-ahead.

View File

@@ -17,3 +17,7 @@ seedModerationLists refills both lists whenever both are empty, and every releas
Seed only once: insert the lists when the settings row is created, or record a seeded flag.
Source: phase 20 code review (20-REVIEW.md IN-05).
## Closed 2026-11-05
Fixed in quick 261105-oc9: sm-demo-app 5630d92 (golem15.demo migration).

View File

@@ -17,3 +17,7 @@ The remote script uses set -e without pipefail. If herald:vapid-generate fails,
Use set -eo pipefail, generate into a variable first, check both keys are non-empty, then append all three lines in one tee.
Source: phase 20 code review (20-REVIEW.md IN-08).
## Closed 2026-11-05
Fixed in quick 261105-oc9: sm-demo-app 054cda5.

View File

@@ -84,7 +84,7 @@ fmt.Println(url)
URLs start with `storage.uploads.public_path_prefix` (`/storage/uploads` by default), or with `storage.uploads.public_url` when a public bucket or CDN serves the files (see [Public URL base](../services/storage.md#public-url-base)). `attach.File.URL` returns the URL of the original, the path WinterCMS's `File::getPath()` returns, and `attach.PublicURL` the URL of any blob key; [Storage](../services/storage.md#the-wintercms-layout) shows the configuration that reproduces WinterCMS's URLs exactly.
Originals in JPEG, PNG, GIF and WebP can be thumbnailed. The thumbnailer cannot write WebP, so the thumbnail of a `.webp` original holds JPEG bytes under the original's `.webp` name, and it is stored with the `image/jpeg` content type. Before decoding, the thumbnailer reads the image size from the file header, so an image larger than 4096 by 4096 pixels is never decoded. As WinterCMS's `File::makeThumb` does, an original that is missing, does not decode or is too large gets WinterCMS's 200 by 200 broken-image picture (`attach.BrokenImagePNG`) stored as its thumbnail, and the reason is logged at warn level: one unusable upload never makes the pages that list it fail. `attach.StaticHandler` serves originals and thumbnails under that prefix; `attach.StaticHandlerPublic` does the same and answers 404 for a row whose `is_public` flag is false. Mount the gated handler when a bucket holds any private file.
Originals in JPEG, PNG, GIF and WebP can be thumbnailed. The thumbnailer cannot write WebP, so the thumbnail of a `.webp` original holds JPEG bytes under the original's `.webp` name, and it is stored with the `image/jpeg` content type. Before decoding, the thumbnailer reads the image size from the file header, so an image larger than 4096 by 4096 pixels is never decoded. As WinterCMS's `File::makeThumb` does, an original that is missing, does not decode or is too large gets WinterCMS's 200 by 200 broken-image picture (`attach.BrokenImagePNG`) stored as its thumbnail, and the reason is logged at warn level: one unusable upload never makes the pages that list it fail. `attach.StaticHandler` serves originals and thumbnails under that prefix; `attach.StaticHandlerPublic` does the same and answers 404 for a row whose `is_public` flag is false. Both send `X-Content-Type-Options: nosniff` and each file's stored Content-Type and Content-Disposition, and send no Content-Security-Policy, which is why the warning below still applies. Mount the gated handler when a bucket holds any private file.
> [!WARNING]
> Serve uploads from a separate origin, or at least never mount the ungated handler on the application's own origin. An uploaded file served with its own content type from the API's origin can run script in that origin.
@@ -137,7 +137,9 @@ The client file name never reaches a blob key. The disk name is 22 random hexade
The disk name's extension follows the content, not the client's name, because a static web server in front of the uploads directory picks the Content-Type from the extension. A public bucket, or a CDN that pulls from it, sends the type stored with the object instead; see the [deploy note](../services/storage.md#deploy-note) for both cases. `attach.DiskExtension` derives the extension from the sniffed type and the client's extension. For a specific type such as PNG, JPEG, PDF, MP3 or WebM, the client's extension is kept when it names that type, else the type's usual extension is used: a PNG uploaded as `x.html` (with `html` allowed) is stored as `<hex>.png`, and its URL ends in `.png`. For a generic sniff (plain text, HTML, XML, a zip container or unknown bytes) the client's extension is kept, so `report.docx` stays `.docx` and an HTML body uploaded as `note.txt` stays `.txt`, unless the extension is one a browser renders or runs (html, svg, xml, js, css, php and the like), which becomes `.txt` for text and `.bin` otherwise. `file_name` always keeps the client's name, so downloads still carry it.
`attach.Store` writes every file whose type `attach.IsInlineContentType` does not report as a raster image (JPEG, PNG, GIF, WebP, AVIF) with `Content-Disposition: attachment`, so a browser that opens it from a public bucket downloads it instead of rendering it. `<img>`, `<video>` and `<audio>` embeds and the `content_type` column are unaffected, and thumbnails stay inline.
`attach.Store` writes every file whose type `attach.IsInlineContentType` does not report as a raster image (JPEG, PNG, GIF, WebP, AVIF) with `Content-Disposition: attachment`, so a browser that opens it from a public bucket or through `attach.StaticHandler` and `attach.StaticHandlerPublic` downloads it instead of rendering it. `<img>`, `<video>` and `<audio>` embeds and the `content_type` column are unaffected, and thumbnails stay inline.
Code that writes an upload to the bucket itself instead of calling `attach.Store` gets the same protection the same way: sniff the type first, name the file with `attach.DiskExtension(contentType, clientExt)` and pass `attach.UploadWriterOptions(contentType)` to the bucket writer (`NewWriter` or `WriteAll`). `file_name` keeps the client's name.
The content type is sniffed from the first bytes of the body, never taken from the client. `Limits.MIMETypes` narrows it further: an entry with a slash is a MIME pattern such as `image/png` or `image/*`, an entry without one is an extension, and a file that matches no entry returns `attach.ErrMIMEType`. `Limits.MaxBytes` is enforced while the body streams into the bucket; a body one byte over the limit returns `attach.ErrTooLarge` and leaves no blob and no row behind.

View File

@@ -138,7 +138,7 @@ Sessions follow the streamable HTTP transport: `initialize` opens one, `Mcp-Sess
## Protected resource metadata
pier serves the OAuth protected resource metadata (RFC 9728) at `/.well-known/oauth-protected-resource` and at `/.well-known/oauth-protected-resource<mcp.path>`: the resource, the authorization server (the verifier's resource origin, else `app.url`), the supported scopes and the bearer method. When the published verifier implements `pact.MCPAdvertisedScopes` and `pact.MCPAdvertisedScopes.MCPScopesSupported` is non-empty, that slice is copied; otherwise the document keeps `pier.PRMScopes` (`read`, `write`, `ai`, `offline_access`, `backend`). pier does not read `pact.MemberTokenScopeCatalog`. The list the OAuth server plugin publishes is the MCP advertised set: the fixed names `read`, `write`, `ai`, `offline_access` and `backend` are always on it. Declared member scopes follow the plugin's `golem15.oauthserver.mcp_scopes` filter, which is that plugin's key and not a framework `mcp.*` key: unset means every declared member scope, a set filter means only those declared names, and an empty filter means the five fixed names only. An empty `wristband.Options.ScopesSupported` on the MCP flavour resolves to those five names; a non-empty list is used as given. OpenID Connect discovery stays `openid`, `profile` and `email`. A route reads who called a tool with `pact.MCPCallFrom`. A 401 carries `WWW-Authenticate: Bearer resource_metadata="…", scope="read"`, which is how a client finds the authorization server. `mcp.resource` must equal the resource the OAuth server issues tokens for; a difference is logged as a warning at boot.
pier serves the OAuth protected resource metadata (RFC 9728) at `/.well-known/oauth-protected-resource` and at `/.well-known/oauth-protected-resource<mcp.path>`: the resource, the authorization server (the verifier's resource origin, else `app.url`), the supported scopes and the bearer method. When the published verifier implements `pact.MCPAdvertisedScopes` and `pact.MCPAdvertisedScopes.MCPScopesSupported` is non-empty, that slice is copied; otherwise the document keeps `pier.PRMScopes` (`read`, `write`, `ai`, `offline_access`, `backend`). pier does not read `pact.MemberTokenScopeCatalog`. The list the OAuth server plugin publishes is the MCP advertised set: the fixed names `read`, `write`, `ai`, `offline_access` and `backend` are always on it. Declared member scopes follow the plugin's `golem15.oauthserver.mcp_scopes` filter, which is that plugin's key and not a framework `mcp.*` key: unset means every declared member scope, a set filter means only those declared names, and an empty filter means the five fixed names only. An empty `wristband.Options.ScopesSupported` on the MCP flavour resolves to those five names; a non-empty list is used as given. OpenID Connect discovery stays `openid`, `profile` and `email`. A route reads who called a tool with `pact.MCPCallFrom`. A 401 carries `WWW-Authenticate: Bearer resource_metadata="…"`, which is how a client finds the authorization server. It carries no scope parameter, so a client requests the scopes listed in the metadata document and consent decides what is granted. `mcp.resource` must equal the resource the OAuth server issues tokens for; a difference is logged as a warning at boot.
## Security notes

View File

@@ -39,7 +39,7 @@ func newServer() *wristband.Server {
> [!WARNING]
> Always set `wristband.Options.Resource` for your deployment. `wristband.DefaultOptions` leaves it empty, and while it is empty every authorization request that sends a `resource` parameter is refused with `invalid_target`.
The defaults cover the rest: pending requests and codes live 10 minutes, access tokens 1 hour and refresh tokens 30 days, at most 200 unrevoked clients may register, and registration bodies are capped at 64 KiB. `wristband.Options.ScopesSupported` is both the advertised list and the authorize allow-list, one resolved slice. A non-empty list is used as given: the example above drops `ai`, so a request for it is `invalid_scope`, and `wristband.DefaultOptions` (`read`, `write`, `ai`, `offline_access`) still refuses `backend`. An empty or nil list on the MCP flavour resolves to `read`, `write`, `ai`, `offline_access` and `backend`. OpenID Connect discovery stays `openid`, `profile` and `email`.
The defaults cover the rest: pending requests and codes live 10 minutes, access tokens 1 hour and refresh tokens 30 days, at most 200 unrevoked clients may register, and registration bodies are capped at 64 KiB. `wristband.Options.ScopesSupported` is both the advertised list and the authorize allow-list, one resolved slice. A non-empty list is used as given: the example above drops `ai`, so a request for it is `invalid_scope`, and `wristband.DefaultOptions` (`read`, `write`, `ai`, `offline_access`) still refuses `backend`. The OpenID Connect identity scopes `openid`, `profile` and `email` are dropped from the request before the allow-list check (RFC 6749 section 3.3 lets a server grant fewer scopes than requested), so a client that also reads the issuer's OpenID Connect discovery document still connects; a request with nothing else left gets the default `read`, and the three are never advertised. An empty or nil list on the MCP flavour resolves to `read`, `write`, `ai`, `offline_access` and `backend`. OpenID Connect discovery stays `openid`, `profile` and `email`.
The MCP advertised set is that resolved list. The fixed names `read`, `write`, `ai`, `offline_access` and `backend` are always on it. Declared member scopes (`pact.FrontendTokenScope`, including `pact.FrontendTokenScope.Implies`) come from the published `pact.MemberTokenScopeCatalog` and are filtered by the OAuth server plugin's key `golem15.oauthserver.mcp_scopes`. That key is the plugin's, not a framework config key: unset means every declared member scope, a set filter means only those declared names, and an empty filter means the five fixed names only. The plugin publishes the list as `pact.MCPAdvertisedScopes`. pier copies `pact.MCPAdvertisedScopes.MCPScopesSupported` when it is non-empty and otherwise keeps `pier.PRMScopes`. The read/write ceiling is unchanged. A route the endpoint runs reads the caller with `pact.MCPCallFrom`: `pact.MCPSubject.TokenID` is a member personal-token id only when `pact.MCPSubject.ClientID` is empty, and the OAuth access-token row otherwise, and `pact.MCPSubject.Scopes` is the grant, not an implied expansion. The metadata document serves the configured values:
@@ -214,7 +214,7 @@ Before either render method runs, the engine sets `X-Frame-Options: DENY`, `Cont
### Client kinds
`wristband.ClientRecord.Kind` and `wristband.OIDCClientRecord.Kind` tell the two kinds of client apart: `wristband.ClientKindMCP` for MCP clients (dynamically registered or issued by an operator command; an empty kind means the same) and `wristband.ClientKindOIDC` for clients an administrator registers. Each server accepts only its own kind at authorize, token and userinfo, and the MCP server keeps refusing the `openid` scope, so a dynamically registered client can never obtain identity scopes or an `id_token`.
`wristband.ClientRecord.Kind` and `wristband.OIDCClientRecord.Kind` tell the two kinds of client apart: `wristband.ClientKindMCP` for MCP clients (dynamically registered or issued by an operator command; an empty kind means the same) and `wristband.ClientKindOIDC` for clients an administrator registers. Each server accepts only its own kind at authorize, token and userinfo, and the MCP server drops `openid`, `profile` and `email` at authorize and never issues an `id_token`, so a dynamically registered client can never obtain identity scopes or an `id_token`.
### Routes

View File

@@ -119,7 +119,7 @@ Admin fileupload previews do not depend on this route: when a public image's URL
The route looks up the bucket and the database on each request: `route:list` lists it without opening either, and it answers 404 until `serve` has published them. The route owns the whole prefix, so start-up fails when a plugin registers a route at or under it.
Without the key, the application decides where files are served: mount `attach.StaticHandlerPublic` itself, or put a web server or CDN in front of the bucket directory. Either way, serve uploads from a separate origin when you can; the [Attachments](../database/attachments.md) page explains why. The built-in route is meant for development and for deployments that cannot split the origin yet.
Without the key, the application decides where files are served: mount `attach.StaticHandlerPublic` itself, or put a web server or CDN in front of the bucket directory. The handler sends the stored Content-Type, the stored Content-Disposition (so a file `attach.Store` stored as an attachment downloads) and `X-Content-Type-Options: nosniff`, but not the route's sandboxing Content-Security-Policy, and it adds no disposition to a file stored without one (see the [deploy note](#deploy-note)). Either way, serve uploads from a separate origin when you can; the [Attachments](../database/attachments.md) page explains why. The built-in route is meant for development and for deployments that cannot split the origin yet.
### Deploy note
@@ -159,9 +159,9 @@ Extend the extension list only with types the application accepts (see `attach.D
**A public S3 bucket, or a CDN that pulls from it, serving the files under `storage.uploads.public_url`.** It does not look at the extension: it sends the headers stored with the object. The stored Content-Type is the sniffed type, so an HTML body uploaded as `note.txt` (txt is in `attach.DefaultFileExtensions`) is stored as `<hex>.txt` with `text/html`. `attach.Store` therefore also stores `Content-Disposition: attachment` with every object whose type `attach.IsInlineContentType` does not report as a raster image (JPEG, PNG, GIF, WebP, AVIF). S3, and a CDN that forwards the stored headers, make a browser that opens the URL download HTML, SVG, PDF or plain text instead of rendering it. `<img>`, `<video>` and `<audio>` embeds are not affected, and thumbnails, being images, carry no disposition.
Objects written before this change, or by code that bypasses `attach.Store`, carry no disposition: set it on them, or add the rule in the CDN. A CDN that rewrites or drops the origin's headers needs the rule itself. Either way, serve such a bucket from its own origin, never the application's, and set `X-Content-Type-Options: nosniff` through the bucket's or the CDN's response-header settings, since S3 cannot store that header on an object.
Code that bypasses `attach.Store` and writes uploads to the bucket itself should write with `attach.UploadWriterOptions` and name its files with `attach.DiskExtension`, which gives its objects the same type and disposition. Objects written before this change, or by code that does neither, carry no disposition: set it on them, or add the rule in the CDN. `attach.StaticHandler` and `attach.StaticHandlerPublic` send the stored disposition too, so the same caveat applies to them, while the built-in route also decides from the type. A CDN that rewrites or drops the origin's headers needs the rule itself. Either way, serve such a bucket from its own origin, never the application's, and set `X-Content-Type-Options: nosniff` through the bucket's or the CDN's response-header settings, since S3 cannot store that header on an object.
A `file://` bucket keeps the disposition in the file driver's `.attrs` sidecar files, which a static server does not read, so the static-server case above still applies to it.
A `file://` bucket keeps the disposition in the file driver's `.attrs` sidecar files. `attach.StaticHandler`, `attach.StaticHandlerPublic` and the built-in route read the sidecar; a static server does not, so the static-server case above applies only to a web server reading the directory.
## Upload size

View File

@@ -120,7 +120,7 @@ asks first; --force skips the question. Off a terminal it never asks.`,
if err := confirmProduction(app, in, out, fmt.Sprintf("Delete every pending job of queue %q in production?", queue)); err != nil {
return err
}
return withDB(ctx, app, func() error {
return withAppDB(ctx, app, func() error {
return clearQueue(ctx, app, queue, out)
})
},
@@ -336,7 +336,9 @@ func dueAt(c pact.Cadence, t time.Time) bool {
}
// withAppDB runs fn with the app's database: the published one when there is
// one, otherwise one opened for the command the way withDB does.
// one, otherwise one opened for the command the way withDB does. It serves
// schedule:run --once and queue:clear, which may run in-process on an app
// that already published its pool.
func withAppDB(ctx context.Context, app *backpack.App, fn func() error) error {
if gdb, ok := app.Lookup[*gorm.DB](); ok && gdb != nil {
return fn()

View File

@@ -19,7 +19,7 @@ import (
// none; a running job and other queues are left alone.
func TestQueueClear(t *testing.T) {
db, dsn := migratedDB(t)
app, gdb := testApp(t, db, dsn, map[string]any{"queue.queues.default": 1})
app, gdb := testApp(t, db, dsn, map[string]any{"queue.queues.default": 1, "app.key": testAppKey})
m, err := From(app)
if err != nil {
t.Fatal(err)
@@ -73,6 +73,21 @@ func TestQueueClear(t *testing.T) {
if !strings.Contains(out, `Clearing queue "default"`) || !strings.Contains(out, "Cleared 2 jobs") {
t.Fatalf("output = %q", out)
}
// In process, on the app that already published its pool (a plugin
// HasSchedule entry naming queue:clear): the block still holds the one
// worker slot, so these two stay pending.
for range 2 {
if err := m.Enqueue(ctx, nil, pingArgs{Tag: "pending-in-process"}, EnqueueOpts{}); err != nil {
t.Fatal(err)
}
}
var inProcess bytes.Buffer
if err := bonfire.Call(ctx, RuntimeCommands(app, nil), "queue:clear", nil, &inProcess); err != nil {
t.Fatalf("in-process queue:clear: %v\n%s", err, inProcess.String())
}
if !strings.Contains(inProcess.String(), "Cleared 2 jobs") {
t.Fatalf("in-process output = %q", inProcess.String())
}
close(release)
deadline := time.Now().Add(10 * time.Second)
for countRows(t, gdb, `SELECT count(*) FROM river_job WHERE kind = ? AND state = 'completed'`, "conga_test_block") != 1 {

View File

@@ -254,6 +254,44 @@ func TestScheduleRunsCommand(t *testing.T) {
logs.waitFor(t, slog.LevelWarn, "schedule: command not registered; skipping", "command", "acme:missing")
}
// TestScheduledDeferredPurgeInProcess runs the framework entry deferred:purge
// the way serve's worker does (callScheduled through the published catalog)
// and through schedule:run --once, both on an app whose pool is already
// published. Both used to fail with "backpack: duplicate provider for
// *sql.DB".
func TestScheduledDeferredPurgeInProcess(t *testing.T) {
db, dsn := migratedDB(t)
app, _ := testApp(t, db, dsn, map[string]any{"app.key": testAppKey})
logs := &captureHandler{}
if err := app.Publish(slog.New(logs)); err != nil {
t.Fatal(err)
}
if err := app.Publish(bonfire.NewCatalog(lagoon.RuntimeCommands(app, nil))); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
skipped, err := callScheduled(t.Context(), app, loggerFromApp(app), "deferred:purge", nil, &buf)
if skipped || err != nil {
t.Fatalf("callScheduled: skipped=%v err=%v\n%s", skipped, err, buf.String())
}
if !strings.Contains(buf.String(), "purged 0 deferred bindings older than 5 days") {
t.Fatalf("output = %q", buf.String())
}
if !logs.find(slog.LevelInfo, "schedule: command finished", "command", "deferred:purge") {
t.Fatal("no Info 'schedule: command finished' record for deferred:purge")
}
if err := db.PingContext(t.Context()); err != nil {
t.Fatalf("published pool closed by the command: %v", err)
}
out, err := onceRun(t, app, nil, mustTime(t, time.UTC, "2026-03-10 03:00:00"))
if err != nil {
t.Fatalf("schedule:run --once: %v\n%s", err, out)
}
if !strings.Contains(out, "Ran 1 due entries (0 failed)") {
t.Fatalf("schedule:run --once output = %q", out)
}
}
// onceRun runs `schedule:run --once` for plugins at the given app time and
// returns its output.
func onceRun(t *testing.T, app *backpack.App, plugins []party.Plugin, now time.Time) (string, error) {

View File

@@ -41,7 +41,7 @@ Use cases:
- Deferred binding: WinterCMS's `deferred_bindings` table holds the uploads and related-record changes of a form whose record is not saved yet. Every operation takes a `lagoon.DeferredKey` (the form's session key, the owning backend admin's id and the master record's morph type from `lagoon.MorphType`) and never reads or changes another admin's rows, since each row stores `backend_user_id`. `lagoon.DeferredBind` and `lagoon.DeferredUnbind` port WinterCMS's duplicate and cancel rules: a repeated bind writes nothing, and an unbind of a slave with a pending bind deletes that bind and returns it so the caller can remove what it created. `lagoon.DeferredBindings` reads and locks a session's bindings for the save that commits them, `lagoon.DeferredForget` deletes them once applied, and `lagoon.DeferredSlaves` is the subquery a list uses to include pending rows. A child created under deferral carries the `lagoon.DeferredEnvelope` (`{"created":true,"pivot":{...},"upload_id":"..."}`) in `pivot_data`. `lagoon.PurgeDeferred` removes expired bindings: it deletes an unattached `system_files` row a bind points at, and its blobs only after the commit, deletes a child only when its binding carries the created envelope, keeps records that were only linked, and locks each batch with `FOR UPDATE SKIP LOCKED`.
- Logging: GORM's lines no longer go to stdout through GORM's own logger. They arrive through [diary](../diary/README.md) as component `db`: slow queries (over 200 ms) and errors at warn, while a missing record (`gorm.ErrRecordNotFound`) is no longer logged. At verbosity 3 (`-vvv`) every query is traced at info, with placeholders instead of the bound values. lagoon's own warnings log as component `lagoon`, and `attach` as component `attach`.
- Imports from Laravel: `lagoon.DecryptLaravelPayload` decrypts Laravel `encrypted` payloads with the old application key, for one-off data imports.
- Attachments (`attach`): the `attach.File` model for `system_files` rows, WinterCMS-compatible partitioned storage keys (`attach.BlobKey`, `attach.PartitionDirectory`), an S3 bucket option behind the `attach.S3BuildTag` build tag (an untagged binary refuses an `s3://` bucket URL at boot with an error naming the tag), public URLs (`attach.PublicURL` for any key, under `storage.uploads.public_url` when a public bucket or CDN serves the files (`attach.PublicURLBase`), `attach.File.URL` for an original, matching WinterCMS's `File::getPath()` under the WinterCMS layout), on-demand thumbnails through `attach.File.Thumb` for JPEG, PNG, GIF and WebP originals (a WebP original's thumbnail is JPEG bytes under its `.webp` name, since `attach` itself does not encode WebP; a missing, undecodable or oversized original gets WinterCMS's broken-image picture, `attach.BrokenImagePNG`, as its thumbnail, as `File::makeThumb` does), storing uploads through `attach.Store` (a server-generated disk name whose extension follows the sniffed content through `attach.DiskExtension`, so a PNG named `x.html` is stored as `.png`, an extension allow-list with `attach.DefaultImageExtensions` and `attach.DefaultFileExtensions` as defaults, a MIME filter, a size limit enforced while streaming, in image mode the `attach.IsAllowedImage` content guard, the refusal of protected uploads while `storage.uploads.public_url` serves the bucket publicly, `attach.ErrProtectedNeedsPrivateBucket`, and `Content-Disposition: attachment` on the blob for every type `attach.IsInlineContentType` does not report as an inline raster image (JPEG, PNG, GIF, WebP, AVIF), while `content_type` keeps the sniffed type), attachment relation declarations (`attach.Relation`, `attach.HasRelations`), static serving with an optional `is_public` gate (`attach.StaticHandlerPublic`), and a two-phase delete that removes blobs only after the database transaction commits (`attach.DeleteForOwner`, `attach.DeleteKeys`).
- Attachments (`attach`): the `attach.File` model for `system_files` rows, WinterCMS-compatible partitioned storage keys (`attach.BlobKey`, `attach.PartitionDirectory`), an S3 bucket option behind the `attach.S3BuildTag` build tag (an untagged binary refuses an `s3://` bucket URL at boot with an error naming the tag), public URLs (`attach.PublicURL` for any key, under `storage.uploads.public_url` when a public bucket or CDN serves the files (`attach.PublicURLBase`), `attach.File.URL` for an original, matching WinterCMS's `File::getPath()` under the WinterCMS layout), on-demand thumbnails through `attach.File.Thumb` for JPEG, PNG, GIF and WebP originals (a WebP original's thumbnail is JPEG bytes under its `.webp` name, since `attach` itself does not encode WebP; a missing, undecodable or oversized original gets WinterCMS's broken-image picture, `attach.BrokenImagePNG`, as its thumbnail, as `File::makeThumb` does), storing uploads through `attach.Store` (a server-generated disk name whose extension follows the sniffed content through `attach.DiskExtension`, so a PNG named `x.html` is stored as `.png`, an extension allow-list with `attach.DefaultImageExtensions` and `attach.DefaultFileExtensions` as defaults, a MIME filter, a size limit enforced while streaming, in image mode the `attach.IsAllowedImage` content guard, the refusal of protected uploads while `storage.uploads.public_url` serves the bucket publicly, `attach.ErrProtectedNeedsPrivateBucket`, and `Content-Disposition: attachment` on the blob for every type `attach.IsInlineContentType` does not report as an inline raster image (JPEG, PNG, GIF, WebP, AVIF), while `content_type` keeps the sniffed type; `attach.UploadWriterOptions` gives code which writes an upload to the bucket without `attach.Store` the same content type and disposition), attachment relation declarations (`attach.Relation`, `attach.HasRelations`), static serving (`attach.StaticHandler`, with an optional `is_public` gate in `attach.StaticHandlerPublic`) that sends each file's stored Content-Type and Content-Disposition with `X-Content-Type-Options: nosniff`, and a two-phase delete that removes blobs only after the database transaction commits (`attach.DeleteForOwner`, `attach.DeleteKeys`).
- WebP variants (`attach/webp`): `webp.Variant` writes a WebP copy of a thumbnail next to it (the thumbnail is made first when missing), at quality 80 (`webp.Quality`), under `attach.VariantKey` (the thumbnail key with the `webp` extension), and returns its key for `attach.PublicURL`. A variant that exists is not written again, and a failed encode leaves no file. `webp.Encode` encodes any image. Callers run it in a background job, not in an upload request. The encoder uses the system libwebp when it is installed (through purego, no cgo) and libwebp transpiled to Go otherwise. A binary that imports the package is dynamically linked against glibc unless it is built with `-tags nodynamic` (transpiled encoder only, static binary). Importing the package also registers the encoder library's WebP decoder ahead of the pure Go one for the whole process, so every WebP decode, uploads included (thumbnails, size checks), goes through the system libwebp when it is installed: keep libwebp patched, or build with `-tags nodynamic`, which decodes with the transpiled copy.
## Usage
@@ -204,7 +204,7 @@ func (p *Plugin) Migrations() []*gormigrate.Migration {
| `attach.OpenBucket` | Opens the uploads bucket from config; refuses `s3://` in a binary built without `attach.S3BuildTag`. |
| `attach.S3BuildTag` | The build tag, `summercms_s3`, that links the S3 driver; list it under `build.tags` in `summer.yaml`. |
| `attach.Publish` | Stores the bucket on the `backpack.App`. |
| `attach.StaticHandler` | Serves stored files and thumbnails under a URL prefix. |
| `attach.StaticHandler` | Serves stored files and thumbnails under a URL prefix with their stored Content-Type and Content-Disposition and `X-Content-Type-Options: nosniff`. |
| `attach.StaticHandlerPublic` | `attach.StaticHandler` plus a 404 for rows that are not public. |
| `attach.DeleteForOwner` | Deletes an owner's attachment rows in a transaction and reports their blob keys. |
| `attach.DeleteKeys` | Deletes blobs, including thumbnails, after the transaction commits. |
@@ -223,6 +223,7 @@ func (p *Plugin) Migrations() []*gormigrate.Migration {
| `attach.AllowedImageMIMEs` | The sniffed content types the image guard accepts. |
| `attach.IsAllowedImage` | The image guard: sniffed type, decoded header and a pixel ceiling, failing closed. |
| `attach.IsInlineContentType` | Reports whether a content type, parameters ignored, is a raster image a browser may show inline (JPEG, PNG, GIF, WebP, AVIF); `attach.Store` stores every other type with `Content-Disposition: attachment`, and the surf uploads route serves it as a download. |
| `attach.UploadWriterOptions` | The blob writer options of an upload: the content type plus `Content-Disposition: attachment` for a type `attach.IsInlineContentType` does not report as inline. `attach.Store` writes with the same options; pair it with `attach.DiskExtension` when writing uploads yourself. |
| `attach.MaxImagePixels` | The image guard's pixel ceiling, 4096 by 4096. |
| `attach.Relation` | One attachOne or attachMany relation: `Name`, `Many` and `Public`. |
| `attach.HasRelations` | Implemented by an owner model that declares its attachment relations. |

View File

@@ -33,9 +33,14 @@ func IsInlineContentType(contentType string) bool {
return err == nil && inlineContentTypes[strings.ToLower(mediaType)]
}
// uploadWriterOptions are the blob writer options of an upload: the content
// type, plus the attachment disposition when the type is not inline.
func uploadWriterOptions(contentType string) *blob.WriterOptions {
// UploadWriterOptions returns the blob writer options of an upload: the
// content type, plus Content-Disposition: attachment (no filename parameter)
// for every type IsInlineContentType does not report as inline. Store writes
// with these options. Code that writes an upload to the bucket itself, with
// NewWriter or WriteAll, passes the sniffed type (the value stored in
// content_type) and names the file with DiskExtension, so its blob is served
// like one Store wrote. Each call returns a new value the caller may extend.
func UploadWriterOptions(contentType string) *blob.WriterOptions {
opts := &blob.WriterOptions{ContentType: contentType}
if !IsInlineContentType(contentType) {
opts.ContentDisposition = attachmentDisposition

View File

@@ -1,6 +1,7 @@
package attach
import (
"reflect"
"strings"
"testing"
@@ -40,6 +41,44 @@ func TestIsInlineContentType(t *testing.T) {
}
}
func TestUploadWriterOptions(t *testing.T) {
cases := []struct {
in string
disposition string
}{
{"image/png", ""},
{"image/jpeg", ""},
{"image/gif", ""},
{"image/avif", ""},
{"IMAGE/WEBP; charset=binary", ""},
{"text/html", "attachment"},
{"text/xml", "attachment"},
{"image/svg+xml", "attachment"},
{"application/pdf", "attachment"},
{"text/plain", "attachment"},
{"application/octet-stream", "attachment"},
{"", "attachment"},
}
for _, tc := range cases {
got := UploadWriterOptions(tc.in)
want := &blob.WriterOptions{ContentType: tc.in, ContentDisposition: tc.disposition}
if !reflect.DeepEqual(got, want) {
t.Errorf("UploadWriterOptions(%q) = %+v, want %+v", tc.in, got, want)
}
}
first := UploadWriterOptions("text/html")
second := UploadWriterOptions("text/html")
if first == second {
t.Fatal("UploadWriterOptions returned the same pointer twice")
}
first.ContentDisposition = "inline"
first.CacheControl = "no-store"
if third := UploadWriterOptions("text/html"); third.ContentDisposition != "attachment" || third.CacheControl != "" {
t.Errorf("mutating one result changed the next: %+v", third)
}
}
// TestWriteBlobDisposition is the driver half of the public-bucket fix: a
// public bucket serves the headers Store stores with the object, so the
// attachment disposition must survive the write on the real drivers. The

View File

@@ -241,6 +241,41 @@ func TestStaticHandlerPublicLooksUpIsPublic(t *testing.T) {
if code := get("/storage/uploads/abc/123/xyz/missing.jpg"); code != http.StatusNotFound {
t.Fatalf("unknown disk_name status = %d, want 404", code)
}
// The stored disposition and nosniff go through the is_public gate.
htmlFile := attach.File{DiskName: "fed654cba.txt", FileName: "note.txt", FileSize: 1, ContentType: "text/html", IsPublic: &pub}
if err := gdb.Create(&htmlFile).Error; err != nil {
t.Fatal(err)
}
if err := bucket.WriteAll(ctx, attach.BlobKey(htmlFile.DiskName), []byte("<html></html>"), &blob.WriterOptions{ContentType: "text/html", ContentDisposition: "attachment"}); err != nil {
t.Fatal(err)
}
serve := func(path string) *http.Response {
t.Helper()
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil))
return rr.Result()
}
html := serve("/storage/uploads/fed/654/cba/fed654cba.txt")
if html.StatusCode != http.StatusOK {
t.Fatalf("public html status = %d, want 200", html.StatusCode)
}
if got := html.Header.Get("Content-Disposition"); got != "attachment" {
t.Errorf("public html Content-Disposition = %q, want attachment", got)
}
if got := html.Header.Get("X-Content-Type-Options"); got != "nosniff" {
t.Errorf("public html X-Content-Type-Options = %q, want nosniff", got)
}
jpeg := serve("/storage/uploads/abc/123/xyz/abc123xyz.jpg")
if jpeg.StatusCode != http.StatusOK {
t.Fatalf("public jpeg status = %d, want 200", jpeg.StatusCode)
}
if got := jpeg.Header.Get("X-Content-Type-Options"); got != "nosniff" {
t.Errorf("public jpeg X-Content-Type-Options = %q, want nosniff", got)
}
if got := jpeg.Header.Values("Content-Disposition"); len(got) != 0 {
t.Errorf("public jpeg Content-Disposition = %q, want none", got)
}
}
func assertFileRow(t *testing.T, gdb *gorm.DB, id uint, want bool) {

View File

@@ -3,6 +3,7 @@ package attach
import (
"context"
"io"
"mime"
"net/http"
"path"
"strings"
@@ -23,12 +24,22 @@ const defaultStaticContentType = "application/octet-stream"
// disk). To 404 is_public=false rows, mount StaticHandlerPublic instead.
// Do not mount the ungated handler on the app origin — same-origin
// Content-Type from uploads is XSS-relevant.
//
// Every response carries X-Content-Type-Options: nosniff. A served file gets
// its stored Content-Type and, when the blob has one, its stored
// Content-Disposition: Store stores attachment for every type
// IsInlineContentType rejects, and a stored value mime.ParseMediaType rejects
// is sent as attachment. The handler derives no disposition from the type and
// sends no Content-Security-Policy, so a blob stored without a disposition is
// served inline with its type. Reading the disposition costs one Attributes
// call per request.
func StaticHandler(bucket *blob.Bucket, prefix string) http.Handler {
return servePublicBlobs(bucket, prefix, nil)
}
// StaticHandlerPublic is StaticHandler plus an is_public gate. Missing
// rows and is_public=false both 404. The lookup runs before NewReader.
// It sends the same headers as StaticHandler.
func StaticHandlerPublic(bucket *blob.Bucket, prefix string, db *gorm.DB) http.Handler {
return servePublicBlobs(bucket, prefix, func(ctx context.Context, filename string) (bool, error) {
return fileIsPublic(ctx, db, filename)
@@ -42,6 +53,7 @@ func servePublicBlobs(bucket *blob.Bucket, prefix string, allow func(context.Con
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("X-Content-Type-Options", "nosniff")
if bucket == nil {
http.NotFound(w, r)
return
@@ -63,6 +75,11 @@ func servePublicBlobs(bucket *blob.Bucket, prefix string, allow func(context.Con
return
}
}
attrs, err := bucket.Attributes(r.Context(), key)
if err != nil {
http.NotFound(w, r)
return
}
reader, err := bucket.NewReader(r.Context(), key, nil)
if err != nil {
http.NotFound(w, r)
@@ -74,6 +91,9 @@ func servePublicBlobs(bucket *blob.Bucket, prefix string, allow func(context.Con
ct = defaultStaticContentType
}
w.Header().Set("Content-Type", ct)
if cd := storedDisposition(attrs.ContentDisposition); cd != "" {
w.Header().Set("Content-Disposition", cd)
}
if r.Method == http.MethodHead {
return
}
@@ -81,6 +101,21 @@ func servePublicBlobs(bucket *blob.Bucket, prefix string, allow func(context.Con
})
}
// storedDisposition is the value of the stored Content-Disposition a handler
// sends: empty for none, the trimmed value when mime.ParseMediaType accepts
// it, and attachment for a value it rejects, so malformed or injected
// metadata never reaches a response header.
func storedDisposition(raw string) string {
v := strings.TrimSpace(raw)
if v == "" {
return ""
}
if _, _, err := mime.ParseMediaType(v); err != nil {
return attachmentDisposition
}
return v
}
func stripStaticPrefix(path, prefix string) (string, bool) {
if prefix == "" {
return strings.TrimPrefix(path, "/"), true

View File

@@ -5,9 +5,11 @@ import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gocloud.dev/blob"
"gocloud.dev/blob/fileblob"
"gocloud.dev/blob/memblob"
)
@@ -172,3 +174,143 @@ func TestStaticHandlerPublicGate(t *testing.T) {
t.Fatalf("nil db status = %d, want 404", missing.Code)
}
}
func TestStoredDisposition(t *testing.T) {
cases := []struct{ in, want string }{
{"", ""},
{" ", ""},
{"attachment", "attachment"},
{"inline", "inline"},
{`attachment; filename="a.png"`, `attachment; filename="a.png"`},
{" attachment ", "attachment"},
{"attachment;;", "attachment"},
{"attachment; filename", "attachment"},
{"attachment\r\nX-Evil: 1", "attachment"},
}
for _, tc := range cases {
if got := storedDisposition(tc.in); got != tc.want {
t.Errorf("storedDisposition(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
// TestStaticHandlerSendsStoredDisposition writes through writeBlob, the
// writer attach.Store uses, and reads the headers back through the handlers:
// the stored disposition and nosniff reach the response on GET and HEAD.
func TestStaticHandlerSendsStoredDisposition(t *testing.T) {
sidecar, err := fileblob.OpenBucket(t.TempDir(), nil)
if err != nil {
t.Fatal(err)
}
buckets := []struct {
name string
bucket *blob.Bucket
}{
{"memblob", memblob.OpenBucket(nil)},
{"fileblob sidecar", sidecar},
}
const (
prefix = "/storage/uploads"
htmlName = "abc123xyz.html"
pngName = "def456uvw.png"
htmlBody = "<html><script>alert(1)</script></html>"
)
pngBody := []byte{0x89, 'P', 'N', 'G'}
url := func(diskName string) string { return prefix + "/" + BlobKey(diskName) }
serve := func(h http.Handler, method, target string) *httptest.ResponseRecorder {
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(method, target, nil))
return rr
}
assertHeaders := func(t *testing.T, label string, rr *httptest.ResponseRecorder, wantCT, wantCD string) {
t.Helper()
res := rr.Result()
if res.StatusCode != http.StatusOK {
t.Fatalf("%s: status = %d, want 200", label, res.StatusCode)
}
if got := res.Header.Get("Content-Type"); got != wantCT {
t.Errorf("%s: Content-Type = %q, want %q", label, got, wantCT)
}
if got := res.Header.Get("X-Content-Type-Options"); got != "nosniff" {
t.Errorf("%s: X-Content-Type-Options = %q, want nosniff", label, got)
}
got := res.Header.Values("Content-Disposition")
if wantCD == "" {
if len(got) != 0 {
t.Errorf("%s: Content-Disposition = %q, want none", label, got)
}
} else if len(got) != 1 || got[0] != wantCD {
t.Errorf("%s: Content-Disposition = %q, want %q", label, got, wantCD)
}
}
for _, b := range buckets {
bucket := b.bucket
t.Cleanup(func() { _ = bucket.Close() })
t.Run(b.name, func(t *testing.T) {
ctx := t.Context()
if _, err := writeBlob(ctx, bucket, BlobKey(htmlName), strings.NewReader(htmlBody), "text/html", 0); err != nil {
t.Fatal(err)
}
if _, err := writeBlob(ctx, bucket, BlobKey(pngName), strings.NewReader(string(pngBody)), "image/png", 0); err != nil {
t.Fatal(err)
}
h := StaticHandler(bucket, prefix)
get := serve(h, http.MethodGet, url(htmlName))
assertHeaders(t, "html GET", get, "text/html", "attachment")
if got := get.Body.String(); got != htmlBody {
t.Errorf("html GET body = %q, want %q", got, htmlBody)
}
head := serve(h, http.MethodHead, url(htmlName))
assertHeaders(t, "html HEAD", head, "text/html", "attachment")
if head.Body.Len() != 0 {
t.Errorf("html HEAD body = %q, want empty", head.Body.String())
}
png := serve(h, http.MethodGet, url(pngName))
assertHeaders(t, "png GET", png, "image/png", "")
if got := png.Body.Bytes(); string(got) != string(pngBody) {
t.Errorf("png GET body = %q, want %q", got, pngBody)
}
assertHeaders(t, "png HEAD", serve(h, http.MethodHead, url(pngName)), "image/png", "")
gated := servePublicBlobs(bucket, prefix, func(context.Context, string) (bool, error) { return true, nil })
gatedGet := serve(gated, http.MethodGet, url(htmlName))
assertHeaders(t, "gated html GET", gatedGet, "text/html", "attachment")
if got := gatedGet.Body.String(); got != htmlBody {
t.Errorf("gated html GET body = %q, want %q", got, htmlBody)
}
missing := serve(h, http.MethodGet, url("mis123sin.html")).Result()
if missing.StatusCode != http.StatusNotFound {
t.Fatalf("missing status = %d, want 404", missing.StatusCode)
}
if got := missing.Header.Get("X-Content-Type-Options"); got != "nosniff" {
t.Errorf("missing X-Content-Type-Options = %q, want nosniff", got)
}
if got := missing.Header.Values("Content-Disposition"); len(got) != 0 {
t.Errorf("missing Content-Disposition = %q, want none", got)
}
})
}
t.Run("memblob explicit dispositions", func(t *testing.T) {
ctx := t.Context()
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
const (
namedPNG = "ghi789rst.png"
brokenTXT = "jkl012mno.txt"
)
if err := bucket.WriteAll(ctx, BlobKey(namedPNG), pngBody, &blob.WriterOptions{ContentType: "image/png", ContentDisposition: `attachment; filename="a.png"`}); err != nil {
t.Fatal(err)
}
if err := bucket.WriteAll(ctx, BlobKey(brokenTXT), []byte(htmlBody), &blob.WriterOptions{ContentType: "text/html", ContentDisposition: "attachment;;"}); err != nil {
t.Fatal(err)
}
h := StaticHandler(bucket, prefix)
assertHeaders(t, "verbatim", serve(h, http.MethodGet, url(namedPNG)), "image/png", `attachment; filename="a.png"`)
assertHeaders(t, "fail closed", serve(h, http.MethodGet, url(brokenTXT)), "text/html", "attachment")
})
}

View File

@@ -205,7 +205,7 @@ func Store(ctx context.Context, db *gorm.DB, bucket *blob.Bucket, in Upload, lim
func writeBlob(ctx context.Context, bucket *blob.Bucket, key string, r io.Reader, contentType string, limit int64) (int64, error) {
writeCtx, cancel := context.WithCancel(ctx)
defer cancel()
w, err := bucket.NewWriter(writeCtx, key, uploadWriterOptions(contentType))
w, err := bucket.NewWriter(writeCtx, key, UploadWriterOptions(contentType))
if err != nil {
return 0, fmt.Errorf("attach: blob writer: %w", err)
}

View File

@@ -483,7 +483,16 @@ func purgeBucket(ctx context.Context, app *backpack.App) (*blob.Bucket, func(),
return b, func() { _ = b.Close() }, nil
}
// withDB runs fn on the app's published database when there is one: an
// in-process run (the scheduler in serve or schedule:run, through
// bonfire.Catalog.Call) borrows that pool and leaves it open. Otherwise it
// opens and publishes a pool for the CLI run and closes it when fn returns.
func withDB(ctx context.Context, app *backpack.App, fn func(*gorm.DB) error) error {
if app != nil {
if gdb, ok := app.Lookup[*gorm.DB](); ok && gdb != nil {
return fn(gdb)
}
}
sqlDB, gdb, err := OpenFromApp(ctx, app)
if err != nil {
return err

View File

@@ -501,3 +501,50 @@ func TestPurgeDeferredCommand(t *testing.T) {
t.Fatal("the command left the file, its blob or a binding")
}
}
// TestPurgeDeferredCommandPublishedDB runs deferred:purge the way the
// in-process scheduler does: through bonfire.Call on an app whose pool serve
// already opened and published. The command used to publish a second pool
// and fail with "backpack: duplicate provider for *sql.DB"; it now runs on
// the published handle and leaves that pool open.
func TestPurgeDeferredCommandPublishedDB(t *testing.T) {
e := newPurgeEnv(t, "lagoon_purge_published")
f := e.file(t, "")
created := e.child(t, "created")
e.bind(t, "cover", DeferredFileType, fmt.Sprint(f.ID), nil, 3*day)
e.bind(t, "children", "acme_purge_children", fmt.Sprint(created), &DeferredEnvelope{Created: true}, 3*day)
app := backpack.New(purgeConfig(t, map[string]any{"database.dsn": e.dsn, "app.key": base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))}))
sqlDB, gdb, err := OpenFromApp(t.Context(), app)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
if err := Publish(app, sqlDB, gdb); err != nil {
t.Fatal(err)
}
if err := attach.Publish(app, e.bucket); err != nil {
t.Fatal(err)
}
plugins := []party.Plugin{purgePlugin{models: []any{&purgeChild{}}}}
var out bytes.Buffer
if err := bonfire.Call(t.Context(), RuntimeCommands(app, plugins), "deferred:purge", []string{"--days", "2"}, &out); err != nil {
t.Fatalf("deferred:purge: %v\n%s", err, out.String())
}
if !strings.Contains(out.String(), "purged 2 deferred bindings older than 2 days (1 files, 1 children, 0 skipped)") {
t.Fatalf("output = %q", out.String())
}
if e.fileExists(t, f.ID) || e.blobExists(t, f) || len(e.bindingIDs(t)) != 0 {
t.Fatal("the command left the file, its blob or a binding")
}
if err := sqlDB.PingContext(t.Context()); err != nil {
t.Fatalf("published pool closed by the command: %v", err)
}
if got, ok := app.Lookup[*gorm.DB](); !ok || got != gdb {
t.Fatalf("published *gorm.DB replaced: %p, want %p", got, gdb)
}
out.Reset()
if err := bonfire.Call(t.Context(), RuntimeCommands(app, plugins), "migrate:status", nil, &out); err != nil {
t.Fatalf("migrate:status: %v\n%s", err, out.String())
}
}

View File

@@ -44,7 +44,7 @@ Use cases:
- Widening on return (A-WR-04): when a binding comes back with a different identity (more or fewer scopes, a backend subject added or dropped), its stateful session or stateless server is re-filtered on that request, so newly allowed tools and prompts appear at once (with `list_changed`) instead of waiting for an event. Token ids play no part in this, so an access-token rotation alone re-filters nothing. A stateless server is shared by every live token of its binding, so a return with less reach only adds there and never removes: the receiving middleware hides, per request, what that request's identity may not use, and a call of a wider token is never cut off by a narrower one. A guard provider error during this re-filtering is not a refusal: the cached server stays as it was and the next request retries (logged as `pier: widening deferred`); a fan-out recompute that meets one hides the failing items and leaves the server to be re-filtered by the binding's next request.
- Protected resource metadata (RFC 9728): `GET /.well-known/oauth-protected-resource` and `GET /.well-known/oauth-protected-resource<mcp.path>` answer the resource, the authorization server (the published verifier's resource origin, else `app.url`), the supported scopes and the bearer method. When the published `pact.MCPTokenVerifier` also implements `pact.MCPAdvertisedScopes` and `pact.MCPAdvertisedScopes.MCPScopesSupported` is non-empty, that slice is copied; otherwise the document keeps `pier.PRMScopes`. pier does not read `pact.MemberTokenScopeCatalog` and does not widen the read/write ceiling. A `mcp.resource` that differs from the verifier's resource is a boot warning. The list the OAuth server plugin publishes is the MCP advertised set: the fixed names `read`, `write`, `ai`, `offline_access` and `backend` are always on it. Declared member scopes follow the plugin's `golem15.oauthserver.mcp_scopes` filter, which is that plugin's key and not a framework `mcp.*` key: unset means every declared member scope, a set filter means only those declared names, and an empty filter means the five fixed names only. An empty `wristband.Options.ScopesSupported` on the MCP flavour resolves to those five names; a non-empty list is used as given. OpenID Connect discovery stays `openid`, `profile` and `email`. A route reads who called a tool with `pact.MCPCallFrom`.
- Prompts (D-28): prefixed and filtered like tools, required arguments checked before `Render` runs; a prompt never dispatches a route. A `Render` error is logged server-side and answered with the fixed JSON-RPC error `pier: the prompt could not be rendered`, so no database or provider error reaches the agent. A prompt declared with `Writes` (it directs tools that are not read-only) sits under the read/write ceiling and is hidden from an identity without `write`.
- A 401 carries `WWW-Authenticate: Bearer resource_metadata="<app.url>/.well-known/oauth-protected-resource<mcp.path>", scope="read"`; a browser request whose `Origin` is not in `mcp.allowed_origins` is refused with 403 before any body is read.
- A 401 carries `WWW-Authenticate: Bearer resource_metadata="<app.url>/.well-known/oauth-protected-resource<mcp.path>"`; there is no scope parameter, so a client requests the `scopes_supported` of the protected resource metadata; a browser request whose `Origin` is not in `mcp.allowed_origins` is refused with 403 before any body is read.
- One audit line per tool call (`pier: tool call`: tool, guard, user id, admin id, client id, token kind, session prefix, status, duration) and one per session step (`pier: session`: `open`, `rehydrate`, `end`, `binding_refused`, `revoked`, `stream_closed`, `expired`), never arguments, bodies or tokens. A tool call served on the stateful path without a session logs the session `ephemeral`, one on the stateless path `stateless`.
## Usage

View File

@@ -247,8 +247,12 @@ func (p *Pier) bodyLimit(next http.Handler) http.Handler {
// bearer verifies the bearer of every request afresh and stores the
// resolved Identity on the request for the session router and the SDK
// handlers; a failure is a 401 carrying WWW-Authenticate with the
// protected resource metadata URL and the read scope.
// handlers. A failure is a 401 whose WWW-Authenticate names only the
// protected resource metadata URL. It carries no scope parameter, so a
// client requests the scopes the metadata document advertises and the
// authorization server's consent decides the grant; tools are then filtered
// per granted scope. No Scopes option is set: the SDK would answer 403 to
// every token lacking them.
func (p *Pier) bearer(next http.Handler) http.Handler {
verify := func(ctx context.Context, token string, r *http.Request) (*auth.TokenInfo, error) {
ctx = towel.WithClientIP(ctx, p.clientIP(r))
@@ -263,13 +267,10 @@ func (p *Pier) bearer(next http.Handler) http.Handler {
Extra: map[string]any{extraIdentity: id, extraCall: callInfoFrom(r)},
}, nil
}
h := auth.RequireBearerToken(verify, &auth.RequireBearerTokenOptions{
return auth.RequireBearerToken(verify, &auth.RequireBearerTokenOptions{
ResourceMetadataURL: p.prmURL,
AllowMissingExpiration: true,
})(next)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h.ServeHTTP(&challengeWriter{ResponseWriter: w}, r)
})
}
// clientIP is the request's client address: the host's answer when it
@@ -285,30 +286,6 @@ func (p *Pier) clientIP(r *http.Request) string {
return r.RemoteAddr
}
// challengeWriter completes the 401 challenge with scope="read", which the
// SDK middleware would otherwise enforce on every request.
type challengeWriter struct {
http.ResponseWriter
}
func (c *challengeWriter) WriteHeader(status int) {
if status == http.StatusUnauthorized {
h := c.Header()
if v := h.Get("WWW-Authenticate"); v != "" && !strings.Contains(v, "scope=") {
h.Set("WWW-Authenticate", v+`, scope="read"`)
}
}
c.ResponseWriter.WriteHeader(status)
}
func (c *challengeWriter) Flush() {
if f, ok := c.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func (c *challengeWriter) Unwrap() http.ResponseWriter { return c.ResponseWriter }
// identityFromRequest reads the Identity the bearer middleware stored.
func identityFromRequest(r *http.Request) (Identity, bool) {
ti := auth.TokenInfoFromContext(r.Context())

View File

@@ -96,21 +96,31 @@ func TestPierTracerSmoke(t *testing.T) {
t.Fatalf("text content = %+v", res.Content[0])
}
// A bad bearer is a 401 carrying the protected resource metadata challenge.
// A bad or missing bearer is a 401 whose challenge names only the
// protected resource metadata URL: no scope parameter, so a client
// requests the scopes the metadata document advertises.
init := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"x","version":"1"}}}`
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, srv.URL+"/mcp", bytes.NewBufferString(init))
req.Header.Set("Authorization", "Bearer oas_at_bad")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
challenge := resp.Header.Get("WWW-Authenticate")
if resp.StatusCode != http.StatusUnauthorized || !strings.Contains(challenge, `resource_metadata="http://acme.test/.well-known/oauth-protected-resource/mcp"`) || !strings.Contains(challenge, `scope="read"`) {
t.Fatalf("bad bearer = %d %q", resp.StatusCode, challenge)
const wantChallenge = `Bearer resource_metadata="http://acme.test/.well-known/oauth-protected-resource/mcp"`
for _, tc := range []struct{ name, authorization string }{
{"bad bearer", "Bearer oas_at_bad"},
{"missing bearer", ""},
} {
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, srv.URL+"/mcp", bytes.NewBufferString(init))
if tc.authorization != "" {
req.Header.Set("Authorization", tc.authorization)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
challenge := resp.Header.Get("WWW-Authenticate")
if resp.StatusCode != http.StatusUnauthorized || challenge != wantChallenge || strings.Contains(challenge, "scope=") {
t.Fatalf("%s = %d %q, want 401 %q", tc.name, resp.StatusCode, challenge, wantChallenge)
}
}
// The same tool declared twice fails Mount naming the exposed name.

View File

@@ -35,9 +35,9 @@ const uploadsContentSecurityPolicy = "default-src 'none'; img-src 'self'; media-
// (attach.IsInlineContentType) is served as an attachment: attach.Store
// records the type sniffed from the bytes, so an uploaded text file that
// starts with <html> is text/html. attach.Store also stores the attachment
// disposition with the blob, but the route still decides from the type on
// every response: attach.StaticHandlerPublic sends only the stored type, and
// blobs written earlier or outside attach.Store carry no disposition. The
// disposition with the blob and attach.StaticHandlerPublic sends it, but the
// route still decides from the type on every response, because blobs written
// earlier or outside attach.Store carry no disposition. The
// bucket and the database are looked up per request: boot and route:list
// build the router before serve publishes either.
// The route owns the whole prefix: a plugin route at or under it fails

View File

@@ -27,7 +27,7 @@ Use cases:
- RFC 8414 metadata document (`wristband.Server.Metadata`) advertising the authorize, token and registration endpoints under the issuer, the `authorization_code` and `refresh_token` grants, S256 PKCE and the RFC 9207 `iss` response parameter.
- RFC 7591 dynamic client registration (`wristband.Server.Register`): JSON only, a bounded request body, redirect URI validation (HTTPS, or loopback HTTP), public (`none`) and confidential (`client_secret_post`, `client_secret_basic`) clients, a cap on unrevoked clients and a sweep of old clients that never got consent, all in one transaction.
- Authorization endpoint (`wristband.Server.Authorize`): the client and its exact registered redirect URI are validated before any redirect is sent (an unknown client gets a local plain-text 400, never an open redirect); then S256 PKCE, the client's scope ceiling and the RFC 8707 `resource` value are checked, a pending request is stored and the browser is sent to the application's consent page at `<issuer>/connect?request=<id>`. The accepted scopes are the same resolved list `wristband.Server.Metadata` advertises. A non-empty `wristband.Options.ScopesSupported` is used as given (`read`, `write`, `ai` and `offline_access` from `wristband.DefaultOptions`, which still refuses `backend`; a host that lists an extra scope accepts it). An empty or nil list on the MCP flavour resolves to `read`, `write`, `ai`, `offline_access` and `backend`. Any other value is `invalid_scope`. OpenID Connect discovery stays `openid`, `profile` and `email` and does not use this list. The OAuth server plugin publishes that list as `pact.MCPAdvertisedScopes`; pier copies `pact.MCPAdvertisedScopes.MCPScopesSupported` when it is non-empty. A route the MCP endpoint runs reads the caller with `pact.MCPCallFrom`.
- Authorization endpoint (`wristband.Server.Authorize`): the client and its exact registered redirect URI are validated before any redirect is sent (an unknown client gets a local plain-text 400, never an open redirect); then S256 PKCE, the client's scope ceiling and the RFC 8707 `resource` value are checked, a pending request is stored and the browser is sent to the application's consent page at `<issuer>/connect?request=<id>`. The accepted scopes are the same resolved list `wristband.Server.Metadata` advertises. A non-empty `wristband.Options.ScopesSupported` is used as given (`read`, `write`, `ai` and `offline_access` from `wristband.DefaultOptions`, which still refuses `backend`; a host that lists an extra scope accepts it). An empty or nil list on the MCP flavour resolves to `read`, `write`, `ai`, `offline_access` and `backend`. The OpenID Connect identity scopes `openid`, `profile` and `email` are dropped from the request before that check (RFC 6749 section 3.3 lets a server grant fewer scopes than requested), so a client that also reads the issuer's OpenID Connect discovery document still connects; a request with nothing else left gets the default `read`, and the three are never advertised. Any other value is `invalid_scope`. OpenID Connect discovery stays `openid`, `profile` and `email` and does not use this list. The OAuth server plugin publishes that list as `pact.MCPAdvertisedScopes`; pier copies `pact.MCPAdvertisedScopes.MCPScopesSupported` when it is non-empty. A route the MCP endpoint runs reads the caller with `pact.MCPCallFrom`.
- Consent operations for the application's own consent screen: `wristband.Server.PendingRequest` (what to show, including `wristband.PendingRequestView.OfflineAccess`, the pending row's flag; `offline_access` is not a scope on the view), `wristband.Server.IssueCode` (grant, returning the redirect URL with `code`, `iss` and `state`; the signature is unchanged) and `wristband.Server.DenyPending` (returning an `access_denied` redirect). Missing, foreign, used and expired requests all report the same `wristband.ErrPendingNotFound`.
- Token endpoint (`wristband.Server.Token`): code exchange with PKCE verification, then an access token minted by the application plus a rotating refresh token. Reusing a spent refresh token revokes its whole lineage and the linked access tokens. Expired codes and refresh tokens are swept on each call.
- Connected-app revocation: `wristband.Server.Revoke` kills an access token and the refresh lineage attached to it.
@@ -36,7 +36,7 @@ Use cases:
- OIDC token and userinfo follow the PHP contract: client authentication by Basic credentials (urldecoded) or form fields with one uniform `invalid_client` answer and `Basic realm="oauth"`, codes burned before any other check so a replayed code revokes the tokens issued from it, `{error, error_description}` bodies with `no-store, private` and `Pragma: no-cache`, and one uniform userinfo 401 with an empty body. The `id_token` carries `iss`, `sub`, `aud` as a string, `exp`, `iat`, `nonce` when sent, `at_hash` and only allowlisted identity claims (no `auth_time`, no `azp`).
- Keys and subjects: RS256 and ES256 keys (`wristband.GenerateSigningKey`), a mixed ring during a switch with discovery advertising what the ring holds, retired keys published through their grace window; public subjects by default and pairwise subjects per sector (`wristband.SubjectSector`, `wristband.ErrSectorRequired`), always random UUIDv4 values stored per user and sector.
- Opt-in refresh for OIDC (`wristband.OIDCOptions.RefreshEnabled`, off by default): only a client with `wristband.OIDCClientRecord.OfflineAccess` granted `offline_access` receives a rotating `oas_rt_` refresh token; a refresh returns a fresh `id_token` without `nonce`, and replaying a rotated token revokes the lineage and its access tokens.
- Client kinds: `wristband.ClientRecord.Kind` is `wristband.ClientKindMCP` (or empty) for MCP clients and `wristband.ClientKindOIDC` for administrator-registered OIDC clients. Each flavour accepts only its own kind at authorize, token and userinfo, and the MCP flavour keeps refusing the `openid` scope, so a dynamically registered client never receives identity scopes or an `id_token`.
- Client kinds: `wristband.ClientRecord.Kind` is `wristband.ClientKindMCP` (or empty) for MCP clients and `wristband.ClientKindOIDC` for administrator-registered OIDC clients. Each flavour accepts only its own kind at authorize, token and userinfo, and the MCP flavour drops `openid`, `profile` and `email` at authorize and never issues an `id_token`, so a dynamically registered client never receives identity scopes or an `id_token`.
- Secret handling: client secrets, codes and refresh tokens are random base64url strings, persisted only as SHA-256 hashes and compared in constant time. `wristband.IssueClientCredentials` and `wristband.RejectRedirectURI` expose the same issuing and validation rules to operator tooling that creates clients outside registration.
## Usage

View File

@@ -219,21 +219,42 @@ func (s *Server) authorizeErrorRedirect(w http.ResponseWriter, redirectURI, errC
writeRedirectHTML(w, http.StatusFound, appendOrderedQuery(redirectURI, pairs))
}
// parseAuthorizeScopes ports OAuthAuthorizeController::parseScopes. An
// absent or empty scope query value defaults to ["read"]; otherwise every
// whitespace-separated token must be in allowed, the server's set
// (allowedAuthorizeScopes).
// mcpIgnoredIdentityScopes are the OpenID Connect identity scopes the MCP
// flavour drops from an authorize request before validating it. The match
// is exact and case-sensitive.
var mcpIgnoredIdentityScopes = map[string]bool{
"openid": true,
"profile": true,
"email": true,
}
// parseAuthorizeScopes ports OAuthAuthorizeController::parseScopes with one
// deliberate divergence. The identity scopes openid, profile and email are
// dropped first, because clients that also read the issuer's OpenID Connect
// discovery document request them on the MCP endpoint, and RFC 6749 section
// 3.3 lets the server grant fewer scopes than requested. The MCP flavour
// issues no ID tokens and never grants identity scopes (phase 19.4 D-07,
// narrowed from refusal to dropping), so the drop happens even when a host
// lists one of them in Options.ScopesSupported. An absent, empty or
// identity-only scope value falls back to ["read"]; any other token outside
// allowed, the server's set (allowedAuthorizeScopes), is refused.
func parseAuthorizeScopes(raw string, allowed map[string]bool) ([]string, string) {
parts := strings.Fields(strings.TrimSpace(raw))
if len(parts) == 0 {
kept := make([]string, 0, len(parts))
for _, p := range parts {
if !mcpIgnoredIdentityScopes[p] {
kept = append(kept, p)
}
}
if len(kept) == 0 {
return []string{"read"}, ""
}
for _, p := range parts {
for _, p := range kept {
if !allowed[p] {
return nil, "scope contains an unsupported value"
}
}
return parts, ""
return kept, ""
}
// stringSliceContains reports whether ss contains v.

View File

@@ -4,6 +4,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strings"
"testing"
"time"
@@ -470,6 +471,109 @@ func TestAuthorizeScopeInvalidValueRedirectsInvalidScope(t *testing.T) {
}
}
// TestAuthorizeDropsOIDCIdentityScopes pins the MCP flavour's tolerance for
// the OpenID Connect identity scopes: openid, profile and email are dropped
// from the request before the allow-list check (RFC 6749 section 3.3 lets
// the server grant fewer scopes than requested), never persisted, and an
// identity-only request falls back to the read default. Any other unknown
// token, including a differently cased identity name, is still refused.
func TestAuthorizeDropsOIDCIdentityScopes(t *testing.T) {
cases := []struct {
name string
scope string
ceiling []string
extra map[string]string
want []string // nil: refused with invalid_scope
}{
{
// A client that also reads the issuer's OpenID Connect discovery
// document adds openid and email to its MCP request.
name: "discovery-reading client request",
scope: "openid email read",
extra: map[string]string{"resource": "https://resource.example.test/mcp", "ui_locales": "en-US"},
want: []string{"read"},
},
{name: "identity only defaults to read", scope: "openid profile", want: []string{"read"}},
{name: "openid alone defaults to read", scope: "openid", want: []string{"read"}},
{name: "order preserved", scope: "openid profile email read write", want: []string{"read", "write"}},
{name: "ceiling applies after the drop", scope: "openid email read ai", ceiling: []string{"read", "write"}, want: []string{"read"}},
{name: "unknown scope beside openid refused", scope: "openid bogus"},
{name: "unknown scope refused", scope: "read bogus"},
{name: "case differs refused", scope: "OPENID read"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv, backend := newAuthorizeTestServer()
insertAuthorizeTestClient(backend, "cli-identity", []string{authorizeTestRedirect}, tc.ceiling)
_, challenge := s256Pair(t)
before := pendingCount(backend)
params := map[string]string{
"client_id": "cli-identity",
"redirect_uri": authorizeTestRedirect,
"response_type": "code",
"code_challenge": challenge,
"code_challenge_method": "S256",
"scope": tc.scope,
"state": "xyz",
}
for k, v := range tc.extra {
params[k] = v
}
rec := httptest.NewRecorder()
srv.Authorize(rec, authorizeRequest(params))
if rec.Code != http.StatusFound {
t.Fatalf("status = %d, want %d (body=%s)", rec.Code, http.StatusFound, rec.Body.String())
}
loc := rec.Header().Get("Location")
q := queryOf(t, loc)
if tc.want == nil {
if q["error"] != "invalid_scope" {
t.Fatalf("error = %q, want invalid_scope (Location=%q)", q["error"], loc)
}
if q["error_description"] != "scope contains an unsupported value" {
t.Fatalf("error_description = %q", q["error_description"])
}
if got := pendingCount(backend); got != before {
t.Fatalf("pending rows = %d, want unchanged %d", got, before)
}
return
}
if !strings.HasPrefix(loc, srv.opts.Issuer+"/connect?request=") {
t.Fatalf("Location = %q, want prefix %q", loc, srv.opts.Issuer+"/connect?request=")
}
if _, has := q["error"]; has {
t.Fatalf("Location %q carries an error", loc)
}
backend.mu.Lock()
defer backend.mu.Unlock()
if got := len(backend.codes); got != before+1 {
t.Fatalf("pending rows = %d, want %d", got, before+1)
}
row := backend.codes[len(backend.codes)-1]
if !slices.Equal(row.Scopes, tc.want) {
t.Fatalf("scopes = %v, want %v", row.Scopes, tc.want)
}
if row.OfflineAccess {
t.Fatal("OfflineAccess = true, want false")
}
if row.State == nil || *row.State != "xyz" {
t.Fatalf("state = %v, want xyz", row.State)
}
if res, ok := tc.extra["resource"]; ok {
if res != srv.opts.Resource {
t.Fatalf("fixture resource %q differs from server resource %q", res, srv.opts.Resource)
}
if row.Resource == nil || *row.Resource != srv.opts.Resource {
t.Fatalf("resource = %v, want %q", row.Resource, srv.opts.Resource)
}
}
})
}
}
func TestAuthorizeNullCeilingPreservesAiScope(t *testing.T) {
srv, backend := newAuthorizeTestServer()
insertAuthorizeTestClient(backend, "cli-null-ceiling", []string{authorizeTestRedirect}, nil)

View File

@@ -11,8 +11,9 @@ import (
// TestMCPScopes (D-21, the framework side of the backend scope): the MCP
// flavour's authorize allow-list is exactly Options.ScopesSupported. The
// default server refuses backend; a server that lists it accepts "read
// backend" and advertises it in its metadata; on either, openid and a scope
// nobody lists stay refused.
// backend" and advertises it in its metadata; on either, the identity scopes
// openid, profile and email are dropped before the check and a scope nobody
// lists stays refused.
func TestMCPScopes(t *testing.T) {
_, challenge := s256Pair(t)
authorize := func(t *testing.T, s *Server, scope string) map[string]string {
@@ -68,13 +69,26 @@ func TestMCPScopes(t *testing.T) {
}
})
t.Run("openid and unlisted scopes stay refused", func(t *testing.T) {
t.Run("identity scopes are dropped and unlisted scopes stay refused", func(t *testing.T) {
for _, s := range []*Server{plain, withBackend} {
for _, scope := range []string{"openid backend", "read openid", "read admin"} {
if q := authorize(t, s, scope); q["error"] != "invalid_scope" {
t.Fatalf("%q = %v", scope, q)
}
if q := authorize(t, s, "read openid"); q["error"] != "" {
t.Fatalf("read openid = %v", q)
}
if q := authorize(t, s, "read admin"); q["error"] != "invalid_scope" {
t.Fatalf("read admin = %v", q)
}
}
if q := authorize(t, plain, "openid backend"); q["error"] != "invalid_scope" {
t.Fatalf("default server openid backend = %v", q)
}
if q := authorize(t, withBackend, "openid backend"); q["error"] != "" {
t.Fatalf("listing server openid backend = %v", q)
}
backend.mu.Lock()
got := backend.codes[len(backend.codes)-1].Scopes
backend.mu.Unlock()
if !slices.Equal(got, []string{"backend"}) {
t.Fatalf("listing server openid backend scopes = %v, want [backend]", got)
}
})
}

View File

@@ -227,15 +227,21 @@ func TestKindIsolationSmoke(t *testing.T) {
t.Fatalf("MCP token with an oidc client = %d %s", rec.Code, rec.Body.String())
}
// MCP authorize still refuses openid (D-07).
// MCP authorize never grants openid (D-07): it is dropped, not persisted.
rec = httptest.NewRecorder()
mcp.Authorize(rec, authorizeRequest(map[string]string{
"client_id": mcpClient.ClientID, "redirect_uri": "https://client.example.test/cb", "response_type": "code",
"code_challenge": challenge, "code_challenge_method": "S256", "scope": "openid read",
}))
if rec.Code != http.StatusFound || queryOf(t, rec.Header().Get("Location"))["error"] != "invalid_scope" {
if _, has := queryOf(t, rec.Header().Get("Location"))["error"]; rec.Code != http.StatusFound || has {
t.Fatalf("MCP authorize with openid = %d %q", rec.Code, rec.Header().Get("Location"))
}
backend.mu.Lock()
stored := backend.codes[len(backend.codes)-1].Scopes
backend.mu.Unlock()
if len(stored) != 1 || stored[0] != "read" {
t.Fatalf("MCP authorize with openid stored scopes %v, want [read]", stored)
}
// An mcp-kind client is unknown_client at OIDC authorize and
// invalid_client at the OIDC token endpoint.

View File

@@ -198,9 +198,10 @@ func TestSetOIDCBackendValidation(t *testing.T) {
}
}
// TestMCPRefusesOpenID pins D-07: the MCP flavour keeps refusing openid at
// authorize, and its token answer never carries an id_token, even for a code
// whose stored scopes mention openid.
// TestMCPRefusesOpenID pins D-07: the MCP flavour never grants openid.
// Authorize drops it (with profile and email) instead of persisting it, and
// the token answer never carries an id_token, even for a code whose stored
// scopes mention openid.
func TestMCPRefusesOpenID(t *testing.T) {
backend := newMemoryBackend()
mcp := newTestServer(backend)
@@ -212,8 +213,17 @@ func TestMCPRefusesOpenID(t *testing.T) {
"client_id": client.ClientID, "redirect_uri": "https://client.example.test/cb", "response_type": "code",
"code_challenge": challenge, "code_challenge_method": "S256", "scope": scope, "state": "s",
}))
if rec.Code != http.StatusFound || queryOf(t, rec.Header().Get("Location"))["error"] != "invalid_scope" {
t.Fatalf("MCP authorize with %q = %d %q", scope, rec.Code, rec.Header().Get("Location"))
if rec.Code != http.StatusFound {
t.Fatalf("MCP authorize with %q = %d", scope, rec.Code)
}
if _, has := queryOf(t, rec.Header().Get("Location"))["error"]; has {
t.Fatalf("MCP authorize with %q = %q", scope, rec.Header().Get("Location"))
}
backend.mu.Lock()
got := backend.codes[len(backend.codes)-1].Scopes
backend.mu.Unlock()
if len(got) != 1 || got[0] != "read" {
t.Fatalf("MCP authorize with %q stored scopes %v, want [read]", scope, got)
}
}