Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
860c41d829 | ||
|
|
6c606c666b | ||
|
|
6b9becd994 | ||
|
|
653d95a5b9 | ||
|
|
8cb56a4c51 | ||
|
|
c8a22e3e84 | ||
|
|
75e19dc153 | ||
|
|
64452a4919 | ||
|
|
291b18fb1e | ||
|
|
4957e5ce25 | ||
|
|
ba8432cfb8 | ||
|
|
61d10436c7 | ||
|
|
202d38ba3c | ||
|
|
10fec478be | ||
|
|
2da42e93f9 | ||
|
|
bb7e14bce9 | ||
|
|
4914cade5a | ||
|
|
f3f87f3cc4 | ||
|
|
3520d5d480 | ||
|
|
f91d70d05e | ||
|
|
6efb9a4997 | ||
|
|
4d75695582 | ||
|
|
795c4f6b29 | ||
|
|
e34d113c80 | ||
|
|
6294ac4141 | ||
|
|
53c0a65a43 | ||
|
|
39c59b8329 | ||
|
|
8476a65aa0 | ||
|
|
825a3fd2da | ||
|
|
36092f542c | ||
|
|
ebdfd78ab4 | ||
|
|
6ea2cb70e0 | ||
|
|
8c9c967c88 | ||
|
|
1a487bc644 | ||
|
|
8bc18c90b6 | ||
|
|
70e4b4183c | ||
|
|
a95a014b52 | ||
|
|
a941ab2394 | ||
|
|
017e0e9a6a | ||
|
|
2bef465ecd | ||
|
|
d788d12785 | ||
|
|
341a3a51a1 | ||
|
|
b5a20996c1 | ||
|
|
1488276bd2 | ||
| 3cc42f5079 | |||
| 1468344460 | |||
| 040187deb9 | |||
| bba6539883 |
@@ -192,6 +192,7 @@ Validated in Phase 18.1: Payment Gateway Subscriptions
|
||||
| OIDC on wristband with golang-jwt v5, not zitadel/oidc (Phase 19.4, `notes/2026-10-19-oidc-on-wristband-golang-jwt.md`) | wristband already owned the OAuth flows and PHP-compatible encoding; zitadel's own storage and routing would fight both. go-oidc and x/oauth2 are test-only, for relying-party interop | ✓ Good (Phase 19.4 UAT, 2026-10-20) |
|
||||
| PHP OIDC parity is recorded at the WavePath cutover, not in Phase 19.4 (D-14 amended) | The harness ships and replays Go-side; recording needs the live PHP site, and WavePath's provider must not move until the replay is green | — Pending (todo `2026-10-19-record-php-oidc-parity-against-wavepath`) |
|
||||
| Administrators and Roles live in cabana as an internal pseudo-plugin; privilege rules run in the save transaction; contributed Administrators Runs are wrapped with `guardOutranked` | WinterCMS screens were missing and 19.7 needs the record-action seam; a delegated editor must not outrank the target or capture a code-declared role | ✓ Good (Phase 19.6.1 UAT 26/26, 2026-10-22) |
|
||||
| MCP OAuth advertises one scope list, puts the MCP subject on the request, and stores one consent row per user and client (Phase 32.1) | Declared member-token scopes join the wristband and pier lists; an empty wristband list stays the five fixed names. `Implies` expands only for OAuth MCP subjects. A decision replaces the stored scopes, revoke deletes that client's MCP codes, and the one-time backfill rolls back as a no-op | ✓ Good (framework v0.5.2, sm-oauthserver-plugin v0.1.4, sm-user-plugin v0.1.16; vue-oauth-layer v0.1.0 is local only, 2026-10-28) |
|
||||
|
||||
## Evolution
|
||||
|
||||
@@ -211,4 +212,4 @@ This document evolves at phase transitions and milestone boundaries.
|
||||
4. Update Context with current state
|
||||
|
||||
---
|
||||
*Last updated: 2026-10-22 after Phase 19.6.1 completion*
|
||||
*Last updated: 2026-10-28 after Phase 32.1 completion*
|
||||
|
||||
@@ -1533,10 +1533,26 @@ Plans:
|
||||
3. The demo is deployed publicly: the frontend is open, `/backend` is not reachable from the public internet, and the site carries a "want to see the backend? contact Golem15" call to action.
|
||||
4. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 0 plans
|
||||
**Plans:** 6 plans
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run /msd-plan-phase 20 to break down)
|
||||
**Wave 1**
|
||||
- [ ] 20-01-PLAN.md — shared plugin release: siblings fast-forwarded, chat and forum pre-save seams (D-18), new sm-moderation-plugin (D-17), go.mod fixes, eight tags after go-ahead
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [ ] 20-02-PLAN.md — demo backend: oauthserver bump (folded todo), eight plugins mounted by tag, demo context and channel adapter, bootstrap endpoints, realtime for members, slash commands, moderation wiring, EN/PL seed and reset, Centrifugo namespaces, local LiveKit
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [ ] 20-03-PLAN.md — vue-chat-layer v0.1.0 (bm port, channels, threads, pins, command picker, link embeds) and /chat, floating panel, Chat tour card in vue-demo-app
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [ ] 20-04-PLAN.md — vue-forum-layer, vue-userfriends-layer, vue-chatvideo-layer v0.1.0 (DM-only call button), their pages and tour cards, herald VAPID config without UI
|
||||
|
||||
**Wave 5** *(blocked on Wave 4 completion)*
|
||||
- [ ] 20-05-PLAN.md — rome deploy: the demo's own LiveKit, Vuurmuur rule (checkpoint), nginx, .env keys, Centrifugo, release, DEPLOY.md, check-deploy and check-live
|
||||
|
||||
**Wave 6** *(blocked on Wave 5 completion)*
|
||||
- [ ] 20-06-PLAN.md — unit tests (Go, layers, site), two-browser chat and DM call checks, phase gate and live verification
|
||||
|
||||
### Phase 30: Płytarium cutover
|
||||
|
||||
@@ -1621,7 +1637,7 @@ Plans:
|
||||
**Goal:** As an application whose plugins declare their own member token scopes, I want an agent connected to `/mcp` over OAuth to get the same tools, attribution and revocation as one using a personal token, so that OAuth sign-in can be switched on in production. A consuming app's agent-access work found three gaps (its D-36, D-37 and D-38); all three are additive and ship as patch releases of the framework (0.5.x) and `sm-oauthserver-plugin`.
|
||||
**Requirements**: TBD
|
||||
**Depends on:** Phase 19.4 (OIDC provider on wristband), Phase 19.7 (generic MCP module)
|
||||
**Plans:** 4/5 plans executed
|
||||
**Plans:** 5/5 plans complete
|
||||
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Scopes a plugin declares through `pact.HasFrontendTokenScopes` can be requested, consented to and granted over MCP OAuth: the wristband metadata and authorize allow-list and pier's protected-resource scopes include them, so a tool route behind `user.scope:<declared>` lists and runs for an OAuth agent granted that scope. Today the list is fixed to `read`, `write`, `ai`, `offline_access` (and `backend`).
|
||||
@@ -1643,7 +1659,7 @@ Plans:
|
||||
- [x] 32.1-04-PLAN.md — vue-oauth-layer /connect checkboxes
|
||||
|
||||
**Wave 5** *(blocked on Wave 4 completion)*
|
||||
- [ ] 32.1-05-PLAN.md — Unit tests, then the release checkpoint
|
||||
- [x] 32.1-05-PLAN.md — Unit tests, then the release checkpoint
|
||||
|
||||
### Phase 33: SEO output, audit and content
|
||||
|
||||
|
||||
@@ -1000,6 +1000,12 @@ Recent decisions affecting current work:
|
||||
- 16.2-05 open UAT items: re-save HTML post 1 untouched (D-03 evidence vacuous), D-19 category edit check, root vhost install (frame-src), unpublish/delete test post 105
|
||||
- Phase 19.4: review warnings WR-01..06 are open (19.4-REVIEW-DISPOSITION.md); WR-01 (a replayed code does not revoke rotated refresh tokens) must be fixed before any client enables refresh. Release still pending: tag summercms, bump sm-oauthserver-plugin go.mod to sm-user-plugin v0.1.1 / summercms v0.2.4.
|
||||
|
||||
| 261028-rqb | Admin SPA reload on deploy (todo .planning/todos/pending/2026-10-11-reload-open-apps-when-a-new-version-is-deployed.md, admin half only; site half already shipped in vue-demo-app): boardwalk exposes a build version derived from the embedded dist (header like X-Summer-Build on admin API responses and/or a version endpoint), the admin SPA records the version it booted with and shows a non-blocking reload banner when a newer build is seen (focus/visibility/interval check), plus a reload-once guard on Vite chunk-load errors (vite:preloadError + sessionStorage flag); unit tests; boardwalk README; rebuild admin and commit modules/boardwalk/dist (scripts/check-admin-dist.sh must pass) | 2026-10-28 | c8a22e3 | .planning/quick/261028-rqb-admin-spa-reload-on-deploy-todo-planning-todos-pending-2026 |
|
||||
| 261028-rqc | Quiet plugin boot logs on console commands like --help, framework side only (todo .planning/todos/pending/2026-10-21-quiet-plugin-boot-logs-on-console-commands.md): help/list/completion style commands must not print plugin Register/Boot Info logs (e.g. raise the default console log level for non-serve introspection commands or defer Boot for them), without hiding logs for serve/worker/migrate; no sm-* plugin edits (plugin-side Info->Debug changes are a separate follow-up); docs for the behaviour | 2026-10-28 | 6c606c6 | .planning/quick/261028-rqc-quiet-plugin-boot-logs-on-console-commands-like-help-framewo |
|
||||
| 261028-rqd | Console review info findings IN-01..IN-13 and the delegate signal exit code 128+n (todo .planning/todos/pending/2026-10-22-console-review-info-findings.md, review .planning/phases/19.8-console-commands-overhaul/19.8-REVIEW.md); all 14 verified still open on 2026-10-28 in modules/bonfire, modules/diary, cmd/summer, scripts/check-phase19.8.sh; fix each, keep behaviour backward compatible, update docs/console pages and TestDelegateExitCodePassthrough for 128+n | 2026-10-28 | 202d38b | .planning/quick/261028-rqd-console-review-info-findings-in-01-in-13-and-the-delegate-si |
|
||||
| 261028-rqe | attach.Store writes non-inline types with a non-rendering disposition (todo .planning/todos/pending/2026-10-28-attach-store-public-content-type.md): in modules/lagoon/attach/store.go pass Content-Disposition attachment (blob.WriterOptions) for anything not in an inline-safe raster allow-list (jpeg, png, gif, webp, avif), keep the DB ContentType unchanged, additive and non-breaking; tests; lagoon README and docs/services/storage.md deploy note updated | 2026-10-28 | 4957e5c | .planning/quick/261028-rqe-attach-store-writes-non-inline-types-with-a-non-rendering-di |
|
||||
| 261028-rqf | Audit-log successful admin action runs with the admin id (todo .planning/todos/pending/admin-action-audit-log.md): in modules/cabana/actions.go runAction log success with admin_id (bouncer.User(ctx).ID), controller, action and kind (toolbar/field), matching the existing bulk/record action log fields; tests | 2026-10-28 | 61d1043 | .planning/quick/261028-rqf-audit-log-successful-admin-action-runs-with-the-admin-id-tod |
|
||||
| 261028-rqg | Show the administrators holding a role on the Roles screen (todo .planning/todos/pending/roles-screen-holders-list.md, origin .planning/phases/19.6.1-backend-administrators-screen/19.6.1-UAT.md:44): read-only list of holders on the Roles form/preview in modules/cabana backend_roles_admin.go (and admin SPA if needed), superusers hidden from non-superuser editors (D-03), assignment stays on the Administrators form; tests; if admin/ changes, rebuild and commit modules/boardwalk/dist | 2026-10-28 | 653d95a | .planning/quick/261028-rqg-show-the-administrators-holding-a-role-on-the-roles-screen-t |
|
||||
### Quick Tasks Completed
|
||||
|
||||
| # | Description | Date | Commit | Directory |
|
||||
@@ -1059,6 +1065,9 @@ Recent decisions affecting current work:
|
||||
| 261022-wah | User plugin opt-in golem15.user.jwt.token_cookie (default off) sets the HttpOnly auth.cookie session cookie on /login, /register and /activate-by-code instead of the body token; a /refresh carried by the auth.cookie cookie rewrites it (sm-user-plugin 846087d, 8bdf2c7, ships v0.5.0; verified) | 2026-10-22 | 846087d, 8bdf2c7 (sm-user-plugin) | [261022-wah-opt-in-httponly-session-cookie-on-user-p](./quick/261022-wah-opt-in-httponly-session-cookie-on-user-p/) |
|
||||
| 261022-x7a | User plugin golem15.user.password.public_routes (default true): false leaves POST /login, /forgot-password and /reset-password unregistered so they 404 like unknown paths; /register and the rest unchanged (sm-user-plugin 3828a65, ships v0.5.0; verified) | 2026-10-22 | e5dd457 | [261022-x7a-user-plugin-golem15-user-password-public](./quick/261022-x7a-user-plugin-golem15-user-password-public/) |
|
||||
| 55 | fix(cabana): backend subject events reach pier as *pact.MCPSubjectChanged after commit | 2026-10-23 | 7c43820 | — |
|
||||
| 261028-pq8 | Fix the four internal/build scaffolder todos (make:plugin vendor path, make:admin-controller output, scaffold header and make:command deps, same-second migration order) | 2026-10-28 | 8bc18c9 | [261028-pq8-fix-the-four-internal-build-scaffolder-t](./quick/261028-pq8-fix-the-four-internal-build-scaffolder-t/) |
|
||||
| 261028-r16 | Fix the two lagoon todos: stored-upload extension from sniffed MIME (tests + deploy note) and the lagoon README after-commit callback order | 2026-10-28 | 36092f5 | [261028-r16-fix-the-two-lagoon-todos-derive-stored-u](./quick/261028-r16-fix-the-two-lagoon-todos-derive-stored-u/) |
|
||||
| fast | ensureToolchain keeps an existing toolchain line instead of adding a second one | 2026-10-28 | 6ea2cb7 | — |
|
||||
|
||||
## Deferred Items
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
status: awaiting_human_verify
|
||||
status: resolved
|
||||
trigger: "User plugin logout: auth cookies not invalidated at once; requests keep authenticating ~60 min until token expiry (todo 2026-10-22-user-logout-cookie-stays-valid)"
|
||||
created: 2026-10-22T00:00:00Z
|
||||
updated: 2026-10-22T00:00:00Z
|
||||
@@ -42,3 +42,7 @@ The console layer (`modules/bonfire`) and the new logging module (`modules/diary
|
||||
- Removing the stack later means rewriting bonfire's widgets and diary's console handler; D-01 and D-02 are rated costly for that reason.
|
||||
- Plugins never import charm modules directly, so a later change of styling library touches only `modules/bonfire` and `modules/diary`.
|
||||
- A check in the plan's acceptance criteria (`go list` over every package's imports) keeps the import boundary honest.
|
||||
|
||||
## Addendum 2026-10-28: fang removed
|
||||
|
||||
`charm.land/fang/v2` was removed in quick 261028-rqd (console review finding IN-06). `fang.Execute` installs its own help function on the root just before `root.ExecuteContext`, so after `bonfire.Execute` the root's help function was no longer bonfire's list renderer, and cobra offers no per-root hook in between to restore it. bonfire had already switched off every fang feature except the error handler, so `bonfire.Execute` now runs `root.ExecuteContext` itself and renders errors with `RenderError` on the root's error stream. The only other thing fang did, enabling VT processing on Windows consoles, is done through `lipgloss.EnableLegacyWindowsANSI` on the output and error streams when they are files (a no-op off Windows). `go mod tidy` dropped fang and its exclusive transitive modules (mango, mango-cobra, mango-pflag, roff, x/exp/charmtone); no module was added. The list, help and error output stayed byte-identical (the goldens pass unchanged). The stack is now five charm modules plus colorprofile.
|
||||
|
||||
@@ -98,7 +98,7 @@ Post-plan attack surface from the quick tasks, mapped to existing entries: `pact
|
||||
|---------|------------|-----------|-------------|------|
|
||||
| AR-18.1-01 | T-18.1-04 | Reorder POST runs under the controller's protect; taglist adds no route and rides the existing saves | plan 18.1-01 (verified by audit) | 2026-10-16 |
|
||||
| AR-18.1-02 | T-18.1-40 | product_id and plan_code are Stripe ids and a plan code, not PII; TestJobArgsMinimal pins the key list and refuses PII | plan 18.1-07 (verified by audit) | 2026-10-16 |
|
||||
| AR-18.1-03 | T-18.1-22 | Toolbar admin actions (sync_all_to_stripe) are not audit-logged with the admin id on success; bulk actions are. The action needs manage_plans and pgsub logs each synced plan. Follow-up todo: admin action audit log (plugin or cabana extension) | user | 2026-10-16 |
|
||||
| AR-18.1-03 | T-18.1-22 | Toolbar admin actions (sync_all_to_stripe) are not audit-logged with the admin id on success; bulk actions are. The action needs manage_plans and pgsub logs each synced plan. Follow-up todo: admin action audit log (plugin or cabana extension) Resolved 2026-10-28 by quick 261028-rqf (61d1043): toolbar and field action runs are logged with admin_id on success. | user | 2026-10-16 |
|
||||
| AR-18.1-04 | T-18.1-43 | A Checkout Session already paid but whose webhook is not yet processed is complete, not open, so it is not expired; a second checkout in that window can bill twice. Detected by warnDuplicateOpen (Error log). Replaces the old 10-minute idempotency key, which covered only identical parameters | user (UAT row 5, quick 261016-myi) | 2026-10-16 |
|
||||
| AR-18.1-05 | T-18.1-39 | Session advisory locks hold a pooled connection; a capped MaxOpenConns or a re-entrant listener could starve the pool (review IN-02, open) | audit residual | 2026-10-16 |
|
||||
| AR-18.1-06 | T-18.1-24 | The customer API throttle keys on client IP in practice (JWT check runs inside the handler); no 429 test for this bucket | audit residual | 2026-10-16 |
|
||||
|
||||
@@ -29,19 +29,19 @@ findings:
|
||||
title: "Process's plain path prints item names without Sanitize"
|
||||
- id: IN-10
|
||||
severity: info
|
||||
disposition: deferred
|
||||
disposition: fixed
|
||||
title: "console.Error during a live view writes straight to the terminal and tears the view"
|
||||
- id: IN-11
|
||||
severity: info
|
||||
disposition: deferred
|
||||
disposition: fixed
|
||||
title: "A blank line printed during a live view is dropped"
|
||||
- id: IN-12
|
||||
severity: info
|
||||
disposition: deferred
|
||||
disposition: fixed
|
||||
title: "Signal relay edge cases: a signal during view start-up, and Windows"
|
||||
- id: IN-13
|
||||
severity: info
|
||||
disposition: deferred
|
||||
disposition: fixed
|
||||
title: "A delegate's stderr always goes to os.Stderr, bypassing the root's error writer"
|
||||
- id: IN-14
|
||||
severity: info
|
||||
@@ -97,39 +97,39 @@ findings:
|
||||
title: "The wrong-directory hint repeats every argument, including a --password value, on stderr"
|
||||
- id: IN-01
|
||||
severity: info
|
||||
disposition: deferred
|
||||
disposition: fixed
|
||||
title: "A forced-color console drops HideOnTTY records even when stderr is not a terminal"
|
||||
- id: IN-02
|
||||
severity: info
|
||||
disposition: deferred
|
||||
disposition: fixed
|
||||
title: "PeekGlobals and cobra disagree on some argument forms"
|
||||
- id: IN-03
|
||||
severity: info
|
||||
disposition: deferred
|
||||
disposition: fixed
|
||||
title: "isUsage recognises cobra's unknown-command error by its text"
|
||||
- id: IN-04
|
||||
severity: info
|
||||
disposition: deferred
|
||||
disposition: fixed
|
||||
title: "The --json flag is read two different ways"
|
||||
- id: IN-05
|
||||
severity: info
|
||||
disposition: deferred
|
||||
disposition: fixed
|
||||
title: "summer's scaffolding result lines are now hidden by -q"
|
||||
- id: IN-06
|
||||
severity: info
|
||||
disposition: deferred
|
||||
disposition: fixed
|
||||
title: "fang replaces the root help function that NewRootIO installs"
|
||||
- id: IN-07
|
||||
severity: info
|
||||
disposition: deferred
|
||||
disposition: fixed
|
||||
title: "diary pulls the whole TUI stack into security modules"
|
||||
- id: IN-08
|
||||
severity: info
|
||||
disposition: deferred
|
||||
disposition: fixed
|
||||
title: "The console sanitizer passes raw C1 bytes through on its fast path"
|
||||
- id: IN-09
|
||||
severity: info
|
||||
disposition: deferred
|
||||
disposition: fixed
|
||||
title: "The phase gate names consuming applications"
|
||||
open: 0
|
||||
total: 32
|
||||
@@ -141,15 +141,15 @@ recorded: 2026-10-22T01:19:33.337Z
|
||||
| Finding | Severity | Disposition | Source |
|
||||
|---------|----------|-------------|--------|
|
||||
| WR-09 | warning | fixed | fix fce232d (quick 261022-4z8) |
|
||||
| WR-10 | warning | fixed | fix 17c722d (quick 261022-4z8); the exit-130 suggestion is deferred to todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
|
||||
| WR-10 | warning | fixed | fix 17c722d (quick 261022-4z8); follow-up bb7e14b (quick 261028-rqd): a delegate killed by a signal exits 128+n, silently for SIGINT and SIGTERM |
|
||||
| WR-11 | warning | fixed | fix 707372b (quick 261022-4z8) |
|
||||
| WR-12 | warning | fixed | fix 57a849d, reworked in 139f5e3 so that lagoon.Status stays plugin-only and migrate:status adds the framework sets itself (quick 261022-4z8) |
|
||||
| WR-13 | warning | fixed | fix 0691d26 (quick 261022-4z8) |
|
||||
| WR-14 | warning | fixed | fix 48e13f9 (quick 261022-4z8) |
|
||||
| IN-10 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
|
||||
| IN-11 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
|
||||
| IN-12 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
|
||||
| IN-13 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
|
||||
| IN-10 | info | fixed | fix 6efb9a4 (quick 261028-rqd) |
|
||||
| IN-11 | info | fixed | fix 4d75695 (quick 261028-rqd) |
|
||||
| IN-12 | info | fixed | fix f91d70d (quick 261028-rqd); part 2 (Windows) not changed, documented in raise's doc comment: Go cannot report another handler, and returning without exiting would swallow Ctrl+C |
|
||||
| IN-13 | info | fixed | fix 4914cad, bonfire.ErrWriter (quick 261028-rqd) |
|
||||
| IN-14 | info | fixed | docs 7c15095 (quick 261022-4z8) |
|
||||
| IN-15 | info | fixed | fix 1ac75ad (quick 261022-4z8) |
|
||||
| CR-01 | critical | fixed | fix b077dc3 (19.8-05) (not in the current review) |
|
||||
@@ -163,15 +163,15 @@ recorded: 2026-10-22T01:19:33.337Z
|
||||
| WR-06 | warning | fixed | fix 39677f1 (19.8-05) (not in the current review) |
|
||||
| WR-07 | warning | fixed | fix 020fe83 (19.8-05) (not in the current review); remainder WR-12 (fix 57a849d) |
|
||||
| WR-08 | warning | fixed | fix 9b62285 (19.8-05) (not in the current review); remainder WR-11 (fix 707372b) |
|
||||
| IN-01 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-02 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-03 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-04 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-05 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-06 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-07 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-08 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-09 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-01 | info | fixed | fix 3520d5d (quick 261028-rqd) (not in the current review) |
|
||||
| IN-02 | info | fixed | fix e34d113 (quick 261028-rqd) (not in the current review) |
|
||||
| IN-03 | info | fixed | fix 39c59b8 (quick 261028-rqd) (not in the current review) |
|
||||
| IN-04 | info | fixed | feat 795c4f6, bonfire.FlagBool (quick 261028-rqd); summer's flagTrue/flagBool and flare's flagSet keep their wider value sets for compatibility (not in the current review) |
|
||||
| IN-05 | info | fixed | docs 2da42e9 (quick 261028-rqd): documentation fix, the code is unchanged (0.4 shipped the lines; -q hiding them is now documented) (not in the current review) |
|
||||
| IN-06 | info | fixed | refactor 53c0a65, fang removed; planning docs 6294ac4 (quick 261028-rqd) (not in the current review) |
|
||||
| IN-07 | info | fixed | refactor f3f87f3, internal/consolekit (quick 261028-rqd) (not in the current review) |
|
||||
| IN-08 | info | fixed | fix 8476a65 (quick 261028-rqd) (not in the current review) |
|
||||
| IN-09 | info | fixed | fix 10fec47 (quick 261028-rqd) (not in the current review) |
|
||||
|
||||
Dispositions: `open` (recorded, not yet triaged), `fixed`, `skipped`, `deferred`.
|
||||
Set `deferred` by hand and put the reason in the Source cell; both are preserved. A `|` in the reason is kept as prose and escaped on the next run.
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
---
|
||||
phase: 20-demo-instance-with-every-shared-plugin
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- ../sm-chat-plugin/events/events.go
|
||||
- ../sm-chat-plugin/contracts/rejection.go
|
||||
- ../sm-chat-plugin/classes/service.go
|
||||
- ../sm-chat-plugin/classes/writes.go
|
||||
- ../sm-chat-plugin/api/errors.go
|
||||
- ../sm-chat-plugin/saving_smoke_test.go
|
||||
- ../sm-chat-plugin/README.md
|
||||
- ../sm-forum-plugin/events/events.go
|
||||
- ../sm-forum-plugin/contracts/rejection.go
|
||||
- ../sm-forum-plugin/classes/forum_service.go
|
||||
- ../sm-forum-plugin/classes/lifecycle.go
|
||||
- ../sm-forum-plugin/api/errors.go
|
||||
- ../sm-forum-plugin/saving_smoke_test.go
|
||||
- ../sm-forum-plugin/README.md
|
||||
- ../sm-chatvideo-plugin/go.mod
|
||||
- ../sm-chatvideo-plugin/go.sum
|
||||
- ../sm-chatvideo-plugin/README.md
|
||||
- ../sm-herald-plugin/go.mod
|
||||
- ../sm-herald-plugin/go.sum
|
||||
- ../sm-herald-plugin/README.md
|
||||
- ../sm-userfriends-plugin/go.mod
|
||||
- ../sm-userfriends-plugin/go.sum
|
||||
- ../sm-userfriends-plugin/README.md
|
||||
- ../sm-moderation-plugin/.gitignore
|
||||
- ../sm-moderation-plugin/go.mod
|
||||
- ../sm-moderation-plugin/go.sum
|
||||
- ../sm-moderation-plugin/go.work
|
||||
- ../sm-moderation-plugin/plugin.go
|
||||
- ../sm-moderation-plugin/admin.go
|
||||
- ../sm-moderation-plugin/admin_permissions.go
|
||||
- ../sm-moderation-plugin/logger.go
|
||||
- ../sm-moderation-plugin/classes/filter.go
|
||||
- ../sm-moderation-plugin/classes/filter_smoke_test.go
|
||||
- ../sm-moderation-plugin/models/settings.go
|
||||
- ../sm-moderation-plugin/models/registry.go
|
||||
- ../sm-moderation-plugin/models/settings/fields.yaml
|
||||
- ../sm-moderation-plugin/updates/202610280001_create_golem15_moderation_settings.go
|
||||
- ../sm-moderation-plugin/updates/registry.go
|
||||
- ../sm-moderation-plugin/lang/en/lang.yaml
|
||||
- ../sm-moderation-plugin/lang/pl/lang.yaml
|
||||
- ../sm-moderation-plugin/config/config.yaml
|
||||
- ../sm-moderation-plugin/README.md
|
||||
autonomous: false
|
||||
requirements: [SC-1, D-17, D-18, D-19, D-20]
|
||||
estimate:
|
||||
tokens: 220000
|
||||
raw_tokens: 220000
|
||||
tasks: 6
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-18 (chat): with a festival listener on events.MessageSaving that returns contracts.Rejection{Field: \"content\", Message: m}, POST /_chat/api/v1/conversations/{id}/messages, POST /_chat/api/v1/messages/{id}/replies and PUT /_chat/api/v1/messages/{id} answer 422 {\"error\":\"Validation failed\",\"errors\":{\"content\":[m]}}; no golem15_chat_messages row is inserted or changed, MessageCreated and MessageUpdated do not fire and nothing is published."
|
||||
- "D-18 (forum): ForumService.CreateThread, Reply and EditPost fire events.PostSaving before breakwater.Render; a contracts.Rejection from a listener maps to the existing 422 envelope under errors.title or errors.body and nothing is persisted."
|
||||
- "D-18 (parity): with no MessageSaving or PostSaving listener every existing route, payload, status, table, migration ID and config key of sm-chat-plugin and sm-forum-plugin is unchanged; their php_method_map_test.go and golden_test.go pass without edits; the seams name no application and ship as patch-level additions."
|
||||
- "D-17: the new shared plugin golem15.moderation (module git.golem15.com/golem15/sm-moderation-plugin) publishes *classes.Filter at boot; Filter.Check returns a Violation for a banned word or phrase (case-insensitive, Unicode letter boundaries, Polish letters included) or a banned link (a listed domain, its subdomains, with or without scheme or www, and URL-prefix entries), and nil for clean text; the lists are the banned_words and banned_links textareas of its admin settings screen (singleton golem15_moderation_settings) behind the permission golem15.moderation.manage_settings; the plugin imports neither chat nor forum."
|
||||
- "SC-1: sm-chatvideo-plugin, sm-herald-plugin and sm-userfriends-plugin go.mod files keep only the framework replace and require sm-chat-plugin v0.1.3, sm-user-plugin v0.1.13 and summercms v0.5.1 where they depend on them; every one of the eight repos passes go vet and go test -short, and the five chat dependants build with GOWORK=off."
|
||||
- "SC-1 (release, after the Task 5 go-ahead): origin of each repo carries its new tag on the pushed HEAD that passed the checks: sm-chat-plugin v0.1.4 (patch, the seam), sm-chatchannels-plugin, sm-chatbots-plugin, sm-chatvideo-plugin, sm-forum-plugin, sm-herald-plugin, sm-userfriends-plugin and sm-moderation-plugin v0.1.0 (or the next free number read from the remote); a scratch module requiring all eight tags and summercms v0.5.1 builds with GOWORK=off."
|
||||
- "D-20 (D-19 superseded): this is plan 01 of the six plans confirmed at plan time; the chat family release precedes the demo backend (plan 02)."
|
||||
artifacts:
|
||||
- path: "../sm-chat-plugin/events/events.go"
|
||||
provides: "MessageSaving pre-save event and MessageSavingEventName"
|
||||
contains: "MessageSaving"
|
||||
- path: "../sm-chat-plugin/contracts/rejection.go"
|
||||
provides: "Rejection{Field, Message} error a pre-save listener returns"
|
||||
contains: "type Rejection struct"
|
||||
- path: "../sm-forum-plugin/events/events.go"
|
||||
provides: "PostSaving pre-save event and PostSavingEventName"
|
||||
contains: "PostSaving"
|
||||
- path: "../sm-forum-plugin/contracts/rejection.go"
|
||||
provides: "Rejection{Field, Message} for forum listeners"
|
||||
contains: "type Rejection struct"
|
||||
- path: "../sm-moderation-plugin/classes/filter.go"
|
||||
provides: "Filter, NewFilter, Check, Match, ParseList, Violation"
|
||||
contains: "func (f *Filter) Check"
|
||||
- path: "../sm-moderation-plugin/admin.go"
|
||||
provides: "moderation_settings settings item (HasSettings, HasLang)"
|
||||
contains: "moderation_settings"
|
||||
key_links:
|
||||
- from: "../sm-chat-plugin/classes/service.go"
|
||||
to: "../sm-chat-plugin/events/events.go"
|
||||
via: "SendMessageWith fires MessageSaving on s.bus before lagoon.Transaction"
|
||||
pattern: "MessageSaving\\{"
|
||||
- from: "../sm-chat-plugin/api/errors.go"
|
||||
to: "../sm-chat-plugin/contracts/rejection.go"
|
||||
via: "writeChatError maps errors.As(err, &contracts.Rejection) to writeValidation"
|
||||
pattern: "errors\\.As\\(err, &rej\\)"
|
||||
- from: "../sm-forum-plugin/classes/forum_service.go"
|
||||
to: "../sm-forum-plugin/events/events.go"
|
||||
via: "CreateThread and Reply fire PostSaving on s.bus before breakwater.Render"
|
||||
pattern: "PostSaving\\{"
|
||||
- from: "../sm-moderation-plugin/plugin.go"
|
||||
to: "../sm-moderation-plugin/classes/filter.go"
|
||||
via: "Boot publishes classes.NewFilter(db) through app.Publish inside lagoon.OnDatabase"
|
||||
pattern: "app\\.Publish"
|
||||
prohibitions:
|
||||
- statement: "No existing route, request or response field, status code, table, migration ID or config key of sm-chat-plugin or sm-forum-plugin changes; the seams are additive (WavePath parity, Phase 31)."
|
||||
status: unresolved
|
||||
verification: automated
|
||||
- statement: "sm-moderation-plugin depends on neither sm-chat-plugin nor sm-forum-plugin."
|
||||
status: unresolved
|
||||
verification: automated
|
||||
- statement: "No README, comment or identifier in the eight repos names a consuming application."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed, no remote repository is created and no tag is set before the Task 5 go-ahead."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented here, see the planning return): `sm-demo-app` runs every shared Golem15 plugin collected so far in one binary, and its public Nuxt site shows them working: chat and channels with realtime, video calls, presentations, quizzes, journal, FAQ, subscriptions and the rest. Phase 19.4.1 deployed the demo early; this phase adds the chat family to that live instance.
|
||||
|
||||
This plan's slice (plan 1 of 6, wave 1, per D-20): the shared plugin release the demo needs before it can require the chat family by tag. Fast-forward the stale sibling checkouts, add the generic pre-save seams to chat and forum (D-18), create `sm-moderation-plugin` (D-17), fix the go.mod files that break module mode, and, after the user's go-ahead, push and tag all eight repositories.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion, recorded)
|
||||
|
||||
- **Seam shape (D-18 discretion):** a festival "-ing" event fired before the write transaction, mirroring `user.Deleting` / `user.Deactivating` in sm-user-plugin. chat gets `events.MessageSaving`, forum a new `events` package with `events.PostSaving`. A listener refuses by returning the plugin's own `contracts.Rejection{Field, Message}`. Each plugin declares its own type, so forum does not import chat and neither imports moderation. The API maps `errors.As(err, &rej)` to the existing 422 envelope. Rationale: `festival.Bus.Fire` already returns the joined listener errors, chat already holds `s.bus`, forum holds an unused `Bus` field, and no service gains a new registration method. RESEARCH Pattern 2.
|
||||
- **Event names:** `MessageSavingEventName = "golem15.chat.message.saving"` and `PostSavingEventName = "golem15.forum.post.saving"`. They are Go-only seams with no PHP counterpart; the doc comments say so.
|
||||
- **Op values:** chat `OpCreate = "create"` and `OpUpdate = "update"`; forum `OpThread = "thread"`, `OpReply = "reply"` and `OpUpdate = "update"`.
|
||||
- **Moderation lists:** two textareas, one entry per line; an `enabled` switch defaults to true; at most 500 entries of at most 200 characters per list (validated in `BeforeValidate`). The filter reads the singleton row on every `Check` and recompiles its matchers only when the stored text changed. RE2's `\b` is ASCII-only, so word boundaries are built from `[^\p{L}\p{N}_]` to cover Polish letters.
|
||||
- **Violation message:** `Violation{Kind, Match, Message}`. Message comes from the plugin's lang files (`golem15.moderation::lang.violation.word` and `.link`, EN and PL) in the locale `towel.Locale(ctx)` carries, resolved through the framework's phrasebook. Without a locale it is English. The chat-family APIs answer in English today, so English is an acceptable fallback.
|
||||
- **Dependants require existing tags:** chatvideo, herald and userfriends require `sm-chat-plugin v0.1.3` (the newest tag before this plan), `sm-user-plugin v0.1.13` and `summercms v0.5.1`, the set RESEARCH compiled. That keeps their module-mode check independent of the new chat tag; minimal version selection gives the demo chat v0.1.4.
|
||||
- **Repository creation:** with the go-ahead, Claude creates `golem15/sm-moderation-plugin` on git.golem15.com with `tea` when `tea login list` shows a git.golem15.com login, using the same visibility as `sm-sitemap-plugin`. Otherwise the user creates it.
|
||||
- spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip).
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- sm-chat-plugin (v0.1.4): `events.MessageSaving`, `events.MessageSavingEventName`, `events.OpCreate`, `events.OpUpdate`, `contracts.Rejection` (`Error()`), the 422 mapping in `writeChatError`, smoke test `TestMessageSavingTracer` (`saving_smoke_test.go`), a README changelog entry v0.1.4.
|
||||
- sm-forum-plugin (v0.1.0): package `events` (`PostSaving`, `PostSavingEventName`, `OpThread`, `OpReply`, `OpUpdate`), `contracts.Rejection`, the 422 mapping in `writeForumError`, smoke test `TestPostSavingTracer`.
|
||||
- sm-moderation-plugin (new repo, v0.1.0): plugin `golem15.moderation`, package `moderation`, `classes.Filter`, `classes.NewFilter(db *gorm.DB) *Filter`, `(*Filter).Check(ctx context.Context, text string) (*Violation, error)`, `classes.Match(words, links []string, text string) *Violation`, `classes.ParseList(text string) []string`, `classes.Violation{Kind, Match, Message}`, `classes.KindWord`, `classes.KindLink`, table `golem15_moderation_settings` (migration `202610280001_create_golem15_moderation_settings`), settings item `moderation_settings`, permission `golem15.moderation.manage_settings`, config keys `golem15.moderation.max_entries` and `golem15.moderation.max_entry_length`, smoke test `TestFilterSmoke`.
|
||||
- go.mod fixes in sm-chatvideo-plugin, sm-herald-plugin and sm-userfriends-plugin.
|
||||
- Tags: chat v0.1.4; chatchannels, chatbots, chatvideo, forum, herald, userfriends and moderation v0.1.0 (numbers read from the remote at release time).
|
||||
- Full unit tests come in plan 06 (project rule).
|
||||
|
||||
<objective>
|
||||
Release the chat family so the demo can require it by tag: the D-18 pre-save seams in chat and forum, the D-17 moderation plugin, go.mod hygiene, and eight tags after the user's go-ahead.
|
||||
|
||||
Purpose: SC-1 needs `sm-demo-app` to build from tagged modules (19.4.1 D-03, `TestGoModRequiresTags`, `scripts/check-pins.sh`). D-17 and D-18 need the seams and the filter before plan 02 wires them. D-20 makes this plan 01 of six and supersedes D-19's earlier split.
|
||||
Output: commits and, after the go-ahead, pushes and tags in eight plugin repositories. No change in sm-demo-app, vue-demo-app or summercms.go code.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-CONTEXT.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-RESEARCH.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-PATTERNS.md
|
||||
@.planning/notes/core-plugins-own-repos.md
|
||||
|
||||
Repos (siblings of summercms.go): ../sm-chat-plugin, ../sm-chatchannels-plugin, ../sm-chatbots-plugin (all three behind origin: fast-forward first), ../sm-chatvideo-plugin, ../sm-forum-plugin, ../sm-herald-plugin, ../sm-userfriends-plugin, and the new ../sm-moderation-plugin. Read-only references: ../sm-bm-app/plugins/golem15/{chat,chatchannels,chatbots} (the origin-main trees, for reading only), ../sm-sitemap-plugin (plugin and settings analog), ../sm-demo-app/plugins/golem15/user/classes/events.go (the "-ing" event analog).
|
||||
|
||||
Rules: no co-author tags in commits (user instruction overrides any attribution default); stage files by explicit path, never `git add -A`; one logical change per commit; nothing pushed, created or tagged before Task 5. Verify commands run from the framework root and address siblings with `go -C ../<repo>`. Plugin repos with a tracked go.work resolve their siblings through it (the seam-carrying ../sm-chat-plugin included). If the Docker socket is denied, run the Docker tests through `sudo -u golem` (CLAUDE.md).
|
||||
|
||||
<interfaces>
|
||||
chat (origin main 51c9e17 = v0.1.3, same tree as ../sm-bm-app/plugins/golem15/chat):
|
||||
- `classes.Service` fields `bus *festival.Bus` (from `Options.Bus`); `SendMessageWith(ctx, in SendInput) (*models.Message, error)` validates metadata, loads the conversation, checks `canSend`, trims content, returns `ErrEmptyMessage` when content and attachments are empty, then opens `lagoon.Transaction` and fires `events.MessageCreated` inside it (classes/service.go:287-345). `SendMessage` and `ReplyTo` (classes/attachments.go:218) funnel into it.
|
||||
- `EditMessage(ctx, actorID, messageID uint, content string) (*models.Message, error)` in classes/writes.go:248, after `CanEditMessage` (writes.go:202).
|
||||
- events/events.go: `MessageEvent{MessageID, ConversationID, ActorID uint; RootID *uint; Tx *gorm.DB}`, `MessageCreated`, `MessageUpdated`, `MessageDeleted`, consts `MessageCreatedEventName` etc.; the package doc says every event fires inside the transaction.
|
||||
- api/errors.go: `writeValidation(w, map[string][]string)`, `writeChatError(w, what, err)` (switch over errors.Is, default 500).
|
||||
- Test helpers: harness_test.go `bootChat`, `(*chatEnv).user`, `.count`, `.memory`, `.conversation(t, createdBy, typ, alias, contextID, participants...)`; http_tracer_test.go `bootWeb`, `(*webEnv).frontendToken`, `.call(t, method, path, token, body)`, `decodeObject`.
|
||||
forum (../sm-forum-plugin, origin main 2779341):
|
||||
- `classes.ForumService{bus *festival.Bus}` built in boot.go `bootService` with `Bus: app.Events`; `CreateThread(ctx, actorID, contextType, contextID, categoryID, title, body)` renders at forum_service.go:281, `Reply(...)` at :330, `EditPost(ctx, actorID, contextType, contextID, postID, body)` in classes/lifecycle.go:217. No `Fire(` call exists in the plugin today.
|
||||
- api/errors.go: `writeValidation`, `writeForumError` (ErrForbidden/ErrNotFound 404, ErrTitleRequired/ErrBodyRequired 422).
|
||||
- Test helpers: harness_test.go `bootForum`, `.user`, `.count`; http_tracer_test.go `bootWeb`, `.frontendToken`, `.call`.
|
||||
festival (framework): `(*Bus).Listen[T any](owner string, fn func(context.Context, T) error)`, `(*Bus).Fire[T any](ctx, event T) error` returns errors.Join of listener errors (callers use errors.As).
|
||||
sitemap analog: plugin.go (embed config, `pact.HasConfig/HasMigrations/HasModels`), admin.go (`Settings() []pact.SettingsItem` with Code, Label, Description, Category, Icon, Permissions, Form, NewModel; `AdminFS`, `LangFS`), admin_permissions.go (`Permissions() []pact.Permission`), models/settings.go (singleton row id 1, `*string` columns, `BeforeValidate` with a lang-key error), models/settings/fields.yaml, updates/202610110002_create_golem15_sitemap_settings.go (CREATE TABLE plus INSERT id 1), go.mod replace `git.golem15.com/golem15/summercms => ../summercms.go`, tracked go.work.
|
||||
go.mod today: chatvideo requires chat v0.0.0 and replaces chat and user with sibling paths; herald replaces user with ../fonoteka.go/plugins/golem15/user; userfriends requires chat pseudo-version v0.0.0-20261017200243-4d0e764ca443.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a rejecting MessageSaving listener turns a chat send into the existing 422 envelope and nothing is stored</name>
|
||||
<reversibility rating="costly">D-18: the seam becomes a public plugin contract other consumers can start using; changing its shape later touches every listener.</reversibility>
|
||||
<precondition>`git -C ../sm-<name>-plugin status --porcelain` prints nothing for chat, chatchannels, chatbots, chatvideo, forum, herald and userfriends; `docker info` exits 0.</precondition>
|
||||
<files>../sm-chat-plugin/events/events.go, ../sm-chat-plugin/contracts/rejection.go, ../sm-chat-plugin/classes/service.go, ../sm-chat-plugin/classes/writes.go, ../sm-chat-plugin/api/errors.go, ../sm-chat-plugin/saving_smoke_test.go, ../sm-chat-plugin/README.md</files>
|
||||
<read_first>../sm-chat-plugin/classes/service.go (SendMessageWith, Options.Bus), ../sm-chat-plugin/classes/writes.go (EditMessage, CanEditMessage), ../sm-chat-plugin/classes/attachments.go (ReplyTo), ../sm-chat-plugin/events/events.go, ../sm-chat-plugin/api/errors.go, ../sm-chat-plugin/api/messages.go and api/replies.go, ../sm-chat-plugin/http_tracer_test.go and harness_test.go, ../sm-chat-plugin/php_method_map_test.go, ../sm-chat-plugin/README.md, ../sm-demo-app/plugins/golem15/user/classes/events.go (DeactivatingEvent doc style), modules/festival/bus.go, 20-RESEARCH.md (Pattern 2, Pitfall 1), 20-PATTERNS.md (Chat / forum pre-save seam)</read_first>
|
||||
<action>Per D-18 and D-20; wire one path end to end: listener, service, API, HTTP response, database unchanged.
|
||||
|
||||
1. Fast-forward every sibling first (RESEARCH Pitfall 1): for chat, chatchannels, chatbots, chatvideo, forum, herald and userfriends run `git -C ../<repo> pull --ff-only` and confirm `git -C ../<repo> rev-parse HEAD` equals the hash `git -C ../<repo> ls-remote origin refs/heads/main` prints (use the default branch each repo tracks). Record both hashes per repo for the SUMMARY. A non-fast-forward stops the task.
|
||||
2. events/events.go: keep the three existing events untouched. Add `MessageSavingEventName = "golem15.chat.message.saving"` on its own line and a `MessageSaving` struct with `ConversationID`, `ActorID` and `MessageID uint` (MessageID is 0 on create), `RootID *uint`, `Content string` and `Op string`, plus the constants `OpCreate = "create"` and `OpUpdate = "update"`. MessageSaving carries no Tx because it fires before the transaction. Rewrite the package doc: the three message events fire inside the transaction after the save; MessageSaving fires before it, has no PHP counterpart, and a listener error aborts the write; a listener refuses with contracts.Rejection.
|
||||
3. contracts/rejection.go: `type Rejection struct { Field, Message string }` with a value-receiver `Error() string` returning "golem15.chat: rejected " + Field + ": " + Message. The doc comment explains that listeners return the value (not a pointer) and callers match it with errors.As, because Fire joins listener errors.
|
||||
4. classes/service.go `SendMessageWith`: after the ErrEmptyMessage check and before `lagoon.Transaction`, when `s.bus != nil`, fire `&events.MessageSaving{Op: events.OpCreate, ConversationID, ActorID: senderID, RootID: rootID, Content: content}` (the trimmed content) and return `nil, err` on error. classes/writes.go `EditMessage`: after CanEditMessage allows the edit and the new content is validated, before its transaction, fire the same event with `Op: events.OpUpdate` and the MessageID. No other line of either method changes.
|
||||
5. api/errors.go `writeChatError`: before the existing switch, declare `var rej contracts.Rejection` and, when `errors.As(err, &rej)`, call `writeValidation(w, map[string][]string{field: {rej.Message}})` with field = rej.Field, or "content" when Field is empty, then return. The other branches stay byte-identical.
|
||||
6. saving_smoke_test.go `TestMessageSavingTracer` (Docker; skips under -short like the other HTTP tests): boot with bootWeb; register on the app's event bus a `*events.MessageSaving` listener returning `contracts.Rejection{Field: "content", Message: "not allowed"}` when Content contains the word "blocked"; create a group conversation with two participants through the harness; then
|
||||
- POST `/_chat/api/v1/conversations/{id}/messages` with content "this is blocked": 422, body errors.content equals ["not allowed"], and golem15_chat_messages has 0 rows;
|
||||
- POST the content "hello": 201, 1 row;
|
||||
- PUT `/_chat/api/v1/messages/{id}` with "blocked edit": 422, and the stored content is still "hello";
|
||||
- POST `/_chat/api/v1/messages/{id}/replies` with "blocked reply": 422, and the row count is still 1.
|
||||
7. README.md: in Features and API reference, add MessageSaving (when it fires, no Tx, Op values), MessageSavingEventName, OpCreate, OpUpdate and contracts.Rejection, with a neutral example host listener (`acme`). Add a changelog entry "v0.1.4: pre-save MessageSaving event and Rejection (additive; no listener means no change)". Name no consuming application.
|
||||
|
||||
Commit in ../sm-chat-plugin by path (`feat: pre-save MessageSaving event and Rejection`), README in the same commit. Do not push or tag.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-chat-plugin vet ./... && go -C ../sm-chat-plugin test -count=1 -short ./... && go -C ../sm-chat-plugin test -count=1 -run '^TestMessageSavingTracer$' -v . 2>&1 | grep -q -- '^--- PASS: TestMessageSavingTracer'</automated>
|
||||
<fails_when>Non-zero exit: vet or the short suite red (php_method_map or golden tests broken by the change), or no "--- PASS: TestMessageSavingTracer" line (Docker unavailable so it skipped, a 201 where 422 was expected, or a stored row after a refusal).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go -C ../sm-chat-plugin test -count=1 ./...` (with Docker) passes, including the unchanged php_method_map_test.go and golden_test.go.
|
||||
- `git -C ../sm-chat-plugin diff HEAD~1 --stat -- php_method_map_test.go golden_test.go testdata` prints nothing.
|
||||
- `grep -c 'MessageSavingEventName = "golem15.chat.message.saving"' ../sm-chat-plugin/events/events.go` prints 1.
|
||||
- `grep -c 'errors.As(err, &rej)' ../sm-chat-plugin/api/errors.go` prints 1.
|
||||
- `grep -c 'v0.1.4' ../sm-chat-plugin/README.md` prints at least 1.
|
||||
- The SUMMARY records the before and after HEAD of all seven siblings, and each after-HEAD equals origin's default branch.
|
||||
</acceptance_criteria>
|
||||
<done>A host can refuse a chat message before it is stored, the client gets the existing 422 envelope, and without a listener chat behaves byte-identically.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: The forum gets the same seam: PostSaving before render on thread, reply and edit</name>
|
||||
<reversibility rating="costly">D-18: a new public event package and Rejection type in a WavePath port-target plugin; later consumers depend on the field names.</reversibility>
|
||||
<files>../sm-forum-plugin/events/events.go, ../sm-forum-plugin/contracts/rejection.go, ../sm-forum-plugin/classes/forum_service.go, ../sm-forum-plugin/classes/lifecycle.go, ../sm-forum-plugin/api/errors.go, ../sm-forum-plugin/saving_smoke_test.go, ../sm-forum-plugin/README.md</files>
|
||||
<read_first>../sm-forum-plugin/classes/forum_service.go (Options, New, CreateThread, Reply), ../sm-forum-plugin/classes/lifecycle.go (EditPost), ../sm-forum-plugin/boot.go (bootService passes app.Events), ../sm-forum-plugin/api/errors.go, ../sm-forum-plugin/api/threads.go and api/posts.go, ../sm-forum-plugin/http_tracer_test.go, harness_test.go and fixture_plugin_test.go (test access resolver), ../sm-forum-plugin/php_method_map_test.go, ../sm-forum-plugin/README.md, ../sm-chat-plugin/events/events.go and contracts/rejection.go (Task 1, for the shape)</read_first>
|
||||
<action>Per D-18.
|
||||
|
||||
- New package events (events/events.go):
|
||||
- `PostSavingEventName = "golem15.forum.post.saving"`;
|
||||
- `PostSaving` with `ContextType string`, `ContextID`, `ActorID`, `ThreadID` and `PostID uint`, `Title`, `Body` and `Op string`;
|
||||
- constants `OpThread = "thread"` (ThreadID 0, Title set), `OpReply = "reply"` and `OpUpdate = "update"`.
|
||||
- The package doc says forum fires no post-save events, PostSaving is a Go-only seam fired before render and persistence, and a listener error aborts the write.
|
||||
- contracts/rejection.go: the same shape as chat's, with "golem15.forum: rejected " in Error().
|
||||
- ForumService, all guarded by `s.bus != nil`, firing after the access and required-field checks and before `breakwater.Render`, returning the error unchanged:
|
||||
- CreateThread fires PostSaving with OpThread, the trimmed title and the body;
|
||||
- Reply fires it with OpReply, the ThreadID and the body;
|
||||
- EditPost (lifecycle.go) fires it with OpUpdate, the PostID, its ThreadID and the body.
|
||||
- api/errors.go `writeForumError`: before the switch, `errors.As(err, &rej)` writes 422 `{rej.Field: [rej.Message]}`, with field "body" when Field is empty.
|
||||
- saving_smoke_test.go `TestPostSavingTracer` (Docker): boot through bootWeb with the fixture resolver that allows posting; register a PostSaving listener rejecting Title or Body containing "blocked" (Field "title" or "body"). Then:
|
||||
- POST a thread whose title holds the word: 422 errors.title, no thread and no post row;
|
||||
- a clean thread: 201;
|
||||
- a reply whose body holds the word: 422 errors.body;
|
||||
- PUT the first post with the word: 422, and content_html is unchanged.
|
||||
- README: add Features "Pre-save seam" and the API reference rows (events.PostSaving, PostSavingEventName, Op constants, contracts.Rejection), and correct the Overview so it says forum fires only this pre-save event. Add a changelog line "v0.1.0: first tag; adds the pre-save PostSaving seam". Name no application.
|
||||
|
||||
Commit in ../sm-forum-plugin by path (`feat: pre-save PostSaving event and Rejection`). Do not push or tag.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-forum-plugin vet ./... && go -C ../sm-forum-plugin test -count=1 -short ./... && go -C ../sm-forum-plugin test -count=1 -run '^TestPostSavingTracer$' -v . 2>&1 | grep -q -- '^--- PASS: TestPostSavingTracer'</automated>
|
||||
<fails_when>Non-zero exit: vet or the short suite red, or no "--- PASS: TestPostSavingTracer" line (skipped without Docker, a refused write that still stored a row, or the wrong 422 field).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go -C ../sm-forum-plugin test -count=1 ./...` passes with Docker, php_method_map_test.go and golden_test.go unedited (`git -C ../sm-forum-plugin diff HEAD~1 --stat -- php_method_map_test.go golden_test.go` prints nothing).
|
||||
- `grep -rn 'sm-chat-plugin' ../sm-forum-plugin/go.mod ../sm-forum-plugin/events ../sm-forum-plugin/contracts` prints nothing.
|
||||
- `grep -c 'PostSaving{' ../sm-forum-plugin/classes/forum_service.go` prints 2 and `grep -c 'PostSaving{' ../sm-forum-plugin/classes/lifecycle.go` prints 1.
|
||||
</acceptance_criteria>
|
||||
<done>Forum threads, replies and edits can be refused before render and storage with the existing 422 envelope; without a listener nothing changes.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: New shared plugin sm-moderation-plugin: banned words and links, editable in admin settings, published as a Filter</name>
|
||||
<files>../sm-moderation-plugin/.gitignore, ../sm-moderation-plugin/go.mod, ../sm-moderation-plugin/go.sum, ../sm-moderation-plugin/go.work, ../sm-moderation-plugin/plugin.go, ../sm-moderation-plugin/admin.go, ../sm-moderation-plugin/admin_permissions.go, ../sm-moderation-plugin/logger.go, ../sm-moderation-plugin/classes/filter.go, ../sm-moderation-plugin/classes/filter_smoke_test.go, ../sm-moderation-plugin/models/settings.go, ../sm-moderation-plugin/models/registry.go, ../sm-moderation-plugin/models/settings/fields.yaml, ../sm-moderation-plugin/updates/202610280001_create_golem15_moderation_settings.go, ../sm-moderation-plugin/updates/registry.go, ../sm-moderation-plugin/lang/en/lang.yaml, ../sm-moderation-plugin/lang/pl/lang.yaml, ../sm-moderation-plugin/config/config.yaml, ../sm-moderation-plugin/README.md</files>
|
||||
<read_first>../sm-sitemap-plugin/plugin.go, admin.go, admin_permissions.go, logger.go, models/settings.go, models/registry.go, models/settings/fields.yaml, updates/202610110002_create_golem15_sitemap_settings.go, updates/registry.go, lang/en/lang.yaml, config/config.yaml, go.mod, go.work, README.md (the whole plugin layout), modules/phrasebook/README.md and modules/towel/README.md (locale lookup), `summer help make:plugin` (scaffold option), .planning/notes/core-plugins-own-repos.md, 20-PATTERNS.md (sm-moderation-plugin)</read_first>
|
||||
<action>Per D-17. Create the repository at /media/nvme/dev/golem15/summercms.io/summercms/sm-moderation-plugin with `git init -b main`. Add no remote yet. Start from `summer make:plugin golem15.moderation` if it emits a standalone module layout, otherwise copy the sitemap layout. Module `git.golem15.com/golem15/sm-moderation-plugin`, package `moderation`, plugin ID `golem15.moderation`, `Requires()` nil, display name Moderation / Moderacja. Standard library plus gorm, gormigrate and framework modules only.
|
||||
|
||||
- go.mod: go 1.27.0, require `git.golem15.com/golem15/summercms v0.5.1` plus gorm and gormigrate at the versions sitemap's go.mod uses, and `replace git.golem15.com/golem15/summercms => ../summercms.go`. Run `go mod tidy`. Track a go.work with `use .` and the same replace, as sitemap does.
|
||||
- plugin.go: the interface assertions (party.Plugin, pact.HasConfig, pact.HasMigrations, pact.HasModels) and `//go:embed config`. Register keeps the app. Boot calls lagoon.OnDatabase and publishes `classes.NewFilter(db)` with `app.Publish`. The package doc says the plugin is a content filter other plugins' hosts wire into their pre-save seams, and that it imports no content plugin.
|
||||
- config/config.yaml: `max_entries: 500`, `max_entry_length: 200`.
|
||||
- models/settings.go: `Settings` singleton on `golem15_moderation_settings` (`ID uint`, `Enabled bool`, `BannedWords *string`, `BannedLinks *string`). `BeforeValidate` refuses:
|
||||
- a list longer than max_entries (lang key `golem15.moderation::lang.errors.too_many`);
|
||||
- an entry longer than max_entry_length (`errors.too_long`);
|
||||
- a link entry that is neither a host name nor an http(s) URL (`errors.link`).
|
||||
models/registry.go `All()`.
|
||||
- models/settings/fields.yaml: `enabled` (switch), `banned_words` (textarea, comment "one word or phrase per line"), `banned_links` (textarea, comment "one domain or URL per line; subdomains match"), with the sitemap header comment block.
|
||||
- updates/202610280001_create_golem15_moderation_settings.go: `CREATE TABLE IF NOT EXISTS golem15_moderation_settings (id INTEGER PRIMARY KEY, enabled BOOLEAN NOT NULL DEFAULT TRUE, banned_words TEXT NULL, banned_links TEXT NULL)` and `INSERT INTO golem15_moderation_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING`. Rollback drops the table. updates/registry.go `All()`.
|
||||
- admin.go: `Settings()` returns item Code `moderation_settings` (lang keys for label, description and category, Icon "shield", Permissions [`golem15.moderation.manage_settings`], Form `models/settings/fields.yaml`, NewModel), plus AdminFS and LangFS, with the sitemap assertions (AdminAssets, HasSettings, HasLang). admin_permissions.go declares the permission on the role list sitemap uses.
|
||||
- lang/en and lang/pl: plugin name, settings label, description and category, field labels and comments, `errors.*`, `violation.word` ("The text contains a word that is not allowed." / "Tekst zawiera niedozwolone słowo.") and `violation.link` ("The text contains a link that is not allowed." / "Tekst zawiera niedozwolony link.").
|
||||
- classes/filter.go:
|
||||
- `KindWord = "word"`, `KindLink = "link"`; `Violation{Kind, Match, Message string}`;
|
||||
- `ParseList(text string) []string` splits lines, trims them, lowercases them and drops empty lines and duplicates;
|
||||
- `Match(words, links []string, text string) *Violation` is pure and returns the first violation, words before links:
|
||||
- a word or phrase matches case-insensitively between non-letter, non-digit boundaries (built from `[^\p{L}\p{N}_]`, start or end of text) with regexp.QuoteMeta;
|
||||
- a link entry that is a host (example.com) matches any host in the text equal to it or ending in "." plus it: http(s) URLs, `www.` prefixes and bare domains (labels of letters, digits and hyphens with at least one dot);
|
||||
- an entry with a path matches a URL whose host and path start with it.
|
||||
- `NewFilter(db) *Filter`. `(*Filter).Check(ctx, text) (*Violation, error)` reads the singleton row, returns nil when Enabled is false, recompiles the matchers only when the stored text differs from the cached text (guard the cache with a sync.Mutex), and fills Message from the lang phrase for `towel.Locale(ctx)` through the framework phrasebook, falling back to the English text.
|
||||
- classes/filter_smoke_test.go `TestFilterSmoke` (pure, no Docker): "Spam here" against the word spam gives a word violation; "spamming" gives none; "Kup TANIO teraz" against the phrase "kup tanio" gives a word violation; "visit https://www.bad.example/x" and "bad.example" against the link bad.example give link violations; "good.example" gives none.
|
||||
- README.md in the standard plugin structure:
|
||||
- H1, one-sentence summary, import line, a `**Name:**` beach mnemonic, Overview, Features, Usage, API reference, Configuration (the two keys), Dependencies, Testing;
|
||||
- Usage shows a neutral host (`acme`) wiring Check into another plugin's pre-save event without importing it here.
|
||||
|
||||
Commit in ../sm-moderation-plugin (`feat: golem15.moderation banned word and link filter`). Do not create a remote, push or tag.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-moderation-plugin vet ./... && go -C ../sm-moderation-plugin test -count=1 -short ./... && go -C ../sm-moderation-plugin test -count=1 -run '^TestFilterSmoke$' -v ./classes 2>&1 | grep -q -- '^--- PASS: TestFilterSmoke'</automated>
|
||||
<fails_when>Non-zero exit: the module does not build or vet, the short suite fails, or no "--- PASS: TestFilterSmoke" line (a boundary or host rule wrong).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go -C ../sm-moderation-plugin list -deps ./... | grep -cE 'sm-(chat|forum)-plugin'` prints 0.
|
||||
- `grep -c 'golem15_moderation_settings' ../sm-moderation-plugin/updates/202610280001_create_golem15_moderation_settings.go` prints at least 2.
|
||||
- `grep -c 'golem15.moderation.manage_settings' ../sm-moderation-plugin/admin_permissions.go` prints at least 1.
|
||||
- `grep -c '^\*\*Name:\*\*' ../sm-moderation-plugin/README.md` prints 1.
|
||||
- `git -C ../sm-moderation-plugin remote` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>A shared, chat-agnostic moderation plugin exists locally: admins edit banned words and links in settings, and any host can call Filter.Check from a pre-save seam.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Module-mode hygiene: chatvideo, herald and userfriends require tags, and the whole family builds and tests</name>
|
||||
<files>../sm-chatvideo-plugin/go.mod, ../sm-chatvideo-plugin/go.sum, ../sm-chatvideo-plugin/README.md, ../sm-herald-plugin/go.mod, ../sm-herald-plugin/go.sum, ../sm-herald-plugin/README.md, ../sm-userfriends-plugin/go.mod, ../sm-userfriends-plugin/go.sum, ../sm-userfriends-plugin/README.md</files>
|
||||
<read_first>../sm-chatvideo-plugin/go.mod and go.work, ../sm-herald-plugin/go.mod and go.work, ../sm-userfriends-plugin/go.mod and go.work, ../sm-chatchannels-plugin/go.mod and ../sm-chatbots-plugin/go.mod (after the Task 1 fast-forward), ../sm-bm-app/plugins/golem15/chatbots/go.mod (the "drop sibling replaces" analog), each README's Dependencies section, 20-RESEARCH.md (Repo State and Tagging, Pitfall 2)</read_first>
|
||||
<action>Per SC-1 and RESEARCH Pitfall 2. With `GOWORK=off GOPRIVATE=git.golem15.com/*`, edit each go.mod:
|
||||
- sm-chatvideo-plugin: delete the sm-chat-plugin and sm-user-plugin replace lines. Require `git.golem15.com/golem15/sm-chat-plugin v0.1.3`, `sm-user-plugin v0.1.13` and `summercms v0.5.1`. Keep `replace git.golem15.com/golem15/summercms => ../summercms.go`.
|
||||
- sm-herald-plugin: delete the sm-user-plugin replace. Require sm-user-plugin v0.1.13 and summercms v0.5.1.
|
||||
- sm-userfriends-plugin: replace the chat pseudo-version with v0.1.3. Require sm-user-plugin v0.1.13 and summercms v0.5.1.
|
||||
- sm-chatchannels-plugin and sm-chatbots-plugin (fast-forwarded in Task 1): confirm their go.mod holds no replace other than the framework one and only tag requires. If either does not, fix it the same way and add it to this task's commits.
|
||||
Run `go mod tidy` in each changed repo (GOWORK=off). The tracked go.work files stay: they are development conveniences and module consumers ignore them. Update each changed README's Dependencies section with the required versions and add a changelog line "v0.1.0: first tag; requires tagged chat, user and SummerCMS modules" where the README keeps a changelog.
|
||||
|
||||
Then verify the family:
|
||||
- all eight repos (chat, chatchannels, chatbots, chatvideo, forum, herald, userfriends, moderation) pass `go vet ./...` and `go test -count=1 -short ./...` through their go.work;
|
||||
- chatchannels, chatbots, chatvideo, herald and userfriends pass `GOWORK=off go build ./...`, which resolves chat v0.1.3 and user v0.1.13 from git.golem15.com and proves no sibling path is needed.
|
||||
Grep the changed files of all eight repos for consuming application names (the sm-*-app repository names next to summercms.go, and their vendor prefixes) and remove any hit in the files this plan changed.
|
||||
|
||||
Commit per repo by path (`chore: require tagged chat, user and SummerCMS modules; drop sibling replaces`). Do not push or tag.</action>
|
||||
<verify>
|
||||
<automated>for r in sm-chat-plugin sm-chatchannels-plugin sm-chatbots-plugin sm-chatvideo-plugin sm-forum-plugin sm-herald-plugin sm-userfriends-plugin sm-moderation-plugin; do go -C ../$r vet ./... && go -C ../$r test -count=1 -short ./... || exit 1; done && for r in sm-chatchannels-plugin sm-chatbots-plugin sm-chatvideo-plugin sm-herald-plugin sm-userfriends-plugin; do GOWORK=off GOPRIVATE='git.golem15.com/*' go -C ../$r build ./... || exit 1; done</automated>
|
||||
<fails_when>Non-zero exit: any repo fails vet or its short suite, or a GOWORK=off build fails (for example "unknown revision v0.0.0" or a sibling replace path that no longer resolves).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -cE '^replace .*=> \.\./(sm-|fonoteka)' ../sm-chatvideo-plugin/go.mod ../sm-herald-plugin/go.mod ../sm-userfriends-plugin/go.mod` prints 0 for each file.
|
||||
- `grep -c 'v0.0.0' ../sm-chatvideo-plugin/go.mod ../sm-userfriends-plugin/go.mod` prints 0 for each file.
|
||||
- `grep -c 'sm-chat-plugin v0.1.3' ../sm-chatvideo-plugin/go.mod ../sm-userfriends-plugin/go.mod` prints 1 for each file.
|
||||
- `git -C ../sm-<repo> status --porcelain` prints nothing for all eight repos after the commits.
|
||||
</acceptance_criteria>
|
||||
<done>Every chat-family module resolves its dependencies by tag, so a demo build without go.work can require them.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 5: Go-ahead for the one-way release: create sm-moderation-plugin on git.golem15.com, push eight repositories and tag them</name>
|
||||
<read_first>The release table Claude prints before asking, one row per repository: current remote tags (`git ls-remote --tags origin`), the next free tag, the commit it will sit on, the commits it adds since the last tag, and why (seam, first tag, go.mod fix, new repo). Also whether `tea login list` shows a git.golem15.com login, and the visibility of sm-sitemap-plugin.</read_first>
|
||||
<acceptance_criteria>
|
||||
- The user chose one option and it is recorded in the SUMMARY. With "release", git.golem15.com/golem15/sm-moderation-plugin exists (`git ls-remote git@git.golem15.com:golem15/sm-moderation-plugin.git` exits 0) before Task 6 pushes.
|
||||
</acceptance_criteria>
|
||||
<decision>Create the sm-moderation-plugin repository, push the commits of this plan and set the eight tags now?</decision>
|
||||
<context>A pushed Go module tag is cached by module proxies and can never point anywhere else (one-way, as 19.4.1 D-03). Tags freeze exactly the commits Tasks 1-4 verified. chat v0.1.4 is a patch: the seam is additive and WavePath's parity tests stay green (D-18). Plan 02 cannot mount the chat family by tag until this runs. Creating a repository on git.golem15.com and pushing need your go-ahead (CLAUDE.md).</context>
|
||||
<options>
|
||||
<option id="release">
|
||||
<name>Create the repository, push and tag</name>
|
||||
<pros>Plan 02 can mount all eight plugins by tag; other applications can adopt the seams and the filter.</pros>
|
||||
<cons>The tags are permanent; a mistake needs a new patch tag, not a moved tag.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold</name>
|
||||
<pros>Nothing permanent happens.</pros>
|
||||
<cons>Plan 02 and every later plan are blocked; the demo cannot require the chat family.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<instructions>For "release": if Claude reported no tea login for git.golem15.com, create the repository golem15/sm-moderation-plugin (empty, same visibility as sm-sitemap-plugin, default branch main) in the git.golem15.com web UI first, then reply. Do not paste tokens into the chat.</instructions>
|
||||
<resume-signal>Reply "release" (add "repo ready" if you created the repository yourself) or "hold".</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 6: Release: push, tag and prove the eight tags resolve in module mode</name>
|
||||
<reversibility rating="one-way">Pushed module tags are cached by proxies and every consumer's go.sum; they can only be superseded, never moved.</reversibility>
|
||||
<precondition>Task 5 resolved with "release"; `git ls-remote git@git.golem15.com:golem15/sm-moderation-plugin.git` exits 0 or `tea login list` lists git.golem15.com.</precondition>
|
||||
<files>../sm-moderation-plugin/README.md</files>
|
||||
<read_first>The Task 5 release table, each repository's README changelog, .planning/notes/core-plugins-own-repos.md</read_first>
|
||||
<action>Per SC-1 and D-17/D-18.
|
||||
1. If the repository does not exist yet, create it with `tea repo create --name sm-moderation-plugin --owner golem15` at sm-sitemap-plugin's visibility. Then `git -C ../sm-moderation-plugin remote add origin git@git.golem15.com:golem15/sm-moderation-plugin.git` and push main with upstream.
|
||||
2. Push the default branch of chat, forum, chatvideo, herald and userfriends (and chatchannels or chatbots only if Task 4 committed there). No force-push.
|
||||
3. Read the next free tag from `git ls-remote --tags origin` per repository: chat the next v0.1.x patch (v0.1.4 expected), the others v0.1.0 unless taken. Create annotated tags (`git tag -a <tag> -m "<one-line summary>"`) on the HEADs Tasks 1-4 verified, chat first. Push each tag. Confirm `git ls-remote origin refs/tags/<tag>^{}` equals the local HEAD.
|
||||
4. If the moderation README says no tag exists yet, fix it in a commit before tagging that repository, never after.
|
||||
5. Module-mode proof: in a fresh `mktemp -d` directory, `go mod init example.invalid/familycheck`, require all eight modules at their new tags plus `git.golem15.com/golem15/summercms v0.5.1`, write a main.go that blank-imports the eight plugin packages, then run `GOWORK=off GOPRIVATE=git.golem15.com/* GOFLAGS=-mod=mod go build ./...`. Delete the directory afterwards. The SUMMARY records every tag with its commit hash for plan 02.</action>
|
||||
<verify>
|
||||
<automated>for r in sm-chat-plugin sm-chatchannels-plugin sm-chatbots-plugin sm-chatvideo-plugin sm-forum-plugin sm-herald-plugin sm-userfriends-plugin sm-moderation-plugin; do t=$(git -C ../$r describe --tags --exact-match HEAD) && [ "$(git -C ../$r ls-remote origin "refs/tags/$t^{}" | cut -f1)" = "$(git -C ../$r rev-parse HEAD)" ] || exit 1; done</automated>
|
||||
<fails_when>Non-zero exit: a repository HEAD carries no tag (describe fails), or the remote tag is missing or points at a different commit than the local HEAD.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git -C ../sm-chat-plugin describe --tags --exact-match HEAD` prints a v0.1.x tag at least v0.1.4.
|
||||
- The scratch module build printed no error, and the SUMMARY lists the eight tags with commit hashes.
|
||||
- `git -C ../sm-<repo> status --porcelain` prints nothing in all eight repositories.
|
||||
</acceptance_criteria>
|
||||
<done>All eight chat-family modules are on git.golem15.com at verified tags and resolve together without go.work.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| chat/forum HTTP clients to the plugin APIs | message and post bodies are untrusted input; the seam runs on them before storage |
|
||||
| host listeners to plugin services | host code decides refusals; a listener error must not leak internals |
|
||||
| admin settings to the filter | administrators edit the lists that every message is matched against |
|
||||
| build machine to git.golem15.com and module proxies | tags become immutable inputs of every consumer |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-20-01 | Tampering | chat write paths that skip the seam | high | mitigate | MessageSaving fires in SendMessageWith (SendMessage, replies and ReplyTo funnel into it) and EditMessage; TestMessageSavingTracer covers send, reply and edit; plan 06 adds the full matrix |
|
||||
| T-20-02 | Information disclosure | listener errors surfacing in API bodies | medium | mitigate | only contracts.Rejection maps to 422 with its own Field and Message; any other listener error keeps the existing opaque 500 path of writeChatError and writeForumError |
|
||||
| T-20-03 | Denial of service | filter matching cost | medium | mitigate | RE2 linear-time regexps from QuoteMeta'd entries; at most 500 entries of at most 200 characters per list (BeforeValidate); matchers compiled once per list version |
|
||||
| T-20-04 | Elevation of privilege | moderation settings screen | medium | mitigate | settings item behind the permission golem15.moderation.manage_settings, granted to the same role as sitemap settings |
|
||||
| T-20-05 | Tampering | one-way module tags | medium | mitigate | tags only on HEADs that passed vet and tests in Tasks 1-4; next free number read from the remote; go-ahead at Task 5; ls-remote check and a GOWORK=off scratch build after pushing |
|
||||
| T-20-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package-manager install: Go modules are the project's own tagged repositories plus gorm and gormigrate at versions already in sm-sitemap-plugin's go.sum (GOPRIVATE, go.sum committed) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: chat seam tracer green, chat parity tests unchanged, seven siblings at origin HEAD.
|
||||
- Task 2: forum seam tracer green, forum parity tests unchanged.
|
||||
- Task 3: moderation plugin builds, filter smoke green, no dependency on chat or forum.
|
||||
- Task 4: all eight repositories vet and test; five chat dependants build with GOWORK=off.
|
||||
- Task 5: go-ahead recorded.
|
||||
- Task 6: eight tags on origin equal to the verified HEADs; the scratch module builds them together.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
The chat family and the moderation plugin are released by tag (SC-1 prerequisite), with the D-18 seams additive and parity-safe and the D-17 filter shared and chat-agnostic.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/20-demo-instance-with-every-shared-plugin/20-01-SUMMARY.md` when done: per repository the before and after HEAD, the tag and its commit, the go.mod changes, how the moderation repository was created, and the checkpoint answer.
|
||||
</output>
|
||||
@@ -0,0 +1,400 @@
|
||||
---
|
||||
phase: 20-demo-instance-with-every-shared-plugin
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["20-01"]
|
||||
files_modified:
|
||||
- ../sm-demo-app/.gitmodules
|
||||
- ../sm-demo-app/go.work
|
||||
- ../sm-demo-app/go.mod
|
||||
- ../sm-demo-app/go.sum
|
||||
- ../sm-demo-app/summer.yaml
|
||||
- ../sm-demo-app/plugins.gen.go
|
||||
- ../sm-demo-app/plugins/golem15/oauthserver
|
||||
- ../sm-demo-app/plugins/golem15/chat
|
||||
- ../sm-demo-app/plugins/golem15/chatchannels
|
||||
- ../sm-demo-app/plugins/golem15/chatbots
|
||||
- ../sm-demo-app/plugins/golem15/chatvideo
|
||||
- ../sm-demo-app/plugins/golem15/forum
|
||||
- ../sm-demo-app/plugins/golem15/herald
|
||||
- ../sm-demo-app/plugins/golem15/userfriends
|
||||
- ../sm-demo-app/plugins/golem15/moderation
|
||||
- ../sm-demo-app/config/golem15.chatvideo.yaml
|
||||
- ../sm-demo-app/plugins/demo/plugin.go
|
||||
- ../sm-demo-app/plugins/demo/config/config.yaml
|
||||
- ../sm-demo-app/plugins/demo/chat.go
|
||||
- ../sm-demo-app/plugins/demo/chat_api.go
|
||||
- ../sm-demo-app/plugins/demo/chatbots.go
|
||||
- ../sm-demo-app/plugins/demo/moderation.go
|
||||
- ../sm-demo-app/plugins/demo/realtime_access.go
|
||||
- ../sm-demo-app/plugins/demo/routes.go
|
||||
- ../sm-demo-app/plugins/demo/classes/chat_context.go
|
||||
- ../sm-demo-app/plugins/demo/classes/forum_access.go
|
||||
- ../sm-demo-app/plugins/demo/console/seed.go
|
||||
- ../sm-demo-app/plugins/demo/console/seed_chat.go
|
||||
- ../sm-demo-app/plugins/demo/console/seed_forum.go
|
||||
- ../sm-demo-app/plugins/demo/console/seed_friends.go
|
||||
- ../sm-demo-app/plugins/demo/console/reset.go
|
||||
- ../sm-demo-app/chat_smoke_test.go
|
||||
- ../sm-demo-app/boot_test.go
|
||||
- ../sm-demo-app/realtime_test.go
|
||||
- ../sm-demo-app/deploy/centrifugo.json.example
|
||||
- ../sm-demo-app/deploy/livekit.yaml.example
|
||||
- ../sm-demo-app/deploy/env.example
|
||||
- ../sm-demo-app/scripts/dev.sh
|
||||
- ../sm-demo-app/README.md
|
||||
- .planning/todos/pending/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md
|
||||
- .planning/todos/done/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md
|
||||
autonomous: true
|
||||
requirements: [SC-1, D-05, D-06, D-07, D-08, D-09, D-10, D-12, D-14, D-17, D-20, D-22, D-23]
|
||||
estimate:
|
||||
tokens: 320000
|
||||
raw_tokens: 320000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "SC-1: sm-demo-app requires chat, chatchannels, chatbots, chatvideo, forum, herald, userfriends and moderation at the plan 01 tags, mounts them as submodules at plugins/golem15/<name> checked out on those tags, lists them in summer.yaml before golem15.demo, and passes TestGoModRequiresTags, TestBootAllPlugins and scripts/check-pins.sh; scripts/build.sh dev builds."
|
||||
- "Folded todo: plugins/golem15/oauthserver sits on tag v0.1.3 (which contains b6a9586), go.mod requires sm-oauthserver-plugin v0.1.3, the change is one commit `chore: bump sm-oauthserver-plugin to v0.1.3`, and the todo file moves to .planning/todos/done with g15office task ssuyq2os set to Live."
|
||||
- "D-06: golem15.demo registers the chat context alias demo (id 1, broadcast chat:context:1) whose members are every activated, non-deleted, non-banned user, and the alias channel (chatchannels' ChannelAlias) that resolves a seeded channel's backing conversation to the same membership with broadcast chat:channel:{id}; both require live membership."
|
||||
- "D-07: channels exist only through demo:seed; the chatchannels access resolver answers CanManageContext false, so POST /_chatchannels/api/v1/contexts/demo/1/channels is refused for every member; a member can join and leave seeded channels and post in them."
|
||||
- "D-08: any member can open a DM with any other member through POST /_chat/api/v1/contexts/demo/1/dm; userfriends does not gate it."
|
||||
- "D-09: demo:seed creates persona users (no usable password) with EN and PL channel history, threads, reactions and pins, forum categories, threads and posts in EN and PL, friendships and a friend group, the demo-moderators user group and default moderation lists; a second run reports exists; demo:reset wipes every chat-family table in foreign-key order and reseeds, keeping golem15_moderation_settings and the userfriends context singleton."
|
||||
- "D-17: a banned word or link in a chat message (create, reply, edit) or a forum thread, reply or edit is refused with 422 through the demo's listeners on events.MessageSaving and events.PostSaving calling the published moderation Filter."
|
||||
- "D-22: members of the user group named by golem15.demo.chat.moderator_group (default demo-moderators) can delete other members' messages in channel conversations (CanModerateMessage) and manage forum posts (forum CanManage); the bootstrap reports moderator true for them; banning stays in the user admin."
|
||||
- "D-05: chatbots lists the demo's ephemeral slash commands in GET /_chatbots/api/v1/commands and POST /_chatbots/api/v1/dispatch answers them in ephemeral mode; there is no bot persona."
|
||||
- "D-12, D-23: config/golem15.chatvideo.yaml sets max_duration 900, max_participants 4, token_ttl 900 and screen_share false; the server does not restrict calls by conversation type (calls are limited to DMs by the UI, plan 04)."
|
||||
- "GET /_demo/api/v1/chat answers a member with {member, moderator, context, channels[], conversations[], limits} and GET /_demo/api/v1/chat/members with {members[]}; both need a JWT."
|
||||
- "GET /api/realtime/token answers 200 for any member (guard demo.realtime-access) and 401 without a JWT; Centrifugo's committed config has the namespaces presentations, chat, user and video with the subscribe proxy; scripts/dev.sh up starts a local LiveKit on 127.0.0.1:7890 (TCP 7891, UDP 7892) with keys written to .env only (D-10 locally)."
|
||||
- "D-14: the forum resolver lets every member read and post in context demo/1 and the seeded EN and PL categories are listed by GET /_forum/api/v1/contexts/demo/1/categories."
|
||||
artifacts:
|
||||
- path: "../sm-demo-app/plugins/demo/classes/chat_context.go"
|
||||
provides: "DemoContext, ChannelContext adapter, ChatAccess resolver, membership query"
|
||||
contains: "ChatContextType"
|
||||
- path: "../sm-demo-app/plugins/demo/chat.go"
|
||||
provides: "registerChat: registry aliases demo and channel, resolvers, limits"
|
||||
contains: "ChannelAlias"
|
||||
- path: "../sm-demo-app/plugins/demo/chat_api.go"
|
||||
provides: "GET /_demo/api/v1/chat and /chat/members (host bootstrap contract)"
|
||||
contains: "/chat/members"
|
||||
- path: "../sm-demo-app/plugins/demo/moderation.go"
|
||||
provides: "MessageSaving and PostSaving listeners calling the moderation Filter"
|
||||
contains: "MessageSaving"
|
||||
- path: "../sm-demo-app/plugins/demo/realtime_access.go"
|
||||
provides: "demo.realtime-access middleware"
|
||||
contains: "demo.realtime-access"
|
||||
- path: "../sm-demo-app/plugins/demo/console/seed_chat.go"
|
||||
provides: "persona users, channels, messages, threads, reactions, pins (EN/PL)"
|
||||
contains: "persona"
|
||||
- path: "../sm-demo-app/deploy/livekit.yaml.example"
|
||||
provides: "LiveKit config template: port 7890, tcp 7891, udp mux 7892, room caps"
|
||||
contains: "udp_port: 7892"
|
||||
- path: "../sm-demo-app/chat_smoke_test.go"
|
||||
provides: "TestDemoChatTracer, TestDemoModerationSmoke, TestSeedChatSmoke"
|
||||
contains: "TestDemoChatTracer"
|
||||
key_links:
|
||||
- from: "../sm-demo-app/plugins/demo/chat.go"
|
||||
to: "../sm-demo-app/plugins/golem15/chat/contracts/registry.go"
|
||||
via: "reg.Add(p.ID(), chatcontracts.Registration{Alias: chanmodels.ChannelAlias, ...})"
|
||||
pattern: "Alias: +chanmodels\\.ChannelAlias"
|
||||
- from: "../sm-demo-app/plugins/demo/moderation.go"
|
||||
to: "../sm-demo-app/plugins/golem15/moderation/classes/filter.go"
|
||||
via: "app.Lookup[*modclasses.Filter]() then Check(ctx, content)"
|
||||
pattern: "Check\\(ctx"
|
||||
- from: "../sm-demo-app/plugins/demo/routes.go"
|
||||
to: "../sm-demo-app/plugins/demo/realtime_access.go"
|
||||
via: "lighthouse.Mount UserAuth surf.Use(\"jwt.auth\", \"demo.realtime-access\", bucketRealtime)"
|
||||
pattern: "demo\\.realtime-access"
|
||||
- from: "../sm-demo-app/plugins/demo/console/reset.go"
|
||||
to: "../sm-demo-app/plugins/demo/console/seed_chat.go"
|
||||
via: "resetTables lists the chat-family tables before users; runSeed reseeds them"
|
||||
pattern: "golem15_chat_messages"
|
||||
prohibitions:
|
||||
- statement: "No file inside a plugin submodule is edited from sm-demo-app; shared plugin fixes go to the plugin repos (plan 01 pattern) and need a new tag."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "go.mod carries no replace directive and no pseudo-version for a golem15 module."
|
||||
status: unresolved
|
||||
verification: automated
|
||||
- statement: "No LiveKit key, VAPID key or persona password appears in a committed file or in command output; .env holds them."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed in this plan (plan 05 pushes with the deploy go-ahead)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented here): `sm-demo-app` runs every shared Golem15 plugin in one binary, and its public Nuxt site shows them working; this phase adds the chat family to the live demo from Phase 19.4.1.
|
||||
|
||||
This plan's slice (plan 2 of 6, wave 2, per D-20): the demo backend. After it, a registered demo member can read and post in seeded channels, DM anyone, use slash commands, read and post in the forum and manage friends through the plugins' APIs. Moderation refuses banned content, and a local LiveKit can mint calls. Nothing in the Nuxt site changes yet (plans 03 and 04).
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion, recorded)
|
||||
|
||||
- **Contexts (D-06):** alias `demo`, id 1, group name "General", broadcast `chat:context:1`. Alias `channel` (chatchannels' `models.ChannelAlias`, legacy class `models.ChannelMorphName`) loads the channel by id, with broadcast `chat:channel:{id}`. Neither broadcast is subscribable, because AuthorizeChat admits only `chat:conversation:{id}`. Both implement `contracts.LiveMembership` (true) and `contracts.MessageModerator` (moderator group, non-DM conversations only), as bm does.
|
||||
- **Joining a channel (RESEARCH assumption A3):** the layer opens a channel with `GET /_chat/api/v1/contexts/channel/{channelId}/conversation`, whose GetGroupChat lazy-joins a context member. The tracer test proves it. If it does not join, the bootstrap adds the caller as participant of each joined channel with chat's `AddUserToGroupChat`, and the SUMMARY records which path holds.
|
||||
- **Personas (discretion):** four, Ada and Ben (English) and Ola and Tomek (Polish). Emails are `<name>@personas.demo.invalid` (RFC 2606, undeliverable). Each gets a random bcrypt hash that is never printed, so nobody can log in as a persona. Channels: `general` (#general), `announcements` (#announcements), `ogolny` (#ogólny, Polish). About 15 to 25 messages per language with two threads, reactions and one pin per channel. Persona Ada sends a welcome DM to the visitor account when golem15.demo.visitor.email is set.
|
||||
- **Seeding writes:** raw SQL in the existing seed style (`insertID`, `exists`), or a ChannelService instance built with a seed-only resolver and no bus or realtime, for the channel rows. The demo's own resolver refuses CanManageContext (RESEARCH Anti-Patterns), so it cannot seed. Forum posts store content_html from `breakwater.Render`, the plugin's own renderer.
|
||||
- **Moderator group (D-22):** user group code `demo-moderators` (config `golem15.demo.chat.moderator_group`), created by the seed with no members. demo:reset wipes memberships with the users, so moderators are reassigned after a reset.
|
||||
- **Slash commands (D-05 discretion):** `/demo` explains what the demo shows and `/tour` lists the site sections with their paths. Both are ephemeral, English and Polish chosen from the actor's locale when known (else English). `/help` is chatbots' built-in.
|
||||
- **Bootstrap contract:** bm's `/_bm/api/v1/chat` shape with `general` replaced by `channels[]`. Each channel is `{id, slug, name, conversation_id, joined, unread, mentions}`. vue-chat-layer documents this contract under a neutral name (plan 03).
|
||||
- **Local LiveKit (D-10 locally):** ports 7890/7891/7892, the same as rome (RESEARCH Pitfall 7), so dev and production configs differ only in keys and external IP. `livekit_url` locally is `ws://127.0.0.1:7890`.
|
||||
- spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip).
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Submodules `plugins/golem15/{chat,chatchannels,chatbots,chatvideo,forum,herald,userfriends,moderation}`; summer.yaml entries; go.mod requires at the plan 01 tags; sm-oauthserver-plugin v0.1.3.
|
||||
- golem15.demo: `classes.DemoContext`, `classes.ChannelContext`, `classes.ChatAccess`, `classes.ForumAccess`, `classes.ChatContextType = "demo"`, `classes.ChatContextID = 1`, `classes.ChatBroadcastChannel = "chat:context:1"`, `classes.ChannelBroadcastChannel(id uint) string`, `classes.MemberIDs`; `(*Plugin).registerChat`; `MiddlewareRealtimeAccess = "demo.realtime-access"` with `pact.HasMiddleware`; routes `GET /_demo/api/v1/chat` and `GET /_demo/api/v1/chat/members`; slash commands `/demo` and `/tour`; seam listeners for `chatevents.MessageSaving` and `forumevents.PostSaving`.
|
||||
- Config keys: `golem15.demo.chat.moderator_group` (default `demo-moderators`), `golem15.demo.chat.max_embeds` (default 3); `config/golem15.chatvideo.yaml` (`livekit_url`, `livekit_admin_url`, `max_duration: 900`, `max_participants: 4`, `token_ttl: 900`, `screen_share: false`).
|
||||
- demo:seed steps: `chat personas`, `chat channels and history (en)`, `chat channels and history (pl)`, `forum categories and threads (en, pl)`, `friends and group`, `moderator group demo-moderators`, `moderation lists`; demo:reset table list extended.
|
||||
- Deploy and dev: `deploy/livekit.yaml.example`, Centrifugo namespaces `chat`, `user`, `video`; `scripts/dev.sh` LiveKit start and stop (pid name `livekit`); env keys `SUMMER_GOLEM15__CHATVIDEO__LIVEKIT_API_KEY` and `SUMMER_GOLEM15__CHATVIDEO__LIVEKIT_API_SECRET` (names only in env.example).
|
||||
- Smoke tests: `TestDemoChatTracer`, `TestDemoModerationSmoke`, `TestSeedChatSmoke` (chat_smoke_test.go). Full unit tests come in plan 06.
|
||||
|
||||
<objective>
|
||||
Mount the released chat family in sm-demo-app and give it a demo host: community context, channel adapter, resolvers, bootstrap endpoints, realtime for every member, moderation wiring, slash commands, EN/PL showcase seed with reset, and a local LiveKit.
|
||||
|
||||
Purpose: SC-1 (builds with every shared plugin; demo:seed fills each), D-05 to D-09, D-12, D-14, D-17, D-22, D-23 on the server side, and the folded oauthserver todo. Plans 03 and 04 build the site UI on top of these endpoints.
|
||||
Output: commits in ../sm-demo-app (code) and one planning commit in summercms.go (the todo move). Nothing pushed.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-CONTEXT.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-RESEARCH.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-PATTERNS.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-01-SUMMARY.md
|
||||
@.planning/todos/pending/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md
|
||||
@.planning/G15OFFICE.md
|
||||
|
||||
Repo: ../sm-demo-app (all code edits). Read-only: the mounted plugin submodules, ../sm-bm-app/plugins/jz/bm (host analog), ../sm-bm-app/deploy/centrifugo/bm-config.json (namespace options).
|
||||
|
||||
Rules: no co-author tags; stage by explicit path; one `feat: mount golem15.<id> <tag>` commit per plugin (precedent 9451b31: submodule, .gitmodules, go.work, go.mod, go.sum, summer.yaml, plugins.gen.go, boot_test.go); planning files in their own commit in summercms.go. Other sessions (Phase 33) may have uncommitted edits in sm-demo-app or vue-demo-app: check `git status --porcelain` before each commit, never stage or revert files this plan did not change, and stop if a file this plan must change carries foreign edits. Verify commands run from the framework root with `go -C ../sm-demo-app` and scripts by path (they cd to their own root). Docker tests: if the socket is denied, run through `sudo -u golem`.
|
||||
|
||||
<interfaces>
|
||||
Host analog ../sm-bm-app/plugins/jz/bm:
|
||||
- chat.go: `registerChat` (HasPlugin guard, `app.Lookup[*chatcontracts.Registry]()`, `reg.Add(p.ID(), chatcontracts.Registration{Alias, Loader})`, `lagoon.OnDatabase` then `app.Lookup[*chanclasses.ChannelService]()` and `SetAccessResolver`; `installChatLimits` via `svc.SetMaxMessageBytes`).
|
||||
- classes/chat_context.go: assertions `chatcontracts.Context`, `MessageModerator`, `LiveMembership`, `chancontracts.AccessResolver`; methods ID, Type, ChatEnabled, GroupName, BroadcastChannel, CanActFor (`chatcontracts.DefaultCanActFor`), CanOverseeChats, CanModerateMessage, RequireLiveMembership, HasMember, Members; `ChatContextLoader`; member query joins users_groups and user_groups.
|
||||
- realtime_access.go: `MiddlewareRealtimeAccess`, `Middlewares() map[string]pact.Middleware`, `realtimeAccess`, `writeRealtimeGuard`.
|
||||
- controllers/api/chat.go: bootstrap JSON structs (member, moderator, context, general, conversations, limits; members with id, name, surname, handle, is_bot).
|
||||
chatchannels: `models.ChannelAlias = "channel"`, `models.ChannelMorphName`, `models.Channel{ContextType, Name, Slug, Visibility...}`; `chancontracts.AccessResolver{CanAccessContext, CanManageContext}`; `ChannelService.CreateChannel` gated by CanManageContext (channel_service.go:136); `JoinChannel` only upserts a pref row (:959).
|
||||
chat: `Service.GetGroupChat(ctx, actorID, alias, contextID)`, `AddUserToGroupChat(ctx, userID, alias, contextID)`, `GetConversationsForUser`, `GetAvailableDmRecipients`, `SetMaxMessageBytes`, `MaxMessageBytes`; events `MessageSaving` and `contracts.Rejection` (plan 01).
|
||||
forum: `forum.Plugin.SetAccessResolver`, `contracts.AccessResolver` (CanRead, CanPost, CanManage, CanManageContext), `ForumService.RegisterContext(alias, legacyPHPClass)`, events `PostSaving`, `contracts.Rejection` (plan 01).
|
||||
chatbots: `contracts.SlashCommand{Trigger, Description, IsAvailable, Handle}`, `classes.Ephemeral(payload)`, `(*classes.Registry).Register` (help is reserved), Registry published in chatbots' OnDatabase hook.
|
||||
moderation: `*classes.Filter` published at boot, `Check(ctx, text) (*Violation, error)` (plan 01).
|
||||
chatvideo config keys (config/config.yaml of the plugin): livekit_url, livekit_api_key, livekit_api_secret, max_duration, token_ttl, livekit_admin_url, max_participants, screen_share; env `SUMMER_GOLEM15__CHATVIDEO__*` or PHP names `LIVEKIT_*`.
|
||||
sm-demo-app today: plugins/demo/plugin.go (Requires, Boot calls wireRealtime), routes.go (`APIPrefix`, `mountRealtime` limited to presentations with `presentations.realtime-owner`), console/seed.go (`runSeed` step table, `created`, `insertID`, `exists`), console/reset.go (`resetTables`, children first), boot_test.go (`wantPlugins`, routed prefixes), realtime_test.go (`TestRealtimeRoutes`, `wantNamespaces`), testmain_test.go (`bootDemo`), go_mod_test.go, scripts/dev.sh (`ensure_env`, `start_centrifugo`, `claim_port`, `up`/`down`/`status`), deploy/centrifugo.json.example (namespaces array with `subscribe_proxy_enabled`).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a registered member reads the chat bootstrap and posts in the seeded #general channel through the chat API</name>
|
||||
<precondition>The plan 01 SUMMARY lists the eight tags and `git ls-remote --tags git@git.golem15.com:golem15/sm-chat-plugin.git` shows the chat tag; `git -C ../sm-demo-app status --porcelain` shows nothing except ` M plugins/golem15/oauthserver`; `docker info` exits 0.</precondition>
|
||||
<files>../sm-demo-app/plugins/golem15/oauthserver, ../sm-demo-app/.gitmodules, ../sm-demo-app/go.work, ../sm-demo-app/go.mod, ../sm-demo-app/go.sum, ../sm-demo-app/summer.yaml, ../sm-demo-app/plugins.gen.go, ../sm-demo-app/plugins/golem15/chat, ../sm-demo-app/plugins/golem15/chatchannels, ../sm-demo-app/plugins/demo/plugin.go, ../sm-demo-app/plugins/demo/chat.go, ../sm-demo-app/plugins/demo/classes/chat_context.go, ../sm-demo-app/plugins/demo/chat_api.go, ../sm-demo-app/plugins/demo/routes.go, ../sm-demo-app/plugins/demo/console/seed.go, ../sm-demo-app/plugins/demo/console/seed_chat.go, ../sm-demo-app/boot_test.go, ../sm-demo-app/chat_smoke_test.go, .planning/todos/pending/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md, .planning/todos/done/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md</files>
|
||||
<read_first>../sm-demo-app/summer.yaml, go.mod, go.work, .gitmodules, plugins.gen.go, `git -C ../sm-demo-app show 9451b31 --stat` (mount commit shape), ../sm-demo-app/plugins/demo/plugin.go, routes.go, console/seed.go, ../sm-demo-app/boot_test.go, testmain_test.go, internal/demotest/postgres.go, ../sm-bm-app/plugins/jz/bm/chat.go, classes/chat_context.go, controllers/api/chat.go, ../sm-demo-app/plugins/golem15/chat/contracts/*.go and classes/writes.go (GetGroupChat, AddUserToGroupChat) after mounting, ../sm-demo-app/plugins/golem15/chatchannels/models/channel.go, classes/channel_service.go (CreateChannel, JoinChannel), contracts/*.go, ../sm-demo-app/scripts/check-pins.sh, the folded todo, .planning/G15OFFICE.md</read_first>
|
||||
<action>Per D-06, D-07, SC-1 and the folded todo.
|
||||
|
||||
1. Folded todo first:
|
||||
- `git -C ../sm-demo-app/plugins/golem15/oauthserver fetch --tags`, confirm `git merge-base --is-ancestor b6a9586 v0.1.3`, then check out v0.1.3;
|
||||
- in ../sm-demo-app run `GOWORK=off GOPRIVATE=git.golem15.com/* go get git.golem15.com/golem15/sm-oauthserver-plugin@v0.1.3` and `go mod tidy`;
|
||||
- commit the gitlink with go.mod and go.sum as `chore: bump sm-oauthserver-plugin to v0.1.3`.
|
||||
In summercms.go, `git mv` the todo to .planning/todos/done/, add a "Done in Phase 20 plan 02" line, and commit it as a planning commit. Move g15office task ssuyq2os to Live with a one-line comment, as .planning/G15OFFICE.md describes.
|
||||
2. Mount chat and chatchannels at their plan 01 tags:
|
||||
- `git submodule add -b main git@git.golem15.com:golem15/sm-<name>-plugin.git plugins/golem15/<name>`, then check out the tag;
|
||||
- add both to go.work `use`, and run `GOWORK=off go get <module>@<tag>` plus `go mod tidy`;
|
||||
- add summer.yaml entries after golem15.user (chat, then chatchannels) and before golem15.demo;
|
||||
- regenerate plugins.gen.go the way 9451b31 did (the summer build generator);
|
||||
- append the IDs to boot_test.go wantPlugins and `/_chat/api/v1` and `/_chatchannels/api/v1` to its routed prefixes.
|
||||
One `feat: mount golem15.<id> <tag>` commit per plugin.
|
||||
3. plugins/demo/classes/chat_context.go, ported from bm's chat_context.go:
|
||||
- constants ChatContextType "demo", ChatContextID 1, ChatGroupName "General", ChatBroadcastChannel "chat:context:1", and `ChannelBroadcastChannel(id)` returning "chat:channel:{id}";
|
||||
- `MemberIDs(ctx, db)` and `IsMember(ctx, db, userID)`: users that are activated, have no deleted_at and are not banned (use the user plugin's own ban column; read its models);
|
||||
- DemoContext and ChannelContext implement chatcontracts.Context, MessageModerator and LiveMembership. CanModerateMessage is true only for a member of the moderator group in a non-DM conversation. RequireLiveMembership returns true;
|
||||
- ChatAccess (chancontracts.AccessResolver): CanAccessContext is true for a member when the type is "demo" and the id is 1; CanManageContext is always false (D-07);
|
||||
- keep bm's `WithMembership` and `MemberFunc` injection so tests can stub membership.
|
||||
4. plugins/demo/chat.go `registerChat(app)`, called from Boot after wireRealtime:
|
||||
- with the HasPlugin guard, register alias demo (loader returns DemoContext for id 1 and the unknown-context error otherwise) and alias `chanmodels.ChannelAlias` with `LegacyPHPClass: chanmodels.ChannelMorphName` (loader reads the channel row by id and returns ChannelContext);
|
||||
- in lagoon.OnDatabase, install ChatAccess on the ChannelService.
|
||||
Requires() gains golem15.chat and golem15.chatchannels. Config keys go into plugins/demo/config/config.yaml in Task 2.
|
||||
5. console/seed_chat.go: a step `chat channels and history (en)` that, in this task, creates only #general in context demo/1 (channel row plus backing conversation, exactly the rows CreateChannel writes, through a seed-only resolver or raw SQL) and one welcome message from the first admin or a persona. It is idempotent by slug. Add it to the runSeed step table.
|
||||
6. plugins/demo/chat_api.go: `GET /_demo/api/v1/chat` behind `jwt.auth` and `throttle:120,1`, mounted from Routes. For a member it answers `{member: true, moderator, context: {type: "demo", id: 1}, channels: [{id, slug, name, conversation_id, joined, unread, mentions}], conversations: [DMs as bm's chatConversationJSON], limits: {max_files, max_bytes, max_embeds}}`. For a non-member it answers `{member: false}`. Errors use `wire.WriteJSON` with `{"error": ...}`.
|
||||
7. chat_smoke_test.go `TestDemoChatTracer` (Docker): bootDemo, run demo:seed, create and activate a user, then with its JWT:
|
||||
- GET /_demo/api/v1/chat shows #general with a conversation_id;
|
||||
- GET /_chat/api/v1/contexts/channel/{channelId}/conversation answers 200;
|
||||
- POST /_chat/api/v1/conversations/{conversation_id}/messages answers 201;
|
||||
- GET .../messages contains the text;
|
||||
- POST /_chatchannels/api/v1/contexts/demo/1/channels is refused (403 or 404).
|
||||
If the lazy join does not make the user a participant, implement the AddUserToGroupChat fallback in the bootstrap and record it.
|
||||
|
||||
Commit the host wiring and the test as `feat: demo chat context, channel adapter and bootstrap`.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-demo-app vet . ./plugins/demo/... && go -C ../sm-demo-app test -count=1 -short . ./plugins/demo/... && go -C ../sm-demo-app test -count=1 -run '^(TestDemoChatTracer|TestGoModRequiresTags|TestBootAllPlugins)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3 && ../sm-demo-app/scripts/check-pins.sh</automated>
|
||||
<fails_when>Non-zero exit: vet or the short suite red; fewer than three "--- PASS" lines (a replace or pseudo-version in go.mod, a plugin not activated, a missing route prefix, the member cannot post, or the channel create was allowed); or check-pins prints a mismatch line instead of "check-pins: ok".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git -C ../sm-demo-app log --oneline -6` shows `chore: bump sm-oauthserver-plugin to v0.1.3`, `feat: mount golem15.chat`, `feat: mount golem15.chatchannels` and the host commit, each separate.
|
||||
- `git -C ../sm-demo-app submodule status plugins/golem15/oauthserver plugins/golem15/chat plugins/golem15/chatchannels` shows no leading `+` and each describes as its tag.
|
||||
- `test -f .planning/todos/done/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md && ! test -e .planning/todos/pending/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md` exits 0.
|
||||
- `grep -c 'Alias: *chanmodels.ChannelAlias' ../sm-demo-app/plugins/demo/chat.go` prints 1.
|
||||
</acceptance_criteria>
|
||||
<done>A demo member can open the seeded #general channel and post in it through the shared chat API, from a build that requires every module by tag.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: The rest of the family is mounted and wired: forum, friends, slash commands, video config, herald, and moderation refusing banned content</name>
|
||||
<files>../sm-demo-app/.gitmodules, ../sm-demo-app/go.work, ../sm-demo-app/go.mod, ../sm-demo-app/go.sum, ../sm-demo-app/summer.yaml, ../sm-demo-app/plugins.gen.go, ../sm-demo-app/plugins/golem15/chatbots, ../sm-demo-app/plugins/golem15/chatvideo, ../sm-demo-app/plugins/golem15/forum, ../sm-demo-app/plugins/golem15/herald, ../sm-demo-app/plugins/golem15/userfriends, ../sm-demo-app/plugins/golem15/moderation, ../sm-demo-app/config/golem15.chatvideo.yaml, ../sm-demo-app/plugins/demo/plugin.go, ../sm-demo-app/plugins/demo/config/config.yaml, ../sm-demo-app/plugins/demo/chat.go, ../sm-demo-app/plugins/demo/chatbots.go, ../sm-demo-app/plugins/demo/moderation.go, ../sm-demo-app/plugins/demo/classes/forum_access.go, ../sm-demo-app/boot_test.go, ../sm-demo-app/chat_smoke_test.go</files>
|
||||
<read_first>../sm-demo-app/plugins/golem15/forum/README.md, boot.go, contracts/access_resolver.go, ../sm-demo-app/plugins/golem15/chatbots/README.md, contracts/slash_command.go, classes/registry.go, classes/result.go, boot.go, ../sm-demo-app/plugins/golem15/chatvideo/README.md and config/config.yaml, ../sm-demo-app/plugins/golem15/herald/README.md and config/config.yaml, ../sm-demo-app/plugins/golem15/userfriends/README.md, ../sm-demo-app/plugins/golem15/moderation/README.md and classes/filter.go, ../sm-demo-app/plugins/golem15/chat/events/events.go and contracts/rejection.go, ../sm-demo-app/plugins/golem15/forum/events/events.go and contracts/rejection.go, ../sm-demo-app/plugins/demo/chat.go (Task 1), ../sm-bm-app/plugins/jz/bm/chat.go (config key block)</read_first>
|
||||
<action>Per D-05, D-08, D-12, D-14, D-17, D-22, D-23, SC-1.
|
||||
|
||||
1. Mount chatbots, chatvideo, forum, herald, userfriends and moderation at their plan 01 tags as in Task 1, one `feat: mount golem15.<id> <tag>` commit each. Order in summer.yaml: chatbots and chatvideo after chatchannels; forum, herald and userfriends after them (userfriends after chat, so it sees the chat registry at Boot); moderation; all before golem15.demo. Extend boot_test.go wantPlugins and its prefixes: /_chatbots/api/v1, /_chatvideo/api/v1, /_forum/api/v1, /_herald/api/v1, /_userfriends/api/v1 and /_demo/api/v1/chat.
|
||||
2. config/golem15.chatvideo.yaml (D-12, D-23): livekit_url "ws://127.0.0.1:7890", livekit_admin_url "http://127.0.0.1:7890", max_duration 900, max_participants 4, token_ttl 900, screen_share false. API key and secret come only from the environment. Herald gets no committed config: its keys come from .env (plan 04).
|
||||
3. plugins/demo/config/config.yaml: `chat: {moderator_group: demo-moderators, max_embeds: 3}`. chat.go reads them as in bm's config key block and reports the limits in the bootstrap (max_bytes from the chat service's MaxMessageBytes).
|
||||
4. Forum (D-14, D-22): classes/forum_access.go `ForumAccess`. CanRead and CanPost are true for a member when the context is demo/1; CanManage and CanManageContext are true for members of the moderator group. registerChat looks up `*forum.Plugin` and calls SetAccessResolver, and registers the context alias demo with the forum service when its API requires registration (check ListCategories on an unregistered alias).
|
||||
5. DMs (D-08): nothing to add beyond DemoContext.HasMember. Verify in the smoke test that two members open a DM with POST /_chat/api/v1/contexts/demo/1/dm.
|
||||
6. chatbots.go (D-05): `/demo` and `/tour` implement contracts.SlashCommand with IsAvailable true for any non-zero actor and Handle returning classes.Ephemeral with a short text (/tour lists /blog, /presentations, /quizzes, /subscriptions, /faq, /chat, /forum, /friends). Register them in lagoon.OnDatabase after chatbots published its Registry; a missing registry is a boot error naming the summer.yaml order.
|
||||
7. moderation.go (D-17): in Boot, `app.Events.Listen[*chatevents.MessageSaving](p.ID(), ...)` looks up `*modclasses.Filter` per event and returns `chatcontracts.Rejection{Field: "content", Message: v.Message}` when Check finds a violation. `app.Events.Listen[*forumevents.PostSaving]` checks Title (Field "title"), then Body (Field "body"), returning `forumcontracts.Rejection`. A Check error is returned as is (500). A missing filter is a boot error, because golem15.demo requires golem15.moderation.
|
||||
8. plugin.go Requires() lists every plugin whose tables the seed or reset writes or that the demo wires: chat, chatchannels, chatbots, chatvideo, forum, herald, userfriends, moderation, plus the existing ones. The package doc mentions the chat host.
|
||||
9. chat_smoke_test.go `TestDemoModerationSmoke` (Docker): seed, then set golem15_moderation_settings banned_words to "blockedword" and banned_links to "bad.example". A member's message "this has blockedword" in #general answers 422 errors.content; a forum thread titled "visit bad.example" in a seeded category answers 422 errors.title; a clean message answers 201. Also assert the DM open from step 5 and that GET /_chatbots/api/v1/commands lists demo and tour.
|
||||
|
||||
Commit wiring (`feat: demo hosts forum, friends, slash commands and moderation`) separately from the six mount commits.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-demo-app vet . ./plugins/demo/... && go -C ../sm-demo-app test -count=1 -short . ./plugins/demo/... && go -C ../sm-demo-app test -count=1 -run '^(TestDemoModerationSmoke|TestBootAllPlugins|TestGoModRequiresTags|TestDemoChatTracer)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 4 && ../sm-demo-app/scripts/check-pins.sh && ../sm-demo-app/scripts/build.sh dev</automated>
|
||||
<fails_when>Non-zero exit: vet or short suite red; fewer than four "--- PASS" lines (banned content stored, a plugin not activated, a prefix not routed, a go.mod rule broken); check-pins reports a mismatch; or the dev build fails.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -cE '^ - id: golem15\.(chat|chatchannels|chatbots|chatvideo|forum|herald|userfriends|moderation)$' ../sm-demo-app/summer.yaml` prints 8.
|
||||
- `grep -c 'max_participants: 4' ../sm-demo-app/config/golem15.chatvideo.yaml` prints 1 and `grep -c 'max_duration: 900' ../sm-demo-app/config/golem15.chatvideo.yaml` prints 1.
|
||||
- `grep -rn 'livekit_api_secret: *"[^"]' ../sm-demo-app/config` prints nothing (no committed secret).
|
||||
- `git -C ../sm-demo-app status --porcelain -- plugins/golem15` prints nothing (submodules clean, on tags).
|
||||
</acceptance_criteria>
|
||||
<done>Every chat-family plugin boots in the demo with its host policy; banned words and links are refused in chat and forum; slash commands answer; video is configured with the D-12 caps.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: demo:seed fills chat, forum and friends with EN and PL showcase data, and demo:reset wipes and reseeds all of it</name>
|
||||
<files>../sm-demo-app/plugins/demo/console/seed.go, ../sm-demo-app/plugins/demo/console/seed_chat.go, ../sm-demo-app/plugins/demo/console/seed_forum.go, ../sm-demo-app/plugins/demo/console/seed_friends.go, ../sm-demo-app/plugins/demo/console/reset.go, ../sm-demo-app/chat_smoke_test.go</files>
|
||||
<read_first>../sm-demo-app/plugins/demo/console/seed.go (step table, created, insertID, exists, visitor), seed_locales.go, reset.go (resetTables order and comments), ../sm-demo-app/reset_test.go and seed_test.go, the baseline migrations of every mounted chat-family plugin (../sm-demo-app/plugins/golem15/{chat,chatchannels,chatbots,chatvideo,forum,herald,userfriends}/updates/*.go) for table names and foreign keys, ../sm-demo-app/plugins/golem15/chatchannels/models (pins, mentions, members, prefs), ../sm-demo-app/plugins/golem15/chat/models (message, reaction, thread summary), modules/breakwater/README.md, 20-RESEARCH.md (Pitfall 10, Anti-Patterns)</read_first>
|
||||
<action>Per D-09, D-14, D-15 (data), D-22, D-17.
|
||||
|
||||
- seed_chat.go:
|
||||
- `chat personas`: Ada, Ben, Ola and Tomek, as decided above. Each is activated, has a random password hash that is never printed, and is skipped when the email exists.
|
||||
- `chat channels and history (en)`: #general and #announcements, with English history from the personas, two threads (root plus 2 to 4 replies, keeping chat's thread summary rows consistent the way the service writes them), reactions on a few messages using the keys chat allows (GET /reactions catalog in the plugin), and one pin per channel in chatchannels' pins table.
|
||||
- `chat channels and history (pl)`: #ogólny (slug ogolny) with Polish history, a thread, reactions and a pin.
|
||||
- A welcome DM from Ada to the visitor when golem15.demo.visitor.email is set (skipped otherwise).
|
||||
- Rows are written in created order with timestamps spread over the last few days. Messages are inserted directly: no realtime, no jobs, no events. Steps are idempotent by channel slug and persona email.
|
||||
- seed_forum.go `forum categories and threads (en, pl)`: categories "General discussion" and "Show and tell" (EN) and "Rozmowy" (PL) in context demo/1, two or three threads each with replies by personas. content is Markdown and content_html comes from `breakwater.Render`. Idempotent by category slug.
|
||||
- seed_friends.go `friends and group`: accepted friendships among the personas, one pending request from Ola to the visitor when set, and a friend group "Demo crew" owned by Ada with Ben and Tomek as members. Use userfriends' tables and the context singleton id 1. Idempotent.
|
||||
- seed.go: steps `moderator group demo-moderators` (a user_groups row with the configured code and name "Demo moderators", no members) and `moderation lists` (when banned_words and banned_links are both empty, fill a short, mild default list of common spam words and one link-shortener domain; otherwise "exists"). Order the new steps after the visitor and before the roadmap decks.
|
||||
- reset.go `resetTables` (RESEARCH Pitfall 10), children first, all before `users`:
|
||||
- chatvideo participants, then sessions;
|
||||
- chat reactions, thread mutes, thread summaries, messages, participants, conversations;
|
||||
- chatchannels mentions, pins, members, prefs, user context prefs, personal categories, channels, categories;
|
||||
- forum thread reads, posts, threads, categories;
|
||||
- userfriends group bans, invites, group members, groups, friendships;
|
||||
- herald push subscriptions;
|
||||
- chatbots bot flags.
|
||||
Use the exact table names from the migrations. Keep golem15_moderation_settings and golem15_userfriends_contexts. Update the comment and the command description. Remove the chat-family uploads (chat attachments in system_files) the same way the reset already removes other owners' attachments, keeping resetKeptAttachments.
|
||||
- chat_smoke_test.go `TestSeedChatSmoke` (Docker): seed with a visitor email.
|
||||
- Three channels exist, #ogólny holds Polish text, at least one thread root has replies, at least one reaction and one pin exist, the forum has three categories with posts whose content_html is non-empty, there are at least three accepted friendships and the group "Demo crew", and the visitor has a DM.
|
||||
- A second seed run reports every new step as "exists".
|
||||
- demo:reset --force empties golem15_chat_messages before reseeding and leaves golem15_moderation_settings unchanged.
|
||||
|
||||
Commit `feat: seed and reset the chat family (EN/PL)`.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-demo-app vet . ./plugins/demo/... && go -C ../sm-demo-app test -count=1 -run '^(TestSeedChatSmoke|TestResetKeepsPlans|TestSeedRoadmapUpsert)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>Non-zero exit or fewer than three "--- PASS" lines: seed not idempotent, a missing EN/PL item, a foreign-key violation in the reset order, moderation settings wiped, or the existing reset and roadmap tests broken.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'golem15_chat_messages' ../sm-demo-app/plugins/demo/console/reset.go` prints at least 1, and TestSeedChatSmoke asserts the moderation settings row is identical before and after demo:reset --force.
|
||||
- `grep -c 'personas.demo.invalid' ../sm-demo-app/plugins/demo/console/seed_chat.go` prints at least 1.
|
||||
- Running `../sm-demo-app/scripts/dev.sh seed` twice against the local stack prints no "created" for the new steps on the second run.
|
||||
</acceptance_criteria>
|
||||
<done>A fresh or reset demo shows lived-in EN and PL channels, threads, reactions, pins, forum threads, friendships and a friend group, and the reset job keeps it that way.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Every member gets realtime, the layers get their members endpoint, and dev runs Centrifugo namespaces and a local LiveKit</name>
|
||||
<files>../sm-demo-app/plugins/demo/realtime_access.go, ../sm-demo-app/plugins/demo/routes.go, ../sm-demo-app/plugins/demo/plugin.go, ../sm-demo-app/plugins/demo/chat_api.go, ../sm-demo-app/realtime_test.go, ../sm-demo-app/deploy/centrifugo.json.example, ../sm-demo-app/deploy/livekit.yaml.example, ../sm-demo-app/deploy/env.example, ../sm-demo-app/scripts/dev.sh, ../sm-demo-app/README.md</files>
|
||||
<read_first>../sm-bm-app/plugins/jz/bm/realtime_access.go, ../sm-demo-app/plugins/demo/routes.go (mountRealtime), realtime.go, ../sm-demo-app/realtime_test.go (TestRealtimeRoutes, wantNamespaces, realtimeApp), ../sm-bm-app/deploy/centrifugo/bm-config.json (chat, user and video namespace options), ../sm-demo-app/deploy/centrifugo.json.example, ../sm-demo-app/scripts/dev.sh (ensure_env, start_centrifugo, claim_port, up, down, status, the usage header), ../sm-demo-app/deploy/env.example, ../sm-demo-app/README.md, ../sm-demo-app/plugins/golem15/chatvideo/README.md (keys, admin URL, TrustedMode), 20-RESEARCH.md (Pitfalls 4, 5, 7; LiveKit config block)</read_first>
|
||||
<action>Per D-10 (locally), D-06, SC-1.
|
||||
|
||||
1. realtime_access.go: `MiddlewareRealtimeAccess = "demo.realtime-access"`, exported through `Middlewares() map[string]pact.Middleware` (assert pact.HasMiddleware in plugin.go). The guard reads `bouncer.User(r.Context())`: no user gives 401, a database not yet published gives 503, and a user for whom classes.IsMember is true passes. Otherwise it falls back to the presentations owner guard looked up once (sync.Once) by name, as bm does. routes.go mountRealtime mounts when golem15.presentations or golem15.chat is active, with `UserAuth: surf.Use("jwt.auth", MiddlewareRealtimeAccess, bucketRealtime)`. Update its doc comment.
|
||||
2. realtime_test.go: the token route's expected middleware becomes ["jwt.auth", "demo.realtime-access", "throttle:golem15.demo.realtime"], and wantNamespaces gains the namespaces the chat, chatvideo and user plugins register. Plan 06 adds the member/non-member cases.
|
||||
3. chat_api.go `GET /_demo/api/v1/chat/members` (jwt.auth, throttle:120,1) answers `{members: [{id, name, surname, handle, is_bot}]}` for every member except the caller, as bm's ChatMembers does (handle from the user plugin's username or a slug of the name; is_bot false). A non-member gets 403.
|
||||
4. deploy/centrifugo.json.example: add namespaces `chat` (presence, join_leave, force_push_join_leave, allow_presence_for_subscriber and subscribe_proxy_enabled, with the option names bm's config uses translated to this file's v6 format), `user` (subscribe_proxy_enabled) and `video` (subscribe_proxy_enabled). Run `centrifugo checkconfig -c deploy/centrifugo.json.example`, then diff the option names against `centrifugo defaultconfig`, because checkconfig misses misspelt names. Update the _comment lines.
|
||||
5. deploy/livekit.yaml.example (RESEARCH LiveKit block):
|
||||
- port 7890, bind_addresses [127.0.0.1];
|
||||
- rtc tcp_port 7891, udp_port 7892 (UDP mux only, no port-range keys), use_external_ip true and a comment that local runs set it false;
|
||||
- room max_participants 4, empty_timeout 120, departure_timeout 20;
|
||||
- keys with one placeholder pair (CHANGE_ME_KEY: CHANGE_ME_SECRET);
|
||||
- a header comment naming rome's path /srv/summercms-demo/livekit.yaml (0640 root:summercms-demo), the supervisor program plan 05 adds, and the env keys.
|
||||
6. scripts/dev.sh:
|
||||
- LIVEKIT=${LIVEKIT:-$(command -v livekit-server || true)};
|
||||
- ensure_env appends SUMMER_GOLEM15__CHATVIDEO__LIVEKIT_API_KEY and SUMMER_GOLEM15__CHATVIDEO__LIVEKIT_API_SECRET (random, umask 077, never echoed) when missing;
|
||||
- `livekit_config` writes logs/livekit.yaml from the example with the .env keys and use_external_ip false;
|
||||
- `start_livekit` claims ports 7890 and 7891, starts `livekit-server --config logs/livekit.yaml` under the pid name livekit with output to logs/livekit.log, waits for http://127.0.0.1:7890/ and warns and continues without video when the binary is missing;
|
||||
- up starts it after Centrifugo, down, destroy and status include livekit, and the header usage lines mention it.
|
||||
7. deploy/env.example: the two LiveKit key names with empty values and a comment. README.md: a "Chat family" section covering the mounted plugins, the contexts, the bootstrap contract of /_demo/api/v1/chat and /chat/members, the seed data, the moderator group, slash commands and local LiveKit.
|
||||
|
||||
Commit `feat: realtime for every demo member, members endpoint, Centrifugo namespaces and local LiveKit`.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-demo-app vet . ./plugins/demo/... && go -C ../sm-demo-app test -count=1 -short . ./plugins/demo/... && centrifugo checkconfig -c ../sm-demo-app/deploy/centrifugo.json.example && ../sm-demo-app/scripts/dev.sh up && curl -fsS http://127.0.0.1:7890/ >/dev/null && ../sm-demo-app/scripts/check-local.sh realtime</automated>
|
||||
<fails_when>Non-zero exit: vet or the short suite red (TestRealtimeRoutes middleware or namespaces), checkconfig refuses the Centrifugo file, dev.sh up fails to start a service, LiveKit does not answer on 127.0.0.1:7890, or check-local realtime reports a non-200 token for the visitor or a token without a JWT.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c '"name": "chat"' ../sm-demo-app/deploy/centrifugo.json.example` prints 1, and the same for "user" and "video".
|
||||
- `grep -v '^\s*#' ../sm-demo-app/deploy/livekit.yaml.example | grep -cE 'port_range_(start|end)'` prints 0, and `grep -c 'udp_port: 7892' ../sm-demo-app/deploy/livekit.yaml.example` prints 1.
|
||||
- With the local stack up, `curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $VISITOR_JWT" http://127.0.0.1:8097/api/realtime/token` prints 200 (JWT from the login API).
|
||||
- `grep -c 'LIVEKIT_API_SECRET=' ../sm-demo-app/deploy/env.example` prints 1 and that line has no value after the equals sign.
|
||||
</acceptance_criteria>
|
||||
<done>Every demo member gets a realtime token and the member list, Centrifugo knows the chat namespaces, and a local LiveKit runs with the dev stack.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| browser to /_demo, /_chat, /_chatchannels, /_forum, /_userfriends, /_chatbots, /_chatvideo | every request untrusted; actor only from the JWT |
|
||||
| Centrifugo subscribe proxy to Go | channel names come from clients; authorizers decide |
|
||||
| seed and reset commands to the database | trusted operator commands; persona accounts must stay unusable |
|
||||
| Go to LiveKit RoomService on loopback | admin calls with the API secret |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-20-06 | Elevation of privilege | channel creation by members | high | mitigate | ChatAccess.CanManageContext is always false; TestDemoChatTracer asserts POST channels is refused (D-07) |
|
||||
| T-20-07 | Information disclosure | subscribing to other conversations | high | mitigate | broadcast channels chat:context:1 and chat:channel:{id} are not subscribable (AuthorizeChat admits only chat:conversation:{id}); user:{id} only to itself (user plugin authorizer); video:session:{id} only to session participants |
|
||||
| T-20-08 | Spoofing | persona accounts | medium | mitigate | random password hashes never printed, emails under .invalid, so no login and no mail; reset wipes and recreates them |
|
||||
| T-20-09 | Elevation of privilege | moderator rights | medium | mitigate | moderator group membership only through the user admin (D-22); CanModerateMessage limited to non-DM conversations; banned users fail HasMember with live membership on |
|
||||
| T-20-10 | Tampering | spam and abuse in public chat and forum | high | mitigate | moderation listeners on both seams (TestDemoModerationSmoke), plugin buckets (chat-api, chat-dm, forum-api, video-initiate), Turnstile on register (19.4.1), reset job |
|
||||
| T-20-11 | Denial of service | realtime token minting for every member | low | mitigate | guard needs a JWT of a live member, per-user bucket golem15.demo.realtime 120/min unchanged |
|
||||
| T-20-12 | Information disclosure | LiveKit and VAPID secrets | high | mitigate | values only in .env (dev.sh writes them with umask 077, never echoed); env.example holds names; config files hold no secret (acceptance grep) |
|
||||
| T-20-13 | Denial of service | stale chat-family rows after reset | low | mitigate | reset table list in FK order covering every chat-family table; TestSeedChatSmoke checks messages are emptied |
|
||||
| T-20-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package-manager install in this plan; Go modules are the project's tagged plugins from plan 01 (go.sum committed, GOPRIVATE) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: tracer test, go.mod tags, boot test, check-pins.
|
||||
- Task 2: moderation smoke, boot and go.mod tests, check-pins, dev build.
|
||||
- Task 3: seed smoke, reset and roadmap tests.
|
||||
- Task 4: short suite, Centrifugo checkconfig, dev stack up with LiveKit, check-local realtime.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
sm-demo-app builds with every shared plugin including the chat family, and demo:seed fills each with EN/PL showcase data (SC-1). The demo hosts one community where every member can chat, DM, post in the forum and befriend others, with moderation, slash commands and video configured (D-05 to D-09, D-12, D-14, D-17, D-22, D-23).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/20-demo-instance-with-every-shared-plugin/20-02-SUMMARY.md` when done: mount commits with tags, which channel join path holds (lazy join or bootstrap), persona and channel names, the reset table order, the Centrifugo option names used, and any boot warnings left by plugins (for example herald's presence gate).
|
||||
</output>
|
||||
@@ -0,0 +1,446 @@
|
||||
---
|
||||
phase: 20-demo-instance-with-every-shared-plugin
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["20-02"]
|
||||
files_modified:
|
||||
- ../vue-chat-layer/.gitignore
|
||||
- ../vue-chat-layer/.nvmrc
|
||||
- ../vue-chat-layer/package.json
|
||||
- ../vue-chat-layer/pnpm-lock.yaml
|
||||
- ../vue-chat-layer/pnpm-workspace.yaml
|
||||
- ../vue-chat-layer/tsconfig.json
|
||||
- ../vue-chat-layer/nuxt.config.ts
|
||||
- ../vue-chat-layer/vitest.config.ts
|
||||
- ../vue-chat-layer/README.md
|
||||
- ../vue-chat-layer/.playground/nuxt.config.ts
|
||||
- ../vue-chat-layer/.playground/app/app.vue
|
||||
- ../vue-chat-layer/app/lib/client.ts
|
||||
- ../vue-chat-layer/app/lib/types.ts
|
||||
- ../vue-chat-layer/app/lib/realtime.ts
|
||||
- ../vue-chat-layer/app/lib/chatEvents.ts
|
||||
- ../vue-chat-layer/app/lib/chatFormat.ts
|
||||
- ../vue-chat-layer/app/lib/chatText.ts
|
||||
- ../vue-chat-layer/app/lib/chatUnread.ts
|
||||
- ../vue-chat-layer/app/lib/linkEmbeds.ts
|
||||
- ../vue-chat-layer/app/lib/format.ts
|
||||
- ../vue-chat-layer/app/lib/media.ts
|
||||
- ../vue-chat-layer/app/lib/tabs.ts
|
||||
- ../vue-chat-layer/app/composables/useChatApi.ts
|
||||
- ../vue-chat-layer/app/composables/useChat.ts
|
||||
- ../vue-chat-layer/app/composables/useChatNotice.ts
|
||||
- ../vue-chat-layer/app/components/Chat/Page.vue
|
||||
- ../vue-chat-layer/app/components/Chat/ChannelList.vue
|
||||
- ../vue-chat-layer/app/components/Chat/Panel.vue
|
||||
- ../vue-chat-layer/app/components/Chat/MessageList.vue
|
||||
- ../vue-chat-layer/app/components/Chat/Message.vue
|
||||
- ../vue-chat-layer/app/components/Chat/Composer.vue
|
||||
- ../vue-chat-layer/app/components/Chat/Header.vue
|
||||
- ../vue-chat-layer/app/components/Chat/DmList.vue
|
||||
- ../vue-chat-layer/app/components/Chat/MemberPicker.vue
|
||||
- ../vue-chat-layer/app/components/Chat/MentionPopup.vue
|
||||
- ../vue-chat-layer/app/components/Chat/ReactionBar.vue
|
||||
- ../vue-chat-layer/app/components/Chat/LinkEmbed.vue
|
||||
- ../vue-chat-layer/app/components/Chat/StagingChips.vue
|
||||
- ../vue-chat-layer/app/components/Chat/DeleteDialog.vue
|
||||
- ../vue-chat-layer/app/components/Chat/Avatar.vue
|
||||
- ../vue-chat-layer/app/components/Chat/ThreadPane.vue
|
||||
- ../vue-chat-layer/app/components/Chat/PinsPanel.vue
|
||||
- ../vue-chat-layer/app/components/Chat/CommandPicker.vue
|
||||
- ../vue-chat-layer/app/components/Chat/FloatingPanel.vue
|
||||
- ../vue-chat-layer/app/components/Chat/Icon.vue
|
||||
- ../vue-chat-layer/app/components/Chat/Notices.vue
|
||||
- ../vue-chat-layer/app/assets/css/chat-tokens.css
|
||||
- ../vue-chat-layer/app/assets/css/chat-base.css
|
||||
- ../vue-chat-layer/i18n/locales/en.json
|
||||
- ../vue-chat-layer/i18n/locales/pl.json
|
||||
- ../vue-chat-layer/tests/smoke.test.ts
|
||||
- ../sm-demo-app/vue-demo-app/package.json
|
||||
- ../sm-demo-app/vue-demo-app/pnpm-lock.yaml
|
||||
- ../sm-demo-app/vue-demo-app/nuxt.config.ts
|
||||
- ../sm-demo-app/vue-demo-app/app/plugins/auth.ts
|
||||
- ../sm-demo-app/vue-demo-app/app/layouts/default.vue
|
||||
- ../sm-demo-app/vue-demo-app/app/pages/chat.vue
|
||||
- ../sm-demo-app/vue-demo-app/app/pages/index.vue
|
||||
- ../sm-demo-app/vue-demo-app/app/components/TourCard.vue
|
||||
- ../sm-demo-app/vue-demo-app/app/lib/realtime.ts
|
||||
- ../sm-demo-app/vue-demo-app/i18n/locales/en.json
|
||||
- ../sm-demo-app/vue-demo-app/i18n/locales/pl.json
|
||||
- ../sm-demo-app/scripts/check-chat.cjs
|
||||
- ../sm-demo-app/scripts/check-local.sh
|
||||
- ../sm-demo-app/scripts/smoke.sh
|
||||
autonomous: false
|
||||
requirements: [SC-2, D-01, D-02, D-03, D-04, D-05, D-07, D-20, D-22, D-23]
|
||||
estimate:
|
||||
tokens: 380000
|
||||
raw_tokens: 380000
|
||||
tasks: 6
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-01: vue-chat-layer is a new shared Nuxt layer (own repository, tag v0.1.0) ported from the bm SPA's chat components, store, API client and chat libraries, with no Pinia (state is useState composables), no bm entity embeds and no name of a consuming application; host specifics enter only through runtime config (public.chat.hostApiBase), the $chatAuth hook, slots and the link-embed renderer registry."
|
||||
- "D-02: on /chat a logged-in member sees the seeded channels in a sidebar, opens one, reads history, sends and edits messages, replies in threads (thread pane), sees typing, unread and mention counts, adds and removes reactions, attaches files, mentions members with @, picks a DM partner from the member picker, deletes own messages (moderators also others' in channels, D-22) through the delete dialog, sees channel pins and sees URLs as links with a generic link embed."
|
||||
- "D-03: every page of vue-demo-app shows a floating chat panel (DM list and unread badge) for logged-in users (not on /chat and not on the full-screen deck pages); the homepage tour has a Chat card; the site is EN and PL."
|
||||
- "D-04: an anonymous visitor on /chat sees a description and a register-to-chat link to /register?next=/chat; there is no read-only preview, and the floating panel is not rendered."
|
||||
- "D-05: typing / at the start of the composer opens a command picker filled from GET /_chatbots/api/v1/commands; choosing or sending a command calls POST /_chatbots/api/v1/dispatch and shows the ephemeral answer only to the sender; there is no chatbots layer."
|
||||
- "D-07: the layer offers no channel creation UI; members join and leave seeded channels."
|
||||
- "D-23 (extension point): the conversation header exposes a slot that the layer renders only for DM conversations, so a call button placed there (plan 04) appears only in DMs."
|
||||
- "SC-2: messages from another member arrive without reload through the shared Centrifuge client (one connection per page, shared with the presentations remote through vue-demo-app's app/lib/realtime.ts re-export)."
|
||||
- "After the Task 5 go-ahead, git.golem15.com/golem15/vue-chat-layer exists with tag v0.1.0 and vue-demo-app pins git+https://git.golem15.com/golem15/vue-chat-layer.git#v0.1.0 in a committed package.json and pnpm-lock.yaml."
|
||||
artifacts:
|
||||
- path: "../vue-chat-layer/nuxt.config.ts"
|
||||
provides: "layer config: runtimeConfig chat.apiBase, public.chat.hostApiBase, css, i18n registration (no routes, no route rules)"
|
||||
contains: "hostApiBase"
|
||||
- path: "../vue-chat-layer/app/composables/useChat.ts"
|
||||
provides: "useState-based chat store (channels, DMs, threads, unread, typing, realtime)"
|
||||
contains: "useState"
|
||||
- path: "../vue-chat-layer/app/lib/realtime.ts"
|
||||
provides: "one shared Centrifuge client with connect, subscribe, presence, openChannel and onUserEvent"
|
||||
contains: "onUserEvent"
|
||||
- path: "../vue-chat-layer/app/lib/linkEmbeds.ts"
|
||||
provides: "linkify and the link-embed renderer registry (registerEmbedRenderer)"
|
||||
contains: "registerEmbedRenderer"
|
||||
- path: "../vue-chat-layer/app/components/Chat/CommandPicker.vue"
|
||||
provides: "slash command picker over the chatbots catalog and dispatch"
|
||||
contains: "dispatch"
|
||||
- path: "../sm-demo-app/vue-demo-app/app/pages/chat.vue"
|
||||
provides: "/chat: ChatPage for members, register prompt for anonymous visitors"
|
||||
contains: "registerToChat"
|
||||
- path: "../sm-demo-app/scripts/check-chat.cjs"
|
||||
provides: "Playwright check: the visitor sends a message in #general on /chat"
|
||||
contains: "check-chat: ok"
|
||||
key_links:
|
||||
- from: "../vue-chat-layer/app/composables/useChatApi.ts"
|
||||
to: "../sm-demo-app/vue-demo-app/app/plugins/auth.ts"
|
||||
via: "the layer reads useNuxtApp().$chatAuth {token, refresh, unauthorized}; the app provides it"
|
||||
pattern: "chatAuth"
|
||||
- from: "../vue-chat-layer/app/composables/useChat.ts"
|
||||
to: "../sm-demo-app/plugins/demo/chat_api.go"
|
||||
via: "bootstrap GET {public.chat.hostApiBase}/chat and /chat/members"
|
||||
pattern: "hostApiBase"
|
||||
- from: "../sm-demo-app/vue-demo-app/app/lib/realtime.ts"
|
||||
to: "../vue-chat-layer/app/lib/realtime.ts"
|
||||
via: "re-export so presentations and chat share one connection"
|
||||
pattern: "vue-chat-layer/app/lib/realtime"
|
||||
- from: "../sm-demo-app/vue-demo-app/app/layouts/default.vue"
|
||||
to: "../vue-chat-layer/app/components/Chat/FloatingPanel.vue"
|
||||
via: "ClientOnly ChatFloatingPanel when logged in"
|
||||
pattern: "ChatFloatingPanel"
|
||||
prohibitions:
|
||||
- statement: "vue-chat-layer names no consuming application and has no default host bootstrap path."
|
||||
status: unresolved
|
||||
verification: automated
|
||||
- statement: "No message, name or attachment field is rendered with v-html except the escaped output of the ported chatText renderer."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No Pinia and no new npm package except those already pinned in the workspace (centrifuge 5.7.3, @nuxtjs/i18n 10.4.0, vitest 3.2.7, nuxt 4.4.8, vue 3.5.35, typescript 5.9.3)."
|
||||
status: unresolved
|
||||
verification: automated
|
||||
- statement: "Nothing is pushed and no repository is created before the Task 5 go-ahead; vue-demo-app is not pushed in this plan (plan 05)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented here): the demo's public Nuxt site shows the shared plugins working, chat and channels with realtime included, through the shared layers.
|
||||
|
||||
This plan's slice (plan 3 of 6, wave 3, per D-20): `vue-chat-layer` v0.1.0, ported from bm's chat SPA and widened to several channels, threads, pins, a command picker and generic link embeds, then used in vue-demo-app as `/chat`, a floating panel and a tour card, in EN and PL.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion, recorded)
|
||||
|
||||
- **Layout of the layer:** pure modules in `app/lib/` (imported explicitly, never auto-imported, so generic names like `initials` cannot clash with an app's utils). Composables `useChat`, `useChatApi` and `useChatNotice` in `app/composables/`. Components in `app/components/Chat/`, auto-registered with the `Chat` prefix (`ChatPage`, `ChatFloatingPanel`). The layer ships components; the app owns routes, page copy and the anonymous prompt (as vue-paymentgateway-layer does).
|
||||
- **Host contract:** `runtimeConfig.public.chat.hostApiBase` defaults to an empty string, and the layer refuses to bootstrap without it, with a console error naming the key. vue-demo-app sets `/_demo/api/v1`. The README documents the `GET {hostApiBase}/chat` and `/chat/members` JSON shapes as the contract any host implements, under neutral names.
|
||||
- **Auth hook:** `$chatAuth` with `token(): string | null`, `refresh(): Promise<string | null>` and `unauthorized(): void`. One refresh-and-retry on 401 (bm's client renews through `/_user/api/v1/fetch`), then `unauthorized()`.
|
||||
- **Store:** bm's Pinia setup store becomes a module-level composable over `useState` keys prefixed `chat:` (19.4.1 no-Pinia rule). `general`/`generalId` become `channels[]` and `activeId`.
|
||||
- **Realtime:** bm's current `lib/realtime.ts` (shared client) is ported with `onUserEvent(handler)`, so other layers (chatvideo, plan 04) get `user:{me}` events they do not own without a second subscription. vue-demo-app's `app/lib/realtime.ts` becomes a re-export, so the presentations remote and chat share one WebSocket.
|
||||
- **Embeds:** bm's entity embeds and picker are dropped. URLs in text are linkified (escaped, `rel="noopener noreferrer nofollow"`, `target="_blank"`). A message with a URL shows a `ChatLinkEmbed` card with host and path; there is no unfurl backend. `registerEmbedRenderer(match, component)` lets a host render its own entity links (D-01 extension point).
|
||||
- **Extension slots:** `conversation-actions` in ChatHeader, rendered only for `type === 'dm'` (D-23), and `sidebar-footer` in ChannelList (the friends link, plan 04).
|
||||
- **Floating panel:** hidden on `/chat`, `/p/**` and `/presentations/**` (full-screen decks) and for anonymous visitors.
|
||||
- **Development pin:** while the layer has no remote, vue-demo-app depends on `link:../../vue-chat-layer`, committed with the first vue-demo-app commit so every commit builds on this machine. Task 6 replaces it with the tag pin; vue-demo-app is pushed only in plan 05, after the swap, so no pushed commit needs the local path.
|
||||
- spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip).
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Repository `vue-chat-layer` (tag v0.1.0):
|
||||
- components `ChatPage`, `ChatChannelList`, `ChatPanel`, `ChatMessageList`, `ChatMessage`, `ChatComposer`, `ChatHeader`, `ChatDmList`, `ChatMemberPicker`, `ChatMentionPopup`, `ChatReactionBar`, `ChatLinkEmbed`, `ChatStagingChips`, `ChatDeleteDialog`, `ChatAvatar`, `ChatThreadPane`, `ChatPinsPanel`, `ChatCommandPicker`, `ChatFloatingPanel`, `ChatIcon`, `ChatNotices`;
|
||||
- composables `useChat`, `useChatApi`, `useChatNotice`;
|
||||
- lib exports: `createChatClient`, `REALTIME_WS_PATH`, `connect`, `subscribe`, `presence`, `openChannel`, `onUserEvent`, `disconnect`, `linkify`, `registerEmbedRenderer`, `embedRendererFor`, and the ported chatEvents, chatFormat, chatText and chatUnread functions;
|
||||
- runtime config `chat.apiBase` (`NUXT_CHAT_API_BASE`), `public.chat.apiBase`, `public.chat.channelsApiBase`, `public.chat.botsApiBase`, `public.chat.hostApiBase`;
|
||||
- Nuxt plugin hook `$chatAuth`; slots `conversation-actions` and `sidebar-footer`; i18n keys under `chat.*` (EN and PL);
|
||||
- smoke test `tests/smoke.test.ts`.
|
||||
- vue-demo-app: page `/chat`, the floating panel in the default layout, Chat tour card (TourCard icon `chat`), i18n keys `chat.*` and `tour.chat.*`, goPaths `/_chat`, `/_chatchannels`, `/_chatbots`, ChatPage inside ClientOnly (the page itself is server-rendered so the banner and the anonymous prompt are in the HTML), `chatAuth` provided in app/plugins/auth.ts, app/lib/realtime.ts re-exporting the layer client.
|
||||
- sm-demo-app: `scripts/check-chat.cjs`, check-local check `chat`, check-local `tour` expecting the Chat card, smoke.sh covering `/chat`.
|
||||
|
||||
<objective>
|
||||
Build and release vue-chat-layer v0.1.0 and put chat on the demo site: `/chat`, the floating panel, the Chat tour card and the anonymous prompt, in EN and PL.
|
||||
|
||||
Purpose: SC-2 (chat and channels with realtime on the public site), D-01 to D-05, D-07 (no channel creation), D-22 (moderator delete) and the D-23 extension point plan 04 uses.
|
||||
Output: a new repository vue-chat-layer (pushed and tagged after the go-ahead), commits in ../sm-demo-app/vue-demo-app (the submodule; not pushed) and ../sm-demo-app (scripts; not pushed).
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-CONTEXT.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-RESEARCH.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-PATTERNS.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-02-SUMMARY.md
|
||||
|
||||
Repos: new ../vue-chat-layer; ../sm-demo-app/vue-demo-app (the submodule, origin golem15/vue-demo-app; never the stale ../vue-demo-app sibling); ../sm-demo-app (scripts). Read-only port source: ../sm-bm-app/frontend/src (components/chat/*, stores/chat.ts and chat.test.ts, api/chat.ts, api/types.ts, lib/chat*.ts, lib/realtime.ts, lib/format.ts, lib/media.ts, lib/tabs.ts, components/Icon.vue). Layer conventions: ../vue-faq-layer and ../vue-paymentgateway-layer.
|
||||
|
||||
Rules: no co-author tags; stage by explicit path; the layer repository gets no remote before Task 5; vue-demo-app is committed in its own repository (never staged from sm-demo-app) and not pushed here; check `git -C ../sm-demo-app/vue-demo-app status --porcelain` before each commit and leave other sessions' edits alone (Phase 33 also edits nuxt.config.ts). The local dev stack runs with `../sm-demo-app/scripts/dev.sh up` (Go 127.0.0.1:8097, Nuxt 127.0.0.1:3020, Centrifugo, LiveKit). Playwright is the global install (`NODE_PATH="$(npm root -g)"`, scripts/lib/browser.cjs).
|
||||
|
||||
<interfaces>
|
||||
Host bootstrap (plan 02, ../sm-demo-app/plugins/demo/chat_api.go): GET /_demo/api/v1/chat gives {member, moderator, context: {type, id}, channels: [{id, slug, name, conversation_id, joined, unread, mentions}], conversations: [{id, type: "dm", other_user_id, unread, last_message: {id, sender_id, content, created_at}}], limits: {max_files, max_bytes, max_embeds}}; GET /_demo/api/v1/chat/members gives {members: [{id, name, surname, handle, is_bot}]}.
|
||||
chat API /_chat/api/v1: GET|POST /contexts/{type}/{id}/conversation; GET|POST /conversations/{id}/messages; POST /conversations/{id}/read, /typing, /mute; GET|PUT|DELETE /messages/{id}; GET /reactions; POST /messages/{id}/reactions; DELETE /messages/{id}/reactions/{key}; GET|POST /messages/{id}/replies; PUT /threads/{rootId}/mute; POST /attachments (multipart, body limit); GET /attachments/{messageId}/{fileId}; POST /contexts/{type}/{id}/dm. 422 envelope {"error":"Validation failed","errors":{field:[msg]}}, other refusals {"error": msg}, IDOR 404.
|
||||
chatchannels /_chatchannels/api/v1/contexts/demo/1: GET channels, POST channels/{id}/join and /leave, GET|POST channels/{id}/pins, DELETE channels/{id}/pins/{messageId}, GET channels/{id}/mentions, GET mentions.
|
||||
chatbots /_chatbots/api/v1: GET /commands gives {commands: [{trigger, description}]}; POST /dispatch {input} gives {mode, payload}; unknown gives 422 errors.input.
|
||||
Realtime: token GET /api/realtime/token; WebSocket /ws; channels user:{me} (chat:message, typing, read, unread, deleted, reacted, updated events per bm's chatEvents.ts; video:* from chatvideo), chat:conversation:{id}.
|
||||
vue-demo-app today: nuxt.config.ts (`goPaths`, `extends`, `runtimeConfig`, `routeRules`, `robots.disallow: goPaths`), app/plugins/auth.ts (`paymentGatewayAuth`), app/utils/authCookie.ts (`DEMO_TOKEN_COOKIE`), app/composables/useDemoAuth.ts (`loggedIn`), app/pages/quizzes/index.vue (anonymous prompt with `registerLink` and `quiz.registerToTake`), app/pages/index.vue (`stops`, TourCard), app/components/TourCard.vue (`icon` union, `ICONS` paths), app/lib/realtime.ts (openChannel subset of bm's file), vitest.config.ts (app/**/*.test.ts), package.json (centrifuge 5.7.3, layers pinned by git+https tag).
|
||||
Layer analogs: ../vue-faq-layer/nuxt.config.ts (currentDir, absolute paths, runtimeConfig), ../vue-paymentgateway-layer/nuxt.config.ts (i18n:registerModule for EN/PL), app/composables/usePaymentGatewayApi.ts (auth hook through useNuxtApp), package.json (main nuxt.config.ts, peerDependencies nuxt 4.4.8, engines node >=22.18).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a logged-in visitor opens /chat, picks #general from the seeded channels and sends a message that appears; an anonymous visitor gets the register prompt</name>
|
||||
<reversibility rating="costly">D-01: a public layer with tagged releases that later sites pin; its runtime-config keys, auth hook and bootstrap contract become a contract.</reversibility>
|
||||
<precondition>The plan 02 SUMMARY exists; `../sm-demo-app/scripts/dev.sh up` brings the stack up and `curl -fsS http://127.0.0.1:8097/_demo/api/v1/roadmap` answers; `git -C ../sm-demo-app/vue-demo-app status --porcelain` lists no file this task changes.</precondition>
|
||||
<files>../vue-chat-layer/.gitignore, ../vue-chat-layer/.nvmrc, ../vue-chat-layer/package.json, ../vue-chat-layer/pnpm-lock.yaml, ../vue-chat-layer/pnpm-workspace.yaml, ../vue-chat-layer/tsconfig.json, ../vue-chat-layer/nuxt.config.ts, ../vue-chat-layer/vitest.config.ts, ../vue-chat-layer/.playground/nuxt.config.ts, ../vue-chat-layer/.playground/app/app.vue, ../vue-chat-layer/app/lib/client.ts, ../vue-chat-layer/app/lib/types.ts, ../vue-chat-layer/app/composables/useChatApi.ts, ../vue-chat-layer/app/composables/useChat.ts, ../vue-chat-layer/app/components/Chat/Page.vue, ../vue-chat-layer/app/components/Chat/ChannelList.vue, ../vue-chat-layer/app/components/Chat/MessageList.vue, ../vue-chat-layer/app/components/Chat/Composer.vue, ../vue-chat-layer/tests/smoke.test.ts, ../sm-demo-app/vue-demo-app/package.json, ../sm-demo-app/vue-demo-app/nuxt.config.ts, ../sm-demo-app/vue-demo-app/app/plugins/auth.ts, ../sm-demo-app/vue-demo-app/app/pages/chat.vue, ../sm-demo-app/vue-demo-app/i18n/locales/en.json, ../sm-demo-app/vue-demo-app/i18n/locales/pl.json, ../sm-demo-app/scripts/check-chat.cjs, ../sm-demo-app/scripts/check-local.sh</files>
|
||||
<read_first>../vue-faq-layer/{nuxt.config.ts,package.json,pnpm-workspace.yaml,tsconfig.json,.gitignore,.nvmrc,.playground/nuxt.config.ts}, ../vue-paymentgateway-layer/nuxt.config.ts and app/composables/usePaymentGatewayApi.ts and app/utils/client.ts, ../sm-bm-app/frontend/src/api/chat.ts and api/types.ts, ../sm-bm-app/frontend/src/stores/chat.ts (bootstrap, open, send), ../sm-bm-app/frontend/src/components/chat/{ChatPanel,MessageList,ChatComposer}.vue, ../sm-demo-app/plugins/demo/chat_api.go, ../sm-demo-app/vue-demo-app/{nuxt.config.ts,package.json,app/plugins/auth.ts,app/pages/quizzes/index.vue,app/composables/useDemoAuth.ts,app/utils/authCookie.ts,i18n/locales/en.json}, ../sm-demo-app/scripts/check-local.sh (visitor_jwt, check_remote, ALL_CHECKS), ../sm-demo-app/scripts/check-remote.cjs and scripts/lib/browser.cjs, 20-PATTERNS.md (vue-chat-layer)</read_first>
|
||||
<action>Per D-01, D-04, D-07, SC-2. One path through every layer: app page, layer component, store, client, host bootstrap, chat API, database, back to the list.
|
||||
|
||||
1. Create the repository: `git init -b main` at /media/nvme/dev/golem15/summercms.io/summercms/vue-chat-layer with the vue-faq-layer files (.gitignore, .nvmrc, tsconfig.json, pnpm-workspace.yaml allowing the same builds plus `protobufjs: false`).
|
||||
- package.json: name vue-chat-layer, version 0.1.0, private, type module, main ./nuxt.config.ts, engines node >=22.18, peerDependencies nuxt 4.4.8;
|
||||
- dependencies centrifuge 5.7.3 and @nuxtjs/i18n 10.4.0; devDependencies nuxt 4.4.8, vue 3.5.35, typescript 5.9.3, vitest 3.2.7;
|
||||
- scripts test (`vitest run`), dev (`nuxi dev .playground`), build:playground and dev:prepare.
|
||||
Run pnpm install.
|
||||
2. nuxt.config.ts:
|
||||
- the header comment says an app consumes it with `extends: ['vue-chat-layer']`, provides `$chatAuth` and sets `public.chat.hostApiBase`; currentDir with absolute paths;
|
||||
- runtimeConfig.chat.apiBase for SSR (`http://127.0.0.1:8096/_chat/api/v1`, NUXT_CHAT_API_BASE);
|
||||
- public.chat: apiBase `/_chat/api/v1`, channelsApiBase `/_chatchannels/api/v1`, botsApiBase `/_chatbots/api/v1`, hostApiBase empty;
|
||||
- no pages and no route rules: the app owns its routes and wraps the chat components in ClientOnly (the components touch window and WebSocket, RESEARCH Pitfall 9);
|
||||
- the i18n:registerModule block of vue-paymentgateway-layer for en and pl (strings come in Task 3; create empty-object locale files now if the module needs them).
|
||||
3. app/lib/client.ts: `createChatClient({ bases, auth, fetch })`, pure and Nuxt-free so tests import it. It builds URLs, sends Bearer from `auth.token()`, retries once after `auth.refresh()` on 401 and then calls `auth.unauthorized()`, and turns the 422 envelope into a typed `ChatApiError` with field errors. Methods for the tracer: `bootstrap()`, `members()`, `openConversation(type, id)`, `messages(conversationId, opts)` and `send(conversationId, body)`. app/lib/types.ts holds the bootstrap and message types ported from bm's api/types.ts. app/composables/useChatApi.ts binds the client to runtime config and `useNuxtApp().$chatAuth`, following usePaymentGatewayApi.
|
||||
4. app/composables/useChat.ts, minimal for now, over `useState('chat:…')`: `start()` loads the bootstrap (and logs a console error, then stops, when hostApiBase is empty); `channels`; `activeId`; `open(channel)` calls openConversation('channel', channel.id) then messages; `send(text)`.
|
||||
5. Components ChatPage (sidebar plus pane), ChatChannelList (channel names, no create button), ChatMessageList (plain text in this task) and ChatComposer (textarea plus send). No v-html.
|
||||
6. vue-demo-app:
|
||||
- run `pnpm --dir ../sm-demo-app/vue-demo-app add vue-chat-layer@link:../../vue-chat-layer` (development pin, committed with this task's vue-demo-app commit; Task 6 swaps it for the tag before any push);
|
||||
- nuxt.config.ts: append 'vue-chat-layer' to extends; add '/_chat', '/_chatchannels' and '/_chatbots' to goPaths; set `public.chat.hostApiBase: '/_demo/api/v1'` and `chat.apiBase` from `api`; no route rule for /chat (the page is server-rendered with the chat inside ClientOnly, so smoke.sh still sees the banner and the prompt);
|
||||
- app/plugins/auth.ts provides `chatAuth: { token, refresh, unauthorized }`. token reads DEMO_TOKEN_COOKIE. refresh calls the user plugin's refresh endpoint that the login flow uses (read useDemoAuth) and stores the new token. unauthorized clears the cookie;
|
||||
- app/pages/chat.vue: for loggedIn, `<ClientOnly><ChatPage /></ClientOnly>`; otherwise a description and a `NuxtLink class="button"` to `{ path: '/register', query: { next: '/chat' } }` labelled `t('chat.registerToChat')`, as the quizzes page does. useSeoMeta with title and description;
|
||||
- i18n en and pl: chat.title, chat.lead and chat.registerToChat.
|
||||
7. sm-demo-app/scripts/check-chat.cjs (CommonJS, Playwright, modeled on check-remote.cjs):
|
||||
- `--base` (Nuxt origin) and DEMO_JWT from the environment;
|
||||
- sets the DEMO_TOKEN_COOKIE cookie with the JWT, opens {base}/chat, clicks the channel whose name is general and sends a unique text;
|
||||
- waits up to 10 s for the text in the message list, then fails on a console error or a 4xx/5xx response from /_chat;
|
||||
- opens {base}/chat in a fresh context without the cookie and asserts the register link to /register?next=/chat;
|
||||
- prints `check-chat: ok`.
|
||||
check-local.sh gets `check_chat`, which takes visitor_jwt and runs the script as check_remote does, and `chat` in ALL_CHECKS plus the header list.
|
||||
8. tests/smoke.test.ts (vitest): createChatClient builds `/_chat/api/v1/conversations/7/messages`, sends the Bearer header, retries once on 401 after refresh and maps a 422 body to field errors.
|
||||
|
||||
Commit vue-chat-layer (`feat: chat layer tracer: bootstrap, channel list, messages, composer`). Commit vue-demo-app package.json, pnpm-lock.yaml (link pin), nuxt.config.ts, auth.ts, chat.vue and locales by path (`feat: /chat page on vue-chat-layer`). Commit sm-demo-app scripts (`test: check-chat browser check`).</action>
|
||||
<verify>
|
||||
<automated>pnpm --dir ../vue-chat-layer test && pnpm --dir ../sm-demo-app/vue-demo-app run build && ../sm-demo-app/scripts/dev.sh up && ../sm-demo-app/scripts/check-local.sh chat</automated>
|
||||
<fails_when>Non-zero exit: the layer smoke test fails; the Nuxt build fails (layer not resolved, type or template error); the stack does not come up; or check-local prints "check-local: chat failed" (the sent text does not appear, /_chat answered 4xx/5xx, or the anonymous page lacks the register link).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git -C ../vue-chat-layer remote` prints nothing.
|
||||
- `grep -c "hostApiBase: ''" ../vue-chat-layer/nuxt.config.ts` prints 1.
|
||||
- `grep -rniE 'sm-bm|jz\.bm|_demo/api' ../vue-chat-layer/app ../vue-chat-layer/nuxt.config.ts ../vue-chat-layer/README.md 2>/dev/null | wc -l` prints 0.
|
||||
- `grep -c "'/_chat'" ../sm-demo-app/vue-demo-app/nuxt.config.ts` prints 1.
|
||||
- `node -p "require('../sm-demo-app/vue-demo-app/package.json').dependencies['vue-chat-layer']"` prints `link:../../vue-chat-layer` (the development pin, swapped in Task 6).
|
||||
</acceptance_criteria>
|
||||
<done>A visitor can chat in #general on the local demo site through the new layer, and an anonymous visitor is invited to register.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: The bm chat experience on the layer: realtime, DMs, member picker, mentions, unread counts and typing</name>
|
||||
<files>../vue-chat-layer/app/lib/realtime.ts, ../vue-chat-layer/app/lib/chatEvents.ts, ../vue-chat-layer/app/lib/chatFormat.ts, ../vue-chat-layer/app/lib/chatText.ts, ../vue-chat-layer/app/lib/chatUnread.ts, ../vue-chat-layer/app/lib/format.ts, ../vue-chat-layer/app/lib/media.ts, ../vue-chat-layer/app/lib/tabs.ts, ../vue-chat-layer/app/lib/client.ts, ../vue-chat-layer/app/composables/useChat.ts, ../vue-chat-layer/app/composables/useChatNotice.ts, ../vue-chat-layer/app/components/Chat/Panel.vue, ../vue-chat-layer/app/components/Chat/MessageList.vue, ../vue-chat-layer/app/components/Chat/Message.vue, ../vue-chat-layer/app/components/Chat/Composer.vue, ../vue-chat-layer/app/components/Chat/Header.vue, ../vue-chat-layer/app/components/Chat/DmList.vue, ../vue-chat-layer/app/components/Chat/MemberPicker.vue, ../vue-chat-layer/app/components/Chat/MentionPopup.vue, ../vue-chat-layer/app/components/Chat/Avatar.vue, ../vue-chat-layer/app/components/Chat/Icon.vue, ../vue-chat-layer/app/components/Chat/Notices.vue, ../vue-chat-layer/app/components/Chat/Page.vue, ../sm-demo-app/vue-demo-app/app/lib/realtime.ts</files>
|
||||
<read_first>../sm-bm-app/frontend/src/stores/chat.ts (whole store), stores/chat.test.ts, lib/realtime.ts, lib/chatEvents.ts, lib/chatFormat.ts, lib/chatText.ts, lib/chatUnread.ts, lib/format.ts, lib/media.ts, lib/tabs.ts, components/Icon.vue, components/chat/{ChatPanel,MessageList,ChatMessage,ChatComposer,ChatHeader,DmList,MemberPicker,MentionPopup,Avatar}.vue, ../sm-demo-app/vue-demo-app/app/lib/realtime.ts and realtime.test.ts, app/pages/presentations/[slug]/index.vue and pilot.vue (openChannel callers), ../vue-chat-layer/app/composables/useChat.ts (Task 1), 20-RESEARCH.md (Pattern 3, Pitfall 9)</read_first>
|
||||
<action>Per D-01, D-02, SC-2.
|
||||
|
||||
- Port bm's lib/realtime.ts into app/lib/realtime.ts. It keeps the same exports (REALTIME_WS_PATH, tokenFailureIsFinal, wsURL, connect, subscribe, presence, onConnection, onUnavailable, realtimeState, realtimeUnavailable, disconnect, openChannel). The token is fetched from `/api/realtime/token` with the `$chatAuth` token through a setter `setRealtimeAuth(getToken)`, so the module stays Nuxt-free. `centrifuge` is loaded with a dynamic import. Add `onUserEvent(handler): () => void`: the chat store's `user:{me}` subscription hands every event it does not handle to the registered handlers, so a second layer never subscribes to `user:{me}` itself.
|
||||
- Port chatEvents, chatFormat, chatText and chatUnread unchanged except for import paths, and the helpers they need from format, media and tabs. The bm tests come in plan 06. chatText's renderer escapes before it marks mentions; keep it the only raw-HTML binding in the layer.
|
||||
- useChat: port bm's store as a composable over useState. Replace `general` and `generalId` with `channels`, `activeId` and a `kind` ('channel' or 'dm') for the open conversation. Keep:
|
||||
- DMs (`conversations`; `openDm(userId)` through POST /contexts/demo/1/dm, using the context from the bootstrap);
|
||||
- the member list from `/chat/members`;
|
||||
- unread and mention counts, read marks and typing, with bm's polling fallback when realtime is unavailable (15 s open conversation, 60 s counts);
|
||||
- realtime start for a member session, edits and deletions from events.
|
||||
Channel join and leave call chatchannels join and leave and refresh the bootstrap. Errors go to useChatNotice (a small toast state rendered by ChatNotices inside ChatPage and the floating panel), replacing bm's toast store.
|
||||
- Port the components ChatPanel, ChatMessageList, ChatMessage (text through chatText, attachment rendering in Task 3), ChatComposer (mention popup, Enter to send, Shift+Enter newline), ChatHeader (unread and mention badges), ChatDmList, ChatMemberPicker, ChatMentionPopup and ChatAvatar, plus ChatIcon with the icons they use. ChatPage composes sidebar (channels, DMs, a new-DM button opening the member picker) and pane. Strip every bm-specific element (entity embeds, deadline pins, the student app's tabs) or leave a named slot in its place.
|
||||
- vue-demo-app app/lib/realtime.ts becomes a re-export of `vue-chat-layer/app/lib/realtime` (with a comment), and the app plugin calls `setRealtimeAuth` with the DEMO_TOKEN_COOKIE reader. If the existing app/lib/realtime.test.ts fails against the shared client, replace it with bm's current realtime.test.ts, adjusted only in import paths.
|
||||
|
||||
Commit vue-chat-layer (`feat: port the chat store, realtime client and components`) and vue-demo-app (`refactor: share the chat layer's realtime client`).</action>
|
||||
<verify>
|
||||
<automated>pnpm --dir ../vue-chat-layer test && pnpm --dir ../sm-demo-app/vue-demo-app test && pnpm --dir ../sm-demo-app/vue-demo-app run build && ../sm-demo-app/scripts/check-local.sh chat realtime remote</automated>
|
||||
<fails_when>Non-zero exit: layer smoke or vue-demo-app vitest red (the realtime re-export broke the presentations tests), the build fails, or check-local reports chat, realtime or remote failed (the presentations remote no longer works through the shared client).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'export function onUserEvent' ../vue-chat-layer/app/lib/realtime.ts` prints 1.
|
||||
- `grep -rn "from 'pinia'" ../vue-chat-layer/app | wc -l` prints 0.
|
||||
- `grep -c 'vue-chat-layer/app/lib/realtime' ../sm-demo-app/vue-demo-app/app/lib/realtime.ts` prints 1.
|
||||
- `grep -rn 'v-html' ../vue-chat-layer/app/components | grep -v 'renderChatText\|rendered' | wc -l` prints 0.
|
||||
</acceptance_criteria>
|
||||
<done>The layer behaves like bm's chat across several channels and DMs, live, with one shared realtime connection on every page.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Threads, pins, reactions, attachments, moderator delete, link embeds and the slash command picker</name>
|
||||
<files>../vue-chat-layer/app/components/Chat/ThreadPane.vue, ../vue-chat-layer/app/components/Chat/PinsPanel.vue, ../vue-chat-layer/app/components/Chat/ReactionBar.vue, ../vue-chat-layer/app/components/Chat/StagingChips.vue, ../vue-chat-layer/app/components/Chat/DeleteDialog.vue, ../vue-chat-layer/app/components/Chat/LinkEmbed.vue, ../vue-chat-layer/app/components/Chat/CommandPicker.vue, ../vue-chat-layer/app/components/Chat/Message.vue, ../vue-chat-layer/app/components/Chat/Composer.vue, ../vue-chat-layer/app/components/Chat/Header.vue, ../vue-chat-layer/app/components/Chat/ChannelList.vue, ../vue-chat-layer/app/lib/linkEmbeds.ts, ../vue-chat-layer/app/lib/client.ts, ../vue-chat-layer/app/composables/useChat.ts, ../vue-chat-layer/app/assets/css/chat-tokens.css, ../vue-chat-layer/app/assets/css/chat-base.css, ../vue-chat-layer/i18n/locales/en.json, ../vue-chat-layer/i18n/locales/pl.json, ../vue-chat-layer/nuxt.config.ts, ../vue-chat-layer/README.md</files>
|
||||
<read_first>../sm-bm-app/frontend/src/components/chat/{ReactionBar,StagingChips,DeleteDialog,ChatMessage,ChatComposer,EmbedCard}.vue, ../sm-bm-app/frontend/src/lib/embeds.ts (what to drop), ../sm-demo-app/plugins/golem15/chat/README.md (replies, thread mute, reactions catalog, attachments and limits), ../sm-demo-app/plugins/golem15/chatchannels/README.md (pins, mentions), ../sm-demo-app/plugins/golem15/chatbots/README.md and api/commands.go, ../vue-paymentgateway-layer/{README.md,app/assets/css/pg-tokens.css}, ../vue-faq-layer/README.md (layer README structure), 20-RESEARCH.md (Open Question 4, Anti-Patterns)</read_first>
|
||||
<action>Per D-02, D-05, D-22, D-23, D-01.
|
||||
|
||||
- Threads: a reply count on root messages opens ChatThreadPane, which lists replies (GET /messages/{id}/replies), posts replies (POST /messages/{id}/replies) and mutes the thread (PUT /threads/{rootId}/mute). Realtime reply events update the count.
|
||||
- Pins: ChatPinsPanel, opened from the header of a channel, lists GET .../channels/{id}/pins. Pin and unpin show only when the API allows it: a refused pin shows a notice and hides the action for the session.
|
||||
- Reactions: ChatReactionBar ported, with the keys from GET /reactions.
|
||||
- Attachments: ChatStagingChips ported. Uploads go to POST /attachments within the bootstrap limits (max_files, max_bytes), and the message shows a download link (GET /attachments/{messageId}/{fileId} with the auth header, fetched as a blob).
|
||||
- Delete: ChatDeleteDialog ported. Own messages always; others' only when the bootstrap's moderator is true and the conversation is a channel (D-22).
|
||||
- Edits: an Edit action on own messages through PUT /messages/{id}.
|
||||
- Link embeds: app/lib/linkEmbeds.ts with `linkify(escapedHtml)`, `registerEmbedRenderer(match: (url: URL) => boolean, component)` and `embedRendererFor(url)`. ChatMessage renders the first URL of a message with a registered renderer or with ChatLinkEmbed (host and path, no fetch). max_embeds caps the cards.
|
||||
- Commands (D-05): typing "/" at the start of the composer opens ChatCommandPicker, filled from GET /commands (cached per session) and filtered by prefix. Sending a "/" message calls POST /dispatch with {input} instead of posting. An ephemeral answer renders as a local, dismissable, sender-only system row with the payload text (escaped); a 422 shows the error.
|
||||
- Slots: ChatHeader renders `<slot name="conversation-actions" :conversation>` only when the open conversation is a DM (D-23). ChatChannelList renders `<slot name="sidebar-footer" />`. ChatPage forwards both slots.
|
||||
- Styling: chat-tokens.css and chat-base.css (neutral tokens an app overrides, as pg-tokens.css does), registered in nuxt.config.ts css with absolute paths.
|
||||
- i18n: every UI string of the layer in en and pl under `chat.*`. The app's own copy stays in the app.
|
||||
- README.md in the layer structure of vue-faq-layer and vue-paymentgateway-layer:
|
||||
- install by tag; extends;
|
||||
- the `$chatAuth` hook; the runtime-config keys;
|
||||
- the host bootstrap contract (the two JSON shapes with a neutral `acme` host);
|
||||
- slots, `registerEmbedRenderer`, `onUserEvent`;
|
||||
- client-only rendering (wrap ChatPage and ChatFloatingPanel in ClientOnly); testing.
|
||||
Name no consuming application.
|
||||
|
||||
Commit vue-chat-layer (`feat: threads, pins, reactions, attachments, link embeds and the command picker`).</action>
|
||||
<verify>
|
||||
<automated>pnpm --dir ../vue-chat-layer test && pnpm --dir ../vue-chat-layer run build:playground && pnpm --dir ../sm-demo-app/vue-demo-app run build && ../sm-demo-app/scripts/check-local.sh chat</automated>
|
||||
<fails_when>Non-zero exit: the layer smoke test, the playground build or the app build fails, or check-local prints "check-local: chat failed".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'conversation-actions' ../vue-chat-layer/app/components/Chat/Header.vue` prints at least 1, and the slot sits inside a condition on the DM type (`grep -c "type === 'dm'" ../vue-chat-layer/app/components/Chat/Header.vue` prints at least 1).
|
||||
- `grep -c 'export function registerEmbedRenderer' ../vue-chat-layer/app/lib/linkEmbeds.ts` prints 1.
|
||||
- `grep -c '/dispatch' ../vue-chat-layer/app/lib/client.ts` prints at least 1.
|
||||
- `node -e "const a=require('../vue-chat-layer/i18n/locales/en.json'),b=require('../vue-chat-layer/i18n/locales/pl.json');const k=o=>Object.entries(o).flatMap(([x,v])=>typeof v==='object'?k(v).map(y=>x+'.'+y):[x]);const d=k(a).filter(x=>!k(b).includes(x));if(d.length){console.error(d);process.exit(1)}"` exits 0 (PL covers every EN key).
|
||||
- In a manual run on the local stack, a "/demo" command in the composer shows the ephemeral answer and nothing is posted to the channel (recorded in the SUMMARY).
|
||||
</acceptance_criteria>
|
||||
<done>The layer has the full D-02 feature set plus the slash command picker and the extension points other layers and hosts plug into.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Chat everywhere on the demo site: floating panel, Chat tour card, EN/PL copy and updated checks</name>
|
||||
<files>../vue-chat-layer/app/components/Chat/FloatingPanel.vue, ../sm-demo-app/vue-demo-app/app/layouts/default.vue, ../sm-demo-app/vue-demo-app/app/pages/index.vue, ../sm-demo-app/vue-demo-app/app/components/TourCard.vue, ../sm-demo-app/vue-demo-app/app/pages/chat.vue, ../sm-demo-app/vue-demo-app/i18n/locales/en.json, ../sm-demo-app/vue-demo-app/i18n/locales/pl.json, ../sm-demo-app/scripts/check-local.sh, ../sm-demo-app/scripts/smoke.sh</files>
|
||||
<read_first>../sm-demo-app/vue-demo-app/app/layouts/default.vue, app/pages/index.vue (stops, TourCard), app/components/TourCard.vue (icon union, ICONS), app/composables/useDemoAuth.ts, i18n/locales/en.json and pl.json (tour.*), ../sm-demo-app/scripts/check-local.sh (check_tour, card_titles), ../sm-demo-app/scripts/smoke.sh (page loop at line 237), ../sm-bm-app/frontend/src/components/chat/DmList.vue</read_first>
|
||||
<action>Per D-03, D-04.
|
||||
- Layer ChatFloatingPanel: a corner button with the total unread badge (DMs plus channel mentions). It opens a compact panel with ChatDmList and the open DM's message list and composer, and has a link to the full chat page whose path comes from a `chatPath` prop (default '/chat'). It starts the chat store only for a member and renders nothing otherwise.
|
||||
- vue-demo-app default.vue: inside `<ClientOnly>`, render `<ChatFloatingPanel>` when loggedIn and the route is not /chat, /p/** or /presentations/**.
|
||||
- index.vue: a Chat stop `{ key: 'chat', to: '/chat' }` after quizzes. TourCard gets an outline chat icon in ICONS and 'chat' in the icon union. en and pl get tour.chat.title and tour.chat.text, and the tour heading text matches the new count ("Seven things to try" / the Polish equivalent).
|
||||
- chat.vue: final copy (EN/PL) explaining channels, DMs, threads and slash commands, plus the register prompt.
|
||||
- check-local.sh check_tour expects Chat (EN) and Czat (PL) among the cards and seven cards. smoke.sh adds /chat to the page loop (banner present; the anonymous prompt link to /register?next=%2Fchat in the HTML).
|
||||
|
||||
Commit the layer (`feat: floating chat panel`), vue-demo-app (`feat: floating chat panel, Chat tour card`) and sm-demo-app scripts separately.</action>
|
||||
<verify>
|
||||
<automated>pnpm --dir ../vue-chat-layer test && pnpm --dir ../sm-demo-app/vue-demo-app run build && ../sm-demo-app/scripts/check-local.sh tour chat</automated>
|
||||
<fails_when>Non-zero exit: build or tests fail, or check-local reports tour failed (missing Chat or Czat card, wrong card count) or chat failed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'ChatFloatingPanel' ../sm-demo-app/vue-demo-app/app/layouts/default.vue` prints 1 and the element sits inside ClientOnly.
|
||||
- `grep -c "key: 'chat'" ../sm-demo-app/vue-demo-app/app/pages/index.vue` prints 1.
|
||||
- `grep -c '/chat' ../sm-demo-app/scripts/smoke.sh` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>Chat is reachable from every page for members and from the tour for everyone, in EN and PL.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 5: Go-ahead to create vue-chat-layer on git.golem15.com, push it and tag v0.1.0</name>
|
||||
<read_first>Claude's summary before asking: `git -C ../vue-chat-layer log --oneline`, the README's install line, the visibility of vue-faq-layer on git.golem15.com (consumers install layers over git+https), whether `tea login list` shows a git.golem15.com login, and the result of the Task 4 checks.</read_first>
|
||||
<acceptance_criteria>
|
||||
- The user chose one option and it is recorded in the SUMMARY; with "release", `git ls-remote https://git.golem15.com/golem15/vue-chat-layer.git` exits 0 before Task 6 pushes, or Task 6 creates it with tea.
|
||||
</acceptance_criteria>
|
||||
<decision>Create the vue-chat-layer repository, push main and tag v0.1.0 now?</decision>
|
||||
<context>D-01 rates the layer costly: later sites pin it by tag, so v0.1.0 freezes the hook, runtime-config and bootstrap contract shown in the README. A pushed tag must not move. vue-demo-app can only pin the layer by tag after this; until then it builds from a local link that cannot be pushed. Repository creation and pushes need your go-ahead (CLAUDE.md).</context>
|
||||
<options>
|
||||
<option id="release">
|
||||
<name>Create, push and tag v0.1.0</name>
|
||||
<pros>vue-demo-app pins the tag and plan 04 can build vue-chatvideo-layer against it.</pros>
|
||||
<cons>The v0.1.0 contract is public; changes need v0.1.1 or v0.2.0.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold</name>
|
||||
<pros>The layer's contract can still change freely.</pros>
|
||||
<cons>Plan 04 (chatvideo extends the chat contract) and the deploy are blocked; vue-demo-app keeps a local link pin, so it cannot be pushed.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<instructions>For "release" without a tea login: create golem15/vue-chat-layer (empty, default branch main, same visibility as vue-faq-layer) in the git.golem15.com web UI, then reply. Do not paste tokens.</instructions>
|
||||
<resume-signal>Reply "release" (add "repo ready" if you created it) or "hold".</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 6: Release vue-chat-layer v0.1.0 and pin it in vue-demo-app</name>
|
||||
<reversibility rating="one-way">A pushed layer tag is installed by consumers' lockfiles; it can be superseded but must never move.</reversibility>
|
||||
<precondition>Task 5 resolved with "release".</precondition>
|
||||
<files>../vue-chat-layer/README.md, ../sm-demo-app/vue-demo-app/package.json, ../sm-demo-app/vue-demo-app/pnpm-lock.yaml</files>
|
||||
<read_first>../vue-chat-layer/README.md, ../sm-demo-app/vue-demo-app/package.json (other layer pins), the Task 5 answer</read_first>
|
||||
<action>Per D-01.
|
||||
1. If the repository is missing, create it with `tea repo create --name vue-chat-layer --owner golem15` at vue-faq-layer's visibility.
|
||||
2. Add the remote `git@git.golem15.com:golem15/vue-chat-layer.git` and push main.
|
||||
3. If the README's install line says no tag exists yet, fix it before tagging. Create the annotated tag v0.1.0, push it, and confirm `git ls-remote origin refs/tags/v0.1.0^{}` equals HEAD.
|
||||
4. In vue-demo-app run `pnpm add "vue-chat-layer@git+https://git.golem15.com/golem15/vue-chat-layer.git#v0.1.0"` (replacing the link pin), then build and test.
|
||||
5. Commit package.json and pnpm-lock.yaml (`feat: pin vue-chat-layer v0.1.0`). Do not push vue-demo-app (plan 05).</action>
|
||||
<verify>
|
||||
<automated>[ "$(git -C ../vue-chat-layer ls-remote origin 'refs/tags/v0.1.0^{}' | cut -f1)" = "$(git -C ../vue-chat-layer rev-parse HEAD)" ] && grep -q 'vue-chat-layer.git#v0.1.0' ../sm-demo-app/vue-demo-app/package.json && pnpm --dir ../sm-demo-app/vue-demo-app test && pnpm --dir ../sm-demo-app/vue-demo-app run build</automated>
|
||||
<fails_when>Non-zero exit: the remote tag is missing or differs from HEAD, package.json still holds the link pin, or the vue-demo-app tests or build fail against the tagged layer.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `node -p "require('../sm-demo-app/vue-demo-app/package.json').dependencies['vue-chat-layer']"` prints `git+https://git.golem15.com/golem15/vue-chat-layer.git#v0.1.0`.
|
||||
- `git -C ../sm-demo-app/vue-demo-app status --porcelain -- package.json pnpm-lock.yaml` prints nothing.
|
||||
- `../sm-demo-app/scripts/check-local.sh chat tour` prints `check-local: ok chat tour`.
|
||||
</acceptance_criteria>
|
||||
<done>vue-chat-layer v0.1.0 is published and the demo site builds against the tag.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| other members' messages, names and URLs to the browser | untrusted content rendered in every member's page |
|
||||
| browser to the chat-family APIs | JWT bearer from the demo cookie; actor fixed server-side |
|
||||
| browser to Centrifugo | connection token per member; subscriptions authorized server-side |
|
||||
| pnpm install from git.golem15.com | the layer tag becomes an input of every consuming site |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-20-14 | Tampering | stored XSS through message text, names or URLs | high | mitigate | text rendered through the ported chatText renderer that escapes before marking mentions; linkify runs on escaped HTML and only emits http(s) anchors with rel noopener noreferrer nofollow; names and payloads are text bindings; acceptance grep limits v-html to the renderer output |
|
||||
| T-20-15 | Information disclosure | JWT handling in the layer | medium | mitigate | the token comes only from the app's $chatAuth hook (cookie reader), is sent as a Bearer header to same-origin paths, never stored by the layer, and cleared through unauthorized() after a failed refresh |
|
||||
| T-20-16 | Information disclosure | attachment downloads | medium | mitigate | downloads go through GET /attachments/{messageId}/{fileId} with the Bearer header (participants only server-side); no public URL is built |
|
||||
| T-20-17 | Spoofing | slash command answers | low | mitigate | ephemeral answers are rendered locally as escaped text marked as a system row and never posted as the user |
|
||||
| T-20-18 | Tampering | layer supply chain | medium | mitigate | the layer pins exact versions already audited in the workspace (centrifuge 5.7.3, @nuxtjs/i18n 10.4.0); vue-demo-app pins the layer by tag with a committed lockfile |
|
||||
| T-20-19 | Denial of service | realtime connections | low | mitigate | one shared Centrifuge client per page (re-export in vue-demo-app); polling fallback intervals of 15 s and 60 s from bm |
|
||||
| T-20-SC | Tampering | npm installs | high | mitigate | no new package: centrifuge 5.7.3 was audited in 19.4.1 (protobufjs postinstall disabled in pnpm-workspace.yaml); @nuxtjs/i18n 10.4.0, vitest 3.2.7, nuxt, vue and typescript are the pins already in the workspace lockfiles |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: layer smoke, app build, check-local chat (browser send, anonymous prompt).
|
||||
- Task 2: layer and app vitest, build, check-local chat, realtime and remote.
|
||||
- Task 3: layer smoke, playground build, app build, check-local chat, PL key coverage.
|
||||
- Task 4: build, check-local tour and chat, smoke.sh page list.
|
||||
- Task 5: go-ahead recorded.
|
||||
- Task 6: remote tag equals HEAD, vue-demo-app builds against the tag.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
The public demo site has `/chat` with channels, DMs, threads, pins, reactions, attachments, mentions, typing, unread counts, link embeds and slash commands working live (SC-2, D-01 to D-05), a floating panel for members and an invitation for visitors. vue-chat-layer v0.1.0 is a reusable, app-neutral layer.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/20-demo-instance-with-every-shared-plugin/20-03-SUMMARY.md` when done: the layer's public API (runtime keys, hook, slots, exports), what was stripped from bm and why, the refresh endpoint used by chatAuth, which realtime test file is used in vue-demo-app, the tag and commit, and the checkpoint answer.
|
||||
</output>
|
||||
@@ -0,0 +1,517 @@
|
||||
---
|
||||
phase: 20-demo-instance-with-every-shared-plugin
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: ["20-03"]
|
||||
files_modified:
|
||||
- ../vue-forum-layer/.gitignore
|
||||
- ../vue-forum-layer/.nvmrc
|
||||
- ../vue-forum-layer/package.json
|
||||
- ../vue-forum-layer/pnpm-lock.yaml
|
||||
- ../vue-forum-layer/pnpm-workspace.yaml
|
||||
- ../vue-forum-layer/tsconfig.json
|
||||
- ../vue-forum-layer/nuxt.config.ts
|
||||
- ../vue-forum-layer/README.md
|
||||
- ../vue-forum-layer/.playground/nuxt.config.ts
|
||||
- ../vue-forum-layer/.playground/app/app.vue
|
||||
- ../vue-forum-layer/app/utils/forumClient.ts
|
||||
- ../vue-forum-layer/app/composables/useForumApi.ts
|
||||
- ../vue-forum-layer/app/composables/useForum.ts
|
||||
- ../vue-forum-layer/app/components/Forum/Page.vue
|
||||
- ../vue-forum-layer/app/components/Forum/CategoryList.vue
|
||||
- ../vue-forum-layer/app/components/Forum/ThreadList.vue
|
||||
- ../vue-forum-layer/app/components/Forum/Thread.vue
|
||||
- ../vue-forum-layer/app/components/Forum/PostEditor.vue
|
||||
- ../vue-forum-layer/app/assets/css/forum-base.css
|
||||
- ../vue-forum-layer/i18n/locales/en.json
|
||||
- ../vue-forum-layer/i18n/locales/pl.json
|
||||
- ../vue-forum-layer/tests/client.test.ts
|
||||
- ../vue-userfriends-layer/.gitignore
|
||||
- ../vue-userfriends-layer/.nvmrc
|
||||
- ../vue-userfriends-layer/package.json
|
||||
- ../vue-userfriends-layer/pnpm-lock.yaml
|
||||
- ../vue-userfriends-layer/pnpm-workspace.yaml
|
||||
- ../vue-userfriends-layer/tsconfig.json
|
||||
- ../vue-userfriends-layer/nuxt.config.ts
|
||||
- ../vue-userfriends-layer/README.md
|
||||
- ../vue-userfriends-layer/.playground/nuxt.config.ts
|
||||
- ../vue-userfriends-layer/.playground/app/app.vue
|
||||
- ../vue-userfriends-layer/app/utils/friendsClient.ts
|
||||
- ../vue-userfriends-layer/app/composables/useFriendsApi.ts
|
||||
- ../vue-userfriends-layer/app/composables/useFriends.ts
|
||||
- ../vue-userfriends-layer/app/components/Friends/Page.vue
|
||||
- ../vue-userfriends-layer/app/components/Friends/List.vue
|
||||
- ../vue-userfriends-layer/app/components/Friends/Requests.vue
|
||||
- ../vue-userfriends-layer/app/components/Friends/Groups.vue
|
||||
- ../vue-userfriends-layer/app/components/Friends/AddFriend.vue
|
||||
- ../vue-userfriends-layer/app/assets/css/friends-base.css
|
||||
- ../vue-userfriends-layer/i18n/locales/en.json
|
||||
- ../vue-userfriends-layer/i18n/locales/pl.json
|
||||
- ../vue-userfriends-layer/tests/client.test.ts
|
||||
- ../vue-chatvideo-layer/.gitignore
|
||||
- ../vue-chatvideo-layer/.nvmrc
|
||||
- ../vue-chatvideo-layer/package.json
|
||||
- ../vue-chatvideo-layer/pnpm-lock.yaml
|
||||
- ../vue-chatvideo-layer/pnpm-workspace.yaml
|
||||
- ../vue-chatvideo-layer/tsconfig.json
|
||||
- ../vue-chatvideo-layer/nuxt.config.ts
|
||||
- ../vue-chatvideo-layer/README.md
|
||||
- ../vue-chatvideo-layer/.playground/nuxt.config.ts
|
||||
- ../vue-chatvideo-layer/.playground/app/app.vue
|
||||
- ../vue-chatvideo-layer/app/lib/videoClient.ts
|
||||
- ../vue-chatvideo-layer/app/lib/callMachine.ts
|
||||
- ../vue-chatvideo-layer/app/composables/useCall.ts
|
||||
- ../vue-chatvideo-layer/app/composables/useIncomingCalls.ts
|
||||
- ../vue-chatvideo-layer/app/components/Chatvideo/Call.vue
|
||||
- ../vue-chatvideo-layer/app/components/Chatvideo/Tile.vue
|
||||
- ../vue-chatvideo-layer/app/components/Chatvideo/CallButton.vue
|
||||
- ../vue-chatvideo-layer/app/components/Chatvideo/IncomingCall.vue
|
||||
- ../vue-chatvideo-layer/app/assets/css/chatvideo-base.css
|
||||
- ../vue-chatvideo-layer/i18n/locales/en.json
|
||||
- ../vue-chatvideo-layer/i18n/locales/pl.json
|
||||
- ../vue-chatvideo-layer/tests/callMachine.test.ts
|
||||
- ../sm-demo-app/vue-demo-app/package.json
|
||||
- ../sm-demo-app/vue-demo-app/pnpm-lock.yaml
|
||||
- ../sm-demo-app/vue-demo-app/nuxt.config.ts
|
||||
- ../sm-demo-app/vue-demo-app/app/plugins/auth.ts
|
||||
- ../sm-demo-app/vue-demo-app/app/layouts/default.vue
|
||||
- ../sm-demo-app/vue-demo-app/app/pages/forum/index.vue
|
||||
- ../sm-demo-app/vue-demo-app/app/pages/forum/t/[id].vue
|
||||
- ../sm-demo-app/vue-demo-app/app/pages/friends.vue
|
||||
- ../sm-demo-app/vue-demo-app/app/pages/call/[conversation].vue
|
||||
- ../sm-demo-app/vue-demo-app/app/pages/chat.vue
|
||||
- ../sm-demo-app/vue-demo-app/app/pages/index.vue
|
||||
- ../sm-demo-app/vue-demo-app/app/pages/account/security.vue
|
||||
- ../sm-demo-app/vue-demo-app/app/components/SiteHeader.vue
|
||||
- ../sm-demo-app/vue-demo-app/app/components/TourCard.vue
|
||||
- ../sm-demo-app/vue-demo-app/i18n/locales/en.json
|
||||
- ../sm-demo-app/vue-demo-app/i18n/locales/pl.json
|
||||
- ../sm-demo-app/scripts/check-local.sh
|
||||
- ../sm-demo-app/scripts/smoke.sh
|
||||
- ../sm-demo-app/scripts/dev.sh
|
||||
- ../sm-demo-app/deploy/env.example
|
||||
- ../sm-demo-app/README.md
|
||||
- .planning/phases/20-demo-instance-with-every-shared-plugin/COVERAGE.md
|
||||
autonomous: false
|
||||
requirements: [SC-2, D-11, D-12, D-13, D-14, D-15, D-16, D-20, D-21, D-22, D-23]
|
||||
estimate:
|
||||
tokens: 380000
|
||||
raw_tokens: 380000
|
||||
tasks: 7
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-14, D-21: /forum shows a member the seeded EN and PL categories and their threads; a thread page lists posts as the server's content_html; a member creates a thread, replies, edits own posts and deletes own posts (moderators any post, D-22); an anonymous visitor on /forum and on a thread URL sees a description and a register-to-join link (/register?next=...), and no forum API call is made without a JWT."
|
||||
- "D-15: /friends shows a member their friends, incoming and sent requests (accept, reject, cancel, unfriend) and friend groups (list, create, join, leave, members); a member sends a request to another member chosen from the app-provided directory; /friends is linked from the chat sidebar (sidebar-footer slot) and from the account area (header link and /account/security)."
|
||||
- "D-11, D-23: the call button (ChatvideoCallButton) appears only in DM conversations through vue-chat-layer's conversation-actions slot; there is no call button in channels; the server is not changed to refuse channel calls."
|
||||
- "D-13: /call/{conversation} is a minimal call page: own camera, remote tiles, mute, camera toggle and leave, built on livekit-client loaded with a dynamic import; an incoming video:call:incoming event (received through vue-chat-layer's onUserEvent, no second user:{me} subscription) shows an accept/decline prompt on every page for members."
|
||||
- "D-12: the call page shows the D-12 caps' effects (a started call ends at the server's max_duration; a fifth participant is refused by LiveKit's room cap) without client-side limits of its own."
|
||||
- "D-16: herald runs with VAPID keys from .env (SUMMER_GOLEM15__HERALD__VAPID__PUBLIC_KEY, __PRIVATE_KEY, __SUBJECT; dev.sh generates them with herald:vapid-generate, never printing them) and there is no subscribe UI; /_herald is not added to vue-demo-app goPaths."
|
||||
- "The homepage tour has Forum, Friends and Calls cards next to Chat (ten cards, EN and PL)."
|
||||
- "livekit-client is installed only after the blocking human-verify checkpoint, at an exact version the user confirmed."
|
||||
- "After the Task 6 go-ahead, vue-forum-layer, vue-userfriends-layer and vue-chatvideo-layer exist on git.golem15.com with tag v0.1.0 each, and vue-demo-app pins all three by git+https tag in a committed package.json and pnpm-lock.yaml."
|
||||
artifacts:
|
||||
- path: "../vue-forum-layer/app/components/Forum/Page.vue"
|
||||
provides: "categories and thread list of one forum context"
|
||||
contains: "contextType"
|
||||
- path: "../vue-userfriends-layer/app/components/Friends/Page.vue"
|
||||
provides: "friends, requests and groups"
|
||||
contains: "Requests"
|
||||
- path: "../vue-chatvideo-layer/app/lib/callMachine.ts"
|
||||
provides: "pure call state machine (idle, starting, connecting, connected, ended, failed)"
|
||||
contains: "connected"
|
||||
- path: "../vue-chatvideo-layer/app/composables/useCall.ts"
|
||||
provides: "LiveKit room lifecycle over the chatvideo API, dynamic import of livekit-client"
|
||||
contains: "import('livekit-client')"
|
||||
- path: "../sm-demo-app/vue-demo-app/app/pages/call/[conversation].vue"
|
||||
provides: "/call/{conversation} page"
|
||||
contains: "ChatvideoCall"
|
||||
- path: ".planning/phases/20-demo-instance-with-every-shared-plugin/COVERAGE.md"
|
||||
provides: "API coverage decisions for livekit-client, LiveKit server, Centrifugo and Web Push"
|
||||
contains: "livekit-client"
|
||||
key_links:
|
||||
- from: "../sm-demo-app/vue-demo-app/app/pages/chat.vue"
|
||||
to: "../vue-chatvideo-layer/app/components/Chatvideo/CallButton.vue"
|
||||
via: "template #conversation-actions renders ChatvideoCallButton (DM only, D-23)"
|
||||
pattern: "conversation-actions"
|
||||
- from: "../vue-chatvideo-layer/app/composables/useIncomingCalls.ts"
|
||||
to: "../vue-chat-layer/app/lib/realtime.ts"
|
||||
via: "onUserEvent receives video:call:incoming from the chat store's user:{me} subscription"
|
||||
pattern: "onUserEvent"
|
||||
- from: "../vue-chatvideo-layer/app/composables/useCall.ts"
|
||||
to: "../sm-demo-app/plugins/golem15/chatvideo/api/video.go"
|
||||
via: "POST /_chatvideo/api/v1/conversations/{id}/video/start or /join gives {livekit_url, token}"
|
||||
pattern: "video/(start|join)"
|
||||
- from: "../sm-demo-app/vue-demo-app/app/plugins/auth.ts"
|
||||
to: "../vue-forum-layer/app/composables/useForumApi.ts"
|
||||
via: "provide forumAuth and userfriendsAuth token getters (chatvideo reuses chatAuth)"
|
||||
pattern: "forumAuth"
|
||||
prohibitions:
|
||||
- statement: "None of the three layers names a consuming application or carries a default host path."
|
||||
status: unresolved
|
||||
verification: automated
|
||||
- statement: "Forum post HTML is rendered only from the server's content_html; user Markdown is never rendered client-side."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "livekit-client is not added to any package.json before the Task 3 checkpoint is approved."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No push, repository creation or tag before the Task 6 go-ahead; vue-demo-app and sm-demo-app are not pushed (plan 05)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented here): the demo's public Nuxt site shows the shared plugins working through shared layers, video calls included.
|
||||
|
||||
This plan's slice (plan 4 of 6, wave 4, per D-20): three new shared layers at v0.1.0 (`vue-forum-layer`, `vue-userfriends-layer`, `vue-chatvideo-layer`), their pages and tour cards in vue-demo-app, and herald's VAPID configuration without UI.
|
||||
|
||||
**Wave note (deviation from D-20, recorded):** D-20 lets plans 03 and 04 run in parallel after plan 02. This plan still depends on plan 03 and runs after it, for two reasons:
|
||||
1. It consumes plan 03's vue-chat-layer outputs: vue-chatvideo-layer imports the shared realtime client (`onUserEvent`, `subscribe`) and renders its call button in vue-chat-layer's DM-only `conversation-actions` slot (D-23), and the friends link goes into its `sidebar-footer` slot.
|
||||
2. Both plans edit the same vue-demo-app files (package.json, pnpm-lock.yaml, nuxt.config.ts, app/plugins/auth.ts, app/pages/index.vue, the locales) in one checkout. Parallel execution runs without worktrees, so the two plans would collide in the same wave.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion, recorded)
|
||||
|
||||
- **Layer shape:** like vue-faq-layer and vue-paymentgateway-layer. A Nuxt-free client in `app/utils/*Client.ts` (tested with `node --test`), an auth-hook composable, components with a directory prefix (`Forum*`, `Friends*`, `Chatvideo*`), EN/PL strings through `i18n:registerModule`, and no pages or route rules: the app owns routes and wraps client-side parts in ClientOnly.
|
||||
- **Auth hooks:** `$forumAuth` and `$userfriendsAuth` are token getters, the vue-paymentgateway-layer shape. vue-chatvideo-layer reuses vue-chat-layer's `$chatAuth`, because it has a peer dependency on that layer.
|
||||
- **Forum context:** props `contextType` and `contextId`; vue-demo-app passes `demo` and `1`. Thread URLs are built by an app-supplied `threadPath(id)` prop. Markdown is written in a textarea with the server preview endpoint (`POST /contexts/{type}/{id}/preview`); nothing is rendered client-side.
|
||||
- **Friend directory:** the layer takes an optional `$userfriendsDirectory(): Promise<{id, name}[]>` hook for the add-friend picker and falls back to a username field (the API accepts user_id, username or email). vue-demo-app provides the directory from `GET /_demo/api/v1/chat/members`.
|
||||
- **chatvideo and chat:** vue-chatvideo-layer lists vue-chat-layer v0.1.0 as a peer dependency and dev dependency, without `extends`. The app extends both, so the chat layer is not loaded twice. It imports `onUserEvent` and `subscribe` from `vue-chat-layer/app/lib/realtime`.
|
||||
- **Call flow:** the call button starts (`video/start`) and navigates to `/call/{conversation}`. The call page joins (`video/join`) when it was not the starter and connects with the returned `livekit_url` and `token`. Leave calls `video/leave` then `room.disconnect()`. An incoming call prompt offers Accept (navigate) and Decline (`video/leave`; the service publishes video:call:declined for a participant who never joined; Task 4 confirms this in video_service.go). Screen share stays off (D-13 minimal, `screen_share: false`).
|
||||
- **Profile link (D-15):** the demo has no profile page. The account area stands for it: a "Friends" header link for members next to "Security", and a link on /account/security.
|
||||
- **Tour:** ten cards (Journal, Presentations, Quizzes, Chat, Forum, Friends, Calls, Payments, FAQ, Accounts). The Calls card links to /chat with text saying calls start from a DM.
|
||||
- **Development pins:** `link:` dependencies to the three local checkouts until Task 7 swaps them for tags, as in plan 03.
|
||||
- spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip).
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Repository `vue-forum-layer` v0.1.0:
|
||||
- components `ForumPage`, `ForumCategoryList`, `ForumThreadList`, `ForumThread`, `ForumPostEditor`;
|
||||
- composables `useForum`, `useForumApi`; client `createForumClient`;
|
||||
- runtime keys `forum.apiBase` (`NUXT_FORUM_API_BASE`) and `public.forum.apiBase`; hook `$forumAuth`; i18n `forum.*`.
|
||||
- Repository `vue-userfriends-layer` v0.1.0:
|
||||
- components `FriendsPage`, `FriendsList`, `FriendsRequests`, `FriendsGroups`, `FriendsAddFriend`;
|
||||
- composables `useFriends`, `useFriendsApi`; client `createFriendsClient`;
|
||||
- runtime keys `userfriends.apiBase` and `public.userfriends.apiBase`; hooks `$userfriendsAuth` and `$userfriendsDirectory`; i18n `friends.*`.
|
||||
- Repository `vue-chatvideo-layer` v0.1.0:
|
||||
- components `ChatvideoCall`, `ChatvideoTile`, `ChatvideoCallButton`, `ChatvideoIncomingCall`;
|
||||
- composables `useCall`, `useIncomingCalls`; lib `createVideoClient`, `callMachine` (`initialCallState`, `transition`);
|
||||
- runtime key `public.chatvideo.apiBase`; prop `callPath`; i18n `chatvideo.*`; dependency `livekit-client` at the confirmed exact version.
|
||||
- vue-demo-app:
|
||||
- pages `/forum`, `/forum/t/[id]`, `/friends`, `/call/[conversation]`;
|
||||
- ChatvideoIncomingCall in the default layout; the call button in /chat's conversation-actions slot; the friends link in /chat's sidebar-footer slot, the header and /account/security;
|
||||
- tour cards forum, friends and calls; goPaths `/_forum`, `/_userfriends`, `/_chatvideo`; hooks `forumAuth`, `userfriendsAuth`, `userfriendsDirectory`.
|
||||
- sm-demo-app: check-local checks `forum`, `friends`, `call`, `herald`; tour expects ten cards; smoke.sh pages /forum and /friends; dev.sh VAPID generation; env keys `SUMMER_GOLEM15__HERALD__VAPID__PUBLIC_KEY`, `__PRIVATE_KEY`, `__SUBJECT` (names only in env.example).
|
||||
- `.planning/phases/20-demo-instance-with-every-shared-plugin/COVERAGE.md` (API coverage matrix).
|
||||
|
||||
<objective>
|
||||
Build and release the forum, friends and video-call layers, put their pages and tour cards on the demo site, and configure herald's VAPID keys without UI.
|
||||
|
||||
Purpose: SC-2 (video calls and the other plugins visible through shared layers), D-11, D-12, D-13, D-14, D-15, D-16, D-21, D-22 (forum moderation), D-23.
|
||||
Output: three new repositories (pushed and tagged after the go-ahead), commits in vue-demo-app and sm-demo-app (not pushed), and the phase COVERAGE.md.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-CONTEXT.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-RESEARCH.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-PATTERNS.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-03-SUMMARY.md
|
||||
|
||||
Repos: new ../vue-forum-layer, ../vue-userfriends-layer and ../vue-chatvideo-layer; ../sm-demo-app/vue-demo-app (submodule); ../sm-demo-app. Layer analogs: ../vue-faq-layer (structure, node --test, client in app/utils), ../vue-paymentgateway-layer (auth hook, i18n registration), ../vue-chat-layer (plan 03: slots, onUserEvent, $chatAuth).
|
||||
|
||||
Rules: as in plan 03 (no co-author tags, stage by path, no remote before the go-ahead, vue-demo-app committed in its own repository and not pushed, check for other sessions' edits before each commit). The local stack: `../sm-demo-app/scripts/dev.sh up` with LiveKit on 127.0.0.1:7890.
|
||||
|
||||
Size (accepted under D-20): seven tasks over three new layer repositories are above the usual per-plan budget, but the user confirmed exactly six plans (D-20, the lean-planning rule in CLAUDE.md), so this plan is not split. After Task 4 is committed, write a progress note into a draft `20-04-SUMMARY.md` (Tasks 1 to 4 with their commits, the approved livekit-client version, the decline endpoint, open points) so a context reset resumes from Task 5.
|
||||
|
||||
<interfaces>
|
||||
forum API /_forum/api/v1 (JWT, forum-api 120/min): GET /contexts/{type}/{id}/categories; GET /contexts/{type}/{id}/categories/{categoryId}/threads; POST same (create thread {title, body}); GET /contexts/{type}/{id}/threads; GET /contexts/{type}/{id}/threads/{threadId}/posts; POST same (reply {body}); PUT /contexts/{type}/{id}/posts/{postId} ({body}); DELETE /contexts/{type}/{id}/posts/{postId}; DELETE /contexts/{type}/{id}/threads/{threadId}; POST /contexts/{type}/{id}/threads/{threadId}/read; GET /contexts/{type}/{id}/unread; POST /contexts/{type}/{id}/preview. Posts carry content_html (server-rendered with breakwater). 422 envelope as chat.
|
||||
userfriends API /_userfriends/api/v1 (JWT; userfriends-api 120/min, community-join 10/min): GET /friends, /friends/requests, /friends/sent; POST /friends/request ({user_id} or {username} or {email}); POST /friends/requests/{id}/accept; DELETE /friends/requests/{id}; DELETE /friends/sent/{id}; DELETE /friends/{userId}; GET|POST /groups; GET|PUT|DELETE /groups/{id}; POST /groups/{id}/join, /leave; GET|POST /groups/{id}/members; DELETE /groups/{id}/members/{userId}; GET|POST|DELETE /groups/{id}/invite; GET|POST /groups/join/{code}.
|
||||
chatvideo API /_chatvideo/api/v1 (JWT): POST /conversations/{id}/video/start (video-initiate 5/min) and /join give {livekit_url, token, ...}; POST /leave, /end; GET /status. Realtime: video:call:incoming on user:{id}; video:call:accepted, declined, ended and participant events on video:session:{id}.
|
||||
vue-chat-layer v0.1.0 (plan 03): slots conversation-actions (DM only) and sidebar-footer on ChatPage; `onUserEvent(handler)`, `subscribe(channel, hooks)` in `vue-chat-layer/app/lib/realtime`; `$chatAuth {token, refresh, unauthorized}`.
|
||||
herald: keys golem15.herald.vapid.{subject, public_key, private_key}; env SUMMER_GOLEM15__HERALD__VAPID__*; command herald:vapid-generate (--json prints {"public_key", "private_key"}; -q quiet).
|
||||
vue-demo-app (after plan 03): app/pages/chat.vue (ChatPage in ClientOnly), app/layouts/default.vue (ChatFloatingPanel), app/pages/index.vue (stops with chat), app/components/TourCard.vue, app/components/SiteHeader.vue (links mine and security), app/pages/account/security.vue, app/plugins/auth.ts (paymentGatewayAuth, chatAuth).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a member opens /forum, sees the seeded categories, opens a thread and posts a reply that the server renders; an anonymous visitor gets the register prompt</name>
|
||||
<precondition>The plan 03 SUMMARY records vue-chat-layer v0.1.0 on git.golem15.com; `../sm-demo-app/scripts/dev.sh up` is running; `git -C ../sm-demo-app/vue-demo-app status --porcelain` lists no file this task changes.</precondition>
|
||||
<files>../vue-forum-layer/.gitignore, ../vue-forum-layer/.nvmrc, ../vue-forum-layer/package.json, ../vue-forum-layer/pnpm-lock.yaml, ../vue-forum-layer/pnpm-workspace.yaml, ../vue-forum-layer/tsconfig.json, ../vue-forum-layer/nuxt.config.ts, ../vue-forum-layer/README.md, ../vue-forum-layer/.playground/nuxt.config.ts, ../vue-forum-layer/.playground/app/app.vue, ../vue-forum-layer/app/utils/forumClient.ts, ../vue-forum-layer/app/composables/useForumApi.ts, ../vue-forum-layer/app/composables/useForum.ts, ../vue-forum-layer/app/components/Forum/Page.vue, ../vue-forum-layer/app/components/Forum/CategoryList.vue, ../vue-forum-layer/app/components/Forum/ThreadList.vue, ../vue-forum-layer/app/components/Forum/Thread.vue, ../vue-forum-layer/app/components/Forum/PostEditor.vue, ../vue-forum-layer/app/assets/css/forum-base.css, ../vue-forum-layer/i18n/locales/en.json, ../vue-forum-layer/i18n/locales/pl.json, ../vue-forum-layer/tests/client.test.ts, ../sm-demo-app/vue-demo-app/package.json, ../sm-demo-app/vue-demo-app/nuxt.config.ts, ../sm-demo-app/vue-demo-app/app/plugins/auth.ts, ../sm-demo-app/vue-demo-app/app/pages/forum/index.vue, ../sm-demo-app/vue-demo-app/app/pages/forum/t/[id].vue, ../sm-demo-app/vue-demo-app/i18n/locales/en.json, ../sm-demo-app/vue-demo-app/i18n/locales/pl.json, ../sm-demo-app/scripts/check-local.sh, ../sm-demo-app/scripts/smoke.sh</files>
|
||||
<read_first>../vue-faq-layer/{nuxt.config.ts,package.json,README.md,app/utils/client.ts,app/composables/useFaqApi.ts,tests/client.test.ts}, ../vue-paymentgateway-layer/{nuxt.config.ts,app/composables/usePaymentGatewayApi.ts}, ../sm-demo-app/plugins/golem15/forum/README.md and api/*.go (payloads), ../sm-demo-app/plugins/demo/classes/forum_access.go, ../sm-demo-app/vue-demo-app/{nuxt.config.ts,app/plugins/auth.ts,app/pages/chat.vue,app/pages/quizzes/index.vue}, ../sm-demo-app/scripts/check-local.sh (visitor_jwt, json_has, ALL_CHECKS) and smoke.sh (page loop)</read_first>
|
||||
<action>Per D-14, D-21, D-22, SC-2.
|
||||
|
||||
1. Create ../vue-forum-layer (`git init -b main`) with the vue-faq-layer skeleton:
|
||||
- package.json: name vue-forum-layer, version 0.1.0, main nuxt.config.ts, peer nuxt 4.4.8, dependency @nuxtjs/i18n 10.4.0, dev deps nuxt, vue and typescript at the workspace pins, test `node --test --test-reporter=tap tests/*.test.ts`;
|
||||
- nuxt.config.ts: header comment, currentDir, runtimeConfig `forum.apiBase` (NUXT_FORUM_API_BASE) and `public.forum.apiBase: '/_forum/api/v1'`, the EN/PL i18n registration, absolute css path.
|
||||
2. app/utils/forumClient.ts `createForumClient({ base, token, fetch })`: categories, threads (per category and all), posts, createThread, reply, editPost, deletePost, deleteThread, markRead, unread and preview, each building `/contexts/{type}/{id}/...` paths, plus the 422 field-error mapping. app/composables/useForumApi.ts binds it to runtime config and `$forumAuth`. useForum keeps the selected category, the threads, and the open thread and posts.
|
||||
3. Components:
|
||||
- ForumPage (props contextType, contextId, threadPath(id)): ForumCategoryList plus ForumThreadList with a "new thread" ForumPostEditor;
|
||||
- ForumThread (props contextType, contextId, threadId): each post renders the server's content_html as raw HTML (the only raw-HTML binding in the layer), with author and date, reply editor, Edit and Delete on own posts and on all posts when the API reports manage rights (a refused delete hides the action);
|
||||
- ForumPostEditor: textarea plus a server preview button.
|
||||
Mark the thread read on open.
|
||||
4. vue-demo-app:
|
||||
- run `pnpm add vue-forum-layer@link:../../vue-forum-layer` (development pin, committed; Task 7 swaps it for the tag before any push);
|
||||
- nuxt.config.ts: extends gains 'vue-forum-layer'; goPaths gains '/_forum'; `forum.apiBase` comes from `api`;
|
||||
- auth.ts provides `forumAuth`;
|
||||
- app/pages/forum/index.vue and forum/t/[id].vue: for members, ClientOnly ForumPage and ForumThread with context demo/1 and `threadPath: id => '/forum/t/' + id`; for anonymous visitors, a server-rendered description and a register link with next set to the current path (D-21, the chat page pattern). useSeoMeta on both;
|
||||
- EN/PL copy: forum.title, forum.lead and forum.registerToJoin.
|
||||
5. check-local.sh `check_forum`:
|
||||
- with the visitor JWT, GET /_forum/api/v1/contexts/demo/1/categories answers 200 with at least three categories;
|
||||
- POST a reply to the first thread answers 201 and its content_html holds a rendered paragraph;
|
||||
- GET $NUXT/forum without a cookie holds the register link with next=%2Fforum.
|
||||
Add it to ALL_CHECKS. smoke.sh adds /forum to the page loop.
|
||||
6. tests/client.test.ts: URL building for every method, the Bearer header, and the 422 mapping.
|
||||
|
||||
Commit the layer (`feat: forum layer: categories, threads and Markdown posts`), vue-demo-app (package.json and pnpm-lock.yaml with the link pin, pages, config, auth and locales) and sm-demo-app scripts separately.</action>
|
||||
<verify>
|
||||
<automated>pnpm --dir ../vue-forum-layer test && pnpm --dir ../sm-demo-app/vue-demo-app run build && ../sm-demo-app/scripts/dev.sh up && ../sm-demo-app/scripts/check-local.sh forum</automated>
|
||||
<fails_when>Non-zero exit: the forum client tests fail, the Nuxt build fails, the stack does not start, or check-local prints "check-local: forum failed" (categories missing, the reply not rendered, or no register link for anonymous visitors).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -rniE 'golem15\.demo|sm-demo|demo/1' ../vue-forum-layer/app ../vue-forum-layer/nuxt.config.ts ../vue-forum-layer/README.md | wc -l` prints 0.
|
||||
- `grep -rn 'v-html' ../vue-forum-layer/app/components | grep -vc 'content_html'` prints 0.
|
||||
- `grep -c "'/_forum'" ../sm-demo-app/vue-demo-app/nuxt.config.ts` prints 1.
|
||||
</acceptance_criteria>
|
||||
<done>Members read and post in the seeded forum on the demo site through a new shared layer; anonymous visitors are invited to register.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Friends: list, requests and groups on /friends, linked from the chat sidebar and the account area</name>
|
||||
<files>../vue-userfriends-layer/.gitignore, ../vue-userfriends-layer/.nvmrc, ../vue-userfriends-layer/package.json, ../vue-userfriends-layer/pnpm-lock.yaml, ../vue-userfriends-layer/pnpm-workspace.yaml, ../vue-userfriends-layer/tsconfig.json, ../vue-userfriends-layer/nuxt.config.ts, ../vue-userfriends-layer/README.md, ../vue-userfriends-layer/.playground/nuxt.config.ts, ../vue-userfriends-layer/.playground/app/app.vue, ../vue-userfriends-layer/app/utils/friendsClient.ts, ../vue-userfriends-layer/app/composables/useFriendsApi.ts, ../vue-userfriends-layer/app/composables/useFriends.ts, ../vue-userfriends-layer/app/components/Friends/Page.vue, ../vue-userfriends-layer/app/components/Friends/List.vue, ../vue-userfriends-layer/app/components/Friends/Requests.vue, ../vue-userfriends-layer/app/components/Friends/Groups.vue, ../vue-userfriends-layer/app/components/Friends/AddFriend.vue, ../vue-userfriends-layer/app/assets/css/friends-base.css, ../vue-userfriends-layer/i18n/locales/en.json, ../vue-userfriends-layer/i18n/locales/pl.json, ../vue-userfriends-layer/tests/client.test.ts, ../sm-demo-app/vue-demo-app/package.json, ../sm-demo-app/vue-demo-app/nuxt.config.ts, ../sm-demo-app/vue-demo-app/app/plugins/auth.ts, ../sm-demo-app/vue-demo-app/app/pages/friends.vue, ../sm-demo-app/vue-demo-app/app/pages/chat.vue, ../sm-demo-app/vue-demo-app/app/pages/account/security.vue, ../sm-demo-app/vue-demo-app/app/components/SiteHeader.vue, ../sm-demo-app/vue-demo-app/i18n/locales/en.json, ../sm-demo-app/vue-demo-app/i18n/locales/pl.json, ../sm-demo-app/scripts/check-local.sh, ../sm-demo-app/scripts/smoke.sh</files>
|
||||
<read_first>../sm-demo-app/plugins/golem15/userfriends/README.md and api/friends.go and api/groups.go (bodies, 422 behaviour), ../vue-forum-layer (Task 1, same skeleton), ../vue-chat-layer/README.md (sidebar-footer slot), ../sm-demo-app/vue-demo-app/{app/pages/chat.vue,app/components/SiteHeader.vue,app/pages/account/security.vue,app/plugins/auth.ts}, ../sm-demo-app/plugins/demo/chat_api.go (members endpoint)</read_first>
|
||||
<action>Per D-15.
|
||||
- Create ../vue-userfriends-layer with the Task 1 skeleton (runtime `userfriends.apiBase`, `public.userfriends.apiBase: '/_userfriends/api/v1'`).
|
||||
- app/utils/friendsClient.ts `createFriendsClient`: friends, incoming, sent, request({user_id} or {username}), accept, reject, cancel, unfriend, groups, group, createGroup, updateGroup, deleteGroup, join, leave, members, kick, invite, joinByCode. useFriendsApi binds `$userfriendsAuth`.
|
||||
- Components:
|
||||
- FriendsList: friends with Unfriend;
|
||||
- FriendsRequests: incoming with Accept and Reject, sent with Cancel;
|
||||
- FriendsAddFriend: picks from `$userfriendsDirectory()` when provided, else a username field; the generic 422 shows the server message;
|
||||
- FriendsGroups: my groups, create (name, visibility as the API allows), join by code, leave, member list;
|
||||
- FriendsPage: all four in sections.
|
||||
- vue-demo-app:
|
||||
- pnpm link dependency; extends 'vue-userfriends-layer'; goPaths '/_userfriends'; runtime config;
|
||||
- auth.ts provides `userfriendsAuth` and `userfriendsDirectory` (GET /_demo/api/v1/chat/members with the JWT, mapped to {id, name: name plus surname});
|
||||
- app/pages/friends.vue: ClientOnly FriendsPage for members, the register prompt for anonymous visitors;
|
||||
- chat.vue passes `<template #sidebar-footer>` with a NuxtLink to /friends;
|
||||
- SiteHeader shows "Friends" for members next to Security, and account/security.vue links to /friends;
|
||||
- EN/PL copy.
|
||||
- check-local.sh `check_friends`, repeatable without touching seed data:
|
||||
- with the visitor JWT, GET /_userfriends/api/v1/friends/requests lists the seeded pending request (read-only);
|
||||
- two fresh users from the throwaway registration helper (extend it so the caller also gets the e-mail it registered): A sends POST /friends/request with {email: B's e-mail} and gets 2xx, B finds the request in GET /friends/requests and accepts it with 2xx, and A's GET /friends lists B.
|
||||
Add it to ALL_CHECKS. smoke.sh adds /friends.
|
||||
- tests/client.test.ts: URL building, Bearer header, 422 mapping.
|
||||
|
||||
Commit the layer, vue-demo-app (with the link pin) and the scripts separately.</action>
|
||||
<verify>
|
||||
<automated>pnpm --dir ../vue-userfriends-layer test && pnpm --dir ../sm-demo-app/vue-demo-app run build && ../sm-demo-app/scripts/check-local.sh friends forum chat</automated>
|
||||
<fails_when>Non-zero exit: the friends client tests or the build fail, or check-local reports friends, forum or chat failed (the seeded request missing, accept refused, or the chat page broken by the slot).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'sidebar-footer' ../sm-demo-app/vue-demo-app/app/pages/chat.vue` prints 1.
|
||||
- `grep -c "to=\"/friends\"" ../sm-demo-app/vue-demo-app/app/components/SiteHeader.vue` prints 1.
|
||||
- `grep -rniE 'golem15\.demo|sm-demo|_demo/api' ../vue-userfriends-layer/app ../vue-userfriends-layer/nuxt.config.ts ../vue-userfriends-layer/README.md | wc -l` prints 0.
|
||||
</acceptance_criteria>
|
||||
<done>Members manage friends, requests and friend groups on /friends, reachable from the chat sidebar and the account area.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking-human">
|
||||
<name>Task 3: Package legitimacy check before installing livekit-client</name>
|
||||
<read_first>20-RESEARCH.md Package Legitimacy Audit (livekit-client flagged [SUS] too-new for its latest release; assumption A7), the output Claude prints before asking: `npm view livekit-client versions --json | tail -15`, `npm view livekit-client time --json` for the last five versions, `npm view livekit-client repository.url maintainers dist-tags scripts --json`, and the proposed exact version (the newest release at least 7 days old)</read_first>
|
||||
<acceptance_criteria>
|
||||
- The user approved a named exact version (recorded in the SUMMARY) or rejected the package; Task 4 installs only that version with `--save-exact`.
|
||||
</acceptance_criteria>
|
||||
<what-built>Nothing is installed yet. Claude proposes an exact livekit-client version for vue-chatvideo-layer, the client SDK the minimal call page (D-13) is built on.</what-built>
|
||||
<how-to-verify>
|
||||
1. Open https://www.npmjs.com/package/livekit-client and check that the publisher and maintainers are the LiveKit organization and the repository is github.com/livekit/client-sdk-js.
|
||||
2. Check that the proposed version exists, is at least 7 days old and that `scripts` shows no install or postinstall hook.
|
||||
3. Check the client SDK's server compatibility notes for LiveKit server 1.13.1 (the rome and local binary).
|
||||
</how-to-verify>
|
||||
<resume-signal>Reply "approved <version>" or "rejected" with the reason.</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Video calls from DMs: the call button, the minimal call page and incoming-call prompts</name>
|
||||
<precondition>Task 3 answered "approved <version>"; LiveKit answers on http://127.0.0.1:7890/ (dev stack).</precondition>
|
||||
<files>../vue-chatvideo-layer/.gitignore, ../vue-chatvideo-layer/.nvmrc, ../vue-chatvideo-layer/package.json, ../vue-chatvideo-layer/pnpm-lock.yaml, ../vue-chatvideo-layer/pnpm-workspace.yaml, ../vue-chatvideo-layer/tsconfig.json, ../vue-chatvideo-layer/nuxt.config.ts, ../vue-chatvideo-layer/README.md, ../vue-chatvideo-layer/.playground/nuxt.config.ts, ../vue-chatvideo-layer/.playground/app/app.vue, ../vue-chatvideo-layer/app/lib/videoClient.ts, ../vue-chatvideo-layer/app/lib/callMachine.ts, ../vue-chatvideo-layer/app/composables/useCall.ts, ../vue-chatvideo-layer/app/composables/useIncomingCalls.ts, ../vue-chatvideo-layer/app/components/Chatvideo/Call.vue, ../vue-chatvideo-layer/app/components/Chatvideo/Tile.vue, ../vue-chatvideo-layer/app/components/Chatvideo/CallButton.vue, ../vue-chatvideo-layer/app/components/Chatvideo/IncomingCall.vue, ../vue-chatvideo-layer/app/assets/css/chatvideo-base.css, ../vue-chatvideo-layer/i18n/locales/en.json, ../vue-chatvideo-layer/i18n/locales/pl.json, ../vue-chatvideo-layer/tests/callMachine.test.ts, ../sm-demo-app/vue-demo-app/package.json, ../sm-demo-app/vue-demo-app/nuxt.config.ts, ../sm-demo-app/vue-demo-app/app/pages/call/[conversation].vue, ../sm-demo-app/vue-demo-app/app/pages/chat.vue, ../sm-demo-app/vue-demo-app/app/layouts/default.vue, ../sm-demo-app/vue-demo-app/i18n/locales/en.json, ../sm-demo-app/vue-demo-app/i18n/locales/pl.json, ../sm-demo-app/scripts/check-local.sh, .planning/phases/20-demo-instance-with-every-shared-plugin/COVERAGE.md</files>
|
||||
<read_first>../sm-demo-app/plugins/golem15/chatvideo/README.md, api/video.go, classes/video_service.go (Start, Join, Leave, End, declined path), classes/notifier.go (event names), ../vue-chat-layer/app/lib/realtime.ts (onUserEvent, subscribe) and README.md (slots), the livekit-client README and API docs for the approved version (Room, RoomEvent, Track, connect, localParticipant.setCameraEnabled and setMicrophoneEnabled, track attach and detach; through Context7 or ctx7 when available), ../sm-demo-app/vue-demo-app/{app/pages/chat.vue,app/layouts/default.vue}, .planning/phases/19.4.1-early-public-demo-on-demo-summercms-io/COVERAGE.md (matrix format), 20-RESEARCH.md (Pitfalls 8 and 9, assumption A1)</read_first>
|
||||
<action>Per D-11, D-12, D-13, D-23.
|
||||
|
||||
1. Create ../vue-chatvideo-layer with the skeleton:
|
||||
- dependencies `livekit-client@<approved>` installed with `--save-exact`, and @nuxtjs/i18n 10.4.0;
|
||||
- peerDependencies nuxt 4.4.8 and `vue-chat-layer: "^0.1.0"`; devDependency vue-chat-layer at the git+https v0.1.0 tag for the playground;
|
||||
- test script `vitest run` with vitest 3.2.7;
|
||||
- runtime `public.chatvideo.apiBase: '/_chatvideo/api/v1'`;
|
||||
- no `extends` of vue-chat-layer (the app extends both).
|
||||
2. app/lib/callMachine.ts (pure): states idle, starting, connecting, connected, ended and failed, and `transition(state, event)` for the events start, joined(token, url), connected, participantJoined, participantLeft, mute, unmute, cameraOff, cameraOn, leave, ended(reason) and error(code). Mute and camera flags live in the state. Token errors (401, 403, 404, 422 too many participants) map to failed with a reason key.
|
||||
3. app/lib/videoClient.ts: start, join, leave, end and status over `/conversations/{id}/video/...` with the `$chatAuth` token getter.
|
||||
4. app/composables/useCall.ts:
|
||||
- drives the machine; on start or join, `await import('livekit-client')`, creates a Room (adaptiveStream and dynacast on), connects with livekit_url and token, and enables camera and microphone;
|
||||
- maps RoomEvent participant and track events to tiles and attaches and detaches tracks to video elements;
|
||||
- offers mute and camera toggles through the local participant, and leave (disconnect, then POST leave);
|
||||
- subscribes to the session channel through vue-chat-layer's `subscribe` for ended and declined events, with the channel name always built by an exported helper `videoSessionChannel(id)` in app/lib/videoClient.ts, and disconnects on route leave and pagehide.
|
||||
useIncomingCalls registers `onUserEvent` for `video:call:incoming` (never its own user:{me} subscription) and exposes the pending call.
|
||||
5. Components:
|
||||
- ChatvideoCall (prop conversationId, start boolean): own camera tile, remote tiles (ChatvideoTile), mute, camera and leave buttons, the state text and failure messages from i18n;
|
||||
- ChatvideoCallButton (props conversationId and callPath(id)): starts and navigates;
|
||||
- ChatvideoIncomingCall: the caller name from the event payload, Accept (navigate to callPath) and Decline (POST leave; confirm in video_service.go that this publishes declined for a participant who never joined, else use the endpoint that does).
|
||||
No screen share (D-13).
|
||||
6. vue-demo-app:
|
||||
- pnpm link dependency; extends gains 'vue-chatvideo-layer' after 'vue-chat-layer'; goPaths '/_chatvideo';
|
||||
- app/pages/call/[conversation].vue: ClientOnly ChatvideoCall for members (start when the query has start=1), the register prompt otherwise;
|
||||
- chat.vue `<template #conversation-actions="{ conversation }">` renders ChatvideoCallButton with callPath `id => '/call/' + id + '?start=1'` (the slot only renders in DMs, D-23);
|
||||
- default.vue renders ClientOnly ChatvideoIncomingCall for members;
|
||||
- EN/PL copy (call.title, call.registerToCall).
|
||||
7. check-local.sh `check_call`:
|
||||
- with the visitor JWT, POST /_chat/api/v1/contexts/demo/1/dm to a persona gives the DM id;
|
||||
- POST /_chatvideo/api/v1/conversations/{dm}/video/start answers 2xx with livekit_url and token;
|
||||
- GET http://127.0.0.1:7890/rtc/validate?access_token=<token> answers 200 (LiveKit accepts the token, proving the keys and the room);
|
||||
- POST .../video/end answers 2xx.
|
||||
Add `call` to ALL_CHECKS.
|
||||
8. tests/callMachine.test.ts (smoke): start, joined, connected, mute, leave and ended; a 422 start ends in failed with the too-many reason.
|
||||
9. COVERAGE.md in the phase directory (format of 19.4.1's):
|
||||
- livekit-client capabilities (Room connect and disconnect, publish camera and microphone, subscribe and attach remote tracks, mute and unmute, camera toggle, participant events, connection state and reconnection, adaptiveStream and dynacast INTEGRATE; screen share, data channel, device selection, E2EE, recording and egress, simulcast tuning, audio-only mode OPT-OUT, each with a reason);
|
||||
- LiveKit server through chatvideo (room create and delete, token mint, list participants, reconcile INTEGRATE; egress, ingress, SIP and webhooks OPT-OUT);
|
||||
- Centrifugo (the chat, user and video namespaces with presence on chat INTEGRATE; history, recovery and client publish OPT-OUT);
|
||||
- Web Push through herald (VAPID keys and the public key route INTEGRATE; subscribe UI and push on chat events OPT-OUT, deferred per D-16).
|
||||
|
||||
Commit the layer, vue-demo-app (with the link pin), the scripts and COVERAGE.md (planning commit) separately.</action>
|
||||
<verify>
|
||||
<automated>pnpm --dir ../vue-chatvideo-layer test && pnpm --dir ../sm-demo-app/vue-demo-app run build && ../sm-demo-app/scripts/check-local.sh call chat</automated>
|
||||
<fails_when>Non-zero exit: the call machine tests fail, the build fails (livekit-client not resolvable or a type error), or check-local reports call failed (start refused, no token or livekit_url, LiveKit's rtc/validate not 200) or chat failed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "import('livekit-client')" ../vue-chatvideo-layer/app/composables/useCall.ts` prints 1 and `grep -rn "^import .*from 'livekit-client'" ../vue-chatvideo-layer/app | grep -vc 'import type'` prints 0 (no static runtime import).
|
||||
- `grep -rn 'subscribe(' ../vue-chatvideo-layer/app | grep -vc 'videoSessionChannel'` prints 0 (the layer's only subscriptions are session channels; incoming calls come through onUserEvent).
|
||||
- `node -p "require('../vue-chatvideo-layer/package.json').dependencies['livekit-client']"` prints the approved version with no range character.
|
||||
- `grep -c 'conversation-actions' ../sm-demo-app/vue-demo-app/app/pages/chat.vue` prints 1.
|
||||
- COVERAGE.md has a reason on every OPT-OUT row (`grep -c 'OPT-OUT | *|' .planning/phases/20-demo-instance-with-every-shared-plugin/COVERAGE.md` prints 0).
|
||||
</acceptance_criteria>
|
||||
<done>A member can start a video call from a DM, the other member gets an accept prompt on any page, and both meet on a minimal call page served by LiveKit.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: Herald's VAPID keys without UI, and the Forum, Friends and Calls tour cards</name>
|
||||
<files>../sm-demo-app/scripts/dev.sh, ../sm-demo-app/deploy/env.example, ../sm-demo-app/README.md, ../sm-demo-app/vue-demo-app/app/pages/index.vue, ../sm-demo-app/vue-demo-app/app/components/TourCard.vue, ../sm-demo-app/vue-demo-app/i18n/locales/en.json, ../sm-demo-app/vue-demo-app/i18n/locales/pl.json, ../sm-demo-app/scripts/check-local.sh</files>
|
||||
<read_first>../sm-demo-app/plugins/golem15/herald/README.md (keys, herald:vapid-generate, presence gate warning), ../sm-demo-app/scripts/dev.sh (ensure_env, build, up), ../sm-demo-app/deploy/env.example, ../sm-demo-app/vue-demo-app/app/pages/index.vue, app/components/TourCard.vue, i18n/locales/*.json (tour.*), ../sm-demo-app/scripts/check-local.sh (check_tour)</read_first>
|
||||
<action>Per D-16 and the tour part of D-03.
|
||||
- dev.sh: after build in `up` and `seed`, when .env lacks SUMMER_GOLEM15__HERALD__VAPID__PUBLIC_KEY, run `./bin/demo herald:vapid-generate --json -q`, append SUMMER_GOLEM15__HERALD__VAPID__PUBLIC_KEY and __PRIVATE_KEY from the JSON (python3 parse, umask 077, values never echoed) and SUMMER_GOLEM15__HERALD__VAPID__SUBJECT=mailto:demo@example.test. Print only "added the herald VAPID keys to .env".
|
||||
- deploy/env.example: the three herald key names with empty values and a comment (generated on rome with herald:vapid-generate; no subscribe UI in this phase, D-16). README "Chat family" section: herald is mounted without UI.
|
||||
- Tour:
|
||||
- index.vue stops in order journal, presentations, quizzes, chat, forum (/forum), friends (/friends), calls (/chat with text tour.calls.text), payments, faq, accounts;
|
||||
- TourCard icons forum, friends and calls added to the union and ICONS (outline, 24-unit grid, currentColor);
|
||||
- en and pl tour.forum, tour.friends and tour.calls titles and texts, and the heading for ten cards.
|
||||
- check-local.sh check_tour expects ten cards including Forum, Friends and Calls (EN) and their Polish titles. New `check_herald`: GET $GO/_herald/api/v1/push/vapid-public-key answers 401 without a JWT and 200 with the visitor's JWT and a non-empty publicKey (json_has); add `herald` to ALL_CHECKS and the header list.
|
||||
|
||||
Commit sm-demo-app (dev.sh, env.example, README, check-local) and vue-demo-app (tour) separately.</action>
|
||||
<verify>
|
||||
<automated>pnpm --dir ../sm-demo-app/vue-demo-app run build && ../sm-demo-app/scripts/dev.sh up && grep -q '^SUMMER_GOLEM15__HERALD__VAPID__PUBLIC_KEY=.' ../sm-demo-app/.env && ../sm-demo-app/scripts/check-local.sh herald tour</automated>
|
||||
<fails_when>Non-zero exit: the build fails, dev.sh does not write the VAPID public key into .env, or check-local reports herald failed (the public-key route is not 200 with a non-empty publicKey for the visitor, or not 401 without a JWT) or tour failed (card count or titles wrong).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'HERALD__VAPID__PRIVATE_KEY=' ../sm-demo-app/deploy/env.example` prints 1, with no value after the equals sign.
|
||||
- `grep -c "'/_herald'" ../sm-demo-app/vue-demo-app/nuxt.config.ts` prints 0.
|
||||
- `grep -cE "key: '(forum|friends|calls)'" ../sm-demo-app/vue-demo-app/app/pages/index.vue` prints 3.
|
||||
- dev.sh output contains no line with a key value (manual look at the `up` output, recorded in the SUMMARY).
|
||||
</acceptance_criteria>
|
||||
<done>Herald runs with VAPID keys and no UI, and the tour shows all ten features in EN and PL.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 6: Go-ahead to create the forum, friends and video-call layer repositories, push them and tag v0.1.0</name>
|
||||
<read_first>Claude's summary: `git log --oneline` of the three layers, their README install lines, vue-faq-layer's visibility, whether `tea login list` shows a git.golem15.com login, the livekit-client version approved in Task 3, and the results of check-local forum friends call tour chat.</read_first>
|
||||
<acceptance_criteria>
|
||||
- The user chose one option and it is recorded in the SUMMARY; with "release", the three repositories exist on git.golem15.com (`git ls-remote` exits 0 for each) before Task 7 pushes, or Task 7 creates them with tea.
|
||||
</acceptance_criteria>
|
||||
<decision>Create vue-forum-layer, vue-userfriends-layer and vue-chatvideo-layer on git.golem15.com, push main and tag v0.1.0 each?</decision>
|
||||
<context>Each layer is public with tagged releases that later sites pin (like D-01 for chat). v0.1.0 freezes their hooks, props and runtime keys. vue-chatvideo-layer's peer dependency on vue-chat-layer ^0.1.0 is part of its contract. vue-demo-app can pin them only after this. Repository creation and pushes need your go-ahead.</context>
|
||||
<options>
|
||||
<option id="release">
|
||||
<name>Create, push and tag all three</name>
|
||||
<pros>vue-demo-app pins the tags; the deploy (plan 05) can build.</pros>
|
||||
<cons>The v0.1.0 contracts are public.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold</name>
|
||||
<pros>Contracts stay open for changes.</pros>
|
||||
<cons>The deploy is blocked; vue-demo-app keeps local link pins, so it cannot be pushed.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<instructions>For "release" without a tea login: create the three repositories (empty, default branch main, vue-faq-layer's visibility) in the git.golem15.com web UI, then reply. Do not paste tokens.</instructions>
|
||||
<resume-signal>Reply "release" (add "repos ready" if you created them) or "hold".</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 7: Release the three layers v0.1.0 and pin them in vue-demo-app</name>
|
||||
<reversibility rating="one-way">Pushed layer tags are installed by consumers' lockfiles; they can be superseded but must never move.</reversibility>
|
||||
<precondition>Task 6 resolved with "release".</precondition>
|
||||
<files>../vue-forum-layer/README.md, ../vue-userfriends-layer/README.md, ../vue-chatvideo-layer/README.md, ../sm-demo-app/vue-demo-app/package.json, ../sm-demo-app/vue-demo-app/pnpm-lock.yaml</files>
|
||||
<read_first>The three READMEs, ../sm-demo-app/vue-demo-app/package.json, the Task 6 answer</read_first>
|
||||
<action>For each of the three layers:
|
||||
- create the repository with tea if missing, at vue-faq-layer's visibility;
|
||||
- add the origin `git@git.golem15.com:golem15/<layer>.git` and push main;
|
||||
- fix a README install line that says the tag does not exist before tagging;
|
||||
- create the annotated tag v0.1.0, push it and check `ls-remote` against HEAD.
|
||||
Then in vue-demo-app run `pnpm add` with the three `git+https://git.golem15.com/golem15/<layer>.git#v0.1.0` specs (replacing the link pins), run the tests and the build, and commit package.json and pnpm-lock.yaml (`feat: pin the forum, friends and video-call layers v0.1.0`). Do not push vue-demo-app.</action>
|
||||
<verify>
|
||||
<automated>for l in vue-forum-layer vue-userfriends-layer vue-chatvideo-layer; do [ "$(git -C ../$l ls-remote origin 'refs/tags/v0.1.0^{}' | cut -f1)" = "$(git -C ../$l rev-parse HEAD)" ] || exit 1; grep -q "$l.git#v0.1.0" ../sm-demo-app/vue-demo-app/package.json || exit 1; done && pnpm --dir ../sm-demo-app/vue-demo-app test && pnpm --dir ../sm-demo-app/vue-demo-app run build && ../sm-demo-app/scripts/check-local.sh forum friends call chat tour</automated>
|
||||
<fails_when>Non-zero exit: a remote tag is missing or differs from HEAD, a tag pin is missing from package.json, the vue-demo-app tests or build fail, or a check-local check fails against the tagged layers.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git -C ../sm-demo-app/vue-demo-app status --porcelain -- package.json pnpm-lock.yaml` prints nothing.
|
||||
- `node -p "Object.values(require('../sm-demo-app/vue-demo-app/package.json').dependencies).filter(v=>v.startsWith('link')).length"` prints 0.
|
||||
</acceptance_criteria>
|
||||
<done>Forum, friends and video-call layers are published at v0.1.0 and the demo site builds against all tagged layers.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| forum posts and friend names to other members' browsers | untrusted content |
|
||||
| browser to LiveKit (signal through nginx, media direct) | WebRTC with a short-lived join token |
|
||||
| realtime events to the incoming-call prompt | payloads from the server about other users |
|
||||
| npm registry to the chatvideo layer | a new third-party dependency (livekit-client) |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-20-20 | Tampering | forum stored XSS | high | mitigate | posts render only content_html, which the server sanitizes on write with breakwater (D-18 of the forum port); Markdown is previewed through the server endpoint; acceptance grep limits v-html to content_html |
|
||||
| T-20-21 | Spoofing | call joins | high | mitigate | tokens are minted by chatvideo only for conversation participants (sessionForActor) with token_ttl 900; the client never builds tokens; LiveKit room max_participants 4 (D-12) |
|
||||
| T-20-22 | Information disclosure | camera and microphone | medium | mitigate | media starts only after an explicit start or accept on the call page; leave and pagehide disconnect and stop local tracks |
|
||||
| T-20-23 | Tampering | incoming-call payloads | low | mitigate | caller names render as text bindings; Accept navigates only to the app's callPath built from the numeric conversation id |
|
||||
| T-20-24 | Denial of service | call spam | medium | mitigate | server bucket video-initiate 5/min, max_duration 900 and the reconcile job (D-12); calls only offered in DMs (D-23) |
|
||||
| T-20-25 | Information disclosure | friend enumeration | low | mitigate | the API's generic 422 for refused requests is shown as is; the directory lists demo members only (the same set the chat member picker shows) |
|
||||
| T-20-26 | Information disclosure | VAPID private key | high | mitigate | generated into .env only (umask 077, never echoed); env.example holds names; /_herald is not routed by the site's goPaths |
|
||||
| T-20-SC | Tampering | npm installs | high | mitigate | livekit-client installed only after the blocking human-verify legitimacy checkpoint (Task 3, gate blocking-human, never auto-approved) at an exact version with no install scripts; every other package is an existing workspace pin |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: forum client tests, app build, check-local forum.
|
||||
- Task 2: friends client tests, build, check-local friends forum chat.
|
||||
- Task 3: livekit-client version approved by the user.
|
||||
- Task 4: call machine tests, build, check-local call chat, COVERAGE.md complete.
|
||||
- Task 5: build, VAPID keys in .env, herald route present, check-local tour.
|
||||
- Task 6: go-ahead recorded.
|
||||
- Task 7: three remote tags equal HEAD; vue-demo-app tests, build and checks green on tags.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
The demo site shows the forum, friends and DM video calls through three new shared layers (SC-2, D-11 to D-15, D-21, D-23), herald is configured without UI (D-16), and the tour lists every feature.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/20-demo-instance-with-every-shared-plugin/20-04-SUMMARY.md` when done: the approved livekit-client version, the decline endpoint used, each layer's public API, the three tags and commits, and both checkpoint answers.
|
||||
</output>
|
||||
@@ -0,0 +1,359 @@
|
||||
---
|
||||
phase: 20-demo-instance-with-every-shared-plugin
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 5
|
||||
depends_on: ["20-02", "20-03", "20-04"]
|
||||
files_modified:
|
||||
- ../sm-demo-app/deploy/nginx/demo.summercms.io.conf
|
||||
- ../sm-demo-app/deploy/supervisor/summercms-demo-livekit.conf
|
||||
- ../sm-demo-app/deploy/livekit.yaml.example
|
||||
- ../sm-demo-app/deploy/env.example
|
||||
- ../sm-demo-app/config/env/production/golem15.chatvideo.yaml
|
||||
- ../sm-demo-app/scripts/check-deploy.sh
|
||||
- ../sm-demo-app/scripts/check-live.sh
|
||||
- ../sm-demo-app/DEPLOY.md
|
||||
- ../sm-demo-app/README.md
|
||||
- ../sm-demo-app/vue-demo-app
|
||||
autonomous: false
|
||||
requirements: [SC-2, SC-3, D-10, D-12, D-16, D-20, D-21]
|
||||
estimate:
|
||||
tokens: 240000
|
||||
raw_tokens: 240000
|
||||
tasks: 6
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-10: rome runs the demo's own LiveKit 1.13.1 under supervisor as summercms-demo-livekit (user summercms-demo, config /srv/summercms-demo/livekit.yaml, 0640 root:summercms-demo, own key pair also in /srv/summercms-demo/.env), signal on 127.0.0.1:7890 behind nginx location /livekit/, ICE TCP 7891 and UDP mux 7892 on the public address; it does not touch WavePath's LiveKit (7880, 7881, 50000-60000) or its keys."
|
||||
- "D-10: Vuurmuur on rome allows TCP 7891 and UDP 7892 from the internet to the firewall through a rule the user approved; DEPLOY.md documents the service, the rule, how to verify it and how to remove it."
|
||||
- "D-12: the deployed LiveKit has room max_participants 4, empty_timeout 120 and departure_timeout 20; production chatvideo config keeps max_duration 900 and max_participants 4; the chatvideo reconcile job runs every 5 minutes inside serve."
|
||||
- "D-16: rome's .env holds herald VAPID keys generated on rome with herald:vapid-generate; /_herald is not routed by nginx."
|
||||
- "SC-3: after the release https://demo.summercms.io serves /chat, /forum, /friends and /call/{id} with the banner and the backend call to action; /backend and /backend/x answer 404; anonymous /chat and /forum show the register prompt (D-04, D-21); the admin stays at /smbackend as decided in 19.4.1."
|
||||
- "SC-2: on the live demo the chat-family APIs answer 401 without a JWT under /_chat, /_chatchannels, /_chatbots, /_chatvideo, /_forum and /_userfriends, /livekit/rtc/validate reaches LiveKit, /ws upgrades to Centrifugo, and the Centrifugo config on rome has the chat, user and video namespaces."
|
||||
- "scripts/check-deploy.sh proves locally that nginx, supervisor, livekit.yaml.example and the production chatvideo config agree on ports (7890, 7891, 7892) and caps, that each chat-family prefix goes to the Go port and that /_herald is not routed; scripts/check-live.sh https://demo.summercms.io prints check-live: ok after the release."
|
||||
- "vue-demo-app's master is pushed before sm-demo-app's pointer bump commit; sm-demo-app is pushed with the go-ahead; robots.txt on the live site disallows the new API prefixes."
|
||||
artifacts:
|
||||
- path: "../sm-demo-app/deploy/supervisor/summercms-demo-livekit.conf"
|
||||
provides: "supervisor program summercms-demo-livekit"
|
||||
contains: "program:summercms-demo-livekit"
|
||||
- path: "../sm-demo-app/deploy/nginx/demo.summercms.io.conf"
|
||||
provides: "Go locations for the chat family and the /livekit/ WebSocket location"
|
||||
contains: "location ^~ /livekit/"
|
||||
- path: "../sm-demo-app/config/env/production/golem15.chatvideo.yaml"
|
||||
provides: "production livekit_url and livekit_admin_url"
|
||||
contains: "wss://demo.summercms.io/livekit"
|
||||
- path: "../sm-demo-app/DEPLOY.md"
|
||||
provides: "LiveKit, firewall, chat family release, verify and rollback"
|
||||
contains: "summercms-demo-livekit"
|
||||
key_links:
|
||||
- from: "../sm-demo-app/deploy/nginx/demo.summercms.io.conf"
|
||||
to: "../sm-demo-app/deploy/livekit.yaml.example"
|
||||
via: "proxy_pass http://127.0.0.1:7890/ equals LiveKit port 7890 (check-deploy compares)"
|
||||
pattern: "127\\.0\\.0\\.1:7890"
|
||||
- from: "../sm-demo-app/config/env/production/golem15.chatvideo.yaml"
|
||||
to: "../sm-demo-app/deploy/supervisor/summercms-demo-livekit.conf"
|
||||
via: "livekit_admin_url http://127.0.0.1:7890 is the program's signal port"
|
||||
pattern: "livekit_admin_url"
|
||||
- from: "../sm-demo-app/scripts/check-live.sh"
|
||||
to: "../sm-demo-app/deploy/nginx/demo.summercms.io.conf"
|
||||
via: "live 401s under the chat-family prefixes and LiveKit's answer on /livekit/rtc/validate"
|
||||
pattern: "/livekit/rtc/validate"
|
||||
prohibitions:
|
||||
- statement: "No change to WavePath's LiveKit program, its config /etc/livekit/livekit.yaml or its ports, and no second key on its server."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No secret value (LiveKit, VAPID, Centrifugo) appears in a committed file, the SUMMARY, a log line or a command line; values are generated on rome."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No push, rome change or firewall change happens before its checkpoint is answered; raw iptables is never used (Vuurmuur manages rome's INPUT chain)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented here): the demo is deployed publicly with the chat family, the frontend open, `/backend` unreachable and the backend call to action shown.
|
||||
|
||||
This plan's slice (plan 5 of 6, wave 5, per D-20): the rome deploy of everything plans 01 to 04 built. That means the demo's own LiveKit under supervisor, the Vuurmuur rule for its media ports (a checkpoint, shared production host), nginx locations, `.env` keys (LiveKit, VAPID), the Centrifugo namespaces, the release, DEPLOY.md, and the check-deploy and check-live extensions.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion, recorded)
|
||||
|
||||
- **Media path (D-10 discretion):** ICE over the UDP mux port 7892 with ICE-TCP 7891 as fallback, and no embedded TURN. nginx `stream` with `ssl_preread` would restructure every 443 vhost on a shared host (RESEARCH Alternatives). If NAT'd browsers fail in plan 06's live check, the fallback recorded in DEPLOY.md is LiveKit's embedded TURN on UDP 3478 and TLS 5349 with a DNS-only host name, which needs a new checkpoint.
|
||||
- **Signal path:** same origin under `/livekit` (`livekit_url: wss://demo.summercms.io/livekit`). livekit-client appends `/rtc`, so nginx `location ^~ /livekit/` proxies to `http://127.0.0.1:7890/` with the WebSocket upgrade headers, `proxy_read_timeout 7d` and `proxy_buffering off`, as lk.wavepath.org does. Go talks to the RoomService on loopback (`livekit_admin_url: http://127.0.0.1:7890`).
|
||||
- **ICE reveals rome's public address** despite Cloudflare. This is accepted, because rome already exposes services on that address (RESEARCH Security Domain). DEPLOY.md says so.
|
||||
- **Admin stays at /smbackend** (19.4.1's decision change of 2026-10-20, RESEARCH Open Question 1). `/backend` stays 404. The deploy checkpoint restates it.
|
||||
- **Robots:** the new prefixes go into SEO › robots.txt on rome (the DEPLOY.md "SEO" procedure). `/_herald` stays unrouted and so needs no line.
|
||||
- spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip).
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Supervisor program `summercms-demo-livekit` (`deploy/supervisor/summercms-demo-livekit.conf`); rome files `/srv/summercms-demo/livekit.yaml` and `/etc/supervisor/conf.d/summercms-demo-livekit.conf`.
|
||||
- nginx locations `^~ /_chat/`, `^~ /_chatchannels/`, `^~ /_chatbots/`, `^~ /_chatvideo/`, `^~ /_forum/`, `^~ /_userfriends/` (Go, 127.0.0.1:8098) and `^~ /livekit/` (LiveKit, 127.0.0.1:7890).
|
||||
- `config/env/production/golem15.chatvideo.yaml` (`livekit_url`, `livekit_admin_url`).
|
||||
- env keys on rome: `SUMMER_GOLEM15__CHATVIDEO__LIVEKIT_API_KEY`, `SUMMER_GOLEM15__CHATVIDEO__LIVEKIT_API_SECRET`, `SUMMER_GOLEM15__HERALD__VAPID__PUBLIC_KEY`, `__PRIVATE_KEY`, `__SUBJECT` (names only in git).
|
||||
- Vuurmuur service `summercms-demo-livekit` and its rule (rome).
|
||||
- check-deploy.sh LiveKit and chat-family checks; check-live.sh chat-family checks; DEPLOY.md sections "LiveKit (the demo's own)", "Firewall (Vuurmuur)", "Chat family"; the sm-demo-app pointer bump of vue-demo-app.
|
||||
|
||||
<objective>
|
||||
Deploy the chat family to the live demo on rome: LiveKit, firewall, nginx, keys, Centrifugo namespaces and the release, with the checks and the runbook to keep it repeatable.
|
||||
|
||||
Purpose: SC-3 (deployed publicly, /backend unreachable, call to action) and the live half of SC-2, D-10, D-12 and D-16 on rome.
|
||||
Output: commits in ../sm-demo-app, pushes of vue-demo-app and sm-demo-app, and changes on rome, all after the go-ahead.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-CONTEXT.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-RESEARCH.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-02-SUMMARY.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-04-SUMMARY.md
|
||||
|
||||
Repo: ../sm-demo-app (deploy files, scripts, DEPLOY.md), its submodule vue-demo-app (push only). Host: rome over `ssh -p 2222 debian@10.8.3.118` (passwordless sudo; the `rome` alias does not resolve here), with the variables `$RSH` and `$ROME` used in DEPLOY.md. Read-only on rome: WavePath's `/etc/livekit/livekit.yaml` and `wavepath-livekit` program, `/etc/vuurmuur/services`, `/etc/vuurmuur/rules/rules.conf`.
|
||||
|
||||
Rules: no co-author tags; stage by explicit path; push vue-demo-app first, then commit and push the pointer bump in sm-demo-app (core-plugins-own-repos note); secrets are generated on rome with `openssl rand` or `herald:vapid-generate` and written by remote commands that never echo values; follow DEPLOY.md's Build, Upload and Release sections; nothing changes on rome and nothing is pushed before Task 3's answer; the firewall changes only after Task 5's answer.
|
||||
|
||||
<interfaces>
|
||||
sm-demo-app deploy today: deploy/nginx/demo.summercms.io.conf (one `location ^~ /_<prefix>/` per Go prefix with proxy_pass http://127.0.0.1:8098, `location = /ws` to Centrifugo 8010 with Upgrade headers, `location ^~ /backend { return 404; }`, `/smbackend` to Go), deploy/supervisor/summercms-demo-centrifugo.conf (program shape), deploy/centrifugo.json.example (namespaces from plan 02), deploy/livekit.yaml.example (plan 02), scripts/check-deploy.sh (nginx -t in a temp prefix, supervisor parse, port agreement, prints "check-deploy: ok"), scripts/check-live.sh (bad, get, header, ws_upgrade helpers; prints "check-live: ok"), DEPLOY.md (Overview, Launch checklist, One-time server setup, Realtime, MCP endpoint, SEO, Build, Upload, Release, Verify, Rollback, Reset, Secrets).
|
||||
RESEARCH facts: rome LiveKit 1.13.1 at /usr/local/bin/livekit-server; WavePath's program uses 7880, 7881/tcp and 50000-60000/udp; ports 7890-7892 were free on 2026-10-28 (re-check with `ss -ltnup`); rome's INPUT policy is DROP under Vuurmuur (vrmr NFLOG prefixes); a Vuurmuur service Livekit exists but no rule uses it.
|
||||
LiveKit validate endpoint: GET /rtc/validate answers 401 without a token and 200 with a valid one.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: the deploy files carry one chat-family release end to end, proven locally by check-deploy before rome is touched</name>
|
||||
<precondition>The plan 04 SUMMARY records the three layer tags pinned in vue-demo-app; `git -C ../sm-demo-app status --porcelain` and `git -C ../sm-demo-app/vue-demo-app status --porcelain` list no foreign edit in the files of this task.</precondition>
|
||||
<files>../sm-demo-app/deploy/nginx/demo.summercms.io.conf, ../sm-demo-app/deploy/supervisor/summercms-demo-livekit.conf, ../sm-demo-app/deploy/livekit.yaml.example, ../sm-demo-app/deploy/env.example, ../sm-demo-app/config/env/production/golem15.chatvideo.yaml, ../sm-demo-app/scripts/check-deploy.sh, ../sm-demo-app/scripts/check-live.sh, ../sm-demo-app/DEPLOY.md, ../sm-demo-app/README.md</files>
|
||||
<read_first>../sm-demo-app/deploy/nginx/demo.summercms.io.conf (whole file), deploy/supervisor/summercms-demo-centrifugo.conf, deploy/livekit.yaml.example, deploy/env.example, config/env/production/*.yaml, scripts/check-deploy.sh (whole file), scripts/check-live.sh (whole file), DEPLOY.md (whole file), 20-RESEARCH.md (Pitfalls 6, 7, 8, 11; Supervisor program and LiveKit config blocks)</read_first>
|
||||
<action>Per D-10, D-12, D-16, SC-2, SC-3.
|
||||
- nginx: add `location ^~ /_chat/`, `/_chatchannels/`, `/_chatbots/`, `/_chatvideo/`, `/_forum/` and `/_userfriends/` with the exact /_demo/ block, and `client_max_body_size 2m` on /_chat/ for attachments (chat's 1 MiB upload limit plus multipart overhead). Add `location ^~ /livekit/` with proxy_pass `http://127.0.0.1:7890/`, `proxy_http_version 1.1`, the Upgrade and Connection headers, Host and X-Forwarded headers, `proxy_read_timeout 7d`, `proxy_send_timeout 7d` and `proxy_buffering off`, and a comment that media bypasses nginx. Update the `/api/realtime/token` comment: every demo member now gets a token (demo.realtime-access). Add nothing for /_herald.
|
||||
- deploy/supervisor/summercms-demo-livekit.conf: RESEARCH's program block, with a header comment in the Centrifugo file's style (ports, config path and mode, binary, that WavePath's instance is separate).
|
||||
- deploy/livekit.yaml.example: confirm or adjust the plan 02 file so production uses `use_external_ip: true`, and comment the rome install command.
|
||||
- config/env/production/golem15.chatvideo.yaml: `livekit_url: "wss://demo.summercms.io/livekit"`, `livekit_admin_url: "http://127.0.0.1:7890"`.
|
||||
- deploy/env.example: the herald subject key with its comment, if plan 04 left it out.
|
||||
- check-deploy.sh, new checks with header lines:
|
||||
- summercms-demo-livekit.conf parses and declares its program with the user summercms-demo;
|
||||
- LiveKit ports agree: the example's `port` equals the nginx /livekit/ proxy_pass port and the production livekit_admin_url port; `rtc.tcp_port` 7891 and `rtc.udp_port` 7892 are set, no port_range key is set, and bind_addresses is 127.0.0.1;
|
||||
- room.max_participants equals config/golem15.chatvideo.yaml max_participants (D-12, D-23);
|
||||
- each of the six chat-family prefixes is a location proxied to the Go port;
|
||||
- no location routes /_herald;
|
||||
- the example holds placeholder keys only.
|
||||
Parse YAML with python3 as the script already does for JSON, and fail with one line per mismatch.
|
||||
- check-live.sh, new checks with header lines:
|
||||
- /chat, /forum and /friends answer 200 with the banner and a register link (D-04, D-21);
|
||||
- GET /_chatchannels/api/v1/contexts/demo/1/channels, /_forum/api/v1/contexts/demo/1/categories, /_userfriends/api/v1/friends, /_chatbots/api/v1/commands and /_chatvideo/api/v1/conversations/1/video/status answer 401;
|
||||
- GET /livekit/rtc/validate answers 401 (or 400) from LiveKit, not Nuxt's HTML 404 (the body is not text/html);
|
||||
- GET /_herald/api/v1/push/vapid-public-key does not answer JSON from Go;
|
||||
- robots.txt holds `Disallow: /_chat` and `Disallow: /_forum`.
|
||||
- DEPLOY.md:
|
||||
- Overview: ports 7890-7892, the new program;
|
||||
- One-time server setup "LiveKit (the demo's own)": install the config with generated keys, the supervisor program, verify with curl on 127.0.0.1:7890;
|
||||
- "Firewall (Vuurmuur)": the service definition (TCP 7891, UDP 7892), the rule from any to the firewall, `vuurmuur_script` apply and reload, verification with `ss -lunp` and `iptables -S | grep 789`, and removal;
|
||||
- "Chat family": the .env keys, the Centrifugo namespaces and when to restart Centrifugo, the VAPID keys, the robots.txt lines to add;
|
||||
- Verify: the new check-live items;
|
||||
- Rollback: stopping summercms-demo-livekit turns calls off only;
|
||||
- Reset: the chat family is included.
|
||||
README links to the new sections.
|
||||
|
||||
Commit `feat: deploy files for the chat family and the demo's LiveKit`.</action>
|
||||
<verify>
|
||||
<automated>../sm-demo-app/scripts/check-deploy.sh 2>&1 | tail -1 | grep -qx 'check-deploy: ok' && bash -n ../sm-demo-app/scripts/check-live.sh</automated>
|
||||
<fails_when>The last line is not "check-deploy: ok" (nginx -t fails with the new locations, a port or cap mismatch, a missing prefix, a routed /_herald, or a supervisor parse error), or check-live.sh has a syntax error.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'location ^~ /livekit/' ../sm-demo-app/deploy/nginx/demo.summercms.io.conf` prints 1.
|
||||
- `grep -cE 'location \^~ /_(chat|chatchannels|chatbots|chatvideo|forum|userfriends)/' ../sm-demo-app/deploy/nginx/demo.summercms.io.conf` prints 6.
|
||||
- `grep -c '\[program:summercms-demo-livekit\]' ../sm-demo-app/deploy/supervisor/summercms-demo-livekit.conf` prints 1.
|
||||
- `grep -c 'Vuurmuur' ../sm-demo-app/DEPLOY.md` prints at least 2.
|
||||
</acceptance_criteria>
|
||||
<done>The repository holds every file the rome release needs, and the local check proves they agree.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Read rome's current state and prepare the release build without changing rome</name>
|
||||
<files>../sm-demo-app/DEPLOY.md</files>
|
||||
<read_first>../sm-demo-app/DEPLOY.md (Build, Upload, Release, Secrets), ../sm-demo-app/scripts/build.sh, .planning/phases/19.4.1-early-public-demo-on-demo-summercms-io/19.4.1-02-SUMMARY.md (first deploy notes)</read_first>
|
||||
<action>Per D-10 and SC-3. Read-only on rome:
|
||||
- `ss -ltnup` filtered on 7890, 7891 and 7892 (free?);
|
||||
- `supervisorctl status`;
|
||||
- `grep -c` for each new key name in /srv/summercms-demo/.env (counts only, never values);
|
||||
- whether /srv/summercms-demo/centrifugo.json lists the chat namespace (`grep -c '"chat"'`);
|
||||
- `ls /etc/vuurmuur/services` and `grep -n Livekit /etc/vuurmuur/rules/rules.conf` (no edit);
|
||||
- `/usr/local/bin/livekit-server --version`.
|
||||
Locally:
|
||||
- `scripts/check-pins.sh`, `scripts/build.sh release` (refuses a dirty tree or off-tag submodules) and `scripts/smoke.sh` against the release build as DEPLOY.md's Build section says;
|
||||
- `git -C vue-demo-app status` (the commits from plans 03 and 04 are local only) and `git log origin/master..master` in both repositories.
|
||||
Record everything in a deploy table for the Task 3 question. If a port is taken, pick the next free trio and update the deploy files and check-deploy in a commit before asking. If DEPLOY.md's Build steps no longer match the scripts, fix them in DEPLOY.md.</action>
|
||||
<verify>
|
||||
<automated>../sm-demo-app/scripts/check-pins.sh && ../sm-demo-app/scripts/build.sh release && ../sm-demo-app/scripts/smoke.sh</automated>
|
||||
<fails_when>Non-zero exit: a submodule off its go.mod tag, the release build refuses (dirty tree, stale ROADMAP snapshot) or fails to compile without go.work, or smoke.sh reports a missing banner, an admin path or a credential on a rendered page.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The SUMMARY's deploy table lists rome's listeners on 7890-7892 (or the chosen trio), the supervisor programs, which env keys exist (counts only), whether centrifugo.json has the chat namespace, the LiveKit version and the unpushed commits per repository.
|
||||
- No file on rome changed (no sudo write command was run).
|
||||
</acceptance_criteria>
|
||||
<done>The release build is ready and the go-ahead question rests on rome's actual state.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 3: Go-ahead to push the demo and deploy the chat family to rome</name>
|
||||
<read_first>The Task 2 deploy table; ../sm-demo-app/DEPLOY.md sections "LiveKit (the demo's own)", "Chat family" and "Release"</read_first>
|
||||
<acceptance_criteria>
|
||||
- The user chose one option and it is recorded in the SUMMARY.
|
||||
</acceptance_criteria>
|
||||
<decision>Push vue-demo-app and sm-demo-app and release the chat family on rome now (LiveKit program, keys, nginx, Centrifugo namespaces, binary and site)?</decision>
|
||||
<context>The release changes a shared production host: a new supervisor program (summercms-demo-livekit on 127.0.0.1:7890), new .env keys generated on rome (LiveKit and VAPID), an nginx reload for the demo vhost, a Centrifugo restart for the demo's own instance, a migration of the chat-family tables and demo:seed. WavePath's LiveKit and keys are not touched. The admin stays at /smbackend with login (19.4.1's decision) and /backend stays 404. Media ports stay closed until the separate firewall question (Task 5), so calls connect only after that. Rollback is in DEPLOY.md (previous binary and site; stop the LiveKit program).</context>
|
||||
<options>
|
||||
<option id="deploy">
|
||||
<name>Push and deploy</name>
|
||||
<pros>Chat, forum and friends go live; calls follow after the firewall rule.</pros>
|
||||
<cons>Changes rome; the new tables are migrated (not reversed on rollback).</cons>
|
||||
</option>
|
||||
<option id="push-only">
|
||||
<name>Push the repositories, deploy later</name>
|
||||
<pros>Code is on origin and reviewed; rome is unchanged.</pros>
|
||||
<cons>SC-3 stays open; plan 06's live verification waits.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold</name>
|
||||
<pros>Nothing leaves this machine.</pros>
|
||||
<cons>The phase cannot verify live.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "deploy", "push-only" or "hold".</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Release: push, install LiveKit and keys on rome, roll out the binary and site, and check the live demo</name>
|
||||
<precondition>Task 3 answered "deploy" (or "push-only", which runs steps 1 and 2 only).</precondition>
|
||||
<files>../sm-demo-app/vue-demo-app, ../sm-demo-app/DEPLOY.md</files>
|
||||
<read_first>../sm-demo-app/DEPLOY.md (whole runbook), the Task 2 deploy table</read_first>
|
||||
<action>Per D-10, D-12, D-16, SC-2, SC-3.
|
||||
1. Push vue-demo-app master, then in sm-demo-app commit the pointer bump `chore: bump vue-demo-app (chat, forum, friends, calls)` and push sm-demo-app.
|
||||
2. Confirm both remotes equal local HEAD (`git ls-remote`).
|
||||
3. On rome, following DEPLOY.md. Never echo or log a value; write secrets with remote heredocs fed from `openssl rand` inside the remote shell.
|
||||
- Generate a LiveKit key and secret.
|
||||
- Install /srv/summercms-demo/livekit.yaml (0640 root:summercms-demo) from the example with them, and append the same values to .env as the SUMMER_GOLEM15__CHATVIDEO__ keys.
|
||||
- Run `herald:vapid-generate --json -q` with the new binary on rome and append the VAPID keys. Set the subject to the demo's contact address from ~/.config/summercms/demo-secrets.env, sent over SSH stdin.
|
||||
- Update /srv/summercms-demo/centrifugo.json: merge the chat, user and video namespaces from the example while keeping its secrets, run `centrifugo checkconfig`, and restart summercms-demo-centrifugo.
|
||||
- Install and start summercms-demo-livekit (reread, update, status RUNNING) and check that `curl -s http://127.0.0.1:7890/` answers OK on rome.
|
||||
- Upload and release the build per DEPLOY.md (migrate, demo:seed, swap, restart), then install the nginx file and reload.
|
||||
- Add the robots.txt lines for the six prefixes through SEO › robots.txt (DEPLOY.md "SEO"; through an SSH tunnel to the admin with the credentials in /root/summercms-demo-admin.txt, or the seo MCP tools with the administrator's connector). If neither path works unattended, list it for the user in Task 6.
|
||||
4. Run `scripts/check-live.sh https://demo.summercms.io`. The /livekit/rtc/validate check passes without the firewall rule, because signalling goes through nginx.
|
||||
5. Record in DEPLOY.md any step that differed from the runbook.</action>
|
||||
<verify>
|
||||
<automated>[ "$(git -C ../sm-demo-app ls-remote origin refs/heads/master | cut -f1)" = "$(git -C ../sm-demo-app rev-parse HEAD)" ] && ../sm-demo-app/scripts/check-live.sh https://demo.summercms.io 2>&1 | tail -1 | grep -qx 'check-live: ok'</automated>
|
||||
<fails_when>sm-demo-app's remote master differs from HEAD (not pushed), or check-live's last line is not "check-live: ok" (a chat-family prefix not 401, /livekit not reaching LiveKit, /backend not 404, missing banner or call to action, robots lines missing, or /ws not 101).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `ssh -p 2222 debian@10.8.3.118 sudo supervisorctl status summercms-demo-livekit` shows RUNNING.
|
||||
- `ssh -p 2222 debian@10.8.3.118 'sudo grep -c "^SUMMER_GOLEM15__CHATVIDEO__LIVEKIT_API_SECRET=." /srv/summercms-demo/.env'` prints 1 (count only).
|
||||
- `ssh -p 2222 debian@10.8.3.118 sudo supervisorctl status wavepath-livekit` shows the same uptime it showed in Task 2 (not restarted).
|
||||
- The SUMMARY holds no secret value.
|
||||
</acceptance_criteria>
|
||||
<done>The chat family is live on demo.summercms.io: chat, forum, friends and the call signalling path answer, with WavePath's services untouched.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 5: Go-ahead for the Vuurmuur rule that opens LiveKit's media ports on rome</name>
|
||||
<read_first>DEPLOY.md "Firewall (Vuurmuur)"; the read-only output Claude prints: the proposed service file content (TCP 7891, UDP 7892), the proposed rule line, the current rules.conf lines around the Livekit service, and `iptables -S | grep -c vrmr` before the change</read_first>
|
||||
<acceptance_criteria>
|
||||
- The user chose one option and it is recorded in the SUMMARY.
|
||||
</acceptance_criteria>
|
||||
<decision>Open TCP 7891 and UDP 7892 on rome for the demo's LiveKit through Vuurmuur?</decision>
|
||||
<context>rome's INPUT chain is managed by Vuurmuur with policy DROP; raw iptables rules would be overwritten. Without this rule calls signal but never connect media. The rule only admits traffic to the demo's LiveKit ports (the program binds media on the public address; signalling stays on loopback behind nginx). It is reversible: removing the rule and reloading Vuurmuur closes the ports. WavePath's own Livekit service definition stays unused and unchanged.</context>
|
||||
<options>
|
||||
<option id="claude-applies">
|
||||
<name>Claude applies the rule</name>
|
||||
<pros>Done now, with the verification commands from DEPLOY.md.</pros>
|
||||
<cons>A change to the shared host's firewall made by the agent.</cons>
|
||||
</option>
|
||||
<option id="user-applies">
|
||||
<name>I apply it myself</name>
|
||||
<pros>You make the firewall change on the shared host.</pros>
|
||||
<cons>Calls stay disconnected until you reply that it is done.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold</name>
|
||||
<pros>The firewall stays as it is.</pros>
|
||||
<cons>Video calls do not connect on the live demo (D-10 open); plan 06's call check is skipped.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<instructions>For "user-applies": follow DEPLOY.md "Firewall (Vuurmuur)" and reply "firewall done".</instructions>
|
||||
<resume-signal>Reply "claude-applies", "firewall done" or "hold".</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 6: Apply and verify the media ports, and hand the live demo to plan 06</name>
|
||||
<precondition>Task 5 answered "claude-applies" or "firewall done".</precondition>
|
||||
<files>../sm-demo-app/DEPLOY.md</files>
|
||||
<read_first>DEPLOY.md "Firewall (Vuurmuur)", the Task 5 answer</read_first>
|
||||
<action>Per D-10.
|
||||
- For "claude-applies": on rome, add the service file /etc/vuurmuur/services/summercms-demo-livekit (TCP 7891, UDP 7892) and the rule line from DEPLOY.md, apply with Vuurmuur's own tooling (`vuurmuur_script` apply and reload, never raw iptables), and back up rules.conf first to rules.conf.bak-<date>.
|
||||
- Either way, verify:
|
||||
- `sudo iptables -S | grep -E '7891|7892'` shows ACCEPT rules with the vrmr prefix;
|
||||
- `ss -lunp | grep 7892` shows livekit-server;
|
||||
- from this machine, `nc -zv -w 3 <rome public address> 7891` connects (UDP is verified by the live call in plan 06).
|
||||
- Note in DEPLOY.md the date applied and the backup path.
|
||||
- If robots.txt could not be updated unattended in Task 4, list the exact lines for the user in the SUMMARY.
|
||||
Commit any DEPLOY.md change (`docs: record the LiveKit firewall rule`), then push sm-demo-app.</action>
|
||||
<verify>
|
||||
<automated>ssh -p 2222 debian@10.8.3.118 "sudo iptables -S | grep -cE 'dport (7891|7892)'" | grep -qvx 0 && nc -zv -w 3 54.36.173.176 7891</automated>
|
||||
<fails_when>The iptables count is 0 (Vuurmuur did not apply the rule), or the TCP connect to 7891 on the public address times out or is refused.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `ssh -p 2222 debian@10.8.3.118 test -f /etc/vuurmuur/services/summercms-demo-livekit` exits 0.
|
||||
- `../sm-demo-app/scripts/check-live.sh https://demo.summercms.io` still prints `check-live: ok`.
|
||||
- The SUMMARY states whether robots.txt was updated and lists any item left for the user.
|
||||
</acceptance_criteria>
|
||||
<done>LiveKit's media ports are reachable from the internet through Vuurmuur and the live demo passes its checks.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| internet to nginx (Cloudflare) on demo.summercms.io | untrusted HTTP and WebSocket, now including /livekit/ and six API prefixes |
|
||||
| internet to rome's public address on 7891/tcp and 7892/udp | WebRTC media straight to LiveKit, bypassing Cloudflare |
|
||||
| build machine to rome over SSH | secrets generated on rome, never transferred |
|
||||
| demo services to WavePath services on the same host | must stay isolated |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-20-27 | Elevation of privilege | LiveKit co-tenancy with WavePath | high | mitigate | own instance, own key pair, own ports and config file (D-10); no key added to /etc/livekit/livekit.yaml; Task 4 asserts wavepath-livekit was not restarted |
|
||||
| T-20-28 | Information disclosure | LiveKit, VAPID and Centrifugo secrets | high | mitigate | generated on rome inside the remote shell, written to 0640 root:summercms-demo files, never echoed; repository files hold names and placeholders; check-deploy fails on non-placeholder keys in the example |
|
||||
| T-20-29 | Denial of service | open media ports | medium | mitigate | only TCP 7891 and UDP 7892 via a Vuurmuur rule the user approved; room max_participants 4, empty_timeout 120, departure_timeout 20, max_duration 900 with reconcile (D-12) |
|
||||
| T-20-30 | Information disclosure | origin IP through ICE candidates | low | accept | rome already exposes services on its public address; recorded in DEPLOY.md |
|
||||
| T-20-31 | Elevation of privilege | unintended Go routes | medium | mitigate | nginx allow-list gains exactly six prefixes and /livekit/; /_herald stays unrouted; check-deploy asserts both; /backend 404 unchanged |
|
||||
| T-20-32 | Tampering | firewall drift | medium | mitigate | changes only through Vuurmuur with a dated backup of rules.conf; DEPLOY.md documents removal |
|
||||
| T-20-33 | Denial of service | release breaking the live demo | medium | mitigate | release build and smoke before the go-ahead (Task 2); DEPLOY.md rollback keeps bin/demo.prev and site/.output.prev; check-live after release |
|
||||
| T-20-34 | Spoofing | signalling WebSocket | low | mitigate | LiveKit validates every join token (HS256, token_ttl 900) minted only for conversation participants by chatvideo |
|
||||
| T-20-SC | Tampering | npm/pip/cargo installs | high | mitigate | no new package: the release installs vue-demo-app's frozen lockfile (layers pinned by tag, livekit-client approved in plan 04) and Go modules by tag with go.sum |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: check-deploy ok with the new locations, program and port agreement.
|
||||
- Task 2: check-pins, release build and smoke; rome read-only table.
|
||||
- Task 3: go-ahead recorded.
|
||||
- Task 4: remotes pushed; check-live ok on https://demo.summercms.io; LiveKit running; WavePath untouched.
|
||||
- Task 5: firewall decision recorded.
|
||||
- Task 6: iptables shows the Vuurmuur rule; TCP 7891 reachable; check-live still ok.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
The chat family runs on the public demo with its own LiveKit and open media ports, /backend refused and the call to action shown (SC-3), with a runbook and checks that make the next release routine.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/20-demo-instance-with-every-shared-plugin/20-05-SUMMARY.md` when done: the deploy table (no secret values), ports used, the commits pushed, what changed on rome, both checkpoint answers, the robots.txt state, and anything left for the user.
|
||||
</output>
|
||||
@@ -0,0 +1,447 @@
|
||||
---
|
||||
phase: 20-demo-instance-with-every-shared-plugin
|
||||
plan: 06
|
||||
type: execute
|
||||
wave: 6
|
||||
depends_on: ["20-01", "20-02", "20-03", "20-04", "20-05"]
|
||||
files_modified:
|
||||
- ../sm-moderation-plugin/classes/filter_test.go
|
||||
- ../sm-moderation-plugin/models/settings_test.go
|
||||
- ../sm-moderation-plugin/plugin_test.go
|
||||
- ../sm-chat-plugin/saving_test.go
|
||||
- ../sm-forum-plugin/saving_test.go
|
||||
- ../sm-demo-app/moderation_test.go
|
||||
- ../sm-demo-app/chat_test.go
|
||||
- ../sm-demo-app/video_test.go
|
||||
- ../sm-demo-app/seed_chat_test.go
|
||||
- ../sm-demo-app/reset_test.go
|
||||
- ../sm-demo-app/realtime_test.go
|
||||
- ../sm-demo-app/boot_test.go
|
||||
- ../sm-demo-app/plugins/demo/classes/chat_context_test.go
|
||||
- ../sm-demo-app/plugins/demo/chatbots_test.go
|
||||
- ../sm-demo-app/scripts/check-chat.cjs
|
||||
- ../sm-demo-app/scripts/check-local.sh
|
||||
- ../sm-demo-app/scripts/check-all.sh
|
||||
- ../sm-demo-app/README.md
|
||||
- ../vue-chat-layer/vitest.config.ts
|
||||
- ../vue-chat-layer/app/lib/chatEvents.test.ts
|
||||
- ../vue-chat-layer/app/lib/chatFormat.test.ts
|
||||
- ../vue-chat-layer/app/lib/chatText.test.ts
|
||||
- ../vue-chat-layer/app/lib/chatUnread.test.ts
|
||||
- ../vue-chat-layer/app/lib/realtime.test.ts
|
||||
- ../vue-chat-layer/app/lib/linkEmbeds.test.ts
|
||||
- ../vue-chat-layer/tests/client.test.ts
|
||||
- ../vue-chat-layer/tests/store.test.ts
|
||||
- ../vue-chatvideo-layer/tests/callMachine.test.ts
|
||||
- ../vue-chatvideo-layer/tests/videoClient.test.ts
|
||||
- ../vue-chatvideo-layer/tests/useCall.test.ts
|
||||
- ../vue-forum-layer/tests/client.test.ts
|
||||
- ../vue-userfriends-layer/tests/client.test.ts
|
||||
- ../sm-demo-app/vue-demo-app/app/utils/communityAuth.ts
|
||||
- ../sm-demo-app/vue-demo-app/app/utils/communityAuth.test.ts
|
||||
- ../sm-demo-app/vue-demo-app/app/plugins/auth.ts
|
||||
- ../sm-demo-app/vue-demo-app/app/lib/realtime.test.ts
|
||||
autonomous: false
|
||||
requirements: [SC-1, SC-2, SC-3, SC-4, D-01, D-02, D-04, D-05, D-06, D-07, D-08, D-09, D-11, D-12, D-13, D-14, D-15, D-17, D-18, D-20, D-21, D-22, D-23]
|
||||
estimate:
|
||||
tokens: 360000
|
||||
raw_tokens: 360000
|
||||
tasks: 8
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "SC-4: every behaviour row of 20-VALIDATION.md has a passing automated test under the name it lists, in the repository it lists; the new code of plans 01 to 05 is covered by unit tests in this last plan (project rule)."
|
||||
- "D-18: TestMessageSaving* (sm-chat-plugin) and TestPostSaving* (sm-forum-plugin) pin: no listener gives byte-identical responses and stored rows; a rejecting listener on create, reply, ReplyTo and edit (chat) and thread, reply and edit (forum) gives 422 under the Rejection's field, writes and changes nothing, fires no post-save event and publishes nothing; a non-Rejection listener error keeps the 500 path; the existing php_method_map and golden tests pass unedited."
|
||||
- "D-17: TestMatch*, TestParseList and TestFilterCheck* (sm-moderation-plugin) pin case-insensitive word and phrase matching with Unicode letter boundaries (Polish letters, digits, underscores), host, subdomain, www and scheme-less link matching, URL-prefix entries, the disabled switch, the recompile-on-change cache, the list limits in BeforeValidate and the locale fallback of the message; TestDemoModeration pins the demo wiring end to end through HTTP."
|
||||
- "D-06, D-07, D-08, D-22: TestDemoChatContext*, TestDemoChannelAdapter, TestDemoChannelsSeededOnly, TestDemoChatBootstrap, TestDemoChatMembers, TestDemoDMs and TestDemoModeratorDelete pin membership (banned and deleted users excluded with live membership), the channel alias, refused channel creation, the bootstrap and members JSON shapes, DMs between any members, and moderator deletion limited to channel conversations."
|
||||
- "D-11, D-12, D-23: TestDemoVideoStart pins that a DM call starts with a token and livekit_url against a fake RoomService and that the server does not refuse by conversation type (calls are limited to DMs by the UI); the vue-chat-layer tests pin that the conversation-actions slot renders only for DMs."
|
||||
- "D-05: TestDemoCommands pins the catalog entries demo and tour and their ephemeral dispatch; the layer tests pin the command picker's dispatch path."
|
||||
- "D-09: TestSeedChat pins EN and PL channels, threads, reactions, pins, forum categories and posts, friendships and the group, and idempotency; TestResetKeepsPlans pins that every chat-family table is emptied and reseeded and that moderation settings survive."
|
||||
- "D-01, D-02, D-04: bm's chatEvents, chatFormat, chatText, chatUnread and realtime tests run under vitest in vue-chat-layer with only import paths changed, next to new tests for the multi-channel store, threads, pins, link embeds, the client's refresh-and-retry and the anonymous no-bootstrap path."
|
||||
- "D-13, D-14, D-15, D-21: the chatvideo machine and useCall (mocked livekit-client Room) tests, and the forum and friends client tests pass under their layers' test scripts."
|
||||
- "SC-2: locally, scripts/check-local.sh chatlive callmedia passes: two browsers see each other's channel message live, and a DM call shows each side a playing remote video with fake media through the local LiveKit."
|
||||
- "SC-2, SC-3: scripts/check-all.sh (including check-live against https://demo.summercms.io) prints check-all: ok, and the user confirms a two-browser chat and a DM call on the live demo."
|
||||
artifacts:
|
||||
- path: "../sm-moderation-plugin/classes/filter_test.go"
|
||||
provides: "TestMatch*, TestParseList, TestFilterCheck*"
|
||||
contains: "TestMatch"
|
||||
- path: "../sm-chat-plugin/saving_test.go"
|
||||
provides: "TestMessageSaving*"
|
||||
contains: "TestMessageSaving"
|
||||
- path: "../sm-forum-plugin/saving_test.go"
|
||||
provides: "TestPostSaving*"
|
||||
contains: "TestPostSaving"
|
||||
- path: "../sm-demo-app/chat_test.go"
|
||||
provides: "TestDemoChat*, TestDemoChannelsSeededOnly, TestDemoDMs, TestDemoModeratorDelete, TestDemoCommands"
|
||||
contains: "TestDemoChannelsSeededOnly"
|
||||
- path: "../sm-demo-app/video_test.go"
|
||||
provides: "TestDemoVideoStart with a fake RoomService"
|
||||
contains: "TestDemoVideoStart"
|
||||
- path: "../sm-demo-app/scripts/check-chat.cjs"
|
||||
provides: "two-browser realtime and DM call checks"
|
||||
contains: "use-fake-device-for-media-stream"
|
||||
- path: "../vue-chat-layer/tests/store.test.ts"
|
||||
provides: "multi-channel store, threads, pins, commands"
|
||||
contains: "describe"
|
||||
key_links:
|
||||
- from: "../sm-demo-app/scripts/check-all.sh"
|
||||
to: "../sm-demo-app/scripts/check-live.sh"
|
||||
via: "the gate runs every suite, the layers and the chat-family plugins, then check-live"
|
||||
pattern: "check-live\\.sh"
|
||||
- from: "../sm-demo-app/scripts/check-local.sh"
|
||||
to: "../sm-demo-app/scripts/check-chat.cjs"
|
||||
via: "check_chatlive and check_callmedia run the Playwright script with two contexts"
|
||||
pattern: "check-chat\\.cjs"
|
||||
- from: "../sm-demo-app/moderation_test.go"
|
||||
to: "../sm-demo-app/plugins/demo/moderation.go"
|
||||
via: "HTTP send with a banned word answers 422 through the demo listener and the moderation Filter"
|
||||
pattern: "422"
|
||||
prohibitions:
|
||||
- statement: "Tests use no real secret, no production database and no network service except the Docker Postgres, the local dev stack and in-process fakes."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No production behaviour changes in this plan except fixes for bugs the tests expose, within Task 7's fix budget (small, no feature, schema or contract change, at most one patch tag per repository and one redeploy), each in its own commit, named in the SUMMARY, and released through the Task 6 checkpoint; the app-wiring extraction in vue-demo-app is behaviour-preserving."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Test-only commits in plugin and layer repositories get no tag; nothing is pushed before the Task 6 go-ahead."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented here): SC-4 says the new code has unit tests, delivered in the phase's last plan. The live demo shows the chat family working.
|
||||
|
||||
This plan's slice (plan 6 of 6, wave 6, per D-20 and the project rule that unit tests are the last plan): unit tests for everything plans 01 to 05 built, in Go, the four layers and the site, then a local two-browser chat and call check and the live verification, ending with the user's look at the live demo.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion, recorded)
|
||||
|
||||
- **Where tests live:**
|
||||
- plugin seam tests next to the plan 01 smoke tests;
|
||||
- the demo's database tests in the root package (they compile every plugin through PluginIDs and the shared Postgres harness, as 19.4.1 did), with pure demo classes tested in plugins/demo/classes;
|
||||
- bm's chat tests next to the ported sources in vue-chat-layer/app/lib (copied with only import paths changed, the 19.4.1 vue-demo-app pattern), new layer tests in tests/.
|
||||
- **Fake LiveKit:** TestDemoVideoStart points `golem15.chatvideo.livekit_admin_url` at an httptest server answering the Twirp RoomService calls Start makes (CreateRoom, and ListParticipants if used), so chatvideo's real code path runs without a LiveKit.
|
||||
- **useCall test:** livekit-client is replaced with vi.mock of a minimal Room (connect, disconnect, localParticipant setters, event emitter). The real SDK is exercised by check-chat.cjs against the local LiveKit with fake media.
|
||||
- **Site tests:** the hook factories in vue-demo-app's app/plugins/auth.ts (token, refresh and unauthorized for chat; token getters; the friends directory mapping) move unchanged into app/utils/communityAuth.ts, so vitest can test them without Nuxt.
|
||||
- spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip).
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Go tests:
|
||||
- sm-moderation-plugin `TestMatchWords`, `TestMatchLinks`, `TestParseList`, `TestFilterCheckCache`, `TestFilterCheckDisabled`, `TestSettingsLimits`, `TestBootPublishesFilter`;
|
||||
- sm-chat-plugin `TestMessageSavingNoListener`, `TestMessageSavingRejects`, `TestMessageSavingOtherError`;
|
||||
- sm-forum-plugin `TestPostSavingNoListener`, `TestPostSavingRejects`;
|
||||
- sm-demo-app `TestDemoModeration`, `TestDemoChatContext*`, `TestDemoChannelAdapter`, `TestDemoChannelsSeededOnly`, `TestDemoChatBootstrap`, `TestDemoChatMembers`, `TestDemoDMs`, `TestDemoModeratorDelete`, `TestDemoCommands`, `TestDemoVideoStart`, `TestSeedChat`, extended `TestResetKeepsPlans`, `TestRealtimeRoutes`, `TestRealtimeMemberToken`, `TestBootAllPlugins`.
|
||||
- Layer tests: vue-chat-layer (ported bm tests plus `store.test.ts`, `client.test.ts`, `linkEmbeds.test.ts`), vue-chatvideo-layer (`callMachine`, `videoClient`, `useCall`), vue-forum-layer and vue-userfriends-layer client tests.
|
||||
- Site: `app/utils/communityAuth.ts` with its test.
|
||||
- Scripts: check-chat.cjs modes `--two` (realtime) and `--call` (fake media); check-local checks `chatlive` and `callmedia`; check-all.sh steps for the chat-family plugins and the four layers.
|
||||
|
||||
<objective>
|
||||
Cover the phase's new code with unit tests, prove two-browser chat and DM calls locally, and verify the live demo with the user.
|
||||
|
||||
Purpose: SC-4 (unit tests in the last plan) and the live proof of SC-2 and SC-3 for every decision the phase implemented.
|
||||
Output: test commits in eight Go and five frontend repositories, script commits in sm-demo-app, pushes after the go-ahead, and the user's sign-off on the live demo.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-VALIDATION.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-RESEARCH.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-01-SUMMARY.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-02-SUMMARY.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-03-SUMMARY.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-04-SUMMARY.md
|
||||
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-05-SUMMARY.md
|
||||
|
||||
Repos: ../sm-moderation-plugin, ../sm-chat-plugin, ../sm-forum-plugin, ../sm-demo-app (root tests, plugins/demo, scripts), ../sm-demo-app/vue-demo-app, ../vue-chat-layer, ../vue-chatvideo-layer, ../vue-forum-layer, ../vue-userfriends-layer. bm test sources (read-only): ../sm-bm-app/frontend/src/lib/{chatEvents,chatFormat,chatText,chatUnread,realtime}.test.ts and stores/chat.test.ts.
|
||||
|
||||
Rules: no co-author tags; stage by explicit path; Docker tests skip under -short and the TestMain calls flag.Parse and testing.Short (CLAUDE.md); if the Docker socket is denied, run through `sudo -u golem`; production code changes only for bugs the tests expose and only within the fix budget in Task 7 (own commit each, named in the SUMMARY; anything larger becomes a todo and decimal-phase candidate); nothing is pushed before Task 6.
|
||||
|
||||
<interfaces>
|
||||
sm-demo-app test harness: testmain_test.go `bootDemo(t, environ, set) *demotest.Demo`; internal/demotest (App, DB, Options{PluginIDs, Environ, Set}); existing tests TestGoModRequiresTags, TestBootAllPlugins (wantPlugins), TestRealtimeRoutes (realtimeApp, realtimeRoutes, wantNamespaces), TestResetKeepsPlans, TestSeedRoadmapUpsert; smoke tests from plan 02: TestDemoChatTracer, TestDemoModerationSmoke, TestSeedChatSmoke (chat_smoke_test.go).
|
||||
Plugin harnesses: chat bootWeb, frontendToken, call, conversation, count, memory (lighthouse MemoryDriver publications); forum bootWeb, call, count; plan 01 smoke tests TestMessageSavingTracer, TestPostSavingTracer, TestFilterSmoke.
|
||||
Demo classes (plan 02): DemoContext, ChannelContext, ChatAccess, ForumAccess with WithMembership and MemberFunc injection; chat_api.go bootstrap and members; chatbots.go demo and tour; moderation.go listeners; realtime_access.go guard.
|
||||
Layers (plans 03 and 04): vue-chat-layer createChatClient, useChat, linkify, registerEmbedRenderer, onUserEvent, slots; vue-chatvideo-layer callMachine (initialCallState, transition), createVideoClient, videoSessionChannel, useCall; createForumClient; createFriendsClient.
|
||||
Scripts: check-local.sh (visitor_jwt, throwaway registration helper, ALL_CHECKS, NODE_PATH="$(npm root -g)" for Playwright scripts), check-chat.cjs (plan 03 single-browser), check-all.sh (steps, "check-all: ok").
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: one banned word travels from the admin settings through the filter, the demo listener and the chat seam to a 422 in a member's request, under test</name>
|
||||
<precondition>`docker info` exits 0; the plan 05 SUMMARY exists.</precondition>
|
||||
<files>../sm-moderation-plugin/classes/filter_test.go, ../sm-demo-app/moderation_test.go</files>
|
||||
<read_first>../sm-moderation-plugin/classes/filter.go and filter_smoke_test.go, ../sm-demo-app/plugins/demo/moderation.go, ../sm-demo-app/chat_smoke_test.go (TestDemoModerationSmoke), ../sm-demo-app/testmain_test.go, 20-VALIDATION.md (D-17 rows)</read_first>
|
||||
<action>Per D-17, D-18, SC-4.
|
||||
- sm-moderation-plugin classes/filter_test.go, table tests for the pure functions:
|
||||
- TestMatchWords: case-insensitive; a word inside a longer word does not match; phrases; Polish letters on both sides of a boundary (for example "żółw" against "żółwik" and "żółw!"); digits and underscores count as word characters; QuoteMeta'd specials such as "c++".
|
||||
- TestMatchLinks: a host entry matches the bare domain, www., http and https URLs, subdomains, upper case and trailing paths; "notbad.example" does not match "bad.example"; a URL-prefix entry matches only below its path.
|
||||
- TestParseList: trims, lowercases, drops empty lines and duplicates.
|
||||
- sm-demo-app moderation_test.go `TestDemoModeration` (Docker), end to end through HTTP:
|
||||
- after demo:seed, update golem15_moderation_settings with banned_words "blockedword" and banned_links "bad.example";
|
||||
- a member's message, reply and edit containing either answer 422 with errors.content, and the message count does not change;
|
||||
- a forum thread title answers 422 errors.title and a reply body 422 errors.body;
|
||||
- after changing the list to another word without restarting, the old word is accepted (cache refresh);
|
||||
- with enabled false, everything is accepted.
|
||||
|
||||
Commit per repository (`test: ...`).</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-moderation-plugin test -count=1 ./... && go -C ../sm-demo-app test -count=1 -run '^TestDemoModeration$' -v . 2>&1 | grep -q -- '^--- PASS: TestDemoModeration'</automated>
|
||||
<fails_when>Non-zero exit from the moderation suite (a boundary, host or cache case wrong), or no "--- PASS: TestDemoModeration" line (skipped without Docker, banned content stored, or a list change not picked up).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c '^func TestMatch' ../sm-moderation-plugin/classes/filter_test.go` prints at least 2.
|
||||
- `grep -c 'żółw' ../sm-moderation-plugin/classes/filter_test.go` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>The D-17/D-18 chain is pinned from the settings row to the HTTP answer.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Seam and moderation plugin unit tests: parity without a listener, refusals on every write path</name>
|
||||
<files>../sm-chat-plugin/saving_test.go, ../sm-forum-plugin/saving_test.go, ../sm-moderation-plugin/models/settings_test.go, ../sm-moderation-plugin/plugin_test.go</files>
|
||||
<read_first>../sm-chat-plugin/saving_smoke_test.go, http_tracer_test.go, harness_test.go (memory driver), classes/attachments.go (ReplyTo), ../sm-forum-plugin/saving_smoke_test.go, http_tracer_test.go, ../sm-moderation-plugin/models/settings.go, plugin.go, updates/*.go, ../sm-sitemap-plugin/models/definition_test.go (settings test style)</read_first>
|
||||
<action>Per D-18, D-17.
|
||||
- sm-chat-plugin saving_test.go:
|
||||
- TestMessageSavingNoListener: the same send, reply, edit and ReplyTo requests answer the same status and body keys as before the seam, and MessageCreated and MessageUpdated still fire;
|
||||
- TestMessageSavingRejects: table over send, reply (POST replies), ReplyTo (service) and edit. Each gives 422 under the Rejection field (and "content" when the field is empty), no row inserted or changed, no MessageCreated or MessageUpdated fired (a counting listener), and no publication on the memory driver;
|
||||
- TestMessageSavingOtherError: a plain error from a listener gives 500 with the generic body and nothing stored;
|
||||
- the event carries Op, ConversationID, ActorID, RootID, MessageID and the trimmed Content.
|
||||
- sm-forum-plugin saving_test.go: TestPostSavingNoListener and TestPostSavingRejects over thread, reply and edit (field title or body, nothing stored, content_html unchanged on a refused edit, Op values).
|
||||
- sm-moderation-plugin:
|
||||
- models/settings_test.go TestSettingsLimits: too many entries, too long entries and a bad link entry refused with their lang keys; valid lists saved;
|
||||
- plugin_test.go TestBootPublishesFilter (Docker, with the sitemap-style harness): migrations create the singleton row, Boot publishes *classes.Filter, and Check on a clean text gives nil.
|
||||
Keep php_method_map_test.go and golden_test.go untouched in both content plugins. Commit per repository.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-chat-plugin test -count=1 ./... && go -C ../sm-forum-plugin test -count=1 ./... && go -C ../sm-moderation-plugin test -count=1 ./... && go -C ../sm-chat-plugin test -count=1 -run '^TestMessageSaving' -v . 2>&1 | grep -c -- '^--- PASS: TestMessageSaving' | grep -qvx '[0-2]'</automated>
|
||||
<fails_when>Non-zero exit from any of the three suites (parity broken, a write path that bypasses the seam, an event fired after a refusal), or fewer than three passing TestMessageSaving* lines (tests skipped without Docker).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git -C ../sm-chat-plugin diff HEAD~1 --stat -- php_method_map_test.go golden_test.go` and the same in ../sm-forum-plugin print nothing.
|
||||
- `grep -c 'ReplyTo' ../sm-chat-plugin/saving_test.go` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>The seams' contract (silent without listeners, total refusal with one) and the moderation plugin's settings and boot are pinned.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Demo host unit tests: context, adapter, bootstrap, DMs, moderator delete, commands, video start, realtime, seed and reset</name>
|
||||
<files>../sm-demo-app/plugins/demo/classes/chat_context_test.go, ../sm-demo-app/plugins/demo/chatbots_test.go, ../sm-demo-app/chat_test.go, ../sm-demo-app/video_test.go, ../sm-demo-app/seed_chat_test.go, ../sm-demo-app/reset_test.go, ../sm-demo-app/realtime_test.go, ../sm-demo-app/boot_test.go</files>
|
||||
<read_first>../sm-demo-app/plugins/demo/{chat.go,chat_api.go,chatbots.go,moderation.go,realtime_access.go,routes.go,classes/chat_context.go,classes/forum_access.go,console/seed_chat.go,console/seed_forum.go,console/seed_friends.go,console/reset.go}, ../sm-demo-app/{chat_smoke_test.go,demo_api_test.go,realtime_test.go,reset_test.go,seed_test.go,boot_test.go,testmain_test.go}, ../sm-demo-app/plugins/golem15/chatvideo/classes/livekit.go (RoomAPI, NewProductionRooms, FakeRooms) and boot.go, ../sm-bm-app/realtime_test.go, 20-VALIDATION.md</read_first>
|
||||
<action>Per D-05, D-06, D-07, D-08, D-09, D-11, D-12, D-22, D-23, SC-1.
|
||||
- plugins/demo/classes/chat_context_test.go (pure, with WithMembership and MemberFunc stubs): constants, BroadcastChannel never empty for either alias, CanModerateMessage only for moderator-group members in non-DM conversations, RequireLiveMembership true, ChatAccess CanManageContext always false and CanAccessContext only for demo/1 members, ForumAccess read, post and manage rules.
|
||||
- plugins/demo/chatbots_test.go: TestDemoCommands. Triggers demo and tour, IsAvailable false for actor 0, Handle returns an ephemeral result whose text lists the site paths.
|
||||
- chat_test.go (Docker), with a seeded demo and members created in the test:
|
||||
- TestDemoChatContextMembership: a banned user and a deleted user are not members; a member banned after joining loses access at the next request (live membership);
|
||||
- TestDemoChannelAdapter: the conversation of each seeded channel resolves through the channel alias;
|
||||
- TestDemoChannelsSeededOnly: POST channels is refused, join and leave work;
|
||||
- TestDemoChatBootstrap: the JSON keys and types of /_demo/api/v1/chat for a member, {member: false} for a non-member, 401 without a JWT;
|
||||
- TestDemoChatMembers: excludes the caller and personas count as members;
|
||||
- TestDemoDMs: any two members open a DM, and opening it again returns the same id;
|
||||
- TestDemoModeratorDelete: a moderator-group member deletes another member's channel message (200) but not a DM message of others (404); a plain member gets 404;
|
||||
- TestDemoCommands over HTTP: GET /_chatbots/api/v1/commands lists demo and tour; POST dispatch "/demo" answers mode ephemeral; "/nope" answers 422 errors.input.
|
||||
- video_test.go (Docker) TestDemoVideoStart: an httptest server answers the Twirp RoomService calls chatvideo's RoomAPI makes, with livekit_admin_url pointing at it and test keys in the environment. POST /_chatvideo/api/v1/conversations/{dm}/video/start answers 2xx with token and livekit_url, the token's claims grant the room for that user, and a second start returns the same session. A start in a seeded channel conversation is not refused by type (D-23 UI-only); assert the status the plugin gives with the channel's participant count and max_participants 4, and record it.
|
||||
- realtime_test.go: TestRealtimeRoutes keeps the middleware list from plan 02. New TestRealtimeMemberToken (Docker): a member's JWT gives 200 with a token, a banned user's gives 403 (or the guard's documented status), no JWT gives 401.
|
||||
- seed_chat_test.go TestSeedChat: exact counts per EN and PL channel, threads with replies and summary rows consistent, reactions, pins, three forum categories with content_html, friendships, the group, the visitor's DM and pending request when the visitor is set, the moderator group, and idempotency (every new step reports exists on the second run).
|
||||
- reset_test.go: TestResetKeepsPlans also asserts that every chat-family table named in resetTables is empty after the wipe and repopulated after the reseed, and that golem15_moderation_settings and the userfriends context row are unchanged.
|
||||
- boot_test.go: the chat-family route prefixes, and jwt.auth on GET /_demo/api/v1/chat and /chat/members.
|
||||
Commit `test: demo chat family`.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-demo-app vet . ./plugins/demo/... && go -C ../sm-demo-app test -count=1 . ./plugins/demo/... && go -C ../sm-demo-app test -count=1 -run '^(TestDemoChannelsSeededOnly|TestDemoVideoStart|TestSeedChat|TestResetKeepsPlans|TestRealtimeMemberToken)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 5</automated>
|
||||
<fails_when>Non-zero exit or a FAIL line from the full app suite, or fewer than five "--- PASS" lines for the named tests (skipped without Docker, a refused channel create that succeeds, a video start error against the fake RoomService, a seed count mismatch, or a reset leaving chat rows).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c '^func Test' ../sm-demo-app/chat_test.go` prints at least 7.
|
||||
- `go -C ../sm-demo-app test -count=1 -short . ./plugins/demo/...` passes without Docker.
|
||||
</acceptance_criteria>
|
||||
<done>Every demo host decision of the phase is pinned by a unit or database test.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Layer and site unit tests: bm's chat tests on the layer, new store and client tests, the call machine and the app wiring</name>
|
||||
<files>../vue-chat-layer/vitest.config.ts, ../vue-chat-layer/app/lib/chatEvents.test.ts, ../vue-chat-layer/app/lib/chatFormat.test.ts, ../vue-chat-layer/app/lib/chatText.test.ts, ../vue-chat-layer/app/lib/chatUnread.test.ts, ../vue-chat-layer/app/lib/realtime.test.ts, ../vue-chat-layer/app/lib/linkEmbeds.test.ts, ../vue-chat-layer/tests/client.test.ts, ../vue-chat-layer/tests/store.test.ts, ../vue-chatvideo-layer/tests/callMachine.test.ts, ../vue-chatvideo-layer/tests/videoClient.test.ts, ../vue-chatvideo-layer/tests/useCall.test.ts, ../vue-forum-layer/tests/client.test.ts, ../vue-userfriends-layer/tests/client.test.ts, ../sm-demo-app/vue-demo-app/app/utils/communityAuth.ts, ../sm-demo-app/vue-demo-app/app/utils/communityAuth.test.ts, ../sm-demo-app/vue-demo-app/app/plugins/auth.ts, ../sm-demo-app/vue-demo-app/app/lib/realtime.test.ts</files>
|
||||
<read_first>../sm-bm-app/frontend/src/lib/{chatEvents,chatFormat,chatText,chatUnread,realtime}.test.ts and stores/chat.test.ts, ../sm-bm-app/frontend/vite.config.ts (its test block), ../vue-chat-layer/app/lib/*.ts, app/composables/useChat.ts, tests/smoke.test.ts, ../vue-chatvideo-layer/app/lib/*.ts and app/composables/useCall.ts, ../vue-forum-layer/app/utils/forumClient.ts, ../vue-userfriends-layer/app/utils/friendsClient.ts, ../sm-demo-app/vue-demo-app/app/plugins/auth.ts, vitest.config.ts, app/lib/realtime.test.ts</read_first>
|
||||
<action>Per D-01, D-02, D-04, D-05, D-13, D-14, D-15, D-21, D-23.
|
||||
- vue-chat-layer:
|
||||
- vitest.config.ts includes app/**/*.test.ts and tests/**/*.test.ts in the node environment, with aliases so bm's tests run with only import paths changed;
|
||||
- copy bm's chatEvents, chatFormat, chatText, chatUnread and realtime tests next to the ports;
|
||||
- linkEmbeds.test.ts: linkify escapes, only http(s), rel and target set, the registry order, max_embeds;
|
||||
- client.test.ts: every endpoint's URL and method, 401 refresh-and-retry once, unauthorized after a failed refresh, the 422 mapping, multipart upload;
|
||||
- store.test.ts (bm's chat.test.ts rewritten for the useState composable, with a fake client and a fake realtime):
|
||||
- the bootstrap fills channels and DMs; an empty hostApiBase starts nothing; a non-member bootstrap starts nothing (D-04);
|
||||
- opening a channel lazy-joins; a realtime message increments unread in other conversations and appends in the open one;
|
||||
- thread replies and counts, pins load, reactions toggle;
|
||||
- a "/" input dispatches instead of posting and the ephemeral answer stays local (D-05);
|
||||
- polling starts when realtime is unavailable;
|
||||
- onUserEvent receives a video:* event the store does not handle;
|
||||
- the conversation-actions slot renders only for DMs (mount ChatHeader with @vue/test-utils only if it is already in the workspace lockfiles, else test the computed visibility helper the header uses).
|
||||
- vue-chatvideo-layer:
|
||||
- callMachine.test.ts: every transition, including failure reasons;
|
||||
- videoClient.test.ts: URLs, auth, videoSessionChannel;
|
||||
- useCall.test.ts: vi.mock livekit-client with a fake Room; start, connect, a remote track event adds a tile, mute and camera toggles call the local participant, leave disconnects and posts leave, an ended event on the session channel ends the call, and a 422 start fails with the too-many reason.
|
||||
- vue-forum-layer and vue-userfriends-layer: extend the client tests to every method and error mapping.
|
||||
- vue-demo-app: move the hook factories from app/plugins/auth.ts unchanged into app/utils/communityAuth.ts (`createChatAuth`, `tokenGetter`, `friendsDirectory`). auth.ts imports them. communityAuth.test.ts covers the token read, refresh storing the new token, unauthorized clearing the cookie, and the directory mapping. app/lib/realtime.test.ts still passes through the re-export.
|
||||
Commit per repository (`test: ...`; the vue-demo-app extraction as `refactor: community auth hooks into app/utils` before its test commit).</action>
|
||||
<verify>
|
||||
<automated>pnpm --dir ../vue-chat-layer test && pnpm --dir ../vue-chatvideo-layer test && pnpm --dir ../vue-forum-layer test && pnpm --dir ../vue-userfriends-layer test && pnpm --dir ../sm-demo-app/vue-demo-app test && pnpm --dir ../sm-demo-app/vue-demo-app run build</automated>
|
||||
<fails_when>Non-zero exit from any layer's test script, the vue-demo-app vitest suite, or the app build after the hook extraction.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `ls ../vue-chat-layer/app/lib/*.test.ts | wc -l` prints at least 6.
|
||||
- `diff <(sed -n '/^describe/,$p' ../sm-bm-app/frontend/src/lib/chatText.test.ts) <(sed -n '/^describe/,$p' ../vue-chat-layer/app/lib/chatText.test.ts)` prints nothing (only imports differ).
|
||||
- `grep -c "vi.mock('livekit-client'" ../vue-chatvideo-layer/tests/useCall.test.ts` prints 1.
|
||||
- `grep -c 'from .*communityAuth' ../sm-demo-app/vue-demo-app/app/plugins/auth.ts` prints 1.
|
||||
</acceptance_criteria>
|
||||
<done>The four layers and the site's wiring are covered by unit tests, with bm's chat tests running unchanged on the port.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: Two browsers locally: live channel messages and a DM call with fake media, and the phase gate in check-all</name>
|
||||
<precondition>`../sm-demo-app/scripts/dev.sh up` runs with LiveKit on 127.0.0.1:7890; the global Playwright resolves (`NODE_PATH="$(npm root -g)" node -e "require('playwright')"` exits 0).</precondition>
|
||||
<files>../sm-demo-app/scripts/check-chat.cjs, ../sm-demo-app/scripts/check-local.sh, ../sm-demo-app/scripts/check-all.sh, ../sm-demo-app/README.md</files>
|
||||
<read_first>../sm-demo-app/scripts/check-chat.cjs (plan 03), check-remote.cjs, lib/browser.cjs, check-local.sh (visitor_jwt, throwaway registration helper, check_remote), check-all.sh (steps and plugin dirs), README.md (checks section)</read_first>
|
||||
<action>Per SC-2, D-11, D-13, D-23.
|
||||
- check-chat.cjs: add `--two` and `--call` modes. Each launches Chromium with `--use-fake-ui-for-media-stream` and `--use-fake-device-for-media-stream` and opens two contexts with two JWTs (DEMO_JWT and DEMO_JWT2).
|
||||
- `--two`: both open /chat#general; A sends a unique text; B sees it within 10 s without reload; B reacts and A sees the reaction; B types and A sees the typing indicator.
|
||||
- `--call`: A opens a DM with B (POST /_chat/api/v1/contexts/demo/1/dm through page.request with A's token) and opens it on /chat. The DM header shows the call button and no call button shows in #general (D-23). A clicks it; B's page shows the incoming prompt within 10 s and accepts. Both call pages reach the connected state and each shows one remote tile whose video element has videoWidth above 0 within 20 s. A mutes and B sees the muted marker if the tile shows one. Both leave and the session ends (GET status).
|
||||
- Print `check-chat: ok two` or `check-chat: ok call`.
|
||||
- check-local.sh: `check_chatlive` and `check_callmedia` take the visitor JWT and a throwaway user's JWT, run the script in the matching mode, and join ALL_CHECKS and the header list.
|
||||
- check-all.sh, new steps after the existing plugin suites:
|
||||
- `go test ./...` in ../sm-chat-plugin, ../sm-forum-plugin and ../sm-moderation-plugin, and `go test -short ./...` in chatchannels, chatbots, chatvideo, herald and userfriends (directories overridable by env, as USER_PLUGIN_DIR is);
|
||||
- `pnpm test` in the four layer checkouts (overridable);
|
||||
- the local browser step runs `check-local.sh chatlive callmedia` alongside back and remote.
|
||||
Update the header comment and README's checks section.
|
||||
Commit `test: two-browser chat and call checks; phase gate covers the chat family`.</action>
|
||||
<verify>
|
||||
<automated>../sm-demo-app/scripts/check-local.sh chat chatlive callmedia forum friends call herald tour realtime remote</automated>
|
||||
<fails_when>The last line is not "check-local: ok ..." listing the checks; a "check-local: chatlive failed" or "callmedia failed" line (message not delivered live, no incoming prompt, no remote video within 20 s, or a call button in a channel) or any other named check failing.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'use-fake-device-for-media-stream' ../sm-demo-app/scripts/check-chat.cjs` prints at least 1.
|
||||
- `grep -cE 'chatlive|callmedia' ../sm-demo-app/scripts/check-local.sh` prints at least 4.
|
||||
- `grep -c 'sm-moderation-plugin' ../sm-demo-app/scripts/check-all.sh` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>Two-browser realtime chat and a DM video call are proven automatically on the local stack, and the phase gate runs every new suite.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 6: Go-ahead to push the test commits (and to release fixes if the tests found bugs)</name>
|
||||
<read_first>Claude's table: per repository the unpushed commits (`git log origin/<branch>..HEAD --oneline`), which are test-only and which are fixes; for each fix, the proposed patch tag (next free number from the remote) and the pin bumps and redeploy it needs (plan 05 runbook); failures outside Task 7's fix budget are listed as todos, not as fixes</read_first>
|
||||
<acceptance_criteria>
|
||||
- The user chose one option and it is recorded in the SUMMARY.
|
||||
</acceptance_criteria>
|
||||
<decision>Push the test commits, and tag and redeploy fixes if there are any?</decision>
|
||||
<context>Test-only commits get no tag (19.4.1 precedent). A production fix in a shared plugin or layer needs a new patch tag (one-way), a go.mod or package.json pin bump in the demo and a redeploy through DEPLOY.md; the table lists each case. If there are no fixes, this only pushes.</context>
|
||||
<options>
|
||||
<option id="push">
|
||||
<name>Push (and release the listed fixes)</name>
|
||||
<pros>Tests are on origin; any fix reaches the live demo before the final check.</pros>
|
||||
<cons>Fix tags are permanent.</cons>
|
||||
</option>
|
||||
<option id="push-tests-only">
|
||||
<name>Push test commits only</name>
|
||||
<pros>No new tags.</pros>
|
||||
<cons>Listed fixes stay local and the live demo keeps the bug.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold</name>
|
||||
<pros>Nothing leaves this machine.</pros>
|
||||
<cons>check-all's suites still run locally, but origin lacks the tests.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "push", "push-tests-only" or "hold".</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 7: Push, release any fixes, and run the phase gate against the live demo</name>
|
||||
<reversibility rating="one-way">Only when fixes are released: their patch tags are cached by module proxies and lockfiles.</reversibility>
|
||||
<precondition>Task 6 answered "push" or "push-tests-only".</precondition>
|
||||
<files>../sm-demo-app/README.md</files>
|
||||
<read_first>The Task 6 table and answer, ../sm-demo-app/DEPLOY.md (Release, Verify)</read_first>
|
||||
<action>Per SC-2, SC-3, SC-4.
|
||||
- Push the test commits of every repository (plugins and layers first, then vue-demo-app, then sm-demo-app).
|
||||
- For "push" with fixes, in one release batch:
|
||||
- tag each fixed plugin or layer with the next patch (annotated, on the verified HEAD) and push;
|
||||
- bump the demo's go.mod (GOWORK=off go get) or package.json pins in one `chore: bump <name> to <tag>` commit each;
|
||||
- push vue-demo-app before the sm-demo-app pointer bump;
|
||||
- release on rome through DEPLOY.md.
|
||||
- Run `scripts/check-all.sh --local` (every suite, the browser checks, check-live). Fix a failure only within the fix budget below, then run it again. Do not loop past the budget.
|
||||
- Fix budget. It bounds the whole plan: fixes made in Tasks 1 to 5, in this task and for the Task 8 findings.
|
||||
- Source: a fix is allowed only for a failure found by this plan's own tests or checks (Tasks 1 to 5, check-all, check-live, the Task 8 UAT).
|
||||
- Size: each fix is small. It adds no feature, changes no schema or migration, and changes no API route, payload, event, config key, hook, prop or other published contract.
|
||||
- Release: at most one new patch tag per affected repository and at most one redeploy on rome in this plan. Every in-budget fix goes into that one release batch. Under "push-tests-only", fixes are committed but not tagged or deployed, and each one is recorded as a todo.
|
||||
- After the release: a fix that needs no tag or redeploy (a check script or a test in sm-demo-app) is still allowed if it is small. Anything that needs a second tag of the same repository or a second redeploy is outside the budget.
|
||||
- Outside the budget: do not fix it in this plan. Record it as a todo in `.planning/todos/pending/` with its g15office task (`.planning/G15OFFICE.md` rules). List it in the SUMMARY as a decimal-phase candidate (for example `/msd-phase --insert 20.1`). Leave the check-all result as it is, and stop at Task 8's checkpoint with the open failures listed for the user.
|
||||
- Record in README's checks section what the gate now covers.</action>
|
||||
<verify>
|
||||
<automated>../sm-demo-app/scripts/check-all.sh --local 2>&1 | tail -1 | grep -qx 'check-all: ok'</automated>
|
||||
<fails_when>The last line is not "check-all: ok" (a suite, the release build, check-pins, smoke, check-deploy, a browser check or check-live failed; the failing step is named in its own "check-all: <step> failed" line). A failure outside the fix budget keeps this red: it is recorded as a todo and the plan stops at Task 8 instead of fixing further.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- For each repository touched in this plan, `git ls-remote origin` shows the default branch at the local HEAD (or the SUMMARY records the "hold" answer).
|
||||
- Any fix tag equals its pushed HEAD and the demo pins it (`check-pins.sh` prints `check-pins: ok`).
|
||||
- The fix budget held. The SUMMARY lists every production fix with its commit and the failure that found it, every tag created in this plan (no repository twice), and at most one DEPLOY.md release. `git -C <repo> show --stat <fix>` for each listed fix touches no migration (`updates/`) and no parity test file (`php_method_map_test.go`, `golden_test.go`).
|
||||
- Either the last line of check-all is `check-all: ok`, or every remaining failure is recorded as a todo in `.planning/todos/pending/` with a `g15office:` ID and named as a decimal-phase candidate in the SUMMARY, and execution stopped at Task 8 with those failures shown to the user.
|
||||
</acceptance_criteria>
|
||||
<done>Every test is on origin, every in-budget fix is live, and the phase gate is green against the live demo, or each remaining failure is recorded as a todo and decimal-phase candidate for the user at Task 8.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 8: The user checks chat and a DM video call on the live demo with two browsers</name>
|
||||
<read_first>The Task 7 check-all output, ../sm-demo-app/DEPLOY.md (Verify), 20-VALIDATION.md (Manual-Only Verifications)</read_first>
|
||||
<acceptance_criteria>
|
||||
- The user replied "approved". Or each issue they listed was fixed within Task 7's fix budget (released in the plan's one release batch only if it is still unused, check-live green again) and the user approved. Or the issues outside the budget are recorded as todos and decimal-phase candidates in the SUMMARY and the user approved closing the plan with them open.
|
||||
</acceptance_criteria>
|
||||
<what-built>https://demo.summercms.io with chat (channels, DMs, threads, pins, reactions, attachments, slash commands, floating panel), forum, friends and DM video calls through the demo's own LiveKit, moderation of banned words and links, EN and PL, and the tour cards. Anonymous visitors get register prompts on /chat and /forum.</what-built>
|
||||
<how-to-verify>
|
||||
1. Browser A: log in as the visitor (DEMO_VISITOR_EMAIL; the password is SUMMER_GOLEM15__DEMO__VISITOR__PASSWORD in /srv/summercms-demo/.env on rome). Browser B (another profile or device on a different network if possible, for example a phone on mobile data): register a new account.
|
||||
2. Both open /chat, then #general. Send a message from A and see it arrive in B without reloading. Reply in a thread, react, and pin if allowed. Type "/demo" and see the answer only in your own window.
|
||||
3. Add a banned word in the admin (log in at /smbackend with the admin credentials in /root/summercms-demo-admin.txt on rome, Settings › Moderation), then send a message containing it: it is refused with a message. Remove the word again.
|
||||
4. From A, open a DM with B's account and press the call button (it must not appear in #general). In B, accept the prompt. Both see and hear each other; mute, toggle the camera, then leave.
|
||||
5. Check /forum and /friends as a member and anonymously (register prompt), the floating chat panel on /blog, and the Polish language switch.
|
||||
</how-to-verify>
|
||||
<resume-signal>Reply "approved", or describe what is wrong.</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| test harnesses to Docker Postgres and local services | test data only, no production systems |
|
||||
| browser checks to the local and live demo | throwaway accounts, the visitor credentials from .env |
|
||||
| build machine to origin and rome (fix releases only) | tags and releases after the go-ahead |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-20-35 | Information disclosure | credentials in tests and check output | medium | mitigate | tests use generated users and test keys; check scripts read the visitor password from .env and never print it; the live UAT names the .env key, not the value |
|
||||
| T-20-36 | Tampering | regressions hiding behind skipped tests | medium | mitigate | verify commands count "--- PASS" lines so a Docker-less skip fails the gate; check-all runs full (non-short) suites |
|
||||
| T-20-37 | Denial of service | throwaway accounts on the live demo | low | accept | the live check registers no accounts (check-live is read-only); the human UAT creates one, removed by the reset job |
|
||||
| T-20-SC | Tampering | npm/pip/cargo installs | high | mitigate | no new package: tests use vitest 3.2.7 and the existing workspace pins; @vue/test-utils only if already in a lockfile (otherwise the helper is tested directly); Playwright is the existing global install |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: moderation suite and TestDemoModeration.
|
||||
- Task 2: chat, forum and moderation suites with Docker; parity tests unedited.
|
||||
- Task 3: sm-demo-app full suite plus the named database tests.
|
||||
- Task 4: four layer suites, vue-demo-app vitest and build.
|
||||
- Task 5: check-local with the two-browser chat and call checks.
|
||||
- Task 6: push and fix-release decision recorded.
|
||||
- Task 7: check-all --local prints check-all: ok against the live demo.
|
||||
- Task 8: the user approves the live demo.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
SC-4: the phase's new code is unit-tested in its last plan. SC-2 and SC-3 are proven live, automatically through check-all and check-live and by the user through a two-browser chat and a DM video call on https://demo.summercms.io.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/20-demo-instance-with-every-shared-plugin/20-06-SUMMARY.md` when done: the test names per repository, bugs found and fixed (commits and tags), what TestDemoVideoStart recorded for a channel start, the check-all output, both checkpoint answers and the user's UAT notes.
|
||||
</output>
|
||||
@@ -0,0 +1,147 @@
|
||||
# Phase 20: Demo instance with every shared plugin - Context
|
||||
|
||||
**Gathered:** 2026-10-28
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Phase 19.4.1 put `sm-demo-app` live on demo.summercms.io with the plugins of that time. This phase adds the chat family to that live instance and shows it working on the public Nuxt site: `chat`, `chatchannels`, `chatvideo`, `chatbots`, `forum`, `herald` and `userfriends` are mounted at tagged versions, seeded by `demo:seed`, deployed on rome, and given site UI through new shared Nuxt layers. Everything decided in 19.4.1 still holds (hosting on rome, own Centrifugo, `/backend` refused by nginx with the contact call to action, open registration behind Turnstile, the reset job, the demo banner, EN/PL, the summercms.io look). This phase does not repeat it.
|
||||
|
||||
New shared repos created by this phase: `vue-chat-layer`, `vue-chatvideo-layer`, `vue-forum-layer`, `vue-userfriends-layer`, `sm-moderation-plugin`.
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Chat frontend
|
||||
- **D-01:** The chat UI is ported from `sm-bm-app`'s Vite SPA (`frontend/src/components/chat/`, `stores/chat.ts`, `api/chat.ts`, `lib/chat*.ts`, about 4.6k lines) into a new shared Nuxt layer, **`vue-chat-layer`**, consumed by `vue-demo-app` like the journal, menu, FAQ and paymentgateway layers. The layer is Golem15 code with no bm (JZ) specifics: BM link embeds, Terminarz deadline pins and other bm-only pieces are stripped or turned into slots or extension points. The layer names no application. — **Reversibility:** costly. A public layer with tagged releases; later sites pin it by tag.
|
||||
- **D-02:** The full bm feature set: channels, DMs, threads, mentions, reactions, typing, unread counts, attachments, member picker, delete dialog, embeds (generic link embeds only).
|
||||
- **D-03:** Chat appears as a full `/chat` page (channel sidebar, message pane) **and** a floating panel (DM list and unread badge) on every page for logged-in users. The homepage tour (19.4.1 D-11) gets a "Chat" card.
|
||||
- **D-04:** Anonymous visitors on `/chat` see a description and a "register to chat" prompt, the same pattern as quizzes (19.4.1 D-18). There is no read-only preview, and the floating panel is hidden for them.
|
||||
- **D-05:** Chatbots has no UI of its own. Its slash commands appear as a command picker in the `vue-chat-layer` composer. There is no `vue-chatbots-layer`.
|
||||
|
||||
### Chat context and access
|
||||
- **D-06:** One demo community: a single chat context (in the spirit of bm's `bm/1`, `plugins/jz/bm/classes/chat_context.go`, e.g. `demo/1`) implemented in `golem15.demo` as the host `contracts.Context` and the chatchannels `ChannelAccessResolver`. Every registered user is a member automatically.
|
||||
- **D-07:** Channels are **seeded only** (for example #general, #announcements, a Polish channel). Users join seeded channels and cannot create channels of any kind.
|
||||
- **D-08:** Any community member can DM any other member, as in bm. userfriends does not gate DMs.
|
||||
- **D-09:** `demo:seed` creates a handful of persona users with message history, threads, reactions and pins in EN and PL channels. The reset job (19.4.1 D-09) wipes and reseeds them along with everything else. There is no live bot persona.
|
||||
|
||||
### Video calls
|
||||
- **D-10:** LiveKit is self-hosted on rome under supervisor, next to the demo's Centrifugo (19.4.1 D-17), with its own keys in `.env`. The media ports (UDP range, TCP fallback, TURN/TLS through nginx stream or LiveKit's embedded TURN) are opened on rome and documented in `sm-demo-app/DEPLOY.md`. — **Reversibility:** reversible. Moving to LiveKit Cloud later only changes `golem15.chatvideo.livekit_*` config.
|
||||
- **D-11:** Calls start from DMs only. chat has DMs plus one context-wide group conversation (`GetGroupChat`) and no ad-hoc group DMs, and with D-07 there are no private channels, so in practice that means 1:1 calls. The researcher confirms this against the plugin. Calls are never started in seeded public channels.
|
||||
- **D-12:** Caps for a public demo: `max_duration` about 15 minutes, about 4 participants per room, the plugin's existing rate limits, and the `chatvideo:reconcile-sessions` job ending idle rooms.
|
||||
- **D-13:** The video UI is a **minimal call page** (own camera, remote tiles, mute, camera toggle, leave) built on `livekit-client` and released as the first version of a new shared **`vue-chatvideo-layer`**. bm has no video UI to port.
|
||||
|
||||
### Forum, friends, push
|
||||
- **D-14:** **`vue-forum-layer`** is a new shared layer with a `/forum` page: categories, threads and Markdown posts, seeded in EN and PL by `demo:seed`.
|
||||
- **D-15:** **`vue-userfriends-layer`** is a new shared layer: friends list, requests and friend groups. It is linked from the chat sidebar and the profile.
|
||||
- **D-16:** herald (Web Push) is mounted and configured (VAPID keys in `.env`) but has **no subscribe UI** in this phase.
|
||||
|
||||
### Moderation
|
||||
- **D-17:** A new shared plugin, **`sm-moderation-plugin`**, filters banned words and links in chat messages and forum posts. The banned-word and link lists are editable in its admin settings. On top of it: the plugins' rate limits, Turnstile on register (19.4.1 D-07), admins deleting messages and banning users from the backend, and the reset job.
|
||||
- **D-18:** chat and forum fire their festival events after the save (`events.MessageCreated` etc.), so the filter needs a **new, generic, additive pre-save seam** in `sm-chat-plugin` and `sm-forum-plugin`: a host- or plugin-registered validator that can reject a message or post before it is persisted. The change is additive and ships as patch tags. It must not change the existing PHP-parity API or payloads (these plugins are WavePath's port target, Phase 31), and the seam names no application. — **Reversibility:** costly. A public plugin contract other consumers can start using.
|
||||
|
||||
### Plan shape (suggestion; confirmed at plan time per the lean-planning rule)
|
||||
- **D-19:** Backend first, then layers. The suggested split from the discussion, before the forum and userfriends layers and the moderation plugin were added: 01 mount and tag the chat family, demo context, seed, LiveKit and the rome deploy (with the folded todo); 02 the `vue-chat-layer` port; 03 `vue-chatvideo-layer` plus herald config; 04 forum and friends layers, slash-command picker, tour cards; 05 unit tests and live verification. The planner fits in `sm-moderation-plugin`, the pre-save seams and the two extra layers, and presents the final count for confirmation.
|
||||
|
||||
### Plan-time decisions (2026-10-28, after research)
|
||||
- **D-20:** Six plans, as in `20-RESEARCH.md` § Suggested Plan Split: 01 shared plugin release (sibling fast-forward, go.mod fixes, chat and forum pre-save seams, `sm-moderation-plugin`, tags); 02 demo backend; 03 `vue-chat-layer` and its use in vue-demo-app; 04 the chatvideo, forum and userfriends layers, pages, tour cards, herald VAPID; 05 rome deploy (LiveKit, Vuurmuur rule as a checkpoint, nginx, `.env`, DEPLOY.md, check-deploy/check-live); 06 unit tests and live verification. 03 and 04 may run in parallel after 02.
|
||||
- **D-21:** Anonymous visitors on `/forum` see a description and a "register to join" prompt, as on `/chat` (D-04). No public forum read endpoint.
|
||||
- **D-22:** People-moderation uses a demo moderator user group (bm pattern) whose members can delete messages from the site UI; banning goes through the existing user admin. Chat-family admin screens are deferred.
|
||||
- **D-23:** chatvideo keeps about 4 participants per room (D-12). Calls are limited to DMs by the UI only: the call button appears only in DM conversations. The server does not refuse channel calls.
|
||||
|
||||
### Claude's Discretion
|
||||
- Seeded channel names and persona users, and how much EN/PL history to seed.
|
||||
- How LiveKit's media and TURN ports are exposed on rome (embedded TURN vs nginx stream), as long as calls work from typical NAT'd browsers.
|
||||
- The exact shape of the pre-save validator seam in chat and forum (interface name, registration through `pact` or the service registry), within D-18's constraints.
|
||||
- Which chatbots commands are registered for the demo (`/help`, `/demo`, …) and what they answer.
|
||||
- How `vue-chat-layer` exposes extension points for app-specific embeds.
|
||||
|
||||
### Folded Todos
|
||||
- **Commit sm-demo-app's leftover journal and oauthserver submodule moves** (`.planning/todos/pending/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md`, g15office `ssuyq2os`): sm-demo-app still shows `M plugins/golem15/oauthserver`. This phase bumps submodule pointers and go.mod requires anyway, so the first plan moves each checkout to the tag go.mod requires and makes one `chore: bump <plugin> to <tag>` commit per plugin before mounting the chat family. `TestGoModRequiresTags` and `scripts/check-pins.sh` must pass.
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### Prior demo decisions
|
||||
- `.planning/phases/19.4.1-early-public-demo-on-demo-summercms-io/19.4.1-CONTEXT.md` — hosting, accounts, reset job, banner, tour, EN/PL, look, Centrifugo, tagged-module go.mod (D-01..D-23)
|
||||
- `../sm-demo-app/DEPLOY.md` — rome layout, ports in use, supervisor programs, Centrifugo, release and rollback
|
||||
- `../sm-demo-app/README.md` — local run, `demo:seed`, credentials handling
|
||||
- `../sm-demo-app/summer.yaml`, `../sm-demo-app/go.mod` — the current plugin set and tagged requires
|
||||
|
||||
### Chat UI source (port)
|
||||
- `../sm-bm-app/frontend/src/components/chat/` — ChatPanel, MessageList, ChatMessage, ChatComposer, ChatHeader, DmList, MemberPicker, MentionPopup, ReactionBar, EmbedCard, EmbedPicker, StagingChips, DeleteDialog, Avatar
|
||||
- `../sm-bm-app/frontend/src/stores/chat.ts`, `../sm-bm-app/frontend/src/api/chat.ts`, `../sm-bm-app/frontend/src/lib/chat{Events,Format,Text,Unread}.ts` (with their tests)
|
||||
- `../sm-bm-app/plugins/jz/bm/classes/chat_context.go`, `../sm-bm-app/plugins/jz/bm/chat.go` — host context and access wiring to mirror in `golem15.demo`
|
||||
- `.planning/phases/19.4.1-early-public-demo-on-demo-summercms-io/` plans for the presentations port (precedent for porting a bm Vite view into Nuxt)
|
||||
|
||||
### Plugins
|
||||
- `../sm-chat-plugin/README.md` — Context contract, Service, events, generic API, channel authorizer
|
||||
- `../sm-chatchannels-plugin/README.md` — channels, access resolver
|
||||
- `../sm-chatvideo-plugin/README.md` — LiveKit config keys, API, reconcile job, limits
|
||||
- `../sm-chatbots-plugin/README.md` — command catalog, dispatch, bot-auth seam
|
||||
- `../sm-forum-plugin/README.md`, `../sm-herald-plugin/README.md`, `../sm-userfriends-plugin/README.md`
|
||||
- `.planning/phases/19-chat-and-chatchannels/19-CONTEXT.md` — chat port contract (PHP parity, D-07 generic API mirrors WavePath, which must not drift)
|
||||
- `.planning/phases/19.1-chat-satellites/19.1-CONTEXT.md` — satellites decisions
|
||||
- `.planning/notes/core-plugins-own-repos.md` — new shared plugin and layer repos, submodule mounting, commit order
|
||||
|
||||
### Existing layers (pattern)
|
||||
- `../vue-journal-layer`, `../vue-faq-layer`, `../vue-menu-layer`, `../vue-paymentgateway-layer`, `../vue-oauth-layer` — layer structure, tags, how `vue-demo-app` consumes them
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- bm chat SPA (about 4.6k lines with tests): the store, the API client and the event handling port almost directly; components need Nuxt/SSR guards (client-only realtime).
|
||||
- The demo's Centrifugo and `lighthouse` wiring from 19.4.1 plan 04 (speaker/remote sync) carries chat realtime too; chat authorizes `chat:conversation:{id}` and chatvideo `video:session:{id}`.
|
||||
- `golem15.demo` plugin and `demo:seed`, with the EN/PL seeding through translate (19.4.1 D-13).
|
||||
- The `realtime_test.go`, `seed_test.go`, `reset_test.go` and `go_mod_test.go` patterns in sm-demo-app for the new plugins.
|
||||
|
||||
### Established Patterns
|
||||
- Shared plugin and layer repos are their own git.golem15.com repos, mounted as submodules. Plugins are pushed and tagged first, then the pointer bump in the app goes in a separate commit.
|
||||
- `go.mod` requires tagged versions (19.4.1 D-03); `scripts/check-pins.sh` and `TestGoModRequiresTags` enforce checkouts at the tag.
|
||||
- chat/chatchannels APIs and events are PHP-parity contracts for WavePath (Phase 19 D-07, Phase 31). Changes are additive only.
|
||||
|
||||
### Integration Points
|
||||
- `golem15.demo`: chat `contracts.Context`, chatchannels `ChannelAccessResolver`, seed, moderation word lists, chatbots command registrations.
|
||||
- `vue-demo-app/nuxt.config.ts` `extends`: four new layers.
|
||||
- rome: new supervisor program for LiveKit, firewall and nginx changes, `.env` keys (LiveKit, VAPID).
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- "There is a simple chat view in sm-bm-app": port it rather than build chat UI from scratch.
|
||||
- The video UI starts small but as a reusable layer ("make it as initial vue-chatvideo-layer"), so later sites and the WavePath work can grow it.
|
||||
- The user prefers reusable shared pieces over demo-only code: a layer for each satellite with UI, and a shared moderation plugin rather than a filter inside `golem15.demo`.
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- Web Push subscribe UI (opt-in notifications for DMs and mentions, sharing the PWA service worker): herald is mounted without UI here.
|
||||
- User-created channels (public or private) and ad-hoc group DMs with group calls.
|
||||
- A live bot persona answering in channels.
|
||||
- A read-only `/chat` preview for anonymous visitors.
|
||||
- Admin screens for chat-family plugins (deleting messages from the backend); D-22 uses a site-side moderator group for now.
|
||||
|
||||
### Reviewed Todos (not folded)
|
||||
- "Prompt open admin and site tabs to reload when a new version is deployed": its site half was folded into 19.4.1 (D-22); the admin half is framework work, not this phase.
|
||||
- "Translate Messages as a Nuxt i18n JSON editor", "Port the menu plugin as sm-menu-plugin…", and the user/mfa/cabana/build todos that matched on keywords only: they are unrelated to the chat family on the demo.
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 20-demo-instance-with-every-shared-plugin*
|
||||
*Context gathered: 2026-10-28*
|
||||
@@ -0,0 +1,137 @@
|
||||
# Phase 20: Demo instance with every shared plugin - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-10-28
|
||||
**Phase:** 20-demo-instance-with-every-shared-plugin
|
||||
**Areas discussed:** Chat frontend source, Chat context & access, Video calls hosting, Abuse & satellites scope
|
||||
|
||||
The user corrected the opening premise: "There is simple chat view in sm-bm-app". bm's Vite SPA chat (about 4.6k lines) became the port source.
|
||||
|
||||
Folded todo: "Commit sm-demo-app's leftover journal and oauthserver submodule moves" (Fold it in).
|
||||
|
||||
---
|
||||
|
||||
## Chat frontend source
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| New vue-chat-layer | Shared Nuxt layer; bm-specific bits stripped or slots | ✓ |
|
||||
| Demo-only pages | Port into vue-demo-app as 19.4.1 did for presentations | |
|
||||
| Layer, but later | Demo-only now, extraction as backlog todo | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Full bm feature set | Channels, DMs, threads, mentions, reactions, typing, unread, attachments | ✓ |
|
||||
| Core only | Channels and DMs with realtime, unread, typing | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Dedicated /chat page | Full-page chat plus tour card | |
|
||||
| Page + floating panel | /chat plus floating DM/unread widget | ✓ |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Read-only #general preview | Live read-only seeded channel | |
|
||||
| Login prompt only | As quizzes D-18 | ✓ |
|
||||
|
||||
---
|
||||
|
||||
## Chat context & access
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| One demo community | Single context like bm/1 | ✓ |
|
||||
| Per-user workspaces | Users create contexts | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Seeded public + user private | Users create private channels | |
|
||||
| Anyone, any channel | | |
|
||||
| Seeded only | Join seeded channels, DM each other | ✓ |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Seeded users + history, EN/PL | Persona users with history | ✓ |
|
||||
| Plus a live bot | Bot answers slash commands | |
|
||||
| Empty channels | | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Any member of the community | userfriends does not gate DMs | ✓ |
|
||||
| Friends only | | |
|
||||
|
||||
---
|
||||
|
||||
## Video calls hosting
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Self-host on rome | LiveKit under supervisor, own keys | ✓ |
|
||||
| LiveKit Cloud free tier | | |
|
||||
| Decide in research | | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| DMs and private groups | No calls in public channels | ✓ |
|
||||
| Any conversation | | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| LiveKit components in the chat layer | Call view on livekit-client | |
|
||||
| Minimal call page | Bare-bones join page | ✓ (as the initial vue-chatvideo-layer) |
|
||||
|
||||
**Notes:** "make it as initial vue-chatvideo-layer". Since channels are seeded only and chat has no ad-hoc group DMs, calls are effectively 1:1 DMs.
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Short calls, few people | ~15 min, ~4 participants | ✓ |
|
||||
| Plugin defaults | | |
|
||||
|
||||
---
|
||||
|
||||
## Abuse & satellites scope
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Rate limits + report + reset | No content filter | |
|
||||
| Plus a word filter | Banned words/link filter | ✓ |
|
||||
| Posting needs approval | | |
|
||||
|
||||
Follow-up, filter home: Hook in plugins + list in demo / Demo plugin only / **New shared plugin** ✓
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Forum page + friends in chat + /commands | Built in demo or chat layer | |
|
||||
| A layer for each | vue-forum-layer, vue-userfriends-layer | ✓ |
|
||||
| Forum only | | |
|
||||
|
||||
Follow-up, which layers: **forum + userfriends; commands in chat layer** ✓ / Also a vue-chatbots-layer
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Push for DMs and mentions | Opt-in notifications | |
|
||||
| Mounted, no UI | | ✓ |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Backend first, then layers | 5-plan split | ✓ |
|
||||
| You decide at plan time | | |
|
||||
|
||||
---
|
||||
|
||||
## Claude's Discretion
|
||||
|
||||
- Seeded channel names, personas, history volume
|
||||
- LiveKit port/TURN exposure on rome
|
||||
- Shape of the pre-save validator seam in chat and forum
|
||||
- Demo chatbots commands
|
||||
- vue-chat-layer extension points for embeds
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
- Web Push subscribe UI
|
||||
- User-created channels and ad-hoc group DMs/group calls
|
||||
- Live bot persona
|
||||
- Anonymous read-only chat preview
|
||||
@@ -0,0 +1,288 @@
|
||||
# Phase 20: Demo instance with every shared plugin - Pattern Map
|
||||
|
||||
**Mapped:** 2026-10-28
|
||||
**Files analyzed:** 34 (grouped by repo)
|
||||
**Analogs found:** 31 / 34
|
||||
|
||||
Paths are relative to the summercms.go repo root. The analogs are tracked source in their own repos. bm's `plugins/golem15/*` trees are nested submodules of sm-bm-app, and `plugins/jz/bm` is its own repo. Edit the shared plugins only in their `../sm-*-plugin` checkouts, after fast-forwarding them (RESEARCH Pitfall 1). Read the bm copies for reference only.
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match |
|
||||
|---|---|---|---|---|
|
||||
| `../sm-chat-plugin/events/events.go` (+`MessageSaving`) | event type | event-driven (pre-save) | `sm-user-plugin/classes/events.go` (DeactivatingEvent, lines 60-90) | role-match |
|
||||
| `../sm-chat-plugin/contracts/rejection.go` (new) | contract/error | request-response | none (new type); errors pattern in `chat/api/errors.go:35-45` | partial |
|
||||
| `../sm-chat-plugin/classes/service.go` (fire seam in `SendMessageWith`, `EditMessage`) | service | CRUD | itself, lines 287-345 | exact |
|
||||
| `../sm-chat-plugin/api/errors.go` (map Rejection to 422) | api helper | request-response | itself, `writeValidation` lines 39-44 | exact |
|
||||
| `../sm-forum-plugin/events/events.go` (new pkg, `PostSaving`) | event type | event-driven | chat `events/events.go` | role-match |
|
||||
| `../sm-forum-plugin/classes/forum_service.go` (fire in CreateThread/Reply/EditPost) | service | CRUD | itself, lines 263-300 (`Bus` field line 57-58, unused) | exact |
|
||||
| `../sm-forum-plugin/api/errors.go` | api helper | request-response | itself, `writeValidation` line 28, `writeForumError` line 44 | exact |
|
||||
| `../sm-chatvideo-plugin/go.mod`, `../sm-herald-plugin/go.mod`, `../sm-userfriends-plugin/go.mod` | config | n/a | `sm-bm-app/plugins/golem15/chatbots/go.mod` (commit d0b9fb1 "drop sibling replaces") | exact |
|
||||
| `../sm-moderation-plugin/plugin.go` | plugin entry | boot | `../sm-sitemap-plugin/plugin.go` lines 1-80 | role-match |
|
||||
| `../sm-moderation-plugin/admin.go` (HasSettings) | admin | CRUD (settings singleton) | `../sm-sitemap-plugin/admin.go` lines 40-72 | exact |
|
||||
| `../sm-moderation-plugin/models/settings.go` + `models/settings/fields.yaml` | model | CRUD | `../sm-sitemap-plugin/models/settings.go`, `models/settings/fields.yaml` | exact |
|
||||
| `../sm-moderation-plugin/classes/filter.go` | service | transform | none | no analog |
|
||||
| `../sm-moderation-plugin/updates/`, `lang/`, `config/`, `README.md` | migration/lang/config | n/a | `../sm-sitemap-plugin/{updates,lang,config,README.md}` | exact |
|
||||
| `../sm-demo-app/plugins/demo/chat.go` | host wiring | boot/event-driven | `../sm-bm-app/plugins/jz/bm/chat.go` lines 1-95 | exact |
|
||||
| `../sm-demo-app/plugins/demo/classes/chat_context.go` (DemoContext, ChannelContext adapter, access resolvers) | model/contract impl | request-response | `../sm-bm-app/plugins/jz/bm/classes/chat_context.go` | exact |
|
||||
| `../sm-demo-app/plugins/demo/realtime_access.go` | middleware | request-response | `../sm-bm-app/plugins/jz/bm/realtime_access.go` | exact |
|
||||
| `../sm-demo-app/plugins/demo/routes.go` (mountRealtime guard, chat bootstrap routes) | route | request-response | itself, lines 60-87 | exact |
|
||||
| `../sm-demo-app/plugins/demo/chat_api.go` (`GET /_demo/api/v1/chat`, `/chat/members`) | controller | request-response | `../sm-bm-app/plugins/jz/bm/controllers/api/chat.go`; style `plugins/demo/quizzes_api.go` | exact |
|
||||
| `../sm-demo-app/plugins/demo/chatbots.go` (demo commands) | service | request-response | RESEARCH Code Examples "Chatbots command"; contracts `chatbots/contracts/slash_command.go` | partial |
|
||||
| `../sm-demo-app/plugins/demo/plugin.go` (Requires, Middlewares, Boot calls registerChat) | plugin entry | boot | itself, lines 33-100 | exact |
|
||||
| `../sm-demo-app/plugins/demo/console/seed_chat.go`, `seed_forum.go`, `seed_friends.go` | console/seed | batch | `plugins/demo/console/seed.go` (`seedQuiz` 309-340, `insertID`/`exists` 177-189) | exact |
|
||||
| `../sm-demo-app/plugins/demo/console/reset.go` (`resetTables`) | console | batch | itself, lines 21-75 | exact |
|
||||
| `../sm-demo-app/{summer.yaml,go.mod,go.work,.gitmodules}` | config | n/a | existing entries (summer.yaml plugins list) | exact |
|
||||
| `../sm-demo-app/go_mod_test.go`, `boot_test.go`, `realtime_test.go`, `reset_test.go`, `seed_test.go` (extend) + new `chat_test.go`, `seed_chat_test.go` | test | n/a | the same files (`TestGoModRequiresTags` :22, `TestBootAllPlugins` :31, `TestRealtimeRoutes` :124) | exact |
|
||||
| `../sm-demo-app/deploy/centrifugo.json.example` (chat/user/video namespaces) | config | pub-sub | itself lines 41-46 + `../sm-bm-app/deploy/centrifugo/bm-config.json` lines 39+ | exact |
|
||||
| `../sm-demo-app/deploy/supervisor/summercms-demo-livekit.conf`, `deploy/livekit.yaml.example` | config | n/a | `deploy/supervisor/summercms-demo-centrifugo.conf`; RESEARCH supervisor block | exact |
|
||||
| `../sm-demo-app/deploy/nginx/demo.summercms.io.conf`, `scripts/{dev.sh,check-deploy.sh,check-live.sh,check-local.sh}`, `DEPLOY.md` | config/script | n/a | the same files (Centrifugo `/ws` sections) | exact |
|
||||
| `../vue-chat-layer/nuxt.config.ts`, `package.json` | config | n/a | `../vue-faq-layer/nuxt.config.ts`, `package.json` | exact |
|
||||
| `../vue-chat-layer/app/composables/useChatApi.ts` (auth hook `chatAuth`) | composable | request-response | `../vue-paymentgateway-layer/app/composables/usePaymentGatewayApi.ts` | exact |
|
||||
| `../vue-chat-layer/app/composables/useChat.ts` (store) | store | event-driven | `../sm-bm-app/frontend/src/stores/chat.ts` (Pinia setup store, convert) | role-match |
|
||||
| `../vue-chat-layer/app/lib/realtime.ts` (+`onUserEvent`) | utility | pub-sub | `../sm-demo-app/vue-demo-app/app/lib/realtime.ts` (`openChannel` :58, dynamic import :59) | exact |
|
||||
| `../vue-chat-layer/app/lib/chat{Events,Format,Text,Unread}.ts` + tests, `app/components/chat/*` | utility/component | transform | `../sm-bm-app/frontend/src/lib/chat*.ts`, `frontend/src/components/chat/*` | exact (port) |
|
||||
| `../vue-chat-layer/app/components/chat/{ChannelList,ThreadPane,PinsPanel,CommandPicker}.vue` | component | request-response | none in bm (bm has one channel, no threads/pins) | no analog |
|
||||
| `../vue-chatvideo-layer/*` (call page on livekit-client) | component/composable | streaming | none (bm has no video UI) | no analog |
|
||||
| `../vue-forum-layer/*`, `../vue-userfriends-layer/*` | layer | request-response | `../vue-faq-layer` (structure) + `../vue-paymentgateway-layer` (authenticated client) | role-match |
|
||||
| `../sm-demo-app/vue-demo-app/{nuxt.config.ts,package.json,app/plugins/auth.ts}` | config/provider | n/a | itself: `extends` line 51, layer deps lines 19-22, auth.ts | exact |
|
||||
| `../sm-demo-app/vue-demo-app/app/pages/{chat.vue,forum.vue,friends.vue,call/[id].vue}` | page | request-response | `app/pages/quizzes/index.vue` (anonymous register prompt, lines 46, 68) | exact |
|
||||
| `../sm-demo-app/vue-demo-app/app/pages/index.vue` (tour cards) | page | n/a | itself, `<TourCard` line 40 | exact |
|
||||
|
||||
Edit `sm-demo-app/vue-demo-app` (the submodule), not the stale `../vue-demo-app` (RESEARCH Anti-Patterns).
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### Chat / forum pre-save seam (D-18)
|
||||
|
||||
**Event type analog:** `sm-user-plugin/classes/events.go` (read here at `../sm-demo-app/plugins/golem15/user/classes/events.go`), lines 13-17 and 74-86:
|
||||
```go
|
||||
const DeactivatingEventName = "user.Deactivating"
|
||||
// ... A listener error rolls the deactivation back.
|
||||
type DeactivatingEvent struct {
|
||||
User *models.User
|
||||
Tx *gorm.DB
|
||||
}
|
||||
```
|
||||
`MessageSaving` and `PostSaving` follow the same doc-comment style and add a `<Name>EventName` const. They fire **before** the transaction, so they carry no `Tx`.
|
||||
|
||||
**Fire site, chat:** `../sm-chat-plugin/classes/service.go` `SendMessageWith` (lines 287-345 at v0.1.3). Insert the call after the `ErrEmptyMessage` check (line ~311) and before `lagoon.Transaction`. Copy the existing nil-bus guard:
|
||||
```go
|
||||
if s.bus != nil {
|
||||
if err := s.bus.Fire(ctx, &events.MessageCreated{MessageEvent: events.MessageEvent{
|
||||
MessageID: msg.ID, ConversationID: conversationID, ActorID: senderID, RootID: rootID, Tx: tx,
|
||||
}}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
```
|
||||
Do the same in `EditMessage` after `CanEditMessage`. `Fire` returns `errors.Join`, so callers use `errors.As`.
|
||||
|
||||
**Fire site, forum:** `../sm-forum-plugin/classes/forum_service.go` `CreateThread` lines 263-281. Fire between the `CanPost` check and `html := breakwater.Render(body)` (line 281). Do the same in `Reply` (line 316, before the Render at 330) and in `EditPost`. Use `s.Bus` (field at line 57-58), guarded for nil.
|
||||
|
||||
**422 mapping:** `../sm-chat-plugin/api/errors.go` lines 39-44 (forum: `api/errors.go:28`):
|
||||
```go
|
||||
func writeValidation(w http.ResponseWriter, errs map[string][]string) {
|
||||
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{
|
||||
"error": errValidation,
|
||||
"errors": errs,
|
||||
})
|
||||
}
|
||||
```
|
||||
In the service-error mapper (`writeChatError` / `writeForumError` line 44), add `var rej contracts.Rejection; if errors.As(err, &rej) { writeValidation(w, map[string][]string{rej.Field: {rej.Message}}); return }` before the 500 default. With no listener registered, no existing branch changes.
|
||||
|
||||
**Tests:** keep `php_method_map_test.go` and `golden_test.go` green. Add the seam tests next to `tombstone_test.go` / `upload_limit_test.go`, using the `harness_test.go` fixture.
|
||||
|
||||
---
|
||||
|
||||
### `../sm-moderation-plugin` (new shared plugin)
|
||||
|
||||
**Plugin entry analog:** `../sm-sitemap-plugin/plugin.go` lines 1-60. Copy the package doc comment, the interface assertions and the embedded config:
|
||||
```go
|
||||
var (
|
||||
_ party.Plugin = (*Plugin)(nil)
|
||||
_ pact.HasConfig = (*Plugin)(nil)
|
||||
_ pact.HasMigrations = (*Plugin)(nil)
|
||||
_ pact.HasModels = (*Plugin)(nil)
|
||||
)
|
||||
//go:embed config
|
||||
var configFS embed.FS
|
||||
type Plugin struct { app *backpack.App }
|
||||
func (p *Plugin) ID() string { return "golem15.sitemap" }
|
||||
func (p *Plugin) Requires() []string { return nil }
|
||||
func (p *Plugin) Register(app *backpack.App) error { p.app = app; return nil }
|
||||
```
|
||||
Publish `*classes.Filter` from an `lagoon.OnDatabase` hook in Boot, the way chat publishes its Service (`app.Lookup[*chatclasses.Service]()` in bm chat.go:58). The plugin must not import chat or forum.
|
||||
|
||||
**Settings analog:** `../sm-sitemap-plugin/admin.go` lines 54-72:
|
||||
```go
|
||||
{
|
||||
Code: "sitemap_settings",
|
||||
Label: "golem15.sitemap::lang.settings.label",
|
||||
Description: "golem15.sitemap::lang.settings.description",
|
||||
Category: "golem15.sitemap::lang.settings.category",
|
||||
Icon: "settings",
|
||||
Permissions: []string{controllers.PermissionAccessDefinitions},
|
||||
Form: "models/settings/fields.yaml",
|
||||
NewModel: func() any { return &models.Settings{} },
|
||||
},
|
||||
// ...
|
||||
_ pact.HasSettings = (*Plugin)(nil)
|
||||
_ pact.HasLang = (*Plugin)(nil)
|
||||
```
|
||||
**Model:** `../sm-sitemap-plugin/models/settings.go` uses a singleton row id 1 on `golem15_<plugin>_settings`, `*string` columns, and validation in `BeforeValidate` with a lang-key error const (`errFrontendURL`). The moderation model gets `banned_words` and `banned_links` (textarea, one per line). **fields.yaml:** copy the header block and the `label/comment/type/span` shape from `../sm-sitemap-plugin/models/settings/fields.yaml`.
|
||||
|
||||
**Filter (no analog):** a stdlib-only `Check(ctx, text) *Violation`: case-insensitive word-boundary match for words; links matched by scheme, host or bare domain. Use table tests.
|
||||
|
||||
---
|
||||
|
||||
### `../sm-demo-app/plugins/demo/chat.go` (host wiring)
|
||||
|
||||
**Analog:** `../sm-bm-app/plugins/jz/bm/chat.go` lines 1-95.
|
||||
|
||||
Imports (lines 3-15). Swap the bm classes for `sm-demo-app/plugins/demo/classes` and add the forum, chatbots, moderation and chat events imports:
|
||||
```go
|
||||
chatclasses "git.golem15.com/golem15/sm-chat-plugin/classes"
|
||||
chatcontracts "git.golem15.com/golem15/sm-chat-plugin/contracts"
|
||||
chanclasses "git.golem15.com/golem15/sm-chatchannels-plugin/classes"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
```
|
||||
Core (lines 35-50, 72-87). Add a second `reg.Add` for `chanmodels.ChannelAlias` (RESEARCH Pattern 1):
|
||||
```go
|
||||
if app == nil || !app.HasPlugin("golem15.chat") { return nil }
|
||||
reg, ok := app.Lookup[*chatcontracts.Registry]()
|
||||
if !ok || reg == nil { return errors.New("jz.bm: golem15.chat must be listed before jz.bm in summer.yaml") }
|
||||
if err := reg.Add(p.ID(), chatcontracts.Registration{Alias: classes.ChatContextType, Loader: p.loadChatContext}); err != nil { return err }
|
||||
...
|
||||
return lagoon.OnDatabase(app, func(_ *sql.DB, _ *gorm.DB) error {
|
||||
svc, ok := app.Lookup[*chanclasses.ChannelService]()
|
||||
if !ok || svc == nil { return errors.New("... must be listed before ...") }
|
||||
svc.SetAccessResolver(classes.NewChatAccess(p.database, p.chatGroup(), nil))
|
||||
return nil
|
||||
})
|
||||
```
|
||||
Copy the config-key const block (lines 17-28) for `golem15.demo.chat.moderator_group` and the limits. The moderation listeners follow RESEARCH Pattern 2 (`app.Events.Listen[*chatevents.MessageSaving](p.ID(), ...)`).
|
||||
|
||||
### `../sm-demo-app/plugins/demo/classes/chat_context.go`
|
||||
|
||||
**Analog:** `../sm-bm-app/plugins/jz/bm/classes/chat_context.go`. Copy:
|
||||
- The interface assertions (lines 12-17).
|
||||
- The const block (lines 21-35): `ChatContextType = "demo"`, `ChatContextID = 1`, `ChatBroadcastChannel = "chat:context:1"`. The broadcast channel must never be empty.
|
||||
- `MemberFunc` injection and `WithMembership` for tests.
|
||||
- The five one-line methods (`ID/Type/ChatEnabled/GroupName/BroadcastChannel`).
|
||||
- `CanActFor` → `chatcontracts.DefaultCanActFor`; `CanOverseeChats` false.
|
||||
- `CanModerateMessage` limited to `conversationType == "group"` plus the moderator group (D-22).
|
||||
- `RequireLiveMembership` true.
|
||||
|
||||
Difference from bm: `HasMember` means "registered, not deleted, not banned". There is no group check (D-06), so drop `ChatMemberIDs`'s group join and query `users` directly. Add a `ChannelContext` with the same methods, loaded by channel id, and `BroadcastChannel() = "chat:channel:{id}"`.
|
||||
|
||||
### `../sm-demo-app/plugins/demo/realtime_access.go`
|
||||
|
||||
**Analog:** `../sm-bm-app/plugins/jz/bm/realtime_access.go` lines 1-70. Copy `MiddlewareRealtimeAccess` (name it `demo.realtime-access`), the `presentationsOwnerGuard` name const, the `sync.Once` owner-guard lookup, and the 401/503/member-pass flow (`bouncer.User(r.Context())`, `writeRealtimeGuard`). Then change `../sm-demo-app/plugins/demo/routes.go:78-86`. Mount when presentations **or** chat is active, and replace `"presentations.realtime-owner"` with the new guard:
|
||||
```go
|
||||
return lighthouse.Mount(r, p.realtime.Driver(), lighthouse.Surfaces{
|
||||
UserAuth: surf.Use("jwt.auth", "presentations.realtime-owner", bucketRealtime),
|
||||
ServerToServer: surf.Use(),
|
||||
Middleware: surf.Use(),
|
||||
})
|
||||
```
|
||||
`plugin.go` gains `pact.HasMiddleware` (`Middlewares() map[string]pact.Middleware`, bm realtime_access.go:24-29).
|
||||
|
||||
### `../sm-demo-app/plugins/demo/chat_api.go`
|
||||
|
||||
**Analog:** `../sm-bm-app/plugins/jz/bm/controllers/api/chat.go` (bootstrap JSON shape: channels with unread and mentions, DMs, members, limits). Mount it the way `routes.go:60-62` does: `r.Group(APIPrefix, surf.Use("jwt.auth", "throttle:120,1"), func(g pact.Router) { g.Get("/chat", ...) })`. Write errors with `wire.WriteJSON(w, status, map[string]string{"error": ...})` (routes.go:98).
|
||||
|
||||
### Seed and reset
|
||||
|
||||
**Seed analog:** `../sm-demo-app/plugins/demo/console/seed.go`:
|
||||
- Helpers at lines 177-189 (`exists`, `insertID` with `RETURNING id`).
|
||||
- `seedQuiz` (309-340) is the idempotent `(bool, error)` step shape, registered through `created(...)` in `runSeed` (40-80).
|
||||
- Use raw SQL only. Do not go through `chat.Service` or a `CreateChannel` gated by the resolver (RESEARCH Anti-Patterns).
|
||||
- Locale variants follow `seed_locales.go`.
|
||||
|
||||
**Reset analog:** `../sm-demo-app/plugins/demo/console/reset.go:21-40`, `resetTables []struct{ table, where string }`, children first. Insert the chat-family tables before `users` in the order from RESEARCH Pitfall 10. Keep `golem15_moderation_settings`.
|
||||
|
||||
### Tests (sm-demo-app)
|
||||
|
||||
Extend `go_mod_test.go:22 TestGoModRequiresTags` (its `wantPlugins` list), `boot_test.go:31 TestBootAllPlugins` (route prefixes), `realtime_test.go:124 TestRealtimeRoutes` (any member gets 200) and `reset_test.go`. New `chat_test.go` follows `demo_api_test.go`, and `seed_chat_test.go` follows `seed_test.go`, Docker-gated through `testmain_test.go`.
|
||||
|
||||
### Deploy config
|
||||
|
||||
- **Centrifugo:** `../sm-demo-app/deploy/centrifugo.json.example:41-46` uses the `{"name": ..., "subscribe_proxy_enabled": true}` format. Add `chat` (presence and join_leave options from `../sm-bm-app/deploy/centrifugo/bm-config.json` namespaces, line 39+), `user` and `video`. Keep the demo's option names, not bm's named proxies (RESEARCH A6).
|
||||
- **Supervisor:** copy `deploy/supervisor/summercms-demo-centrifugo.conf` to `summercms-demo-livekit.conf` using the RESEARCH "Supervisor program" block. **LiveKit yaml:** use the RESEARCH block (ports 7890/7891/7892).
|
||||
- **nginx:** add the `location ^~ /livekit/` block next to the existing `/ws` block in `deploy/nginx/demo.summercms.io.conf`, and extend the Go path allow-list with `/_chat/ /_chatchannels/ /_chatvideo/ /_chatbots/ /_forum/ /_userfriends/`.
|
||||
|
||||
---
|
||||
|
||||
### `../vue-chat-layer` (and the structure of the other three layers)
|
||||
|
||||
**Config analog:** `../vue-faq-layer/nuxt.config.ts`. Copy:
|
||||
- The header comment ("An app consumes it with `extends: [...]`").
|
||||
- `const currentDir = fileURLToPath(new URL('.', import.meta.url))` with absolute css paths.
|
||||
- `runtimeConfig.<x>.apiBase` (server, `NUXT_<X>_API_BASE`) and `runtimeConfig.public.<x>.apiBase` (same-origin).
|
||||
|
||||
Leave out the faq-specific `cachePurge`/`siteCache` keys. Add `public.chat.hostApiBase` (default `/_demo/api/v1` must NOT be hard-coded with an app name; default `''` and let the app set it) and `routeRules: { '/chat/**': { ssr: false } }`.
|
||||
|
||||
**package.json analog:** `../vue-faq-layer/package.json`: `"private": true, "type": "module", "main": "./nuxt.config.ts"`, `engines.node >=22.18`, `peerDependencies.nuxt 4.4.8`, devDeps nuxt 4.4.8 / typescript 5.9.3 / vue 3.5.35. For vue-chat-layer, replace the `node --test` script with vitest 3.2.7 and add `centrifuge@5.7.3`.
|
||||
|
||||
**Auth-hook client analog:** `../vue-paymentgateway-layer/app/composables/usePaymentGatewayApi.ts` lines 1-55. Copy the doc comment with the `defineNuxtPlugin` provide example, the hook lookup through `useNuxtApp()`, `Authorization: Bearer`, and the server-vs-browser base split:
|
||||
```ts
|
||||
type AuthHook = () => string | null | undefined
|
||||
const auth = (useNuxtApp() as unknown as { $paymentGatewayAuth?: AuthHook }).$paymentGatewayAuth
|
||||
const authHeaders = (): Record<string, string> => {
|
||||
const token = typeof auth === 'function' ? auth() : null
|
||||
return token ? { authorization: `Bearer ${token}` } : {}
|
||||
}
|
||||
```
|
||||
For chat, the hook becomes `$chatAuth: { token(), refresh(), unauthorized() }` (RESEARCH Pattern 4). The pure client goes in `app/utils/client.ts` (as in faq/paymentgateway `utils/client.ts`) so `tests/*.test.ts` can test it without Nuxt.
|
||||
|
||||
**App wiring:** `../sm-demo-app/vue-demo-app/app/plugins/auth.ts`:
|
||||
```ts
|
||||
export default defineNuxtPlugin(() => ({
|
||||
provide: { paymentGatewayAuth: () => useCookie<string | null>(DEMO_TOKEN_COOKIE).value ?? null },
|
||||
}))
|
||||
```
|
||||
Add `chatAuth` (and forum/friends/video hooks) to the same `provide`. Append the layers to `nuxt.config.ts:51` `extends`, and add `package.json` deps in the `git+https://git.golem15.com/golem15/vue-x-layer.git#vX.Y.Z` form (lines 19-22).
|
||||
|
||||
**Store port:** `../sm-bm-app/frontend/src/stores/chat.ts`. It imports `defineStore` from 'pinia' plus `@/api/chat`, `@/lib/chatEvents`, `@/stores/toast`. Convert it to a module-level composable that uses `useState` (no Pinia, 19.4.1). Replace `general`/`generalId` with `channels[]`. Strip the bm `embeds` lib (`deadline|class|reading`) and add a slot or renderer registry for it.
|
||||
|
||||
**Realtime analog:** `../sm-demo-app/vue-demo-app/app/lib/realtime.ts`: `REALTIME_WS_PATH = '/ws'` (:7), `UNAVAILABLE = new Set([401,403,404])` (:19), `tokenFailureIsFinal` (:22), `wsURL` (:42), `openChannel` with `await import('centrifuge')` (:58-59). Move it into the layer as one shared client and add `onUserEvent(handler)` (Pattern 3). vue-chatvideo-layer reuses it and must not open a second client.
|
||||
|
||||
**Pages, anonymous prompt analog:** `../sm-demo-app/vue-demo-app/app/pages/quizzes/index.vue`. The doc comment (line 4) covers the anonymous prompt; `registerLink` (line 46) is `{ path: '/register', query: { next } }`; line 68 renders `<NuxtLink class="button button--small" :to="registerLink(...)">{{ t('quiz.registerToTake') }}</NuxtLink>`. Apply it to `/chat` (D-04) and `/forum` (D-21). Tour cards follow `<TourCard` in `app/pages/index.vue:40`. Wrap the floating panel in `<ClientOnly>` in the app layout.
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Host wiring through the service registry plus OnDatabase
|
||||
**Source:** `../sm-bm-app/plugins/jz/bm/chat.go:55-87`
|
||||
**Apply to:** demo chat.go (chat limits, chatchannels resolver, forum resolver, chatbots registry, moderation filter lookup). Use the guard `if !app.HasPlugin(id) { return nil }`, then `app.Lookup[*T]()` inside `lagoon.OnDatabase`. A missing service returns the error "must be listed before … in summer.yaml".
|
||||
|
||||
### Festival events with listener-error rollback
|
||||
**Source:** `sm-user-plugin/classes/events.go:60-110`, fire sites `classes/access_revoked.go:50`, `backend_links.go:358` (`app.Events.Fire(ctx, &X{...})`)
|
||||
**Apply to:** the chat and forum seams and the demo listeners.
|
||||
|
||||
### Error envelope
|
||||
**Source:** `../sm-chat-plugin/api/errors.go:35-45`, `../sm-forum-plugin/api/errors.go:24-44`: `{"error": msg}` and the 422 `{"error":"Validation failed","errors":{field:[msg]}}` through `wire.WriteJSON`.
|
||||
**Apply to:** the seam rejection and the demo chat bootstrap API.
|
||||
|
||||
### Layer conventions
|
||||
**Source:** `../vue-faq-layer` (config, package, `tests/*.test.ts`, `app/utils/client.ts` + `app/composables/use*Api.ts`), `../vue-paymentgateway-layer` (auth hook).
|
||||
**Apply to:** all four new layers. They name no app and have no Pinia.
|
||||
|
||||
### Release hygiene
|
||||
**Source:** `sm-bm-app/plugins/golem15/chatbots/go.mod` (commit d0b9fb1, no sibling replaces). Requires are tags only. `../sm-demo-app/scripts/check-pins.sh` and `go_mod_test.go:22`.
|
||||
**Apply to:** every plugin go.mod and the demo pointer bumps. Make one `chore: bump <plugin> to <tag>` commit per plugin.
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|---|---|---|---|
|
||||
| `../sm-moderation-plugin/classes/filter.go` | service | transform | No text filter exists. Use RESEARCH D-17 test map (word boundary, links). |
|
||||
| `../vue-chat-layer` ChannelList / ThreadPane / PinsPanel / CommandPicker | component | request-response | bm UI has one channel, no threads or pins. Build on chatchannels pins and chat replies APIs (RESEARCH OQ 4). |
|
||||
| `../vue-chatvideo-layer` call page | component | streaming | No video UI anywhere. Use livekit-client (human-verify checkpoint first), loaded with dynamic `import()` like centrifuge. |
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** sm-demo-app (plugins/demo, deploy, scripts, vue-demo-app), sm-bm-app (plugins/jz/bm, plugins/golem15/{chat,chatchannels,chatbots}, frontend/src), sm-forum-plugin, sm-sitemap-plugin, sm-user-plugin (via demo submodule), vue-faq-layer, vue-paymentgateway-layer
|
||||
**Files scanned:** ~40
|
||||
**Pattern extraction date:** 2026-10-28
|
||||
@@ -0,0 +1,694 @@
|
||||
# Phase 20: Demo instance with every shared plugin - Research
|
||||
|
||||
**Researched:** 2026-10-28
|
||||
**Domain:** Mounting the chat family (chat, chatchannels, chatvideo, chatbots, forum, herald, userfriends) into `sm-demo-app`, new shared Nuxt layers, a self-hosted LiveKit on rome, a new shared moderation plugin
|
||||
**Confidence:** HIGH for the backend and repo state (read and compiled this session), MEDIUM for LiveKit deployment, MEDIUM for the layer port effort
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
|
||||
#### Chat frontend
|
||||
- **D-01:** The chat UI is ported from `sm-bm-app`'s Vite SPA (`frontend/src/components/chat/`, `stores/chat.ts`, `api/chat.ts`, `lib/chat*.ts`, about 4.6k lines) into a new shared Nuxt layer, **`vue-chat-layer`**, consumed by `vue-demo-app` like the journal, menu, FAQ and paymentgateway layers. The layer is Golem15 code with no bm (JZ) specifics: BM link embeds, Terminarz deadline pins and other bm-only pieces are stripped or turned into slots or extension points. The layer names no application. — **Reversibility:** costly. A public layer with tagged releases; later sites pin it by tag.
|
||||
- **D-02:** The full bm feature set: channels, DMs, threads, mentions, reactions, typing, unread counts, attachments, member picker, delete dialog, embeds (generic link embeds only).
|
||||
- **D-03:** Chat appears as a full `/chat` page (channel sidebar, message pane) **and** a floating panel (DM list and unread badge) on every page for logged-in users. The homepage tour (19.4.1 D-11) gets a "Chat" card.
|
||||
- **D-04:** Anonymous visitors on `/chat` see a description and a "register to chat" prompt, the same pattern as quizzes (19.4.1 D-18). There is no read-only preview, and the floating panel is hidden for them.
|
||||
- **D-05:** Chatbots has no UI of its own. Its slash commands appear as a command picker in the `vue-chat-layer` composer. There is no `vue-chatbots-layer`.
|
||||
|
||||
#### Chat context and access
|
||||
- **D-06:** One demo community: a single chat context (in the spirit of bm's `bm/1`, `plugins/jz/bm/classes/chat_context.go`, e.g. `demo/1`) implemented in `golem15.demo` as the host `contracts.Context` and the chatchannels `ChannelAccessResolver`. Every registered user is a member automatically.
|
||||
- **D-07:** Channels are **seeded only** (for example #general, #announcements, a Polish channel). Users join seeded channels and cannot create channels of any kind.
|
||||
- **D-08:** Any community member can DM any other member, as in bm. userfriends does not gate DMs.
|
||||
- **D-09:** `demo:seed` creates a handful of persona users with message history, threads, reactions and pins in EN and PL channels. The reset job (19.4.1 D-09) wipes and reseeds them along with everything else. There is no live bot persona.
|
||||
|
||||
#### Video calls
|
||||
- **D-10:** LiveKit is self-hosted on rome under supervisor, next to the demo's Centrifugo (19.4.1 D-17), with its own keys in `.env`. The media ports (UDP range, TCP fallback, TURN/TLS through nginx stream or LiveKit's embedded TURN) are opened on rome and documented in `sm-demo-app/DEPLOY.md`. — **Reversibility:** reversible. Moving to LiveKit Cloud later only changes `golem15.chatvideo.livekit_*` config.
|
||||
- **D-11:** Calls start from DMs only. chat has DMs plus one context-wide group conversation (`GetGroupChat`) and no ad-hoc group DMs, and with D-07 there are no private channels, so in practice that means 1:1 calls. The researcher confirms this against the plugin. Calls are never started in seeded public channels.
|
||||
- **D-12:** Caps for a public demo: `max_duration` about 15 minutes, about 4 participants per room, the plugin's existing rate limits, and the `chatvideo:reconcile-sessions` job ending idle rooms.
|
||||
- **D-13:** The video UI is a **minimal call page** (own camera, remote tiles, mute, camera toggle, leave) built on `livekit-client` and released as the first version of a new shared **`vue-chatvideo-layer`**. bm has no video UI to port.
|
||||
|
||||
#### Forum, friends, push
|
||||
- **D-14:** **`vue-forum-layer`** is a new shared layer with a `/forum` page: categories, threads and Markdown posts, seeded in EN and PL by `demo:seed`.
|
||||
- **D-15:** **`vue-userfriends-layer`** is a new shared layer: friends list, requests and friend groups. It is linked from the chat sidebar and the profile.
|
||||
- **D-16:** herald (Web Push) is mounted and configured (VAPID keys in `.env`) but has **no subscribe UI** in this phase.
|
||||
|
||||
#### Moderation
|
||||
- **D-17:** A new shared plugin, **`sm-moderation-plugin`**, filters banned words and links in chat messages and forum posts. The banned-word and link lists are editable in its admin settings. On top of it: the plugins' rate limits, Turnstile on register (19.4.1 D-07), admins deleting messages and banning users from the backend, and the reset job.
|
||||
- **D-18:** chat and forum fire their festival events after the save (`events.MessageCreated` etc.), so the filter needs a **new, generic, additive pre-save seam** in `sm-chat-plugin` and `sm-forum-plugin`: a host- or plugin-registered validator that can reject a message or post before it is persisted. The change is additive and ships as patch tags. It must not change the existing PHP-parity API or payloads (these plugins are WavePath's port target, Phase 31), and the seam names no application. — **Reversibility:** costly. A public plugin contract other consumers can start using.
|
||||
|
||||
#### Plan shape (suggestion; confirmed at plan time per the lean-planning rule)
|
||||
- **D-19:** Backend first, then layers. The suggested split from the discussion, before the forum and userfriends layers and the moderation plugin were added: 01 mount and tag the chat family, demo context, seed, LiveKit and the rome deploy (with the folded todo); 02 the `vue-chat-layer` port; 03 `vue-chatvideo-layer` plus herald config; 04 forum and friends layers, slash-command picker, tour cards; 05 unit tests and live verification. The planner fits in `sm-moderation-plugin`, the pre-save seams and the two extra layers, and presents the final count for confirmation.
|
||||
|
||||
#### Folded Todos
|
||||
- **Commit sm-demo-app's leftover journal and oauthserver submodule moves** (`.planning/todos/pending/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md`, g15office `ssuyq2os`): sm-demo-app still shows `M plugins/golem15/oauthserver`. This phase bumps submodule pointers and go.mod requires anyway, so the first plan moves each checkout to the tag go.mod requires and makes one `chore: bump <plugin> to <tag>` commit per plugin before mounting the chat family. `TestGoModRequiresTags` and `scripts/check-pins.sh` must pass.
|
||||
|
||||
### Claude's Discretion
|
||||
- Seeded channel names and persona users, and how much EN/PL history to seed.
|
||||
- How LiveKit's media and TURN ports are exposed on rome (embedded TURN vs nginx stream), as long as calls work from typical NAT'd browsers.
|
||||
- The exact shape of the pre-save validator seam in chat and forum (interface name, registration through `pact` or the service registry), within D-18's constraints.
|
||||
- Which chatbots commands are registered for the demo (`/help`, `/demo`, …) and what they answer.
|
||||
- How `vue-chat-layer` exposes extension points for app-specific embeds.
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
- Web Push subscribe UI (opt-in notifications for DMs and mentions, sharing the PWA service worker): herald is mounted without UI here.
|
||||
- User-created channels (public or private) and ad-hoc group DMs with group calls.
|
||||
- A live bot persona answering in channels.
|
||||
- A read-only `/chat` preview for anonymous visitors.
|
||||
|
||||
#### Reviewed Todos (not folded)
|
||||
- "Prompt open admin and site tabs to reload when a new version is deployed": its site half was folded into 19.4.1 (D-22); the admin half is framework work, not this phase.
|
||||
- "Translate Messages as a Nuxt i18n JSON editor", "Port the menu plugin as sm-menu-plugin…", and the user/mfa/cabana/build todos that matched on keywords only: they are unrelated to the chat family on the demo.
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
No requirement IDs are mapped (ROADMAP: "Requirements: TBD"). The success criteria and D-01..D-19 are the source of truth.
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|------------------|
|
||||
| SC-1 | `sm-demo-app` builds with every shared plugin incl. chat, chatchannels, chatvideo, chatbots, forum, herald, userfriends; `demo:seed` fills each | §Repo state and tagging, §Demo host wiring, §Seeding |
|
||||
| SC-2 | Public Nuxt pages show chat/channels with realtime, video calls, presentations, journal, others through shared layers | §Layers, §Realtime, §LiveKit |
|
||||
| SC-3 | Deployed publicly; frontend open; `/backend` unreachable; "contact Golem15" CTA | §rome deployment, Open Question 1 |
|
||||
| SC-4 | New code unit-tested in the last plan | §Validation Architecture |
|
||||
</phase_requirements>
|
||||
|
||||
## Summary
|
||||
|
||||
The backend half is in better shape than the repo layout suggests, but every chat-family plugin needs release work before `sm-demo-app` can require it. Only `sm-chat-plugin` has tags (v0.1.0-v0.1.3 on origin). `chatchannels`, `chatbots`, `chatvideo`, `forum`, `herald` and `userfriends` have none. Three sibling checkouts (`../sm-chat-plugin`, `../sm-chatchannels-plugin`, `../sm-chatbots-plugin`) are **behind origin**; the current code lives in `sm-bm-app/plugins/golem15/*`. `chatvideo` still requires `sm-chat-plugin v0.0.0`, and `chatvideo` and `herald` keep sibling `replace` lines. I copied all seven trees to a scratch workspace, set their requires to tags (chat v0.1.3, user v0.1.13, summercms v0.5.1) and dropped the sibling replaces. With that, all seven `go build`, `go vet` and `go test -short` pass against framework **v0.5.1**, the version `sm-demo-app` pins. Without the go.mod fix, module resolution fails with `unknown revision v0.0.0`. So the gap is release hygiene, not framework drift.
|
||||
|
||||
The frontend half is larger than D-01's "port about 4.6k lines" implies. bm's chat SPA is built around **one** channel (`# ogólny`) plus DMs. Its store has `general`/`generalId` throughout. It has **no thread UI, no pins, no channel list**, and its embeds are bm entity types (`'deadline' | 'class' | 'reading'`), not link embeds. It also depends on a bm host bootstrap endpoint (`/_bm/api/v1/chat` and `/chat/members`). D-02 and D-07 (several seeded channels, threads, pins) therefore need new UI: a multi-channel store, a thread pane and a pins view. The demo also needs its own bootstrap endpoints in `golem15.demo`.
|
||||
|
||||
The backend gaps are small but each one blocks something:
|
||||
- `chatchannels` does not register the `channel` chat alias. Messages in channels made with `CreateChannel` resolve through a host "channel-as-context adapter", which `golem15.demo` must register.
|
||||
- The demo's realtime token route is limited to presentation owners. Chat needs every member to get a token.
|
||||
- Centrifugo needs the `chat`, `user` and `video` namespaces.
|
||||
- No chat-family plugin has admin screens. "Admins deleting messages from the backend" (D-17) has no UI today.
|
||||
|
||||
On rome, a WavePath LiveKit 1.13.1 already runs on 127.0.0.1:7880, 7881/tcp and 50000-60000/udp. Its media ports are defined in Vuurmuur but **not applied in the firewall rules**. The demo needs its own instance on free ports and a Vuurmuur rule.
|
||||
|
||||
**Primary recommendation:** Plan in three stages:
|
||||
1. Release work in the shared plugin repos: fast-forward the siblings, fix the go.mod files, add the pre-save seams, create `sm-moderation-plugin`, tag v0.1.x.
|
||||
2. Demo host wiring and seed: the context, the channel adapter, bootstrap endpoints, the realtime guard, Centrifugo namespaces, local LiveKit.
|
||||
3. Layers, with the realtime client living in `vue-chat-layer` and reused by `vue-chatvideo-layer`, then the rome deploy and the tests plan.
|
||||
|
||||
Set `golem15.chatvideo.max_participants: 2` so the server itself enforces D-11 (DM-only calls).
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| Chat persistence, permissions, realtime publish | API / Backend (`golem15.chat`) | Database | Shared plugin; host only supplies context |
|
||||
| Demo community membership, channel adapter, channel/forum access policy | API / Backend (`golem15.demo`) | — | Host-specific policy (D-06); plugins stay neutral |
|
||||
| Chat bootstrap (channels with unread/mentions, DMs, members, limits) | API / Backend (`golem15.demo` `/_demo/api/v1/chat*`) | — | bm keeps this host-side (`/_bm/api/v1/chat`); generic APIs lack a "my conversations across contexts" call |
|
||||
| Pre-save content filter | API / Backend (`sm-moderation-plugin` service, wired by `golem15.demo` into chat/forum seams) | — | Must run before persistence; must not couple moderation to chat/forum modules |
|
||||
| Slash commands | API / Backend (`golem15.chatbots` registry, demo registers commands) | Browser (composer picker) | Dispatch runs as JWT actor server-side |
|
||||
| Video room lifecycle, tokens | API / Backend (`golem15.chatvideo`) | LiveKit SFU (rome) | Plugin mints tokens; media never touches Go |
|
||||
| Media transport | LiveKit SFU on rome (UDP mux / ICE-TCP) | Browser (`livekit-client`) | WebRTC needs direct ports, not nginx/Cloudflare |
|
||||
| Realtime fan-out | Centrifugo (demo instance) | API (subscribe proxy authorizers) | Already deployed in 19.4.1 |
|
||||
| Chat/forum/friends/video UI | Browser / Client (layers, client-only) | Frontend Server (SSR shell, `/forum` may SSR) | Chat and video touch window/WebSocket; SSR only the shell |
|
||||
| Anonymous prompts, tour cards | Frontend Server (vue-demo-app) | — | App-specific copy, EN/PL |
|
||||
|
||||
## Project Constraints (from CLAUDE.md)
|
||||
|
||||
- **Stdlib first.** A new Go dependency needs a research doc or decision note naming it. No new Go deps are needed beyond what the chat family already carries (`livekit/server-sdk-go/v2` and `SherClockHolmes/webpush-go` come in with `chatvideo` and `herald`). `sm-moderation-plugin` should be stdlib-only (+ `gorm`, framework modules).
|
||||
- **YAML** is parsed with `goccy/go-yaml`, never `gopkg.in/yaml.v3`.
|
||||
- **Framework code and shared plugins/layers never name a consuming application.** READMEs use neutral names (`blog`, `acme`). The pre-save seam, `sm-moderation-plugin` and every `vue-*-layer` follow this. `golem15.demo` may name itself.
|
||||
- **Core plugins: no breaking changes** (user, blog, pages, payment… and here chat/forum: WavePath parity, Phase 31). Seams are additive and ship as patch tags (D-18).
|
||||
- **Submodule commit order:** commit and push the plugin first, then the pointer bump in the app as a separate commit. Never stage files inside a submodule from the app repo. One `chore: bump <plugin> to <tag>` per plugin.
|
||||
- **go.mod requires tags only** (`TestGoModRequiresTags`, `scripts/check-pins.sh`); `go.work` handles local checkouts.
|
||||
- **Module/plugin READMEs** follow the standard structure (H1, summary, import, `**Name:**` mnemonic, Overview, Features, Usage, API reference, Configuration, CLI, Dependencies, Testing); every identifier must exist (docs checker for framework; plugin READMEs by hand).
|
||||
- **Lean planning:** few, large plans; present the plan count with one-line scopes and wait for confirmation. **Unit tests are the last plan.** STATE.md via `msd_run query state.*` handlers. g15office sync on phase advance.
|
||||
- **Commits:** no co-author tags (user's global CLAUDE.md overrides the harness attribution); stage files by explicit path; planning docs and code in separate commits. Don't push, tag or deploy without the user's go-ahead.
|
||||
- **Pushed release tags `vX.Y.Z`** of the framework get a changelog post on summercms.io the same day (only if this phase cuts a framework tag — not expected).
|
||||
- **Memory:** tag only at phase completion for the framework (interim plugin tags are fine). Add new shared plugins to the demo (this phase's whole point).
|
||||
- **Docker tests:** if the Docker socket is denied, run through `sudo -u golem`.
|
||||
|
||||
## Repo State and Tagging (verified this session)
|
||||
|
||||
| Plugin | Where current code lives | Origin `main` | Tags on origin | go.mod problems for module mode |
|
||||
|--------|-------------------------|---------------|----------------|----------------------------------|
|
||||
| `sm-chat-plugin` | `sm-bm-app/plugins/golem15/chat` (51c9e17 = v0.1.3) | 51c9e17 | v0.1.0, v0.1.1, v0.1.2, v0.1.3 | none (requires user v0.1.2, summercms v0.4.0) |
|
||||
| `sm-chatchannels-plugin` | `sm-bm-app/plugins/golem15/chatchannels` (a30f04a) | a30f04a | **none** | none (requires chat v0.1.0) |
|
||||
| `sm-chatbots-plugin` | `sm-bm-app/plugins/golem15/chatbots` (d0b9fb1 "drop sibling replaces") | d0b9fb1 | **none** | none (requires chat v0.1.0) |
|
||||
| `sm-chatvideo-plugin` | `../sm-chatvideo-plugin` (be688fe) | be688fe | **none** | `require sm-chat-plugin v0.0.0`; `replace sm-chat-plugin => ../sm-chat-plugin`; `replace sm-user-plugin => ../fonoteka.go/plugins/golem15/user` |
|
||||
| `sm-forum-plugin` | `../sm-forum-plugin` (2779341) | 2779341 | **none** | none |
|
||||
| `sm-herald-plugin` | `../sm-herald-plugin` (74c889c) | 74c889c | **none** | `replace sm-user-plugin => ../fonoteka.go/plugins/golem15/user` |
|
||||
| `sm-userfriends-plugin` | `../sm-userfriends-plugin` (57db2ba) | 57db2ba | **none** | requires chat pseudo-version `v0.0.0-20261017200243-4d0e764ca443` |
|
||||
|
||||
[VERIFIED: `git ls-remote origin` per repo; `git -C … log`; go.mod files read]
|
||||
|
||||
- **Sibling checkouts are stale:** `../sm-chat-plugin` is at 5fea654 (origin 51c9e17). `../sm-chatchannels-plugin` is at f3ac5d1 (origin a30f04a). `../sm-chatbots-plugin` is at 9dfe8b7 (origin d0b9fb1). Fast-forward (`git pull --ff-only`) before any edit.
|
||||
- **Compatibility proof:** in a scratch `go.work` I used framework v0.5.1 (from `git archive v0.5.1`) and user v0.1.13 (the demo's submodule), set every chat-family go.mod to `sm-chat-plugin v0.1.3`, `sm-user-plugin v0.1.13` and `summercms v0.5.1`, and removed the sibling replaces. All seven pass `go build ./...`, `go vet ./...` and `go test -short ./...`. Before the go.mod fix, every build failed with `git.golem15.com/golem15/sm-chat-plugin@v0.0.0: reading … go.mod at revision v0.0.0: unknown revision v0.0.0`. [VERIFIED: scratch build output]
|
||||
- **Suggested tags** (one-way, D-03 of 19.4.1):
|
||||
|
||||
| Plugin | Tag |
|
||||
|--------|-----|
|
||||
| chat | v0.1.4: pre-save seam, additive (patch) |
|
||||
| chatchannels | v0.1.0 |
|
||||
| chatbots | v0.1.0 |
|
||||
| chatvideo | v0.1.0, after the go.mod fix |
|
||||
| forum | v0.1.0, with the seam (its first tag) |
|
||||
| herald | v0.1.0, after dropping the replace |
|
||||
| userfriends | v0.1.0, chat requirement → v0.1.3 or v0.1.4 |
|
||||
| sm-moderation-plugin | v0.1.0 |
|
||||
|
||||
Raising the minimal `summercms` / `sm-user-plugin` requires in plugin go.mod files is optional (MVS picks the demo's v0.5.1 / v0.1.13). Keep the `replace git.golem15.com/golem15/summercms => ../summercms.go` line, the standard plugin pattern.
|
||||
- **bm is unaffected:** `sm-bm-app` pins chatchannels and chatbots as `v0.0.0` with local `replace` lines. It keeps building whatever the new tags are.
|
||||
- **Folded todo (oauthserver):** the `sm-demo-app` submodule is at b6a9586 (`v0.1.2-7-gb6a9586`, uncommitted move). The index has 8bbec72 = v0.1.2, and go.mod requires v0.1.2. Tag v0.1.3 = df2eddd ("require SummerCMS v0.5.0 and sm-user-plugin v0.1.11") contains b6a9586. Move the checkout to v0.1.3, run `GOWORK=off go get git.golem15.com/golem15/sm-oauthserver-plugin@v0.1.3` then `go mod tidy`, and commit `chore: bump sm-oauthserver-plugin to v0.1.3`. The journal half of the todo is already done (`11d485f chore: bump journal to v0.2.5`). [VERIFIED: `git submodule status`, `git log`]
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core (backend, all already chosen in Phases 19/19.1)
|
||||
| Library / module | Version | Purpose | Why Standard |
|
||||
|------------------|---------|---------|--------------|
|
||||
| `git.golem15.com/golem15/summercms` | v0.5.1 (demo pin; v0.5.2 exists) | Framework | Demo already on it; chat family proven on it this session |
|
||||
| `sm-chat-plugin` | v0.1.3 → v0.1.4 (seam) | Conversations, messages, reactions, attachments | Phase 19 port |
|
||||
| `sm-chatchannels-plugin` | first tag v0.1.0 | Channels, pins, mentions | Phase 19 port |
|
||||
| `sm-chatvideo-plugin` | first tag v0.1.0 | LiveKit sessions, tokens, reconcile | Phase 19.1 |
|
||||
| `sm-chatbots-plugin` | first tag v0.1.0 | Slash command registry/dispatch | Phase 19.1 |
|
||||
| `sm-forum-plugin` | first tag v0.1.0 (seam) | Forum | Phase 19.1 |
|
||||
| `sm-herald-plugin` | first tag v0.1.0 | Web Push (mounted, no UI) | Phase 19.1 |
|
||||
| `sm-userfriends-plugin` | first tag v0.1.0 | Friends, groups | Phase 19.1 |
|
||||
| `github.com/livekit/server-sdk-go/v2` | v2.19.0 (transitive via chatvideo) | RoomService client | Already in chatvideo go.mod |
|
||||
| LiveKit server | 1.13.1 (installed on rome `/usr/local/bin/livekit-server` and locally) | SFU | Same binary WavePath runs; no install needed |
|
||||
| Centrifugo | v6.5.1 (rome and local) | Realtime | Already the demo's (D-17) |
|
||||
|
||||
### Frontend
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| `nuxt` | 4.4.8 (pinned across layers) | Layers and app | All layers `peerDependencies: { nuxt: 4.4.8 }` |
|
||||
| `vue` | 3.5.35 | — | — |
|
||||
| `centrifuge` | 5.7.3 (already pinned in vue-demo-app; audited in 19.4.1) | Centrifugo client | Move/duplicate into `vue-chat-layer` deps at the same pin |
|
||||
| `livekit-client` | 2.23.0 latest (published 2026-10-16) `[WARNING: flagged as suspicious — verify before using.]` | WebRTC client for `vue-chatvideo-layer` | Pin an exact version after the human-verify checkpoint |
|
||||
| `vitest` | 3.2.7 (vue-demo-app's pin) | Layer unit tests for the ported bm tests | bm's chat tests are vitest |
|
||||
|
||||
**No Pinia.** 19.4.1 established "Pinia stores become `useState` composables" (19.4.1-PATTERNS.md:259; 19.4.1-04-PLAN.md:134). `@pinia/nuxt` 1.0.3 peers `pinia ^4.1.0` and depends on `@nuxt/kit ^4.6.0`, against the repo's `nuxt` 4.4.8. bm's store is a setup store (`defineStore('chat', () => {…})`), so converting it to a module-level factory or composable is mechanical. [VERIFIED: npm view @pinia/nuxt peerDependencies dependencies]
|
||||
|
||||
### Alternatives Considered
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| Own LiveKit instance on rome | Add a key pair to WavePath's LiveKit (`/etc/livekit/livekit.yaml`) | Any API key can list/delete every room on that server; cross-tenant blast radius; D-10 says own keys → use own instance |
|
||||
| UDP mux (`rtc.udp_port`) | Port range (`port_range_start/end`) | Range needs a wide firewall hole and overlaps WavePath's 50000-60000; mux needs one port |
|
||||
| Embedded TURN/TLS on 443 via nginx `stream` `ssl_preread` | No TURN, ICE-TCP fallback (`rtc.tcp_port`) | `ssl_preread` would restructure every 443 vhost on a shared host; ICE-TCP covers most NAT'd browsers |
|
||||
| Festival "-ing" event as pre-save seam | Validator registry on the services (`Service.AddValidator`) | Both additive; festival mirrors `user.Deleting`/`user.Deactivating` and needs no new type on the service |
|
||||
|
||||
**Installation (layers):**
|
||||
```bash
|
||||
# vue-chat-layer
|
||||
pnpm add centrifuge@5.7.3
|
||||
pnpm add -D vitest@3.2.7 nuxt@4.4.8 vue@3.5.35 typescript@5.9.3
|
||||
# vue-chatvideo-layer (after checkpoint:human-verify on livekit-client)
|
||||
pnpm add livekit-client@<pinned>
|
||||
```
|
||||
|
||||
**Version verification:** `npm view livekit-client version` → 2.23.0 (2026-10-16). `npm view centrifuge version` → 5.8.0 (2026-10-20); the demo stays on the audited 5.7.3. [VERIFIED: npm registry]
|
||||
|
||||
## Package Legitimacy Audit
|
||||
|
||||
| Package | Registry | Age (latest) | Downloads | Source Repo | Verdict | Disposition |
|
||||
|---------|----------|-----|-----------|-------------|---------|-------------|
|
||||
| `livekit-client` | npm | latest 12 days | 5.67M/wk | github.com/livekit/client-sdk-js | [SUS] reason `too-new` | Flagged — planner must add checkpoint:human-verify; pin an exact version |
|
||||
| `centrifuge` | npm | latest 8 days | 1.61M/wk | github.com/centrifugal/centrifuge-js | [SUS] reason `too-new` (latest only) | Keep the already-audited 5.7.3 pin from 19.4.1; no new install decision |
|
||||
| `pinia`, `@pinia/nuxt` | npm | — | — | github.com/vuejs/pinia | [SUS] `too-new` | Not recommended (see above) |
|
||||
|
||||
No postinstall scripts on `livekit-client` or `centrifuge` (`npm view … scripts.postinstall` empty). `centrifuge` pulls `protobufjs`, whose postinstall is already disabled in `pnpm-workspace.yaml` (`protobufjs: false`).
|
||||
|
||||
**Packages removed due to [SLOP] verdict:** none
|
||||
**Packages flagged as suspicious [SUS]:** `livekit-client`: the planner inserts checkpoint:human-verify before the install. The "too-new" verdict is about the latest release, not the package. It is LiveKit's official JS SDK, but its name came to me from training and the chatvideo README, not from Context7, so treat it as `[ASSUMED]` until verified.
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### System Architecture Diagram
|
||||
|
||||
```
|
||||
Browser (vue-demo-app + layers, client-only chat/video)
|
||||
│ JWT (DEMO cookie) as Bearer
|
||||
├─ GET /_demo/api/v1/chat ─────────────► golem15.demo bootstrap ──► chat.Service.GetConversationsForUser
|
||||
│ chatchannels.ChannelService (channels, mention counts)
|
||||
├─ /_chat/api/v1/... (send/edit/react/replies/upload) ──► chat.Service
|
||||
│ └─ [NEW] Fire MessageSaving (pre-save) ──► demo listener ──► moderation.Filter.Check ──► 422 or continue
|
||||
│ └─ tx: insert ─► Fire MessageCreated ─► chatchannels mentions; lighthouse Bind ─► broadcasts queue ─► Centrifugo publish
|
||||
├─ /_chatchannels/api/v1/contexts/demo/1/channels/... (join, pins, mentions)
|
||||
├─ /_chatbots/api/v1/commands | /dispatch ─► chatbots Registry (demo-registered commands, ephemeral answers)
|
||||
├─ /_forum/api/v1/contexts/demo/1/... ─► forum.Service ─► [NEW] Fire PostSaving ─► moderation ─► persist (breakwater render)
|
||||
├─ /_userfriends/api/v1/...
|
||||
├─ /_chatvideo/api/v1/conversations/{dm}/video/start ─► chatvideo (participants ≤ max_participants=2 ⇒ DMs only)
|
||||
│ └─ RoomAPI ─► http://127.0.0.1:<lk-signal> (loopback, TrustedMode) ; returns {token, livekit_url}
|
||||
├─ GET /api/realtime/token ─► demo guard (any member) ─► lighthouse token
|
||||
├─ wss /ws ─► nginx ─► Centrifugo (demo) ──subscribe proxy──► Go authorizers: chat / user / video / presentations
|
||||
└─ wss /livekit/rtc ─► Cloudflare ─► nginx ─► LiveKit (demo instance) signal
|
||||
media: UDP <mux port> / TCP <ice-tcp port> direct to rome public IP (bypasses Cloudflare)
|
||||
```
|
||||
|
||||
### Recommended Project Structure
|
||||
|
||||
```
|
||||
sm-moderation-plugin/ # new shared plugin (golem15.moderation)
|
||||
├── plugin.go admin.go # pact.HasSettings, HasMigrations, HasConfig, HasLang
|
||||
├── classes/filter.go # Filter.Check(ctx, text) -> *Violation (words, links)
|
||||
├── models/settings.go + models/settings/fields.yaml
|
||||
├── updates/ lang/ config/
|
||||
sm-demo-app/plugins/demo/
|
||||
├── chat.go # registry aliases "demo" + "channel", resolvers, limits, seam listeners, chatbots commands
|
||||
├── classes/ (or chat_context.go) # DemoContext, ChannelContext adapter, access resolvers (chatchannels, forum)
|
||||
├── chat_api.go # GET /_demo/api/v1/chat, /chat/members (bootstrap)
|
||||
├── console/seed_chat.go seed_forum.go seed_friends.go
|
||||
vue-chat-layer/ # new
|
||||
├── app/components/chat/… # ported + ChannelList, ThreadPane, PinsPanel, CommandPicker
|
||||
├── app/composables/useChat.ts # ported store (no Pinia)
|
||||
├── app/lib/realtime.ts # shared Centrifuge client + onUserEvent hook (used by chatvideo layer)
|
||||
├── app/lib/chat{Events,Format,Text,Unread}.ts (+ tests)
|
||||
├── app/pages/chat.vue # optional; or app provides page and renders <ChatPage>
|
||||
vue-chatvideo-layer/ # extends vue-chat-layer
|
||||
vue-forum-layer/ vue-userfriends-layer/
|
||||
```
|
||||
|
||||
### Pattern 1: Host context and channel-as-context adapter (golem15.demo)
|
||||
**What:** Register two aliases on `*chatcontracts.Registry`:
|
||||
- `demo`: one context, id 1. Every registered, non-deleted, non-banned user is a member.
|
||||
- `channel`: loads a `chatchannels` channel by id. Its members are the demo community's members, since every channel is public (D-07).
|
||||
|
||||
Install `chatchannels` `SetAccessResolver` (any member may access `demo/1`, nobody manages it) and `forum.SetAccessResolver`.
|
||||
**Why the `channel` alias is required:** `CreateChannel` writes the backing conversation with `ContextType: &alias` where `alias := models.ChannelAlias` (`"channel"`) [VERIFIED: sm-bm-app/plugins/golem15/chatchannels/classes/channel_service.go:168-174]. No production code registers that alias. The only `Alias: models.ChannelAlias` registration is the test fixture [VERIFIED: chatchannels/fixture_plugin_test.go:172]. Without it, chat resolves the conversation's context to `ErrUnknownContextType` (404).
|
||||
**Lazy join:** `JoinChannel` only upserts a pref row (`row.HasLeft = false`) and adds no chat participant [VERIFIED: channel_service.go:959-969]. Sending needs `isParticipant` [VERIFIED: chat/classes/service.go:463-468], while reading falls back to `host.HasMember` [VERIFIED: service.go:630-640]. The client gets participant rows by calling `GET /_chat/api/v1/contexts/channel/{channelId}/conversation`, where `GetGroupChat` "still lazy-joins a context member" (chat README). Alternatively, the demo bootstrap adds the participant. Choose one and test it.
|
||||
**Example (mirror bm):**
|
||||
```go
|
||||
// Source: sm-bm-app/plugins/jz/bm/chat.go:35-90 (pattern), adapted
|
||||
func (p *Plugin) registerChat(app *backpack.App) error {
|
||||
reg, ok := app.Lookup[*chatcontracts.Registry]()
|
||||
if !ok || reg == nil { return errors.New("golem15.demo: golem15.chat must boot first") }
|
||||
if err := reg.Add(p.ID(), chatcontracts.Registration{Alias: "demo", Loader: p.loadDemoContext}); err != nil { return err }
|
||||
if err := reg.Add(p.ID(), chatcontracts.Registration{Alias: chanmodels.ChannelAlias, Loader: p.loadChannelContext, LegacyPHPClass: chanmodels.ChannelMorphName}); err != nil { return err }
|
||||
return lagoon.OnDatabase(app, func(_ *sql.DB, _ *gorm.DB) error {
|
||||
ch, _ := app.Lookup[*chanclasses.ChannelService]()
|
||||
ch.SetAccessResolver(p.channelAccess) // CanAccessContext: member && demo/1; CanManageContext: false
|
||||
f, _ := app.Lookup[*forum.Plugin]()
|
||||
f.SetAccessResolver(p.forumAccess) // CanRead/CanPost: member; CanManage: moderator group
|
||||
// chatbots registry is published inside its own OnDatabase hook (chatbots/boot.go bootService)
|
||||
bots, _ := app.Lookup[*botclasses.Registry]()
|
||||
return registerDemoCommands(bots)
|
||||
})
|
||||
}
|
||||
```
|
||||
`BroadcastChannel()` must be non-empty for both contexts ("an empty host channel silences the conversation channels too", bm chat_context.go:31-35). Use names no client can subscribe to, for example `chat:context:1` and `chat:channel:{id}`. `AuthorizeChat` admits only `chat:conversation:{id}`.
|
||||
Implement `contracts.LiveMembership` (`RequireLiveMembership() true`) and `contracts.MessageModerator`, as bm does, so that banned users lose access at the next check and a moderator group can delete messages in group conversations.
|
||||
|
||||
### Pattern 2: Additive pre-save seam (D-18), recommended shape
|
||||
**What:** Use a festival "-ing" event, fired **before** the write transaction, mirroring `user.Deleting` / `user.Deactivating` (sm-user-plugin/classes/events.go: "A listener error rolls the deactivation back").
|
||||
- chat: `events.MessageSaving{ConversationID, ActorID, RootID, Content, Metadata, Op}`, where `Op` is `"create"` or `"update"`. Fire it in `SendMessageWith` after the participant and empty checks, and in `EditMessage` after `CanEditMessage`.
|
||||
- forum: `events.PostSaving{ContextType, ContextID, ActorID, ThreadID, PostID, Title, Body, Op}`. Fire it in `CreateThread`, `Reply` and `EditPost` before `breakwater.Render`.
|
||||
- A listener rejects with a typed error, for example `contracts.Rejection{Field, Message}`, implementing `error`. The services return it wrapped. The API maps `errors.As(err, &contracts.Rejection{})` to the **existing** 422 envelope `{"error":"Validation failed","errors":{field:[message]}}` (chat api/errors.go:39-44, forum api/errors.go). Every existing route, payload and status stays the same. Only a request a listener rejects gets the new 422.
|
||||
- With no listener, behaviour is byte-identical (WavePath parity; the PHP method map tests stay green).
|
||||
|
||||
**Why not the existing events:** `MessageCreated` fires inside the transaction after `tx.Create(msg)` (service.go SendMessageWith). A listener error there rolls back, but it maps to 500 (the `writeChatError` default) and burns an id. **Forum fires no festival events at all.** `grep "Fire("` over sm-forum-plugin finds none, and `ForumService` has a `Bus` field (classes/forum_service.go:57-58) that is never used. CONTEXT's "chat and forum fire their festival events after the save" is accurate only for chat.
|
||||
|
||||
**Coupling:** `sm-moderation-plugin` must not import chat or forum. It publishes a `*classes.Filter`, and `golem15.demo` (which already imports both) wires it:
|
||||
```go
|
||||
// Source: pattern from festival bus.go:43 (Listen[T]) and user events
|
||||
app.Events.Listen[*chatevents.MessageSaving](p.ID(), func(ctx context.Context, ev *chatevents.MessageSaving) error {
|
||||
if v := filter.Check(ctx, ev.Content); v != nil {
|
||||
return chatcontracts.Rejection{Field: "content", Message: v.Message}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
```
|
||||
Note: `festival.Bus.Fire` returns `errors.Join` of the listener errors (bus.go:74-75), so callers must use `errors.As`, not `==`.
|
||||
|
||||
### Pattern 3: One shared realtime connection across layers
|
||||
bm's `lib/realtime.ts` keeps a single Centrifuge client (`connect`, `subscribe`, `presence`). The chat store subscribes to `user:{me}` for the whole session. chatvideo publishes `video:call:incoming` to `user:{id}` (chatvideo README). One client cannot hold two subscriptions to the same channel, so `vue-chatvideo-layer` must **not** open its own `user:{me}` subscription. Put the realtime client in `vue-chat-layer` and add `onUserEvent(handler)` so unknown `user:` events (`video:*`) reach other layers. `vue-chatvideo-layer` `extends` `vue-chat-layer` and subscribes to `video:session:{id}` through the same client.
|
||||
|
||||
### Pattern 4: Layer conventions (from vue-faq-layer, vue-paymentgateway-layer, vue-oauth-layer)
|
||||
- `nuxt.config.ts` builds absolute paths from `fileURLToPath(new URL('.', import.meta.url))`.
|
||||
- runtimeConfig has a server `apiBase` (`NUXT_<X>_API_BASE`) and a public same-origin `apiBase`.
|
||||
- `peerDependencies: { nuxt: 4.4.8 }` and `engines.node >=22.18`.
|
||||
- Tests run with `node --test --test-reporter=tap tests/*.test.ts`.
|
||||
- Each layer is consumed as `"vue-x-layer": "git+https://git.golem15.com/golem15/vue-x-layer.git#vX.Y.Z"`.
|
||||
- **Auth hook:** the app provides a token getter through a Nuxt plugin (`provide: { paymentGatewayAuth: () => useCookie(DEMO_TOKEN_COOKIE).value ?? null }`, vue-demo-app/app/plugins/auth.ts). The chat layer needs more than a getter: a 401 refresh (bm's client calls `/_user/api/v1/fetch` to renew) and an `onUnauthorized`. Define a `chatAuth` hook with `token()`, `refresh()` and `unauthorized()`, or let the app inject the whole HTTP client.
|
||||
- **Host bootstrap contract:** the layer reads `public.chat.hostApiBase` (for example `/_demo/api/v1`) and documents the `GET {hostApiBase}/chat` and `/chat/members` JSON shapes as the layer's contract. Any host implements them, so the layer names no app.
|
||||
- **SSR:** `routeRules` `ssr: false` for `/chat/**` and the call page. The floating panel goes in `<ClientOnly>` in the app's layout, because a layer cannot inject into the app's layout.
|
||||
|
||||
### Pattern 5: DM-only calls enforced server-side (D-11)
|
||||
`VideoService.Start` checks only that the actor is a participant and that the conversation's participant count is at most `golem15.chatvideo.max_participants` (`max := s.intKey("golem15.chatvideo.max_participants", 8); if max > 0 && len(memberIDs) > max { return nil, ErrTooMany }`) [VERIFIED: sm-chatvideo-plugin/classes/video_service.go:109-112]. Conversation type is not checked, so **the plugin does not restrict calls to DMs.** `GetOrCreateDm` creates two-person DMs only. With `max_participants: 2` and every seeded channel holding three or more participants, `Start` on any channel or group conversation answers 422 `errTooMany` [VERIFIED: chatvideo/api/errors.go:42-43]. That is server-side enforcement of D-11 with no plugin change. The UI shows the call button only in DMs. LiveKit's `room.max_participants: 4` is a second cap. `Join` does not check counts (video_service.go:171-195), but only conversation participants can get a session (`sessionForActor`).
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **Seeding channels through `CreateChannel` with the demo's resolver.** It is gated by `CanManageContext` [VERIFIED: channel_service.go:136], which the demo answers false (D-07). Seed with a separate trusted instance instead, `chanclasses.New(chanclasses.Options{DB: tx, Resolver: seedResolver})`, or with raw SQL in the existing seed style.
|
||||
- **Seeding chat messages through `chat.Service`.** Its `SendMessageWith` emits realtime and enqueues jobs inside the tx. The seed already uses raw SQL (`insertID`, `exists`) and stays idempotent.
|
||||
- **Announce-mode chatbots commands.** HTTP dispatch passes `classes.CommandContext{}` [VERIFIED: chatbots/api/commands.go:57], so `announce` never persists (`cmdCtx.ConversationID == 0` returns early, dispatcher.go:52-56). Demo commands must be `Ephemeral`.
|
||||
- **Opening a second Centrifuge client** in the video layer (see Pattern 3).
|
||||
- **Editing `../vue-demo-app`.** That sibling checkout is stale (32049f7, no remote). The real app is the submodule `sm-demo-app/vue-demo-app` (44a5229, origin `golem15/vue-demo-app`).
|
||||
- **Putting the moderation filter in `golem15.demo`.** The user asked for a shared plugin (CONTEXT specifics).
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Chat persistence, permissions, unread, reactions, tombstones | Demo-side message tables or endpoints | `sm-chat-plugin` generic API `/_chat/api/v1` | PHP-parity contracts; WavePath target |
|
||||
| Channel pins, mentions, joins | Custom pin/mention tables | `/_chatchannels/api/v1/contexts/demo/1/channels/{id}/pins`, `/mentions` | Already ported, with mention accrual listeners |
|
||||
| Video tokens / room cleanup | Own JWT minting or room GC | `chatvideo` `Start/Join/Leave/End` + `chatvideo:reconcile-sessions` (every 5 min) + LiveKit `room.empty_timeout` | HS256 golden tested |
|
||||
| WebRTC | Raw `RTCPeerConnection` | `livekit-client` | SFU signalling, reconnection, simulcast |
|
||||
| Realtime | Own WebSocket server | Centrifugo + lighthouse authorizers | Already deployed |
|
||||
| Markdown → safe HTML for forum | Client-side markdown rendering of user input | Server `content_html` from `breakwater.Render` (stored on write) | Sanitized server-side; render with `v-html` only from that field |
|
||||
| Web Push | Custom VAPID | `herald` + `herald:vapid-generate` | — |
|
||||
| Settings screen for word lists | Custom admin page | `pact.HasSettings` + `fields.yaml` singleton (pattern: sm-feedback-plugin admin.go) | cabana renders it |
|
||||
| Firewall changes on rome | Raw `iptables` | Vuurmuur (`vuurmuur_conf`/`vuurmuur_script`): rome's INPUT chain is Vuurmuur-managed (`vrmr:` NFLOG prefixes) | Raw rules get overwritten on Vuurmuur reload |
|
||||
|
||||
**Key insight:** The chat family is complete server-side. The work is host glue (context, adapter, resolvers, bootstrap), release hygiene and UI. Adding parallel endpoints or tables would fork the WavePath contract.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Stale sibling checkouts
|
||||
**What goes wrong:** Editing `../sm-chat-plugin` (13+ commits behind) and tagging an old tree, or a seam patch that conflicts with v0.1.3.
|
||||
**How to avoid:** `git -C ../sm-chat-plugin pull --ff-only` (same for chatchannels and chatbots) as task 1, then assert `git rev-parse HEAD == $(git ls-remote origin refs/heads/main)`.
|
||||
|
||||
### Pitfall 2: `v0.0.0` and pseudo-version requires break module mode
|
||||
**What goes wrong:** `GOWORK=off go build` in sm-demo-app fails with `unknown revision v0.0.0` (verified).
|
||||
**How to avoid:** Fix chatvideo and userfriends go.mod before tagging. `TestGoModRequiresTags` checks only the app's own go.mod, not transitive ones, so also build `GOWORK=off` in a clean temp dir.
|
||||
|
||||
### Pitfall 3: Missing `channel` alias, so messages in seeded channels 404
|
||||
**How to avoid:** Pattern 1. Test sending to a seeded channel conversation through the HTTP API.
|
||||
|
||||
### Pitfall 4: Realtime token route limited to presentation owners
|
||||
**What goes wrong:** Chat users get 403 from `/api/realtime/token`. The demo mounts `UserAuth: surf.Use("jwt.auth", "presentations.realtime-owner", bucketRealtime)` and mounts nothing unless presentations is active [VERIFIED: sm-demo-app/plugins/demo/routes.go:78-87]. The vue-demo-app realtime client treats 401/403/404 as final (`UNAVAILABLE = new Set([401, 403, 404])`, app/lib/realtime.ts) and stops for good.
|
||||
**How to avoid:** Port bm's `jz.bm.realtime-access` guard (sm-bm-app/plugins/jz/bm/realtime_access.go): a demo member passes, otherwise the presentations owner guard decides.
|
||||
|
||||
### Pitfall 5: Centrifugo namespaces missing
|
||||
**What goes wrong:** Subscribes to `chat:…`, `user:…` and `video:…` fail. The demo config has only `presentations` (deploy/centrifugo.json.example).
|
||||
**How to avoid:** Add `chat` (`presence`, `join_leave`, `force_push_join_leave`, `allow_presence_for_subscriber`, subscribe proxy), `user` and `video` (subscribe proxy), copying bm's namespace block (sm-bm-app/deploy/centrifugo/bm-config.json). "`centrifugo checkconfig` does not catch misspelt option names" (sm-bm-app DEPLOY.md:395). Compare against `centrifugo defaultconfig`. The `user` authorizer comes from sm-user-plugin v0.1.13 (`RegisterUserAuthorizer`, classes/authorizer.go:35-47). Restart `summercms-demo-centrifugo` after changing `centrifugo.json`.
|
||||
|
||||
### Pitfall 6: LiveKit media ports not open on rome
|
||||
**What goes wrong:** Signalling works but the call shows no remote video (ICE fails). rome's INPUT policy is DROP under Vuurmuur. A Vuurmuur service `Livekit` exists (`RANGE="6;7881*1024:65535"`, `RANGE="17;50000:60000*1024:65535"`) but no rule in `/etc/vuurmuur/rules/rules.conf` uses it, and `iptables -S` shows no rule for 7881 or 50000-60000 [VERIFIED: read-only ssh probe 2026-10-28].
|
||||
**How to avoid:** Define a demo service in Vuurmuur (TCP ICE port plus UDP mux port) and a rule allowing it from the internet to the firewall, then apply it through Vuurmuur. This needs the user's go-ahead (shared production host). Verify with `ss -lunp` and an external `nc -u` / a real two-browser call.
|
||||
|
||||
### Pitfall 7: Port collisions with WavePath's LiveKit
|
||||
**What goes wrong:** LiveKit defaults (7880, 7881, 50000-60000) collide with the running `wavepath-livekit` program.
|
||||
**How to avoid:** Use distinct ports: for example signal `127.0.0.1:7890`, `rtc.tcp_port: 7891`, `rtc.udp_port: 7892` (mux, no `port_range_*`). Check them free with `ss -ltnup` at deploy time. The ss probe today showed 7890-7892 unused. Extend `scripts/check-deploy.sh` port agreement checks to LiveKit.
|
||||
|
||||
### Pitfall 8: LiveKit behind Cloudflare and nginx
|
||||
**What goes wrong:** The signal WebSocket drops or the URL path is wrong.
|
||||
**How to avoid:**
|
||||
- Keep the signal on the same origin under a prefix, for example `livekit_url: wss://demo.summercms.io/livekit`. livekit-client appends paths to the existing pathname ("`wss://host/livekit` becomes `wss://host/livekit/rtc`", client-sdk-js src/api/utils.ts `appendUrlPath`) [CITED: github.com/livekit/client-sdk-js/blob/main/src/api/utils.ts].
|
||||
- nginx: `location ^~ /livekit/ { proxy_pass http://127.0.0.1:7890/; proxy_http_version 1.1; Upgrade/Connection headers; proxy_read_timeout 7d; proxy_buffering off; }`, as lk.wavepath.org does.
|
||||
- Set `livekit_admin_url: http://127.0.0.1:7890` so the Go RoomService calls stay on loopback. fetchguard TrustedMode allows loopback.
|
||||
- Media goes direct to the public IP, so ICE candidates reveal rome's IP (54.36.173.176) despite Cloudflare. It is accepted risk, since rome already exposes game servers on that IP.
|
||||
- Newer livekit-client tries `/rtc/v1` and falls back to `/rtc`. Server 1.13.1 support is `[ASSUMED]`. Test locally against the same 1.13.1 binary.
|
||||
|
||||
### Pitfall 9: Floating panel and SSR
|
||||
**What goes wrong:** Hydration mismatches or `window` errors.
|
||||
**How to avoid:** Render the floating panel in `<ClientOnly>`. Set `ssr: false` for `/chat/**` and the call route. Load `livekit-client` with a dynamic `import()`, as `centrifuge` is in realtime.ts.
|
||||
|
||||
### Pitfall 10: Reset leaves LiveKit rooms and chat rows behind
|
||||
**What goes wrong:** `demo:reset` deletes `users`. Chat tables cascade from users in parts only, `golem15_chatchannels_*` rows have no FK to messages, and active LiveKit rooms survive.
|
||||
**How to avoid:** Add every chat-family table to `resetTables` in FK order, children first, before `users`:
|
||||
- chatvideo participants and sessions
|
||||
- chat reactions, thread mutes, thread summaries, messages, participants and conversations
|
||||
- chatchannels mentions, pins, members, prefs, user context prefs, personal categories, channels and categories
|
||||
- forum reads, posts, threads and categories
|
||||
- userfriends bans, invites, group members, groups and friendships (keep the singleton context id=1)
|
||||
- herald subscriptions
|
||||
- chatbots bot flags
|
||||
|
||||
Keep `golem15_moderation_settings`. Set LiveKit `room.empty_timeout` and `departure_timeout` so orphaned rooms close. `TestResetKeepsPlans` must grow to cover the new tables.
|
||||
|
||||
### Pitfall 11: robots.txt and allow-lists drift
|
||||
**What goes wrong:** New API prefixes are not routed by nginx, or they show up in robots.txt.
|
||||
**How to avoid:** Add the new paths in three places:
|
||||
- nginx Go allow-list: `/_chat/`, `/_chatchannels/`, `/_chatvideo/`, `/_chatbots/`, `/_forum/`, `/_userfriends/`, and `/livekit/` to LiveKit.
|
||||
- `vue-demo-app` `goPaths`: dev proxy and robots.
|
||||
- The SEO plugin's robots.txt in the admin (DEPLOY.md "SEO": the Disallow lines are copied by hand).
|
||||
|
||||
`/_herald/` stays unrouted: there is no UI (D-16).
|
||||
|
||||
### Pitfall 12: Forum and friends APIs are JWT-only
|
||||
Every forum route is behind `jwt.auth` (forum README). An anonymous `/forum` cannot list threads without a new public endpoint. See Open Question 3.
|
||||
|
||||
### Pitfall 13: No admin screens for chat/forum content
|
||||
None of the seven plugins implements `HasAdminControllers` or `HasSettings` (grep). D-17's "admins deleting messages … from the backend" has no backend UI. See Open Question 2.
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Demo context (verbatim interface members bm implements)
|
||||
```go
|
||||
// Source: sm-bm-app/plugins/jz/bm/classes/chat_context.go:12-17,101-110
|
||||
var (
|
||||
_ chatcontracts.Context = (*ChatContext)(nil)
|
||||
_ chatcontracts.MessageModerator = (*ChatContext)(nil)
|
||||
_ chatcontracts.LiveMembership = (*ChatContext)(nil)
|
||||
_ chancontracts.AccessResolver = (*ChatAccess)(nil)
|
||||
)
|
||||
func (c *ChatContext) ID() uint { return ChatContextID }
|
||||
func (c *ChatContext) Type() string { return ChatContextType }
|
||||
func (c *ChatContext) ChatEnabled() bool { return true }
|
||||
func (c *ChatContext) GroupName() string { return ChatGeneralName }
|
||||
func (c *ChatContext) BroadcastChannel() string { return ChatBroadcastChannel }
|
||||
// + CanActFor, CanOverseeChats, CanModerateMessage, RequireLiveMembership, HasMember, Members
|
||||
```
|
||||
|
||||
### Chatbots command (ephemeral)
|
||||
```go
|
||||
// Source: contracts in sm-bm-app/plugins/golem15/chatbots/contracts/slash_command.go
|
||||
type demoCommand struct{}
|
||||
func (demoCommand) Trigger() string { return "demo" }
|
||||
func (demoCommand) Description() string { return "What this demo shows" }
|
||||
func (demoCommand) IsAvailable(actor uint, _ contracts.CommandContext) bool { return actor != 0 }
|
||||
func (demoCommand) Handle(args string, actor uint, _ contracts.CommandContext) contracts.CommandResult {
|
||||
return classes.Ephemeral("This is the SummerCMS demo: chat, channels, calls, forum …")
|
||||
}
|
||||
```
|
||||
Catalog JSON: `GET /_chatbots/api/v1/commands` → `{"commands":[{"trigger","description"}]}`. Dispatch: `POST /dispatch {"input":"/demo"}` → `{"mode","payload"}`; unknown → 422 `errors.input` [VERIFIED: chatbots/api/commands.go:21-66; classes/dispatcher.go:89-99].
|
||||
|
||||
### Chatvideo config for the demo
|
||||
```yaml
|
||||
# config/golem15.chatvideo.yaml (keys verbatim from sm-chatvideo-plugin/config/config.yaml:4-11)
|
||||
livekit_url: "wss://demo.summercms.io/livekit"
|
||||
livekit_admin_url: "http://127.0.0.1:7890"
|
||||
max_duration: 900 # D-12 ≈15 min (reconcile runs every 5 min, so up to ~20)
|
||||
max_participants: 2 # DM-only calls server-side (Pattern 5)
|
||||
token_ttl: 900
|
||||
screen_share: false # minimal call page (D-13)
|
||||
# secrets: SUMMER_GOLEM15__CHATVIDEO__LIVEKIT_API_KEY / __LIVEKIT_API_SECRET in .env (or PHP names LIVEKIT_API_KEY/SECRET)
|
||||
```
|
||||
|
||||
### LiveKit config for the demo instance (keys from LiveKit config-sample)
|
||||
```yaml
|
||||
# /srv/summercms-demo/livekit.yaml (root:summercms-demo 0640)
|
||||
port: 7890
|
||||
bind_addresses: [127.0.0.1]
|
||||
rtc:
|
||||
tcp_port: 7891
|
||||
udp_port: 7892 # UDP mux; port_range_start/end must NOT be set
|
||||
use_external_ip: true
|
||||
room:
|
||||
max_participants: 4
|
||||
empty_timeout: 120
|
||||
departure_timeout: 20
|
||||
keys:
|
||||
CHANGE_ME_KEY: CHANGE_ME_SECRET
|
||||
```
|
||||
[CITED: raw.githubusercontent.com/livekit/livekit/master/config-sample.yaml]. The `udp_port` comment there says "port_range_start & end must not be set for this config to take effect". `bind_addresses` is used by rome's working WavePath config [VERIFIED: /etc/livekit/livekit.yaml on rome].
|
||||
|
||||
### Supervisor program (pattern from rome's wavepath-livekit.conf)
|
||||
```ini
|
||||
[program:summercms-demo-livekit]
|
||||
command=/usr/local/bin/livekit-server --config /srv/summercms-demo/livekit.yaml
|
||||
directory=/srv/summercms-demo
|
||||
user=summercms-demo
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startsecs=5
|
||||
stopsignal=INT
|
||||
stopwaitsecs=10
|
||||
redirect_stderr=true
|
||||
stdout_logfile=/var/log/supervisor/summercms-demo-livekit.log
|
||||
```
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| bm SPA: one channel + DMs, host bootstrap | Multi-channel sidebar over chatchannels + host bootstrap | This phase | New store shape (`channels[]` replaces `general`) |
|
||||
| Pinia stores | `useState`/module composables | 19.4.1 | No Pinia in layers |
|
||||
| LiveKit port range | UDP mux single port | LiveKit ≥1.x | One firewall hole |
|
||||
| livekit-client `/rtc` | `/rtc/v1` with v0 fallback | client 2.17+ (per community reports) | Test against server 1.13.1 |
|
||||
|
||||
**Deprecated/outdated:**
|
||||
- The sibling `../sm-chat-plugin`, `../sm-chatchannels-plugin` and `../sm-chatbots-plugin` trees are behind origin. Treat origin `main` as the truth.
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if Wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | `livekit-client` (latest 2.x) connects to LiveKit server 1.13.1 through `/livekit` prefix behind Cloudflare | Pitfall 8 | Calls fail; fallback: dedicated `lk.demo.summercms.io` host (DNS + cert) |
|
||||
| A2 | UDP mux + ICE-TCP without TURN is enough for "typical NAT'd browsers" | Alternatives | Some corporate networks fail; fallback: embedded TURN on UDP 3478 / TLS 5349 with a DNS-only hostname |
|
||||
| A3 | Chat `GetGroupChat` on alias `channel` lazy-joins a channel member as participant (README statement; not traced through the HTTP handler this session) | Pattern 1 | Users cannot post in channels; fallback: bootstrap adds participants |
|
||||
| A4 | Forum `PostSaving` and chat `MessageSaving` names/shape | Pattern 2 | Discretion area; any additive shape is fine |
|
||||
| A5 | Persona users without passwords are acceptable (no login) | Seeding | None material |
|
||||
| A6 | Centrifugo v6 namespace option names copied from bm's running config are correct for the demo's config format (`channel.proxy.subscribe` + `subscribe_proxy_enabled`, unlike bm's named `proxies`) | Pitfall 5 | Subscribes denied; `centrifugo checkconfig` + `defaultconfig` comparison catches most |
|
||||
| A7 | `livekit-client` is the official LiveKit package (name from chatvideo README/training, registry repo github.com/livekit/client-sdk-js) | Legitimacy | Human-verify checkpoint covers it |
|
||||
|
||||
## Open Questions (RESOLVED)
|
||||
|
||||
Resolved 2026-10-28: Q1 by the 19.4.1 decision (admin at `/smbackend`, `/backend` 404; restated in plan 05's deploy checkpoint); Q2 by D-22 (moderator group, admin screens deferred); Q3 by D-21 (register prompt); Q4 by plans 03/04 (threads, pins, linkified URLs and an embed slot).
|
||||
|
||||
1. **Is the admin public or not?**
|
||||
- What we know: ROADMAP's goal says "The admin backend is not public". 19.4.1-02's DECISION CHANGE (user, 2026-10-20) made the admin public at `/smbackend` with a login required, throttled to 5/min, while `/backend` returns 404. SC-3 says "`/backend` is not reachable". That holds literally.
|
||||
- Recommendation: keep `/smbackend` as the user decided, and confirm in the plan's checkpoint.
|
||||
2. **How do admins delete chat messages and forum posts (D-17)?**
|
||||
- What we know: no chat-family plugin has admin screens.
|
||||
- Options:
|
||||
- (a) A demo moderator user group. `DemoContext.CanModerateMessage` (delete in group conversations) and the forum resolver's `CanManage` grant deletion. bm's `DeleteDialog` already supports moderators, and admins add users to the group in the user admin.
|
||||
- (b) cabana list screens in `golem15.demo` or `sm-moderation-plugin` over chat messages and forum posts, with delete actions. That puts chat/forum coupling into moderation, or app code into demo.
|
||||
- Recommendation: (a) now, and (b) as a backlog item.
|
||||
3. **Anonymous `/forum`.** D-14 does not say whether anonymous visitors see the forum, and the forum API is JWT-only. Recommendation: use the D-04/D-18 pattern (description plus "register to read and post") unless the user wants a public read endpoint in `golem15.demo`.
|
||||
4. **D-02 scope vs. bm reality.** bm has no threads, pins, multi-channel list or link embeds. Recommendation:
|
||||
- Threads: replies through `/messages/{id}/replies`, plus `PUT /threads/{rootId}/mute`.
|
||||
- Pins: chatchannels pins API.
|
||||
- Link embeds: linkified URLs. No unfurl backend exists, so no preview card. Add a slot or extension point for host embed renderers (bm's entity embeds become a host extension).
|
||||
|
||||
Confirm with the user that link embeds without previews is enough.
|
||||
5. **New git.golem15.com repos.** `vue-chat-layer`, `vue-chatvideo-layer`, `vue-forum-layer`, `vue-userfriends-layer` and `sm-moderation-plugin` must be created on git.golem15.com (as for vue-oauth-layer in 32.1). The layers must be public or pnpm-installable from the build machine (existing layers install via `git+https`). The plan should start with a user-run or token-backed repo creation step.
|
||||
6. **Firewall change on a shared production host.** Opening the LiveKit ports through Vuurmuur needs the user's go-ahead. Plan it as a `checkpoint:human-action`.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Go | all Go repos | ✓ | go1.27.2 | — |
|
||||
| Docker | testcontainers tests | ✓ | 29.9.1 | `-short` tests |
|
||||
| Node | layers, vue-demo-app | ✓ | v24.22.0 local (rome site runs Node 22.22.0) | — |
|
||||
| pnpm | layers | ✓ | 12.14.0 | — |
|
||||
| livekit-server (local) | dev.sh video, tests | ✓ | 1.13.1 | `classes.FakeRooms` in Go tests |
|
||||
| livekit-server (rome) | D-10 | ✓ binary present | 1.13.1 | — |
|
||||
| centrifugo (local + rome) | realtime | ✓ | v6.5.1 | null driver |
|
||||
| rome free ports 7890-7892 | demo LiveKit | ✓ (not listening today) | — | other free ports |
|
||||
| rome firewall opening | media | ✗ (Vuurmuur rule needed) | — | none for media; blocks calls |
|
||||
| ssh rome | deploy | ✓ (`debian@10.8.3.118 -p 2222`, passwordless sudo) | — | — |
|
||||
|
||||
**Missing dependencies with no fallback:** the Vuurmuur rule for the demo LiveKit media ports (user-approved action).
|
||||
**Missing with fallback:** none.
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework (Go) | `go test` (testcontainers Postgres for full runs; `-short` skips Docker) |
|
||||
| Framework (layers) | `node --test` (existing layers); **vitest 3.2.7** recommended for `vue-chat-layer` (bm's chat tests are vitest, `vi.mock`) |
|
||||
| Framework (app site) | vitest 3.2.7 (`sm-demo-app/vue-demo-app/vitest.config.ts`) |
|
||||
| Quick run (plugin) | `go vet ./... && go test -short ./...` in the plugin checkout |
|
||||
| Quick run (demo) | `cd ../sm-demo-app && go vet ./... && go test -short ./...` |
|
||||
| Full suite (demo) | `scripts/check-all.sh` (Go app + summercms_mcp tag, user/journal plugin tests, vitest, sync-roadmap, check-pins, build release, smoke, check-deploy, browser checks, check-live) |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
| Req | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|-----|----------|-----------|-------------------|-------------|
|
||||
| SC-1 | go.mod requires tags for all chat-family modules and summer.yaml lists them | unit | `go test -run TestGoModRequiresTags .` (sm-demo-app) | ✅ (extend wantPlugins) |
|
||||
| SC-1 | checkouts on required tags | script | `scripts/check-pins.sh` | ✅ |
|
||||
| SC-1 | every plugin activates, routes mounted (`/_chat/api/v1`, `/_chatchannels/api/v1`, `/_chatvideo/api/v1`, `/_chatbots/api/v1`, `/_forum/api/v1`, `/_userfriends/api/v1`, `/_herald/api/v1`, `/_demo/api/v1/chat`) | unit | `go test -short -run TestBootAllPlugins .` | ✅ extend |
|
||||
| SC-1 | release builds without go.work | script | `scripts/build.sh release` | ✅ |
|
||||
| SC-1 | demo:seed fills chat/channels/threads/reactions/pins/forum/friends EN+PL, idempotent | integration (Docker) | `go test -run 'TestSeed' .` | ❌ Wave 0: `seed_chat_test.go` |
|
||||
| SC-1 | reset wipes chat family and reseeds | integration | `go test -run TestResetKeepsPlans .` | ✅ extend |
|
||||
| D-06 | demo context membership, live membership, banned user excluded; channel adapter resolves | unit/integration | `go test -run 'TestDemoChat' ./plugins/demo/...` | ❌ Wave 0 |
|
||||
| D-07 | members cannot create channels (403/404 on POST channels) | integration | `go test -run TestDemoChannelsSeededOnly .` | ❌ |
|
||||
| D-11 | `video/start` on a channel conversation → 422; on DM → 201 with FakeRooms | integration | `go test -run TestDemoCallsDMOnly .` | ❌ |
|
||||
| Pitfall 4 | `/api/realtime/token` 200 for any member, 401 without JWT | unit | `go test -run TestRealtimeRoutes .` | ✅ extend |
|
||||
| D-18 | seam: no listener → identical behaviour; rejecting listener → 422 errors.content / errors.body, nothing persisted, no event | unit + Docker | `go test -short ./...` + `go test -run 'TestMessageSaving|TestPostSaving' .` in sm-chat-plugin / sm-forum-plugin | ❌ |
|
||||
| D-18 | PHP parity untouched | unit | existing `php_method_map_test.go`, `golden_test.go` | ✅ |
|
||||
| D-17 | filter: words (case-insensitive, word boundary), links (scheme/host/bare domain), settings round-trip | unit | `go test ./...` in sm-moderation-plugin | ❌ new repo |
|
||||
| D-05 | demo commands listed and dispatched ephemeral | unit | `go test -run TestDemoCommands ./plugins/demo/...` | ❌ |
|
||||
| Centrifugo | namespaces chat/user/video/presentations valid | script | `scripts/check-deploy.sh` (`centrifugo checkconfig`) | ✅ extend |
|
||||
| LiveKit | config valid, ports agree across supervisor/nginx/config | script | `scripts/check-deploy.sh` | ❌ extend |
|
||||
| D-01/02 | store: channels, DMs, threads, unread, mentions, reactions, typing, events parsing | unit | `pnpm test` in vue-chat-layer (ported `chat.test.ts`, `chatEvents/Format/Text/Unread.test.ts`) | ❌ port |
|
||||
| D-13 | call page state machine (join/leave/mute/camera, token errors) with a mocked Room | unit | `pnpm test` in vue-chatvideo-layer | ❌ |
|
||||
| D-14/15 | forum and friends API clients (URL building, error envelopes) | unit | `pnpm test` in each layer | ❌ |
|
||||
| SC-2/3 | live: /chat prompt anonymous, /forum, CTA, /backend 404, /_chat 401 without JWT, `/livekit/rtc/validate` reachable, /ws 101 | live | `scripts/check-live.sh https://demo.summercms.io` | ✅ extend |
|
||||
| SC-2 | two-browser chat realtime + DM call | manual (browser) | `scripts/check-local.sh chat` (Playwright, as check-remote.cjs) + human UAT on live | ❌ |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** the quick run of the repo touched (`go test -short ./...` or `pnpm test`)
|
||||
- **Per wave merge:** sm-demo-app `go test ./...` (Docker) + `pnpm --dir vue-demo-app test` + `scripts/check-pins.sh`
|
||||
- **Phase gate:** `scripts/check-all.sh` green, then `/msd-verify-work`
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `sm-demo-app/seed_chat_test.go`, `chat_test.go` (context, adapter, resolvers, DM-only calls, bootstrap JSON)
|
||||
- [ ] `sm-chat-plugin` seam tests; `sm-forum-plugin` seam tests
|
||||
- [ ] `sm-moderation-plugin` package + tests (new repo)
|
||||
- [ ] `vue-chat-layer` vitest config + ported bm tests (rewrite `setActivePinia(createPinia())` setup for the composable store)
|
||||
- [ ] `vue-chatvideo-layer`, `vue-forum-layer`, `vue-userfriends-layer` `tests/*.test.ts`
|
||||
- [ ] `scripts/dev.sh`: start a local `livekit-server` (dev keys, ports 7890-7892 or `--dev`) like Centrifugo; `scripts/check-local.sh chat`
|
||||
|
||||
## Security Domain
|
||||
|
||||
### Applicable ASVS Categories
|
||||
| ASVS Category | Applies | Standard Control |
|
||||
|---------------|---------|-----------------|
|
||||
| V2 Authentication | yes | `jwt.auth` on every chat-family route; Turnstile on register (existing) |
|
||||
| V3 Session Management | yes | user JWT + refresh; realtime tokens (lighthouse, TTL 3600); LiveKit join tokens (HS256, `token_ttl` 900) |
|
||||
| V4 Access Control | yes | Host resolvers (demo context membership, live membership), chat participant checks, IDOR→404 in plugins, DM-only calls via `max_participants: 2` |
|
||||
| V5 Input Validation | yes | Plugin validators + new pre-save moderation seam; forum Markdown sanitized on write (`breakwater.Render`); chat content rendered as text (bm `chatText`), never `v-html` of raw content |
|
||||
| V6 Cryptography | yes | No hand-rolled crypto: LiveKit keys and VAPID keys generated (`openssl rand`, `herald:vapid-generate`), stored in `.env` 0640 |
|
||||
| V12 Files | yes | Chat attachments private, served only to participants; upload body limit 1 MiB default (keep, nginx `client_max_body_size 2m`) |
|
||||
|
||||
### Known Threat Patterns
|
||||
| Pattern | STRIDE | Standard Mitigation |
|
||||
|---------|--------|---------------------|
|
||||
| Spam/abuse in public chat and forum | Tampering / DoS | Moderation filter (pre-save), plugin buckets (`chat-api` 120/min, `chat-dm` 10/min, `chat-upload` 20/min, `forum-api`, `video-initiate` 5/min), Turnstile, reset job, user ban |
|
||||
| Stored XSS via messages/posts/names | Tampering | Text rendering for chat; server-sanitized `content_html` only for forum; no `v-html` on user fields |
|
||||
| Subscribing to others' conversations | Information disclosure | Centrifugo subscribe proxy → `AuthorizeChat` (participants only), `AuthorizeVideo`, user authorizer (`user:{self}`) |
|
||||
| LiveKit key co-tenancy with WavePath | Elevation | Separate LiveKit instance and keys (not a second key on WavePath's server) |
|
||||
| Origin IP disclosure via ICE | Information disclosure | Accepted (rome already exposes services on its IP); document in DEPLOY.md |
|
||||
| Call abuse (long or large rooms) | DoS | `max_duration` 900 + reconcile, `room.max_participants` 4, `empty_timeout`, `video-initiate` bucket |
|
||||
| Upload abuse | DoS | chat pending quota (10 files), prune job hourly, 1 MiB body limit |
|
||||
| Secrets in logs/payloads | Information disclosure | Same discipline as 19.4.1: keys only by name; never in `NUXT_PUBLIC_*` except public ones (VAPID public key) |
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- Repo reads this session:
|
||||
- `sm-bm-app/plugins/golem15/{chat,chatchannels,chatbots}` (README, classes, api, boot, events, fixture tests)
|
||||
- `sm-{chatvideo,forum,herald,userfriends}-plugin` (README, config, classes, api)
|
||||
- `sm-bm-app/plugins/jz/bm/{chat.go,classes/chat_context.go,classes/chat_sync.go,controllers/api/chat.go,realtime_access.go}`
|
||||
- `sm-bm-app/frontend/src/{api/chat.ts,api/types.ts,stores/chat.ts,lib/chatEvents.ts,components/chat/*}`
|
||||
- `sm-demo-app/{summer.yaml,go.mod,go.work,.gitmodules,DEPLOY.md,README.md,scripts/check-pins.sh,go_mod_test.go,boot_test.go,plugins/demo/*,deploy/*,config/*}`
|
||||
- `vue-{faq,paymentgateway,oauth}-layer`
|
||||
- `sm-demo-app/vue-demo-app`
|
||||
- `summercms.go/modules/{festival,lighthouse,fetchguard,party,conga}` README and source
|
||||
- Scratch compile and tests of all seven plugins against framework v0.5.1 (go build/vet/test -short)
|
||||
- `git ls-remote` per plugin repo; npm registry (`npm view`); msd package-legitimacy seam
|
||||
- Read-only ssh probe of rome (ports, supervisor programs, LiveKit config with keys redacted, Vuurmuur service, iptables)
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- LiveKit config sample: https://raw.githubusercontent.com/livekit/livekit/master/config-sample.yaml
|
||||
- livekit-client URL handling: https://raw.githubusercontent.com/livekit/client-sdk-js/main/src/api/utils.ts
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- [upd.dev patch notes on /rtc/v1 fallback](https://upd.dev/slopus/happy/compare/4533ef5666..c97ea347f1.patch)
|
||||
- [LiveKit community: v1.13.1 signalling drops behind nginx ssl_preread](https://community.livekit.io/t/livekit-v1-13-1-signaling-ws-drops-mid-call-with-client-request-leave-full-reconnect-media-udp-unaffected-behind-nginx-ssl-preread/1481)
|
||||
- [client-sdk-js PR #1785 (new rtc path)](https://app.semanticdiff.com/gh/livekit/client-sdk-js/pull/1785/overview)
|
||||
- [livekit-client 2.18.4 release notes](https://newreleases.io/project/npm/livekit-client/release/2.18.4)
|
||||
- [LiveKit firewall tips](https://kb.livekit.io/articles/1724892785-establishing-media-connection-firewall-troubleshooting)
|
||||
|
||||
## Suggested Plan Split (for the lean-planning confirmation)
|
||||
|
||||
| # | Scope (one line) | Repos |
|
||||
|---|------------------|-------|
|
||||
| 01 | Shared plugin release: fast-forward siblings; fix chatvideo/herald/userfriends go.mod; chat v0.1.4 and forum pre-save seams; new `sm-moderation-plugin`; tag all eight (with go-ahead) | 8 plugin repos |
|
||||
| 02 | Demo backend: oauthserver bump (folded todo); mount 8 plugins (submodules, go.work, go.mod, summer.yaml); demo context + channel adapter + resolvers; bootstrap endpoints; realtime guard; chatbots commands; moderation wiring; seed + reset; Centrifugo namespaces; local LiveKit in dev.sh | sm-demo-app |
|
||||
| 03 | `vue-chat-layer` v0.1.0 (port, multi-channel, threads, pins, command picker, shared realtime) and its use in vue-demo-app (`/chat`, floating panel, Chat tour card, EN/PL) | vue-chat-layer, vue-demo-app |
|
||||
| 04 | `vue-chatvideo-layer`, `vue-forum-layer`, `vue-userfriends-layer` v0.1.0 and their pages and tour cards in vue-demo-app; herald VAPID config | 3 layers, vue-demo-app, sm-demo-app |
|
||||
| 05 | rome deploy: LiveKit program, Vuurmuur rule (checkpoint), nginx locations, `.env` keys (LiveKit, VAPID), Centrifugo restart, release, DEPLOY.md, check-deploy and check-live extensions | sm-demo-app, rome |
|
||||
| 06 | Unit tests (Go, layers, site) and live verification incl. a two-browser chat and DM call | all |
|
||||
|
||||
Plans 03 and 04 can run in parallel after 02. 05 can merge into 04 if the user prefers fewer plans.
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH. Versions come from go.mod files and the registry, and compatibility was compiled this session.
|
||||
- Architecture: HIGH for the backend glue (code read), MEDIUM for the layer design (new multi-channel work).
|
||||
- LiveKit deployment: MEDIUM. rome was probed, but the client path behaviour on server 1.13.1 behind Cloudflare is assumed.
|
||||
- Pitfalls: HIGH for repo, realtime and seed issues, MEDIUM for the network ones.
|
||||
|
||||
**Research date:** 2026-10-28
|
||||
**Valid until:** 2026-11-27 for the repo state (re-check `git ls-remote` before tagging); 7 days for the livekit-client version pick
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
phase: "20"
|
||||
slug: "demo-instance-with-every-shared-plugin"
|
||||
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
|
||||
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
created: "2026-10-28"
|
||||
---
|
||||
|
||||
# Phase 20 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | go test (plugins, sm-demo-app; testcontainers Postgres on full runs); vitest 3.2.7 (vue-demo-app, vue-chat-layer); node --test or vitest (other layers) |
|
||||
| **Config file** | `../sm-demo-app/vue-demo-app/vitest.config.ts`; new layers: none — Wave 0 installs |
|
||||
| **Quick run command** | `go vet ./... && go test -short ./...` in the touched Go repo; `pnpm test` in the touched layer |
|
||||
| **Full suite command** | `cd ../sm-demo-app && scripts/check-all.sh` |
|
||||
| **Estimated runtime** | ~30 seconds quick, ~300 seconds full |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** Run the quick run command of the repo touched
|
||||
- **After every plan wave:** Run sm-demo-app `go test ./...` (Docker) + `pnpm --dir vue-demo-app test` + `scripts/check-pins.sh`
|
||||
- **Before `/msd-verify-work`:** Full suite must be green
|
||||
- **Max feedback latency:** 60 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
Filled by the planner from the plans; source map in `20-RESEARCH.md` § Validation Architecture.
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||
| 20-01-01 | 01 | 1 | D-18, SC-1 | T-20-01, T-20-02 | a rejecting MessageSaving listener gives 422 and stores nothing; no listener changes nothing | integration (Docker) | `go -C ../sm-chat-plugin test -count=1 -run '^TestMessageSavingTracer$' -v .` | ❌ created in task (saving_smoke_test.go) | ⬜ pending |
|
||||
| 20-01-02 | 01 | 1 | D-18 | T-20-01 | forum thread, reply and edit refused before render | integration (Docker) | `go -C ../sm-forum-plugin test -count=1 -run '^TestPostSavingTracer$' -v .` | ❌ created in task | ⬜ pending |
|
||||
| 20-01-03 | 01 | 1 | D-17 | T-20-03, T-20-04 | word and link matching; list caps; settings behind a permission | unit | `go -C ../sm-moderation-plugin test -count=1 -run '^TestFilterSmoke$' -v ./classes` | ❌ new repo | ⬜ pending |
|
||||
| 20-01-04 | 01 | 1 | SC-1 | T-20-05 | modules resolve by tag only | build | `for r in …8 repos; do go -C ../$r vet ./... && go -C ../$r test -short ./...; done` + `GOWORK=off go build` of 5 dependants | ✅ | ⬜ pending |
|
||||
| 20-01-05 | 01 | 1 | — | T-20-05 | go-ahead before one-way tags | checkpoint | — | — | ⬜ pending |
|
||||
| 20-01-06 | 01 | 1 | SC-1 | T-20-05 | remote tags equal verified HEADs | script | `git ls-remote origin refs/tags/<tag>^{}` loop | ✅ | ⬜ pending |
|
||||
| 20-02-01 | 02 | 2 | SC-1, D-06, D-07 | T-20-06, T-20-07 | member posts in #general; channel creation refused | integration (Docker) | `go -C ../sm-demo-app test -count=1 -run '^(TestDemoChatTracer\|TestGoModRequiresTags\|TestBootAllPlugins)$' -v .` + `scripts/check-pins.sh` | ❌ created in task (chat_smoke_test.go) | ⬜ pending |
|
||||
| 20-02-02 | 02 | 2 | D-05, D-08, D-12, D-14, D-17, D-22, D-23 | T-20-09, T-20-10 | banned words and links refused in chat and forum | integration (Docker) | `go -C ../sm-demo-app test -count=1 -run '^TestDemoModerationSmoke$' -v .` + `scripts/build.sh dev` | ❌ created in task | ⬜ pending |
|
||||
| 20-02-03 | 02 | 2 | D-09 | T-20-08, T-20-13 | personas cannot log in; reset empties every chat-family table | integration (Docker) | `go -C ../sm-demo-app test -count=1 -run '^(TestSeedChatSmoke\|TestResetKeepsPlans\|TestSeedRoadmapUpsert)$' -v .` | ❌ / ✅ | ⬜ pending |
|
||||
| 20-02-04 | 02 | 2 | D-06, D-10 | T-20-11, T-20-12 | realtime token only for live members; no secret committed | unit + script | `go -C ../sm-demo-app test -short .` + `centrifugo checkconfig` + `scripts/dev.sh up` + `scripts/check-local.sh realtime` | ✅ | ⬜ pending |
|
||||
| 20-03-01 | 03 | 3 | D-01, D-04, D-07, SC-2 | T-20-15 | anonymous prompt; Bearer only to same-origin APIs | unit + browser | `pnpm --dir ../vue-chat-layer test` + vue-demo-app build + `scripts/check-local.sh chat` | ❌ created in task | ⬜ pending |
|
||||
| 20-03-02 | 03 | 3 | D-02, SC-2 | T-20-14, T-20-19 | text escaped; one realtime connection | unit + browser | layer and vue-demo-app tests + `scripts/check-local.sh chat realtime remote` | ✅ (app) | ⬜ pending |
|
||||
| 20-03-03 | 03 | 3 | D-02, D-05, D-22, D-23 | T-20-14, T-20-16, T-20-17 | linkify on escaped HTML; authorised downloads; ephemeral answers stay local | unit + build | `pnpm --dir ../vue-chat-layer test` + `build:playground` + `scripts/check-local.sh chat` | ✅ | ⬜ pending |
|
||||
| 20-03-04 | 03 | 3 | D-03, D-04 | — | floating panel hidden for anonymous visitors | browser | `scripts/check-local.sh tour chat` | ✅ extend | ⬜ pending |
|
||||
| 20-03-05 | 03 | 3 | D-01 | — | go-ahead before the public tag | checkpoint | — | — | ⬜ pending |
|
||||
| 20-03-06 | 03 | 3 | D-01 | T-20-18 | layer pinned by tag with a committed lockfile | script | remote tag equals HEAD + pin grep + vue-demo-app test and build | ✅ | ⬜ pending |
|
||||
| 20-04-01 | 04 | 4 | D-14, D-21, D-22 | T-20-20 | posts render only server content_html | unit + script | `pnpm --dir ../vue-forum-layer test` + build + `scripts/check-local.sh forum` | ❌ created in task | ⬜ pending |
|
||||
| 20-04-02 | 04 | 4 | D-15 | T-20-25 | friend refusals stay generic | unit + script | `pnpm --dir ../vue-userfriends-layer test` + `scripts/check-local.sh friends forum chat` | ❌ created in task | ⬜ pending |
|
||||
| 20-04-03 | 04 | 4 | D-13 | T-20-SC | livekit-client legitimacy confirmed before install | checkpoint (blocking-human) | — | — | ⬜ pending |
|
||||
| 20-04-04 | 04 | 4 | D-11, D-12, D-13, D-23 | T-20-21, T-20-22, T-20-23, T-20-24 | server-minted tokens; call button only in DMs; media only after start or accept | unit + script | `pnpm --dir ../vue-chatvideo-layer test` + build + `scripts/check-local.sh call chat` | ❌ created in task | ⬜ pending |
|
||||
| 20-04-05 | 04 | 4 | D-16 | T-20-26 | VAPID keys only in .env | script | `scripts/dev.sh up` + `scripts/check-local.sh herald tour` | ❌ check added in task | ⬜ pending |
|
||||
| 20-04-06 | 04 | 4 | D-14, D-15, D-13 | — | go-ahead before public tags | checkpoint | — | — | ⬜ pending |
|
||||
| 20-04-07 | 04 | 4 | SC-2 | T-20-SC | layers pinned by tag | script | remote tags equal HEAD + vue-demo-app test, build + `scripts/check-local.sh forum friends call chat tour` | ✅ | ⬜ pending |
|
||||
| 20-05-01 | 05 | 5 | D-10, D-12, SC-3 | T-20-31 | exact Go allow-list; /_herald unrouted; ports and caps agree | script | `scripts/check-deploy.sh` (last line `check-deploy: ok`) | ✅ extend | ⬜ pending |
|
||||
| 20-05-02 | 05 | 5 | SC-1 | T-20-33 | release build from tags, smoke clean | script | `scripts/check-pins.sh` + `scripts/build.sh release` + `scripts/smoke.sh` | ✅ | ⬜ pending |
|
||||
| 20-05-03 | 05 | 5 | SC-3 | T-20-27 | go-ahead before rome changes | checkpoint | — | — | ⬜ pending |
|
||||
| 20-05-04 | 05 | 5 | SC-2, SC-3, D-10, D-16 | T-20-27, T-20-28, T-20-34 | WavePath untouched; secrets generated on rome | live | `scripts/check-live.sh https://demo.summercms.io` | ✅ extend | ⬜ pending |
|
||||
| 20-05-05 | 05 | 5 | D-10 | T-20-29, T-20-32 | go-ahead before the firewall change | checkpoint | — | — | ⬜ pending |
|
||||
| 20-05-06 | 05 | 5 | D-10 | T-20-29, T-20-32 | only 7891/tcp and 7892/udp opened, through Vuurmuur | live | `ssh … iptables -S` count + `nc -zv … 7891` | ✅ | ⬜ pending |
|
||||
| 20-06-01 | 06 | 6 | D-17, D-18, SC-4 | T-20-10 | settings to filter to listener to 422 | unit + integration | `go -C ../sm-moderation-plugin test ./...` + `-run '^TestDemoModeration$'` | ❌ created in task | ⬜ pending |
|
||||
| 20-06-02 | 06 | 6 | D-18, D-17 | T-20-01, T-20-02 | parity without listeners; refusal on every write path | unit + integration | chat, forum and moderation full suites | ❌ created in task | ⬜ pending |
|
||||
| 20-06-03 | 06 | 6 | D-05 to D-09, D-11, D-12, D-22, D-23, SC-1 | T-20-06, T-20-07, T-20-09, T-20-11 | membership, refused channel create, moderator scope, member-only tokens | integration | `go -C ../sm-demo-app test -count=1 . ./plugins/demo/...` + named tests | ❌ created in task | ⬜ pending |
|
||||
| 20-06-04 | 06 | 6 | D-01, D-02, D-04, D-05, D-13, D-14, D-15, D-21, D-23 | T-20-14 | escaping and DM-only slot pinned | unit | `pnpm test` in four layers + vue-demo-app test and build | ❌ created in task | ⬜ pending |
|
||||
| 20-06-05 | 06 | 6 | SC-2, D-11, D-13, D-23 | — | two-browser realtime; DM call with fake media | browser E2E | `scripts/check-local.sh chat chatlive callmedia forum friends call herald tour realtime remote` | ❌ created in task | ⬜ pending |
|
||||
| 20-06-06 | 06 | 6 | — | — | go-ahead before pushes and fix tags | checkpoint | — | — | ⬜ pending |
|
||||
| 20-06-07 | 06 | 6 | SC-2, SC-3, SC-4 | T-20-36 | full gate including check-live | gate | `scripts/check-all.sh --local` (last line `check-all: ok`) | ✅ extend | ⬜ pending |
|
||||
| 20-06-08 | 06 | 6 | SC-2, SC-3 | — | two-browser chat and DM call on the live demo | manual (UAT) | — | — | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
Each gap is closed inside the plan that creates the code (a smoke test or check in the same task), and the full tests come in plan 06 (project rule: unit tests are the last plan).
|
||||
|
||||
- [ ] `sm-demo-app/seed_chat_test.go`, `chat_test.go` (context, channel adapter, resolvers, DM calls, bootstrap JSON): smoke in 20-02 (`chat_smoke_test.go`), full in 20-06 Task 3
|
||||
- [ ] `sm-chat-plugin` and `sm-forum-plugin` pre-save seam tests: smoke in 20-01 Tasks 1-2, full in 20-06 Task 2
|
||||
- [ ] `sm-moderation-plugin` package and tests (new repo): smoke in 20-01 Task 3, full in 20-06 Tasks 1-2
|
||||
- [ ] `vue-chat-layer` vitest config and ported bm chat tests (composable store setup): config and smoke in 20-03 Task 1, ported tests in 20-06 Task 4
|
||||
- [ ] `vue-chatvideo-layer`, `vue-forum-layer`, `vue-userfriends-layer` `tests/*.test.ts`: smoke in 20-04 Tasks 1, 2 and 4, full in 20-06 Task 4
|
||||
- [ ] `scripts/dev.sh` local livekit-server (20-02 Task 4); `scripts/check-local.sh chat` (20-03 Task 1), `forum`, `friends`, `call`, `herald` (20-04), `chatlive` and `callmedia` (20-06 Task 5)
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Two-browser chat realtime and a DM video call on the live demo | SC-2, D-13 | Real media over NAT'd browsers and the rome firewall | Two accounts on https://demo.summercms.io: send a message, see it arrive live; start a DM call, see and hear both tiles, mute, leave |
|
||||
| Vuurmuur firewall rule on rome | D-10 | Shared production host change | Apply with the user's go-ahead; confirm with `/livekit/rtc/validate` and a call |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [x] All tasks have `<automated>` verify or Wave 0 dependencies (checkpoints excepted)
|
||||
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [x] No watch-mode flags (vitest run, node --test)
|
||||
- [ ] Feedback latency < 60s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
@@ -0,0 +1,206 @@
|
||||
---
|
||||
phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con
|
||||
plan: 05
|
||||
subsystem: auth
|
||||
tags: [mcp, oauth, release, changelog, scopes]
|
||||
|
||||
requires:
|
||||
- phase: 32.1-01
|
||||
provides: MCP scope list and MCPCall on the tool-call context
|
||||
- phase: 32.1-02
|
||||
provides: OAuth implied scopes in sm-user-plugin
|
||||
- phase: 32.1-03
|
||||
provides: MCP consent replace, kind, and backfill
|
||||
- phase: 32.1-04
|
||||
provides: vue-oauth-layer /connect
|
||||
provides:
|
||||
- "Unit tests for a bare MCP call, both guards, PRM scopes, implied scopes, consent replace, kind, and backfill"
|
||||
- "SummerCMS v0.5.2, sm-user-plugin v0.1.16, sm-oauthserver-plugin v0.1.4, and local vue-oauth-layer v0.1.0"
|
||||
- "Changelog post https://summercms.io/changelog/summercms-0-5-2"
|
||||
affects: [33]
|
||||
|
||||
actuals:
|
||||
tokens: 6474
|
||||
tasks: 4
|
||||
commits: 1
|
||||
plan_head_before: 06a144e19f1bca9b918af7adfc44e7b7a1e3f011
|
||||
plan_head_after: 9094bf9ad99c92b4343d8d3f1062ef4863f58e00
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Patch tags are computed from git tag --list at release time, recorded in 32.1-05-release-decision.txt before any ref changes"
|
||||
- "sm-oauthserver-plugin bumps require versions after those tags are pushed and keeps the summercms replace"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- .planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-release-decision.txt
|
||||
- .planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-changelog-notes.md
|
||||
- modules/pact/mcp_call_negative_test.go
|
||||
- modules/pier/dispatch_mcpcall_test.go
|
||||
- modules/pier/prm_scopes_test.go
|
||||
- ../sm-user-plugin/mcp_implies_test.go
|
||||
- ../sm-oauthserver-plugin/mcp_consent_test.go
|
||||
- ../sm-oauthserver-plugin/authorizations_kind_test.go
|
||||
- ../sm-oauthserver-plugin/mcp_backfill_test.go
|
||||
modified:
|
||||
- ../sm-oauthserver-plugin/go.mod
|
||||
- ../sm-oauthserver-plugin/go.sum
|
||||
- .planning/ROADMAP.md
|
||||
|
||||
key-decisions:
|
||||
- "tag-and-publish. Framework v0.5.2 on master, sm-user-plugin v0.1.16 on phase-32.1-mcp-implied-scopes, sm-oauthserver-plugin v0.1.4 on master after the require bump, vue-oauth-layer v0.1.0 local only."
|
||||
- "sm-user-plugin v0.1.16 stays on phase-32.1-mcp-implied-scopes and is not retargeted onto v0.1.15. That tag is not an ancestor."
|
||||
- "vue-oauth-layer has no origin. The tag was not pushed and no remote was created."
|
||||
|
||||
patterns-established:
|
||||
- "Release decision file is written from for-each-ref before the first tag or push and is not rewritten."
|
||||
|
||||
requirements-completed: []
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "A bare context has no MCP call, both guards record MCPCall, and PRM uses advertised scopes or falls back."
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: modules/pact/mcp_call_negative_test.go#TestMCPCallFromBareContext
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: modules/pier/dispatch_mcpcall_test.go#TestDispatchMCPCallBothGuards
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: modules/pier/prm_scopes_test.go#TestPRMUsesAdvertisedScopes
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "OAuth implied scopes do not expand a personal token, write does not grant read, and the subject slice is unchanged."
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: sm-user-plugin/mcp_implies_test.go#TestMCPImpliesPersonalUnchanged
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "MCP consent replace, offline_access, revoke, authorization kind, and idempotent backfill."
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: sm-oauthserver-plugin/mcp_consent_test.go#TestMCPConsentReplace
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: sm-oauthserver-plugin/mcp_backfill_test.go#TestMCPConsentBackfillIdempotent
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Patch tags pushed where origin existed, and the framework changelog post is live."
|
||||
verification:
|
||||
- kind: other
|
||||
ref: python3 plan task-4 verify (exit 0) against 32.1-05-release-decision.txt and https://summercms.io/changelog/summercms-0-5-2
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 34min
|
||||
completed: 2026-10-28
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 32.1 Plan 05: Unit tests and patch release Summary
|
||||
|
||||
**SummerCMS v0.5.2, sm-user-plugin v0.1.16, sm-oauthserver-plugin v0.1.4, and local vue-oauth-layer v0.1.0 ship the MCP OAuth scope, caller, and consent gaps, with the framework changelog posted**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 34 min
|
||||
- **Started:** 2026-10-28T15:09:30Z
|
||||
- **Completed:** 2026-10-28T15:42:50Z
|
||||
- **Tasks:** 4
|
||||
- **Files modified:** 12
|
||||
|
||||
`actuals.tokens` is chars/4 of `git show` for `9094bf9`, `756797c`, `4f8618e`, and `a7743e7`, plus the decision file and changelog notes (25896 / 4). This summary is not in that count. `commits: 1` is `git rev-list --count 06a144e19f1bca9b918af7adfc44e7b7a1e3f011..9094bf9` at summary time. The metadata commit is after that range.
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Framework tests prove a bare context has no MCP call, both guards set one, and PRM uses an advertised list or falls back.
|
||||
- sm-user-plugin tests keep implied scopes off the stored subject. sm-oauthserver-plugin tests prove consent replace, offline access, revoke, authorization kind, and an idempotent backfill.
|
||||
- Tags: SummerCMS `v0.5.2` on `9094bf9` (pushed `master`, then the tag), sm-user-plugin `v0.1.16` on `756797c` (pushed `phase-32.1-mcp-implied-scopes`, then the tag), sm-oauthserver-plugin `v0.1.4` on `a7743e7` (pushed `master`, then the tag), vue-oauth-layer `v0.1.0` on `7f805b3` (local only).
|
||||
- Changelog post id 46: https://summercms.io/changelog/summercms-0-5-2. The plan python verify exited 0.
|
||||
- No app submodule pointer moved. `go.work` in sm-oauthserver-plugin stayed unstaged.
|
||||
|
||||
## Task Commits
|
||||
|
||||
The test commits were already on the branches before this continuation. They were not redone.
|
||||
|
||||
1. **Task 1: Tracer: bare context has no MCP call, and both guards' tool calls do** - `9094bf9` (test, summercms.go)
|
||||
2. **Task 2: Plugin, layer, and docs proofs** - `756797c` (test, sm-user-plugin), `4f8618e` (test, sm-oauthserver-plugin). vue-oauth-layer had no new commit in this plan; its tests were already on `7f805b3`.
|
||||
3. **Task 3: Release checkpoint** - no commit. The accepted option is `tag-and-publish`, recorded in `32.1-05-release-decision.txt` before any tag or push. That file was not rewritten.
|
||||
4. **Task 4: Tag and publish** - `a7743e7` (chore, sm-oauthserver-plugin) sets `summercms v0.5.2` and `sm-user-plugin v0.1.16` and keeps the summercms replace. Tags and pushes are listed above. No framework commit in this task before the metadata commit.
|
||||
|
||||
**Plan metadata:** this summary, the decision file, the changelog notes, and the roadmap check, committed in summercms.go after this file was written.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `modules/pact/mcp_call_negative_test.go` - bare context and a FrontendTokenScope literal without Implies
|
||||
- `modules/pier/dispatch_mcpcall_test.go` - frontend and backend tool calls record MCPCall
|
||||
- `modules/pier/prm_scopes_test.go` - advertised scopes and the PRMScopes fallback
|
||||
- `../sm-user-plugin/mcp_implies_test.go` - personal token, write-does-not-grant-read, subject slice unchanged
|
||||
- `../sm-oauthserver-plugin/mcp_consent_test.go` - replace, offline access, revoke
|
||||
- `../sm-oauthserver-plugin/authorizations_kind_test.go` - kind mcp or oidc
|
||||
- `../sm-oauthserver-plugin/mcp_backfill_test.go` - idempotent backfill
|
||||
- `../sm-oauthserver-plugin/go.mod` - require SummerCMS v0.5.2 and sm-user-plugin v0.1.16
|
||||
- `../sm-oauthserver-plugin/go.sum` - sum for sm-user-plugin v0.1.16
|
||||
- `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-release-decision.txt` - checkpoint record and the tag lists from before the release
|
||||
- `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-changelog-notes.md` - notes published for v0.5.2
|
||||
- `.planning/ROADMAP.md` - 32.1-05 checked, 5/5 plans executed
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- The checkpoint option `tag-and-publish` was applied. Versions came from the tag lists in the decision file: next v0.5 patch is v0.5.2, next user v0.1 patch is v0.1.16, next oauth v0.1 patch is v0.1.4, and vue-oauth-layer is v0.1.0 because that tag did not exist.
|
||||
- sm-user-plugin was tagged on `phase-32.1-mcp-implied-scopes` at `756797c`. `v0.1.15` is not an ancestor. The tag was not moved onto `v0.1.15`. The tag message and the changelog say the missing-scope fields from v0.1.15 are absent. The plugin `go.mod` still requires SummerCMS v0.5.0; the new seams are in v0.5.2, which sm-oauthserver-plugin v0.1.4 requires.
|
||||
- vue-oauth-layer has no `origin`. `v0.1.0` is local. `git@git.golem15.com:golem15/vue-oauth-layer.git` was not created.
|
||||
- STATE.md and state.json were not edited and are not in the metadata commit. Current Position stays phase 33.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
None during the release continuation. The test tasks were already committed.
|
||||
|
||||
The user-plugin test file is `mcp_implies_test.go` at the plugin root, not `classes/mcp_guard_test.go`. That path was chosen by the earlier test commit and was left as committed.
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 0 auto-fixed during the release continuation
|
||||
**Impact on plan:** The release followed the accepted checkpoint. The user-plugin tag is intentionally not based on v0.1.15.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- Pushing summercms.go `master` published 44 commits since v0.5.1, including the phase 32 review fixes and planning commits that were already on local master. The push was a fast-forward. No force-push.
|
||||
- Phase 33 plan 01 reserved the name v0.5.2 for a later tag that would also carry phase 33 seams, and plan 33-14 was going to create that tag. This release took v0.5.2 because it was the next v0.5 patch. Phase 33 must not reuse v0.5.2. The next framework patch is v0.5.3 unless a newer v0.5 tag appears first.
|
||||
- vue-oauth-layer `README.md` inside `v0.1.0` still says the tag does not exist yet. The release order tagged the existing commit and did not add a README commit. The tag was not moved. "No tag has been pushed" is still true, because there is no remote.
|
||||
- `state.advance-plan` was not run. It would move Current Position, which is phase 33. Roadmap 32.1-05 was checked by editing ROADMAP.md directly.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
Create `git@git.golem15.com:golem15/vue-oauth-layer.git`, then push `main` and the annotated tag `v0.1.0` from `/media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer`. Do not force-push. Until that remote exists, apps cannot pin the layer. The tagged README still describes the pre-tag install shape.
|
||||
|
||||
No journal token was printed, copied, or minted.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- The three MCP OAuth gaps are on the tags above. No application go.mod or submodule pointer was bumped.
|
||||
- Phase 33's release ledger must treat v0.5.2 as already published. Its framework seams belong on a later patch.
|
||||
- Current Position was left on phase 33.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
- `vue-oauth-layer/README.md` line 9, inside tag `v0.1.0`: "That tag does not exist yet." The local tag exists; the remote does not. Not edited, because editing would require moving the tag.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: `32.1-05-release-decision.txt` (sha256 `3e33d20e4c626c7c43d5da66930ac4b51a0cc02d2995c1fc7e6d5e59f638fa8f`, first line `tag-and-publish`, not rewritten after the tags)
|
||||
- FOUND: `32.1-05-changelog-notes.md`
|
||||
- FOUND: summercms.go `9094bf9`, sm-user-plugin `756797c`, sm-oauthserver-plugin `4f8618e` and `a7743e7`
|
||||
- FOUND remote tags: `v0.5.2`, `v0.1.16`, `v0.1.4`. Local only: vue-oauth-layer `v0.1.0`
|
||||
- Plan task-4 python verify exit 0. Changelog URL https://summercms.io/changelog/summercms-0-5-2 contains the posted title.
|
||||
|
||||
---
|
||||
*Phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con*
|
||||
*Completed: 2026-10-28*
|
||||
@@ -0,0 +1,29 @@
|
||||
## Highlights
|
||||
|
||||
SummerCMS 0.5.2 lets an MCP OAuth agent use the scopes a plugin declares, and lets a tool call see which subject invoked it. sm-user-plugin v0.1.16 applies read and write as implied scopes for OAuth subjects only. sm-oauthserver-plugin v0.1.4 stores one consent per MCP client, replaces it on a later allow, and backfills live grants. vue-oauth-layer v0.1.0 is the `/connect` page for that consent. Together these versions close the three gaps sm-servers-app needs before MCP OAuth can be switched on: the same tool list as a personal token, attribution of the caller, and per-client revoke.
|
||||
|
||||
## Added
|
||||
|
||||
- One MCP scope list. Plugins publish member-token scopes, the OAuth server advertises that list (optionally filtered), and the protected-resource metadata uses the same names. An empty list still means the five fixed scopes, including backend. OIDC discovery is unchanged.
|
||||
- An MCP call on the request context: guard, user, client, token, scopes, session and tool. A request that is not an MCP tool call has none.
|
||||
- A pending consent view reports whether offline access was requested.
|
||||
- sm-user-plugin v0.1.16 expands a declared scope when an OAuth grant holds the read or write that the scope marks as implied. A personal token is not expanded, write does not imply read, and the stored subject scopes stay as issued. The dispatch credential still carries the personal token id.
|
||||
- sm-oauthserver-plugin v0.1.4 records MCP consent per client, shows scope details on the consent JSON, marks authorizations as mcp or oidc, and backfills a consent for each live grant. A second migrate inserts nothing.
|
||||
- vue-oauth-layer v0.1.0 renders `/connect` from that JSON: checkboxes, read locked, the administrator named as text, and a refetch when the backend link changed. The layer's git remote is not published yet, so an app cannot pin the tag until that repository exists.
|
||||
|
||||
## Changed
|
||||
|
||||
- A later MCP allow replaces the stored consent scopes. It does not merge them with the previous grant.
|
||||
- Unticking offline access clears that flag on the issued code. Omitting the scopes field leaves the pending flag as it was.
|
||||
- Revoking an authorized MCP client deletes its unused code and leaves the user's other clients in place.
|
||||
- sm-oauthserver-plugin v0.1.4 requires SummerCMS v0.5.2 and sm-user-plugin v0.1.16.
|
||||
- sm-user-plugin v0.1.16 is not a descendant of v0.1.15. It does not include the missing-scope and missing-permission fields from that tag. Build it with SummerCMS v0.5.2; its go.mod still states v0.5.0 and selects the newer framework only when the application does.
|
||||
|
||||
## Fixed
|
||||
|
||||
- A hidden relation field or partial field no longer keeps its routes after it is removed from the form.
|
||||
- Record events fire when a hasMany child is linked, unlinked, or attached from a deferred commit, and the event locale is the request locale's primary tag.
|
||||
- A widget action dispatched while the widget connects is delivered.
|
||||
- Uploading through a form extension follows that extension's visibility.
|
||||
- A required field that belongs to a form extension is checked on the extension's own value, so saving the host form no longer fails on a rule the owner model cannot satisfy.
|
||||
- The additive API check compares the new release with the previous one after the tree is tagged.
|
||||
@@ -0,0 +1,70 @@
|
||||
tag-and-publish
|
||||
framework yes
|
||||
v0.1.1
|
||||
v0.1.10
|
||||
v0.1.11
|
||||
v0.1.12
|
||||
v0.1.13
|
||||
v0.1.14
|
||||
v0.1.15
|
||||
v0.1.16
|
||||
v0.1.17
|
||||
v0.1.18
|
||||
v0.1.19
|
||||
v0.1.2
|
||||
v0.1.20
|
||||
v0.1.21
|
||||
v0.1.22
|
||||
v0.1.23
|
||||
v0.1.24
|
||||
v0.1.25
|
||||
v0.1.26
|
||||
v0.1.27
|
||||
v0.1.3
|
||||
v0.1.4
|
||||
v0.1.5
|
||||
v0.1.6
|
||||
v0.1.7
|
||||
v0.1.8
|
||||
v0.1.9
|
||||
v0.2.0
|
||||
v0.2.1
|
||||
v0.2.2
|
||||
v0.2.3
|
||||
v0.2.4
|
||||
v0.3.0
|
||||
v0.3.1
|
||||
v0.4.0
|
||||
v0.4.1
|
||||
v0.4.2
|
||||
v0.4.3
|
||||
v0.4.4
|
||||
v0.5.0
|
||||
v0.5.1
|
||||
.
|
||||
user yes
|
||||
v0.1.0
|
||||
v0.1.1
|
||||
v0.1.10
|
||||
v0.1.11
|
||||
v0.1.12
|
||||
v0.1.13
|
||||
v0.1.14
|
||||
v0.1.15
|
||||
v0.1.2
|
||||
v0.1.3
|
||||
v0.1.4
|
||||
v0.1.5
|
||||
v0.1.6
|
||||
v0.1.7
|
||||
v0.1.8
|
||||
v0.1.9
|
||||
.
|
||||
oauth yes
|
||||
v0.1.0
|
||||
v0.1.1
|
||||
v0.1.2
|
||||
v0.1.3
|
||||
.
|
||||
layer no
|
||||
.
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
phase: 32.1
|
||||
review: 32.1-REVIEW.md
|
||||
titles: json
|
||||
findings:
|
||||
- id: CR-01
|
||||
severity: critical
|
||||
disposition: open
|
||||
title: "An in-flight MCP allow recreates a consent the user just revoked"
|
||||
- id: WR-01
|
||||
severity: warning
|
||||
disposition: open
|
||||
title: "Clearing a declared scope does not stop `read` or `write` from implying it"
|
||||
- id: WR-02
|
||||
severity: warning
|
||||
disposition: open
|
||||
title: "`mcp_scopes` does not limit what an OAuth `read` or `write` grant can call"
|
||||
- id: WR-03
|
||||
severity: warning
|
||||
disposition: open
|
||||
title: "The consent backfill treats unusable tokens as live grants"
|
||||
- id: WR-04
|
||||
severity: warning
|
||||
disposition: open
|
||||
title: "Replacing a consent does not revoke refresh tokens that still carry the removed scopes"
|
||||
- id: WR-05
|
||||
severity: warning
|
||||
disposition: open
|
||||
title: "sm-user-plugin v0.1.16 does not require the framework version it uses"
|
||||
- id: WR-06
|
||||
severity: warning
|
||||
disposition: open
|
||||
title: "`/connect` can allow a stale request after `?request=` changes"
|
||||
open: 7
|
||||
total: 7
|
||||
recorded: 2026-10-28T16:03:08.042Z
|
||||
---
|
||||
|
||||
# Phase 32.1: Code Review Disposition
|
||||
|
||||
| Finding | Severity | Disposition | Source |
|
||||
|---------|----------|-------------|--------|
|
||||
| CR-01 | critical | open | - |
|
||||
| WR-01 | warning | open | - |
|
||||
| WR-02 | warning | open | - |
|
||||
| WR-03 | warning | open | - |
|
||||
| WR-04 | warning | open | - |
|
||||
| WR-05 | warning | open | - |
|
||||
| WR-06 | warning | open | - |
|
||||
|
||||
Dispositions: `open` (recorded, not yet triaged), `fixed`, `skipped`, `deferred`.
|
||||
Set `deferred` by hand and put the reason in the Source cell; both are preserved. A `|` in the reason is kept as prose and escaped on the next run.
|
||||
Re-running the gate keeps every row it can. A row the current review no longer reports is kept and its Source cell flagged, so a finding does not leave this record silently. ONE exception: when a finding id is REUSED by a different finding, the earlier decision cannot keep a row — the id is taken — and it is dropped. A RECORDED decision (anything but `open`) is named on the console when that happens; a row still at `open` is replaced silently, because `open` records no decision to lose.
|
||||
@@ -0,0 +1,185 @@
|
||||
---
|
||||
phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con
|
||||
reviewed: 2026-10-28T15:58:24Z
|
||||
depth: standard
|
||||
files_reviewed: 53
|
||||
files_reviewed_list:
|
||||
- modules/pact/capabilities.go
|
||||
- modules/pact/mcp.go
|
||||
- modules/pact/mcp_call_test.go
|
||||
- modules/pact/mcp_call_negative_test.go
|
||||
- modules/pact/README.md
|
||||
- modules/pier/dispatch.go
|
||||
- modules/pier/prm.go
|
||||
- modules/pier/session.go
|
||||
- modules/pier/stateless.go
|
||||
- modules/pier/mcp_seam_test.go
|
||||
- modules/pier/dispatch_mcpcall_test.go
|
||||
- modules/pier/prm_scopes_test.go
|
||||
- modules/pier/README.md
|
||||
- modules/wristband/authorize.go
|
||||
- modules/wristband/server.go
|
||||
- modules/wristband/consent.go
|
||||
- modules/wristband/mcp_scopes_smoke_test.go
|
||||
- modules/wristband/README.md
|
||||
- docs/services/authentication.md
|
||||
- docs/services/mcp.md
|
||||
- docs/services/oauth-server.md
|
||||
- sm-user-plugin/classes/api_token_scopes.go
|
||||
- sm-user-plugin/classes/mcp_guard.go
|
||||
- sm-user-plugin/plugin.go
|
||||
- sm-user-plugin/go.mod
|
||||
- sm-user-plugin/mcp_implies_test.go
|
||||
- sm-user-plugin/README.md
|
||||
- sm-oauthserver-plugin/classes/mcp_scopes.go
|
||||
- sm-oauthserver-plugin/classes/mcp_verifier.go
|
||||
- sm-oauthserver-plugin/classes/mcp_backend_binding.go
|
||||
- sm-oauthserver-plugin/classes/mcp_store.go
|
||||
- sm-oauthserver-plugin/controllers/mcp_consent_api.go
|
||||
- sm-oauthserver-plugin/controllers/authorizations.go
|
||||
- sm-oauthserver-plugin/config/config.yaml
|
||||
- sm-oauthserver-plugin/plugin.go
|
||||
- sm-oauthserver-plugin/servers.go
|
||||
- sm-oauthserver-plugin/lang/en/lang.yaml
|
||||
- sm-oauthserver-plugin/lang/pl/lang.yaml
|
||||
- sm-oauthserver-plugin/README.md
|
||||
- sm-oauthserver-plugin/updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go
|
||||
- sm-oauthserver-plugin/mcp_scopes_test.go
|
||||
- sm-oauthserver-plugin/mcp_consent_test.go
|
||||
- sm-oauthserver-plugin/mcp_consent_smoke_test.go
|
||||
- sm-oauthserver-plugin/mcp_backfill_test.go
|
||||
- sm-oauthserver-plugin/authorizations_smoke_test.go
|
||||
- sm-oauthserver-plugin/authorizations_kind_test.go
|
||||
- sm-oauthserver-plugin/go.mod
|
||||
- vue-oauth-layer/app/pages/connect.vue
|
||||
- vue-oauth-layer/app/utils/connectConsent.ts
|
||||
- vue-oauth-layer/tests/connect.test.ts
|
||||
- vue-oauth-layer/nuxt.config.ts
|
||||
- vue-oauth-layer/package.json
|
||||
- vue-oauth-layer/README.md
|
||||
findings:
|
||||
critical: 1
|
||||
warning: 6
|
||||
info: 0
|
||||
total: 7
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# Phase 32.1: Code Review Report
|
||||
|
||||
**Reviewed:** 2026-10-28T15:58:24Z
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 53
|
||||
**Status:** issues_found
|
||||
|
||||
## Summary
|
||||
|
||||
Reviewed the phase 32.1 commits only: summercms.go `63d05e8`, `9e29d6f`, `876f0f0`, `9094bf9`; sm-user-plugin `25fd981`, `193a381`, `756797c`; sm-oauthserver-plugin `644641a`, `52529c6`, `a409074`, `4f8618e`, `a7743e7`; vue-oauth-layer `02c466e`, `37c1e12`, `7f805b3`. Lockfiles, the vue skeleton, and phase 33 planning commits were not treated as product changes. Advisory only; no product file was modified.
|
||||
|
||||
The scope list, `MCPCall` placement, PRM copy, and the OAuth-only `Implies` expansion match the phase decisions. The consent decision does not. `IssueCode` commits the authorization code, then a later transaction writes the consent row, and neither takes `LockUserGrants`. A revoke that lands in that gap deletes the code and the old consent, and the allow puts the consent back. Separately, clearing a declared scope on `/connect` does not stop the user guard from implying it back from locked `read`/`write`, and a replaced consent does not revoke refresh tokens that still carry the removed scopes.
|
||||
|
||||
## Narrative Findings (AI reviewer)
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### CR-01: An in-flight MCP allow recreates a consent the user just revoked
|
||||
|
||||
**File:** `sm-oauthserver-plugin/controllers/mcp_consent_api.go:221-241`, `sm-oauthserver-plugin/controllers/authorizations.go:138-164`, `sm-oauthserver-plugin/classes/mcp_store.go:256-279`
|
||||
|
||||
**Issue:** `MCPConsentDecide` calls `srv.IssueCode`, which commits `MarkIssued` in its own transaction, and only then calls `recordMCPConsent`. `MarkIssued` does not take `LockUserGrants`. Revoke does: it locks, deletes the consent, and deletes MCP codes, access tokens, and refresh tokens for that user and client.
|
||||
|
||||
This interleaving is possible whenever a consent row already exists (a re-consent, including the silent `consent_required: false` path):
|
||||
|
||||
1. Allow's `IssueCode` commits. The new MCP code exists. The consent row is still the old one, or not yet replaced.
|
||||
2. Revoke commits. It deletes that consent and the new code, then returns `{"revoked": true}`.
|
||||
3. Allow's `recordMCPConsent` runs. `Take` now returns not found, so `replaceMCPConsent` inserts a new row.
|
||||
4. Allow returns 200 and the redirect for a code revoke already deleted.
|
||||
|
||||
The token exchange fails, but the consent row is back. The next authorize for scopes that row covers sets `consent_required` false, and `vue-oauth-layer` posts allow immediately. Revoke did not stick. The same split leaves a live code and a 500 if the consent write fails after `IssueCode` succeeds, because the redirect is withheld while the code hash is already stored.
|
||||
|
||||
**Fix:** Issue the code and upsert the consent in one transaction that holds `LockUserGrants` for the whole unit, the same exclusive lock revoke takes. Revoke then either waits and deletes both rows, or commits first and the allow sees that result inside the lock. Do not write the consent after `IssueCode` returns.
|
||||
|
||||
```go
|
||||
err = s.backend.WithinTx(ctx, func(tx Tx) error {
|
||||
if err := classes.LockUserGrants(ctx, tx.DB(), []uint{userID}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.MarkIssued(ctx, pending.ID, sha256Hex(code), userID, grantedScopes, collectionIDs, expiresAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.MarkConsented(ctx, client.ClientID); err != nil {
|
||||
return err
|
||||
}
|
||||
return replaceMCPConsent(ctx, tx.DB(), userID, client.ClientID, stored, now)
|
||||
})
|
||||
```
|
||||
|
||||
`replaceMCPConsent` has to use that same `tx`. A consent failure must roll the code back.
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: Clearing a declared scope does not stop `read` or `write` from implying it
|
||||
|
||||
**File:** `sm-user-plugin/classes/mcp_guard.go:160-174`, `sm-oauthserver-plugin/controllers/mcp_consent_api.go:408-418`, `vue-oauth-layer/app/pages/connect.vue:147-160`
|
||||
|
||||
**Issue:** `read` is locked whenever it was requested. Declared scopes are checkboxes the user can clear, and `grantedScopes` then omits them from the token and, on a real replace, from the consent row. `mcpEffectiveScopes` adds every catalog name whose `Implies` is `read` or `write` whenever the OAuth grant still holds that stock scope. A grant of only `read` is also what a client receives when the user cleared `servers:read` and left locked `read`. `MCPAllows` and the in-memory `user.scope` credential both see the name again, so the tool still runs. The checkbox is not a denial for any scope implied by a stock scope that remains on the grant.
|
||||
|
||||
**Fix:** Store an explicit denial, or stop presenting an implied name as optional while its stock scope is on the request. If the name stays optional, `mcpEffectiveScopes` must not append a declared name the user cleared on this grant. Absence cannot mean both "never requested" and "user denied" without a separate denial list.
|
||||
|
||||
### WR-02: `mcp_scopes` does not limit what an OAuth `read` or `write` grant can call
|
||||
|
||||
**File:** `sm-user-plugin/classes/mcp_guard.go:165-173`, `sm-oauthserver-plugin/classes/mcp_scopes.go:25-57`, `sm-oauthserver-plugin/config/config.yaml:77-84`
|
||||
|
||||
**Issue:** `AdvertisedMCPScopes` drops declared names that are not in `golem15.oauthserver.mcp_scopes`. An empty list is documented as the five fixed names only, and authorize rejects anything outside the advertised slice. `mcpEffectiveScopes` never reads that slice. It walks the whole member-token catalog. A client that was only allowed to request `read` still satisfies every `Implies: "read"` name, including names the operator excluded and that `/connect` never shows.
|
||||
|
||||
**Fix:** Intersect the expansion with the advertised MCP scope list (the same slice `NewServers` stored on the verifier). An unset filter still implies every declared name. A set or empty filter then matches what metadata, authorize, and the consent page show.
|
||||
|
||||
### WR-03: The consent backfill treats unusable tokens as live grants
|
||||
|
||||
**File:** `sm-oauthserver-plugin/updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go:151-182`
|
||||
|
||||
**Issue:** `loadLiveMCPTokens` keeps a row when `revoked_at` is null and `expires_at` is null or in the future. It does not require an active, unrevoked MCP client, a non-null access-token expiry, or `rotated_to_id IS NULL`. `MCPTokenVerifier` rejects an access token with a nil expiry, a revoked client, or an inactive client (`classes/mcp_verifier.go:103-115`). A refresh token with `rotated_to_id` set is a replay, not a usable grant (`modules/wristband/token.go:332-340`). Those rows still seed `golem15_oauthserver_consents`. The inserted consent outlives the token, so a later authorize for those scopes skips the prompt even though this server would not have honored the source token.
|
||||
|
||||
**Fix:** Match the verifier. Require `c.revoked_at IS NULL AND c.is_active`, `a.expires_at IS NOT NULL AND a.expires_at > NOW()` for access tokens, and `r.rotated_to_id IS NULL` for refresh tokens.
|
||||
|
||||
### WR-04: Replacing a consent does not revoke refresh tokens that still carry the removed scopes
|
||||
|
||||
**File:** `sm-oauthserver-plugin/controllers/mcp_consent_api.go:348-364`, `modules/wristband/token.go:361-370`
|
||||
|
||||
**Issue:** A real allow replaces `golem15_oauthserver_consents.scopes` with the new grant. It does not revoke that client's access or refresh tokens. Refresh rotation copies `rec.Scopes` and `rec.OfflineAccess` from the existing refresh row, not from the consent row. After the user clears `write` or `offline_access` on a later allow, the old refresh token keeps minting access tokens with the old scope set until it expires or the user deletes the whole authorization.
|
||||
|
||||
**Fix:** In the same locked transaction as the consent replace, revoke this client’s access tokens and refresh-token lineages whose scopes are not a subset of the new grant (and whose `offline_access` column is set when the new grant omits it). Leave other clients alone.
|
||||
|
||||
### WR-05: sm-user-plugin v0.1.16 does not require the framework version it uses
|
||||
|
||||
**File:** `sm-user-plugin/go.mod:6`, `sm-user-plugin/go.mod:183`
|
||||
|
||||
**Issue:** The tagged module calls `pact.MemberTokenScopeCatalog` and `FrontendTokenScope.Implies`, which shipped in summercms v0.5.2. `go.mod` still requires `git.golem15.com/golem15/summercms v0.5.0` and replaces it with `../summercms.go`. Downstream modules ignore that replace. An app that selects v0.1.16 without already selecting summercms v0.5.2 does not compile. sm-oauthserver-plugin v0.1.4 happens to require v0.5.2, so the full stack builds only because of that other module.
|
||||
|
||||
**Fix:** Require `git.golem15.com/golem15/summercms v0.5.2` in sm-user-plugin and drop the sibling replace from the release commit. A local replace can stay uncommitted in a development checkout.
|
||||
|
||||
### WR-06: `/connect` can allow a stale request after `?request=` changes
|
||||
|
||||
**File:** `vue-oauth-layer/app/pages/connect.vue:52-68`
|
||||
|
||||
**Issue:** `load` has no generation token. `watch(requestId)` starts another `load` while the previous `$fetch` is in flight, and `consent_required === false` posts allow before render. Whichever response arrives last assigns `show` and, if that body does not require consent, calls `submit('allow')` for the request it loaded, which may no longer be `requestId`. Both requests can be approved, and the browser can be sent to the redirect of the one the user is no longer on.
|
||||
|
||||
**Fix:** Capture a sequence number (or the request id) before the fetch and ignore the response, including the auto-allow, when it is no longer current.
|
||||
|
||||
```ts
|
||||
let loadSeq = 0
|
||||
async function load(fromRefetch = false) {
|
||||
const seq = ++loadSeq
|
||||
const id = requestId.value
|
||||
// ...
|
||||
const body = await $fetch<ConsentShow>(consentUrl(), { credentials: 'include' })
|
||||
if (seq !== loadSeq || id !== requestId.value) return
|
||||
// assign show and maybe submit
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-10-28T15:58:24Z_
|
||||
_Reviewer: Claude (msd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
@@ -0,0 +1,195 @@
|
||||
---
|
||||
phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con
|
||||
verified: 2026-10-28T15:54:38Z
|
||||
status: passed
|
||||
score: 8/8 must-haves verified
|
||||
covered_files:
|
||||
- ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-01-PLAN.md"
|
||||
- ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-01-SUMMARY.md"
|
||||
- ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-02-PLAN.md"
|
||||
- ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-02-SUMMARY.md"
|
||||
- ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-03-PLAN.md"
|
||||
- ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-03-SUMMARY.md"
|
||||
- ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-04-PLAN.md"
|
||||
- ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-04-SUMMARY.md"
|
||||
- ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-PLAN.md"
|
||||
- ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-SUMMARY.md"
|
||||
- ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-CONTEXT.md"
|
||||
- "docs/services/authentication.md"
|
||||
- "docs/services/mcp.md"
|
||||
- "docs/services/oauth-server.md"
|
||||
- "modules/pact/README.md"
|
||||
- "modules/pact/capabilities.go"
|
||||
- "modules/pact/mcp.go"
|
||||
- "modules/pact/mcp_call_negative_test.go"
|
||||
- "modules/pact/mcp_call_test.go"
|
||||
- "modules/pier/README.md"
|
||||
- "modules/pier/dispatch.go"
|
||||
- "modules/pier/dispatch_mcpcall_test.go"
|
||||
- "modules/pier/events.go"
|
||||
- "modules/pier/filter.go"
|
||||
- "modules/pier/identity.go"
|
||||
- "modules/pier/mcp_seam_test.go"
|
||||
- "modules/pier/prm.go"
|
||||
- "modules/pier/prm_scopes_test.go"
|
||||
- "modules/pier/session.go"
|
||||
- "modules/pier/stateless.go"
|
||||
- "modules/wristband/README.md"
|
||||
- "modules/wristband/authorize.go"
|
||||
- "modules/wristband/consent.go"
|
||||
- "modules/wristband/mcp_scopes_smoke_test.go"
|
||||
- "modules/wristband/server.go"
|
||||
covered_digest: "v2:sha256:647c82bb902ca0f7439d83e8c93465a4d4a63e02d02ec0ed4838ce50b4489262"
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
decision_coverage:
|
||||
honored: 18
|
||||
total: 18
|
||||
not_honored: []
|
||||
---
|
||||
|
||||
# Phase 32.1: MCP OAuth for plugin apps Verification Report
|
||||
|
||||
**Phase Goal:** As an application whose plugins declare their own member token scopes, I want an agent connected to `/mcp` over OAuth to get the same tools, attribution and revocation as one using a personal token, so that OAuth sign-in can be switched on in production. Three gaps ship as patch releases of the framework (0.5.x) and `sm-oauthserver-plugin`.
|
||||
**Verified:** 2026-10-28T15:54:38Z
|
||||
**Status:** passed
|
||||
**Re-verification:** No — initial verification
|
||||
|
||||
MVP mode does not apply. Phase 32.1 has no `Mode: mvp` in ROADMAP.md. The goal is user-story shaped but does not match the validator (`I want an agent`, not `I want to`).
|
||||
|
||||
Sibling implementations were read and tested in their own checkouts. They cannot be listed in `covered_files` because the fingerprint refuses paths outside this repository. The released commits are `summercms` `v0.5.2` (`9094bf9`, tag object `35c1f0f`), `sm-user-plugin` `v0.1.16` (`756797c`), `sm-oauthserver-plugin` `v0.1.4` (`a7743e7`), and local `vue-oauth-layer` `v0.1.0` (`7f805b3`). Framework `HEAD` (`bba6539`) is only the phase-05 planning commit; `v0.5.2..HEAD` does not change product code.
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
| --- | ------- | ---------- | ---------- |
|
||||
| 1 | Scopes a plugin declares through `pact.HasFrontendTokenScopes` can be requested, consented to and granted over MCP OAuth. Wristband metadata and the authorize allow-list, and pier protected-resource scopes, include them, so a tool behind `user.scope:<declared>` lists and runs for an OAuth agent granted that scope (including a stock scope the declaration `Implies`). | ✓ VERIFIED | `AdvertisedMCPScopes` appends declared catalog names after the five fixed names. `NewServers` assigns that one slice to `mcpOpts.ScopesSupported` and `NewMCPTokenVerifier`. Metadata and `allowedAuthorizeScopes` both call `resolvedScopes`. pier copies `MCPScopesSupported` when non-empty. `MCPAllows` / `MCPDispatch` share `mcpEffectiveScopes`, which keeps names already on the grant and, only when `ClientID` is set, appends catalog names whose `Implies` is held. `tools/list` reaches that check through `filter.go` `allows`. `user.scope` reads the dispatch credential via `HasScope`. `TestMCPAdvertisedScopes` (metadata equals the verifier, including `acme:read`) and `TestMCPImplies*` passed. |
|
||||
| 2 | A route run by an MCP tool call can read the MCP subject (guard, user, client ID, token id) from its request context. A personal-token call's credential keeps the token id. | ✓ VERIFIED | `dispatch.go` sets `WithMCPCall` with `id.subjectFor` immediately before `bouncer.WithDispatch`. OAuth identity copies `ClientID` and `TokenID` from the grant onto both frontend and backend subjects. `MCPCallFrom` on a bare context is false. `MCPDispatch` sets `credential.ID = subject.TokenID` only when `ClientID` is empty. `TestMCPCallFromBareContext`, `TestMCPCallRoundTrip`, `TestDispatchMCPCallBothGuards`, and `TestMCPImpliesPersonalUnchanged` passed. |
|
||||
| 3 | `sm-oauthserver-plugin` records a consent for MCP grants, so per-client revoke ends one agent's grant and its MCP sessions without touching the user's other agents. | ✓ VERIFIED | `MCPConsentDecide` calls `recordMCPConsent` after `IssueCode`. `replaceMCPConsent` is `ON CONFLICT` assignment, not `SaveConsent`. `revoke` takes `LockUserGrants` and deletes the consent, OIDC codes, MCP codes, access tokens and refresh tokens for that user and client, then fires `MCPSubjectRevoked` with `ClientID` and a zero `TokenID`. pier `revokedMatch` closes only that client's sessions when `TokenID` is 0. `TestMCPConsentReplace`, `TestMCPRevokeDeletesCode` (other client's token still verifies; event shape matches), and `TestMCPConsentBackfillIdempotent` passed. Rollback returns nil and does not delete. |
|
||||
| 4 | Module READMEs and `docs/` pages describe the three changes. The framework and `sm-oauthserver-plugin` have patch tags. | ✓ VERIFIED | pact, pier, wristband, user-plugin and oauthserver READMEs plus `docs/services/mcp.md`, `oauth-server.md` and `authentication.md` name `MCPCallFrom`, `MCPAdvertisedScopes`, `MemberTokenScopeCatalog`, `Implies`, `mcp_scopes`, consent replace and the per-client revoke. No consuming application is named. `go test ./cmd/summer -run TestDocsTree` passed. Remote tags: `v0.5.2`, `v0.1.16`, `v0.1.4`. Changelog `https://summercms.io/changelog/summercms-0-5-2` returned HTTP 200 with title `SummerCMS 0.5.2`. |
|
||||
| 5 | An empty MCP `Options.ScopesSupported` advertises and accepts `read`, `write`, `ai`, `offline_access` and `backend`. A non-empty list is used as given, so `DefaultOptions` still refuses `backend`. OIDC discovery stays `openid`, `profile`, `email`. `PendingRequestView.OfflineAccess` is the pending flag. `IssueCode`'s signature is unchanged. | ✓ VERIFIED | `resolvedScopes` in `modules/wristband/server.go`. `PendingRequest` copies `pending.OfflineAccess`. `IssueCode(ctx, requestID, userID, grantedScopes, collectionIDs)`. `TestMCPScopesSmoke`, `TestDiscoveryFields` and `TestMCPOfflineAccessUntick` passed. |
|
||||
| 6 | Implies expansion is OAuth-only. `write` does not satisfy `Implies` `read`. The compiled catalog is published as `pact.MemberTokenScopeCatalog`. Implied names are not written onto the subject, the token row, or a fingerprint. The demo app user submodule was not edited. | ✓ VERIFIED | `bootAPITokenScopes` publishes the concrete catalog and `pact.MemberTokenScopeCatalog`. `mcpEffectiveScopes` returns `subject.Scopes` unchanged when `ClientID` is empty and never writes the clone back. `TestMCPImpliesOAuth`, `TestMCPImpliesPersonalUnchanged`, `TestMCPImpliesWriteDoesNotGrantRead`, `TestMCPImpliesNotPersisted`, `TestMCPImpliesAlreadyPresent` and `TestMCPImpliesCompileRejects` passed. `sm-demo-app/plugins/golem15/user` is still tag `v0.1.13`. |
|
||||
| 7 | `golem15.oauthserver.mcp_scopes` unset advertises every declared member scope; a set list keeps only those names; an empty list is the five fixed names; an unknown name fails boot and names the key. Fixed names are always present. pier copies the verifier list or `PRMScopes` and does not read the member-token catalog. | ✓ VERIFIED | `classes/mcp_scopes.go` and `servers.go`. `mountPRM` type-asserts `MCPAdvertisedScopes` only. `TestMCPAdvertisedScopes`, `TestPRMUsesAdvertisedScopes` and `TestPRMFallsBackToPRMScopes` passed. |
|
||||
| 8 | Authorization entries carry `kind` `oidc` or `mcp`. An allow with no scopes field grants the whole request. `offline_access` stays a column flag. The `/connect` layer locks `read` in the UI, does not ask the server to force `read`, renders client and scope text rather than raw HTML, and refetches on `409 backend_link_changed`. | ✓ VERIFIED | `TestAuthorizationsKind` and `TestMCPOfflineAccessUntick` passed. `grantedScopes` returns the request when `submitted` is nil. `allowBody` keeps locked names and omits `scopes` when consent is already stored. `connect.vue` interpolates `{{ }}` and calls `shouldRefetch` then `load(true)`. `node --test tests/connect.test.ts` passed (8 tests), including the source scan that rejects `v-html`. |
|
||||
|
||||
**Score:** 8/8 truths verified (0 present, behavior-unverified)
|
||||
|
||||
### Decision Coverage
|
||||
|
||||
All trackable CONTEXT.md decisions are honored by shipped artifacts (18/18). Non-blocking.
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
| -------- | ----------- | ------ | ------- |
|
||||
| `modules/pact/mcp.go` | Catalog, advertised scopes, `MCPCall` / `WithMCPCall` / `MCPCallFrom` | ✓ VERIFIED | Interfaces and accessor are exported and used by pier and both plugins. On `v0.5.2`. Absent from `v0.5.0`. |
|
||||
| `modules/pact/capabilities.go` | `FrontendTokenScope.Implies` | ✓ VERIFIED | Field present. User plugin rejects any value other than empty, `read`, or `write`. |
|
||||
| `modules/pier/dispatch.go` | `MCPCall` beside `WithDispatch` | ✓ VERIFIED | Lines 215–216. Both guards' tool calls observed it. |
|
||||
| `modules/pier/prm.go` | PRM scopes from the verifier, else `PRMScopes` | ✓ VERIFIED | Does not look up `MemberTokenScopeCatalog`. |
|
||||
| `modules/wristband/server.go` | One `resolvedScopes` slice | ✓ VERIFIED | Metadata uses it. Authorize builds its allow-list from the same method. |
|
||||
| `modules/wristband/consent.go` | `PendingRequestView.OfflineAccess`; `IssueCode` signature unchanged | ✓ VERIFIED | Direct copy from the pending row. |
|
||||
| `sm-user-plugin/classes/mcp_guard.go` | OAuth-only Implies shared by `MCPAllows` and `MCPDispatch` | ✓ VERIFIED | On tag `v0.1.16`. |
|
||||
| `sm-user-plugin/plugin.go` | Publish `MemberTokenScopeCatalog` | ✓ VERIFIED | `bootAPITokenScopes`. |
|
||||
| `sm-oauthserver-plugin/classes/mcp_scopes.go` | `golem15.oauthserver.mcp_scopes` | ✓ VERIFIED | Key documented in `config/config.yaml` (commented default). |
|
||||
| `sm-oauthserver-plugin/classes/mcp_verifier.go` | `MCPScopesSupported` | ✓ VERIFIED | Same slice stored at `NewServers`. |
|
||||
| `sm-oauthserver-plugin/controllers/mcp_consent_api.go` | `scope_details` and consent replace | ✓ VERIFIED | `replaceMCPConsent` uses `ON CONFLICT`. |
|
||||
| `sm-oauthserver-plugin/controllers/authorizations.go` | Revoke under `lockUserGrants`, then the client-scoped event | ✓ VERIFIED | Deletes `MCPCode` with the other pair rows. |
|
||||
| `sm-oauthserver-plugin/updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go` | Idempotent backfill, no-delete rollback | ✓ VERIFIED | `WHERE NOT EXISTS`. `Rollback` returns nil. |
|
||||
| `vue-oauth-layer/app/pages/connect.vue` | `/connect` checkboxes | ✓ VERIFIED | Imports `allowBody`. Text interpolations only. Local tag `v0.1.0`. |
|
||||
| `vue-oauth-layer/app/utils/connectConsent.ts` | Show JSON to POST body | ✓ VERIFIED | Locked `read` stays. No server-side force-read field. |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
| ---- | --- | --- | ------ | ------- |
|
||||
| `wristband.Server.Metadata` | `allowedAuthorizeScopes` | `resolvedScopes` | ✓ WIRED | Both call the same method. A non-empty host list is cloned as given. |
|
||||
| `NewServers` | `MCPTokenVerifier.MCPScopesSupported` | the slice assigned to `mcpOpts.ScopesSupported` | ✓ WIRED | `TestMCPAdvertisedScopes` compared metadata JSON to the verifier. |
|
||||
| `pier.mountPRM` | `pact.MCPAdvertisedScopes` | lookup of `MCPTokenVerifier`, then type assert | ✓ WIRED | Empty or unimplemented falls back to `PRMScopes`. |
|
||||
| `pier.dispatch` | `pact.MCPCallFrom` | `WithMCPCall` immediately before `WithDispatch` | ✓ WIRED | Frontend and backend tool handlers observed the call. |
|
||||
| `MCPAllows` | `MCPDispatch` | `mcpEffectiveScopes` | ✓ WIRED | One helper. Personal tokens return the same slice. |
|
||||
| `plugin.bootAPITokenScopes` | `pact.MemberTokenScopeCatalog` | `app.Publish` | ✓ WIRED | `TestMCPImpliesOAuth` looks the interface up. |
|
||||
| `MCPConsentDecide` | `golem15_oauthserver_consents` | replace upsert after `IssueCode`, not `SaveConsent` | ✓ WIRED | Second allow of a smaller set stored only `read`. |
|
||||
| `authorizations.revoke` | MCP sessions | `LockUserGrants`, delete `MCPCode`, `MCPSubjectRevoked` with `ClientID` | ✓ WIRED | pier `revokedMatch` selects that client when `TokenID` is 0. |
|
||||
| `connect.vue` | `POST /oauth/mcp/requests/{request}` | `allowBody` (`decision`, `scopes`, `backend_admin`) | ✓ WIRED | Page and tests import the same functions. |
|
||||
|
||||
### Data-Flow Trace (Level 4)
|
||||
|
||||
| Artifact | Data Variable | Source | Produces Real Data | Status |
|
||||
| -------- | ------------- | ------ | ------------------ | ------ |
|
||||
| Wristband MCP metadata `scopes_supported` | `resolvedScopes()` | `Options.ScopesSupported` set from `AdvertisedMCPScopes` (catalog plus fixed names) | Yes | ✓ FLOWING |
|
||||
| pier protected-resource `scopes_supported` | `advertised.MCPScopesSupported()` | the verifier slice, else `PRMScopes` | Yes | ✓ FLOWING |
|
||||
| MCP consent row `scopes` | `stored` in `MCPConsentDecide` | granted intersection of the pending request | Yes | ✓ FLOWING |
|
||||
| Route `pact.MCPCall.Subject` | `id.subjectFor` | OAuth grant or personal-token resolver (`ClientID`, `TokenID`, scopes) | Yes | ✓ FLOWING |
|
||||
| `user.scope` check during a tool call | dispatch `*models.APIToken.Scopes` | `mcpEffectiveScopes` (grant, plus OAuth implied names) | Yes | ✓ FLOWING |
|
||||
| `/connect` checkboxes | `scope_details` from consent show | catalog labels, not a hardcoded tool list | Yes | ✓ FLOWING |
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
| -------- | ------- | ------ | ------ |
|
||||
| Bare context has no MCP call; round trip keeps the subject | `go test -short -count=1 ./modules/pact -run 'TestMCPCallFromBareContext\|TestMCPCallRoundTrip\|TestFrontendTokenScopeLiteralWithoutImplies'` | ok | ✓ PASS |
|
||||
| Both guards record `MCPCall`; PRM uses the advertised list or falls back | `go test -short -count=1 ./modules/pier -run 'TestDispatchMCPCallBothGuards\|TestPRMUsesAdvertisedScopes\|TestPRMFallsBackToPRMScopes\|TestMCPSeam'` | ok | ✓ PASS |
|
||||
| Empty MCP scope list includes `backend`; OIDC discovery stays the three names | `go test -short -count=1 ./modules/wristband -run 'TestMCPScopesSmoke\|TestDiscoveryFields'` | ok | ✓ PASS |
|
||||
| OAuth Implies, personal token unchanged, write does not grant read | `go test -short -count=1 . -run TestMCPImplies` in `sm-user-plugin` | ok | ✓ PASS |
|
||||
| Declared name `acme:read` is on MCP metadata and the verifier; unknown `mcp_scopes` fails boot | `GOWORK=off` not required; `go test -short -count=1 . -run TestMCPAdvertisedScopes` in `sm-oauthserver-plugin` | ok | ✓ PASS |
|
||||
| Consent replace, offline flag, revoke leaves the other client, kind, idempotent backfill | `GOWORK=off go test -count=1 -timeout 180s -run 'TestMCPConsentReplace\|TestMCPRevokeDeletesCode\|TestMCPConsentBackfillIdempotent\|TestAuthorizationsKind\|TestMCPOfflineAccessUntick'` | ok (6.951s) | ✓ PASS |
|
||||
| `/connect` mapping, locked read, no `v-html` | `node --test tests/connect.test.ts` | 8 pass | ✓ PASS |
|
||||
| Docs identifiers | `go test ./cmd/summer -count=1 -run TestDocsTree` | ok | ✓ PASS |
|
||||
| Patch tags on the remotes | `git ls-remote --tags` for `v0.5.2`, `v0.1.16`, `v0.1.4` | each tag object matches the local tag | ✓ PASS |
|
||||
| Changelog post | `curl -fsS https://summercms.io/changelog/summercms-0-5-2` | HTTP 200, title `SummerCMS 0.5.2` | ✓ PASS |
|
||||
|
||||
`TestMCPAdvertisedScopes` was run in the oauth plugin directory, whose uncommitted `go.work` points `sm-user-plugin` at `sm-demo-app` `v0.1.13`. That test publishes its own catalog and does not import the phase user plugin. The consent tests were run with `GOWORK=off`, so they used `go.mod`'s `sm-user-plugin v0.1.16`.
|
||||
|
||||
### Probe Execution
|
||||
|
||||
No phase plan or success criterion declares a `scripts/*/tests/probe-*.sh` probe. None were required.
|
||||
|
||||
### Test Quality Audit
|
||||
|
||||
| Test File | Linked Req | Active | Skipped | Circular | Assertion Level | Verdict |
|
||||
|-----------|-----------|--------|---------|----------|-----------------|---------|
|
||||
| `modules/pact/mcp_call_negative_test.go` | SC-2 | 2 | 0 | no | value (`ok` false; empty `Implies`) | sufficient |
|
||||
| `modules/pier/dispatch_mcpcall_test.go` | SC-2 | 1 | 0 | no | behavioral (real `tools/call` on both guards) | sufficient |
|
||||
| `modules/pier/prm_scopes_test.go` | SC-1 | 2 | 0 | no | value (scope list includes `acme:read`, fallback includes `backend`) | sufficient |
|
||||
| `sm-user-plugin/mcp_implies_test.go` | SC-1, SC-2 | 6 matched by `TestMCPImplies` | 0 | no | value (credential id, scopes, slice identity) | sufficient |
|
||||
| `sm-oauthserver-plugin/mcp_consent_test.go` | SC-3 | replace, offline, revoke | 0 in the run | no | behavioral (stored row, other token still verifies, event equality) | sufficient |
|
||||
| `sm-oauthserver-plugin/mcp_backfill_test.go` | SC-3 | idempotent backfill | 0 in the run | no | behavioral | sufficient |
|
||||
| `vue-oauth-layer/tests/connect.test.ts` | layer must-haves | 8 | 0 | no | value, plus a source scan for `v-html` | sufficient |
|
||||
|
||||
**Disabled tests on requirements:** 0
|
||||
**Circular patterns detected:** 0
|
||||
**Insufficient assertions:** 0
|
||||
|
||||
`TestDispatchMCPCallBothGuards` checks guard, tool and session id. It does not echo `ClientID` or `TokenID`. Those fields are copied with the whole `MCPSubject` in `identity.go` (`ClientID: grant.ClientID`, `TokenID: grant.TokenID`) and stored by `WithMCPCall`. The personal-token id rule is asserted directly in `TestMCPImpliesPersonalUnchanged`.
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Description | Status | Evidence |
|
||||
| ----------- | ---------- | ----------- | ------ | -------- |
|
||||
| none | all five plans `requirements: []` | ROADMAP says Requirements: TBD. `REQUIREMENTS.md` has no Phase 32.1 IDs. | ✓ SATISFIED | No ID to satisfy and none orphaned. |
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
| ---- | ---- | ------- | -------- | ------ |
|
||||
| `sm-user-plugin/go.mod` | require | `summercms v0.5.0` while the tagged source calls `pact.MemberTokenScopeCatalog`, which does not exist on `v0.5.0` | ⚠️ Warning | A module graph that selects only `v0.5.0` does not compile `v0.1.16`. `sm-oauthserver-plugin` `v0.1.4` requires `summercms v0.5.2` and `sm-user-plugin v0.1.16`, so the graph that turns MCP OAuth on selects `v0.5.2`. The local `replace` is ignored by dependents. Not a failed success criterion. |
|
||||
| `vue-oauth-layer` | tag `v0.1.0` | No `origin`. README inside the tag still says the tag does not exist. | ⚠️ Warning | SC-4 does not require this tag. Apps cannot pin the layer until a remote exists. The local tag was not moved. |
|
||||
| `sm-oauthserver-plugin/go.work` | uncommitted | `replace` of `sm-user-plugin` points at `sm-demo-app/plugins/golem15/user` (`v0.1.13`), not the phase branch | ℹ️ Info | Not on tag `v0.1.4`. Dependents do not use `go.work`. A local `go test` without `GOWORK=off` would compile the wrong user plugin. |
|
||||
| `sm-user-plugin/classes/device_auth.go` | comments | The letters `XXXX` match a `XXX` debt scan | ℹ️ Info | Short-code format, not a `XXX` marker. No `TBD` / `FIXME` / `XXX` debt markers in the phase files. |
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
None. The state transitions (Implies expansion, consent replace, revoke isolation, backfill idempotence, both guards' `MCPCall`) were exercised by the named tests above. The `/connect` contract is covered by `node --test`, including the no-raw-HTML source check. No live browser OAuth session was required: the phase ships the framework and plugins, and it does not turn `mcp.enabled` on in an application.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No gap blocks the phase goal. Declared member scopes are on the MCP authorization server and the protected-resource metadata. An OAuth subject can list and run a `user.scope:<declared>` tool either because the grant holds that name or because the catalog marks it implied by `read` or `write`. The route can read the caller through `pact.MCPCallFrom`, and a personal token's credential id is still the token id. An MCP allow writes a consent row that a later smaller allow replaces, and deleting that authorization revokes only that client. Docs describe the three changes. `v0.5.2` and `sm-oauthserver-plugin` `v0.1.4` are on their remotes, and the framework changelog post is live.
|
||||
|
||||
The two warnings (user-plugin minimum version, unpublished vue layer) do not stop an application that takes `sm-oauthserver-plugin` `v0.1.4` from compiling the OAuth path. They should be fixed in a later patch if the user plugin is built on its own, or when `vue-oauth-layer` gets a remote.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-10-28T15:54:38Z_
|
||||
_Verifier: Claude (msd-verifier)_
|
||||
212
.planning/quick-batches/261028-rqa/BATCH.json
Normal file
212
.planning/quick-batches/261028-rqa/BATCH.json
Normal file
@@ -0,0 +1,212 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"batch_id": "261028-rqa",
|
||||
"created_at": "2026-10-28T18:57:57.955Z",
|
||||
"options": {},
|
||||
"base_revision": "825a3fd2daa55f7906920bad60d42dd338874bae",
|
||||
"items": [
|
||||
{
|
||||
"quick_id": "261028-rqb",
|
||||
"client_id": null,
|
||||
"description": "Admin SPA reload on deploy (todo .planning/todos/pending/2026-10-11-reload-open-apps-when-a-new-version-is-deployed.md, admin half only; site half already shipped in vue-demo-app): boardwalk exposes a build version derived from the embedded dist (header like X-Summer-Build on admin API responses and/or a version endpoint), the admin SPA records the version it booted with and shows a non-blocking reload banner when a newer build is seen (focus/visibility/interval check), plus a reload-once guard on Vite chunk-load errors (vite:preloadError + sessionStorage flag); unit tests; boardwalk README; rebuild admin and commit modules/boardwalk/dist (scripts/check-admin-dist.sh must pass)",
|
||||
"status": "complete",
|
||||
"depends_on": [],
|
||||
"planned_files": [
|
||||
"modules/boardwalk/boardwalk.go",
|
||||
"modules/boardwalk/boardwalk_test.go",
|
||||
"modules/boardwalk/README.md",
|
||||
"modules/boardwalk/dist/index.html",
|
||||
"modules/boardwalk/dist/assets/",
|
||||
"docs/backend/admin-spa.md",
|
||||
"modules/phrasebook/backend/lang/en/lang.yaml",
|
||||
"modules/phrasebook/backend/lang/pl/lang.yaml",
|
||||
"admin/index.html",
|
||||
"admin/src/app/runtime.ts",
|
||||
"admin/src/app/buildUpdate.ts",
|
||||
"admin/src/app/chunkReload.ts",
|
||||
"admin/src/components/ui/UpdateBanner.vue",
|
||||
"admin/src/App.vue",
|
||||
"admin/src/main.ts",
|
||||
"admin/tests/app/runtime.test.ts",
|
||||
"admin/tests/app/buildUpdate.test.ts",
|
||||
"admin/tests/app/chunkReload.test.ts",
|
||||
"admin/tests/ui/UpdateBanner.test.ts",
|
||||
"admin/tests/fixtures/lang.json"
|
||||
],
|
||||
"directory": null,
|
||||
"worktree": null,
|
||||
"dispatched_worktree": null,
|
||||
"dispatched_branch": null,
|
||||
"dispatched_base": null,
|
||||
"wave": 0,
|
||||
"commit": "c8a22e3",
|
||||
"failure_reason": null
|
||||
},
|
||||
{
|
||||
"quick_id": "261028-rqc",
|
||||
"client_id": null,
|
||||
"description": "Quiet plugin boot logs on console commands like --help, framework side only (todo .planning/todos/pending/2026-10-21-quiet-plugin-boot-logs-on-console-commands.md): help/list/completion style commands must not print plugin Register/Boot Info logs (e.g. raise the default console log level for non-serve introspection commands or defer Boot for them), without hiding logs for serve/worker/migrate; no sm-* plugin edits (plugin-side Info->Debug changes are a separate follow-up); docs for the behaviour",
|
||||
"status": "complete",
|
||||
"depends_on": [
|
||||
"261028-rqd"
|
||||
],
|
||||
"planned_files": [
|
||||
"modules/diary/helponly.go",
|
||||
"modules/diary/diary.go",
|
||||
"modules/diary/export_test.go",
|
||||
"modules/diary/helponly_test.go",
|
||||
"modules/diary/bootquiet_smoke_test.go",
|
||||
"modules/diary/README.md",
|
||||
"docs/services/logging.md",
|
||||
"docs/console/introduction.md"
|
||||
],
|
||||
"directory": null,
|
||||
"worktree": null,
|
||||
"dispatched_worktree": null,
|
||||
"dispatched_branch": null,
|
||||
"dispatched_base": null,
|
||||
"wave": 2,
|
||||
"commit": "6c606c6",
|
||||
"failure_reason": null
|
||||
},
|
||||
{
|
||||
"quick_id": "261028-rqd",
|
||||
"client_id": null,
|
||||
"description": "Console review info findings IN-01..IN-13 and the delegate signal exit code 128+n (todo .planning/todos/pending/2026-10-22-console-review-info-findings.md, review .planning/phases/19.8-console-commands-overhaul/19.8-REVIEW.md); all 14 verified still open on 2026-10-28 in modules/bonfire, modules/diary, cmd/summer, scripts/check-phase19.8.sh; fix each, keep behaviour backward compatible, update docs/console pages and TestDelegateExitCodePassthrough for 128+n",
|
||||
"status": "complete",
|
||||
"depends_on": [],
|
||||
"planned_files": [
|
||||
"modules/bonfire/errors.go",
|
||||
"modules/bonfire/errors_test.go",
|
||||
"modules/bonfire/execute.go",
|
||||
"modules/bonfire/root.go",
|
||||
"modules/bonfire/root_test.go",
|
||||
"modules/bonfire/globals.go",
|
||||
"modules/bonfire/globals_test.go",
|
||||
"modules/bonfire/command.go",
|
||||
"modules/bonfire/command_test.go",
|
||||
"modules/bonfire/list.go",
|
||||
"modules/bonfire/README.md",
|
||||
"go.mod",
|
||||
"go.sum",
|
||||
"modules/cabana/token_commands.go",
|
||||
"modules/cabana/commands.go",
|
||||
"modules/surf/routelist_command.go",
|
||||
"modules/surf/summary_test.go",
|
||||
"modules/lighthouse/centrifugo/commands.go",
|
||||
"modules/lagoon/commands.go",
|
||||
"modules/lagoon/keygen.go",
|
||||
"modules/conga/commands.go",
|
||||
"docs/console/writing-commands.md",
|
||||
"docs/console/introduction.md",
|
||||
"scripts/check-phase19.8.sh",
|
||||
".planning/research/STACK.md",
|
||||
".planning/notes/2026-10-21-console-charm-stack.md",
|
||||
"modules/bonfire/live.go",
|
||||
"modules/bonfire/live_test.go",
|
||||
"modules/bonfire/output.go",
|
||||
"modules/bonfire/output_test.go",
|
||||
"modules/bonfire/styles.go",
|
||||
"modules/bonfire/helpers.go",
|
||||
"internal/consolekit/consolekit.go",
|
||||
"internal/consolekit/consolekit_test.go",
|
||||
"modules/diary/console.go",
|
||||
"modules/diary/diary.go",
|
||||
"modules/diary/channels.go",
|
||||
"modules/diary/diary_test.go",
|
||||
"modules/diary/console_internal_test.go",
|
||||
"modules/diary/deps_test.go",
|
||||
"modules/diary/README.md",
|
||||
"cmd/summer/runtime.go",
|
||||
"cmd/summer/runtime_test.go",
|
||||
"docs/console/scaffolding.md",
|
||||
"docs/setup/upgrading.md",
|
||||
".planning/phases/19.8-console-commands-overhaul/19.8-REVIEW-DISPOSITION.md",
|
||||
".planning/todos/completed/2026-10-22-console-review-info-findings.md"
|
||||
],
|
||||
"directory": null,
|
||||
"worktree": null,
|
||||
"dispatched_worktree": null,
|
||||
"dispatched_branch": null,
|
||||
"dispatched_base": null,
|
||||
"wave": 0,
|
||||
"commit": "202d38b",
|
||||
"failure_reason": null
|
||||
},
|
||||
{
|
||||
"quick_id": "261028-rqe",
|
||||
"client_id": null,
|
||||
"description": "attach.Store writes non-inline types with a non-rendering disposition (todo .planning/todos/pending/2026-10-28-attach-store-public-content-type.md): in modules/lagoon/attach/store.go pass Content-Disposition attachment (blob.WriterOptions) for anything not in an inline-safe raster allow-list (jpeg, png, gif, webp, avif), keep the DB ContentType unchanged, additive and non-breaking; tests; lagoon README and docs/services/storage.md deploy note updated",
|
||||
"status": "complete",
|
||||
"depends_on": [],
|
||||
"planned_files": [
|
||||
"modules/lagoon/attach/inline.go",
|
||||
"modules/lagoon/attach/inline_test.go",
|
||||
"modules/lagoon/attach/store.go",
|
||||
"modules/lagoon/attach/store_test.go",
|
||||
"modules/lagoon/attach/thumb_test.go",
|
||||
"modules/lagoon/README.md",
|
||||
"docs/services/storage.md",
|
||||
"docs/database/attachments.md",
|
||||
"modules/surf/uploads.go",
|
||||
"modules/surf/README.md"
|
||||
],
|
||||
"directory": null,
|
||||
"worktree": null,
|
||||
"dispatched_worktree": null,
|
||||
"dispatched_branch": null,
|
||||
"dispatched_base": null,
|
||||
"wave": 0,
|
||||
"commit": "4957e5c",
|
||||
"failure_reason": null
|
||||
},
|
||||
{
|
||||
"quick_id": "261028-rqf",
|
||||
"client_id": null,
|
||||
"description": "Audit-log successful admin action runs with the admin id (todo .planning/todos/pending/admin-action-audit-log.md): in modules/cabana/actions.go runAction log success with admin_id (bouncer.User(ctx).ID), controller, action and kind (toolbar/field), matching the existing bulk/record action log fields; tests",
|
||||
"status": "complete",
|
||||
"depends_on": [],
|
||||
"planned_files": [
|
||||
"modules/cabana/actions.go",
|
||||
"modules/cabana/action_run_log_test.go",
|
||||
"modules/cabana/README.md",
|
||||
"docs/backend/users-and-permissions.md"
|
||||
],
|
||||
"directory": null,
|
||||
"worktree": null,
|
||||
"dispatched_worktree": null,
|
||||
"dispatched_branch": null,
|
||||
"dispatched_base": null,
|
||||
"wave": 0,
|
||||
"commit": "61d1043",
|
||||
"failure_reason": null
|
||||
},
|
||||
{
|
||||
"quick_id": "261028-rqg",
|
||||
"client_id": null,
|
||||
"description": "Show the administrators holding a role on the Roles screen (todo .planning/todos/pending/roles-screen-holders-list.md, origin .planning/phases/19.6.1-backend-administrators-screen/19.6.1-UAT.md:44): read-only list of holders on the Roles form/preview in modules/cabana backend_roles_admin.go (and admin SPA if needed), superusers hidden from non-superuser editors (D-03), assignment stays on the Administrators form; tests; if admin/ changes, rebuild and commit modules/boardwalk/dist",
|
||||
"status": "complete",
|
||||
"depends_on": [],
|
||||
"planned_files": [
|
||||
"modules/cabana/backend_roles_admin.go",
|
||||
"modules/cabana/backendscreens/models/userrole/fields.yaml",
|
||||
"modules/cabana/backendscreens/controllers/roles/_users.htm",
|
||||
"modules/phrasebook/backend/lang/en/lang.yaml",
|
||||
"modules/phrasebook/backend/lang/pl/lang.yaml",
|
||||
"modules/cabana/README.md",
|
||||
"docs/backend/users-and-permissions.md",
|
||||
"modules/cabana/backend_screens_smoke_test.go",
|
||||
"modules/cabana/backend_screens_test.go",
|
||||
"modules/cabana/roles_postgres_test.go"
|
||||
],
|
||||
"directory": null,
|
||||
"worktree": null,
|
||||
"dispatched_worktree": null,
|
||||
"dispatched_branch": null,
|
||||
"dispatched_base": null,
|
||||
"wave": 1,
|
||||
"commit": "653d95a",
|
||||
"failure_reason": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
---
|
||||
phase: quick-261028-pq8
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- internal/build/artifact.go
|
||||
- internal/build/scaffold.go
|
||||
- internal/build/leaf.go
|
||||
- internal/build/registry.go
|
||||
- internal/build/new.go
|
||||
- internal/build/stubs/artifacts.tmpl
|
||||
- internal/build/stubs/plugin.tmpl
|
||||
- internal/build/stubs/registry.tmpl
|
||||
- internal/build/stubs/new.tmpl
|
||||
- internal/build/build_test.go
|
||||
- internal/build/new_test.go
|
||||
- internal/build/new_golden_test.go
|
||||
- internal/build/testdata/new/*.golden
|
||||
- internal/build/testdata/new-no-plugin/*.golden
|
||||
- cmd/summer/main.go
|
||||
- cmd/summer/main_test.go
|
||||
- docs/console/scaffolding.md
|
||||
- docs/console/writing-commands.md
|
||||
- docs/setup/porting-a-plugin.md
|
||||
- docs/architecture/go-modules-and-workspaces.md
|
||||
- docs/plugins/registration.md
|
||||
- docs/plugins/testing.md
|
||||
- docs/backend/admin-controllers.md
|
||||
- docs/database/migrations.md
|
||||
- modules/cabana/README.md
|
||||
- docs/examples/blog/scaffold_layout_test.go
|
||||
- docs/examples/blog/plugin.go
|
||||
- docs/examples/blog/registry.gen.go
|
||||
- docs/examples/blog/controllers/posts.go
|
||||
- docs/examples/blog/controllers/posts/config_form.yaml
|
||||
- docs/examples/blog/controllers/posts/config_list.yaml
|
||||
- docs/examples/blog/models/post/fields.yaml
|
||||
- docs/examples/blog/models/post/columns.yaml
|
||||
- docs/examples/blog/models/post.go
|
||||
- docs/examples/blog/console/publish.go
|
||||
- docs/examples/blog/updates/20260101000000_create_acme_blog_posts.go
|
||||
- docs/examples/blog/updates/20260101000100_add_published_at.go
|
||||
autonomous: true
|
||||
requirements:
|
||||
- todo:2026-10-28-make-plugin-ignores-vendor-namespace
|
||||
- todo:scaffold-same-second-migration-order
|
||||
- todo:scaffold-admin-controller-incomplete
|
||||
- todo:scaffold-generated-header-and-command-deps
|
||||
|
||||
estimate:
|
||||
tokens: 150000
|
||||
raw_tokens: 150000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "summer make:plugin pixelpixel.servers creates plugins/pixelpixel/servers with module <app>/plugins/pixelpixel/servers, and acme.blog and pixelpixel.blog can both be scaffolded in one application"
|
||||
- "make:model/migration/command/job/admin-controller find a plugin through summer.yaml, then plugins/<vendor>/<name>, then the legacy plugins/<name>; an existing app with plugins/<name> keeps working unchanged (no breaking change)"
|
||||
- "summer new puts its starter plugin golem15.<name> at plugins/golem15/<name> and the generated app still builds"
|
||||
- "A new migration always gets a timestamp later than every migration already in updates/, so make:model then make:migration in the same second list CreateX before AddY in registry.gen.go"
|
||||
- "Files the developer edits (model, migration, command, job, admin controller) start with //summer:make <kind>; registry.gen.go keeps its Code generated header; files with either the new directive or the old header are listed in registry.gen.go"
|
||||
- "make:command writes func <Name>Command(app *backpack.App) bonfire.Command, the registry lists it and passes the app; a scaffolded plugin keeps the app from Boot and returns generatedCommands(p.app); an older plugin.go that calls generatedCommands() still compiles"
|
||||
- "make:admin-controller acme.blog Posts writes ModelName/modelClass Post and YAML under models/post/, returns &models.Post{} from NewRecord when that model exists, offers create and delete with showCheckboxes: true, and --model overrides the singular"
|
||||
- "A scaffolded plugin implements pact.AdminAssets through generatedAdminFS(), an empty tree until the first controller's YAML exists and the embedded YAML afterwards"
|
||||
- "Docs and CLI help describe the new behavior, include an optional-move upgrading note, and summer docs:build --check stays green"
|
||||
artifacts:
|
||||
- path: internal/build/artifact.go
|
||||
provides: "nextMigrationFile ordering, plugin lookup with vendor + legacy fallback, MakeAdminController model argument, singularize"
|
||||
- path: internal/build/registry.go
|
||||
provides: "scaffold-marker detection (new directive + legacy header), app-taking command scan, admin YAML embed patterns"
|
||||
- path: internal/build/stubs/registry.tmpl
|
||||
provides: "generatedCommands(app ...*backpack.App) and generatedAdminFS()"
|
||||
- path: internal/build/stubs/plugin.tmpl
|
||||
provides: "Plugin keeps app from Boot, Commands passes it, AdminFS wired"
|
||||
- path: internal/build/stubs/artifacts.tmpl
|
||||
provides: "//summer:make <kind> markers, app-taking command stub, model-named admin controller and YAML"
|
||||
- path: docs/console/scaffolding.md
|
||||
provides: "Updated scaffolder reference and upgrading note"
|
||||
key_links:
|
||||
- from: internal/build/stubs/plugin.tmpl
|
||||
to: internal/build/stubs/registry.tmpl
|
||||
via: "plugin.go calls generatedCommands(p.app) and generatedAdminFS(), which registry.gen.go defines"
|
||||
pattern: "generatedCommands\\(p\\.app\\)|generatedAdminFS\\(\\)"
|
||||
- from: docs/examples/blog/scaffold_layout_test.go
|
||||
to: internal/build
|
||||
via: "TestScaffoldLayout runs the make commands and compares the file set with docs/examples/blog"
|
||||
pattern: "plugins\", \"acme\", \"blog\""
|
||||
- from: internal/build/artifact.go
|
||||
to: internal/build/leaf.go
|
||||
via: "one shared default-plugin-dir helper used by locatePluginDir and checkModelsLeaf"
|
||||
pattern: "plugins/<vendor>/<name>"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Fix the four pending `internal/build` scaffolder todos:
|
||||
1. `make:plugin` drops the vendor.
|
||||
2. Migrations created in the same second run out of order.
|
||||
3. Every editable file carries a header saying it is generated and must not be edited, and `make:command` has no way to reach the application.
|
||||
4. `make:admin-controller` output does not boot as a working screen.
|
||||
|
||||
Purpose: `summer make:*` should produce plugins that work without the hand fixes `docs/setup/porting-a-plugin.md` now lists under "What the scaffolder leaves to you". Applications that already use `plugins/<name>`, the old header or zero-argument `generatedCommands()` must keep working, because the core plugins run in many apps.
|
||||
|
||||
Output: three tasks and four code+docs commits (Task 1 makes two commits because the project's CLAUDE.md asks for one logical change per commit). The four todo files are moved by the orchestrator, not by this plan.
|
||||
|
||||
Shape: tracer-first does not apply here. These are three independent fixes on a scaffolder path that already works. Each task is proven end to end by the scaffold tests, which run the make commands in a copy of examples/hello and then build the generated app.
|
||||
|
||||
Orchestrator defaults (fixed, do not re-ask):
|
||||
- OD-1: the starter plugin of `summer new` moves to `plugins/golem15/<name>`.
|
||||
- OD-2: the registry scanner keeps recognizing the old header.
|
||||
- OD-3: the docs get an upgrading note saying that moving `plugins/<name>` is optional.
|
||||
- OD-4: docs and help text change in the same commit as the code they describe, and `summer docs:build --check` stays green.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@./CLAUDE.md
|
||||
@.planning/STATE.md
|
||||
@.planning/todos/pending/2026-10-28-make-plugin-ignores-vendor-namespace.md
|
||||
@.planning/todos/pending/scaffold-same-second-migration-order.md
|
||||
@.planning/todos/pending/scaffold-admin-controller-incomplete.md
|
||||
@.planning/todos/pending/scaffold-generated-header-and-command-deps.md
|
||||
@internal/build/scaffold.go
|
||||
@internal/build/artifact.go
|
||||
@internal/build/registry.go
|
||||
@internal/build/leaf.go
|
||||
@internal/build/stubs/artifacts.tmpl
|
||||
@internal/build/stubs/plugin.tmpl
|
||||
@internal/build/stubs/registry.tmpl
|
||||
|
||||
Facts read at planning time. Use these, not the line numbers in the todos:
|
||||
- The admin-controller stub ALREADY implements `NewRecord` (returns nil with a TODO) and `RequiredPermissions` with `<id>.access_<snake>` (commit 20a79c5). What is left from that todo: the model name, the YAML directory, a real `NewRecord` when the model exists, `AdminFS` and list `delete`.
|
||||
- `party.Activate` runs Register then Boot, and only after that does the generated `main` call each plugin's `Commands()` (internal/build/build.go). So `Commands()` can use an app saved in Boot.
|
||||
- `docs/examples/blog` is part of the root module. Its `TestScaffoldLayout` runs MakePlugin, MakeModel, MakeMigration, MakeAdminController("Posts") and MakeCommand on acme.blog in a copy of examples/hello, then compares the FILE SET (not the contents) with docs/examples/blog. Every go fence in the porting page must be a `src=` copy of docs/examples/blog (cmd/summer/phase11_1_acceptance_test.go). After editing the example's source, run `go run ./cmd/summer docs:sync`.
|
||||
- examples/hello keeps the old layout (`plugins/base`, `plugins/greeter`, `plugins/optional`). It is the fixture `copyHelloApp` copies, so it covers the legacy-path fallback. Do not move it.
|
||||
- Existing users of the old header and of zero-argument `generatedCommands()` outside this repo include sm-demo-app/plugins/golem15/quizzes and many shared plugins. sm-faq-plugin declares its own `adminFS` var. New registry identifiers must not collide with these, so use `generatedAdminTree` and `generatedAdminFS`; grep found no existing uses of either name.
|
||||
- cabana rules: `modelClass` must equal `ModelName()`. The `delete` toolbar button requires `showCheckboxes: true`. A plugin that has admin controllers but no non-nil `AdminFS()` stops start-up. A zero `embed.FS` is a valid, empty, non-nil fs.FS.
|
||||
- Golden files: `go test ./internal/build -run 'TestNewGolden' -update` rewrites them; `goldenName` maps "/" to "__".
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Migration order, then vendor-namespaced plugin directories (two commits)</name>
|
||||
<files>internal/build/artifact.go, internal/build/scaffold.go, internal/build/leaf.go, internal/build/new.go, internal/build/stubs/new.tmpl, internal/build/build_test.go, internal/build/new_test.go, internal/build/new_golden_test.go, internal/build/testdata/new/*.golden, internal/build/testdata/new-no-plugin/*.golden, cmd/summer/main.go, cmd/summer/main_test.go, docs/examples/blog/scaffold_layout_test.go, docs/console/scaffolding.md, docs/setup/porting-a-plugin.md, docs/architecture/go-modules-and-workspaces.md, docs/plugins/registration.md, docs/plugins/testing.md, docs/database/migrations.md</files>
|
||||
<behavior>
|
||||
- nextMigrationFile with a frozen migrationNow: two different slugs created in the same second get strictly increasing timestamps, so the second file name sorts after the first.
|
||||
- If updates/ already holds a migration whose timestamp is later than now (for example 20990101000000_x.go), the new one gets that timestamp plus one second. Files that do not start with 14 digits and an underscore (doc.go, *_test.go) are ignored.
|
||||
- TestScaffoldCoreArtifacts with a frozen clock: MakeModel Album, then MakeMigration AddWidgetIndex. registry.gen.go lists updates.CreateAlbums() before updates.AddWidgetIndex().
|
||||
- MakePlugin "golem15.demo" returns <app>/plugins/golem15/demo with module <app module>/plugins/golem15/demo. MakePlugin acme.blog and pixelpixel.blog in one hello copy give two directories, each of which builds.
|
||||
- The existing-directory error hint names plugin:add plugins/<vendor>/<name>.
|
||||
- A plugin moved to the legacy plugins/<name> and not listed in summer.yaml is still found by MakeModel (the fallback). Lookup tries plugins/<vendor>/<name> first.
|
||||
- summer new: the starter plugin is at plugins/golem15/demo; go.work, go.mod and summer.yaml point there; the app still builds (TestNewAppSmoke and the goldens).
|
||||
</behavior>
|
||||
<action>
|
||||
COMMIT 1a: migration order.
|
||||
- In internal/build/artifact.go nextMigrationFile, before choosing a timestamp, read the updates/ directory once.
|
||||
- Collect the 14-digit timestamp prefix of every .go file whose name matches 14 digits followed by an underscore.
|
||||
- Start the candidate at migrationNow(). If the candidate is not later than the newest existing timestamp, use the newest plus one second.
|
||||
- Keep the underRoot check, the exact-name existence guard and the MkdirAll.
|
||||
- Add the unit test TestNextMigrationFileSortsAfterExisting with the behavior cases. Freeze migrationNow and restore it in t.Cleanup; do not use t.Parallel.
|
||||
- In TestScaffoldCoreArtifacts, freeze the clock around the MakeModel/MakeMigration pair and assert the order of the registry lines (use the index of each string).
|
||||
- Docs in the same commit:
|
||||
- docs/console/scaffolding.md: replace the paragraph saying that migrations with different names in the same second share a timestamp. New text: each new migration gets a timestamp after every file already in updates/, so registry order is creation order.
|
||||
- docs/setup/porting-a-plugin.md: remove the "Check the migration order" bullet. If the fixed example timestamps 20260101000000/20260101000100 are explained elsewhere on the page, keep that explanation.
|
||||
- grep docs/ and cmd/summer/main.go for "same second" and fix any other mention, including docs/database/migrations.md if it has one.
|
||||
- Commit as fix(build): order scaffolded migrations after every existing one. Stage by explicit path.
|
||||
|
||||
COMMIT 1b: vendor path. Per OD-1 and the todo; nothing breaks for existing apps.
|
||||
- In internal/build/scaffold.go:
|
||||
- Add a helper that returns the vendor (the part before the dot) next to pluginName.
|
||||
- Add one helper that returns the default plugin directory candidates for an ID, in this order: plugins/<vendor>/<name>, then plugins/<name>.
|
||||
- makePlugin creates plugins/<vendor>/<name>. The module path is m.Module + "/plugins/" + vendor + "/" + name. The Go package name stays <name>.
|
||||
- The already-exists hint becomes plugin:add plugins/<vendor>/<name>, slash-separated.
|
||||
- Update the MakePlugin doc comment.
|
||||
- frameworkReplaceFor already computes the replace relative to pluginDir, so it needs no change. Check that the plugin go.mod of the generated starter has one more ../ in its relative replace.
|
||||
- In artifact.go locatePluginDir: keep the summer.yaml/replace lookup first, then try the candidates in order, applying underRoot to each. The first existing directory wins. The not-found error and hint stay as they are.
|
||||
- In leaf.go checkModelsLeaf: when localModuleDir is empty, use the same candidates (the first existing one). Do not duplicate the list.
|
||||
- In new.go:
|
||||
- addStarterPlugin registers filepath.Join("plugins", "golem15", name).
|
||||
- In ValidateAppName, the reserved-name messages name plugins/golem15/main and plugins/golem15/internal. Both reasons still hold: package main, and the internal path element.
|
||||
- In stubs/new.tmpl, change the CLAUDE.md lines to make:plugin golem15.<name> under plugins/golem15/<name> and plugin:add plugins/<vendor>/<name>, the starter path to plugins/golem15/{{.Name}}, and the next-steps plugin:add line.
|
||||
- Tests:
|
||||
- Update every plugins/demo pin in internal/build/build_test.go, internal/build/new_test.go, internal/build/new_golden_test.go and cmd/summer/main_test.go to plugins/golem15/demo. This covers wantDir, AddPlugin paths, the module suffix check in assertScaffoldFiles and the go.work strings.
|
||||
- In docs/examples/blog/scaffold_layout_test.go, the scaffolded directory becomes plugins/acme/blog.
|
||||
- Add TestMakePluginVendorDirs: acme.blog and pixelpixel.blog in one hello copy.
|
||||
- Add TestLocatePluginLegacyLayout: scaffold golem15.demo, os.Rename it to plugins/demo (copyHelloApp makes the framework replace absolute, so the move is safe), then MakeModel with --no-migration succeeds there.
|
||||
- Regenerate the goldens with go test ./internal/build -run TestNewGolden -update. git rm the old plugins__demo__*.golden files. Review the golden diff: only path changes are expected.
|
||||
- cmd/summer/main.go help text:
|
||||
- make:plugin Long says plugins/<vendor>/<plugin>.
|
||||
- Examples: "Create plugins/acme/blog with the ID acme.blog" and plugin:add plugins/acme/blog.
|
||||
- Every "Inside plugins/blog" and "from inside plugins/blog" becomes plugins/acme/blog.
|
||||
- Docs in the same commit:
|
||||
- docs/console/scaffolding.md: the starter-plugin table row, "Creating a plugin" and the cd example.
|
||||
- docs/architecture/go-modules-and-workspaces.md, around the plugins/<name> paragraph and the plugin:add example.
|
||||
- docs/plugins/registration.md: the directory sentence and the tree root.
|
||||
- docs/plugins/testing.md: go test ./plugins/acme/blog/...
|
||||
- docs/setup/porting-a-plugin.md: the plugin:add lines, the make:plugin and plugin:add table rows, and the module example.
|
||||
- grep docs/, README.md and modules/*/README.md for plugins/blog and plugins/<name>, and fix the remaining layout mentions. Manifest-only module strings in tag tests can stay.
|
||||
- Add the OD-3 upgrading note to docs/console/scaffolding.md under "Creating a plugin":
|
||||
- An existing plugins/<name> keeps working, because make:* finds both layouts and summer build follows summer.yaml.
|
||||
- Moving is optional. To move: move the directory, drop the old go.work use line (go work edit -dropuse), run summer plugin:add plugins/<vendor>/<name> (the module path and the summer.yaml entry stay), and add one ../ to a relative framework replace in the plugin go.mod.
|
||||
- Verify these exact steps in a scratch copy of examples/hello under the session scratchpad before writing them down. Correct the note to match what actually works.
|
||||
- Run go vet ./... and go test -short ./... before committing.
|
||||
- Commit as fix(build): scaffold plugins under plugins/<vendor>/<name>.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -short -count=1 ./internal/build/ ./cmd/summer/ ./docs/examples/blog/... && go test -count=1 ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check</automated>
|
||||
</verify>
|
||||
<done>Two commits exist. Migration timestamps always sort after the existing ones, with a frozen-clock unit test and a registry-order check. make:plugin writes plugins/<vendor>/<name>. make:* and the models-leaf check find both layouts. The summer new starter is at plugins/golem15/<name> and the goldens are regenerated. Help text and docs, including the verified upgrading note, match. go vet, go test -short ./... and the docs checker are green.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: //summer:make marker for editable files and app-taking console commands (one commit)</name>
|
||||
<files>internal/build/registry.go, internal/build/artifact.go, internal/build/stubs/artifacts.tmpl, internal/build/stubs/plugin.tmpl, internal/build/stubs/registry.tmpl, internal/build/build_test.go, internal/build/testdata/new/*.golden, cmd/summer/main.go, docs/console/scaffolding.md, docs/console/writing-commands.md, docs/setup/porting-a-plugin.md, docs/examples/blog/plugin.go, docs/examples/blog/registry.gen.go, docs/examples/blog/models/post.go, docs/examples/blog/updates/20260101000000_create_acme_blog_posts.go, docs/examples/blog/updates/20260101000100_add_published_at.go, docs/examples/blog/controllers/posts.go, docs/examples/blog/console/publish.go</files>
|
||||
<behavior>
|
||||
- The model, migration, command, job and admin-controller stubs begin with the line //summer:make model / migration / command / job / admin-controller, followed by a blank line. registry.gen.go still begins with the Code generated by summer make header.
|
||||
- A console/ file carrying the old header with a zero-argument command function is still listed (OD-2). A console/ file with neither marker is not listed.
|
||||
- A command function taking exactly one *backpack.App parameter (named or unnamed) is listed. registry.gen.go calls it with the app passed to generatedCommands. A value backpack.App parameter, two parameters, or any other parameter type is skipped.
|
||||
- A freshly scaffolded plugin keeps the app from Boot, and Commands returns generatedCommands(p.app). make:command writes a function taking app *backpack.App. The plugin builds, and the built app runs the scaffolded command.
|
||||
- A plugin.go in the older form, with an empty Plugin struct and a Commands that returns generatedCommands() without arguments, still compiles after make:command. The make:command hint then explains that the app arrives as nil and how to pass it.
|
||||
</behavior>
|
||||
<action>
|
||||
Per the todo and OD-2. The aim is to stay backward compatible with every existing plugin.go.
|
||||
- In internal/build/registry.go:
|
||||
- Keep the legacy marker constant.
|
||||
- Add the directive constant //summer:make.
|
||||
- Replace isGeneratedFile with a scaffold-marker check: a comment whose text is the directive, or the directive followed by a space, OR any comment containing the legacy marker.
|
||||
- In funcRefs, for the command scan kind only, accept zero parameters or exactly one parameter field with at most one name whose type is a pointer (ast.StarExpr) to the selector backpack.App. astTypeName strips the star, so check for the StarExpr explicitly.
|
||||
- Record a TakesApp flag on registryRef.
|
||||
- Every other kind still accepts zero parameters only.
|
||||
- In stubs/registry.tmpl:
|
||||
- Always import modules/backpack.
|
||||
- Declare generatedCommands with a variadic app parameter of type *backpack.App. A variadic parameter keeps every existing plugin.go that calls generatedCommands() compiling; do not use a plain parameter.
|
||||
- Call zero-argument refs as they are called now.
|
||||
- Call TakesApp refs with the first variadic value, or nil when none was passed. Declare that local only when at least one TakesApp ref exists.
|
||||
- Keep the header line and the sort order unchanged.
|
||||
- In stubs/artifacts.tmpl:
|
||||
- Swap the first line of each of the five editable stubs for the matching //summer:make <kind> directive line.
|
||||
- The command stub imports modules/backpack, and the function takes app *backpack.App. Add a doc comment saying that app gives the command the config and published services (app.Lookup), and that it is the app the plugin kept from Boot. The Run body stays unchanged.
|
||||
- In stubs/plugin.tmpl:
|
||||
- The Plugin struct holds the app.
|
||||
- Boot stores it.
|
||||
- Commands returns generatedCommands(p.app), with a comment that Commands is collected after Boot.
|
||||
- Regenerate the summer new goldens with -update and review the diff.
|
||||
- In artifact.go MakeCommand: when the plugin's plugin.go contains a generatedCommands call with empty parentheses (a whitespace-tolerant regexp), append a note to the result Hint. The note says that <Func> receives a nil *backpack.App until Boot keeps the app and Commands returns generatedCommands(p.app).
|
||||
- Tests in internal/build/build_test.go cover each behavior line.
|
||||
- Run the built app binary's demo:purge after the App build in TestScaffoldAllArtifacts. If that binary cannot start without services the test environment lacks, write a throwaway _test.go into the temporary plugin instead. It calls Boot with an app and finds demo:purge in Commands(); run it with execGo go test.
|
||||
- Legacy plugin.go case: scaffold, rewrite plugin.go to the older shape, run MakeCommand, then go build ./... in the plugin and check the hint text.
|
||||
- docs/examples/blog:
|
||||
- Switch the five editable files' first line to their //summer:make <kind> directive.
|
||||
- Update registry.gen.go by hand to what the new template renders for this plugin: the backpack import and the variadic generatedCommands. It lists no commands, because PublishCommand takes withDB.
|
||||
- plugin.go Commands appends to generatedCommands(p.app).
|
||||
- Run go run ./cmd/summer docs:sync to refresh the src= copies.
|
||||
- cmd/summer/main.go make:command Long: replace "the generated function takes no arguments" with: the function receives the application the plugin kept from Boot, and a function with another parameter (such as the porting guide's withDB) is returned from Commands by hand.
|
||||
- Docs in the same commit:
|
||||
- docs/console/scaffolding.md: describe the directive line and the note that registry.gen.go lists only files that carry it or the older header; describe app-taking commands.
|
||||
- docs/console/writing-commands.md: the "Registering commands" paragraph and the make:command starting point. A command that needs the database or services takes app *backpack.App.
|
||||
- docs/setup/porting-a-plugin.md:
|
||||
- Rewrite the generated-code-header bullet in "What the scaffolder leaves to you" around the directive. Keep the line, or the file drops out of registry.gen.go; linters now lint these files.
|
||||
- In the console section, explain that the generated accessor lists commands taking *backpack.App. This example passes the narrower withDB, which its tests inject, so it is still added by hand.
|
||||
- Any new go fence must be a src= copy of docs/examples/blog.
|
||||
- Run go vet ./... and go test -short ./... before committing.
|
||||
- Commit as feat(build): mark scaffolded files with //summer:make and pass the app to generated commands.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -short -count=1 ./internal/build/ ./cmd/summer/ ./docs/examples/blog/... && go test -count=1 ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check</automated>
|
||||
</verify>
|
||||
<done>One commit exists. Editable stubs carry //summer:make <kind>. registry.gen.go keeps the Code generated header. Both markers are recognized. App-taking commands are listed and receive the app. New plugins pass p.app. Older plugin.go files compile and get a hint. The docs, the blog example and the help text match, and the checker is green.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: make:admin-controller names the model, embeds its YAML and offers delete (one commit)</name>
|
||||
<files>internal/build/artifact.go, internal/build/registry.go, internal/build/stubs/artifacts.tmpl, internal/build/stubs/plugin.tmpl, internal/build/stubs/registry.tmpl, internal/build/build_test.go, internal/build/testdata/new/*.golden, cmd/summer/main.go, docs/examples/blog/scaffold_layout_test.go, docs/examples/blog/plugin.go, docs/examples/blog/registry.gen.go, docs/examples/blog/controllers/posts/config_form.yaml, docs/examples/blog/controllers/posts/config_list.yaml, docs/examples/blog/models/post/fields.yaml, docs/examples/blog/models/post/columns.yaml, docs/console/scaffolding.md, docs/setup/porting-a-plugin.md, docs/backend/admin-controllers.md, modules/cabana/README.md</files>
|
||||
<behavior>
|
||||
- singularize: Posts→Post, Albums→Album, BlogPosts→BlogPost, Categories→Category, Boxes→Box, Matches→Match, Wishes→Wish, Addresses→Address, Genres→Genre, Status→Status, Post→Post. For every word pluralize handles, singularize(pluralize(w)) == w.
|
||||
- MakeAdminController(ctx, dir, "golem15.demo", "Albums", "") writes controllers/albums.go (ID golem15.demo.albums, ConfigDir controllers/albums, ModelName "Album"), controllers/albums/config_form.yaml and config_list.yaml (modelClass: Album, form/list paths ~/plugins/golem15/demo/models/album/...), and models/album/fields.yaml and columns.yaml. cabana.CompileForm and CompileList pass strictly.
|
||||
- A non-empty model argument overrides the default ("People" with "Person" gives models/person/ and modelClass: Person). An invalid model identifier fails before anything is written.
|
||||
- config_list.yaml has showCheckboxes: true and buttons: [create, delete], and CompileList accepts it.
|
||||
- With models.Album already present (make:model first), NewRecord returns &models.Album{} and the plugin builds. Without it, NewRecord keeps today's nil body with its TODO naming make:model, and the result Hint says so.
|
||||
- A fresh make:plugin implements pact.AdminAssets: AdminFS returns generatedAdminFS(), and registry.gen.go has no go:embed line while no admin YAML exists. After make:admin-controller, registry.gen.go embeds controllers/*/*.yaml models/*/*.yaml, and both YAML files can be read through (&Plugin{}).AdminFS() in the built plugin.
|
||||
- make:admin-controller in a plugin whose root .go files declare no AdminFS method appends a hint naming generatedAdminFS().
|
||||
</behavior>
|
||||
<action>
|
||||
Per the todo; the remaining items only, since NewRecord and RequiredPermissions already exist.
|
||||
- In internal/build/artifact.go:
|
||||
- Add singularize next to pluralize, with rules that invert pluralize: ies to y; the es suffix after s, x, z, ch and sh is removed; words ending in ss, us or is stay unchanged; a trailing s is removed. Irregular forms are covered by --model.
|
||||
- Change the signature to MakeAdminController(ctx, startDir, pluginID, name, model string). An empty model means singularize(name). Validate it with validateExportedIdent and identHint.
|
||||
- Add ModelIdent and ModelSnake to artifactData.
|
||||
- The YAML directory is models/<model snake>/. The controller ID, ConfigDir, redirect URLs and Permission keep the controller snake.
|
||||
- The duplicate-file checks cover the new paths.
|
||||
- Add Module to pluginRef, set from the go.mod that resolvePlugin already reads.
|
||||
- Decide NewRecord by parsing models/*.go (all files, not only marked ones; skip _test.go) for an exported struct named ModelIdent. When it exists, render NewRecord returning a pointer to it and import <module>/models. When it does not, render the current nil body and add a Hint line: run summer make:model <id> <Model>, then return &models.<Model>{}.
|
||||
- If no non-test .go file in the plugin root declares a method named AdminFS, append a Hint line that names generatedAdminFS() and pact.AdminAssets.
|
||||
- In stubs/artifacts.tmpl:
|
||||
- ModelName returns ModelIdent, and the NewRecord branch is conditional.
|
||||
- config_form.yaml: name is the model, modelClass is the model, and form points at models/<model snake>/fields.yaml.
|
||||
- config_list.yaml: modelClass is the model, title is the controller, the list path points at models/<model snake>/columns.yaml, showCheckboxes: true is added, and the buttons are [create, delete].
|
||||
- In internal/build/registry.go:
|
||||
- refreshRegistry sets AdminPatterns: each of controllers/*/*.yaml and models/*/*.yaml that has at least one filepath.Glob match in the plugin dir, in that order.
|
||||
- emptyRegistry sets none.
|
||||
- In stubs/registry.tmpl:
|
||||
- Always import embed and io/fs.
|
||||
- Declare var generatedAdminTree embed.FS, with a go:embed line listing AdminPatterns only when there are any; a zero embed.FS is an empty, non-nil tree.
|
||||
- Add func generatedAdminFS() fs.FS returning it.
|
||||
- Note in a template comment that the go:embed globs are re-evaluated at compile time, and that the line is rewritten by the next make: run.
|
||||
- In stubs/plugin.tmpl, add the pact.AdminAssets assertion and an AdminFS method returning generatedAdminFS(). Regenerate the summer new goldens with -update.
|
||||
- Update every MakeAdminController call site to pass "" or a model: cmd/summer/main.go, internal/build/build_test.go and docs/examples/blog/scaffold_layout_test.go.
|
||||
- In cmd/summer/main.go, add a --model flag to make:admin-controller (a bonfire.Flag with a value, defaulting to the singular of the name) and update the Long text and Examples (Posts to Post; People --model Person).
|
||||
- Tests in internal/build/build_test.go:
|
||||
- Update TestMakeAdminControllerLayout: Albums uses model Album and models/album; the duplicate-asset subtest pre-creates models/genre/fields.yaml; scaffoldAlbumsController.ModelName returns "Album"; buttons are [create, delete] with showCheckboxes.
|
||||
- Add a table test for singularize.
|
||||
- Add the --model override case.
|
||||
- Add the NewRecord-with-model case, with go build ./... in the plugin.
|
||||
- Add an AdminFS end-to-end case: write a throwaway _test.go into the temporary plugin that reads both YAML files through (&Plugin{}).AdminFS(), then run go test there with execGo.
|
||||
- assertWinterPluginShape must still pass for a fresh plugin, because no new files are written.
|
||||
- docs/examples/blog:
|
||||
- git mv models/posts to models/post.
|
||||
- Point config_form.yaml and config_list.yaml at ~/plugins/acme/blog/models/post/.
|
||||
- plugin.go AdminFS returns generatedAdminFS(); drop the hand-written adminFS embed var and update its comment.
|
||||
- registry.gen.go gets the generatedAdminTree embed of both patterns plus generatedAdminFS.
|
||||
- grep docs/examples/blog for models/posts and fix the remaining references, including the tests.
|
||||
- Run go run ./cmd/summer docs:sync.
|
||||
- Docs in the same commit:
|
||||
- docs/console/scaffolding.md: the make:admin-controller row (models/<model>/, --model, NewRecord, delete) and the make:plugin AdminFS.
|
||||
- docs/setup/porting-a-plugin.md:
|
||||
- The make:admin-controller table row becomes models/post/.
|
||||
- The sentence saying that fields sit under models/posts/ becomes models/post/, as in WinterCMS.
|
||||
- Point the src= paths at models/post/.
|
||||
- Replace the two admin bullets of "What the scaffolder leaves to you" with what is still manual: declare the permission in Permissions(), give the model Fillable and Rules, and fill fields.yaml and columns.yaml.
|
||||
- Update the sentence that says AdminFS was added by hand.
|
||||
- docs/backend/admin-controllers.md: the make:admin-controller paragraph.
|
||||
- modules/cabana/README.md: the scaffold mention.
|
||||
- Run go vet ./..., go test -short ./... and go -C examples/hello test ./... before committing.
|
||||
- Commit as feat(build): scaffold working admin controllers with model, AdminFS and delete.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -short -count=1 ./... && go -C examples/hello test ./... && go test -count=1 ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check</automated>
|
||||
</verify>
|
||||
<done>One commit exists. make:admin-controller writes model-named YAML and controller, a real NewRecord when the model exists, and create/delete with showCheckboxes. --model overrides the singular. Scaffolded plugins serve AdminFS from registry.gen.go, empty until the first controller. The docs example, the porting page, the admin docs, the cabana README and the help text match. Full go test -short, examples/hello and the docs checker are green.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| CLI argument → filesystem | The plugin ID, artifact name and --model value from the developer's command line become directory and file paths |
|
||||
| scaffolded source → registry generator | Files in a plugin's leaf directories are parsed and decide what registry.gen.go calls and embeds |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-q261028-01 | Tampering | makePlugin / locatePluginDir vendor segment | medium | mitigate | The vendor comes from an ID that ValidatePluginID has already checked (lowercase vendor.plugin, no slashes). Every candidate dir (vendor and legacy) still goes through underRoot before use; TestMakePluginRejectsPathTraversalAndShellMeta stays green |
|
||||
| T-q261028-02 | Tampering | MakeAdminController --model | low | mitigate | The model goes through validateExportedIdent before any path is built, and rejectDuplicateFile/underRoot cover models/<model snake>/ |
|
||||
| T-q261028-03 | Information disclosure | generatedAdminTree go:embed | low | mitigate | The patterns are limited to controllers/*/*.yaml and models/*/*.yaml, so Go sources are never embedded in the binary (the same choice sm-faq-plugin made) |
|
||||
| T-q261028-04 | Denial of service | app-taking commands under legacy wiring | low | mitigate | The variadic generatedCommands passes nil only when plugin.go omits the app; make:command prints a hint naming the fix, and the default stub does not dereference app |
|
||||
| T-q261028-SC | Tampering | npm/pip/cargo installs | low | accept | No new dependencies; only stdlib embed/io/fs and the existing modules/backpack |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- go vet ./... and go test -short ./... are green after each of the four commits (the CLAUDE.md rule).
|
||||
- go -C examples/hello test ./... is green; examples/hello is not modified.
|
||||
- go test ./cmd/summer -run TestDocsTree and go run ./cmd/summer docs:build --check are green after each commit.
|
||||
- TestScaffoldLayout (docs/examples/blog) passes: the walkthrough file set equals the scaffolder output under plugins/acme/blog with models/post/.
|
||||
- git log shows four commits without co-author tags, each staging only its own paths; other sessions' STATE.md, state.json and phase-20 files are left alone.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All four todos' problems are fixed, as the must_haves truths describe.
|
||||
- Nothing breaks for existing apps: plugins/<name>, the old header and zero-argument generatedCommands() calls keep working.
|
||||
- Docs, help text and the porting walkthrough describe the new behavior and include the upgrading note.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/261028-pq8-fix-the-four-internal-build-scaffolder-t/261028-pq8-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,169 @@
|
||||
---
|
||||
phase: quick-261028-pq8
|
||||
plan: 01
|
||||
subsystem: internal/build (scaffolder)
|
||||
status: complete
|
||||
tags: [scaffolder, make-plugin, migrations, admin-controller, registry, docs]
|
||||
requires: []
|
||||
provides:
|
||||
- migration timestamps after every existing updates/ file
|
||||
- plugins/<vendor>/<name> layout with legacy plugins/<name> fallback
|
||||
- //summer:make <kind> marker; app-taking console commands
|
||||
- make:admin-controller with model name, real NewRecord, AdminFS and delete
|
||||
affects: [cmd/summer, docs/console, docs/setup, docs/backend, docs/plugins, docs/architecture, docs/examples/blog, modules/cabana/README.md]
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "variadic generatedCommands(app ...*backpack.App) keeps old zero-arg callers compiling"
|
||||
- "registry.gen.go embeds admin YAML only for globs that match (go:embed rejects empty patterns)"
|
||||
key-files:
|
||||
created:
|
||||
- internal/build/testdata/new/plugins__golem15__demo__plugin.go.golden (renamed from plugins__demo__)
|
||||
- internal/build/testdata/new/plugins__golem15__demo__routes.go.golden (renamed from plugins__demo__)
|
||||
- docs/examples/blog/models/post/ (moved from models/posts/)
|
||||
modified:
|
||||
- internal/build/artifact.go
|
||||
- internal/build/scaffold.go
|
||||
- internal/build/leaf.go
|
||||
- internal/build/new.go
|
||||
- internal/build/registry.go
|
||||
- internal/build/stubs/artifacts.tmpl
|
||||
- internal/build/stubs/plugin.tmpl
|
||||
- internal/build/stubs/registry.tmpl
|
||||
- internal/build/stubs/new.tmpl
|
||||
- internal/build/build_test.go
|
||||
- cmd/summer/main.go
|
||||
- docs/console/scaffolding.md
|
||||
- docs/setup/porting-a-plugin.md
|
||||
decisions:
|
||||
- "make:plugin writes plugins/<vendor>/<name>; make:* looks up summer.yaml, then plugins/<vendor>/<name>, then legacy plugins/<name> (OD-1)"
|
||||
- "Registry scan recognises both //summer:make and the old Code generated header (OD-2)"
|
||||
- "Moving an existing plugins/<name> is optional; documented, verified upgrading steps (OD-3)"
|
||||
- "New registry identifiers are generatedAdminTree / generatedAdminFS / bootApp to avoid collisions with handwritten adminFS vars"
|
||||
metrics:
|
||||
duration: ~75min
|
||||
completed: 2026-10-28
|
||||
estimate_ref: "plan estimate 150000 tokens, 3 tasks"
|
||||
actuals:
|
||||
tokens: 35400
|
||||
tasks: 3
|
||||
commits: 4
|
||||
commits: 4
|
||||
plan_head_before: 017e0e9
|
||||
plan_head_after: 8bc18c9
|
||||
---
|
||||
|
||||
# Quick 261028-pq8: Fix the four internal/build scaffolder todos Summary
|
||||
|
||||
`summer make:*` now writes vendor-namespaced plugins (`plugins/acme/blog`), orders migrations correctly within one second, marks editable files with `//summer:make <kind>` and passes the `*backpack.App` to generated commands, and produces admin controllers that boot: model-named YAML under `models/<model>/`, a real `NewRecord`, `AdminFS` from `registry.gen.go`, and create and delete buttons. Existing apps keep working: `plugins/<name>`, the old header and zero-argument `generatedCommands()` all still work.
|
||||
|
||||
## Commits
|
||||
|
||||
| Task | Commit | Message |
|
||||
|------|--------|---------|
|
||||
| 1a | a941ab2 | fix(build): order scaffolded migrations after every existing one |
|
||||
| 1b | a95a014 | fix(build): scaffold plugins under plugins/<vendor>/<name> |
|
||||
| 2 | 70e4b41 | feat(build): mark scaffolded files with //summer:make and pass the app to generated commands |
|
||||
| 3 | 8bc18c9 | feat(build): scaffold working admin controllers with model, AdminFS and delete |
|
||||
|
||||
## What was built
|
||||
|
||||
**1a: Migration order.** `nextMigrationFile` reads `updates/` once, takes the newest `\d{14}_*.go` timestamp, and starts one second after it when now is not later. Unit test `TestNextMigrationFileSortsAfterExisting` (frozen clock; same second, a future-dated existing file, non-migration files ignored, older existing). `TestScaffoldCoreArtifacts` freezes the clock and asserts `CreateAlbums` before `AddWidgetIndex` in `registry.gen.go`. The docs no longer mention the same-second caveat or the manual order check.
|
||||
|
||||
**1b: Vendor path.**
|
||||
- `makePlugin` writes `plugins/<vendor>/<name>` with module `<app>/plugins/<vendor>/<name>`. The package stays `<name>`.
|
||||
- `pluginDirCandidates` and `defaultPluginDir` in `scaffold.go` hold the shared lookup order (vendor dir, then legacy), used by both `locatePluginDir` and `checkModelsLeaf`.
|
||||
- `summer new` puts the starter plugin at `plugins/golem15/<name>`. The plugin's framework replace gets one more `../` (`../../../../summercms`), and the goldens were regenerated (paths only).
|
||||
- Updated: help text, the `new.tmpl` CLAUDE.md and next steps, and the docs. `scaffolding.md` gained an upgrading note ("Upgrading: plugins without a vendor directory").
|
||||
- New tests: `TestMakePluginVendorDirs` (acme.blog and pixelpixel.blog side by side, both build, the hint names `plugin:add plugins/acme/blog`) and `TestLocatePluginLegacyLayout`.
|
||||
|
||||
**2: Marker and app-taking commands.**
|
||||
- Each editable stub starts with `//summer:make model|migration|command|job|admin-controller`. `registry.gen.go` keeps `// Code generated by summer make. DO NOT EDIT.`
|
||||
- `isScaffoldedFile` accepts the directive (alone, or followed by a space) and the old header.
|
||||
- The command scan also accepts exactly one `*backpack.App` parameter, named or unnamed, and records it as `TakesApp`.
|
||||
- `generatedCommands(app ...*backpack.App)` calls `TakesApp` refs with `bootApp`. That local exists only when at least one such ref does.
|
||||
- The plugin stub keeps the app from `Boot` and returns `generatedCommands(p.app)`.
|
||||
- `MakeCommand` adds a nil-app note when `plugin.go` matches `generatedCommands\(\s*\)`.
|
||||
- Tests:
|
||||
- `TestRegistryScanMarkersAndAppCommands`
|
||||
- `TestMakeCommandLegacyPluginGo`: older plugin.go plus make:command, then `go build` and `go vet`, and the hint is checked.
|
||||
- `TestScaffoldAllArtifacts` checks the markers, patches `purge.go` to print `app=%t`, and runs `bin/hello demo:purge` on the built app, which must print `app=true`.
|
||||
|
||||
**3: Admin controller.**
|
||||
- `singularize` sits next to `pluralize`. `MakeAdminController(ctx, dir, id, name, model)` takes the new model argument, and the CLI has a `--model` flag.
|
||||
- `ModelName`, `name:` and `modelClass:` use the model, and the YAML goes under `models/<model snake>/`. The controller ID, ConfigDir, redirects and permission keep the controller snake case.
|
||||
- `NewRecord` returns `&models.<Model>{}` when `models/*.go` (package models, non-test) declares the struct. Otherwise the nil body plus TODO stays, and a hint names `make:model`.
|
||||
- `config_list.yaml` has `showCheckboxes: true` and `buttons: [create, delete]`.
|
||||
- `registry.gen.go` declares `var generatedAdminTree embed.FS`. It only gets a `//go:embed controllers/*/*.yaml models/*/*.yaml` line for globs that match a file, and `generatedAdminFS()` returns the tree.
|
||||
- The plugin stub asserts `pact.AdminAssets` and returns `generatedAdminFS()` from `AdminFS`. Plugins with no `AdminFS` method get a hint.
|
||||
- The blog example moved to `models/post/`, and its hand-written `adminFS` embed var was dropped.
|
||||
- Tests:
|
||||
- `TestSingularize`: the table plus the round trip.
|
||||
- `TestMakeAdminControllerModel`: `--model`, an invalid model writes nothing, the AdminFS hint on the greeter plugin.
|
||||
- `TestScaffoldAdminFSEndToEnd`: a probe `_test.go` runs with `go test` inside the plugin. It sees an empty tree first, then reads all four YAML files, and checks that no Go source is embedded.
|
||||
- Plus a CLI `--model` check and updated layout and strict-compile tests.
|
||||
|
||||
## Verification
|
||||
|
||||
- `go vet ./...` passed and `go test -short ./...` was green, both after each task.
|
||||
- `go -C examples/hello test ./...` was green, and examples/hello is unchanged.
|
||||
- `go test ./cmd/summer -run TestDocsTree` and `go run ./cmd/summer docs:build --check` passed: no problems found.
|
||||
- The non-short `go test ./internal/build -run TestNew` was green (TestNewAppSmoke, TestNewNoPluginBuilds).
|
||||
- I checked the upgrading note by hand on a copy of examples/hello under the scratchpad:
|
||||
- Set-up: relative framework replaces, go.work from `plugin:add`.
|
||||
- Moved `plugins/greeter` to `plugins/golem15/greeter`, ran `go work edit -dropuse ./plugins/greeter`, then `summer plugin:add plugins/golem15/greeter`.
|
||||
- The build failed until the plugin `go.mod` replace got one more `../`. After that, `summer build` worked, `bin/hello greeter:hello` ran, and `make:model` and `make:job` (the latter from inside the moved directory) found the plugin.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 2 - Missing guard] make:plugin refuses a nested or duplicate plugin**
|
||||
- **Found during:** Task 1b
|
||||
- **Issue:** With the vendor layout, an ID whose vendor matches a legacy `plugins/<vendor>` plugin module (for example `greeter.extra` in examples/hello) would nest a module inside another plugin. Separately, a legacy `plugins/<name>` that already holds the same ID would get a second copy.
|
||||
- **Fix:** `makePlugin` errors with a hint in both cases. Both are covered in `TestMakePluginVendorDirs` and `TestLocatePluginLegacyLayout`.
|
||||
- **Commit:** a95a014
|
||||
|
||||
**2. [Plan detail] singularize rules refined**
|
||||
- The plan's literal rule ("es after s removed") would turn Courses into Cours, Houses into Hous and Responses into Respons.
|
||||
- The rules now drop `es` only after `ss`, `us` (but not `ous`/`aus`), `sis`, `x`, `ch`, `sh` and `zz`. `quizzes` gives `quiz`, and otherwise a trailing `s` is dropped.
|
||||
- All plan cases pass. The round trip `singularize(pluralize(w)) == w` holds for the tested word list. It fails for words like `gas`, where pluralize gives `gases` and that singularizes to `gase`; `--model` covers those.
|
||||
- **Commit:** 8bc18c9
|
||||
|
||||
**3. [Docs] Fixed example timestamps kept**
|
||||
- The fixed example timestamps (20260101000000/20260101000100) were explained only in the bullet the plan removed.
|
||||
- I moved that explanation into the porting page's migration paragraph.
|
||||
- **Commit:** a941ab2
|
||||
|
||||
**4. [Docs] Extra layout mentions updated**
|
||||
- The summer.yaml examples in `docs/architecture/go-modules-and-workspaces.md` and `docs/services/storage.md` now use `.../plugins/acme/blog`, to match what make:plugin writes.
|
||||
- `docs/plugins/registration.md` gained the admin YAML embed in the tree.
|
||||
- **Commits:** a95a014, 8bc18c9
|
||||
|
||||
**5. [Docs] Upgrading note step added**
|
||||
- The verified steps include `mkdir -p plugins/<vendor>` before the `mv`.
|
||||
- The note also says that other relative replaces in the plugin `go.mod` need the extra `../` too.
|
||||
- **Commit:** a95a014
|
||||
|
||||
### Out of scope (not fixed)
|
||||
- `cmd/summer/main.go`, `cmd/summer/runtime.go` and `cmd/summer/runtime_test.go` were already not gofmt-clean at HEAD 017e0e9. I left them alone.
|
||||
- `internal/build/registry.go`'s const-block alignment got fixed in passing, because Task 2 rewrote that block.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None in this plan's code. The scaffolded admin controller deliberately keeps `NewRecord() any { return nil }` with a TODO when the model does not exist yet. That is intended scaffold output, and the command prints a hint about it.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. The threat-model mitigations are in place:
|
||||
- **T-01:** Vendor and legacy candidates both go through `underRoot`, and the existing traversal tests stay green.
|
||||
- **T-02:** `--model` is validated by `validateExportedIdent` before any path is built.
|
||||
- **T-03:** The embed patterns are limited to `*.yaml` under `controllers/*/` and `models/*/`; the probe checks that no Go source is embedded.
|
||||
- **T-04:** The variadic `generatedCommands` keeps existing callers compiling, and the stub never dereferences `app`.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: internal/build/testdata/new/plugins__golem15__demo__plugin.go.golden
|
||||
- FOUND: internal/build/testdata/new/plugins__golem15__demo__routes.go.golden
|
||||
- FOUND: docs/examples/blog/models/post/fields.yaml, docs/examples/blog/models/post/columns.yaml
|
||||
- FOUND commits: a941ab2, a95a014, 70e4b41, 8bc18c9 (none carry a co-author line)
|
||||
@@ -0,0 +1,316 @@
|
||||
---
|
||||
phase: quick-261028-r16
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- modules/lagoon/attach/store_test.go
|
||||
- docs/services/storage.md
|
||||
- docs/database/attachments.md
|
||||
- modules/lagoon/transaction.go
|
||||
- modules/lagoon/transaction_test.go
|
||||
- modules/lagoon/relations.go
|
||||
- modules/lagoon/README.md
|
||||
- docs/database/transactions.md
|
||||
autonomous: true
|
||||
requirements:
|
||||
- todo:2026-10-11-uploads-keep-client-extension-stored-xss
|
||||
- todo:lagoon-readme-after-commit-callback-order
|
||||
|
||||
estimate:
|
||||
tokens: 70000
|
||||
raw_tokens: 70000
|
||||
tasks: 2
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A real PNG with <script> in a tEXt chunk passes attach.IsAllowedImage. Uploaded as x.html through attach.Store in image mode (html allowed), it is stored as <hex>.png and its URL ends in .png. file_name stays x.html, content_type is image/png and the stored bytes are unchanged. A Docker-free test also checks the IsAllowedImage + DiskExtension half"
|
||||
- "attach.DiskExtension (phase 19.3, commit 2e22e9f) stays the single shared helper and attach.Store keeps naming blobs through it. No exported lagoon or attach identifier is added, removed or changed"
|
||||
- "docs/services/storage.md's deploy note covers two cases. (1) A static server picks the Content-Type from the extension: set nosniff at server level, use an extension allow-list with its own types map, answer 404 for everything else, with a neutral nginx example. (2) A public S3 bucket or a CDN in front of it sends the Content-Type stored with the object, which is the sniffed type: use a separate origin, and set nosniff plus attachment disposition for non-images through the bucket/CDN header settings"
|
||||
- "The lagoon.OnDatabase example in modules/lagoon/README.md registers its create callback After(\"gorm:create\").Before(\"gorm:commit_or_rollback_transaction\") and says why, matching docs/database/transactions.md"
|
||||
- "The lagoon.RegisterJoinTable doc comment and its README row send pivot-ordered reads through an explicit join on the pivot table, and say that a Preload of the field cannot be ordered by a pivot column"
|
||||
- "lagoon.AfterCommit logs a warning when a callback that runs after lagoon:after_commit on the same write registers work. Nothing else changes: the work still never runs, the write still succeeds, and the work is still buffered as before. Correctly ordered callbacks log nothing and behave exactly as before, including a callback between GORM's commit step and lagoon:after_commit and a later write on a reused chained statement"
|
||||
- "go vet ./..., go test -short ./..., go test ./cmd/summer -run TestDocsTree and go run ./cmd/summer docs:build --check are green after each commit. The Docker-backed attach and lagoon tests named below pass"
|
||||
artifacts:
|
||||
- path: modules/lagoon/attach/store_test.go
|
||||
provides: "pngWithScriptText fixture, TestPNGScriptChunkNaming (no Docker), TestStoreScriptChunkPNG (Postgres)"
|
||||
contains: "TestStoreScriptChunkPNG"
|
||||
- path: docs/services/storage.md
|
||||
provides: "Deploy note split into static-server and public-bucket cases, with a neutral nginx allow-list example"
|
||||
contains: "X-Content-Type-Options"
|
||||
- path: modules/lagoon/transaction.go
|
||||
provides: "statementFlushedKey marker set by flushStatementAfterCommit; AfterCommit warning guarded by transactionalHandle"
|
||||
contains: "statementFlushedKey"
|
||||
- path: modules/lagoon/transaction_test.go
|
||||
provides: "late_callback_warns, callback_between_commit_and_flush_runs, reused_statement_no_false_warning subtests"
|
||||
contains: "late_callback_warns"
|
||||
- path: modules/lagoon/README.md
|
||||
provides: "Corrected OnDatabase example, warning sentence, RegisterJoinTable row"
|
||||
contains: "Before(\"gorm:commit_or_rollback_transaction\")"
|
||||
- path: modules/lagoon/relations.go
|
||||
provides: "Corrected RegisterJoinTable read guidance"
|
||||
key_links:
|
||||
- from: modules/lagoon/attach/store.go
|
||||
to: modules/lagoon/attach/diskext.go
|
||||
via: "newDiskName(DiskExtension(sniffed, ext)) in Store (unchanged; pinned by the new regression)"
|
||||
pattern: "DiskExtension\\(sniffed, ext\\)"
|
||||
- from: modules/lagoon/transaction.go flushStatementAfterCommit
|
||||
to: modules/lagoon/transaction.go AfterCommit
|
||||
via: "db.InstanceSet(statementFlushedKey, true) read back with db.InstanceGet and the !transactionalHandle(db) guard"
|
||||
pattern: "statementFlushedKey"
|
||||
- from: modules/lagoon/README.md
|
||||
to: modules/lagoon/example_test.go#on-database
|
||||
via: "Same registration chain as the tested docs region shown on docs/database/transactions.md"
|
||||
pattern: "After\\(\"gorm:create\"\\)\\.Before\\(\"gorm:commit_or_rollback_transaction\"\\)"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Close the two pending lagoon todos in the framework (summercms.go only).
|
||||
|
||||
1. Stored XSS through the client file extension (todo 2026-10-11, major). Live reads at planning time show that the framework part is already done. Phase 19.3 plan 03 (commit 2e22e9f, D-15 of 19.3-CONTEXT) added the shared helper `attach.DiskExtension` in `modules/lagoon/attach/diskext.go`. `attach.Store` names every blob through it (`store.go`: `newDiskName(DiskExtension(sniffed, ext))`), `file_name` keeps the client's name, and `TestDiskExtensionTable`, `TestStoreDiskExtensionSmoke` and `TestStoreDiskExtension` cover it. `attachments.md` and the lagoon README describe it, and `docs/services/storage.md` has a deploy note. Do NOT add a second helper or change attach's exported API. Two gaps remain:
|
||||
- No test covers the exact exploit from the todo: a real, decodable PNG with `<script>` in a `tEXt` chunk going through image mode, where it passes `IsAllowedImage`.
|
||||
- The deploy note claims that a CDN in front of the bucket "picks the Content-Type from the file extension". That is wrong for a public S3 bucket: S3 sends the Content-Type stored with the object, and `attach.Store` stores the sniffed type (19.3 deferred-items.md). The note also has no concrete web-server allow-list.
|
||||
Task 1 closes both gaps.
|
||||
2. lagoon README after-commit callback order (todo, priority high). The README's `lagoon.OnDatabase` example registers its callback off GORM's after-create step, which GORM sorts after `lagoon:after_commit`, so the buffered work silently never runs. The `RegisterJoinTable` doc comment still advises ordering a Preload by pivot columns, which fails. Task 2 fixes both texts and adds a log-only warning in `lagoon.AfterCommit`. The warning is small and safe: one marker in the existing flush callback, one guarded log line, and no change to what runs.
|
||||
|
||||
Decomposition: two independent auto tasks, two commits. They touch no common files, so either order works; run them in order. Tracer-first does not apply. These are two separate hardening fixes on paths that are already proven, so this plan behaves as `--no-tracer`, the same shape as earlier quick plans in this repo.
|
||||
|
||||
Not in scope (orchestrator records as follow-ups; do not edit sibling repos):
|
||||
- `../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collection_media_controller.go:251` (`storeUpload`) and `../fonoteka.go/plugins/golem15/fonoteka/classes/album_files.go:35` (`StorePublicFile`). Both append `filepath.Ext(<client name>)` to the disk name. Adoption is one line per site: sniff first, then use `attach.DiskExtension(contentType, filepath.Ext(name))`. That function already strips the dot, lower-cases the extension and drops MIME parameters. Note for that follow-up: an upload without an extension then gets `.bin`/`.txt` instead of no extension.
|
||||
- Already safe, optional dedup only: `sm-feedback-plugin` `screenshotExtension` (classes/submission_store.go) and `sm-user-plugin` `avatarExt` (controllers/api_controller.go:1497). Both derive the extension from their own sniff tables.
|
||||
- Behaviour follow-up, deferred: `attach.Store` writes the sniffed type as blob metadata. A public S3 bucket therefore serves an HTML body stored as `.txt` as `text/html`. The fix would be to write a non-rendering type, or `Content-Disposition: attachment`, for non-inline types. It changes Store's output, so it gets its own todo. This plan only documents the risk.
|
||||
- The orchestrator's closing docs commit moves both todos to `.planning/todos/completed/`. The executor does not move them in the code commits.
|
||||
|
||||
Purpose: the exploit from the todo gets a test, the deploy docs stop giving wrong advice about bucket-served uploads, and a misordered after-commit callback becomes visible in the logs instead of silently doing nothing.
|
||||
Output: two commits in summercms.go and 261028-r16-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-11-uploads-keep-client-extension-stored-xss.md
|
||||
@.planning/todos/pending/lagoon-readme-after-commit-callback-order.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/deferred-items.md
|
||||
|
||||
<interfaces>
|
||||
Existing code, verified at planning time. Nothing below changes signature.
|
||||
|
||||
modules/lagoon/attach/diskext.go
|
||||
- func DiskExtension(contentType, clientExt string) string. For a specific sniffed type it returns the client's extension when that extension belongs to the type, else the type's canonical one (image/png gives png). For a generic type it keeps the client's extension unless that one is script-capable, which becomes txt for text/* and bin otherwise.
|
||||
|
||||
modules/lagoon/attach/store.go
|
||||
- func Store(ctx, db *gorm.DB, bucket *blob.Bucket, in Upload, lim Limits) (*File, error)
|
||||
- type Upload { FileName string; Body io.Reader; Public bool }
|
||||
- type Limits { Extensions []string; MIMETypes []string; MaxBytes int64; Image bool }. In image mode the bytes must pass IsAllowedImage. An empty Extensions list means DefaultImageExtensions in image mode, else DefaultFileExtensions.
|
||||
- writeBlob writes &blob.WriterOptions{ContentType: contentType}, where contentType is the sniffed type, or the type looked up from the extension when the sniff says only application/octet-stream.
|
||||
- DefaultFileExtensions includes "txt".
|
||||
|
||||
modules/lagoon/attach/guard.go
|
||||
- func IsAllowedImage(data []byte) bool: the bytes must sniff as an allowed image type, image.DecodeConfig must succeed with a positive size, and the size must stay under MaxImagePixels.
|
||||
|
||||
modules/lagoon/attach/store_test.go (package attach_test)
|
||||
- smokePNG(t) []byte: png.Encode of a 4x3 RGBA image.
|
||||
- attachGorm(t) *gorm.DB: testcontainers Postgres. Tests that use it skip under testing.Short().
|
||||
- The TestStoreDiskExtensionSmoke pattern: lagoon.Migrate(gdb, nil), memblob.OpenBucket(nil), attach.Store(...).
|
||||
|
||||
modules/lagoon/transaction.go (package lagoon)
|
||||
- const AfterCommitCallback = "lagoon:after_commit"; const statementBufferKey = "lagoon:after_commit"
|
||||
- func AfterCommit(ctx, db *gorm.DB, fn). Branches, in order: (1) bufferFrom(ctx, db) finds a buffer, meaning the call is inside lagoon.Transaction; (2) db.InstanceGet("gorm:started_transaction") is set, meaning GORM opened an implicit transaction: the work is buffered under statementBufferKey; (3) transactionalHandle(db), meaning a foreign tx: warn and skip; (4) otherwise run the work now.
|
||||
- func flushStatementAfterCommit(db *gorm.DB): registered After("gorm:commit_or_rollback_transaction") on create, update and delete by registerAfterCommit. It InstanceGets statementBufferKey and runs the buffered work when db.Error is nil.
|
||||
- func transactionalHandle(db) bool: db.Statement.ConnPool implements gorm.TxCommitter.
|
||||
- moduleLog = diary.For("lagoon"), with slog-style Warn(msg, key, value...). Tests capture it by swapping slog.SetDefault to a TextHandler at LevelWarn (see the plain_gorm_transaction_is_refused subtest).
|
||||
|
||||
GORM v1.31.2 callbacks/transaction.go, which the warning guard relies on:
|
||||
- BeginTransaction sets db.Statement.ConnPool to the tx pool and InstanceSet("gorm:started_transaction", true).
|
||||
- CommitOrRollbackTransaction commits or rolls back and then sets db.Statement.ConnPool = db.ConnPool, the pool rather than a TxCommitter. After the commit step, transactionalHandle(db) is therefore false on that statement.
|
||||
- InstanceSet/InstanceGet key by fmt.Sprintf("%p", db.Statement)+key. A statement reused across writes on a chained handle (clone == 0, for example g.WithContext(ctx).Table("t")) keeps its Settings between writes.
|
||||
|
||||
modules/lagoon/transaction_test.go
|
||||
- TestTransactionAfterCommit(t): db, _ := dedicatedDB(t, "lagoon_after_commit"); gdb, _ := Use(ctx, db). Table lagoon_ac_items (id SERIAL, name TEXT UNIQUE), model acItem{Name}. Also recorder{add, names} and committedCount(t, db, name).
|
||||
- The implicit_single_statement subtest shows the pattern: a separate handle g2, _ := Use(ctx, db), then g2.Callback().Create().Before("gorm:create").Register("lagoon_test:after_commit", ...), whose callback calls AfterCommit(stmt.Statement.Context, stmt, ...).
|
||||
|
||||
modules/lagoon/example_test.go: region `docs:start on-database` (line ~587) holds the correct chain After("gorm:create").Before("gorm:commit_or_rollback_transaction") and is run by TestDocsOnDatabase.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Pin the script-chunk PNG exploit through attach.Store and correct the uploads deploy note</name>
|
||||
<files>modules/lagoon/attach/store_test.go, docs/services/storage.md, docs/database/attachments.md</files>
|
||||
<read_first>
|
||||
- modules/lagoon/attach/diskext.go (the helper; do not change it)
|
||||
- modules/lagoon/attach/store.go lines 95-200 (Store, writeBlob, Limits defaults)
|
||||
- modules/lagoon/attach/guard.go (IsAllowedImage)
|
||||
- modules/lagoon/attach/store_test.go lines 1-60 and 286-420 (imports, smokePNG, attachGorm, TestStoreDiskExtensionSmoke, TestStoreDiskExtension)
|
||||
- docs/services/storage.md lines 96-126 (built-in route, deploy note)
|
||||
- docs/database/attachments.md lines 92-100 (disk-name paragraph)
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf lines 209-231 (read-only: the tested nginx pattern. Copy its shape only; framework docs never name a consuming app)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- pngWithScriptText(t) returns a PNG that image/png decodes and attach.IsAllowedImage accepts, whose bytes contain the literal script tag in a tEXt chunk.
|
||||
- http.DetectContentType on it gives image/png, and attach.DiskExtension(that type, "html") gives "png" (no Docker).
|
||||
- attach.Store in image mode with Limits{Image: true, Extensions: png and html} and FileName "x.html" returns a File with DiskName ending ".png", URL() ending ".png", FileName "x.html" and ContentType "image/png". Reading back attach.BlobKey(DiskName) from the bucket returns exactly the uploaded bytes (Postgres, skips under -short).
|
||||
</behavior>
|
||||
<action>
|
||||
Part A, the test. Per the stored-XSS todo's acceptance and 19.3 D-15, and with no production code change: the framework fix exists in diskext.go/store.go and stays as it is.
|
||||
|
||||
In modules/lagoon/attach/store_test.go (package attach_test), add a helper pngWithScriptText(t *testing.T) []byte. It starts from smokePNG(t). It splits after the 8-byte signature plus the IHDR chunk; the IHDR chunk is 25 bytes (4 length, 4 type, 13 data, 4 CRC), so the split is at offset 33. Assert that bytes 12..16 equal "IHDR" and t.Fatal otherwise. At the split, insert a tEXt chunk:
|
||||
- a 4-byte big-endian data length (encoding/binary),
|
||||
- the type "tEXt",
|
||||
- the data: keyword "Comment", a NUL byte, then the text `<script>alert(document.cookie)</script>`,
|
||||
- a 4-byte big-endian CRC computed by hash/crc32.ChecksumIEEE over type plus data.
|
||||
Add encoding/binary, hash/crc32 and net/http to the imports.
|
||||
|
||||
Add TestPNGScriptChunkNaming. It is Docker-free and must not skip under -short. It checks:
|
||||
- png.Decode of the fixture succeeds,
|
||||
- attach.IsAllowedImage(fixture) is true,
|
||||
- the fixture contains the script literal (bytes.Contains),
|
||||
- http.DetectContentType(fixture) is "image/png",
|
||||
- attach.DiskExtension of that type with client extension "html" is "png".
|
||||
|
||||
Add TestStoreScriptChunkPNG, modelled on TestStoreDiskExtensionSmoke. It skips under testing.Short() with the same message, and sets up attachGorm, lagoon.Migrate(gdb, nil) and memblob.OpenBucket(nil) with t.Cleanup Close. It calls attach.Store with Upload{FileName: "x.html", Body: bytes.NewReader(fixture), Public: true} and Limits{Image: true, Extensions: []string{"png", "html"}}. html is allowed on purpose, so the extension check is not what stops the attack. Then it asserts:
|
||||
- the DiskName and URL() suffixes are ".png",
|
||||
- FileName is "x.html" and ContentType is "image/png",
|
||||
- bucket.ReadAll(ctx, attach.BlobKey(f.DiskName)) equals the fixture byte for byte. The payload is stored as uploaded; only its name makes it harmless.
|
||||
Doc-comment both tests, naming the todo scenario (a write-scoped client uploads a valid image under an .html name).
|
||||
|
||||
Part B, the docs. Per the todo's defence-in-depth item and the 19.3 deferred-items note.
|
||||
|
||||
In docs/services/storage.md, rewrite the "Deploy note" blockquote at the end of the built-in route section as a short subsection or blockquote with two cases. Keep the existing sentence that attach.Store names each file after its sniffed content through attach.DiskExtension, so a PNG uploaded as x.html is stored as .png.
|
||||
|
||||
(1) A static server such as nginx serving the uploads directory. It picks the Content-Type from the extension and sends none of the route's headers. Give a neutral nginx example in a fenced nginx block with these parts:
|
||||
- a server-level `add_header X-Content-Type-Options nosniff always;`, with a comment that an add_header inside a location replaces every add_header inherited from the server level,
|
||||
- a case-insensitive regex location `~* ^/storage/app/uploads/public/.+\.(?:jpe?g|png|gif|webp)$` with `root /srv/acme`, its own `types` block that maps only those extensions (image/jpeg jpg jpeg, image/png png, image/gif gif, image/webp webp), and `default_type application/octet-stream`,
|
||||
- a plain prefix `location /storage/` that answers `return 404;`, with a comment that it must stay a plain prefix, not `^~`, so that nginx tries the regex first.
|
||||
Say in prose: extend the extension list only with types the application accepts (see attach.DefaultFileExtensions), and serve anything that is not a raster image as a download (Content-Disposition attachment) or from a separate origin, the way the built-in route does.
|
||||
|
||||
(2) 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 Content-Type stored with the object, which attach.Store sets to the sniffed type. So an HTML body uploaded as note.txt (txt is in attach.DefaultFileExtensions) is stored as `<hex>.txt` but served as text/html. Serve such a bucket from its own origin, never the application's, and set X-Content-Type-Options nosniff and, for everything but raster images, Content-Disposition attachment through the bucket's or CDN's response-header settings.
|
||||
|
||||
Use only identifiers that exist: attach.Store, attach.DiskExtension, attach.DefaultFileExtensions, storage.uploads.public_url. The docs checker verifies the identifiers. It does not verify config keys, so check those by hand against the configuration rows already on the page.
|
||||
|
||||
In docs/database/attachments.md, line ~98, make the first sentence's reason precise: a static web server in front of the uploads directory picks the Content-Type from the extension. Add a short pointer that a public bucket sends the stored type instead, linking to the storage page's deploy note anchor. Use the anchor that `go run ./cmd/summer docs:build --check` accepts.
|
||||
|
||||
Commit only these three paths, staged by explicit path. Suggested message: "docs(storage): pin the script-chunk PNG upload and split the uploads deploy note (quick-261028-r16)". No co-author tag.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/lagoon/... && go test -count=1 -short -run 'TestPNGScriptChunkNaming|TestDiskExtensionTable' -v ./modules/lagoon/attach 2>&1 | grep -q -- '^--- PASS: TestPNGScriptChunkNaming' && go test -count=1 -run 'TestStoreScriptChunkPNG|TestStoreDiskExtension' ./modules/lagoon/attach && go test -short ./... && go test ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'func TestStoreScriptChunkPNG\|func TestPNGScriptChunkNaming\|func pngWithScriptText' modules/lagoon/attach/store_test.go` prints 3.
|
||||
- The non-short run of TestStoreScriptChunkPNG reports PASS, not SKIP. If the Docker socket is denied, rerun through `sudo -u golem` as CLAUDE.md describes, and record in SUMMARY which way it ran.
|
||||
- `grep -c 'nosniff always' docs/services/storage.md` is at least 1 and `grep -c 'Content-Type stored with the object' docs/services/storage.md` is at least 1 (or equivalent wording the executor records in SUMMARY).
|
||||
- With SHA1 set to this task's own commit (from `git log -1 --format=%H` right after committing, before anything else lands), `git show --name-only --format= "$SHA1"` exits 0 and lists store_test.go as the only file under modules/lagoon/attach/: no production attach code changed.
|
||||
- No consuming application name appears in the new docs text (grep the diff for fonoteka, grzyby, plytarium: 0 hits).
|
||||
</acceptance_criteria>
|
||||
<done>One commit. The exact todo exploit (a decodable PNG with a script tEXt chunk, uploaded in image mode as x.html) is pinned with and without Docker. The storage deploy note correctly separates static-server and public-bucket serving, with a working neutral nginx allow-list. attachments.md agrees with it. go vet, go test -short ./... and the docs checker are green.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Warn on after-commit work registered after lagoon:after_commit; correct the OnDatabase example and the RegisterJoinTable read guidance</name>
|
||||
<files>modules/lagoon/transaction.go, modules/lagoon/transaction_test.go, modules/lagoon/relations.go, modules/lagoon/README.md, docs/database/transactions.md</files>
|
||||
<read_first>
|
||||
- modules/lagoon/transaction.go (whole file, about 260 lines)
|
||||
- modules/lagoon/transaction_test.go lines 1-60 and 195-260 (harness, implicit_single_statement, plain_gorm_transaction_is_refused log capture)
|
||||
- modules/lagoon/relations.go lines 1-28
|
||||
- modules/lagoon/README.md lines 25-40, 80-105, 125-135 and 190-196
|
||||
- docs/database/transactions.md lines 66-92
|
||||
- docs/database/relations.md lines 20-56 (already-correct pivot-order guidance to mirror)
|
||||
- modules/lagoon/example_test.go lines 580-625 (on-database docs region and TestDocsOnDatabase)
|
||||
- $(go list -m -f '{{.Dir}}' gorm.io/gorm)/callbacks/transaction.go (ConnPool reset after commit)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- late_callback_warns: on a fresh handle from Use(ctx, db), a Create callback registered After(AfterCommitCallback) calls AfterCommit while creating acItem "flush-late". The row is committed (committedCount 1), the recorded work never runs (recorder empty, the same as before this change), Create returns no error, and the captured Warn log contains "registered after lagoon:after_commit ran".
|
||||
- callback_between_commit_and_flush_runs: a callback registered After("gorm:commit_or_rollback_transaction").Before(AfterCommitCallback), creating "flush-between", has its work run exactly once ([flush-between]) and logs no warning.
|
||||
- reused_statement_no_false_warning: a correctly ordered callback (Before("gorm:create")) on q := g.WithContext(ctx).Table("lagoon_ac_items"), creating "flush-reuse-1" and then "flush-reuse-2" through the same q, records [flush-reuse-1 flush-reuse-2] and logs no warning. This pins the transactionalHandle guard against the marker left behind by the first write.
|
||||
- All existing TestTransactionAfterCommit subtests and TestDocsOnDatabase stay green unchanged.
|
||||
</behavior>
|
||||
<action>
|
||||
Code. Per the after-commit todo's suggested fix. The change is log-only and adds no exported identifiers.
|
||||
|
||||
In modules/lagoon/transaction.go:
|
||||
- Add an unexported const statementFlushedKey = "lagoon:after_commit_flushed" next to statementBufferKey.
|
||||
- In flushStatementAfterCommit, make the first statement db.InstanceSet(statementFlushedKey, true), before the buffer InstanceGet. Every write that passes through the flush then marks its statement. Extend the function's doc comment to say so.
|
||||
- In AfterCommit's branch for a GORM-started transaction (gorm:started_transaction is set), before the buffering code, check two things: db.InstanceGet(statementFlushedKey) is present, and transactionalHandle(db) is false. Both together mean GORM's commit step already reset the statement's ConnPool to the pool and lagoon:after_commit already ran for this write. In that case call moduleLog.Warn with exactly the message "lagoon: after-commit callback registered after lagoon:after_commit ran; it will not run" and the key/value pair "hint", "register the GORM callback before gorm:commit_or_rollback_transaction".
|
||||
- Then fall through to the existing buffering, unchanged. Do not return early and do not run the work. That keeps behaviour identical in every case, including the rare case of a callback sorted between commit and flush on a statement reused from an earlier write.
|
||||
- Add a sentence to AfterCommit's doc comment. Called from a GORM callback that runs after AfterCommitCallback on the same write, the work is registered too late and never runs, and AfterCommit logs a warning. Register such callbacks Before("gorm:commit_or_rollback_transaction"). The transactionalHandle check keeps a statement reused across writes from warning for a correctly ordered callback in a later write.
|
||||
|
||||
In modules/lagoon/transaction_test.go, add the three subtests from <behavior> inside TestTransactionAfterCommit, after implicit_single_statement:
|
||||
- Each uses its own handle from Use(ctx, db) so its callback stays off the shared gdb.
|
||||
- Callback names: "lagoon_test:late", "lagoon_test:between", "lagoon_test:reuse".
|
||||
- Row names: "flush-late", "flush-between", "flush-reuse-1", "flush-reuse-2".
|
||||
- Capture logs exactly like plain_gorm_transaction_is_refused: swap slog.SetDefault to a TextHandler on a bytes.Buffer at LevelWarn and restore it with defer.
|
||||
- Assert with strings.Contains on the substring "registered after lagoon:after_commit ran".
|
||||
- If GORM itself rejects the second Create through the shared chained q (an error from GORM, not from lagoon), keep the other two subtests. Replace the reuse case with the closest GORM-supported reuse of one Statement, or drop it, and record the deviation and the reason in SUMMARY.
|
||||
|
||||
Texts.
|
||||
- modules/lagoon/README.md, OnDatabase example (about line 89): change the registration chain to the one in example_test.go's on-database region, After("gorm:create").Before("gorm:commit_or_rollback_transaction"). The example currently chains off GORM's after-create callback.
|
||||
- README, the lead-in sentence before that example (about line 84): add one sentence. Register the callback before GORM's gorm:commit_or_rollback_transaction step: lagoon runs a single-statement write's after-commit work from lagoon:after_commit right after that step, so work buffered by a callback GORM sorts later never runs, and lagoon.AfterCommit logs a warning for it. Link it to ../../docs/database/transactions.md#callbacks-registered-at-boot if the docs checker accepts README links; otherwise leave the link out.
|
||||
- README, features line 29 (after-commit work): add that a callback which runs after lagoon:after_commit on the same write registers its work too late, the work never runs, and lagoon.AfterCommit logs a warning.
|
||||
- README, API row for lagoon.RegisterJoinTable (about line 194): append that pivot-ordered reads join the pivot table explicitly, because a Preload of the field cannot be ordered by a pivot column.
|
||||
- modules/lagoon/relations.go, RegisterJoinTable doc comment: replace the final sentence, which currently advises a Preload of the field plus an Order on the pivot's columns. The new text says the following. Reads in pivot order join the pivot table explicitly (Joins on the join table plus Order on its columns). A Preload of the field still loads the targets through the pivot model once RegisterJoinTable has run, but GORM issues that query without the join table, so ordering it by a pivot column fails with a missing FROM-clause entry. Keep the pivot-write contract paragraph as it is.
|
||||
- docs/database/transactions.md, WARNING block (about line 86): keep its first sentence. Change the end of the second sentence so that it says the buffered work is never run, the write still succeeds, and lagoon logs a warning when that happens.
|
||||
|
||||
Commit only these five paths, staged by explicit path. Suggested message: "fix(lagoon): warn on after-commit work registered after lagoon:after_commit; correct the OnDatabase example and pivot read guidance (quick-261028-r16)". No co-author tag. Leave other sessions' STATE.md and state.json edits alone.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/lagoon/... && go test -count=1 -run 'TestTransactionAfterCommit|TestDocsOnDatabase' -v ./modules/lagoon/ 2>&1 | grep -q -- '--- PASS: TestTransactionAfterCommit/late_callback_warns' && go test -count=1 -run 'TestTransactionAfterCommit|TestDocsOnDatabase' ./modules/lagoon/ && go test -short ./... && go test ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'gorm:after_create' modules/lagoon/README.md` prints 0.
|
||||
- `grep -c 'After("gorm:create").Before("gorm:commit_or_rollback_transaction")' modules/lagoon/README.md` prints at least 1.
|
||||
- `grep -c 'Preload(field)' modules/lagoon/relations.go` prints 0, and `grep -c -i 'join' modules/lagoon/relations.go` is at least 2.
|
||||
- `grep -c 'statementFlushedKey' modules/lagoon/transaction.go` is at least 3 (declaration, set, read).
|
||||
- `grep -c 'registered after lagoon:after_commit ran' modules/lagoon/transaction.go modules/lagoon/transaction_test.go` shows at least 1 in each file.
|
||||
- The non-short run reports PASS for late_callback_warns, callback_between_commit_and_flush_runs and reused_statement_no_false_warning, unless the reuse case was replaced as described, and for every existing TestTransactionAfterCommit subtest and TestDocsOnDatabase. If the Docker socket is denied, rerun through `sudo -u golem`.
|
||||
- No exported identifier is added or removed. Set SHA2 to this task's own commit (`git log -1 --format=%H` right after committing). Write `git show "$SHA2" -- modules/lagoon/` to a file in the session scratchpad and check that the command exited 0. Then `grep -cE '^[+-](func|type|var|const) [A-Z]'` on that file prints 0.
|
||||
</acceptance_criteria>
|
||||
<done>One commit. lagoon.AfterCommit warns, and only warns, when a callback that runs after lagoon:after_commit on the same write registers work; correct orderings and reused statements stay silent. The README example matches the tested docs region. The RegisterJoinTable comment, the README row and the transactions WARNING tell the truth. go vet, go test -short ./..., the Docker lagoon tests and the docs checker are green.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| client upload → attach.Store | The client's file name and bytes are untrusted. The name's extension is checked against the allow-list, but it must not decide how the stored file is served |
|
||||
| bucket/static server → browser | Whoever serves the uploads picks the Content-Type: nginx picks it from the extension, S3 sends the stored object's metadata. A rendering type on a shared origin is stored XSS |
|
||||
| plugin GORM callback → lagoon after-commit buffer | Plugin code decides callback order. Misordered callbacks lose side effects (mail, jobs, broadcasts) without any trace |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-q261028r16-01 | Elevation of privilege | attach.Store disk name (valid PNG with script uploaded as x.html) | high | mitigate | Already mitigated by attach.DiskExtension in Store (19.3, 2e22e9f). Task 1 adds TestPNGScriptChunkNaming (no Docker) and TestStoreScriptChunkPNG (image mode, html allowed), which pin the exact todo exploit: the .png disk name, the .png URL and the bytes stored unchanged |
|
||||
| T-q261028r16-02 | Elevation of privilege | Public S3 bucket or CDN serving the stored text/html metadata of a .txt upload | medium | transfer | Task 1 corrects the deploy note: serve a public bucket from its own origin, with nosniff and attachment disposition for non-images set through the bucket or CDN header settings. Changing Store's blob metadata changes behaviour, so it is deferred to its own todo (recorded by the orchestrator) |
|
||||
| T-q261028r16-03 | Tampering | Static server serving the uploads directory by extension | medium | mitigate | Task 1 documents a neutral nginx pattern: server-level nosniff, a regex allow-list location with its own types map and default_type application/octet-stream, and a plain prefix 404 for everything else |
|
||||
| T-q261028r16-04 | Repudiation | lagoon.AfterCommit buffering work after lagoon:after_commit already ran | medium | mitigate | Task 2 logs a warning with a fix hint. The README example and the transactions docs register before gorm:commit_or_rollback_transaction |
|
||||
| T-q261028r16-05 | Denial of service | Warning noise or false positives on hot write paths | low | mitigate | The warning needs both the flushed marker and the statement's ConnPool no longer being a TxCommitter. reused_statement_no_false_warning and callback_between_commit_and_flush_runs pin the silent cases. Cost per write: one sync.Map store |
|
||||
| T-q261028r16-SC | Tampering | npm/pip/cargo installs | low | accept | No new dependencies. Only the stdlib packages encoding/binary, hash/crc32 and net/http are added to a test file |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- After each of the two commits: go vet ./..., go test -short ./..., go test ./cmd/summer -run TestDocsTree and go run ./cmd/summer docs:build --check are green (CLAUDE.md rule).
|
||||
- Docker-backed: go test -count=1 -run 'TestStoreScriptChunkPNG|TestStoreDiskExtension' ./modules/lagoon/attach and go test -count=1 -run 'TestTransactionAfterCommit|TestDocsOnDatabase' ./modules/lagoon/ pass (fall back to sudo -u golem when the socket is denied).
|
||||
- No exported lagoon or attach identifier changes. No file outside summercms.go is edited; the sibling repos were only read at planning time.
|
||||
- git log shows two commits without co-author tags, each staging only its own paths. Other sessions' STATE.md, state.json, G15OFFICE.md and .gitkeep files are left alone.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- The stored-XSS todo's acceptance holds in the framework with a test of the exact exploit. The deploy docs give correct defence-in-depth advice for both static servers and public buckets.
|
||||
- The after-commit todo's three items are done: README example, RegisterJoinTable comment and README line, and the AfterCommit warning, which is log-only and adds no API.
|
||||
- The SUMMARY lists the follow-ups for the orchestrator: the fonoteka.go storeUpload (collection_media_controller.go:251) and StorePublicFile (album_files.go:35) adoption; the optional dedup in sm-feedback-plugin and sm-user-plugin; the deferred Store blob-metadata hardening for public buckets.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/261028-r16-fix-the-two-lagoon-todos-derive-stored-u/261028-r16-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
phase: quick-261028-r16
|
||||
plan: 01
|
||||
subsystem: lagoon
|
||||
tags: [attach, uploads, stored-xss, docs, after-commit, gorm-callbacks]
|
||||
status: complete
|
||||
requires: []
|
||||
provides:
|
||||
- "TestPNGScriptChunkNaming / TestStoreScriptChunkPNG regression for the script-chunk PNG upload"
|
||||
- "Storage deploy note split into static-server and public-bucket cases"
|
||||
- "lagoon.AfterCommit warning for work registered after lagoon:after_commit"
|
||||
affects: [lagoon, lagoon/attach, docs/services/storage.md, docs/database]
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: ["statement-scoped marker via InstanceSet read back in AfterCommit, guarded by transactionalHandle"]
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- modules/lagoon/attach/store_test.go
|
||||
- docs/services/storage.md
|
||||
- docs/database/attachments.md
|
||||
- modules/lagoon/transaction.go
|
||||
- modules/lagoon/transaction_test.go
|
||||
- modules/lagoon/relations.go
|
||||
- modules/lagoon/README.md
|
||||
- docs/database/transactions.md
|
||||
decisions:
|
||||
- "No production attach change: attach.DiskExtension (19.3, 2e22e9f) stays the single helper; the exploit is pinned by tests only"
|
||||
- "AfterCommit late-registration check is log-only and falls through to the existing buffering; no exported identifier added"
|
||||
- "Deploy note became an H3 subsection (#deploy-note) so attachments.md can link to it"
|
||||
metrics:
|
||||
duration: ~20min
|
||||
completed: 2026-10-28
|
||||
actuals:
|
||||
tokens: 7000
|
||||
tasks: 2
|
||||
commits: 2
|
||||
plan_head_before: 6ea2cb70e04e731404ad00d78a4a82dc8b50d5a7
|
||||
plan_head_after: 36092f542c55ba7bf5c4fdb51a0151305c49d85a
|
||||
---
|
||||
|
||||
# Quick 261028-r16 Plan 01: Fix the two lagoon todos Summary
|
||||
|
||||
The todo's stored-XSS exploit now has a test: a decodable PNG with a `<script>` tEXt chunk, uploaded in image mode as `x.html`, is stored as `<hex>.png` with its bytes unchanged. The uploads deploy note now tells static servers (they pick the type from the extension) apart from public buckets (they send the sniffed type stored with the object). `lagoon.AfterCommit` now logs a warning when a GORM callback registers its work after `lagoon:after_commit` has already run.
|
||||
|
||||
## Tasks
|
||||
|
||||
| Task | Name | Commit | Files |
|
||||
| ---- | ---- | ------ | ----- |
|
||||
| 1 | Pin the script-chunk PNG exploit through attach.Store and correct the uploads deploy note | ebdfd78 | modules/lagoon/attach/store_test.go, docs/services/storage.md, docs/database/attachments.md |
|
||||
| 2 | Warn on after-commit work registered after lagoon:after_commit; correct the OnDatabase example and RegisterJoinTable read guidance | 36092f5 | modules/lagoon/transaction.go, modules/lagoon/transaction_test.go, modules/lagoon/relations.go, modules/lagoon/README.md, docs/database/transactions.md |
|
||||
|
||||
## What was done
|
||||
|
||||
**Task 1.**
|
||||
- New `pngWithScriptText` fixture: smokePNG with a tEXt chunk (`Comment\0<script>alert(document.cookie)</script>`, correct CRC) inserted after IHDR at offset 33.
|
||||
- `TestPNGScriptChunkNaming` (no Docker) checks that the fixture decodes, passes `IsAllowedImage`, contains the script, sniffs as `image/png`, and that `DiskExtension(…, "html")` gives `png`.
|
||||
- `TestStoreScriptChunkPNG` (Postgres) runs the upload through `attach.Store` with `Limits{Image: true, Extensions: [png, html]}`. It asserts a `.png` DiskName and URL, FileName `x.html`, ContentType `image/png`, and blob bytes identical to the upload.
|
||||
- In `docs/services/storage.md`, the old one-paragraph note is now a `### Deploy note` subsection covering both cases:
|
||||
- Static server: a neutral nginx example with server-level `add_header X-Content-Type-Options nosniff always;`, a regex image allow-list location with its own `types` and `default_type application/octet-stream`, and a plain-prefix `location /storage/ { return 404; }`.
|
||||
- Public S3 bucket or CDN: it sends "the Content-Type stored with the object", which is the sniffed type. Advice: use a separate origin, and set nosniff and attachment disposition through the bucket's or CDN's response-header settings.
|
||||
- In `attachments.md`, the reason is now precise (static servers pick the type by extension) and it links to `../services/storage.md#deploy-note`.
|
||||
|
||||
**Task 2.**
|
||||
- `statementFlushedKey` (unexported) is set first thing in `flushStatementAfterCommit`.
|
||||
- In the GORM-started-transaction branch, `AfterCommit` now warns (`lagoon: after-commit callback registered after lagoon:after_commit ran; it will not run`, hint `register the GORM callback before gorm:commit_or_rollback_transaction`) when the marker is present and `!transactionalHandle(db)`. It then buffers exactly as before.
|
||||
- New subtests: `late_callback_warns`, `callback_between_commit_and_flush_runs` and `reused_statement_no_false_warning`.
|
||||
- A mutation check (dropping the `!transactionalHandle` guard) makes `reused_statement_no_false_warning` fail, so that subtest really pins the guard. The guard was restored before committing.
|
||||
- README: the OnDatabase example now uses `After("gorm:create").Before("gorm:commit_or_rollback_transaction")`, and its lead-in explains why and links to the transactions page. The after-commit feature line and the `RegisterJoinTable` API row were updated.
|
||||
- `relations.go`: pivot-ordered reads use an explicit join on the pivot table. A Preload cannot be ordered by a pivot column.
|
||||
- `transactions.md` WARNING: the write still succeeds, and lagoon logs a warning.
|
||||
|
||||
## Verification
|
||||
|
||||
- After each commit: `go vet ./...`, `go test -short ./...`, `go test ./cmd/summer -run TestDocsTree` and `go run ./cmd/summer docs:build --check` all green.
|
||||
- Docker-backed tests ran directly; the socket was accessible, so `sudo -u golem` was not needed:
|
||||
- `TestStoreScriptChunkPNG`, `TestStoreDiskExtensionSmoke` and `TestStoreDiskExtension`: PASS.
|
||||
- `TestTransactionAfterCommit` (all 12 subtests, including the 3 new ones) and `TestDocsOnDatabase`: PASS.
|
||||
- Acceptance greps:
|
||||
- store_test.go defines the 3 new functions.
|
||||
- storage.md contains `nosniff always` and `Content-Type stored with the object`.
|
||||
- lagoon README: `gorm:after_create` has 0 hits, the correct chain has 1.
|
||||
- relations.go: `Preload(field)` has 0 hits, `join` has 9.
|
||||
- `statementFlushedKey` appears 4 times in transaction.go.
|
||||
- The warning text appears in both transaction.go and transaction_test.go.
|
||||
- Commit contents:
|
||||
- Task 1 changed only `store_test.go` under `modules/lagoon/attach/`.
|
||||
- In the Task 2 diff, `grep -cE '^[+-](func|type|var|const) [A-Z]'` gives 0, so no exported identifier changed.
|
||||
- No consuming-app names appear in the diffs.
|
||||
- Neither commit has a co-author line.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None. The plan was executed as written. The reuse subtest worked through a shared chained `q := g.WithContext(ctx).Table("lagoon_ac_items")`, so no replacement was needed.
|
||||
|
||||
One transient environment note: the first full `go vet ./...` failed once in `internal/build` (`could not import go/version`) while another session had an uncommitted edit in flight in `internal/build/scaffold.go`. A rerun passed, and that file was not touched. `plan_head_before` (6ea2cb7) is a commit from that other session that landed after the 8c9c967 snapshot.
|
||||
|
||||
## Follow-ups (for the orchestrator; sibling repos not edited)
|
||||
|
||||
1. **fonoteka.go adoption of attach.DiskExtension.** Two call sites still append `filepath.Ext(<client name>)` to the disk name:
|
||||
- `../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collection_media_controller.go:251` (`storeUpload`)
|
||||
- `../fonoteka.go/plugins/golem15/fonoteka/classes/album_files.go:35` (`StorePublicFile`)
|
||||
|
||||
The fix is one line per site: sniff first, then use `attach.DiskExtension(contentType, filepath.Ext(name))`. The function already strips the dot, lower-cases the extension and drops MIME parameters. After the change, an upload without an extension gets `.bin`/`.txt` instead of no extension.
|
||||
2. **Optional dedup only (already safe):** `sm-feedback-plugin` `screenshotExtension` (classes/submission_store.go) and `sm-user-plugin` `avatarExt` (controllers/api_controller.go:1497) could use `attach.DiskExtension`.
|
||||
3. **Deferred behaviour change, needs its own todo:** `attach.Store` writes the sniffed type as blob metadata, so a public S3 bucket serves an HTML body stored as `.txt` as `text/html`. The fix would write a non-rendering Content-Type, or `Content-Disposition: attachment`, for non-inline types. That changes Store's output, so this plan only documents the risk in the deploy note.
|
||||
4. Move both todos (`2026-10-11-uploads-keep-client-extension-stored-xss.md`, `lagoon-readme-after-commit-callback-order.md`) to `.planning/todos/completed/` in the closing docs commit.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. No new endpoints or trust-boundary surface. The only new runtime behaviour is one log line.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: modules/lagoon/attach/store_test.go (TestStoreScriptChunkPNG, TestPNGScriptChunkNaming, pngWithScriptText)
|
||||
- FOUND: modules/lagoon/transaction.go (statementFlushedKey)
|
||||
- FOUND: commit ebdfd78
|
||||
- FOUND: commit 36092f5
|
||||
@@ -0,0 +1,312 @@
|
||||
---
|
||||
phase: quick-261028-rqb
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- modules/boardwalk/boardwalk.go
|
||||
- modules/boardwalk/boardwalk_test.go
|
||||
- modules/boardwalk/README.md
|
||||
- modules/boardwalk/dist/index.html
|
||||
- modules/boardwalk/dist/assets/index-CoBJlG5O.js
|
||||
- modules/boardwalk/dist/assets/index-DoVyFFyY.css
|
||||
- modules/boardwalk/dist/assets/editor-lelm3tpS.js
|
||||
- docs/backend/admin-spa.md
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- admin/index.html
|
||||
- admin/src/app/runtime.ts
|
||||
- admin/src/app/buildUpdate.ts
|
||||
- admin/src/app/chunkReload.ts
|
||||
- admin/src/components/ui/UpdateBanner.vue
|
||||
- admin/src/App.vue
|
||||
- admin/src/main.ts
|
||||
- admin/tests/app/runtime.test.ts
|
||||
- admin/tests/app/buildUpdate.test.ts
|
||||
- admin/tests/app/chunkReload.test.ts
|
||||
- admin/tests/ui/UpdateBanner.test.ts
|
||||
- admin/tests/fixtures/lang.json
|
||||
files_deleted:
|
||||
- modules/boardwalk/dist/assets/index-BNWI_4R0.js
|
||||
- modules/boardwalk/dist/assets/index-Bakgr9jB.css
|
||||
- modules/boardwalk/dist/assets/editor-CxeFI7rG.js
|
||||
autonomous: true
|
||||
requirements:
|
||||
- todo:2026-10-11-reload-open-apps-when-a-new-version-is-deployed
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "boardwalk computes one build id per handler, once at mount: boardwalk.BuildID(root, info) is the first 12 lowercase hex characters of one sha256 over the dist tree (every regular file's path and contents in fs.WalkDir order) followed by the binary's build identity from runtime/debug.ReadBuildInfo. The identity is the Go version, the main module path, version and sum, the vcs.revision and vcs.modified settings when present, and every dependency's path, version and sum (and its replacement's) sorted by path. With info nil (no build info) the id is the dist hash alone"
|
||||
- "The id changes when any dist file changes, is added or is renamed, and also on a redeploy of a different binary: a new vcs.revision, a change of vcs.modified, a main module version or sum change, a toolchain change, or a dependency version or sum bump (a plugin version bump included). The same tree with the same info always gives the same id"
|
||||
- "Every response the boardwalk handler writes (index.html, assets, client routes, 404s, build.json and the api/ misses it hands to notFoundAPI) carries the X-Summer-Build header (boardwalk.BuildHeader) set to that id"
|
||||
- "GET {prefix}/build.json answers 200 with Content-Type application/json, Cache-Control no-store and the body {\"build\":\"<id>\"}"
|
||||
- "The served index.html carries <meta name=\"summer-admin-build\" content=\"<id>\">: the handler replaces boardwalk.BuildToken (__SUMMER_ADMIN_BUILD__) in admin/index.html. A dist without that token still boots and is served unchanged, and the SPA then turns the check off"
|
||||
- "The admin SPA reads the build it booted with from that meta (runtime.build; empty when the meta is missing, unreplaced or not 6-64 lowercase hex). When it is non-empty, it fetches build.json on visibilitychange to visible, on window focus and every 5 minutes while visible, at most once per 30 s with one request at a time. A different build shows a non-blocking banner, UpdateBanner, with Reload and Close buttons. It never reloads by itself, and a dirty form's beforeunload prompt still guards Reload. Close hides the banner until a build different from the dismissed one is seen"
|
||||
- "A vite:preloadError (a failed dynamic import or chunk preload) reloads the page once. A sessionStorage timestamp under summer-admin.chunk-reload blocks a second automatic reload within 60 s, and storage that cannot be read or written means no reload. The guard never calls preventDefault, so the importer's own catch path still runs"
|
||||
- "The new strings backend::lang.version.available and backend::lang.version.reload resolve in pl and en (TestPhase10SPAKeysResolve stays green)"
|
||||
- "No exported boardwalk identifier is removed or changed; BuildID, BuildHeader, BuildToken and BuildPath are additions. modules/cabana is not modified"
|
||||
- "go vet ./..., go test -short ./..., go test ./cmd/summer -run TestDocsTree, go run ./cmd/summer docs:build --check, npm --prefix admin run typecheck, npm --prefix admin run test and scripts/check-admin-dist.sh are green after the final commit"
|
||||
artifacts:
|
||||
- path: modules/boardwalk/boardwalk.go
|
||||
provides: "BuildID(root fs.FS, info *debug.BuildInfo), BuildHeader, BuildToken, BuildPath; unexported readBuildInfo (overridable, as in bonfire); build field on handler computed once in newHandler; header on every response; build.json; meta token replacement"
|
||||
contains: "X-Summer-Build"
|
||||
- path: admin/src/app/buildUpdate.ts
|
||||
provides: "isNewBuild, fetchBuild, noteBuild, dismissNewBuild, resetBuildUpdate, newBuildAvailable, startBuildWatch, BUILD_CHECK_INTERVAL, BUILD_CHECK_MIN_GAP"
|
||||
contains: "startBuildWatch"
|
||||
- path: admin/src/app/chunkReload.ts
|
||||
provides: "CHUNK_RELOAD_KEY, CHUNK_RELOAD_WINDOW, shouldReload, installChunkReloadGuard"
|
||||
contains: "vite:preloadError"
|
||||
- path: admin/src/components/ui/UpdateBanner.vue
|
||||
provides: "Non-blocking reload banner bound to newBuildAvailable"
|
||||
contains: "backend::lang.version.available"
|
||||
- path: modules/boardwalk/README.md
|
||||
provides: "Features bullets and API reference rows for the build id, header, endpoint and token"
|
||||
contains: "boardwalk.BuildID"
|
||||
- path: docs/backend/admin-spa.md
|
||||
provides: "Serving bullets for X-Summer-Build and build.json; a New versions section for the banner and the chunk reload guard"
|
||||
contains: "build.json"
|
||||
- path: modules/boardwalk/dist/index.html
|
||||
provides: "Rebuilt embedded admin carrying the summer-admin-build meta token"
|
||||
contains: "__SUMMER_ADMIN_BUILD__"
|
||||
key_links:
|
||||
- from: admin/index.html summer-admin-build meta
|
||||
to: modules/boardwalk/boardwalk.go newHandler
|
||||
via: "bytes.ReplaceAll of BuildToken with the handler's build id after RewriteIndex"
|
||||
pattern: "BuildToken"
|
||||
- from: modules/boardwalk/boardwalk.go newHandler
|
||||
to: runtime/debug.ReadBuildInfo
|
||||
via: "BuildID(root, buildInfo()) where buildInfo calls the package var readBuildInfo and returns nil when ok is false"
|
||||
pattern: "readBuildInfo"
|
||||
- from: admin/src/app/runtime.ts readBuild
|
||||
to: admin/src/app/buildUpdate.ts startBuildWatch
|
||||
via: "startBuildWatch defaults booted to runtime.build"
|
||||
pattern: "runtime\\.build"
|
||||
- from: admin/src/app/buildUpdate.ts fetchBuild
|
||||
to: modules/boardwalk/boardwalk.go build.json route
|
||||
via: "globalThis.fetch(`${runtime.base}/build.json`, { cache: 'no-store' })"
|
||||
pattern: "build\\.json"
|
||||
- from: admin/src/main.ts boot
|
||||
to: admin/src/app/chunkReload.ts installChunkReloadGuard and admin/src/app/buildUpdate.ts startBuildWatch
|
||||
via: "guard installed before the first await; watch started after app.mount"
|
||||
pattern: "installChunkReloadGuard\\(|startBuildWatch\\("
|
||||
---
|
||||
|
||||
<objective>
|
||||
Close the admin half of the pending todo `.planning/todos/pending/2026-10-11-reload-open-apps-when-a-new-version-is-deployed.md` (g15office ltcvqext). The site half already shipped in vue-demo-app. A browser tab opened before a deploy keeps running the old admin bundle against the new backend, sometimes for days, because the SPA refreshes its JWT silently. On uat.grzybyfunkcjonalne.pl this showed "Unsupported field type: mlblogeditor". `index.html` is already `no-store` and the hashed assets are immutable, so HTTP caching is not the gap. The gap is that an open tab never learns a new build exists.
|
||||
|
||||
Scope, per the batch item:
|
||||
1. boardwalk exposes a build id derived from the embedded dist plus the binary's build identity. Every response it writes carries it in an `X-Summer-Build` header, `{prefix}/build.json` returns it, and it is written into index.html as a meta.
|
||||
2. The admin SPA records the build it booted with. It re-checks on focus, on visibility and on an interval, and on a mismatch shows a non-blocking reload banner.
|
||||
3. A reload-once guard on Vite chunk-load errors (`vite:preloadError` plus a sessionStorage flag).
|
||||
4. Unit tests, the boardwalk README and the docs, and a rebuilt `modules/boardwalk/dist` that passes `scripts/check-admin-dist.sh`.
|
||||
|
||||
Discretion choices, recorded here so the executor does not revisit them:
|
||||
- The id is the first 12 hex characters of one sha256 over the dist tree followed by the binary's build identity from `runtime/debug.ReadBuildInfo`. Twelve is the length cabana already uses for plugin asset `?v=` versions. The id must change on a redeploy of a different binary, not only on an admin bundle change; that is the orchestrator's decision on 2026-10-28, which closes the incident case of a new plugin field type with an unchanged dist. The identity is the Go version, the main module path, version and sum, `vcs.revision` and `vcs.modified` when present, and every dependency's path, version and sum (plus its replacement's), sorted. `summer build` runs a plain `go build -o <bin> .` in the app directory (`internal/build/build.go` goBuildArgs), so the default `-buildvcs=auto` stamps the vcs settings in deployed binaries. With no build info the id falls back to the dist hash alone. A spurious banner after an identical rebuild is acceptable.
|
||||
- Plugin admin asset trees (cabana's compiled controller and field type assets) are not part of the id. boardwalk is a leaf module that cannot see them without new exported cross-module API. Their changes still reach the id through the dependency versions (plugins in their own repos) and the app's `vcs.revision` (in-repo plugins and submodule pointer bumps).
|
||||
- The version check uses the endpoint. The header goes on the responses boardwalk itself writes, not on cabana's admin API handlers. That keeps `modules/cabana` untouched (items rqf and rqg in this batch edit cabana), and polling the endpoint covers the failure the todo describes: an idle tab.
|
||||
|
||||
Purpose: a stale admin tab tells the administrator to reload instead of failing on a schema it does not understand, and a missing hashed chunk after a deploy recovers by itself once.
|
||||
Output: four commits in summercms.go (feature, chunk guard, dist rebuild, tests) and `261028-rqb-SUMMARY.md`. Do not tag, push or post a changelog entry. The todo notes it needs a framework tag and a changelog post, and both wait for the user's go-ahead.
|
||||
</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-11-reload-open-apps-when-a-new-version-is-deployed.md
|
||||
@/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/boardwalk/boardwalk.go
|
||||
@/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/modules/boardwalk/README.md
|
||||
@/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/admin/src/main.ts
|
||||
@/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/admin/src/app/runtime.ts
|
||||
|
||||
Interfaces the executor builds against (verified by live reads on 2026-10-28):
|
||||
- `boardwalk.Handler(prefix string, notFoundAPI http.Handler) (http.Handler, error)` calls the unexported `newHandler(root fs.FS, prefix string, notFoundAPI http.Handler)`. Keep both signatures; the tests call `newHandler` with `fstest.MapFS` fixtures.
|
||||
- `modules/bonfire/version.go` is the pattern for reading build info: `var readBuildInfo = debug.ReadBuildInfo`, replaced in tests. boardwalk uses its own unexported var of the same name and shape.
|
||||
- `boardwalk.RewriteIndex(raw []byte, prefix string) ([]byte, error)` is exported and stays as it is. It errors when `BaseToken` (`__SUMMER_ADMIN_BASE__`) is missing. The new token `__SUMMER_ADMIN_BUILD__` does not contain `__SUMMER_ADMIN_BASE__`, so the two replacements never collide.
|
||||
- `handler.ServeHTTP` calls `SetSecurityHeaders(w.Header())` first, then hands `api` / `api/...` to notFoundAPI, then cleans the name and serves the index, a file or a 404.
|
||||
- cabana mounts the handler with `g.Get("", s.serveSPA)` and `g.Get("/{path...}", s.serveSPA)` under the admin prefix, public and unguarded (`modules/cabana/http.go` around line 456). So `{prefix}/build.json` reaches boardwalk with no cabana change.
|
||||
- SPA: `runtime` in `admin/src/app/runtime.ts` is `{ base, api } as const`, and `readBase(doc)` is the pattern for reading a meta. `page.reload()` in `admin/src/state/usePreferences.ts` is the reload indirection the tests stub with `vi.spyOn(page, 'reload')`. Toast styling (`bg-[#1d2740]`, `shadow-toast`, `bg-accent text-on-accent`, the `t('backend::lang.form.close')` close button) is in `admin/src/components/ui/Toast.vue`. FormView installs a `beforeunload` prompt for dirty forms (`admin/src/views/FormView.vue:615`). The only dynamic import is `import('../richeditor/editor')` in `admin/src/components/form/fields/RichEditorField.vue:94`, inside a try/catch that switches to source mode.
|
||||
- Strings come from `modules/phrasebook/backend/lang/{en,pl}/lang.yaml`. `TestPhase10SPAKeysResolve` (`modules/phrasebook/phase10_test.go:152`) scans `admin/src` for `backend::lang.*` keys and fails when one does not resolve in pl or en. The vitest pl bundle is the hand-kept `admin/tests/fixtures/lang.json`, which `resetState()` in `admin/tests/helpers.ts` loads.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: End-to-end build id, from the boardwalk meta, header and build.json to the SPA watch and the reload banner</name>
|
||||
<files>modules/boardwalk/boardwalk.go, modules/boardwalk/boardwalk_test.go, modules/boardwalk/README.md, docs/backend/admin-spa.md, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/index.html, admin/src/app/runtime.ts, admin/src/app/buildUpdate.ts, admin/src/components/ui/UpdateBanner.vue, admin/src/App.vue, admin/src/main.ts</files>
|
||||
<read_first>
|
||||
- modules/boardwalk/boardwalk.go (whole file: newHandler, RewriteIndex, ServeHTTP, serveIndex, SetSecurityHeaders)
|
||||
- modules/boardwalk/boardwalk_test.go lines 1-80 (apiSpy, get helper, newHandler with fstest.MapFS)
|
||||
- admin/src/app/runtime.ts and admin/tests/app/runtime.test.ts (meta reading pattern)
|
||||
- admin/src/components/ui/Toast.vue (styling and close button to mirror)
|
||||
- admin/src/state/usePreferences.ts lines 76-82 (page.reload)
|
||||
- docs/backend/admin-spa.md lines 11-24 (Serving section)
|
||||
</read_first>
|
||||
<action>
|
||||
Go side, in modules/boardwalk/boardwalk.go. Every addition is additive, and RewriteIndex, Handler, Dist, ContentType and SetSecurityHeaders keep their signatures.
|
||||
- Add exported constants with doc comments. `BuildHeader = "X-Summer-Build"` is the response header that carries the build id. `BuildToken = "__SUMMER_ADMIN_BUILD__"` is the token in dist/index.html's summer-admin-build meta that the handler replaces with the build id. `BuildPath = "build.json"` is the path under the prefix that answers the current build.
|
||||
- Add `func BuildID(root fs.FS, info *debug.BuildInfo) (string, error)`. It is a pure function; its doc comment names every input.
|
||||
- Dist part: it walks root with fs.WalkDir, which gives lexical order, and for each regular file writes the slash path, a NUL byte, the decimal length, a NUL byte and the contents into one sha256. Directories are skipped. The tree is hashed raw, before any prefix rewrite, so one build has one id at any backend URI.
|
||||
- Binary part, when info is not nil: write a NUL-separated marker (for example "buildinfo"), then one newline-terminated line per item. The items are the Go version (`go <GoVersion>`), the main module (`main <Path> <Version> <Sum>`), each of the settings `vcs.revision` and `vcs.modified` that is present (`setting <Key>=<Value>`, in that fixed key order), and one line per dependency (`dep <Path> <Version> <Sum>`, followed by ` => <Replace.Path> <Replace.Version> <Replace.Sum>` when Replace is not nil). Write the dependencies from a copy sorted by Path, so the order of info.Deps never matters. Leave out other settings (build flags, GOOS and so on): they do not identify a deploy any better and make ids noisier.
|
||||
- With info nil the binary part is skipped, so the id is the dist hash alone. That is the fallback when build info is unavailable.
|
||||
- It returns the first 12 characters of the lowercase hex digest; 12 matches cabana's plugin asset `version`. Walk and read errors are wrapped with a "boardwalk:" prefix.
|
||||
- Add `var readBuildInfo = debug.ReadBuildInfo` (unexported; tests replace it, as in modules/bonfire/version.go) and an unexported `buildInfo() *debug.BuildInfo` that returns nil when ok is false.
|
||||
- In newHandler, after RewriteIndex: compute `build, err := BuildID(root, buildInfo())` once (return the error), then `index = bytes.ReplaceAll(index, []byte(BuildToken), []byte(build))`. This is the "compute once at mount" point: cabana builds the handler once at activation. The id is hex, so it needs no escaping. A dist without BuildToken is not an error: the index is served without the id, and the SPA turns its check off. Do not make the token mandatory, because the in-memory fixtures in boardwalk_test.go carry only BaseToken. Store `build` in a new `build string` field on handler.
|
||||
- In ServeHTTP, right after SetSecurityHeaders, set `w.Header().Set(BuildHeader, h.build)`, so every response carries it, the api/ hand-off included. After the api/ branch and the name cleaning, add a branch before the index check: when `name == BuildPath`, call a new `serveBuild(w, r)` that sets `Content-Type: application/json`, sets `Cache-Control: no-store` and writes the JSON object with the single key build. Marshal a struct with encoding/json, or write the bytes directly, since the id is hex. The build route wins over a dist file with the same name, and the doc comment says so.
|
||||
- Update the package doc comment to mention the build id, the header and build.json.
|
||||
- Smoke test in boardwalk_test.go: `TestBuildSmoke` builds newHandler over an fstest.MapFS whose index.html carries both the summer-admin-base meta (BaseToken) and a `<meta name="summer-admin-build" content="__SUMMER_ADMIN_BUILD__" />`, plus one assets/ file. It asserts three things. The index body has the meta with content equal to `BuildID(root, buildInfo())`. GET {prefix}/build.json returns that id in the JSON body. The X-Summer-Build header on both responses equals it. Task 3 adds the full test set.
|
||||
|
||||
SPA side:
|
||||
- admin/index.html: add `<meta name="summer-admin-build" content="__SUMMER_ADMIN_BUILD__" />` right after the summer-admin-base meta. Vite keeps it as written. The dev server leaves it unreplaced, so the check is off under `npm run dev`. Leave vite.config.ts as it is.
|
||||
- admin/src/app/runtime.ts: add `BUILD_TOKEN = '__SUMMER_ADMIN_BUILD__'` and `export function readBuild(doc: Document = document): string`. It reads `meta[name="summer-admin-build"]`, trims the content and returns it only when it matches /^[0-9a-f]{6,64}$/; it returns '' when the meta is missing, empty, the unreplaced token or anything else. Add `build` to the `runtime` object with a doc comment.
|
||||
- New admin/src/app/buildUpdate.ts, with a header comment in the style of the other app/ files that explains the todo's failure mode:
|
||||
- `BUILD_CHECK_INTERVAL = 5 * 60 * 1000`, the same 5 minutes as the site apps' Nuxt manifest check, and `BUILD_CHECK_MIN_GAP = 30 * 1000`.
|
||||
- `isNewBuild(booted: string, seen: string): boolean`: true only when both are non-empty and differ. Ids are not ordered; any difference means a different deploy, a rollback included.
|
||||
- `fetchBuild(): Promise<string>`: `globalThis.fetch(`${runtime.base}/build.json`, { cache: 'no-store', credentials: 'same-origin' })`. Resolve fetch per call, as api/client.ts does, so tests can replace globalThis.fetch. It returns the `build` string from the JSON body, or '' when the response is not ok, the body is not JSON, `build` is not a string or the request rejects. It never throws. Do not use the openapi-fetch `api` client: build.json is not under the admin API root.
|
||||
- Module state is a private `ref(false)` exposed as `export const newBuildAvailable = readonly(...)`, plus private `latest` and `dismissed` strings. `noteBuild(seen: string, booted: string = runtime.build): void`: when isNewBuild(booted, seen) and seen differs from `dismissed`, it sets latest to seen and the ref to true. `dismissNewBuild(): void` sets dismissed to latest and the ref to false. `resetBuildUpdate(): void` clears all three, for tests.
|
||||
- `startBuildWatch(options = {}): () => void`, where options are optional `booted` (default runtime.build), `check` (default fetchBuild), `interval`, `minGap`, `win` (default window), `doc` (default document) and `now` (default Date.now). When booted is '' it returns a no-op stop and installs nothing. Otherwise a private run() returns early when `doc.visibilityState === 'hidden'`, when a check is in flight, or when less than minGap has passed since the last check started. Otherwise it records the start, awaits check(), calls noteBuild(seen, booted) and clears the in-flight flag in finally. It listens to visibilitychange on doc and to focus on win, and runs setInterval(run, interval). The returned stop removes both listeners and clears the interval.
|
||||
- New admin/src/components/ui/UpdateBanner.vue (script setup, lang ts): it renders only while newBuildAvailable is true, as a `div` with `data-update-banner`, `role="status"` and `aria-live="polite"`. Position it fixed at the bottom right, 88px up (the same clearance as Toast.vue for the sticky form footer), z-50, max-w-[420px], with Toast.vue's dark styling (`rounded-[14px] bg-[#1d2740] text-white shadow-toast`). It holds three things: the text `t('backend::lang.version.available')`; a Reload button (type=button, `data-update-reload`, accent classes `bg-accent text-on-accent`, label `t('backend::lang.version.reload')`) that calls `page.reload()` from state/usePreferences; and a close button (type=button, `data-update-close`, an X icon from @lucide/vue, aria-label `t('backend::lang.form.close')`) that calls dismissNewBuild(). The banner never reloads by itself, which leaves unsaved edits alone. If Reload is pressed on a dirty form, FormView's beforeunload prompt still asks.
|
||||
- admin/src/App.vue: import UpdateBanner and render `<UpdateBanner />` after the AppShell/RouterView pair, outside the v-if, so it also shows on the login screen. Note this in the template comment.
|
||||
- admin/src/main.ts: import startBuildWatch and call `startBuildWatch()` after `app.mount('#app')`, with a comment that the watch is off when the served index carries no build.
|
||||
- Strings: add a top-level `version:` section at the end of modules/phrasebook/backend/lang/en/lang.yaml with `available: A new version of the admin is available.` and `reload: Reload`. Add the same section to pl/lang.yaml with `available: Dostępna jest nowa wersja panelu.` and `reload: Odśwież`. Match the files' quoting style.
|
||||
|
||||
Docs, in the same commit, per CLAUDE.md:
|
||||
- modules/boardwalk/README.md: Overview gets one sentence on the build id. Features get bullets for the build id, the X-Summer-Build header on every response, `{prefix}/build.json` (no-store JSON) and the summer-admin-build meta written from `boardwalk.BuildToken`. The build id bullet (or a short Usage paragraph) documents the inputs: the dist tree, plus the Go version, the main module version and sum, `vcs.revision` and `vcs.modified`, and the dependency versions and sums from `runtime/debug.ReadBuildInfo`, computed once when the handler is built. It also covers what follows from that. Any redeploy of a different binary changes the id: a new commit, a plugin version bump, a toolchain change. Without build info the id is the dist hash alone. Plugin admin asset files are covered only through those versions and the revision. A build with `-buildvcs=false` and only local `replace` directives does not see edits inside the replaced modules. An identical rebuild may still show the banner once, which is acceptable. API reference gets rows for `boardwalk.BuildID`, `boardwalk.BuildHeader`, `boardwalk.BuildToken` and `boardwalk.BuildPath`. Dependencies gets the standard library list updated to match the imports exactly (crypto/sha256, encoding/hex, runtime/debug, sort or slices, encoding/json or strconv, whichever are used). The root README table row keeps its summary sentence.
|
||||
- docs/backend/admin-spa.md, Serving section: add bullets for the X-Summer-Build header on every SPA response and for GET `<prefix>/build.json` (no-store). Next to the BaseToken paragraph, add a sentence on `boardwalk.BuildToken` being replaced with the build id (`boardwalk.BuildID`: the admin build plus the binary's build information, so every redeploy of a different binary gets a new id). Then add a `## New versions` section after "How the SPA talks to the server" and before "Keyboard shortcuts". It covers the booted build from the meta, the checks (focus, visibility, every 5 minutes while visible, at most once per 30 s), the non-blocking banner (Reload or Close, never an automatic reload, the dirty-form prompt still applies, Close holds until another build appears) and the fact that the check is off when the index carries no build (the dev server). Task 2 adds the chunk guard paragraph.
|
||||
|
||||
Commit only these paths, staged explicitly: `feat(boardwalk): expose the admin build id and show a reload banner when a new build is deployed (quick-261028-rqb)`. Do not stage .planning/STATE.md, state.json or other sessions' edits. The committed dist is rebuilt in Task 3.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./modules/boardwalk/ && go test -short ./modules/boardwalk/... ./modules/phrasebook/... ./modules/cabana/... && go test ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check && npm --prefix admin run typecheck && npm --prefix admin run test</automated>
|
||||
</verify>
|
||||
<done>TestBuildSmoke passes: index meta, build.json body and X-Summer-Build header agree on BuildID(root, buildInfo()). The existing boardwalk, cabana and phrasebook tests (TestPhase10SPAKeysResolve included) pass. The SPA type-checks and its suite is green. README and docs name only identifiers that exist. One commit holds the code, README, docs and strings.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Reload once on a Vite chunk-load error, guarded by a sessionStorage timestamp</name>
|
||||
<files>admin/src/app/chunkReload.ts, admin/src/main.ts, docs/backend/admin-spa.md</files>
|
||||
<read_first>
|
||||
- admin/src/main.ts (as left by Task 1)
|
||||
- admin/src/components/form/fields/RichEditorField.vue lines 85-105 (the one dynamic import and its catch)
|
||||
- admin/src/state/usePreferences.ts lines 60-82 (storage access wrapped in try/catch, the summer-admin.* key style)
|
||||
</read_first>
|
||||
<action>
|
||||
- New admin/src/app/chunkReload.ts, with a header comment. After a deploy, an old tab asking for a removed hashed chunk fails the dynamic import, and Vite's preload helper dispatches `vite:preloadError` on window. One reload loads the new index and its chunks. The flag stops a loop when the reload does not help.
|
||||
- `CHUNK_RELOAD_KEY = 'summer-admin.chunk-reload'` and `CHUNK_RELOAD_WINDOW = 60_000`.
|
||||
- `shouldReload(storage: Storage | null, now: number): boolean`. With a null storage it returns false: never reload without a guard. Otherwise, inside a try, it reads the key and parses it as an integer timestamp. A finite value with `now - value` in [0, CHUNK_RELOAD_WINDOW) returns false. Anything else writes `String(now)` under the key and returns true. Any storage exception returns false.
|
||||
- `installChunkReloadGuard(options = {}): () => void`, where options are optional `target` (default window), `storage` (a function returning Storage or null; default reads window.sessionStorage inside try/catch and returns null on error), `reload` (default `() => window.location.reload()`) and `now` (default Date.now). It adds a `vite:preloadError` listener on target that calls `reload()` when `shouldReload(storage(), now())` is true. It deliberately does not call preventDefault. The error still reaches the importer: RichEditorField's catch switches to source mode for the instant before the reload, and when the guard declines, the existing failure state shows. It returns a function that removes the listener.
|
||||
- admin/src/main.ts: call `installChunkReloadGuard()` at the top of boot(), before applyTheme and before the first await, with a one-line comment.
|
||||
- docs/backend/admin-spa.md: add a paragraph to the `## New versions` section from Task 1. It says that a failed chunk load (Vite's `vite:preloadError`) reloads the page once, that a timestamp in `sessionStorage` (`summer-admin.chunk-reload`) blocks a second automatic reload within 60 seconds, and that with no usable sessionStorage the SPA does not reload.
|
||||
|
||||
Commit these paths explicitly: `feat(admin): reload once when a chunk fails to load after a deploy (quick-261028-rqb)`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && npm --prefix admin run typecheck && npm --prefix admin run test && go test ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check</automated>
|
||||
</verify>
|
||||
<done>chunkReload.ts exists with shouldReload and installChunkReloadGuard. main.ts installs the guard before the first await. The SPA type-checks and its suite stays green. The docs paragraph is in place. One commit.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Unit tests for the build id, banner and chunk guard; rebuild and commit modules/boardwalk/dist</name>
|
||||
<files>modules/boardwalk/boardwalk_test.go, admin/tests/app/runtime.test.ts, admin/tests/app/buildUpdate.test.ts, admin/tests/app/chunkReload.test.ts, admin/tests/ui/UpdateBanner.test.ts, admin/tests/fixtures/lang.json, modules/boardwalk/dist/index.html, modules/boardwalk/dist/assets/</files>
|
||||
<precondition>admin/node_modules exists; if it does not, run npm --prefix admin ci first.</precondition>
|
||||
<read_first>
|
||||
- modules/boardwalk/boardwalk_test.go (TestSecurityHeadersOnEveryResponse and TestPhase10BoardwalkServing for the request matrix and the MapFS fixtures)
|
||||
- admin/tests/app/runtime.test.ts (docWith helper)
|
||||
- admin/tests/ui/ui.test.ts lines 1-40 (mount, resetState, enableAutoUnmount)
|
||||
- admin/tests/state/usePreferences.test.ts around line 165 (vi.spyOn(page, 'reload'))
|
||||
- admin/tests/helpers.ts lines 165-185 (resetState loads tests/fixtures/lang.json)
|
||||
- scripts/check-admin-dist.sh
|
||||
</read_first>
|
||||
<behavior>
|
||||
- BuildID dist part (info nil): same MapFS gives the same id twice; it is 12 lowercase hex characters; changing one file's bytes, adding a file or renaming a file changes it; an empty tree still returns an id without error
|
||||
- BuildID binary part (a hand-built *debug.BuildInfo fixture with GoVersion, Main path/version/sum, settings vcs.revision, vcs.modified and an unrelated setting, and two Deps one with a Replace): the same tree with info differs from the same tree with nil. The same info twice gives the same id. The same info with Deps in reversed order gives the same id. Each of these changes the id: vcs.revision, vcs.modified false to true, Main.Version, Main.Sum, GoVersion, one dependency's Version (the plugin version bump case), one dependency's Sum, adding a dependency, and the Replace version. Changing only the unrelated setting (for example GOOS or -tags) leaves the id unchanged. Settings without vcs keys hash without error
|
||||
- Handler fallback: with readBuildInfo replaced (restored by t.Cleanup) by a func returning (nil, false), the served meta, header and build.json carry BuildID(root, nil). With it returning a fixture, they carry BuildID(root, fixture). Two handlers over the same tree with fixtures differing only in vcs.revision serve different ids
|
||||
- X-Summer-Build equals the handler's id on index.html, a client route, an assets/ file, a missing-extension 404, build.json and an api/ miss (the apiSpy answer)
|
||||
- GET {prefix}/build.json returns 200, application/json, Cache-Control no-store and a body that decodes to build = BuildID(root, buildInfo())
|
||||
- A MapFS index with BuildToken gets the meta content replaced and no token left. A MapFS index without BuildToken still builds, and its body differs from RewriteIndex(raw, prefix) in no way
|
||||
- Embedded dist: Handler(testPrefix, spy) serves an index whose summer-admin-build meta content equals BuildID(Dist(), buildInfo()), and build.json agrees
|
||||
- readBuild: a 12-hex meta returns it; a missing meta, an empty value, the unreplaced __SUMMER_ADMIN_BUILD__, uppercase hex, a value with markup and a 65-character hex value return ''
|
||||
- isNewBuild table: ('', x) false, (x, '') false, (x, x) false, (x, y) true
|
||||
- fetchBuild: requests '/admin-test/build.json' with cache 'no-store'; it returns the id on ok JSON, and '' on 500, invalid JSON, non-string build and a rejected fetch
|
||||
- startBuildWatch: booted '' installs nothing and never calls check. A visibilitychange with visibilityState visible runs check; hidden does not. Focus runs check. The interval runs check (vi.useFakeTimers). A focus and a visibilitychange within minGap run check once. A check that resolves to a different build sets newBuildAvailable; the same build does not. stop() removes the listeners and the interval (no check after stop)
|
||||
- noteBuild/dismissNewBuild: after dismiss, the same build keeps the banner hidden; a third, different build shows it again
|
||||
- shouldReload: the first call returns true and writes the timestamp; a second within 60 s returns false; one after 60 s returns true; null storage returns false; storage whose getItem or setItem throws returns false
|
||||
- installChunkReloadGuard: dispatching a cancelable Event('vite:preloadError') on the target calls reload once, a second dispatch within the window does not, the event is not defaultPrevented, and after the returned uninstall no further reload happens
|
||||
- UpdateBanner: hidden by default; after noteBuild(new, booted) it shows the pl fixture text for backend::lang.version.available; Reload calls page.reload (spied); Close hides it
|
||||
</behavior>
|
||||
<action>
|
||||
Write the tests from the behavior list, then rebuild the dist.
|
||||
|
||||
Go, in modules/boardwalk/boardwalk_test.go: add TestBuildID (dist part), TestBuildIDBuildInfo (binary part, from a hand-built *debug.BuildInfo fixture; never depend on the test binary's real build info for exact values), TestBuildInfoFallback (swaps the readBuildInfo var and restores it with t.Cleanup; these tests do not run in parallel), TestBuildHeaderOnEveryResponse, TestBuildEndpoint, TestBuildToken (with and without the token) and TestEmbeddedBuild. Keep TestBuildSmoke from Task 1. Reuse the existing apiSpy, get helper, testPrefix and MapFS fixture style, and decode build.json with encoding/json.
|
||||
|
||||
Vitest:
|
||||
- Extend admin/tests/app/runtime.test.ts with a readBuild describe block that builds documents with a summer-admin-build meta, like docWith.
|
||||
- New admin/tests/app/buildUpdate.test.ts and admin/tests/app/chunkReload.test.ts. Inject `check`, `win`, `doc`, `now`, `storage`, `target` and `reload` through the options instead of patching globals. For fetchBuild, replace globalThis.fetch with vi.fn and restore it. For the visibilityState cases, a doc stub object (EventTarget plus a visibilityState property) is simplest. Call resetBuildUpdate() in beforeEach.
|
||||
- New admin/tests/ui/UpdateBanner.test.ts, in the ui.test.ts style: resetState() and resetBuildUpdate() in beforeEach, enableAutoUnmount, `vi.spyOn(page, 'reload').mockImplementation(() => undefined)`.
|
||||
- Add the two pl strings to admin/tests/fixtures/lang.json in its sorted key order, in the same `{ "other": ... }` form: `backend::lang.version.available` = "Dostępna jest nowa wersja panelu." and `backend::lang.version.reload` = "Odśwież".
|
||||
|
||||
Rebuild: run `npm --prefix admin run build`, which writes modules/boardwalk/dist. Check that modules/boardwalk/dist/index.html carries `name="summer-admin-build" content="__SUMMER_ADMIN_BUILD__"`. Run scripts/check-admin-dist.sh. Then reconcile this plan's `files_modified` / `files_deleted` frontmatter with `git status --porcelain modules/boardwalk/dist`, adding or removing the hashed asset names the build actually created or deleted. The quick-batch merge reads that frontmatter.
|
||||
|
||||
Commit in this order, so every commit is green:
|
||||
1. `git add modules/boardwalk/dist` (stages additions and removals inside the directory), then commit `chore(boardwalk): rebuild the embedded admin for the reload banner and chunk guard (quick-261028-rqb)`.
|
||||
2. Stage the test files and the fixture explicitly, then commit `test(boardwalk): cover the build id, the reload banner and the chunk reload guard (quick-261028-rqb)`.
|
||||
|
||||
TestEmbeddedBuild needs the rebuilt dist, which is why the dist commit comes first. Never use git add -A at the repository root.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./... && go test -short ./... && go test ./modules/boardwalk/... -run 'TestBuild|TestEmbeddedBuild' -v && npm --prefix admin run typecheck && npm --prefix admin run test && grep -c 'name="summer-admin-build" content="__SUMMER_ADMIN_BUILD__"' modules/boardwalk/dist/index.html && scripts/check-admin-dist.sh && go test ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check</automated>
|
||||
</verify>
|
||||
<done>Every behavior bullet has a passing test. modules/boardwalk/dist is rebuilt, carries the build meta token and passes check-admin-dist.sh. go vet ./... and go test -short ./... are green. The plan frontmatter lists the dist files actually changed. Two commits (dist, then tests) on top of Tasks 1 and 2.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| browser → boardwalk (public) | `{prefix}/build.json` and index.html are served without authentication, like the rest of the SPA shell |
|
||||
| served index.html → SPA runtime | the SPA trusts the summer-admin-build meta written by the server |
|
||||
| sessionStorage → chunk guard | per-tab storage that page script can read and write |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-rqb-01 | Information disclosure | build.json, X-Summer-Build | low | accept | The id is a truncated sha256 over public static assets plus the binary's module versions, sums and vcs revision. The 48-bit prefix of a hash over the whole dependency list cannot be inverted into a revision or a single module version, so it reveals only that a deploy changed |
|
||||
| T-rqb-02 | Tampering (markup injection) | newHandler meta replacement, readBuild | low | mitigate | The server writes only the 12-character hex sha256 prefix, and readBuild accepts only /^[0-9a-f]{6,64}$/, so neither the meta nor the banner logic ever carries markup |
|
||||
| T-rqb-03 | Denial of service (client reload loop) | installChunkReloadGuard | medium | mitigate | One automatic reload per 60 s through the sessionStorage timestamp. Storage that cannot be read or written means no reload. Covered by the shouldReload and guard tests |
|
||||
| T-rqb-04 | Denial of service (server load) | build.json polling | low | accept | At most one tiny no-store request per tab every 5 minutes while visible, plus focus and visibility checks throttled to once per 30 s with one in flight |
|
||||
| T-rqb-05 | Tampering (lost admin edits) | UpdateBanner, chunk guard | medium | mitigate | The banner never reloads by itself. Reload goes through page.reload, so FormView's beforeunload prompt still guards a dirty form. The chunk guard's reload triggers the same prompt |
|
||||
| T-rqb-SC | Tampering | npm/pip/cargo installs | high | accept | No package is added: no change to admin/package.json or go.mod. npm --prefix admin ci reinstalls only the committed lockfile |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- go vet ./... and go test -short ./... green (repository root)
|
||||
- go test ./modules/boardwalk/... -run 'TestBuild|TestEmbeddedBuild' -v shows every new test passing
|
||||
- go test ./modules/phrasebook -run TestPhase10SPAKeysResolve green (new strings resolve in pl and en)
|
||||
- npm --prefix admin run typecheck and npm --prefix admin run test green
|
||||
- scripts/check-admin-dist.sh prints "modules/boardwalk/dist matches a fresh build"
|
||||
- go test ./cmd/summer -run TestDocsTree and go run ./cmd/summer docs:build --check green
|
||||
- git diff --stat HEAD~4 shows no file under modules/cabana
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- An admin tab that booted on build A shows the reload banner within one focus, visibility change or 5-minute tick after a different binary or admin bundle is deployed, and reloads only when the administrator presses Reload
|
||||
- A missing hashed chunk after a deploy reloads the page once and never loops
|
||||
- boardwalk exposes BuildID, BuildHeader, BuildToken and BuildPath additively, documented in its README and in docs/backend/admin-spa.md
|
||||
- The committed modules/boardwalk/dist matches a fresh build
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/quick/261028-rqb-admin-spa-reload-on-deploy-todo-planning-todos-pending-2026/261028-rqb-SUMMARY.md` when done. Note in it:
|
||||
- The todo's admin half is done. Moving the todo to done and the g15office ltcvqext comment are left to the quick workflow's close step.
|
||||
- A framework tag and changelog post are pending the user's go-ahead.
|
||||
- The build id covers the embedded dist plus the binary's build information, so any redeploy of a different binary shows the banner, including a deploy that only adds a plugin field type. Plugin admin asset files are covered through module versions and the app's vcs.revision, not hashed directly. A `-buildvcs=false` build that uses only local `replace` directives misses edits inside the replaced modules, and the README says so.
|
||||
</output>
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
phase: quick-261028-rqb
|
||||
plan: 01
|
||||
subsystem: boardwalk, admin SPA
|
||||
tags: [boardwalk, admin, deploy, build-id, reload]
|
||||
status: complete
|
||||
requires: []
|
||||
provides:
|
||||
- boardwalk.BuildID, BuildHeader, BuildToken, BuildPath
|
||||
- "{prefix}/build.json and X-Summer-Build on every boardwalk response"
|
||||
- admin buildUpdate.ts (startBuildWatch), UpdateBanner.vue, chunkReload.ts
|
||||
affects: [cabana (mounts boardwalk; not modified)]
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "readBuildInfo package var replaced in tests (as in bonfire)"
|
||||
- "SPA meta-driven runtime config (runtime.build)"
|
||||
key-files:
|
||||
created:
|
||||
- admin/src/app/buildUpdate.ts
|
||||
- admin/src/app/chunkReload.ts
|
||||
- admin/src/components/ui/UpdateBanner.vue
|
||||
- admin/tests/app/buildUpdate.test.ts
|
||||
- admin/tests/app/chunkReload.test.ts
|
||||
- admin/tests/ui/UpdateBanner.test.ts
|
||||
modified:
|
||||
- modules/boardwalk/boardwalk.go
|
||||
- modules/boardwalk/boardwalk_test.go
|
||||
- modules/boardwalk/README.md
|
||||
- docs/backend/admin-spa.md
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- admin/index.html
|
||||
- admin/src/app/runtime.ts
|
||||
- admin/src/App.vue
|
||||
- admin/src/main.ts
|
||||
- admin/tests/app/runtime.test.ts
|
||||
- admin/tests/fixtures/lang.json
|
||||
- modules/boardwalk/dist (index.html; assets index-CoBJlG5O.js, index-DoVyFFyY.css, editor-lelm3tpS.js replace index-BNWI_4R0.js, index-Bakgr9jB.css, editor-CxeFI7rG.js)
|
||||
decisions:
|
||||
- "build.json is written as raw bytes (the id is hex), not via encoding/json"
|
||||
- "BuildID hashes each dist file as path NUL length NUL contents, then a NUL-delimited buildinfo marker and one line per identity item; deps sorted by path with slices.SortStableFunc on a clone"
|
||||
metrics:
|
||||
duration: ~12 min
|
||||
completed: 2026-10-28
|
||||
actuals:
|
||||
tokens: 30000
|
||||
tasks: 3
|
||||
commits: 4
|
||||
plan_head_before: 4957e5ce255947bea04d1d9ba32cac227e775488
|
||||
plan_head_after: c8a22e3e846625b37e440f7e8305b1a3f67ece56
|
||||
---
|
||||
|
||||
# Quick 261028-rqb Plan 01: Admin SPA reload on deploy Summary
|
||||
|
||||
boardwalk now computes a 12-hex build id once per handler, a sha256 over the raw dist tree plus the binary's `runtime/debug` build identity. It serves the id in an `X-Summer-Build` header on every response, at `{prefix}/build.json` (no-store) and in a `summer-admin-build` meta. The admin SPA compares the build it booted with against `build.json` on focus, on visibility and every 5 minutes, and on a mismatch shows a non-blocking Reload/Close banner. A `vite:preloadError` reloads the page at most once per 60 s.
|
||||
|
||||
## Commits
|
||||
|
||||
| Task | Commit | Message |
|
||||
|------|--------|---------|
|
||||
| 1 | 291b18f | feat(boardwalk): expose the admin build id and show a reload banner when a new build is deployed |
|
||||
| 2 | 64452a4 | feat(admin): reload once when a chunk fails to load after a deploy |
|
||||
| 3a | 75e19dc | chore(boardwalk): rebuild the embedded admin for the reload banner and chunk guard |
|
||||
| 3b | c8a22e3 | test(boardwalk): cover the build id, the reload banner and the chunk reload guard |
|
||||
|
||||
## Notes
|
||||
|
||||
- The admin half of todo `2026-10-11-reload-open-apps-when-a-new-version-is-deployed` is done. Moving the todo to done and the g15office ltcvqext comment are left to the quick workflow's close step.
|
||||
- A framework tag and a changelog post are pending the user's go-ahead.
|
||||
- The build id covers the embedded dist plus the binary's build information, so any redeploy of a different binary shows the banner, including a deploy that only adds a plugin field type. Plugin admin asset files are covered through module versions and the app's `vcs.revision`, not hashed directly. A `-buildvcs=false` build that uses only local `replace` directives misses edits inside the replaced modules; the boardwalk README says so.
|
||||
- `modules/cabana` is not modified (`git diff 4957e5c..HEAD -- modules/cabana` is empty). No exported boardwalk identifier changed; BuildID, BuildHeader, BuildToken and BuildPath are additions.
|
||||
- Tracer gate (Task 1): the full verify chain was re-run after the commit and passed before Task 2 began.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None. The plan was executed as written. Small choices within the plan's discretion:
|
||||
- `startBuildWatch` swallows a rejected custom `check` (`run().catch(() => undefined)`), so an injected check that throws does not produce an unhandled rejection. The default `fetchBuild` never throws.
|
||||
- The test-only `docStub`/`memoryStorage` helpers live inside their test files.
|
||||
|
||||
## Verification
|
||||
|
||||
- go vet ./... and go test -short ./... green
|
||||
- go test ./modules/boardwalk/... -run 'TestBuild|TestEmbeddedBuild' -v: TestBuildSmoke, TestBuildID, TestBuildIDBuildInfo, TestBuildInfoFallback, TestBuildHeaderOnEveryResponse, TestBuildEndpoint, TestBuildToken and TestEmbeddedBuild all pass
|
||||
- TestPhase10SPAKeysResolve green
|
||||
- npm --prefix admin run typecheck green; npm --prefix admin run test: 1605 tests passed
|
||||
- modules/boardwalk/dist/index.html carries `name="summer-admin-build" content="__SUMMER_ADMIN_BUILD__"`; scripts/check-admin-dist.sh: "modules/boardwalk/dist matches a fresh build"
|
||||
- go test ./cmd/summer -run TestDocsTree and go run ./cmd/summer docs:build --check green
|
||||
- The plan frontmatter `files_modified` now lists the three new hashed assets
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Created files exist: admin/src/app/buildUpdate.ts, admin/src/app/chunkReload.ts, admin/src/components/ui/UpdateBanner.vue, admin/tests/app/buildUpdate.test.ts, admin/tests/app/chunkReload.test.ts, admin/tests/ui/UpdateBanner.test.ts
|
||||
- Commits exist: 291b18f, 64452a4, 75e19dc, c8a22e3 (git rev-list --count 4957e5c..HEAD = 4)
|
||||
@@ -0,0 +1,197 @@
|
||||
---
|
||||
phase: quick-261028-rqc
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["261028-rqd"]
|
||||
files_modified:
|
||||
- modules/diary/helponly.go
|
||||
- modules/diary/diary.go
|
||||
- modules/diary/export_test.go
|
||||
- modules/diary/helponly_test.go
|
||||
- modules/diary/bootquiet_smoke_test.go
|
||||
- modules/diary/README.md
|
||||
- docs/services/logging.md
|
||||
- docs/console/introduction.md
|
||||
autonomous: true
|
||||
requirements:
|
||||
- todo:2026-10-21-quiet-plugin-boot-logs-on-console-commands
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "bin/<app> serve --help (and -h, a global shorthand cluster holding h, --help=true) prints no Info or Debug record that a plugin logs from Register or Boot on the console, whether the plugin logs through diary.For or through slog.Default"
|
||||
- "bin/<app> with no command word, list, list --format=json, help, help <command>, and shell completion requests (cobra's __complete and __completeNoDesc) boot the same quiet way"
|
||||
- "Warnings and errors logged during boot still print on a help-only invocation"
|
||||
- "With -v (or more) a help-only invocation prints the boot Info records as before"
|
||||
- "serve, migrate, queue:work and every other command that runs boot at the configured console levels, unchanged"
|
||||
- "A channel that is not a console (daily files, the stderr driver) keeps its level on a help-only invocation, the same rule as -q: a daily channel at info still receives the boot Info records"
|
||||
- "The generated main is byte-for-byte unchanged: internal/build, the summer new golden, examples/hello/main.go and cmd/summer are not edited. An existing generated main (diary.Bootstrap and diary.Setup with diary.FromGlobals(g)) gets the behaviour from a framework upgrade alone, and a main generated by a newer summer tool still compiles against an older framework"
|
||||
- "Options built as a literal without Args (tests, embedding programs) do no help-only check, so their console levels are unchanged even under go test, whose os.Args hold no command word"
|
||||
- "diary gains only the exported field Options.Args; no exported identifier of diary is removed or changed, and diary imports neither bonfire (beyond what it already imports at execution time) nor cobra for this"
|
||||
- "No sm-* plugin or other sibling repository is edited"
|
||||
artifacts:
|
||||
- path: modules/diary/helponly.go
|
||||
provides: "unexported helpOnly(args []string) bool and the boot-time quiet rule applied by Bootstrap and Setup"
|
||||
contains: "func helpOnly(args []string) bool"
|
||||
- path: modules/diary/diary.go
|
||||
provides: "Options.Args; FromGlobals fills it from os.Args[1:]; Bootstrap and Setup apply the help-only rule"
|
||||
contains: "Args []string"
|
||||
- path: modules/diary/bootquiet_smoke_test.go
|
||||
provides: "TestBootQuietSmoke: the unchanged generated main's sequence with a party plugin that logs from Register and Boot"
|
||||
contains: "func TestBootQuietSmoke"
|
||||
- path: modules/diary/helponly_test.go
|
||||
provides: "TestHelpOnly table and TestHelpOnlyAgreesWithCobra (package diary_test, through export_test.go)"
|
||||
contains: "func TestHelpOnlyAgreesWithCobra"
|
||||
key_links:
|
||||
- from: generated main diary.Bootstrap(os.Stderr, diary.FromGlobals(g)) and diary.Setup(cfg, diary.FromGlobals(g))
|
||||
to: modules/diary/diary.go FromGlobals
|
||||
via: "FromGlobals sets Options.Args to a non-nil copy of os.Args[1:], so the generated main needs no change"
|
||||
- from: modules/diary/diary.go Bootstrap and Setup
|
||||
to: modules/diary/helponly.go helpOnly
|
||||
via: "when Args is non-nil, Verbosity is 0 and helpOnly(Args) holds, the installed options get Quiet, which raises only console channels to warn"
|
||||
- from: bonfire root OnGlobals(diary.ApplyGlobals)
|
||||
to: modules/diary/diary.go ApplyGlobals
|
||||
via: "a list, help or passthrough command that runs re-applies the parsed -v/-q, so the boot-time Quiet never outlives the boot for a command that runs"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Stop plugin Register/Boot Info records from printing on console invocations that only ask the binary to describe itself (`--help`, `-h`, `help`, `list`, no command, shell completion requests), without hiding anything for `serve`, `queue:work`, `migrate` or any other command that runs. Framework side only, and without changing what the generated main calls.
|
||||
|
||||
Purpose: the generated main boots every plugin before bonfire parses the command line, so `bin/demo serve --help` prints the shared payment plugins' "operators registered" and "registries collected" Info lines above the help (todo `.planning/todos/pending/2026-10-21-quiet-plugin-boot-logs-on-console-commands.md`). diary already installs itself as `slog.Default` in `diary.Bootstrap`, before config loads and plugins boot, so the todo's plain `time=… level=INFO msg=…` lines are diary's logfmt console off a terminal. Raising diary's console channels for the boot therefore reaches records logged through `slog.Default` and through `diary.For` alike.
|
||||
|
||||
Orchestrator decision (binding): the detection lives inside the diary entry points the generated main already calls. The generated main passes `diary.FromGlobals(g)` to `diary.Bootstrap` and `diary.Setup`; `FromGlobals` now also records the process arguments (`os.Args[1:]`) in a new `Options.Args` field, and `Bootstrap`/`Setup` start the console channels at warn when those arguments only ask for help and no `-v` is given. An existing generated main gets the behaviour from a framework upgrade alone, and a main generated by a newer `summer` tool still compiles against an older framework (no version caveat). The help-only classifier is an unexported copy inside diary, self-contained (no bonfire or cobra import from it), pinned by a cobra-agreement test. Deferring `Boot` is rejected: the generated main collects plugin `Commands()` after `party.Activate`, and scaffolded plugins rely on `Boot` having set the application before `Commands` runs.
|
||||
|
||||
Out of scope: the plugin-side Info→Debug change in sm-paymentgateway-plugin (`boot.go:129`) and sm-paymentgatewaysubscriptions-plugin (`boot.go:86`) and the sweep of other shared plugins (separate follow-up); any edit to internal/build, the goldens, examples/hello or cmd/summer.
|
||||
|
||||
Output: `Options.Args`, the help-only rule in `Bootstrap`/`Setup`, tests, the diary README, and the logging and console docs pages.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@CLAUDE.md
|
||||
@.planning/STATE.md
|
||||
@.planning/todos/pending/2026-10-21-quiet-plugin-boot-logs-on-console-commands.md
|
||||
@modules/diary/diary.go
|
||||
@modules/diary/channels.go
|
||||
@modules/diary/tracer_smoke_test.go
|
||||
@modules/bonfire/globals.go
|
||||
@modules/bonfire/execute.go
|
||||
@modules/bonfire/root.go
|
||||
@internal/build/build.go
|
||||
@docs/services/logging.md
|
||||
@docs/console/introduction.md
|
||||
|
||||
**Runs after 261028-rqd.** That item (console review findings IN-01..IN-13) edits `modules/diary/diary.go` and `console.go`, may remove diary's import of bonfire (IN-07: `ColorMode`, `DetectProfile` and the like move to a leaf package, and `FromGlobals`/`ApplyGlobals` may then take the leaf package's globals type), may rewrite `bonfire.PeekGlobals` (IN-02) and fang handling in `execute.go`/`root.go` (IN-06), and edits the diary README and `docs/console/introduction.md` (exit code 128+n). Re-read every file listed above live before editing; do not trust line numbers or shapes quoted here. Whatever shape `FromGlobals` has after rqd, keep its signature and add the `Args` recording to it. Keep `helpOnly` consistent with `PeekGlobals` at execution time on the three shared rules: stop at `--`, skip the value of `--env`, read single-dash clusters letter by letter and stop at the first letter that is not a global shorthand (v, q, n, h). If rqd introduced a leaf package that diary imports and that already exports those scan rules, `helpOnly` may call them; otherwise keep the small self-contained copy. Never add a bonfire or cobra import to non-test diary code for this.
|
||||
|
||||
<interfaces>
|
||||
Existing, used as-is (verify names live):
|
||||
- diary: `type Options struct { Verbosity int; Quiet bool; Color …ColorMode; Root string; Stderr io.Writer; Environ []string; Now func() time.Time }` and its unexported `normalize()`; `Quiet` raises only the console-driver channels to warn (`channels.consoles`, `consoleLevel.apply`), daily and stderr-driver channels keep their levels. `func FromGlobals(g …Globals) Options`, `func Bootstrap(w io.Writer, opts Options)`, `func Setup(cfg *compass.Config, opts Options) error`, `func ApplyGlobals(g …Globals)` (sets verbosity and quiet from the parsed globals, rebuilding channels only when the color mode changed), `func Reset()`.
|
||||
- Generated main (internal/build/build.go, unchanged): `g := bonfire.PeekGlobals(args)`, `diary.Bootstrap(os.Stderr, diary.FromGlobals(g))`, `compass.Open`, `diary.Setup(cfg, diary.FromGlobals(g))`, `party.Activate`, command collection, `bonfire.NewRoot(…, bonfire.OnGlobals(diary.ApplyGlobals))`, `bonfire.Execute`. `main` calls `run(os.Args[1:], os.Stdout)`, so `os.Args[1:]` is exactly the args the binary dispatches on. cmd/summer/main.go has the same Bootstrap call and gets the rule too (it boots no plugins; harmless).
|
||||
- bonfire: `Execute` strips `-h`/`--help` only when there is no command word (`hasCommandWord`) and then prints the list; the root's `PersistentPreRunE` runs the OnGlobals hooks for a command that runs (list, help, any plugin command, passthrough commands via `PeekGlobals`) but cobra's `--help` flag path returns before it. `NewRootIO(name, commands, in, out, errW, opts...)`, `OnGlobals(fn)`, `Command.Passthrough`.
|
||||
- cobra v1.10.2: `cobra.ShellCompRequestCmd` is "__complete", `cobra.ShellCompNoDescRequestCmd` is "__completeNoDesc"; cobra adds the hidden `__complete` command on every `ExecuteC`, independent of `CompletionOptions.DisableDefaultCmd`.
|
||||
- party: `Register(p Plugin)` (process-wide registry), `Activate(app *backpack.App, ids []string) ([]Plugin, error)` runs every Register before any Boot; `Plugin` is `ID() string`, `Requires() []string`, `Register(app *backpack.App) error`, `Boot(app *backpack.App) error`.
|
||||
|
||||
New (this plan):
|
||||
- diary, `modules/diary/diary.go`: exported field `Options.Args []string`. Doc: the command-line arguments without the program name; when non-nil, Bootstrap and Setup check whether they only ask for help and, at verbosity 0, install the setup with Quiet; FromGlobals sets them to a copy of `os.Args[1:]`; nil (a literal Options) means no check; a caller that dispatches on other arguments sets Args itself.
|
||||
- diary, new file `modules/diary/helponly.go`: unexported `helpOnly(args []string) bool` and the unexported rule that Bootstrap and Setup apply.
|
||||
- diary, new file `modules/diary/export_test.go` (package diary): `var HelpOnly = helpOnly` for the external tests.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: End-to-end "serve --help boots with the console at warn" through the unchanged generated main</name>
|
||||
<files>modules/diary/helponly.go, modules/diary/diary.go, modules/diary/bootquiet_smoke_test.go, modules/diary/README.md, docs/services/logging.md</files>
|
||||
<behavior>
|
||||
- TestBootQuietSmoke/serve_--help: the generated main's exact sequence (PeekGlobals, Bootstrap with FromGlobals(g), compass.Open, Setup with FromGlobals(g), backpack.New, party.Activate, NewRootIO with OnGlobals(diary.ApplyGlobals), Execute) on args `serve --help`, with Args set to those args on the FromGlobals result, exits 0, prints serve's help on stdout, and the diary console buffer holds the plugin's boot Warn record but neither Info record (Register via slog.Default, Boot via diary.For); the daily file (channel level info) holds both Info records.
|
||||
- TestBootQuietSmoke/serve: the same sequence on `serve` exits 0 and the console buffer holds both Info records and the Warn record.
|
||||
- TestBootQuietSmoke/literal_options: Setup with a literal Options (no Args) under go test prints the boot Info records on the console (no check without Args, although the test binary's os.Args hold no command word).
|
||||
- TestFromGlobalsArgs: with os.Args temporarily set to a program name plus `serve --help`, FromGlobals returns Args equal to `serve --help`; with only a program name it returns a non-nil empty Args; mutating the returned slice does not change os.Args.
|
||||
</behavior>
|
||||
<action>
|
||||
Write the failing tests first (RED), then the code (GREEN). This task wires the single `-h`/`--help` path through FromGlobals, Bootstrap and Setup; list, help, no-command and completion requests come in Task 2. Do not edit internal/build, its goldens, examples/hello or cmd/summer: the point is that the existing generated main gets the behaviour unchanged.
|
||||
|
||||
`modules/diary/helponly.go` (new): unexported `helpOnly(args []string) bool`, in this task true when, before a `--` token, args hold `--help` or a single-dash cluster whose letters, read the way `bonfire.PeekGlobals` reads them (global shorthands v, q, n, h; stop at the first other letter), reach `h`; the token after `--env` is a value and is skipped. It never fails and imports only the standard library. Doc comment: a tolerant pre-scan of a binary's raw arguments, made before plugins boot, that cannot tell a passthrough command (whose `--help` reaches its Run) from another, so `<passthrough> --help` counts; the root's OnGlobals hook restores the levels before that command runs. Add the unexported rule Bootstrap and Setup apply: when `opts.Args` is non-nil, `opts.Verbosity` is 0 and `helpOnly(opts.Args)` holds, set `opts.Quiet`.
|
||||
|
||||
`modules/diary/diary.go`: add the exported field `Args []string` to `Options` with the doc from the interfaces block. `FromGlobals` keeps its signature and also sets `Args` to a fresh non-nil copy of `os.Args[1:]` (an empty slice when there are no arguments, so a bare `bin/app` counts as asking for the list in Task 2). Apply the rule in `Bootstrap` and in `Setup` after `normalize`, before building channels; do not put it in `normalize` itself and do not touch `ApplyGlobals`, `SetVerbosity` or `Reset`. Update the doc comments of `Options.Quiet`, `FromGlobals`, `Bootstrap` and `Setup` in one sentence each.
|
||||
|
||||
Tracer test, new file `modules/diary/bootquiet_smoke_test.go`, package `diary_test`, runs under `-short` (no Docker). In the file's `init`, `party.Register` a small test plugin (ID such as `acme.bootlog`, no requirements) whose Register logs one Info record through `slog.Default()` (shaped like the payment plugin's "operators registered" line) and whose Boot logs one Info and one Warn record through `diary.For("bootlog")`. `TestBootQuietSmoke` runs one subtest per row, each reproducing the generated main: `t.Cleanup(diary.Reset)`; a temp app root with `config/log.yaml` (default stack of console plus a daily channel, format json, level info, as in tracer_smoke_test.go); `g := bonfire.PeekGlobals(args)`; `opts := diary.FromGlobals(g)` then on the copy set `Args = args` (hermetic instead of os.Args), `Root`, `Stderr` (a bytes.Buffer), `Environ: []string{}` and a fixed `Now`; `diary.Bootstrap(buf, opts)`; `compass.Open` with `Environ: []string{}`; `diary.Setup(cfg, opts)`; `backpack.New(cfg)`; `party.Activate(app, []string{"acme.bootlog"})`; `bonfire.NewRootIO("acme", …)` with one `serve` command whose Run returns nil, empty stdin, separate stdout and error buffers and `bonfire.OnGlobals(diary.ApplyGlobals)`; `bonfire.Execute`. Rows in this task: `serve --help`, `serve`, and the literal-options row (Options built as a struct literal without Args). Find the daily file by globbing storage/logs under the temp root. `TestFromGlobalsArgs` saves and restores `os.Args` in `t.Cleanup` and does not call `t.Parallel`.
|
||||
|
||||
Docs in the same commit (CLAUDE.md documentation rules). diary README: the Overview sentence about the generated main and the global-options Features bullet say that `diary.FromGlobals` also records the process arguments and that `diary.Bootstrap` and `diary.Setup` start the console at warnings for an invocation that only asks for help, unless `-v` is given; the API reference row of `diary.Options` (or a new row if there is none) mentions `Args`; the Testing section names `TestBootQuietSmoke`; the Usage snippet of the generated main stays as it is. docs/services/logging.md: the paragraph under the H1 that describes what the generated main calls is out of date (it names a zero Options value); rewrite it to say the generated main calls `diary.Bootstrap(os.Stderr, diary.FromGlobals(g))` first and `diary.Setup` with the same options after compass loads `config/`, and that an invocation that only asks for help boots with the console at warnings unless `-v` is given. Every identifier written in docs must exist (docs checker). Commit message, no co-author or attribution trailer (user CLAUDE.md): `feat(diary): boot --help invocations with the console at warn (quick-261028-rqc)`. Stage files by path.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/diary && go test -short -count=1 -run 'TestBootQuietSmoke|TestFromGlobalsArgs' -v ./modules/diary 2>&1 | grep -q -- '^--- PASS: TestBootQuietSmoke' && go test -short -count=1 -run 'TestFromGlobalsArgs' -v ./modules/diary 2>&1 | grep -q -- '^--- PASS: TestFromGlobalsArgs' && go test -short -count=1 ./modules/diary ./modules/bonfire ./internal/build ./cmd/summer && go run ./scripts/apisurface modules/diary | grep -Eq '^field Options\.Args \[\]string' && git diff --quiet HEAD -- internal/build examples/hello cmd/summer modules/bonfire && go test ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check</automated>
|
||||
</verify>
|
||||
<done>Before committing: the apisurface of modules/diary at HEAD (extract with `git archive HEAD modules/diary` into a mktemp dir, run `go run ./scripts/apisurface` on it and on the working tree, sort both with LC_ALL=C, `comm -23` base against working tree) prints nothing, so the change is strictly additive (only `field Options.Args []string` is new). `serve --help` hides the boot Info records on the console and keeps the Warn record, `serve` and literal Options show them, the daily file keeps them; internal/build, examples/hello, cmd/summer and modules/bonfire are untouched and TestGenerateMainInstallsDiary still passes unchanged; the README and logging.md describe it; one commit.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: list, help, no command and completion requests boot quietly too; docs for the behaviour</name>
|
||||
<files>modules/diary/helponly.go, modules/diary/export_test.go, modules/diary/helponly_test.go, modules/diary/bootquiet_smoke_test.go, modules/diary/README.md, docs/console/introduction.md, docs/services/logging.md</files>
|
||||
<behavior>
|
||||
- TestHelpOnly true rows: no args (empty, non-nil); `--help`; `-h`; `serve --help`; `serve -h`; `-h serve`; `serve --help=true`; `-nh serve`; `-vh serve`; `list`; `list --format=json`; `help`; `help serve`; `--env staging`; `--env=staging`; `-v`; `--env help` (env value, no command word); `__complete serve ""`; `__completeNoDesc se`.
|
||||
- TestHelpOnly false rows: `serve`; `migrate -v`; `queue:work`; `--env list serve`; `serve -- --help`; `serve --help=false`; `serve -xh` (x is not a global shorthand, the cluster stops there); `blog:import https://example.com/help`.
|
||||
- TestHelpOnlyAgreesWithCobra: for every row cobra accepts, bonfire.Execute on a fresh NewRootIO root whose serve, migrate and queue:work commands record that their Run ran gives ran == !HelpOnly(args); one passthrough row (`tool --help` on a Passthrough command) is asserted as the documented exception: HelpOnly is true and the command's Run still runs. The completion literals in helponly.go equal cobra.ShellCompRequestCmd and cobra.ShellCompNoDescRequestCmd.
|
||||
- TestBootQuietSmoke new rows: no args, `list`, `help serve` and `-h` hide the boot Info records on the console; `-v serve --help` and `queue:work` show them; every row keeps the boot Warn record on the console and the Info records in the daily file.
|
||||
</behavior>
|
||||
<action>
|
||||
Tests first (RED), then extend `helpOnly` (GREEN). Expansion of Task 1's single path: after this task `bin/<app> list`, `bin/<app> help migrate`, a bare `bin/<app>` and shell completion requests no longer print boot chatter.
|
||||
|
||||
`modules/diary/helponly.go`: besides the help flag, `helpOnly` now also reports true when args hold no command word (the first token before `--` that is neither a flag nor the value of `--env`, the rule of bonfire's `hasCommandWord`, under which `Execute` prints the list), or when that command word is `list`, `help`, `__complete` or `__completeNoDesc` (string literals with a comment naming cobra's `ShellCompRequestCmd` and `ShellCompNoDescRequestCmd`; no cobra import in non-test code). Accept `--help=<bool>` through `strconv.ParseBool` (true counts, false and unparsable do not), matching pflag. The help flag still counts after the command word (cobra honours it there) but not after `--`. Update the doc comment to list every case and keep the passthrough caveat. Do not treat an unknown or misspelled command as help-only (unknowable before boot; the error block path is unchanged).
|
||||
|
||||
`modules/diary/export_test.go` (new, package diary): `var HelpOnly = helpOnly`. `modules/diary/helponly_test.go` (new, package diary_test, imports bonfire and cobra; test-only imports are fine): `TestHelpOnly` as a table with the rows above; `TestHelpOnlyAgreesWithCobra` builds a fresh root per row with `bonfire.NewRootIO` (empty stdin, discarded stdout and stderr) and runs `bonfire.Execute`; mark rows cobra rejects as usage errors (unknown command, unknown shorthand) as excluded from the agreement check the way bonfire's peekCases mark them, give serve one optional argument so `serve -- --help` is accepted, keep the passthrough exception as its own assertion, and assert the two completion literals against the cobra constants.
|
||||
|
||||
`modules/diary/bootquiet_smoke_test.go`: add the rows listed in the behavior, adding a `queue:work` command to the test root. Keep it hermetic and `-short`.
|
||||
|
||||
Docs in the same commit. diary README: the Features bullet says which invocations boot quietly (no command, list, help, `-h`/`--help`, shell completion requests) and the passthrough caveat; Testing names `TestHelpOnly` and `TestHelpOnlyAgreesWithCobra`. docs/console/introduction.md, Global options section, after the paragraph that says the binary reads the options before it loads `config/` and starts plugins: a paragraph saying that an invocation that only lists or describes commands (no command, `list`, `help`, `--help` or `-h`, shell completion) starts the plugins with the console log at warnings, as `-q` would, so the info lines plugins log while they register and boot do not print above the help; `-v` shows them; `serve`, `queue:work`, `migrate` and every other command boot at the configured levels, and log files keep their levels; it works for both programs and needs no rebuild of the generated `main.go`, only the framework version. Add an example line such as `./bin/acme -v serve --help` to the existing sh block only if the docs checker accepts it, and do not write a `bin/acme __complete` command line in docs (the checker validates command names; say "shell completion requests" instead). docs/services/logging.md, section "Logging from a plugin": a short paragraph that Register and Boot run before every command, so a plugin logs what it registered or collected at Debug (visible with `-vv`) rather than Info; only help-only invocations boot with the console at warnings, while `migrate`, `queue:work` and one-off commands print every Info record a plugin logs while booting. rqd edited docs/console/introduction.md first: re-read it and change only the Global options section. Commit message, no co-author or attribution trailer: `feat(diary): boot list, help and completion requests quietly (quick-261028-rqc)`. Stage files by path; leave other sessions' uncommitted files (STATE.md, state.json, G15OFFICE.md) alone.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -short -count=1 -run 'TestHelpOnly|TestHelpOnlyAgreesWithCobra' -v ./modules/diary 2>&1 | grep -q -- '^--- PASS: TestHelpOnlyAgreesWithCobra' && go test -short -count=1 -run 'TestBootQuietSmoke' -v ./modules/diary 2>&1 | grep -q -- '^--- PASS: TestBootQuietSmoke' && go test -short -count=1 ./... && (cd examples/hello && go test -short -count=1 ./...) && git diff --quiet HEAD -- internal/build examples/hello cmd/summer modules/bonfire && go test ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check</automated>
|
||||
</verify>
|
||||
<done>Before committing, the same apisurface `comm -23` check as Task 1 prints nothing for modules/diary (no exported change in this task). helpOnly covers no command, list, help, the help flag forms and completion requests, agrees with cobra on every accepted row except the documented passthrough case; the boot smoke rows hide Info only for help-only invocations without -v; the console and logging docs describe the behaviour; go vet, go test -short ./..., the hello workspace tests and the docs checker are green; the generated main, goldens, examples/hello, cmd/summer and bonfire are untouched; one commit.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| argv → diary.FromGlobals / Bootstrap / Setup | Raw process arguments decide, before plugins boot, whether the console starts at warn |
|
||||
| plugin Register/Boot → diary channels | Plugin log records go through diary (component loggers and slog.Default) to console, daily and stderr channels |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-rqc-01 | Repudiation | boot-time Quiet in diary.Bootstrap / diary.Setup | low | mitigate | Only console-driver channels are raised, and only to warn (the existing -q rule); daily files and stderr channels keep their levels, warnings and errors still print, -v restores Info. TestBootQuietSmoke asserts the daily file receives the boot Info records and the console keeps the Warn record on every help-only row. |
|
||||
| T-rqc-02 | Tampering | helpOnly pre-scan | low | mitigate | A misclassified argv can only hide console Info records during boot, never warnings or a command's own output: ApplyGlobals re-applies the parsed options before any command runs. TestHelpOnlyAgreesWithCobra pins the classification against cobra's real dispatch, with the passthrough case documented. Literal Options without Args do no check, so tests and embedding programs keep their levels. |
|
||||
| T-rqc-03 | Information disclosure | boot records | low | accept | No new record or sink; every channel still redacts through sunscreen. |
|
||||
| T-rqc-SC | Tampering | go module dependencies | low | accept | No new dependency; non-test diary code imports only the standard library for this, and cobra appears only in a test file of an existing dependency. go.mod and go.sum stay unchanged. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `go vet ./...` and `go test -short ./...` are green after each commit; `cd examples/hello && go test -short ./...` is green with its main.go untouched.
|
||||
- `go test ./cmd/summer -run TestDocsTree` and `go run ./cmd/summer docs:build --check` pass (every new identifier in the README and docs exists).
|
||||
- scripts/apisurface shows only the addition `field Options.Args []string` for modules/diary; modules/bonfire, internal/build, examples/hello and cmd/summer have no diff.
|
||||
- Manual spot check, optional: in an application with a plugin that logs Info at boot, after moving only the framework version (no `summer build` regeneration needed beyond the normal rebuild), `bin/<app> serve --help` shows only the help, `bin/<app> -v serve --help` shows the boot lines first, `bin/<app> serve` logs them as before.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Help-only invocations (no command, list, help, -h/--help, completion requests) print no plugin Register/Boot Info records on the console unless -v is given; warnings still print.
|
||||
- serve, queue:work, migrate and every other command boot at the configured levels, unchanged; non-console channels keep their levels; literal Options keep their levels.
|
||||
- The generated main is unchanged, so old and new generated mains compile against old and new framework versions, and the behaviour arrives with a framework upgrade alone.
|
||||
- Strictly additive exported API (Options.Args only); the diary README and docs updated in the same commits as the code.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/261028-rqc-quiet-plugin-boot-logs-on-console-commands-like-help-framewo/261028-rqc-SUMMARY.md` when done.
|
||||
|
||||
For the orchestrator's separate docs commit (planning docs never share a commit with code): the todo `.planning/todos/pending/2026-10-21-quiet-plugin-boot-logs-on-console-commands.md` is resolved on the framework side only. Close it with a resolution line naming the two code commits, and record the remaining plugin-side work as a new pending todo: log the boot records of sm-paymentgateway-plugin (`boot.go:129`) and sm-paymentgatewaysubscriptions-plugin (`boot.go:86`) at Debug through `diary.For`, and sweep the other shared plugins for `slog.Default()` Info calls in Register/Boot (no breaking change, plugin repos first, then pointer bumps). Post a short comment on g15office task izelucbh (`.planning/G15OFFICE.md`).
|
||||
</output>
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
phase: quick-261028-rqc
|
||||
plan: 01
|
||||
subsystem: diary
|
||||
tags: [logging, console, boot, help]
|
||||
status: complete
|
||||
requires: [261028-rqd]
|
||||
provides:
|
||||
- diary.Options.Args (filled by FromGlobals from os.Args[1:])
|
||||
- help-only boot rule in diary.Bootstrap and diary.Setup
|
||||
affects: [generated main (unchanged, gets the behaviour via framework upgrade), cmd/summer]
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: [tolerant argv pre-scan pinned by a cobra-agreement test]
|
||||
key-files:
|
||||
created:
|
||||
- modules/diary/helponly.go
|
||||
- modules/diary/export_test.go
|
||||
- modules/diary/helponly_test.go
|
||||
- modules/diary/bootquiet_smoke_test.go
|
||||
modified:
|
||||
- modules/diary/diary.go
|
||||
- modules/diary/README.md
|
||||
- docs/services/logging.md
|
||||
- docs/console/introduction.md
|
||||
decisions:
|
||||
- "Help-only detection lives in diary (Bootstrap/Setup via Options.Args filled by FromGlobals); the generated main is unchanged"
|
||||
- "helpOnly is a self-contained stdlib-only copy of PeekGlobals' scan rules (stop at --, skip --env value, global shorthand clusters v/q/n/h, =value forms) because internal/consolekit exports no scan helpers"
|
||||
- "--help=<bool> and -h=<bool> follow pflag (last occurrence wins, unparsable ignored)"
|
||||
metrics:
|
||||
duration: 8min
|
||||
completed: 2026-10-28
|
||||
tasks: 2
|
||||
files: 8
|
||||
actuals:
|
||||
tokens: 7800
|
||||
tasks: 2
|
||||
commits: 2
|
||||
plan_head_before: 653d95a5b9f6032eee2325e32e536feaadbe8127
|
||||
plan_head_after: 6c606c666b65202a59ca9ddbabf22a55df8ce602
|
||||
---
|
||||
|
||||
# Quick 261028-rqc: Quiet plugin boot logs on help-only console invocations Summary
|
||||
|
||||
`diary.FromGlobals` now records `os.Args[1:]` in the new `Options.Args`. When the arguments only ask the binary to describe itself and no `-v` is given, `diary.Bootstrap` and `diary.Setup` install the setup with `Quiet`, which raises only the console channels to warn. Those invocations are: no command, `list`, `help`, `-h`/`--help` (also after a command), `__complete` and `__completeNoDesc`. So `bin/<app> serve --help` no longer prints the Info lines plugins log from Register/Boot. The generated main is unchanged.
|
||||
|
||||
## Commits
|
||||
|
||||
| Task | Commit | Message |
|
||||
|------|--------|---------|
|
||||
| 1 | 6b9becd | feat(diary): boot --help invocations with the console at warn (quick-261028-rqc) |
|
||||
| 2 | 6c606c6 | feat(diary): boot list, help and completion requests quietly (quick-261028-rqc) |
|
||||
|
||||
## What was built
|
||||
|
||||
- `modules/diary/helponly.go`: the unexported `helpOnly(args)` scan, `clusterHelp` and `bootQuiet(opts)`, plus the completion command literals `__complete` and `__completeNoDesc` (no cobra import). The scan rules match the current `bonfire.PeekGlobals` after rqd's IN-02: stop at `--`, skip the value of `--env`, read a shorthand cluster letter by letter over v/q/n/h, and accept `=value` forms.
|
||||
- `modules/diary/diary.go`: the exported field `Options.Args []string`. `FromGlobals` keeps its `consolekit.Globals` signature and sets `Args` to a fresh, non-nil copy of `os.Args[1:]`. Bootstrap and Setup apply `bootQuiet` after `normalize`. `ApplyGlobals`, `SetVerbosity`, `Reset` and `normalize` are unchanged.
|
||||
- Tests:
|
||||
- `TestBootQuietSmoke` runs the generated main's sequence with a party plugin that logs Info from Register (through `slog.Default`) and Info plus Warn from Boot (through `diary.For`). It has 9 rows: `serve --help`, `serve`, literal options, no args, `list`, `help serve`, `-h`, `-v serve --help` and `queue:work`.
|
||||
- `TestFromGlobalsArgs`.
|
||||
- `TestHelpOnly` has 36 rows.
|
||||
- `TestHelpOnlyAgreesWithCobra` checks that a command runs exactly when `!helpOnly(args)`, for every row cobra accepts. Rows cobra rejects must return ExitUsage. A separate assertion covers the passthrough `--help` exception, and the completion literals are checked against `cobra.ShellCompRequestCmd` and `cobra.ShellCompNoDescRequestCmd`.
|
||||
- Docs:
|
||||
- diary README: Overview, a "Quiet boot for help" Features bullet, the API rows for `FromGlobals` and `Options`, and Testing.
|
||||
- docs/services/logging.md: the intro paragraph is rewritten (it used to name `diary.Options{}`), and a paragraph in "Logging from a plugin" says to log boot facts at Debug.
|
||||
- docs/console/introduction.md, Global options: a paragraph on quiet boot and the example line `./bin/acme -v serve --help` (the docs checker accepts it).
|
||||
|
||||
## Verification
|
||||
|
||||
- `go vet ./...`, `go test -short -count=1 ./...` and `go -C examples/hello test ./...` pass at both commits.
|
||||
- `go test ./cmd/summer -run TestDocsTree` and `go run ./cmd/summer docs:build --check` pass ("no problems found").
|
||||
- apisurface: compared with the base, the only change for modules/diary is `field Options.Args []string`. Task 2 has no exported change.
|
||||
- `git diff --quiet HEAD -- internal/build examples/hello cmd/summer modules/bonfire` passes, and `TestGenerateMainInstallsDiary` passes unchanged.
|
||||
- `TestDiaryDoesNotLinkTheConsoleStack` still passes. diary's non-test code imports only the standard library for this change; cobra appears only in `helponly_test.go`.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
**1. [Adaptation] Added extra rows to the shared case table, beyond the plan's list**
|
||||
- **Rows added:** `serve --help=1`, `--help=false` with no command (it lists, so it counts as help-only), `serve --help --help=false` (the last one wins), `serve -vh=true`, `serve -h=false`, `serve -qv`, `serve --help=yes` (usage error), `blog:import help` and `-q --env=list migrate`.
|
||||
- **Why:** they pin the pflag `=value` semantics and the "help after the command word is an argument" case.
|
||||
- **Test root:** it has `blog:import` (one optional arg) and the passthrough command `acme:tool`. The plan's bare `tool` name is not a valid bonfire command name.
|
||||
|
||||
**2. [Adaptation] Literal-options row in TestBootQuietSmoke uses args `serve --help`**
|
||||
- **Why:** this proves that no check runs without Args even when the arguments do ask for help.
|
||||
|
||||
Otherwise the plan was executed as written.
|
||||
|
||||
## Notes / Known limitations
|
||||
|
||||
- Under `go test`, a caller that uses `FromGlobals` (for example `examples/hello`'s `run`) reads the test binary's `os.Args`. Those are only `-test.*` flags, so there is no command word and the boot is quiet on the console. `ApplyGlobals` restores the levels before a command runs. The hello tests do not assert console logs, so nothing changes for them. The `Options.Args` doc says that a caller dispatching on other arguments sets Args itself.
|
||||
- A command flag that takes a value can swallow `--help` as that value (`cmd --name --help`), and `helpOnly` still counts it as help. This only affects console Info lines during boot, and `ApplyGlobals` re-applies the parsed globals before the command runs.
|
||||
|
||||
## Follow-up for the orchestrator (not done here)
|
||||
|
||||
- Close todo `.planning/todos/pending/2026-10-21-quiet-plugin-boot-logs-on-console-commands.md` on the framework side (commits 6b9becd and 6c606c6).
|
||||
- Open a new pending todo for the plugin side. sm-paymentgateway-plugin (`boot.go:129`) and sm-paymentgatewaysubscriptions-plugin (`boot.go:86`) should log their boot records at Debug through `diary.For`. Then sweep the other shared plugins for `slog.Default()` Info calls in Register/Boot. This is not a breaking change: commit the plugin repos first, then the pointer bumps.
|
||||
- Post a g15office comment on task izelucbh.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: modules/diary/helponly.go, export_test.go, helponly_test.go, bootquiet_smoke_test.go
|
||||
- FOUND: 6b9becd, 6c606c6 in git log
|
||||
@@ -0,0 +1,346 @@
|
||||
---
|
||||
phase: quick-261028-rqd
|
||||
plan: 01
|
||||
quick_id: 261028-rqd
|
||||
slug: console-review-info-findings-in-01-in-13-and-the-delegate-si
|
||||
type: execute
|
||||
mode: quick-batch
|
||||
wave: 1
|
||||
# rqc (quiet plugin boot logs on help/list) also edits bonfire and diary and may
|
||||
# add a diary -> bonfire use. IN-07 has to see that final state so it can move
|
||||
# the use behind the leaf package. Execution is sequential on master, so this
|
||||
# ordering costs no parallelism.
|
||||
depends_on: []
|
||||
autonomous: true
|
||||
files_modified:
|
||||
# Task 1: bonfire root, flags and errors (IN-08, IN-03, IN-06, IN-02, IN-04)
|
||||
- modules/bonfire/errors.go
|
||||
- modules/bonfire/errors_test.go
|
||||
- modules/bonfire/execute.go
|
||||
- modules/bonfire/root.go
|
||||
- modules/bonfire/root_test.go
|
||||
- modules/bonfire/globals.go
|
||||
- modules/bonfire/globals_test.go
|
||||
- modules/bonfire/command.go
|
||||
- modules/bonfire/command_test.go
|
||||
- modules/bonfire/list.go
|
||||
- modules/bonfire/README.md
|
||||
- go.mod
|
||||
- go.sum
|
||||
- modules/cabana/token_commands.go
|
||||
- modules/cabana/commands.go
|
||||
- modules/surf/routelist_command.go
|
||||
- modules/surf/summary_test.go
|
||||
- modules/lighthouse/centrifugo/commands.go
|
||||
- modules/lagoon/commands.go
|
||||
- modules/lagoon/keygen.go
|
||||
- modules/conga/commands.go
|
||||
- docs/console/writing-commands.md
|
||||
- docs/console/introduction.md
|
||||
- scripts/check-phase19.8.sh
|
||||
- .planning/research/STACK.md
|
||||
- .planning/notes/2026-10-21-console-charm-stack.md
|
||||
# Task 2: live views and diary (IN-11, IN-10, IN-12, IN-01, IN-07)
|
||||
- modules/bonfire/live.go
|
||||
- modules/bonfire/live_test.go
|
||||
- modules/bonfire/output.go
|
||||
- modules/bonfire/output_test.go
|
||||
- modules/bonfire/styles.go
|
||||
- modules/bonfire/helpers.go
|
||||
- internal/consolekit/consolekit.go
|
||||
- internal/consolekit/consolekit_test.go
|
||||
- modules/diary/console.go
|
||||
- modules/diary/diary.go
|
||||
- modules/diary/channels.go
|
||||
- modules/diary/diary_test.go
|
||||
- modules/diary/console_internal_test.go
|
||||
- modules/diary/deps_test.go
|
||||
- modules/diary/README.md
|
||||
# Task 3: summer delegates, docs, gate script, records (IN-13, WR-10 128+n, IN-05, IN-09)
|
||||
- cmd/summer/runtime.go
|
||||
- cmd/summer/runtime_test.go
|
||||
- docs/console/scaffolding.md
|
||||
- docs/setup/upgrading.md
|
||||
- .planning/phases/19.8-console-commands-overhaul/19.8-REVIEW-DISPOSITION.md
|
||||
- .planning/todos/completed/2026-10-22-console-review-info-findings.md
|
||||
files_deleted:
|
||||
- .planning/todos/pending/2026-10-22-console-review-info-findings.md # git mv to todos/completed/
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "IN-08: bonfire.Sanitize never returns a string with invalid UTF-8. A lone 0x9b (8-bit CSI) or any other invalid byte becomes U+FFFD before the control scan, and a clean valid string is still returned unchanged."
|
||||
- "IN-03: Execute converts cobra's unknown-command error into a typed UsageError in one place. It does so only when root.Find on the same args reproduces that exact error, so no plugin command ran. isUsage and RenderError no longer match the 'unknown command' text: a Run error worded that way exits 1 and is printed verbatim. 'summer migrat' still exits 2 with `Command \"migrat\" is not defined.` and the did-you-mean Try lines."
|
||||
- "IN-06: bonfire.Execute no longer calls fang. It runs root.ExecuteContext, renders errors with RenderError on root.ErrOrStderr() and enables legacy Windows ANSI on the out/err files through lipgloss.EnableLegacyWindowsANSI. After Execute, the root's help function is still bonfire's list renderer. fang is gone from go.mod, and the output of list, help and error blocks is byte-identical (the existing goldens pass unchanged)."
|
||||
- "IN-02: PeekGlobals honours --verbose=N, -v=N, --quiet=B, -q=B, --no-interaction=B, -n=B, --ansi=B and --no-ansi=B the way pflag does. TestGlobalFlagsAgree covers each form. Once cobra has parsed the command's flags, Execute's error block uses the parsed globals, so `--name -v` (where -v is the value of --name) does not switch the block to the -v chain."
|
||||
- "IN-04: bonfire.FlagBool(in, name) is new, additive API with exactly lagoon's former flagOn semantics (strconv.ParseBool; unset, empty or unparsable is false). cabana (token json/revoked, admin superuser), surf route:list, centrifugo, lagoon, conga and bonfire's own list --all use it, so --json=1 works everywhere. summer's flagTrue/flagBool and flare's flagSet keep their wider value sets, because narrowing them would be a behaviour break; this is recorded in the disposition."
|
||||
- "IN-11: out.Println() during a live view prints a blank line above the view instead of dropping it."
|
||||
- "IN-10: out.Error during a live view that this console owns prints above the view when the console's error writer is a terminal. It no longer lands mid-frame."
|
||||
- "IN-12: the signal relay kills the program only after the program has started (the wrapped model's Init closed a ready channel) or Run has ended, so a signal during view start-up still restores the cursor. Windows (part 2) is unchanged and documented in raise's doc comment and the disposition: Go cannot report whether another handler is registered, and returning without exiting would swallow Ctrl+C for commands without one."
|
||||
- "IN-01: diary's styled console handler drops HideOnTTY records only when its writer is a terminal (checked once when the handler is built). Forced color (--ansi, FORCE_COLOR) on a redirected stderr keeps the startup summary and listening-on records."
|
||||
- "IN-07: `go list -deps` of modules/diary, modules/bouncer and modules/wristband lists neither modules/bonfire nor bubbletea, bubbles, huh or cobra. ColorMode (with its constants), Palette, DefaultPalette, DetectProfile, Sanitize, Globals (with Args) and the PrintAbove hook live in internal/consolekit. bonfire re-exports each one under its current name (type aliases, wrapper funcs, re-declared constants), so no exported bonfire or diary identifier is removed or changes type, and bonfire.OnGlobals(diary.ApplyGlobals) still compiles."
|
||||
- "IN-13: bonfire.ErrWriter(out) is new, additive API that returns the stream the root's error block goes to: os.Stderr for NewRoot on os.Stdout, errW for NewRootIO and NewOutput, os.Stderr for any non-bonfire Output. Off a terminal, summer's delegates send the app binary's stderr there, and TestDelegateExitCodePassthrough checks that the binary's own error block reached the root's error writer."
|
||||
- "WR-10 follow-up: a delegate that dies by a signal makes summer exit 128+n. SIGINT (130) and SIGTERM (143) are treated as already reported and print no block. Any other signal (SIGKILL is 137) gets summer's block with the argv masked by argmask.Join. docs/console/introduction.md says so."
|
||||
- "IN-05: the code is unchanged (the finding's documentation option). docs/setup/upgrading.md (0.4 Result lines) and docs/console/scaffolding.md say that -q hides the Created and Added lines, like every success line."
|
||||
- "IN-09: scripts/check-phase19.8.sh names no consuming application, and no split spelling of one. Every sibling-application path and the forbidden-name list come from PHASE198_* environment variables with no defaults. A stage or sub-check whose variable is unset prints NOT RUN, and PHASE198_REQUIRE_SIBLINGS=1 turns that into a failure."
|
||||
- "Each finding is its own commit with its pinning test (RED recorded on the unchanged code, except for IN-05 and IN-09) and its README and docs changes. Planning docs go in separate commits. There is no Co-Authored-By trailer, and nothing is pushed or tagged."
|
||||
artifacts:
|
||||
- path: internal/consolekit/consolekit.go
|
||||
provides: "leaf package: ColorMode, Palette, DefaultPalette, DetectProfile, Sanitize, Globals + Args + Normalize, SetPrintAbove/PrintAbove hook"
|
||||
contains: "func SetPrintAbove("
|
||||
- path: modules/bonfire/command.go
|
||||
provides: "FlagBool"
|
||||
contains: "func FlagBool(in Input, name string) bool"
|
||||
- path: modules/bonfire/helpers.go
|
||||
provides: "ErrWriter accessor"
|
||||
contains: "func ErrWriter(out Output) io.Writer"
|
||||
- path: modules/bonfire/execute.go
|
||||
provides: "Execute without fang; typed unknown command; parsed globals for the error block"
|
||||
contains: "root.ExecuteContext("
|
||||
- path: cmd/summer/runtime.go
|
||||
provides: "128+n mapping and stderr through bonfire.ErrWriter"
|
||||
contains: "bonfire.ErrWriter(out)"
|
||||
- path: modules/diary/deps_test.go
|
||||
provides: "pins IN-07 with go list -deps"
|
||||
contains: "func TestDiaryDoesNotLinkTheConsoleStack"
|
||||
key_links:
|
||||
- from: modules/diary/console.go
|
||||
to: internal/consolekit/consolekit.go
|
||||
via: "diary uses consolekit.Sanitize, consolekit.PrintAbove, consolekit.DetectProfile and consolekit.DefaultPalette instead of bonfire"
|
||||
pattern: "consolekit\\.(Sanitize|PrintAbove)"
|
||||
- from: modules/bonfire/live.go
|
||||
to: internal/consolekit/consolekit.go
|
||||
via: "bonfire's init registers printAboveLines as consolekit's PrintAbove hook"
|
||||
pattern: "consolekit\\.SetPrintAbove\\(printAboveLines\\)"
|
||||
- from: cmd/summer/runtime.go
|
||||
to: modules/bonfire/helpers.go
|
||||
via: "appStreams off a terminal returns (out, bonfire.ErrWriter(out))"
|
||||
pattern: "bonfire\\.ErrWriter\\(out\\)"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Close the console review's deferred info findings IN-01..IN-13 and the WR-10 follow-up (exit code 128+n for a delegate killed by a signal). All 14 were verified open on 2026-10-28; the sources are todo `.planning/todos/pending/2026-10-22-console-review-info-findings.md` and `19.8-REVIEW.md`. Keep every exported API and current behaviour backward compatible. Where a fix would break behaviour, record it instead of forcing it (IN-04's wider helpers, IN-12's Windows part, IN-05's code).
|
||||
|
||||
Output: about 15 local commits on master. Each finding gets one `fix(...)`, `refactor(...)` or `docs(...)` commit with its pinning test and its README and docs changes. A separate planning-docs commit records the fang removal, and a final records commit updates the disposition and moves the todo. Nothing is pushed, tagged, released or posted to the changelog.
|
||||
|
||||
Shape: this is a gap-closure-style quick task. The orchestrator fixed the grouping at three tasks, so there is no tracer task. Every code finding follows the pattern of plans 19.8-05 and 261022-4z8: write or extend the test, run it on the unchanged code and record the RED (assertion output) for the SUMMARY, fix it, run it GREEN, update README and docs, then commit.
|
||||
|
||||
**Hard commit rules.** One finding per commit, in the order each task lists. Subject style: `fix(bonfire): <what> (IN-08, quick-261028-rqd)`. No `Co-Authored-By` trailer: the user's CLAUDE.md overrides any attribution reminder. Planning docs and code never go in the same commit. Stage and commit with explicit paths only (`git add -- <paths>`, then `git commit -m "…" -- <paths>`), never `git add -A` or `git add .`.
|
||||
|
||||
Planner decisions (the executor does not revisit these; record them in the SUMMARY):
|
||||
|
||||
- **P-01 (IN-06): drop fang rather than restore the help func.** Restoring is impossible inside fang.Execute: it sets the root help func just before root.ExecuteContext, and cobra has no per-root hook in between. bonfire already turns off every fang feature except the error handler. The only other thing fang did was enable Windows VT processing, and `lipgloss.EnableLegacyWindowsANSI(*os.File)` replaces that (lipgloss v2.0.6 is already a direct dependency; it is a no-op off Windows). No new module enters go.mod, and go mod tidy removes fang and its exclusive transitive modules.
|
||||
- **P-02 (IN-03): verify cobra's error by re-running Find.** After ExecuteContext fails, Execute calls `root.Find(args)`. When that returns an error, its command is the root and its message equals the returned error's message, the error is cobra's legacyArgs unknown-command error and no plugin code ran. Only then does Execute wrap it as `&UsageError{Err: &unknownCommandError{…}}`. The unexported error type keeps cobra's original Error() text, and name and suggestions are parsed once there with the existing parseUnknownCommand. root.Args is deliberately not set: that would move the check after flag parsing and change `acme foo --help` and `acme foo --bogus` (help or an unknown-flag error instead of `Command "foo" is not defined.`). RenderError detects the typed error with errors.As. Direct callers of RenderError that pass a raw cobra error now get cobra's text; the doc comment says so.
|
||||
- **P-03 (IN-02): token scan plus parsed globals, not a throwaway pflag FlagSet.** pflag with UnknownFlags whitelisted keeps reading a shorthand cluster after an unknown letter (pflag v1.0.9 parseSingleShortArg), so `-xq`, where -x is a command string flag with value q, would become quiet. That is worse than today's scan, which stops at the first non-global letter. So extend the scan to the `=value` forms with pflag's own value rules. Count: strconv.ParseInt base 0 sets the count. Bool: strconv.ParseBool sets true or false. --ansi and --no-ansi follow colorFlag.Set: true sets the mode, false resets it to ColorAuto only when the mode is that flag's. An unparsable value is ignored, and a parse that never fails stays never-failing. In a shorthand cluster, '=' straight after a global letter takes the rest of the token as that flag's value. For the `--name -q` half, which no pre-boot reader can resolve: Execute puts a holder in the context, the root's PersistentPreRunE stores the globals it applied, and the error block uses them when they are set, otherwise PeekGlobals. The PeekGlobals doc comment states the remaining limit.
|
||||
- **P-04 (IN-04): FlagBool semantics = lagoon's flagOn.** `v, ok := in.Flag(name)`; false when in is nil or !ok; otherwise strconv.ParseBool(v), and an error is false. Sites that compared with the string true only widen, which is the fix. lagoon and conga keep their exact semantics. cmd/summer's flagTrue and flagBool (which accept an empty value and yes) and flare's flagSet (anything but false) are left alone, because moving them would turn values that are on today off. The disposition records this.
|
||||
- **P-05 (IN-07): leaf package `internal/consolekit` plus aliases in bonfire.** Methods cannot be declared on an alias of another package's type, so Globals.Args moves to consolekit. bonfire's `normalize` method and its `peekShorthands` method become package-level functions (consolekit.Normalize, and an unexported bonfire func). The docs checker cannot resolve a method through an alias (`go doc -c os PathError.Error` fails the same way), so the bonfire README API row `bonfire.Globals.Args` is reworded to name the method next to `bonfire.Globals` without the three-part span. Do not extend internal/docsite. PrintAbove becomes a hook: consolekit.PrintAbove(s) calls the function registered with consolekit.SetPrintAbove (an atomic.Pointer, nil gives false), and bonfire registers printAboveLines in an init func. When bonfire is not linked, no live view can run, so false is correct.
|
||||
- **P-06 (IN-13): a new accessor instead of console.err.** In production, NewRoot builds the console with err = out = os.Stdout (only cobra's error stream is moved to os.Stderr), so returning console.err would send the child's stderr to stdout and regress CR-03. The console therefore gets an error-stream field. NewRootIO sets it to errW, NewRoot sets it to os.Stderr whenever it moves the root's error stream there (refactor NewRootIO's body into an unexported constructor that also returns the console), and NewOutput leaves it unset (ErrWriter then returns c.err). out.Error() keeps writing where it writes today.
|
||||
- **P-07 (WR-10 follow-up).** In runAppCmd, an *exec.ExitError whose `Sys().(syscall.WaitStatus)` reports Signaled() gives code 128+int(Signal()). SIGINT and SIGTERM become `bonfire.Reported(bonfire.WithExitCode(err, code))`. Any other signal becomes `bonfire.WithExitCode(err, code)` with the existing masked `summer: <argmask.Join(argv)>: …` message. A normal exit and a start failure behave exactly as today. Factor the mapping into an unexported helper that takes the signal, so SIGINT is covered without delivering one: a background job's inherited SIG_IGN would make a self-sent SIGINT flaky.
|
||||
- **P-08 (IN-05): documentation only.** 0.4 already shipped the `Created`/`Added` success lines and upgrading.md documents the wording, so changing the output again would be a second break. Add that -q hides them.
|
||||
- **P-09 (IN-09): environment only, NOT RUN when unset.** This mirrors the script's docker convention (NOT RUN, and a failure with PHASE198_REQUIRE_DOCKER=1). New variables: PHASE198_PLUGIN_WORKSPACE, PHASE198_APPS (space-separated checkout paths; labels from basename), PHASE198_DEMO_APP, PHASE198_LANDING_APP, PHASE198_LANDING_FRONTEND, PHASE198_APP_NAMES (space-separated ERE patterns) and PHASE198_REQUIRE_SIBLINGS. The shared sm-*-plugin entries under PLUGINS_ROOT stay: they are shared plugins, not consuming applications. Earlier phase gates are out of scope; if they still name consumers, add a pending todo in the records commit.
|
||||
- **P-10.** Each new top-level pinning test joins its list in scripts/check-phase19.8.sh in the same commit, as in 261022-4z8 P-08. upgrading.md gets no new version section, because no release is cut. The SUMMARY gets a "Release-note input" section: exit 128+n for signal-killed delegates, FlagBool, ErrWriter, forced-color HideOnTTY, the Sanitize UTF-8 change, fang removed, and the delegate stderr now going to the root's error writer.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@CLAUDE.md
|
||||
@.planning/todos/pending/2026-10-22-console-review-info-findings.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-REVIEW.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-REVIEW-DISPOSITION.md
|
||||
@.planning/quick/261022-4z8-fix-the-open-19-8-re-review-findings-wr-/261022-4z8-SUMMARY.md
|
||||
|
||||
Source (read each file once, only the regions named; line numbers as of planning, so locate by symbol):
|
||||
@modules/bonfire/errors.go (isUsage, Sanitize, RenderError's unknown-command branch, parseUnknownCommand)
|
||||
@modules/bonfire/execute.go (Execute, exitCode, hasCommandWord: the whole file)
|
||||
@modules/bonfire/root.go (NewRoot, NewRootIO, PersistentPreRunE, SetHelpFunc)
|
||||
@modules/bonfire/globals.go (Globals, PeekGlobals, peekShorthands, normalize, Args, colorFlag, register)
|
||||
@modules/bonfire/globals_test.go (peekCases, TestGlobalFlagsAgree)
|
||||
@modules/bonfire/command.go (Input, cobraInput.Flag)
|
||||
@modules/bonfire/styles.go (ColorMode, Palette, DefaultPalette, DetectProfile)
|
||||
@modules/bonfire/output.go (console fields, newConsole, emit, Error)
|
||||
@modules/bonfire/helpers.go (consoleState, GlobalsOf, IsTerminal, PrintAbove)
|
||||
@modules/bonfire/live.go (runLive, watchSignals, raise, printAbove: about lines 340-546)
|
||||
@modules/bonfire/live_signal_unix_test.go (helper-process signal test pattern)
|
||||
@modules/bonfire/help_smoke_test.go (TestErrorRenderSmoke: unknown-command block expectations)
|
||||
@modules/diary/console.go (newConsoleHandler, Handle's HideOnTTY check, isTerminal)
|
||||
@modules/diary/diary.go (Options.Color, FromGlobals, ApplyGlobals)
|
||||
@modules/diary/channels.go (DriverConsole branch: DetectProfile, ttyFilter)
|
||||
@modules/diary/diary_test.go (TestHideOnTTY)
|
||||
@modules/pact/capabilities_test.go (around line 635: the go-tool test pattern for deps_test.go)
|
||||
@cmd/summer/runtime.go (appStreams, runAppCmd)
|
||||
@cmd/summer/runtime_test.go (TestExitHelperProcess, exitHelperRunner, TestDelegateExitCodePassthrough, TestRunAppCmdMasksSecrets, TestDelegateStreams, TestAppStreams)
|
||||
@cmd/summer/docs_test.go (runSummer: NewRoot on one buffer)
|
||||
@scripts/check-phase19.8.sh (header comment, env defaults lines 60-80, APP_REPOS, plugin list, APP_NAMES and NAME_SCOPE, the landing and apps stages)
|
||||
|
||||
Environment rules for every step:
|
||||
- **Concurrent sessions.** Record `BASE=$(git rev-parse HEAD)` before the first edit. Before editing a file, run `git status --short -- <file>`. If it shows changes this task did not make, do not edit that file; record the skipped change in the SUMMARY as a follow-up. Never touch, stage or commit `.planning/STATE.md`, `.planning/state.json`, `.planning/G15OFFICE.md`, `.msd/`, `.planning/quick-batches/` or other quick or phase directories. CLAUDE.md asks for extra care with cabana: check the two cabana files right before editing them.
|
||||
- **The Bash tool runs fish.** Wrap multi-statement scripts and `VAR=$(…)` in `bash -c '…'`. If the orchestrator gives a GOCACHE path, use it for every go command, including the gate script.
|
||||
- **Docker.** Docker-backed tests run without `-short`. If the Docker socket is denied, run the same command through `sudo -u golem` (CLAUDE.md).
|
||||
- **Foreign test failures.** If a test fails only because of another session's uncommitted files, export a clean HEAD into the scratchpad (`git archive HEAD | tar -x -C <dir>`), run that package's tests there, record both results in the SUMMARY and remove the export.
|
||||
- **Known flake.** `TestOIDCSubjectIsRandomSmoke` (wristband) can fail once at random. Rerun it once before treating it as a failure.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: bonfire root, flags and errors: IN-08, IN-03, IN-06, IN-02, IN-04 (five commits, plus one planning-docs commit)</name>
|
||||
<files>modules/bonfire/errors.go, modules/bonfire/errors_test.go, modules/bonfire/execute.go, modules/bonfire/root.go, modules/bonfire/root_test.go, modules/bonfire/globals.go, modules/bonfire/globals_test.go, modules/bonfire/command.go, modules/bonfire/command_test.go, modules/bonfire/list.go, modules/bonfire/README.md, go.mod, go.sum, modules/cabana/token_commands.go, modules/cabana/commands.go, modules/surf/routelist_command.go, modules/surf/summary_test.go, modules/lighthouse/centrifugo/commands.go, modules/lagoon/commands.go, modules/lagoon/keygen.go, modules/conga/commands.go, docs/console/writing-commands.md, docs/console/introduction.md, scripts/check-phase19.8.sh, .planning/research/STACK.md, .planning/notes/2026-10-21-console-charm-stack.md</files>
|
||||
<behavior>
|
||||
- IN-08 (extend TestSanitize): "a\x9b31mb" returns a valid UTF-8 string with no 0x9b byte, and the CSI is neutralised to "a<>31mb". A lone "\x9b" gives "<22>". "\xffok" gives "<22>ok". "plain ok" is returned unchanged (same string). RED: the unchanged fast path returns the raw byte.
|
||||
- IN-03 (new TestPluginErrorWordedUnknownCommand in errors_test.go): a root whose command returns errors.New(`unknown command "x" for "acme"`) exits ExitFailure (1), not 2. The block shows that text verbatim and no `is not defined` line. In the same test, Execute on "migrat" still exits 2 with `Command "migrat" is not defined.` and Try `bin/acme migrate`. RED: exit 2 and the rewritten message. TestErrorRenderSmoke and examples/hello's unknown-command check stay green unchanged.
|
||||
- IN-06 (new TestExecuteKeepsRootHelp in root_test.go): build a root on a buffer, Execute it with ["list"], reset the buffer, call root.HelpFunc()(root, nil) and assert the buffer equals what Execute with no args prints (bonfire's list). RED: fang's help func is installed and prints its own layout. Every golden in golden_test.go stays byte-identical.
|
||||
- IN-02 (rows added to peekCases with cobra: true, so TestGlobalFlagsAgree diffs them against cobra): "--verbose=2" gives 2; "-v=3" gives 3; "-vv=1" gives 1; "--quiet=true" gives Quiet and NoInteraction; "-q --quiet=false" gives Quiet false; "--no-interaction=1"; "--ansi=false" gives ColorAuto; "--ansi --ansi=false" gives ColorAuto; "--no-ansi=true" gives ColorNever; "--no-ansi --ansi=false" gives ColorNever. Each row is placed after a command word "migrate". RED: PeekGlobals ignores the forms. A second new test, TestErrorBlockUsesParsedGlobals: a command with a string flag --name whose Run returns fmt.Errorf("outer: %w", errors.New("inner")), run with "migrate --name -v". The block contains "Run with -v for details." and no "Caused by:". RED: PeekGlobals reads -v.
|
||||
- IN-04 (new TestFlagBool in command_test.go, through Call on a command with a Bare flag): no flag gives false; "--x" gives true; "--x=1", "--x=t", "--x=TRUE" and "--x=True" give true; "--x=false", "--x=0" and "--x=" give false; "--x=yes" gives false (ParseBool semantics, documented); a nil Input gives false. TestRouteListJSON gains the args {"--json=1"}. RED there: the string comparison prints the table.
|
||||
</behavior>
|
||||
<action>
|
||||
Order: IN-08, IN-03, IN-06, IN-02, IN-04, then the planning-docs commit. For each code finding: write the test, record the RED, fix, go GREEN, update docs, commit.
|
||||
|
||||
IN-08. In bonfire.Sanitize, the fast path returns s unchanged only when utf8.ValidString(s) holds and no rune is a control other than tab. Otherwise run strings.ToValidUTF8(s, "<22>") first, then the existing control filter. Update the doc comment (invalid UTF-8 becomes U+FFFD, so a raw 8-bit C1 byte cannot reach the terminal) and the bonfire README Sanitize row. Commit `fix(bonfire): replace invalid UTF-8 before the control scan in Sanitize (IN-08, quick-261028-rqd)`.
|
||||
|
||||
IN-03 (P-02). Add the unexported unknownCommandError type (name, suggestions, and Error() returning cobra's original message) in errors.go. Delete the text-prefix match from isUsage, so only a UsageError in the chain counts. In RenderError, replace the message-prefix branch with errors.As on *unknownCommandError, keeping the same output (message, suggestions as Try lines, the fallback `{bin} list`). In Execute, add the Find-verified conversion before rendering and before exitCode. wrongDirectory must see the converted error too, so a usage error gets no wrong-directory hint, as today. Update the RenderError and isUsage doc comments, the README error-block paragraph ("an unknown command, recognised by Execute"), and the docs/console/introduction.md exit-code table only if its wording depends on the text match. Commit `fix(bonfire): recognise cobra's unknown command error once in Execute (IN-03, quick-261028-rqd)`.
|
||||
|
||||
IN-06 (P-01). Rewrite Execute without fang: Find-verify and convert, render with the error writer root.ErrOrStderr(), return exitCode. Before running, call lipgloss.EnableLegacyWindowsANSI for root.OutOrStdout() and root.ErrOrStderr() when each is an *os.File. Keep the colorprofile.Writer unwrap in RenderError for outside callers. Remove the import, run go mod tidy at the repository root, and check that examples/hello still builds in the workspace (cd examples/hello and go build ./...). Update the Execute doc comment, the README paragraph and row that say Execute runs "through fang", and the README Dependencies line. Commit the code with go.mod and go.sum: `refactor(bonfire): run the root without fang so bonfire's root help stays installed (IN-06, quick-261028-rqd)`. Then make a separate planning-docs commit `docs(quick-261028-rqd): record that bonfire dropped fang`. In it, mark the fang row in .planning/research/STACK.md as removed (and correct its "imported only by bonfire and diary" note), and append a dated addendum to .planning/notes/2026-10-21-console-charm-stack.md: fang removed in quick-261028-rqd for IN-06, with VT enabling now through lipgloss.EnableLegacyWindowsANSI. The phase gate's DECISION_NAMES check still finds the module name in the note.
|
||||
|
||||
IN-02 (P-03). In PeekGlobals, add the `--verbose=`, `--quiet=`, `--no-interaction=`, `--ansi=` and `--no-ansi=` prefixed cases, and teach the shorthand scan to read '=value' after v, q or n (count or bool rules as in P-03). The scan still stops at the first letter that is not a global. Add the context holder: an unexported key type and a struct with the Globals and a set flag. Execute installs it, NewRootIO's PersistentPreRunE stores g into it when present in cmd.Context(), and Execute's error rendering prefers it. bonfire.Call never installs a holder. In the PeekGlobals doc comment, say that a value-taking command flag followed by a global-looking token is read as the global before boot, and that the command and its error block use cobra's reading. Mirror that one sentence in the README. Commit `fix(bonfire): read the =value forms of the global flags and use the parsed globals in the error block (IN-02, quick-261028-rqd)`.
|
||||
|
||||
IN-04 (P-04). Add `func FlagBool(in Input, name string) bool` in command.go with a doc comment that names the accepted values. Switch these sites: cabana token_commands.go (json at three places, revoked) and commands.go (superuser); surf routelist_command.go; centrifugo commands.go; lagoon commands.go and keygen.go (delete the unexported flagOn and call bonfire.FlagBool); conga commands.go (schedule:run --once and confirmProduction's --force); bonfire list.go (--all). Leave cmd/summer's flagTrue and flagBool and flare's flagSet unchanged (P-04). Add a FlagBool row to the bonfire README API table, a feature-list mention, and update docs/console/writing-commands.md: the Bare paragraph and the dry-run example read the switch with bonfire.FlagBool. If that example is a src= snippet, change its source and run `summer docs:sync`. Commit `feat(bonfire): add FlagBool and read every bare switch with it (IN-04, quick-261028-rqd)`.
|
||||
|
||||
Add each new top-level test name to its list in scripts/check-phase19.8.sh in its own commit: the bonfire list for TestPluginErrorWordedUnknownCommand, TestExecuteKeepsRootHelp, TestErrorBlockUsesParsedGlobals and TestFlagBool.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bash -c 'cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./... && go test -short ./modules/bonfire/... ./modules/surf/... ./modules/lagoon/... ./modules/conga/... ./modules/cabana/... ./modules/lighthouse/... ./cmd/summer/... ./internal/... && (cd examples/hello && go test ./...) && go run ./cmd/summer docs:build --check && test "$(grep -c "charm.land/fang" go.mod)" = 0 && test "$(grep -rl "charm.land/fang" --include=*.go modules cmd internal | wc -l)" = 0 && test "$(grep -n "HasPrefix(.*unknown command" modules/bonfire/errors.go | wc -l)" = 0 && test "$(grep -n "== \"true\"" modules/cabana/token_commands.go modules/cabana/commands.go modules/surf/routelist_command.go modules/lighthouse/centrifugo/commands.go modules/bonfire/list.go | wc -l)" = 0 && go test -short -race ./modules/bonfire'</automated>
|
||||
</verify>
|
||||
<done>Five fix, refactor or feat commits, IN-08, IN-03, IN-06, IN-02 and IN-04 in that order, each with its pinning test GREEN and its RED recorded, plus one planning-docs commit for the fang removal. fang is gone from go.mod. Every golden and help/list/error smoke test passes unchanged. The listed call sites use bonfire.FlagBool. go vet and go test -short ./... stay green.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: live views and diary: IN-11, IN-10, IN-12, IN-01, IN-07 (five commits)</name>
|
||||
<files>modules/bonfire/live.go, modules/bonfire/live_test.go, modules/bonfire/output.go, modules/bonfire/output_test.go, modules/bonfire/styles.go, modules/bonfire/globals.go, modules/bonfire/errors.go, modules/bonfire/helpers.go, modules/bonfire/README.md, internal/consolekit/consolekit.go, internal/consolekit/consolekit_test.go, modules/diary/console.go, modules/diary/diary.go, modules/diary/channels.go, modules/diary/diary_test.go, modules/diary/console_internal_test.go, modules/diary/deps_test.go, modules/diary/README.md, docs/console/writing-commands.md, scripts/check-phase19.8.sh</files>
|
||||
<behavior>
|
||||
- IN-11 (new TestPrintAboveBlankLine in live_test.go): on a tty console over a lockedBuffer, inside c.Spinner, call c.Println("before-blank"), c.Println() and c.Println("after-blank"). After the spinner, ansi.Strip of the output has a line that is empty or only spaces between the "before-blank" and "after-blank" lines. RED: the two lines are adjacent. Inspect the inserted-above byte layout first (TestPrintAboveOrderedNoLoss shows it) and assert on what bubbletea actually writes.
|
||||
- IN-10 (new TestErrorPrintsAboveView in live_test.go, internal): a console with tty true on out (a lockedBuffer) and a separate err lockedBuffer, with the console's err-is-terminal field forced true. Inside c.Spinner, call c.Error("view-error-line"). The line reaches out (above the view) once, and err stays empty. With the field false, the line goes to err as today. RED: it lands in err (on a real terminal, mid-frame).
|
||||
- IN-12 (new TestSignalRelayWaitsForStart, internal, no real signal): drive the factored relay with a test signal channel, ready and finished channels, and fake kill, raise and unregister functions. Send os.Interrupt with ready still open: kill is not called within 100ms. Close ready: kill is called. Close finished: raise is called once with os.Interrupt. A second case closes finished without ready: the relay proceeds and raises. RED is not applicable before the refactor (the seam does not exist); record that and add the "startup" mode check below if it can be made deterministic. Otherwise rely on this unit test and the unchanged TestLiveViewRestoresTerminalOnSignal, which must stay green.
|
||||
- IN-01 (flip TestHideOnTTY's first assertion, and add console_internal_test.go): the styled console with ColorAlways on a lockedBuffer (not a terminal) now shows "hidden summary" (RED: it is dropped today). An internal test builds newConsoleHandler on a buffer, forces the handler's terminal field true, and asserts a HideOnTTY record is dropped and an ordinary one written.
|
||||
- IN-07 (new TestDiaryDoesNotLinkTheConsoleStack in modules/diary/deps_test.go, following modules/pact/capabilities_test.go's go-tool pattern; skip when exec.LookPath("go") fails): `go list -deps` of the diary, bouncer and wristband import paths lists none of modules/bonfire, charm.land/bubbletea/v2, charm.land/bubbles/v2, charm.land/huh/v2 or github.com/spf13/cobra. RED: all five are present today. Every existing bonfire and diary test compiles and passes unchanged through the aliases; diary tests may keep their bonfire.ColorAlways spellings.
|
||||
</behavior>
|
||||
<action>
|
||||
Order: IN-11, IN-10, IN-12, IN-01, IN-07. Behaviour fixes come first; the pure refactor comes last.
|
||||
|
||||
IN-11. In printAbove, after trimming one trailing newline, send a single space when the block is empty and s was not empty: bubbletea v2.1.0's insertAbove returns early for an empty string. An empty s (no newline) still sends nothing. Comment the reason next to the substitution. Commit `fix(bonfire): keep a blank line printed during a live view (IN-11, quick-261028-rqd)`.
|
||||
|
||||
IN-10. Add a field to console that records whether the error writer is a terminal, computed in newConsole with isTerminal(errW). In emit, reroute through printAbove(c, s) when w is c.out, and also when w is c.err, c.tty is set and that field is true. Production consoles from NewRoot already have c.err equal to c.out, so this changes only consoles with a separate terminal stderr (NewOutput, NewRootIO). Update the emit doc comment and the README live-view sentence ("and its Error lines when stderr is a terminal"). Commit `fix(bonfire): print Error lines above a running live view (IN-10, quick-261028-rqd)`.
|
||||
|
||||
IN-12. Wrap the model in runLive with a small unexported model whose Init closes a ready channel through sync.Once and then returns the inner Init. Its Update returns the inner model's result; View is promoted. bubbletea v2.1.0 calls Init only after startRenderer, so ready means a renderer exists. Factor the relay goroutine of watchSignals into a function that takes the signal channel, ready, finished, kill, unregister and raise. In production it is called with view.prog.Kill, the liveMu unregister and raise. On a signal it first waits for ready or finished and then kills, as today. Windows part (IN-12 item 2): no behaviour change. Extend raise's doc comment: on platforms where the process cannot signal itself (Windows), the relay exits with 128 plus the signal number after restoring the terminal, so a command's own handler may not run; Go cannot tell whether another handler is registered. Add the same note in one sentence to the README live-view paragraph. Commit `fix(bonfire): kill a live view on a signal only after it has started (IN-12, quick-261028-rqd)`.
|
||||
|
||||
IN-01. In diary's newConsoleHandler, set a terminal field once from isTerminal(w), on the raw writer before the colorprofile wrapping. Handle drops a HideOnTTY record only when that field is true. Update the NewConsoleHandler and HideOnTTY doc comments, the diary README HideOnTTY sentence, and any docs page that describes HideOnTTY (grep docs/ for it): forced color on a redirected stream keeps the records. Commit `fix(diary): skip HideOnTTY records only on a terminal console, also with forced color (IN-01, quick-261028-rqd)`.
|
||||
|
||||
IN-07 (P-05). Create internal/consolekit with a package doc comment ("console types and helpers shared by bonfire and diary without the TUI stack"). Move into it, unchanged in behaviour:
|
||||
- ColorMode and its three constants
|
||||
- Palette and DefaultPalette
|
||||
- DetectProfile with forceColor and envValue
|
||||
- Sanitize
|
||||
- the Globals struct with its Args method, a Normalize function, and the verbosity cap it needs
|
||||
- the PrintAbove hook (SetPrintAbove and PrintAbove on an atomic.Pointer)
|
||||
|
||||
In bonfire, replace the moved declarations with type aliases (ColorMode, Palette, Globals), re-declared constants (ColorAuto, ColorAlways, ColorNever, each keeping its doc comment), and wrapper funcs (DefaultPalette, DetectProfile, Sanitize; PrintAbove stays bonfire's own func). Turn the peekShorthands and normalize methods into functions. Register printAboveLines with consolekit.SetPrintAbove in an init func in live.go. Keep bonfire's flag-name constants for parsing, and keep Args emitting exactly the same strings (TestGlobalsArgs pins them).
|
||||
|
||||
Switch diary.go, console.go and channels.go from bonfire to consolekit: Options.Color, FromGlobals and ApplyGlobals use consolekit.ColorMode and consolekit.Globals, which are identical types through the aliases. Doc comments may keep saying bonfire.ColorAuto and bonfire.OnGlobals. If rqc (which runs first) added another diary use of bonfire, move it the same way or behind consolekit; never re-add the import.
|
||||
|
||||
Docs: in the diary README Dependencies, drop bonfire and name internal/consolekit (the palette, color detection and sanitizer it shares with bonfire). Correct the overview sentence that says diary builds on bonfire. In the bonfire README, reword the Globals.Args API row per P-05. Run `go run ./cmd/summer docs:build --check`.
|
||||
|
||||
Commit `refactor(diary): share the console types through internal/consolekit so diary no longer links bonfire (IN-07, quick-261028-rqd)`.
|
||||
|
||||
Add the new test names to scripts/check-phase19.8.sh (bonfire list: TestPrintAboveBlankLine, TestErrorPrintsAboveView, TestSignalRelayWaitsForStart; DIARY_TESTS: TestDiaryDoesNotLinkTheConsoleStack, plus the new internal HideOnTTY test name) in the commit of each.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bash -c 'cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./... && go test -short -race ./modules/bonfire/... ./modules/diary/... ./internal/consolekit/... && go test -short ./... && (cd examples/hello && go test ./...) && go run ./cmd/summer docs:build --check && test "$(go list -deps ./modules/diary ./modules/bouncer ./modules/wristband | grep -cE "modules/bonfire$|charm.land/bubbletea|charm.land/huh|spf13/cobra")" = 0'</automated>
|
||||
</verify>
|
||||
<done>Five commits, IN-11, IN-10, IN-12, IN-01 and IN-07 in that order, each with its pinning test GREEN and its RED recorded. IN-12's seam test has no RED (no seam before the refactor); the SUMMARY notes it. diary, bouncer and wristband no longer link bonfire or the TUI stack. Every exported bonfire and diary identifier still exists with the same type. go vet and go test -short ./... are green, including -race for bonfire and diary.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: summer delegates, docs, the gate script and the records: IN-13, WR-10 128+n, IN-05, IN-09 (four commits plus the records commit)</name>
|
||||
<files>modules/bonfire/helpers.go, modules/bonfire/root.go, modules/bonfire/output.go, modules/bonfire/output_test.go, modules/bonfire/README.md, cmd/summer/runtime.go, cmd/summer/runtime_test.go, docs/console/introduction.md, docs/console/writing-commands.md, docs/console/scaffolding.md, docs/setup/upgrading.md, scripts/check-phase19.8.sh, .planning/phases/19.8-console-commands-overhaul/19.8-REVIEW-DISPOSITION.md, .planning/todos/completed/2026-10-22-console-review-info-findings.md</files>
|
||||
<behavior>
|
||||
- IN-13 (new TestErrWriter in output_test.go; TestAppStreams, TestDelegateStreams and TestDelegateExitCodePassthrough extended): ErrWriter is os.Stderr for NewRoot(…, os.Stdout); errW for NewRootIO(…, out, errW) (reach the Output through a command's Run); errW for NewOutput(in, out, errW), and out when errW is nil; os.Stderr for a stub Output that is not a bonfire console. In TestAppStreams, off a terminal, NewOutput with separate buffers gives stderr equal to the err buffer. In TestDelegateStreams, use NewRootIO with separate buffers: "log-line" reaches the error buffer and not stdout, and "data-line" reaches stdout. TestDelegateExitCodePassthrough's "exit code" subtest also asserts the error writer holds the helper's "ERROR the app binary failed" line once. RED: the child's stderr goes to the real os.Stderr.
|
||||
- WR-10 follow-up (TestDelegateExitCodePassthrough updated, plus new TestDelegateSignalExitCodes): the existing "killed by a signal" subtests (SIGKILL) now want exit 137 and still one block with "summer: migrate --bogus: signal: killed" and "--password ***", and never hunter2. A new helper mode "sigterm" (the child signals itself with SIGTERM) wants exit 143 and no " ERROR " block from summer. TestDelegateSignalExitCodes drives the factored mapping helper for SIGINT, SIGTERM and SIGKILL. Each result is returned from a test command's Run through bonfire.Execute: SIGINT gives exit 130 and an empty error writer; SIGTERM gives 143 and empty; SIGKILL gives 137 and one block with the argv masked. RED: exit 1 and a block for every signal.
|
||||
- IN-05: documentation only (no test). IN-09: the script's self-test stage plus the verify greps below.
|
||||
</behavior>
|
||||
<action>
|
||||
Order: IN-13, WR-10 follow-up, IN-05, IN-09, then the records commit.
|
||||
|
||||
IN-13 (P-06). Add the error-stream field to console, and a consoleState method that returns it (or c.err when unset). Add `func ErrWriter(out Output) io.Writer` to helpers.go next to IsTerminal, with a doc comment that names the three cases. Refactor NewRootIO's body into an unexported constructor that also returns the console, so NewRoot can set the console's error stream to os.Stderr whenever it calls root.SetErr(os.Stderr). In cmd/summer, appStreams returns (out, bonfire.ErrWriter(out)) off a terminal; the terminal branch is unchanged. Update the appStreams doc comment: the child's stderr goes to the root's error stream, which is os.Stderr for the real summer, so CR-03 still holds. Add an ErrWriter row to the bonfire README API table and a sentence to its helpers feature line. Check docs/console/introduction.md's delegate-stream paragraph and adjust it only if it contradicts. Commit `fix(summer): send a delegate's stderr to the root's error writer through bonfire.ErrWriter (IN-13, quick-261028-rqd)`.
|
||||
|
||||
WR-10 follow-up (P-07). Implement the mapping in runAppCmd through an unexported helper that takes argv, the wrapped error and the signal. Add the "sigterm" mode to TestExitHelperProcess. Update the runAppCmd doc comment, and the docs/console/introduction.md sentence that says a signal death gets a block and exit code 1: from now on it exits with 128 plus the signal number (130 for Ctrl+C, 143 for SIGTERM, 137 for SIGKILL), a SIGINT or SIGTERM death prints nothing more, and any other signal gets summer's block with secret-looking flag values masked. Commit `fix(summer): exit 128+n when a delegated binary dies by a signal (WR-10, quick-261028-rqd)`.
|
||||
|
||||
IN-05 (P-08). In docs/setup/upgrading.md, 0.4 "Result lines" bullet: add that these are success lines, so `-q` hides them, and that `plugin:add` prints `Added <dir> to summer.yaml, go.mod and go.work`; scripts that read the created paths should run without `-q`. In docs/console/scaffolding.md, next to "Each command prints `Created <path>`", add that -q hides the lines. No code change. Commit `docs(console): say that -q hides the Created and Added lines (IN-05, quick-261028-rqd)`.
|
||||
|
||||
IN-09 (P-09). In scripts/check-phase19.8.sh:
|
||||
- Remove every default path that points at a sibling application checkout, the application labels in APP_REPOS, the plugin-workspace variable named after an application, and the split-string name list in the docs stage.
|
||||
- Read the paths and the forbidden names from the PHASE198_* variables in P-09, with no defaults. APP_REPOS comes from PHASE198_APPS, labelled by basename. The three plugin checkouts move under PHASE198_PLUGIN_WORKSPACE. The landing stage uses PHASE198_LANDING_APP and PHASE198_LANDING_FRONTEND, and the roadmap step uses PHASE198_DEMO_APP.
|
||||
- When a stage or sub-check lacks its variable, it prints `NOT RUN <stage>: set <VAR>`. Under PHASE198_REQUIRE_SIBLINGS=1 the same condition fails, the way the docker stage treats PHASE198_REQUIRE_DOCKER.
|
||||
- Update the header's Environment section and the stage descriptions. Keep the shared sm-*-plugin entries under PLUGINS_ROOT.
|
||||
- Make sure --self-test still passes and still checks that every mode is wired. If it checks the env wiring, add a planted case for NOT RUN on an unset variable.
|
||||
|
||||
Run the docs stage once with PHASE198_APP_NAMES set to the old pattern list (take it from `git show $BASE:scripts/check-phase19.8.sh`), typed only into your shell command, to prove the name check still works; record that in the SUMMARY. Grep the earlier gate scripts (scripts/check-phase*.sh other than 19.8) for consumer names; if any match, the records commit adds a pending todo for them. Commit `fix(scripts): read the sibling application paths and names from the environment in the 19.8 gate (IN-09, quick-261028-rqd)`.
|
||||
|
||||
Records commit (planning docs only): `docs(quick-261028-rqd): record the console info findings as fixed and close the todo`.
|
||||
- 19.8-REVIEW-DISPOSITION.md: set IN-01..IN-13 to `fixed` in the frontmatter and both tables, each with its commit hash and `(quick 261028-rqd)`. IN-12's note: part 2 (Windows) not changed, documented in raise. IN-04's note: summer's flagTrue/flagBool and flare's flagSet keep their wider value sets for compatibility. IN-05's note: documentation fix. Add the 128+n hash to the WR-10 row's note.
|
||||
- `git mv` the todo to .planning/todos/completed/ and append a "## Resolution" section that lists each finding with its commit hash and the P-04, P-08 and IN-12 part 2 exceptions.
|
||||
- Do not edit STATE.md or G15OFFICE.md. The orchestrator handles state and closes g15office task rwb3fd9p; say so in the SUMMARY.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bash -c 'cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./... && go test -short ./... && go test ./cmd/summer -run "TestDelegate|TestAppStreams|TestRunAppCmd|TestErrWriter|TestDocsTree" && go test -short ./modules/bonfire -run TestErrWriter && go run ./cmd/summer docs:build --check && scripts/check-phase19.8.sh --self-test && test "$(grep -v "^[[:space:]]*#" scripts/check-phase19.8.sh | grep -ciE "sm-(demo|bm|grzyb|summercmsio)-app|fono|grzyby|summercmsio|horo\"\"|wave\"\"|jakub\"\"|p\[l")" = 0 && test "$(grep -ciE "sm-(demo|bm|grzyb|summercmsio)-app|fonoteka|grzybyfunkcjonalne" scripts/check-phase19.8.sh)" = 0 && test -f .planning/todos/completed/2026-10-22-console-review-info-findings.md && test ! -e .planning/todos/pending/2026-10-22-console-review-info-findings.md'</automated>
|
||||
</verify>
|
||||
<done>Four code or docs commits (IN-13, WR-10 follow-up, IN-05, IN-09) and one records commit. The real summer still sends a delegate's stderr to os.Stderr, and a NewRootIO root captures it. A signal-killed delegate exits 128+n, silently for SIGINT and SIGTERM. The gate names no consuming application and passes --self-test. The disposition lists all 14 items as fixed, with the noted exceptions, and the todo is in todos/completed/. go vet and go test -short ./... are green.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| untrusted text to terminal | Database, remote and log text reaches the terminal through error blocks, tables, process views and diary's console |
|
||||
| command line to stderr/CI logs | argv with secret flag values reaches summer's error block when a delegate dies by a signal |
|
||||
| app binary to summer streams | The delegated child's stderr is routed into summer's error writer |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-rqd-01 | Tampering | bonfire.Sanitize (IN-08) | medium | mitigate | ToValidUTF8 before the control scan, so a raw 8-bit C1 byte (0x9b CSI) cannot reach a terminal that honours C1; pinned in TestSanitize |
|
||||
| T-rqd-02 | Information disclosure | runAppCmd signal branch (WR-10 follow-up) | medium | mitigate | The non-INT/TERM signal block keeps argmask.Join(argv); INT/TERM print nothing; TestDelegateExitCodePassthrough asserts the password never appears |
|
||||
| T-rqd-03 | Information disclosure | appStreams / ErrWriter (IN-13) | low | mitigate | Production NewRoot keeps os.Stderr as the error stream (P-06), so child log lines never enter stdout data such as --json; TestErrWriter pins it |
|
||||
| T-rqd-04 | Spoofing | unknown-command conversion (IN-03) | low | mitigate | Converted only when root.Find reproduces the exact error, so plugin errors are never relabelled as usage errors |
|
||||
| T-rqd-05 | Elevation of privilege | security modules' linked code (IN-07) | low | mitigate | diary drops bonfire, so bouncer and wristband no longer link cobra, bubbletea, bubbles or huh; TestDiaryDoesNotLinkTheConsoleStack pins it |
|
||||
| T-rqd-06 | Denial of service | signal relay on Windows (IN-12 part 2) | low | accept | Unchanged exit 130 on Windows; a command's own handler may not run. Go cannot report other handlers, and changing it would swallow Ctrl+C; documented |
|
||||
| T-rqd-SC | Tampering | go.mod changes | low | mitigate | Only a removal (fang and its exclusive transitive modules via go mod tidy); no module is added, and lipgloss and x/sys are already in the graph |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `go vet ./...` and `go test -short ./...` are green after every commit. `cd examples/hello && go test ./...` is green after Tasks 1 and 2.
|
||||
- `go test -short -race ./modules/bonfire ./modules/diary` is green.
|
||||
- Docker-backed suites for the touched modules (`go test ./modules/lagoon ./modules/conga ./modules/cabana`, with `sudo -u golem` if the socket is denied) are green, or the SUMMARY records why they could not run.
|
||||
- `go run ./cmd/summer docs:build --check` passes. `scripts/check-phase19.8.sh --self-test`, `--go` and `--docs` pass (the docs name check run once with PHASE198_APP_NAMES set, see IN-09).
|
||||
- `git log $BASE..HEAD` shows one commit per finding in task order, with no Co-Authored-By trailer, and planning docs only in the fang note commit and the records commit.
|
||||
- The exported surface (`go doc -all ./modules/bonfire` and `./modules/diary`, compared with BASE) is the same plus FlagBool and ErrWriter. Every former identifier still resolves (aliases, wrappers and constants).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
All 14 items are closed: IN-01..IN-13 and the WR-10 exit-code follow-up. Each has a pinning test or, for IN-05 and IN-09, a documented or scripted check. The exceptions are recorded in the disposition and the closed todo: summer's and flare's wider flag helpers, the Windows signal path, and IN-05 being docs-only. No exported identifier is removed or changes type. The real summer's streams and output are byte-identical except for the intended fixes (the 128+n exit codes, forced-color HideOnTTY, blank lines and Error lines above live views, and FlagBool's wider accepted values).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/261028-rqd-console-review-info-findings-in-01-in-13-and-the-delegate-si/261028-rqd-SUMMARY.md` with:
|
||||
- the RED/GREEN evidence for each finding
|
||||
- the decisions P-01..P-10 as applied, and any deviation
|
||||
- the commit list
|
||||
- skipped files (concurrent edits), if any
|
||||
- the follow-ups: the earlier gate scripts, if they name consumers; the g15office task rwb3fd9p for the orchestrator
|
||||
- a "Release-note input" section (P-10)
|
||||
</output>
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
phase: quick-261028-rqd
|
||||
plan: 01
|
||||
quick_id: 261028-rqd
|
||||
subsystem: console (bonfire, diary, summer delegates, 19.8 gate)
|
||||
tags: [console, bonfire, diary, review-findings, gate]
|
||||
status: complete
|
||||
requires: []
|
||||
provides:
|
||||
- bonfire.FlagBool
|
||||
- bonfire.ErrWriter
|
||||
- internal/consolekit (leaf package shared by bonfire and diary)
|
||||
affects: [bonfire, diary, cabana, surf, lagoon, conga, lighthouse/centrifugo, cmd/summer, scripts/check-phase19.8.sh]
|
||||
tech-stack:
|
||||
added: []
|
||||
removed: [charm.land/fang/v2 and its exclusive transitive modules (mango, mango-cobra, mango-pflag, roff, x/exp/charmtone)]
|
||||
patterns: [type aliases plus wrapper funcs to move types into a leaf package without an API break, a registered hook (atomic.Pointer) instead of an import]
|
||||
key-files:
|
||||
created:
|
||||
- internal/consolekit/consolekit.go
|
||||
- internal/consolekit/consolekit_test.go
|
||||
- modules/diary/deps_test.go
|
||||
- modules/diary/console_internal_test.go
|
||||
- .planning/todos/pending/2026-10-28-earlier-phase-gates-name-consuming-apps.md
|
||||
modified:
|
||||
- modules/bonfire/{errors,execute,root,globals,command,list,live,output,helpers,styles}.go and README.md
|
||||
- modules/diary/{console,channels,diary}.go and README.md
|
||||
- modules/{cabana,surf,lagoon,conga,lighthouse/centrifugo} command files (FlagBool)
|
||||
- cmd/summer/runtime.go
|
||||
- scripts/check-phase19.8.sh
|
||||
- docs/console/{writing-commands,introduction,scaffolding,setup-and-maintenance}.md, docs/setup/upgrading.md
|
||||
- go.mod, go.sum
|
||||
- .planning/research/STACK.md, .planning/notes/2026-10-21-console-charm-stack.md
|
||||
- .planning/phases/19.8-console-commands-overhaul/19.8-REVIEW-DISPOSITION.md
|
||||
decisions:
|
||||
- "P-01..P-10 applied as planned (see below); no planner decision revisited"
|
||||
- "IN-01 also changed surf's TestServeRecords: it used forced color on a buffer as a stand-in for a terminal; it now asserts the records are kept there, and the terminal case is pinned in diary's internal test"
|
||||
- "Execute sets SilenceUsage, SilenceErrors and CompletionOptions.DisableDefaultCmd on the root itself (fang did), so a root not built by NewRootIO behaves as before"
|
||||
metrics:
|
||||
duration: ~75m
|
||||
completed: 2026-10-28
|
||||
actuals:
|
||||
tokens: 41000
|
||||
tasks: 3
|
||||
commits: 16
|
||||
plan_head_before: 825a3fd2daa55f7906920bad60d42dd338874bae
|
||||
plan_head_after: 202d38ba3c48115f412b2687fe17e102f8a764df
|
||||
---
|
||||
|
||||
# Quick 261028-rqd: console review info findings IN-01..IN-13 and the delegate signal exit code
|
||||
|
||||
All 13 deferred info findings of the 19.8 console review and the WR-10 follow-up (exit 128+n for a signal-killed delegate) are closed on master in 16 local commits, one per finding plus two planning-docs commits. The changes keep the exported API: two identifiers were added (bonfire.FlagBool, bonfire.ErrWriter) and the rest moved behind aliases. fang is gone. diary, bouncer and wristband no longer link bonfire, cobra or the TUI stack.
|
||||
|
||||
## Commits
|
||||
|
||||
| # | Hash | Finding | Subject |
|
||||
|---|------|---------|---------|
|
||||
| 1 | 8476a65 | IN-08 | fix(bonfire): replace invalid UTF-8 before the control scan in Sanitize |
|
||||
| 2 | 39c59b8 | IN-03 | fix(bonfire): recognise cobra's unknown command error once in Execute |
|
||||
| 3 | 53c0a65 | IN-06 | refactor(bonfire): run the root without fang so bonfire's root help stays installed |
|
||||
| 4 | 6294ac4 | IN-06 docs | docs(quick-261028-rqd): record that bonfire dropped fang (STACK.md, charm-stack note addendum) |
|
||||
| 5 | e34d113 | IN-02 | fix(bonfire): read the =value forms of the global flags and use the parsed globals in the error block |
|
||||
| 6 | 795c4f6 | IN-04 | feat(bonfire): add FlagBool and read every bare switch with it |
|
||||
| 7 | 4d75695 | IN-11 | fix(bonfire): keep a blank line printed during a live view |
|
||||
| 8 | 6efb9a4 | IN-10 | fix(bonfire): print Error lines above a running live view |
|
||||
| 9 | f91d70d | IN-12 | fix(bonfire): kill a live view on a signal only after it has started |
|
||||
| 10 | 3520d5d | IN-01 | fix(diary): skip HideOnTTY records only on a terminal console, also with forced color |
|
||||
| 11 | f3f87f3 | IN-07 | refactor(diary): share the console types through internal/consolekit so diary no longer links bonfire |
|
||||
| 12 | 4914cad | IN-13 | fix(summer): send a delegate's stderr to the root's error writer through bonfire.ErrWriter |
|
||||
| 13 | bb7e14b | WR-10 follow-up | fix(summer): exit 128+n when a delegated binary dies by a signal |
|
||||
| 14 | 2da42e9 | IN-05 | docs(console): say that -q hides the Created and Added lines |
|
||||
| 15 | 10fec47 | IN-09 | fix(scripts): read the sibling application paths and names from the environment in the 19.8 gate |
|
||||
| 16 | 202d38b | records | docs(quick-261028-rqd): record the console info findings as fixed and the earlier gates' consumer names as a todo |
|
||||
|
||||
Every subject ends with `(…, quick-261028-rqd)`. No Co-Authored-By trailer (checked: `git log --format=%B 825a3fd..HEAD | grep -ci co-authored` = 0). Nothing was pushed or tagged. No other session committed in between; all 16 commits are this task's.
|
||||
|
||||
## RED / GREEN evidence
|
||||
|
||||
| Finding | Pinning test | RED on the unchanged code | GREEN |
|
||||
|---------|--------------|---------------------------|-------|
|
||||
| IN-08 | TestSanitize (3 new rows plus a valid-UTF-8 and clean-string check) | `Sanitize("a\x9b31mb") = "a\x9b31mb", want "a<>31mb"`, not valid UTF-8; same for `"\x9b"` and `"\xffok"` | pass |
|
||||
| IN-03 | TestPluginErrorWordedUnknownCommand | `plugin error: exit 2, want 1`, block showed `Command "x" is not defined.` | pass; TestErrorRenderSmoke and examples/hello unchanged and green |
|
||||
| IN-06 | TestExecuteKeepsRootHelp | root help after Execute printed fang's layout (`-h --help Help for acme`, …) instead of the command list | pass; every golden byte-identical, unchanged |
|
||||
| IN-02 | 15 new peekCases rows (cobra: true, so TestGlobalFlagsAgree diffs them against cobra) plus 1 PeekGlobals-only row; TestErrorBlockUsesParsedGlobals | e.g. `PeekGlobals(["migrate" "--verbose=2"]) = {Verbosity:0}`, `-vv=1` gave 2, `-q --quiet=false` stayed quiet; hook (cobra) disagreed with PeekGlobals on every new row; the error block for `migrate --name -v` showed `Caused by:` | pass |
|
||||
| IN-04 | TestFlagBool; TestRouteListJSON gains `--json=1` | TestFlagBool: undefined FlagBool (new API); TestRouteListJSON with `--json=1` printed the table, not JSON | pass |
|
||||
| IN-11 | TestPrintAboveBlankLine | inserted lines `["before-blank" "after-blank"]`, want `["before-blank" "" "after-blank"]` (raw: two adjacent `CSI L … CSI K` insertions) | pass |
|
||||
| IN-10 | TestErrorPrintsAboveView | `terminal stderr: line 0 times above the view, 1 times on stderr; want 1 and 0` | pass (redirected-stderr case unchanged) |
|
||||
| IN-12 | TestSignalRelayWaitsForStart | not applicable: the relay seam did not exist before the refactor (build failure `undefined: relaySignals`) | pass, also `-count=3`; TestLiveViewRestoresTerminalOnSignal (default and own_handler) stays green, `-count=3` |
|
||||
| IN-01 | TestHideOnTTY (first assertion flipped); new TestConsoleHideOnTTYTerminal (internal) | `styled console off a terminal dropped a HideOnTTY record` | pass |
|
||||
| IN-07 | TestDiaryDoesNotLinkTheConsoleStack | diary, bouncer or wristband linked modules/bonfire, cobra, bubbletea, bubbles/v2/* and huh/v2/* | pass; `go list -deps ./modules/diary ./modules/bouncer ./modules/wristband \| grep -cE "modules/bonfire$\|charm.land/bubbletea\|charm.land/huh\|spf13/cobra"` = 0 |
|
||||
| IN-13 | TestErrWriter; TestAppStreams, TestDelegateStreams, TestDelegateExitCodePassthrough extended | `the binary's error block reached the root's error writer 0 times, want 1`; `the binary's stderr did not reach the root's error writer`; appStreams returned os.Stderr | pass |
|
||||
| WR-10 | TestDelegateExitCodePassthrough (SIGKILL now 137, new SIGTERM subtest 143, no block); new TestDelegateSignalExitCodes | with a stub of the old behaviour: `exit 1, want 137`, `exit 1, want 143` plus a block for SIGTERM; SIGINT `exit 1, want 130` plus a block | pass |
|
||||
| IN-05 | docs only (no test) | — | docs:build --check passes |
|
||||
| IN-09 | `--self-test` (4 new planted NOT RUN / required cases) and the plan's greps | — | self-test passes; both name greps = 0 |
|
||||
|
||||
Each new top-level test was added to its list in scripts/check-phase19.8.sh in its own commit (BONFIRE_TESTS, DIARY_TESTS, SUMMER_TESTS).
|
||||
|
||||
## Decisions as applied
|
||||
|
||||
- **P-01 (IN-06).** fang dropped. `bonfire.Execute` runs `root.ExecuteContext`, renders with `RenderError` on `root.ErrOrStderr()` and calls `lipgloss.EnableLegacyWindowsANSI` on the out and err streams when they are `*os.File`. `go mod tidy` removed fang, mango, mango-cobra, mango-pflag, roff and x/exp/charmtone; nothing was added. examples/hello builds and passes in the workspace. The colorprofile.Writer unwrap stays in RenderError for outside callers. Execute also sets SilenceUsage, SilenceErrors and CompletionOptions.DisableDefaultCmd itself, as fang did, so a root not built by NewRootIO behaves as before.
|
||||
- **P-02 (IN-03).** Applied as planned: unexported `unknownCommandError`, conversion only when `root.Find(args)` fails at the root with the same message, `isUsage` without the text match, RenderError with `errors.As`. wrongDirectory sees the converted error.
|
||||
- **P-03 (IN-02).** The token scan was extended with pflag's value rules (count: ParseInt base 0; bool: ParseBool; --ansi/--no-ansi: colorFlag.Set rules; unparsable values ignored). In a shorthand cluster, `=` after v, q or n takes the rest of the token. A context holder (`parsedGlobals`) is filled by NewRootIO's PersistentPreRunE, and Execute's block prefers it. Call never installs one.
|
||||
- **P-04 (IN-04).** `FlagBool` has lagoon's former flagOn semantics. It is used in cabana (token json at three places and revoked, admin superuser), surf route:list, centrifugo, lagoon (flagOn deleted), conga (schedule:run --once, confirmProduction's --force) and bonfire's list --all. summer's flagTrue/flagBool and flare's flagSet are unchanged, recorded in the disposition. The writing-commands.md ExampleCatalog snippet and the bonfire README example now read the switch with FlagBool (`docs:sync` updated the snippet).
|
||||
- **P-05 (IN-07).** `internal/consolekit` holds ColorMode with its constants, Palette, DefaultPalette, DetectProfile (with forceColor and envValue), Sanitize, Globals with Args, Normalize, MaxVerbosity and SetPrintAbove/PrintAbove (atomic.Pointer). bonfire re-exports them through type aliases (ColorMode, Palette, Globals), re-declared constants and wrapper funcs. normalize and peekShorthands are now package functions, and bonfire registers printAboveLines in an init func in live.go. The only test change needed was `tc.g.normalize()` → `normalize(tc.g)` in TestGlobalsArgs (an unexported method cannot exist on an alias). The README `bonfire.Globals.Args` row was folded into the `bonfire.Globals` row. `go doc -short` of bonfire and diary, compared with BASE: the same identifiers plus FlagBool (and ErrWriter after IN-13). ColorMode, Globals and Palette now print as aliases, and diary's FromGlobals/ApplyGlobals print `consolekit.Globals`, which is the same type.
|
||||
- **P-06 (IN-13).** The console has an `errStream` field. `newRootIO` (NewRootIO's former body) returns the console and sets errStream to errW, NewRoot sets it to os.Stderr when it moves the root's error stream there, and NewOutput leaves it unset. out.Error is unchanged.
|
||||
- **P-07 (WR-10).** `signalExit(argv, err, sig)` gives `WithExitCode(…, 128+n)`, wrapped in `Reported` for SIGINT and SIGTERM. Other signals keep the masked `summer: <argmask.Join(argv)>: …` block. A normal exit and a start failure are unchanged. The helper is driven directly for SIGINT, so no signal is delivered to the test process.
|
||||
- **P-08 (IN-05).** Documentation only: upgrading.md (0.4 Result lines, plus plugin:add's Added line) and scaffolding.md.
|
||||
- **P-09 (IN-09).** Paths and names come from PHASE198_PLUGIN_WORKSPACE, PHASE198_APPS, PHASE198_DEMO_APP, PHASE198_LANDING_APP, PHASE198_LANDING_FRONTEND and PHASE198_APP_NAMES, with no defaults. Without them the gate prints `NOT RUN <stage>: set <VAR>`, and PHASE198_REQUIRE_SIBLINGS=1 turns that into a failure. The shared sm-*-plugin entries under PLUGINS_ROOT stay. The docs name check, run once with PHASE198_APP_NAMES set to BASE's six-pattern list (typed only on the command line), passed: "69 files … checked, no consuming application named". A planted pattern (`consolekit`) made it refuse with exit 1.
|
||||
- **P-10.** No upgrading.md version section was added. The release-note input is below.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
1. **[Rule 1 - test follow-up] surf's TestServeRecords (IN-01 commit).** It used forced color on a buffer as a stand-in for a terminal console and asserted that the HideOnTTY records are hidden. That is exactly the behaviour IN-01 fixes, so it now asserts the records are kept, and the terminal case is pinned by diary's TestConsoleHideOnTTYTerminal. Files: modules/surf/summary_test.go (commit 3520d5d). docs/console/setup-and-maintenance.md also gained one clause on forced color.
|
||||
2. **IN-07 test change.** TestGlobalsArgs now calls `normalize(tc.g)` instead of the removed unexported method (P-05 consequence). It is the only change to an existing bonfire test in that commit.
|
||||
3. **The todo move was skipped, as the orchestrator instructed.** `.planning/todos/pending/2026-10-22-console-review-info-findings.md` is NOT moved to completed and has no Resolution section. The orchestrator moves it and closes g15office task rwb3fd9p. The Resolution text is below, ready to paste. The disposition rows therefore cite commit hashes, not the todo path.
|
||||
4. **rqc ordering.** The plan expected quick rqc (quiet plugin boot logs) to land before IN-07. At execution time rqc had no commits and no uncommitted edits in bonfire or diary, so IN-07 was done on the state at hand. If rqc adds a diary → bonfire import later, TestDiaryDoesNotLinkTheConsoleStack catches it. The import should then go behind internal/consolekit.
|
||||
|
||||
## Resolution text for the closed todo (for the orchestrator)
|
||||
|
||||
- IN-01 3520d5d, IN-02 e34d113, IN-03 39c59b8, IN-04 795c4f6, IN-05 2da42e9 (docs only), IN-06 53c0a65 + 6294ac4, IN-07 f3f87f3, IN-08 8476a65, IN-09 10fec47, IN-10 6efb9a4, IN-11 4d75695, IN-12 f91d70d, IN-13 4914cad, WR-10 follow-up (exit 128+n) bb7e14b, records 202d38b.
|
||||
- Exceptions: P-04, summer's flagTrue/flagBool and flare's flagSet keep their wider value sets (narrowing them would turn values that are on today off). P-08, IN-05 is a documentation fix and the code is unchanged. IN-12 part 2 (Windows) is unchanged and documented in raise's doc comment and the bonfire README.
|
||||
|
||||
## Verification
|
||||
|
||||
- At every commit: `go vet ./...` plus `go test -short` on the touched packages, and `go test -short ./...` in full after IN-04, IN-01, IN-07 and IN-13 (all green). `go run ./cmd/summer docs:build --check` passed: no problems found.
|
||||
- `go test -short -race ./modules/bonfire ./modules/diary ./internal/consolekit`: green.
|
||||
- `cd examples/hello && go test ./...`: green after Tasks 1 and 2.
|
||||
- Docker suites run without -short; the socket was reachable, so no sudo was needed. Once after IN-04: lagoon, conga, cabana, surf ok. At the end: lagoon, conga, cabana, surf, diary, bonfire, cmd/summer ok.
|
||||
- Gate at HEAD: `--self-test` passed, `--go` passed (including the race stage), `--hello` passed, `--docker` passed, `--docs` passed (NOT RUN for the name check without PHASE198_APP_NAMES; passed with the old pattern list), and `--apps` printed `NOT RUN apps: set PHASE198_APPS`. `--plugins` and `--landing` were not run: they need the sibling checkouts given through the new variables.
|
||||
- Plan greps: no `charm.land/fang` in go.mod or any .go file; no `HasPrefix(.*unknown command` in errors.go; no `== "true"` in the five listed files; both IN-09 consumer-name greps on check-phase19.8.sh return 0.
|
||||
- Exported surface (`go doc -short` vs BASE, exported from `git archive` into the scratchpad): bonfire gains FlagBool and ErrWriter (rechecked at HEAD), and ColorMode, Globals and Palette print as aliases. diary's FromGlobals and ApplyGlobals now print `consolekit.Globals`, which is the same type as `bonfire.Globals`. No identifier was removed.
|
||||
|
||||
## Skipped files (concurrent edits)
|
||||
|
||||
None. Before each edit, `git status` of the target files showed no foreign changes. Other sessions' uncommitted `.planning/STATE.md`, `state.json`, `G15OFFICE.md`, `.msd/` and other quick directories were left alone. A stray `examples/hello/hello` binary from this task's `go build ./...` was deleted; it was never committed.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- **Earlier gate scripts name consuming applications.** 29 `scripts/check-phase*.sh` other than 19.8 match the consumer names. The records commit adds `.planning/todos/pending/2026-10-28-earlier-phase-gates-name-consuming-apps.md`. It has no g15office id yet: the orchestrator should mirror it (CLAUDE.md rule 4).
|
||||
- **Orchestrator:** move `.planning/todos/pending/2026-10-22-console-review-info-findings.md` to `todos/completed/` with the Resolution text above, close g15office task rwb3fd9p and update STATE. This task did not touch STATE.md or G15OFFICE.md.
|
||||
- Consuming apps and sibling plugin repos that run the 19.8 gate's plugins, apps or landing stages must now pass the PHASE198_* variables. Without them those stages print NOT RUN.
|
||||
|
||||
## Release-note input
|
||||
|
||||
- A delegated app binary that dies by a signal now makes `summer` exit 128 plus the signal number (130 for Ctrl+C, 143 for SIGTERM, 137 for SIGKILL). After SIGINT or SIGTERM, summer prints no error block of its own. Before, it exited 1 with a block.
|
||||
- New `bonfire.FlagBool(in, name)` reads a bare switch with `strconv.ParseBool`. The framework's own switches (`--json`, `--revoked`, `--superuser`, `--once`, `--force`, `--pretend`, `list --all`) now accept `--json=1` and `--json=false`.
|
||||
- New `bonfire.ErrWriter(out)` returns the stream the root's error block goes to. A delegated binary's stderr now goes to the root's error writer. That is still stderr for the real `summer`, and a root built on buffers (`NewRootIO`) now captures it.
|
||||
- Forced color (`--ansi`, `FORCE_COLOR`) on a redirected stderr keeps the startup summary and the `listening on` records. Only a console on a terminal hides them.
|
||||
- `bonfire.Sanitize` (and diary's console) now replaces invalid UTF-8 with U+FFFD, so a raw 8-bit C1 byte such as 0x9b cannot reach the terminal.
|
||||
- `charm.land/fang/v2` was removed. `bonfire.Execute` runs the root itself and leaves the root's help function in place. List, help and error output is unchanged.
|
||||
- During a live view, `out.Println()` keeps its blank line, `out.Error` lines print above the view when stderr is a terminal, and a signal that arrives while the view is still starting restores the cursor.
|
||||
- The global flags' `=value` forms (`--verbose=2`, `-v=3`, `--quiet=false`, `--ansi=false`) are read before boot the way cobra reads them, and the error block uses cobra's parsed globals. A command's error that happens to read like "unknown command" is no longer turned into a usage error (exit 2).
|
||||
- diary no longer links bonfire, cobra or the TUI stack. It shares its console types through `internal/consolekit`, and `bonfire.Globals`, `bonfire.ColorMode` and `bonfire.Palette` are aliases of those types.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Files exist: internal/consolekit/consolekit.go, internal/consolekit/consolekit_test.go, modules/diary/deps_test.go, modules/diary/console_internal_test.go, .planning/todos/pending/2026-10-28-earlier-phase-gates-name-consuming-apps.md.
|
||||
- All 16 commit hashes in the table resolve in `git log 825a3fd..HEAD`.
|
||||
@@ -0,0 +1,352 @@
|
||||
---
|
||||
phase: quick-261028-rqe
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- modules/lagoon/attach/inline.go
|
||||
- modules/lagoon/attach/inline_test.go
|
||||
- modules/lagoon/attach/store.go
|
||||
- modules/lagoon/attach/store_test.go
|
||||
- modules/lagoon/attach/thumb_test.go
|
||||
- modules/lagoon/README.md
|
||||
- docs/services/storage.md
|
||||
- docs/database/attachments.md
|
||||
- modules/surf/uploads.go
|
||||
- modules/surf/README.md
|
||||
autonomous: true
|
||||
requirements:
|
||||
- todo:2026-10-28-attach-store-public-content-type
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "attach.Store writes every blob with its Content-Type set to the stored content type. For any type other than image/jpeg, image/png, image/gif, image/webp and image/avif, the blob also gets Content-Disposition: attachment (bare, no filename parameter). For those five raster types the blob has no disposition"
|
||||
- "The system_files.content_type value Store records does not change: an HTML body uploaded as note.txt is still stored as <hex>.txt with content_type text/html. Only the blob attributes gain the disposition"
|
||||
- "attach.IsInlineContentType(contentType string) bool is the single inline-type definition. It ignores parameters and case, and it returns false for an empty or unparsable value. The surf uploads route calls it instead of keeping its own copy, and its observable headers do not change: TestUploadsResponseHeaders and TestUploadsMount pass unmodified"
|
||||
- "Thumbnails (attach.File.Thumb and the broken-image picture) are still written without a disposition and stay inline; thumb.go is not edited"
|
||||
- "memblob and fileblob with sidecar metadata, the default for file:// bucket URLs, persist the disposition. fileblob with metadata skipped accepts the write without an error"
|
||||
- "Non-breaking: the only exported change is the added attach.IsInlineContentType. No exported identifier is removed or changes its signature, and writeBlob keeps its signature"
|
||||
- "The lagoon README, docs/services/storage.md (public-bucket deploy note and the built-in route bullet), docs/database/attachments.md and the surf README describe the behaviour and name attach.IsInlineContentType. go vet ./..., go test -short ./..., go test ./cmd/summer -run TestDocsTree and go run ./cmd/summer docs:build --check are green after each commit, and the Docker-backed TestStoreDiskExtension passes"
|
||||
artifacts:
|
||||
- path: modules/lagoon/attach/inline.go
|
||||
provides: "IsInlineContentType, the unexported raster-type set, the attachment disposition constant and the upload writer-options helper"
|
||||
contains: "func IsInlineContentType(contentType string) bool"
|
||||
- path: modules/lagoon/attach/inline_test.go
|
||||
provides: "TestIsInlineContentType and TestWriteBlobDisposition on memblob, fileblob sidecar and fileblob metadata skip (Docker-free)"
|
||||
contains: "TestWriteBlobDisposition"
|
||||
- path: modules/lagoon/attach/store.go
|
||||
provides: "writeBlob builds its WriterOptions through the helper; the Store doc comment documents the disposition"
|
||||
- path: modules/lagoon/attach/store_test.go
|
||||
provides: "TestStoreDiskExtension asserts the blob's Content-Type and Content-Disposition per case (Postgres)"
|
||||
contains: "ContentDisposition"
|
||||
- path: modules/surf/uploads.go
|
||||
provides: "The disposition writer decides through attach.IsInlineContentType; no private type list"
|
||||
contains: "attach.IsInlineContentType"
|
||||
- path: docs/services/storage.md
|
||||
provides: "Public-bucket deploy note rewritten for the stored disposition; built-in route bullet names the shared list"
|
||||
contains: "attach.IsInlineContentType"
|
||||
key_links:
|
||||
- from: modules/lagoon/attach/store.go writeBlob
|
||||
to: modules/lagoon/attach/inline.go
|
||||
via: "bucket.NewWriter options come from the upload writer-options helper, which calls IsInlineContentType"
|
||||
pattern: "IsInlineContentType"
|
||||
- from: modules/surf/uploads.go uploadsDispositionWriter.decide
|
||||
to: modules/lagoon/attach/inline.go IsInlineContentType
|
||||
via: "attach.IsInlineContentType(w.Header().Get(\"Content-Type\"))"
|
||||
pattern: "attach\\.IsInlineContentType\\("
|
||||
---
|
||||
|
||||
<objective>
|
||||
Close the todo `2026-10-28-attach-store-public-content-type` in the framework (summercms.go only). A public S3 bucket, or a CDN that pulls from one, sends the headers stored with each object. `attach.Store` stores the sniffed type, so an HTML body uploaded as `note.txt` is served as `text/html` from the bucket. The fix from the batch item description: write `Content-Disposition: attachment` as a blob attribute (`blob.WriterOptions.ContentDisposition`) for every type outside the inline-safe raster allow-list (jpeg, png, gif, webp, avif). The DB `content_type` column stays unchanged. The change is additive and non-breaking.
|
||||
|
||||
Live reads at planning time:
|
||||
- `modules/lagoon/attach/store.go:204` (`writeBlob`) passes `&blob.WriterOptions{ContentType: contentType}` and nothing else. `thumb.go:227` and `thumb.go:264` write thumbnails with their own image options.
|
||||
- `modules/surf/uploads.go:33-39,168-172` already keeps exactly this allow-list for the built-in route. The list and its helper are both unexported, so they can be replaced without breaking anything.
|
||||
- gocloud.dev v0.46.0: memblob keeps `ContentDisposition`, fileblob keeps it in its `.attrs` sidecar, fileblob with `MetadataDontWrite` drops it without an error, and s3blob sends it as the object's header.
|
||||
- Go's sniffer does not detect AVIF. A `.avif` upload falls back to `mime.TypeByExtension`, which gives `image/avif`, so it stays inline.
|
||||
|
||||
Decomposition: three tasks, two commits, run in order.
|
||||
- Task 1 is the tracer. It adds the shared predicate and wires it into Store's blob write, and Docker-free tests prove the attribute on the real drivers.
|
||||
- Task 2 extends the Postgres-backed Store test and updates the README and docs. It commits Tasks 1 and 2 together, because the project CLAUDE.md requires the module README and the affected docs pages in the same commit as an exported-API change.
|
||||
- Task 3 makes the surf route use the shared definition. It is a second commit with its own docs.
|
||||
|
||||
Not in scope (the orchestrator records these as follow-ups; do not edit sibling repos):
|
||||
- `attach.StaticHandler` and `attach.StaticHandlerPublic` still send only the stored Content-Type. The surf route adds its own disposition. An application that mounts `StaticHandlerPublic` itself gets no disposition from the stored attribute, and reading it would cost an extra `Attributes` call per request.
|
||||
- Code that writes blobs directly bypasses Store and gets no disposition. Examples: fonoteka.go `storeUpload` and `StorePublicFile`, and the avatar and screenshot writers in sm-user-plugin and sm-feedback-plugin. They can adopt `attach.IsInlineContentType` later.
|
||||
- Backfilling the disposition onto objects written before this change. This plan only documents it.
|
||||
- Moving the todo to completed and the g15office comment (`q1xe3vzb`): the orchestrator's closing docs commit handles both.
|
||||
|
||||
Purpose: a public bucket stops being a stored-XSS vector for HTML, SVG and similar bodies that Store records with a rendering type. Raster images, embeds and thumbnails behave exactly as before.
|
||||
Output: two commits in summercms.go and 261028-rqe-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-store-public-content-type.md
|
||||
|
||||
<interfaces>
|
||||
Existing code, verified at planning time.
|
||||
|
||||
modules/lagoon/attach/store.go
|
||||
- func Store(ctx context.Context, db *gorm.DB, bucket *blob.Bucket, in Upload, lim Limits) (*File, error). The signature stays.
|
||||
- func writeBlob(ctx context.Context, bucket *blob.Bucket, key string, r io.Reader, contentType string, limit int64) (int64, error). The signature stays; line 204 builds the writer options.
|
||||
- contentType passed to writeBlob is a base media type without parameters: the sniffed type, or, when the sniff only says application/octet-stream, the extension's registered type. The row's ContentType is the same value.
|
||||
- baseMediaType(ct string) string is the existing helper (lenient on parse errors). Do not use it for the new predicate, which must be strict like surf's.
|
||||
|
||||
modules/lagoon/attach/thumb.go (not edited)
|
||||
- Thumb writes bucket.NewWriter(ctx, thumbKey, &blob.WriterOptions{ContentType: image/jpeg | image/png | image/gif}) at line 227.
|
||||
- brokenThumb writes bucket.WriteAll(..., BrokenImagePNG, &blob.WriterOptions{ContentType: "image/png"}) at line 264.
|
||||
|
||||
modules/surf/uploads.go (current state, replaced in Task 3)
|
||||
- var uploadsInlineTypes = map[string]bool{"image/avif", "image/gif", "image/jpeg", "image/png", "image/webp": true} at lines 30-39.
|
||||
- func uploadsInline(contentType string) bool at lines 168-172: mime.ParseMediaType; err == nil && uploadsInlineTypes[strings.ToLower(mediaType)].
|
||||
- (w *uploadsDispositionWriter) decide(status int) at lines 146-154 sets Content-Disposition "attachment" on a 2xx response when the helper reports false.
|
||||
- A grep over modules/ at planning time found both names only in uploads.go. The `mime` import is used only by the helper. `strings` is still used by uploadsPrefix and mountUploads.
|
||||
|
||||
modules/surf/uploads_test.go (not edited)
|
||||
- TestUploadsResponseHeaders (lines 388-440): the inline cases are image/png, image/jpeg, "IMAGE/WEBP; charset=binary", image/gif and image/avif. The attachment cases are "text/html; charset=utf-8", image/svg+xml, application/pdf, "text/plain; charset=utf-8", application/octet-stream, "" and "not a media type;;". A 404 gets no disposition.
|
||||
|
||||
gocloud.dev v0.46.0 (go.mod)
|
||||
- blob.WriterOptions.ContentDisposition string. (*blob.Bucket).Attributes(ctx, key) (*blob.Attributes, error) returns ContentType and ContentDisposition.
|
||||
- memblob keeps ContentDisposition (memblob.go:371).
|
||||
- fileblob sidecar (default) keeps it as json "user.content_disposition" (attrs.go:32, fileblob.go:775).
|
||||
- fileblob.OpenBucket(dir string, opts *fileblob.Options). Options.Metadata = fileblob.MetadataDontWrite skips the sidecar and returns no error (fileblob.go:758-767).
|
||||
- s3blob sets the S3 object's ContentDisposition (s3blob.go:773).
|
||||
- bucket.go already imports gocloud.dev/blob/fileblob for its URL opener, so a test import adds no dependency.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: attach.IsInlineContentType and the attachment disposition on Store's blob write (tracer through real blob drivers)</name>
|
||||
<files>modules/lagoon/attach/inline.go, modules/lagoon/attach/inline_test.go, modules/lagoon/attach/store.go, modules/lagoon/attach/thumb_test.go</files>
|
||||
<read_first>
|
||||
- modules/lagoon/attach/store.go (whole file, 296 lines: the Store doc comment at lines 94-118, writeBlob at 199-231, baseMediaType)
|
||||
- modules/surf/uploads.go lines 23-39 and 126-172 (the current inline list and its parse rule, which the new function must reproduce exactly)
|
||||
- modules/surf/uploads_test.go lines 383-440 (the behaviour table to match)
|
||||
- modules/lagoon/attach/thumb.go lines 219-268 (the thumbnail and broken-image writes, which stay untouched)
|
||||
- modules/lagoon/attach/thumb_test.go lines 1-16 and 92-131 (imports, TestFileThumbResizesOnce)
|
||||
- /home/jin/go/pkg/mod/gocloud.dev@v0.46.0/blob/fileblob/fileblob.go lines 170-200, 235-262 and 755-790 (sidecar attributes, the MetadataDontWrite branch)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- IsInlineContentType is true for image/jpeg, image/png, image/gif, image/webp, image/avif and "IMAGE/WEBP; charset=binary".
|
||||
- IsInlineContentType is false for "text/html; charset=utf-8", image/svg+xml, application/pdf, text/plain, application/octet-stream, application/zip, image/bmp, image/x-icon, video/mp4, audio/mpeg, "" and "not a media type;;".
|
||||
- writeBlob on memblob and on fileblob with sidecar metadata: Attributes().ContentDisposition is "attachment" for text/html, text/plain, application/pdf, image/svg+xml and application/zip, and "" for image/png, image/jpeg and image/avif. Attributes().ContentType equals the type passed in, and ReadAll returns the body unchanged.
|
||||
- writeBlob on fileblob with Metadata MetadataDontWrite and a text/html body returns no error, and ReadAll returns the body.
|
||||
- After f.Thumb on a JPEG original, the thumbnail blob's Attributes() reports ContentType image/jpeg and an empty ContentDisposition.
|
||||
</behavior>
|
||||
<action>
|
||||
This task implements the todo's Solution: a non-rendering disposition for non-inline types, additive, with no breaking change. Per the batch context, the inline list must stay identical to the surf uploads route and live in one shared definition. Write the tests from the behavior block first and watch them fail to compile or fail, then implement.
|
||||
|
||||
1. Create modules/lagoon/attach/inline.go (package attach).
|
||||
- Add an unexported set of the five raster types: image/avif, image/gif, image/jpeg, image/png and image/webp. These are the same five the surf route allows today.
|
||||
- Add the exported IsInlineContentType(contentType string) bool. It parses the value with mime.ParseMediaType, lower-cases the media type, and returns true only when parsing succeeds and the type is in the set. An empty or unparsable value returns false. This is the same rule the surf route applies today, so its tests stay green when Task 3 switches the route over.
|
||||
- Doc comment for IsInlineContentType: it reports whether contentType, with parameters and case ignored, is a raster image a browser may show inline from an upload (JPEG, PNG, GIF, WebP, AVIF). Store stores every other type, SVG, PDF, HTML and plain text included, with Content-Disposition: attachment, and the surf uploads route serves it as a download.
|
||||
- Add an unexported constant holding the bare disposition value attachment, with no filename parameter: the client's file name must never reach blob metadata or a response header.
|
||||
- Add an unexported helper, for example uploadWriterOptions(contentType string), that returns the *blob.WriterOptions for an upload: ContentType set to the given type, plus ContentDisposition set to that constant whenever IsInlineContentType reports false.
|
||||
- The only new exported identifier is IsInlineContentType.
|
||||
|
||||
2. In modules/lagoon/attach/store.go, change writeBlob to get its writer options from that helper instead of the literal it builds at line 204. Leave writeBlob's signature, Store's signature and the row fields alone: content_type keeps exactly the value it has today. Extend the Store doc comment after the sentence about streaming the body into bucket at BlobKey. The added text should say:
|
||||
- the blob's Content-Type is the stored content type;
|
||||
- a type IsInlineContentType does not accept also gets Content-Disposition: attachment, so a public bucket or CDN that sends the stored headers makes a browser download the file instead of rendering it;
|
||||
- the row's content_type holds the same type either way.
|
||||
|
||||
3. Leave modules/lagoon/attach/thumb.go unedited. Thumbnails and the broken-image picture keep their own image writer options and stay inline.
|
||||
|
||||
4. Create modules/lagoon/attach/inline_test.go (package attach, Docker-free, must not skip under -short).
|
||||
- TestIsInlineContentType: a table over the behavior cases, with a failure message that names the input.
|
||||
- TestWriteBlobDisposition: run one case list against three buckets built in the test, each closed through t.Cleanup:
|
||||
- memblob.OpenBucket(nil);
|
||||
- fileblob.OpenBucket(t.TempDir(), nil), which uses sidecar metadata, the default behind file:// URLs;
|
||||
- fileblob.OpenBucket(t.TempDir(), &fileblob.Options{Metadata: fileblob.MetadataDontWrite}).
|
||||
- For each case, call writeBlob with limit 0 and a short HTML-looking body under a partition-style key: three directory segments plus a file name unique to the case.
|
||||
- On the memblob and sidecar buckets, assert that bucket.Attributes reports the passed ContentType and the expected ContentDisposition.
|
||||
- On all three buckets, assert that the write succeeds and bucket.ReadAll returns the body.
|
||||
- Doc comment for TestWriteBlobDisposition: it is the driver half of the todo, because a public bucket serves what Store stores. The metadata-skip bucket shows that the driver accepts the option without keeping it.
|
||||
|
||||
5. In modules/lagoon/attach/thumb_test.go, TestFileThumbResizesOnce: after the URL assertions, get the thumbnail key by trimming the /storage/uploads/ prefix from url1. Read bucket.Attributes for it and assert ContentType image/jpeg and an empty ContentDisposition. Add a one-line comment that thumbnails stay inline.
|
||||
|
||||
Do not commit at the end of this task. IsInlineContentType is exported API, and CLAUDE.md requires the module README and the affected docs pages in the same commit, so Task 2 commits Tasks 1 and 2 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 'TestIsInlineContentType|TestWriteBlobDisposition|TestFileThumbResizesOnce' ./modules/lagoon/attach && go test -short -count=1 ./modules/lagoon/... ./modules/surf/...</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'func IsInlineContentType(contentType string) bool' modules/lagoon/attach/inline.go` prints 1.
|
||||
- `grep -c 'IsInlineContentType' modules/lagoon/attach/store.go modules/lagoon/attach/inline.go` shows at least 1 match in inline.go. The helper in inline.go calls it, and store.go calls the helper.
|
||||
- `git diff --quiet -- modules/lagoon/attach/thumb.go` exits 0, so thumb.go is untouched.
|
||||
- `git diff -U0 -- modules/lagoon/attach/store.go > "$SCRATCH/rqe-store.diff"` exits 0 ($SCRATCH is the session scratchpad).
|
||||
- `grep -cE '^[-+]func ' "$SCRATCH/rqe-store.diff"` prints 0: no function signature in store.go changes.
|
||||
- The -v run reports PASS for TestIsInlineContentType, TestWriteBlobDisposition and TestFileThumbResizesOnce under -short, so nothing was skipped.
|
||||
- `go test -short ./modules/surf/...` passes without any surf edit, because surf still has its own list at this point.
|
||||
</acceptance_criteria>
|
||||
<done>Store's blob write carries Content-Disposition: attachment for non-inline types on memblob and fileblob. Raster types and thumbnails carry none. IsInlineContentType matches the surf route's rule case for case. Tests are green under -short. Nothing is committed yet; Task 2 commits.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Postgres-backed Store assertion, lagoon README and storage/attachments docs; commit Tasks 1 and 2</name>
|
||||
<files>modules/lagoon/attach/store_test.go, modules/lagoon/README.md, docs/services/storage.md, docs/database/attachments.md</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/store_test.go lines 1-24 and 444-518 (imports, TestStoreDiskExtension)
|
||||
- modules/lagoon/README.md lines 40-44 (Features, attach bullet) and 205-225 (attach API table)
|
||||
- docs/services/storage.md lines 98-161 (Serving files, Deploy note; the public-bucket paragraph is line 160)
|
||||
- docs/database/attachments.md lines 92-100 (Storing an upload)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- TestStoreDiskExtension (Postgres, skips under -short): in every case, the blob's Attributes().ContentType equals the ContentType Store returned and the stored row's ContentType.
|
||||
- ContentDisposition is empty for "png named html", "jpeg named png" and "jpeg named jpeg".
|
||||
- ContentDisposition is "attachment" for every other case. That includes "html named txt", the todo scenario where text/html is stored under <hex>.txt, as well as the docx/zip, ics/text, html, svg/xml, both js, pdf and mkv/webm cases.
|
||||
</behavior>
|
||||
<action>
|
||||
This task covers the Store-level proof and the docs for the todo's "Update the lagoon README and the storage page".
|
||||
|
||||
1. In modules/lagoon/attach/store_test.go, TestStoreDiskExtension, inside the per-case subtest after the ReadAll check:
|
||||
- Read bucket.Attributes for attach.BlobKey(f.DiskName).
|
||||
- Assert that its ContentType equals f.ContentType.
|
||||
- Add stored.ContentType equal to f.ContentType to the existing stored-row check.
|
||||
- Compute the expected disposition from the case itself, never from the function under test: empty when tc.ct is image/png or image/jpeg, otherwise "attachment". Assert it.
|
||||
- Next to the assertion, add a comment naming the todo scenario: an HTML body uploaded as note.txt keeps text/html as its type but is stored with an attachment disposition, so a public bucket makes the browser download it.
|
||||
|
||||
2. In modules/lagoon/README.md:
|
||||
- (a) Features, the attach bullet (line 42): inside the attach.Store parenthetical, add that the blob gets Content-Disposition: attachment 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.
|
||||
- (b) API table: add a short clause with the same fact to the attach.Store row.
|
||||
- (c) Add an attach.IsInlineContentType row right after the attach.IsAllowedImage row. It 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.
|
||||
|
||||
3. In docs/services/storage.md, Deploy note, rewrite the public-bucket case (the paragraph that starts with the bold "A public S3 bucket, or a CDN that pulls from it").
|
||||
- Keep two facts: such a bucket sends the headers stored with the object and ignores the extension, and the stored Content-Type is still the sniffed type (an HTML body uploaded as note.txt is stored as <hex>.txt with text/html).
|
||||
- State the new behaviour: attach.Store 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, therefore 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.
|
||||
- Then cover objects without the disposition: objects written before this change, or by code that bypasses attach.Store, carry none, so 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.
|
||||
- Keep the advice to serve such a bucket from its own origin and to 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.
|
||||
- Add one sentence for file:// buckets: the file driver keeps the disposition in its .attrs sidecar files, which a static server does not read, so the static-server case above still applies.
|
||||
- Leave the static-server case and the nginx example unchanged.
|
||||
|
||||
4. In docs/database/attachments.md, Storing an upload, after the paragraph about the disk name's extension (the one linking the deploy note), add two or three sentences:
|
||||
- attach.Store writes every file whose type attach.IsInlineContentType does not report as a raster image with Content-Disposition: attachment, so a browser that opens it from a public bucket downloads it.
|
||||
- img, video and audio embeds and the content_type column are unaffected.
|
||||
- Thumbnails stay inline.
|
||||
|
||||
5. Run the docs checker and fix any identifier, link or anchor it reports. Use only identifiers that exist: attach.Store, attach.IsInlineContentType, attach.DefaultFileExtensions, attach.DiskExtension. The checker does not verify config keys, so keep storage.uploads.public_url spelled exactly as the page already has it.
|
||||
|
||||
6. Commit Tasks 1 and 2 as one commit, staging exactly these paths:
|
||||
- modules/lagoon/attach/inline.go
|
||||
- modules/lagoon/attach/inline_test.go
|
||||
- modules/lagoon/attach/store.go
|
||||
- modules/lagoon/attach/store_test.go
|
||||
- modules/lagoon/attach/thumb_test.go
|
||||
- modules/lagoon/README.md
|
||||
- docs/services/storage.md
|
||||
- docs/database/attachments.md
|
||||
|
||||
Message: fix(lagoon/attach): store non-inline uploads with Content-Disposition attachment (quick-261028-rqe). Never stage everything at once. Leave other sessions' STATE.md, state.json, G15OFFICE.md and the untracked .gitkeep and .msd files alone. Add no co-author tag (user and project CLAUDE.md).
|
||||
</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 'TestStoreDiskExtension$|TestStoreSmoke$' ./modules/lagoon/attach</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'attach.IsInlineContentType' modules/lagoon/README.md` prints at least 2 (the Features bullet and its own row).
|
||||
- `grep -c 'attach.IsInlineContentType' docs/services/storage.md` and `grep -c 'attach.IsInlineContentType' docs/database/attachments.md` each print at least 1.
|
||||
- The Docker-backed -v run reports PASS for TestStoreDiskExtension and all 12 of its subtests, not SKIP, and for TestStoreSmoke. 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 8 paths above.
|
||||
- `git show "$SHA1" -- modules/lagoon/ > "$SCRATCH/rqe-lagoon.diff"` exits 0.
|
||||
- `grep -E '^[+-](func|type|var|const) [A-Z]' "$SCRATCH/rqe-lagoon.diff"` prints exactly one line, the added IsInlineContentType: nothing exported is removed or changed.
|
||||
- `git log -1 --format=%B "$SHA1" > "$SCRATCH/rqe-msg.txt"` exits 0.
|
||||
- `grep -ci 'co-authored-by' "$SCRATCH/rqe-msg.txt"` prints 0.
|
||||
</acceptance_criteria>
|
||||
<done>One commit. Store writes the disposition, proven against Postgres plus memblob, and both docs pages and the lagoon README describe the behaviour, the shared predicate and the backfill caveat. go vet, go test -short ./..., the docs checkers and the Docker-backed Store tests are green.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: surf uploads route takes its inline types from attach.IsInlineContentType</name>
|
||||
<files>modules/surf/uploads.go, modules/surf/README.md, docs/services/storage.md</files>
|
||||
<read_first>
|
||||
- modules/surf/uploads.go (whole file, 172 lines)
|
||||
- modules/surf/uploads_test.go lines 296-325 and 383-440 (TestUploadsMount headers, TestUploadsResponseHeaders)
|
||||
- modules/surf/README.md line 39 (Opt-in uploads route feature)
|
||||
- docs/services/storage.md lines 110-115 (Serving files bullets)
|
||||
</read_first>
|
||||
<action>
|
||||
The batch context asks for one inline-type definition shared by the route and Store, with no change to surf's exported API.
|
||||
|
||||
1. In modules/surf/uploads.go:
|
||||
- Delete surf's private copy of the inline type list: the unexported map at lines 30-39 and its comment.
|
||||
- Delete the unexported helper at lines 168-172 that parses a Content-Type against that list.
|
||||
- In the disposition writer's decide method, call attach.IsInlineContentType on the response's Content-Type header instead. The attach import is already there.
|
||||
- Remove the mime import, which becomes unused.
|
||||
- Update the doc comment of mountUploads (lines 41-52) to name attach.IsInlineContentType as the list. Also say that attach.Store now stores the attachment disposition with the blob too, but the route still decides from the type on every response: StaticHandlerPublic sends only the stored type, and blobs written earlier or outside attach.Store have no disposition. Reflow the over-long line 48 while you are there.
|
||||
- Update the doc comment of serveUploads (lines 126-130) to name attach.IsInlineContentType.
|
||||
- Behaviour must not change. TestUploadsResponseHeaders and TestUploadsMount must pass without edits, so do not touch uploads_test.go.
|
||||
|
||||
2. In modules/surf/README.md, line 39 (Opt-in uploads route feature): after the list (JPEG, PNG, GIF, WebP, AVIF), name attach.IsInlineContentType as the shared definition, the same one attach.Store uses for the disposition it stores with each file.
|
||||
|
||||
3. In docs/services/storage.md, Serving files, third bullet (the Content-Disposition: attachment bullet at line 114): add that the list is attach.IsInlineContentType, the same one attach.Store uses for the disposition it stores with the file. Link the deploy note anchor (#deploy-note).
|
||||
|
||||
4. Commit, staging exactly modules/surf/uploads.go, modules/surf/README.md and docs/services/storage.md. Message: refactor(surf): take the uploads inline types from attach.IsInlineContentType (quick-261028-rqe). Add no co-author tag.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./... && go test -short -count=1 -v -run 'TestUploadsResponseHeaders|TestUploadsMount' ./modules/surf && go test -short ./... && go test ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'uploadsInline' modules/surf/uploads.go` prints 0.
|
||||
- `grep -c 'attach.IsInlineContentType(' modules/surf/uploads.go` prints at least 1.
|
||||
- `grep -c 'attach.IsInlineContentType' modules/surf/README.md` prints at least 1.
|
||||
- After committing, `git show --name-only --format= HEAD` lists exactly modules/surf/uploads.go, modules/surf/README.md and docs/services/storage.md. In particular, uploads_test.go is not in the commit.
|
||||
- `git show HEAD -- modules/surf/ > "$SCRATCH/rqe-surf.diff"` exits 0.
|
||||
- `grep -cE '^[+-](func|type|var|const) [A-Z]' "$SCRATCH/rqe-surf.diff"` prints 0, so no exported surf identifier changes.
|
||||
- TestUploadsResponseHeaders and TestUploadsMount pass unmodified.
|
||||
</acceptance_criteria>
|
||||
<done>One commit. The surf route and attach.Store share one inline-type definition, the route's headers are unchanged, and the surf README and storage page name the shared function. go vet, go test -short ./... and the docs checkers are green.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| client upload → attach.Store | The client's file name and bytes are untrusted. The type is sniffed from the bytes, so an HTML body under an allowed .txt name gets a rendering type |
|
||||
| bucket/CDN → browser | A public bucket sends the headers stored with the object. A rendering type without a disposition, on any origin the user trusts, is stored XSS |
|
||||
| surf uploads route → browser | The route already adds nosniff, a sandbox CSP and an attachment disposition. Its inline list must not drift from Store's |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-q261028rqe-01 | Elevation of privilege | A public S3 bucket or CDN serving an HTML-sniffed .txt upload as text/html | medium | mitigate | Tasks 1-2: Store writes Content-Disposition: attachment for every non-raster type. TestWriteBlobDisposition (drivers) and the TestStoreDiskExtension "html named txt" case (Postgres) pin it |
|
||||
| T-q261028rqe-02 | Tampering | Header injection through the client file name in the disposition | low | mitigate | The disposition is the constant bare value attachment with no filename parameter, and file_name never reaches blob metadata |
|
||||
| T-q261028rqe-03 | Elevation of privilege | Drift between the surf route's inline list and Store's | low | mitigate | Task 3 leaves one definition, attach.IsInlineContentType. TestUploadsResponseHeaders stays unmodified as the regression check |
|
||||
| T-q261028rqe-04 | Elevation of privilege | Objects written before this change, or by code that bypasses Store, have no disposition | medium | transfer | Task 2's deploy note tells operators to set it on old objects or in the CDN, and keeps the separate-origin and nosniff advice. Direct-writing plugins are recorded as follow-ups |
|
||||
| T-q261028rqe-05 | Denial of service | Images, thumbnails or media embeds breaking because of a disposition | low | mitigate | Raster types and thumbnails get none (Task 1 thumb assertion, thumb.go untouched). Embeds ignore the disposition, which the docs say |
|
||||
| T-q261028rqe-SC | Tampering | npm/pip/cargo installs | low | accept | No new dependency. gocloud.dev/blob/fileblob is already imported by bucket.go and is only newly imported in a test |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- After each of the two commits: go vet ./..., go test -short ./..., go test ./cmd/summer -run TestDocsTree and go run ./cmd/summer docs:build --check are green (CLAUDE.md rule).
|
||||
- Docker-backed: go test -count=1 -run 'TestStoreDiskExtension$|TestStoreSmoke$' ./modules/lagoon/attach passes. If the socket is denied, fall back to sudo -u golem.
|
||||
- The only exported change is the added attach.IsInlineContentType. The surf exported API is unchanged, and so is system_files.content_type.
|
||||
- No file outside summercms.go is edited.
|
||||
- git log shows two commits without co-author tags, each staging only its own paths. Other sessions' STATE.md, state.json, G15OFFICE.md and untracked files are left alone.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- The todo's Solution holds: types that are not meant to render inline are stored with Content-Disposition: attachment, the change is additive and non-breaking, and the lagoon README and the storage page are updated.
|
||||
- The surf route and Store share one inline-type definition, and the route behaves exactly as before.
|
||||
- The SUMMARY lists these follow-ups for the orchestrator:
|
||||
- StaticHandler and StaticHandlerPublic do not send the stored disposition.
|
||||
- Direct blob writers in fonoteka.go, sm-user-plugin and sm-feedback-plugin can adopt attach.IsInlineContentType.
|
||||
- Backfilling the disposition onto existing bucket objects is an operator task.
|
||||
- The todo move and the g15office comment (q1xe3vzb) are left to the orchestrator.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/261028-rqe-attach-store-writes-non-inline-types-with-a-non-rendering-di/261028-rqe-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
phase: quick-261028-rqe
|
||||
plan: 01
|
||||
subsystem: lagoon/attach, surf
|
||||
tags: [security, uploads, storage, s3, content-disposition]
|
||||
status: complete
|
||||
requires: []
|
||||
provides:
|
||||
- attach.IsInlineContentType (shared inline raster-type definition)
|
||||
- Content-Disposition attachment stored with every non-inline blob written by attach.Store
|
||||
affects:
|
||||
- modules/surf uploads route (now uses the shared definition)
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- one inline-type predicate shared by the blob write and the HTTP route
|
||||
key-files:
|
||||
created:
|
||||
- modules/lagoon/attach/inline.go
|
||||
- modules/lagoon/attach/inline_test.go
|
||||
modified:
|
||||
- modules/lagoon/attach/store.go
|
||||
- modules/lagoon/attach/store_test.go
|
||||
- modules/lagoon/attach/thumb_test.go
|
||||
- modules/lagoon/README.md
|
||||
- docs/services/storage.md
|
||||
- docs/database/attachments.md
|
||||
- modules/surf/uploads.go
|
||||
- modules/surf/README.md
|
||||
decisions:
|
||||
- "attach.IsInlineContentType is the single inline-type definition (JPEG, PNG, GIF, WebP, AVIF; strict mime.ParseMediaType, case-insensitive); attach.Store and the surf uploads route both use it"
|
||||
- "The stored disposition is the bare value attachment with no filename parameter; system_files.content_type is unchanged"
|
||||
requirements:
|
||||
- todo:2026-10-28-attach-store-public-content-type
|
||||
metrics:
|
||||
completed: 2026-10-28
|
||||
actuals:
|
||||
tokens: 8700
|
||||
tasks: 3
|
||||
commits: 2
|
||||
plan_head_before: 61d10436c733f929086f51093a82f7c00093075f
|
||||
plan_head_after: 4957e5ce255947bea04d1d9ba32cac227e775488
|
||||
---
|
||||
|
||||
# Quick 261028-rqe: attach.Store writes non-inline types with a non-rendering disposition Summary
|
||||
|
||||
`attach.Store` now writes `Content-Disposition: attachment` as a blob attribute for every type outside the raster allow-list that the new `attach.IsInlineContentType` defines. A public S3 bucket or CDN therefore makes the browser download an HTML body uploaded as `note.txt` instead of rendering it. The surf uploads route now uses the same predicate.
|
||||
|
||||
## Commits
|
||||
|
||||
| Task | Commit | Message |
|
||||
|------|--------|---------|
|
||||
| 1+2 | ba8432c | fix(lagoon/attach): store non-inline uploads with Content-Disposition attachment (quick-261028-rqe) |
|
||||
| 3 | 4957e5c | refactor(surf): take the uploads inline types from attach.IsInlineContentType (quick-261028-rqe) |
|
||||
|
||||
## What changed
|
||||
|
||||
- `modules/lagoon/attach/inline.go` (new) adds:
|
||||
- the unexported raster set (avif, gif, jpeg, png, webp);
|
||||
- the exported `IsInlineContentType`;
|
||||
- the `attachmentDisposition` constant;
|
||||
- `uploadWriterOptions`, which `writeBlob` now calls.
|
||||
- The `writeBlob` and `Store` signatures and the row fields are unchanged. The Store doc comment describes the disposition.
|
||||
- `thumb.go` is not edited. Thumbnails and the broken-image picture stay inline, and a test assertion in `TestFileThumbResizesOnce` checks this.
|
||||
- `surf/uploads.go`: removed `uploadsInlineTypes`, `uploadsInline` and the `mime` import. `decide` now calls `attach.IsInlineContentType`. The doc comments of `mountUploads` and `serveUploads` were updated and reflowed.
|
||||
- Docs:
|
||||
- lagoon README: the Features bullet, the `attach.Store` row and a new `attach.IsInlineContentType` row.
|
||||
- `docs/services/storage.md`: rewrote the public-bucket deploy note, covering the stored disposition, objects written earlier or outside Store, a CDN that drops headers, nosniff, and the `.attrs` sidecar on `file://` buckets. The route bullet now names the shared list and links `#deploy-note`.
|
||||
- `docs/database/attachments.md`: a paragraph under "Storing an upload".
|
||||
- surf README: the uploads-route feature bullet.
|
||||
|
||||
## Verification
|
||||
|
||||
- `go vet ./...` and `go test -short ./...` passed after each commit.
|
||||
- `go test ./cmd/summer -run TestDocsTree` passed and `go run ./cmd/summer docs:build --check` reported no problems.
|
||||
- With `-short`, `TestIsInlineContentType`, `TestWriteBlobDisposition` (memblob, fileblob sidecar, fileblob with metadata skipped) and `TestFileThumbResizesOnce` passed. Before the implementation the test file failed to compile, which was the RED step.
|
||||
- Docker tests:
|
||||
- `TestStoreSmoke` passed.
|
||||
- `TestStoreDiskExtension` passed with all 12 subtests.
|
||||
- The whole `./modules/lagoon/...` and `./modules/surf/...` passed without `-short`.
|
||||
- `TestUploadsMount` and `TestUploadsResponseHeaders` passed. `uploads_test.go` was not modified.
|
||||
- API check: the only exported identifier added is `IsInlineContentType`. The plan's grep also matches the two new `Test*` functions, which are tests and not API. No exported surf identifier changed.
|
||||
- Neither commit has a co-author line. Each commit staged only its own paths.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None in behaviour. Small notes:
|
||||
- The plan's acceptance grep `^[+-](func|type|var|const) [A-Z]` on the lagoon diff prints three lines, not one, because `TestIsInlineContentType` and `TestWriteBlobDisposition` are also exported-looking `func`s. The only API addition is `IsInlineContentType`.
|
||||
- I also ran the full lagoon and surf packages against Docker, not just the two named Store tests, to catch any other assertions on blob attributes.
|
||||
|
||||
## Follow-ups (for the orchestrator)
|
||||
|
||||
- `attach.StaticHandler` and `attach.StaticHandlerPublic` still send only the stored Content-Type, not the stored disposition. An app that mounts `StaticHandlerPublic` itself gets no disposition; reading it would cost an extra `Attributes` call per request.
|
||||
- Code that writes blobs directly bypasses `attach.Store` and gets no disposition. These can adopt `attach.IsInlineContentType`:
|
||||
- fonoteka.go `storeUpload` and `StorePublicFile`;
|
||||
- the sm-user-plugin avatar writer;
|
||||
- the sm-feedback-plugin screenshot writer.
|
||||
- Adding the disposition to bucket objects written before this change is an operator task. The deploy note documents it.
|
||||
- Moving the todo `2026-10-28-attach-store-public-content-type` to completed and the g15office comment (`q1xe3vzb`) are left to the orchestrator.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. No new endpoints or trust-boundary surface. T-q261028rqe-01/02/03/05 mitigated as planned, and T-04 is handed to operators through the deploy note.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: modules/lagoon/attach/inline.go, modules/lagoon/attach/inline_test.go
|
||||
- FOUND: ba8432c, 4957e5c
|
||||
@@ -0,0 +1,198 @@
|
||||
---
|
||||
phase: quick-261028-rqf
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- modules/cabana/actions.go
|
||||
- modules/cabana/action_run_log_test.go
|
||||
- modules/cabana/README.md
|
||||
- docs/backend/users-and-permissions.md
|
||||
autonomous: true
|
||||
requirements:
|
||||
- todo:admin-action-audit-log
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A successful toolbar action run (POST .../{controller}/toolbar/{action}) writes one Info line 'cabana: admin action run' with controller, action, admin_id (the signed-in administrator's id, never 0 for an authenticated admin) and kind=toolbar, and no field or record_id key"
|
||||
- "A successful widget (form field) action run (POST .../{controller}/widgets/{field}) writes one 'cabana: admin action run' line with controller, action, admin_id, kind=field, field=<field name>, plus record_id when the request carried one"
|
||||
- "The run line never carries the posted values, the payload, the answer's fill, data or message, or record contents"
|
||||
- "A run that ends in a 403 permission denial, a plugin 422 ValidationError, a plugin 403 ForbiddenError, a plugin error (500) or a rejected Data value (500) writes no run line. The existing failure and data-rejected lines keep their messages and now also carry admin_id and kind"
|
||||
- "The bulk and record action log lines are unchanged (same messages and keys); T-12.1-08 stays green"
|
||||
- "No exported cabana identifier is added, removed or changed"
|
||||
- "go vet ./..., go test -short ./..., the Docker-backed cabana action tests, go test ./cmd/summer -run TestDocsTree and go run ./cmd/summer docs:build --check are green after the commit"
|
||||
artifacts:
|
||||
- path: modules/cabana/actions.go
|
||||
provides: "actionKindToolbar/actionKindField constants, actionAdminID helper, runAction kind parameter and the success log line"
|
||||
contains: "cabana: admin action run"
|
||||
- path: modules/cabana/action_run_log_test.go
|
||||
provides: "TestAdminActionRunLog on the acme.demo.gadgets fixture (Postgres)"
|
||||
contains: "func TestAdminActionRunLog"
|
||||
- path: modules/cabana/README.md
|
||||
provides: "Widget and toolbar feature bullets name the run log; Testing names the new test"
|
||||
contains: "TestAdminActionRunLog"
|
||||
- path: docs/backend/users-and-permissions.md
|
||||
provides: "Action audit sentence covers toolbar and widget actions"
|
||||
key_links:
|
||||
- from: modules/cabana/actions.go toolbarAction
|
||||
to: modules/cabana/actions.go runAction
|
||||
via: "s.runAction(w, r, cc, action, pact.AdminActionInput{}, nil, actionKindToolbar)"
|
||||
pattern: "actionKindToolbar\\)"
|
||||
- from: modules/cabana/actions.go widgetAction
|
||||
to: modules/cabana/actions.go runAction
|
||||
via: "s.runAction(w, r, cc, action, input, field.Fill, actionKindField)"
|
||||
pattern: "actionKindField\\)"
|
||||
- from: modules/cabana/actions.go runAction
|
||||
to: modules/bouncer/context.go User
|
||||
via: "actionAdminID(r.Context()) reads bouncer.User and returns principal.ID or 0"
|
||||
pattern: "actionAdminID\\(r.Context\\(\\)\\)"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Close the pending todo `.planning/todos/pending/admin-action-audit-log.md` (g15office xkujbsv4, accepted risk AR-18.1-03 from the Phase 18.1 security audit) in the framework only: cabana logs every successful toolbar and widget (form field) action run with the administrator's id, the controller, the action and the kind (`toolbar` or `field`), the same way bulk and record action runs are already logged (`cabana: admin bulk action` and `cabana: admin record action` in `modules/cabana/actions.go`).
|
||||
|
||||
Today `runAction` (`modules/cabana/actions.go`, about line 334) only logs failures (`cabana: admin action failed`, `cabana: admin action data rejected`) and neither of those lines carries the admin id. A plugin toolbar action such as a "sync everything" button leaves no record of who ran it.
|
||||
|
||||
Chosen shape (from the todo's two options, per the batch item): a log line in cabana, not an event and not a separate plugin.
|
||||
|
||||
Purpose: an administrator-triggered toolbar or widget action becomes attributable in the server log, which closes the Repudiation gap recorded as AR-18.1-03.
|
||||
Output: one commit in summercms.go (code, test, README and docs together) and `261028-rqf-SUMMARY.md`.
|
||||
|
||||
Not in scope (orchestrator handles or records):
|
||||
- Moving the todo to `.planning/todos/completed/`, the g15office comment, and a note on AR-18.1-03 in `.planning/phases/18.1-payment-gateway-subscriptions/18.1-SECURITY.md` belong to the orchestrator's closing docs commit. The executor does not touch planning files other than the SUMMARY.
|
||||
- An event on the application bus for action runs, and a separate audit-log plugin: not chosen.
|
||||
- The plugin that owns the sync toolbar action (a sibling `sm-*-plugin` repo) needs no change: the framework line now names the admin. Do not edit sibling repos.
|
||||
- Logging plugin 422 or 403 answers from `runAction`: unchanged (no line today, none after).
|
||||
</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/admin-action-audit-log.md
|
||||
|
||||
<interfaces>
|
||||
Existing code, verified at planning time.
|
||||
|
||||
modules/cabana/actions.go (package cabana)
|
||||
- func (s *service) widgetAction(w, r): resolves the `type: widget` field, its action (the controller's Actions, or a form extension's actions per D-91), checks permissions with s.allowAction, decodes the body, loads record_id through readScopedRecord when present, builds pact.AdminActionInput{Field, Values, Payload, RecordID, Record} and calls s.runAction(w, r, cc, action, input, field.Fill) at about line 96.
|
||||
- func (s *service) toolbarAction(w, r): calls s.runAction(w, r, cc, action, pact.AdminActionInput{}, nil) at about line 123.
|
||||
- func (s *service) bulkAction(w, r), lines ~177-181, and func (s *service) recordAction(w, r), lines ~248-252: each computes `var adminID uint; if principal, _ := bouncer.User(r.Context()); principal != nil { adminID = principal.ID }` and logs moduleLog.Info("cabana: admin bulk action", "controller", controllerID(cc), "action", name, "admin_id", adminID, "affected", ...) or ("cabana: admin record action", ..., "record_id", id).
|
||||
- func (s *service) runAction(w, r, cc *CompiledController, action pact.AdminAction, input pact.AdminActionInput, fill []string): runAction is unexported and its only two callers are widgetAction and toolbarAction (grep confirmed). On a Run error it answers 422/403 for *ValidationError/*ForbiddenError without logging, otherwise logs moduleLog.Error("cabana: admin action failed", "controller", ..., "action", action.Name, "field", input.Field, "error", err) and answers 500. When result.Data does not encode or exceeds maxActionDataBytes it logs "cabana: admin action data rejected" (same keys plus "bytes") and answers 500. Otherwise WriteData(w, http.StatusOK, out, nil).
|
||||
- func controllerID(cc) string (crud.go:1050) returns cc.Controller.ID().
|
||||
- moduleLog = diary.For("cabana") (log.go), slog-style Info/Error(msg, key, value...), written through slog.Default() at call time.
|
||||
|
||||
modules/bouncer/context.go
|
||||
- func User(ctx context.Context) (*Principal, bool); Principal.ID is uint.
|
||||
|
||||
Test harness (package cabana_test, all Docker-backed through adminGorm, which skips under testing.Short()):
|
||||
- newActEnv(t) (*actEnv, *gorm.DB) in phase101_actions_test.go: the acme.demo plugin; controller ID "acme.demo.gadgets" at admin path "/acme/demo/gadgets". Widget route "/acme/demo/gadgets/widgets/lookup" runs action "lookup"; toolbar route "/acme/demo/gadgets/toolbar/recount" runs "recount". Auth modes: "bearer" (full admin), "limited" (no acme.demo.run, so 403 on both routes), "cookie", "cookie-only".
|
||||
- The lookup action answers by in.Values["name"]: "invalid" gives a ValidationError (422), "boom" a plain error "upstream said hunter2" (500), "refused" a ForbiddenError (403); any other name a 200 with Message "Name and Active were filled in." and fill name "looked-up". A non-empty payload echoes the payload as Data; payload "big" answers Data over 256 KiB (500, data rejected).
|
||||
- actInsert(t, gdb, name, tenant) uint; tenant "acme" is in the form scope.
|
||||
- env.expect(t, status, method, rel, body, auth) *httptest.ResponseRecorder; env.spy.take() drains recorded inputs.
|
||||
- captureLog(t) *logCapture (phase121_threats_test.go): installs a capturing slog default; logs.matching(prefix) []string returns lines formatted as `message key=value key=value` (values via %v).
|
||||
- profileOf(t, raw) cabana.AdminProfile (account_test.go); AdminProfile.ID uint is in GET "/auth/me" (env.expect(t, 200, "GET", "/auth/me", "", "bearer")).
|
||||
- T-12.1-08 in phase121_threats_test.go pins the bulk and record lines (message prefix, controller, action, admin_id non-zero, no record contents).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Log successful toolbar and widget action runs with the admin id, controller, action and kind</name>
|
||||
<files>modules/cabana/action_run_log_test.go, modules/cabana/actions.go, modules/cabana/README.md, docs/backend/users-and-permissions.md</files>
|
||||
<read_first>
|
||||
- modules/cabana/actions.go (whole file, about 460 lines)
|
||||
- modules/cabana/phase101_actions_test.go lines 60-125 (actSpy, actPlugin, actController), 130-195 (lookup and recount actions), 220-360 (actEnv, call, expect, newActEnv, actInsert)
|
||||
- modules/cabana/phase121_threats_test.go lines 30-75 (logCapture, captureLog) and the T-12.1-08 subtest (about lines 234-263)
|
||||
- modules/cabana/account_test.go lines 61-68 (profileOf)
|
||||
- modules/cabana/README.md lines 29, 31, 32, 37 and the Testing section (line 404 on)
|
||||
- docs/backend/users-and-permissions.md line 75
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Toolbar run: POST /acme/demo/gadgets/toolbar/recount with `{}` as "bearer" answers 200 and adds exactly one line with prefix "cabana: admin action run" containing controller=acme.demo.gadgets, action=recount, kind=toolbar and admin_id=<id from GET /auth/me>, and containing neither "field=" nor "record_id=".
|
||||
- Widget run on a saved record: POST .../widgets/lookup with record_id of an in-scope gadget named "Secretname" and values name "typedsecret" answers 200 and adds one run line with action=lookup, kind=field, field=lookup, record_id=<that id> and the same admin_id. The line contains none of "Secretname", "typedsecret", "looked-up" or "filled in".
|
||||
- Widget run on the create form with payload `{"note":"payloadsecret"}` answers 200 and adds one run line with kind=field and field=lookup, no "record_id=" and no "payloadsecret".
|
||||
- No run line for: values name "invalid" (422), "refused" (403), "boom" (500), payload "big" (500), and the toolbar and widget routes as "limited" (403). After all of these the run-line count is still 3.
|
||||
- The "boom" request's "cabana: admin action failed" line and the "big" request's "cabana: admin action data rejected" line each contain kind=field and the same admin_id.
|
||||
</behavior>
|
||||
<action>
|
||||
Order: write the test first and run it (it fails: no run line exists), then change actions.go, then the docs, then run the full verify and commit once.
|
||||
|
||||
Test, per the todo and the batch item. Create modules/cabana/action_run_log_test.go in package cabana_test with TestAdminActionRunLog, doc-commented as pinning the action run log of the admin-action-audit-log todo (AR-18.1-03): who ran which toolbar or widget action. Build it on newActEnv, actInsert, captureLog, profileOf and env.expect exactly as listed in the interfaces block; get the admin id from GET /auth/me as "bearer" and fail if it is 0; build the expected token with fmt.Sprintf of admin_id=%d. Install captureLog after the env and the /auth/me call. Drive the requests and assertions listed in the behavior block in that order, filtering lines with logs.matching("cabana: admin action run"), logs.matching("cabana: admin action failed") and logs.matching("cabana: admin action data rejected"). Drain env.spy with take() where the spy would otherwise keep inputs. Use only the standard library plus the cabana_test helpers already in the package; no new dependency.
|
||||
|
||||
Code, in modules/cabana/actions.go. Nothing exported changes.
|
||||
- Add two unexported untyped string constants next to the action routes: actionKindToolbar = "toolbar" and actionKindField = "field", with a one-line comment that they are the kind key of the action run log.
|
||||
- Add an unexported helper actionAdminID(ctx context.Context) uint: bouncer.User(ctx), principal.ID when the principal is non-nil, else 0. Replace the two inline adminID blocks in bulkAction and recordAction with a call to it; their log messages and keys stay exactly as they are (T-12.1-08 must stay green unchanged). allowAction keeps its own principal lookup because it also needs the principal for Allows.
|
||||
- Give runAction a final parameter kind string. widgetAction passes actionKindField and toolbarAction passes actionKindToolbar.
|
||||
- At the top of runAction build one key/value slice for every log line of that run: "controller" controllerID(cc), "action" action.Name, "admin_id" actionAdminID(r.Context()), "kind" kind; then, only when kind is actionKindField, "field" input.Field; then, only when input.RecordID is non-nil, "record_id" with the dereferenced id. Each code path logs at most once and returns, so appending the extra keys to that slice on the failure path is safe; still build the error-path arguments with append on a clipped copy (slices.Clip) so no later edit can alias them.
|
||||
- The failure line keeps its message "cabana: admin action failed" and gets the shared keys plus "error". The data-rejected line keeps its message "cabana: admin action data rejected" and gets the shared keys plus "bytes" and "error". Consequence to note in the SUMMARY: a toolbar failure line no longer writes an empty field key.
|
||||
- Add the success line moduleLog.Info with message "cabana: admin action run" and the shared keys, placed after the Data size/encoding check and immediately before the final WriteData, so a data-rejected 500 never logs a run. Log nothing from the result: not Message, Fill or Data, and nothing from input.Values, input.Payload or input.Record.
|
||||
- Extend the runAction doc comment with one sentence: a successful run is logged with the controller, action, administrator id and kind (field name and record id for a widget), never the values, payload, answer or record contents; the failure lines carry the same keys.
|
||||
|
||||
Docs, same commit (CLAUDE.md documentation rule).
|
||||
- modules/cabana/README.md, Features, the "Form widgets and controller actions" bullet (line ~29): append a sentence that each successful run is logged with the controller, action, administrator, kind `field`, the field name and the record id when one was posted, without values, payload, the answer or record contents, and that a failed run's log line carries the same fields.
|
||||
- Same file, the "Toolbar actions" bullet (line ~31): append that each successful run is logged with the controller, action, administrator and kind `toolbar`.
|
||||
- Same file, Testing section: add one sentence that the toolbar and widget action run log is covered by the Postgres test TestAdminActionRunLog in `action_run_log_test.go` (who ran it, kind, field and record id, and no line for refused, invalid or failed runs).
|
||||
- docs/backend/users-and-permissions.md line 75: rewrite the last sentence so it covers all four kinds: a bulk, record, toolbar or widget action that ran is logged with the controller, the action and the administrator, plus the affected count for a bulk action, the record id for a record action (and for a widget action posted from a saved record), and the kind `toolbar` or `field` for the other two, without record contents, posted values or payload.
|
||||
- Write the log message text in prose, not as a backticked identifier, if the docs checker objects to it. Framework docs never name a consuming application or plugin.
|
||||
|
||||
Before editing, run git status on modules/cabana and docs to confirm no other session has uncommitted edits there; if one does, stop and report. Commit only the four files, staged by explicit path. Suggested message: "feat(cabana): log successful toolbar and widget action runs with the admin id (quick-261028-rqf)". No co-author tag, no push.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -short ./... && go test -count=1 -v -run 'TestAdminActionRunLog' ./modules/cabana/ 2>&1 | grep -q -- '^--- PASS: TestAdminActionRunLog' && go test -count=1 -run 'TestPhase101Actions|TestWidgetPayloadAndData|TestPhase121Threats|TestDetachedBulkAction' ./modules/cabana/ && go test ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c '"cabana: admin action run"' modules/cabana/actions.go` prints 1.
|
||||
- `grep -c 'actionKindToolbar\|actionKindField' modules/cabana/actions.go` prints at least 4 (the declarations, two call sites, the field-only condition).
|
||||
- `grep -c 'actionAdminID(' modules/cabana/actions.go` prints at least 4 (definition, runAction, bulkAction, recordAction).
|
||||
- `grep -c 'func TestAdminActionRunLog' modules/cabana/action_run_log_test.go` prints 1, and the non-short run reports `--- PASS: TestAdminActionRunLog`, not SKIP. If the Docker socket is denied although the user is in the docker group, rerun the Docker commands through `sudo -u golem` as CLAUDE.md describes and record in the SUMMARY which way they ran.
|
||||
- `grep -c 'TestAdminActionRunLog' modules/cabana/README.md` prints 1, and `grep -c 'toolbar or widget action' docs/backend/users-and-permissions.md` prints at least 1.
|
||||
- No exported identifier changes: set SHA to this task's commit (`git log -1 --format=%H` right after committing), write `git show "$SHA" -- modules/cabana/actions.go` to a file in the session scratchpad, check the command exited 0, then `grep -cE '^[+-](func|type|var|const) [A-Z]'` on that file prints 0.
|
||||
- `git show --name-only --format= "$SHA"` exits 0 and lists exactly the four files in files_modified.
|
||||
</acceptance_criteria>
|
||||
<done>One commit. Every successful toolbar and widget action run writes "cabana: admin action run" with controller, action, admin_id and kind (plus field and record_id for a widget), with no posted or returned content; refused, invalid and failed runs write no run line, and the failure lines now carry admin_id and kind. The bulk and record lines are unchanged. README and the permissions docs say so. go vet, go test -short ./..., the Docker cabana action tests and the docs checker are green.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| admin SPA / API token client → cabana action routes | An authenticated administrator triggers plugin code (toolbar and widget actions) that may change data or call external services; the server log is the only record of who did it |
|
||||
| request body → server log | Posted values and payload, and the plugin's answer, are untrusted or sensitive and must not reach the log |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-q261028rqf-01 | Repudiation | cabana runAction (toolbar and widget action routes) | medium | mitigate | Task 1 logs every successful run as "cabana: admin action run" with admin_id from bouncer.User, controller, action and kind (field and record_id for widgets); the failure lines gain admin_id and kind. TestAdminActionRunLog pins the admin id against GET /auth/me. Closes AR-18.1-03 |
|
||||
| T-q261028rqf-02 | Information disclosure | the new run line | medium | mitigate | Only compiled names (controller ID, action name, field name), the numeric admin and record ids and the fixed kind are logged; never input.Values, input.Payload, input.Record or the result's Message, Fill or Data. TestAdminActionRunLog asserts the record name, posted value, payload and answer text are absent |
|
||||
| T-q261028rqf-03 | Repudiation | refused or failed runs logged as runs | low | mitigate | The run line is written only after Run succeeded and the Data check passed; the test asserts no run line for 403 (permission and refusal), 422, 500 and data-rejected answers |
|
||||
| T-q261028rqf-04 | Tampering | log injection through logged strings | low | accept | Every logged string comes from boot-compiled plugin configuration, not the request; slog handlers quote values |
|
||||
| T-q261028rqf-05 | Denial of service | log volume | low | accept | One Info line per administrator-triggered action, the same rate as the existing bulk and record lines |
|
||||
| T-q261028rqf-SC | Tampering | npm/pip/cargo installs | low | accept | No new dependencies; the test uses the standard library and existing package helpers only |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- After the commit: go vet ./..., go test -short ./..., go test ./cmd/summer -run TestDocsTree and go run ./cmd/summer docs:build --check are green (CLAUDE.md rule).
|
||||
- Docker-backed: go test -count=1 -run 'TestAdminActionRunLog|TestPhase101Actions|TestWidgetPayloadAndData|TestPhase121Threats|TestDetachedBulkAction' ./modules/cabana/ passes, with TestAdminActionRunLog reported PASS (fall back to sudo -u golem when the socket is denied).
|
||||
- No exported cabana identifier changes; the bulk and record log lines are byte-for-byte the same messages and keys.
|
||||
- git log shows one commit without a co-author tag, staging only the four files. Other sessions' STATE.md, state.json, G15OFFICE.md, .gitkeep and quick-batches files are left alone. No file outside summercms.go is edited.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- The todo's gap is closed in the framework: a successful toolbar or widget action leaves a log line naming the administrator, the controller, the action and the kind, matching the bulk and record lines.
|
||||
- The SUMMARY lists for the orchestrator: move admin-action-audit-log.md to completed, the g15office xkujbsv4 comment, and a note that AR-18.1-03 in 18.1-SECURITY.md is now resolved by this quick task; plus the small log-shape change (a toolbar failure line no longer writes an empty field key).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/261028-rqf-audit-log-successful-admin-action-runs-with-the-admin-id-tod/261028-rqf-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
phase: quick-261028-rqf
|
||||
plan: 01
|
||||
subsystem: cabana
|
||||
tags: [audit-log, admin-actions, security, repudiation]
|
||||
status: complete
|
||||
requires: []
|
||||
provides:
|
||||
- "cabana: admin action run log line for toolbar and widget action runs"
|
||||
affects: [modules/cabana, docs/backend]
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: ["shared slog key/value slice per run, clipped before appends"]
|
||||
key-files:
|
||||
created:
|
||||
- modules/cabana/action_run_log_test.go
|
||||
modified:
|
||||
- modules/cabana/actions.go
|
||||
- modules/cabana/README.md
|
||||
- docs/backend/users-and-permissions.md
|
||||
decisions:
|
||||
- "Audit of toolbar and widget action runs is a cabana log line (not an event, not a plugin), matching the bulk and record lines"
|
||||
metrics:
|
||||
duration: ~12min
|
||||
completed: 2026-10-28
|
||||
actuals:
|
||||
tasks: 1
|
||||
commits: 1
|
||||
plan_head_before: 202d38ba3c48115f412b2687fe17e102f8a764df
|
||||
plan_head_after: 61d10436c733f929086f51093a82f7c00093075f
|
||||
---
|
||||
|
||||
# Quick 261028-rqf: Audit log for successful admin action runs Summary
|
||||
|
||||
cabana now writes `cabana: admin action run` for every successful toolbar and widget action. The line carries the controller, the action, the signed-in administrator's `admin_id` and `kind` (`toolbar` or `field`). Widget runs also get `field` and, when one was posted, `record_id`. This closes the repudiation gap AR-18.1-03.
|
||||
|
||||
## What changed
|
||||
|
||||
- `modules/cabana/actions.go`:
|
||||
- Adds the unexported constants `actionKindToolbar` and `actionKindField`.
|
||||
- Adds the helper `actionAdminID(ctx)`. `bulkAction` and `recordAction` now call it, and their messages and keys are unchanged (T-12.1-08 is green).
|
||||
- `runAction` takes a new `kind` parameter and builds one shared key/value slice, clipped with `slices.Clip`.
|
||||
- The failure and data-rejected lines keep their messages and now carry `admin_id` and `kind`.
|
||||
- The success line is written after the Data check and just before `WriteData`. It never includes values, payload, the result's Message/Fill/Data or record contents.
|
||||
- `modules/cabana/action_run_log_test.go`: TestAdminActionRunLog (Postgres, acme.demo.gadgets). It covers:
|
||||
- the toolbar run, the widget run on a saved record and the widget run on the create form, with the admin id compared against GET /auth/me
|
||||
- leak checks for "Secretname", "typedsecret", "looked-up", "filled in" and "payloadsecret"
|
||||
- no run line for 422, 403 (refused), 500 (boom), data-rejected or a limited 403 on either route
|
||||
- `kind`/`admin_id` present on the failure and data-rejected lines
|
||||
- README: the widget and toolbar feature bullets describe the run log, and the Testing section names TestAdminActionRunLog. `docs/backend/users-and-permissions.md` now covers all four action kinds.
|
||||
|
||||
## Log-shape change (for the orchestrator)
|
||||
|
||||
A toolbar failure line no longer writes an empty `field=` key. `field` now appears only for kind `field`. Both failure lines gained `admin_id` and `kind`, plus `record_id` when a widget posted one.
|
||||
|
||||
## Commits
|
||||
|
||||
- 61d1043: feat(cabana): log successful toolbar and widget action runs with the admin id (quick-261028-rqf)
|
||||
|
||||
## Verification
|
||||
|
||||
- `go vet ./...` and `go test -short ./...` were green, as were `go test ./cmd/summer -run TestDocsTree` and `go run ./cmd/summer docs:build --check` ("no problems found").
|
||||
- The Docker tests ran directly with no `sudo -u golem` fallback. TestAdminActionRunLog was RED before the code change (no run line) and PASS after. TestPhase101Actions, TestWidgetPayloadAndData, TestPhase121Threats, TestDetachedBulkAction, TestPhase121Actions and TestPhase10OpenAPIConformance all passed.
|
||||
- No exported identifier changed: grep for `^[+-](func|type|var|const) [A-Z]` on the actions.go diff found 0. The commit contains exactly the four planned files and has no co-author line.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
- [Rule 2 - Docs] Added `slices` to the standard-library list in the cabana README Dependencies section. cabana already imported it elsewhere but the list left it out, and `actions.go` now imports it as well. Same file, same commit.
|
||||
|
||||
## Left for the orchestrator (not done by the executor)
|
||||
|
||||
- Move `.planning/todos/pending/admin-action-audit-log.md` to `.planning/todos/completed/`.
|
||||
- Post the g15office comment on xkujbsv4.
|
||||
- Add a note in `.planning/phases/18.1-payment-gateway-subscriptions/18.1-SECURITY.md` that AR-18.1-03 is resolved by quick-261028-rqf.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: modules/cabana/action_run_log_test.go
|
||||
- FOUND: commit 61d1043
|
||||
@@ -0,0 +1,209 @@
|
||||
---
|
||||
phase: quick-261028-rqg
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- modules/cabana/backend_roles_admin.go
|
||||
- modules/cabana/backendscreens/models/userrole/fields.yaml
|
||||
- modules/cabana/backendscreens/controllers/roles/_users.htm
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- modules/cabana/README.md
|
||||
- docs/backend/users-and-permissions.md
|
||||
- modules/cabana/backend_screens_smoke_test.go
|
||||
- modules/cabana/backend_screens_test.go
|
||||
- modules/cabana/roles_postgres_test.go
|
||||
autonomous: true
|
||||
requirements:
|
||||
- todo:roles-screen-holders-list
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "On the Roles update form (summercms.cabana.roles) an Administrators tab shows a read-only table of the administrators whose role_id is the role: login, first name, last name, email, ordered by login then id"
|
||||
- "A superuser editor sees every holder, superusers included (marked Superuser); an editor who does not act as a superuser never sees a superuser holder, neither as a row nor in the count (D-03, same scopeAdministrators scope as the Administrators list)"
|
||||
- "Deleted holders are listed with a Deleted line and deactivated ones with a Deactivated line, so the list matches the delete refusal count, which counts deleted accounts too (D-17)"
|
||||
- "A holder's login links to <backend.uri>/summercms/cabana/users/<id>/preview only for an editor that holds backend.manage_users (cabana.Allows); others see plain text. Nothing on the Roles form assigns or removes a role: assignment stays on the Administrators form"
|
||||
- "A role nobody (visible) holds shows 'No administrator has this role.'; more than 100 visible holders show the first 100 plus a translated note with the total; the create form shows no Administrators tab"
|
||||
- "No admin/ change, no new route, no exported identifier added or changed; admin/openapi and modules/boardwalk/dist are untouched"
|
||||
- "go vet ./..., go test -short ./..., the Docker-backed roles tests, go test ./cmd/summer -run TestDocsTree and summer docs:build --check are green"
|
||||
artifacts:
|
||||
- path: modules/cabana/backend_roles_admin.go
|
||||
provides: "rolesAdmin implements pact.AdminPartialData (partial users), rolesAdmin.db(), roleHoldersView/roleHolderRow view model, roleHoldersLimit"
|
||||
contains: "func (c rolesAdmin) PartialData"
|
||||
- path: modules/cabana/backendscreens/controllers/roles/_users.htm
|
||||
provides: "html/template of the holders table (summer-table style kit)"
|
||||
- path: modules/cabana/backendscreens/models/userrole/fields.yaml
|
||||
provides: "users field: type partial, path users, context update, tab backend::lang.user.menu_label"
|
||||
contains: "path: users"
|
||||
- path: modules/phrasebook/backend/lang/en/lang.yaml
|
||||
provides: "user.role.users_field, users_comment, users_empty, users_more (plural)"
|
||||
contains: "users_empty"
|
||||
- path: modules/cabana/roles_postgres_test.go
|
||||
provides: "TestRolesHolders (Postgres): D-03 hiding, links by permission, states, empty role, 100-row cap, 403 without manage_roles"
|
||||
contains: "func TestRolesHolders"
|
||||
key_links:
|
||||
- from: modules/cabana/backendscreens/models/userrole/fields.yaml (users partial field)
|
||||
to: modules/cabana/partial_render.go service.partial
|
||||
via: "GET <prefix>/api/v1/summercms/cabana/roles/partials/users?id=<role id>, record loaded by readScopedRecord, then rolesAdmin.PartialData"
|
||||
- from: modules/cabana/backend_roles_admin.go PartialData
|
||||
to: modules/cabana/backend_users_admin.go scopeAdministrators
|
||||
via: "the holders query and count both go through scopeAdministrators (Unscoped + is_superuser = false for non-superusers)"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Show, on the Roles screen's update form, the administrators who hold the role, as a read-only list (todo `.planning/todos/pending/roles-screen-holders-list.md`, origin 19.6.1 UAT test 6). Superusers stay hidden from non-superuser editors (19.6.1 D-03). Role assignment stays on the Administrators form.
|
||||
|
||||
Purpose: the Roles form shows code, name and permissions but not who holds the role; WinterCMS's UserRoles shows its users on an Administrators tab (`_users@update`).
|
||||
|
||||
Approach (Claude's discretion, decided from live code reads): a schema-driven `type: partial` field rendered by the existing partial pipeline (`partial_render.go`, SPA `PartialField.vue` + `PartialHost.vue` already pass `?id=` on the update form). No SPA change, so no `modules/boardwalk/dist` rebuild and no dependency on 261028-rqb. I did not use the relation-manager widget (`type: relation-manager` with a hasMany contract over `role_id`) because its linked-rows query (`hasManyBaseQuery`, non-candidate branch, relation.go) has no controller scope hook. It would list superusers to non-superusers (breaking D-03), and fixing that needs a new exported pact capability. It also has no row links. The partial reuses `scopeAdministrators` exactly and adds no exported API.
|
||||
|
||||
Output: holders partial (Go view model + template + fields.yaml field + phrases en/pl), README + docs in the same commit, smoke assertion; then unit and Postgres tests.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@CLAUDE.md
|
||||
@.planning/todos/pending/roles-screen-holders-list.md
|
||||
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-CONTEXT.md
|
||||
|
||||
Live-read facts the executor can rely on (verified at planning time, 2026-10-28):
|
||||
- `modules/cabana/backend_roles_admin.go`: `rolesAdmin{screens *backendScreens}`, ConfigDir `controllers/roles`, RequiredPermissions `PermissionManageRoles`, a `var (...)` block of interface assertions, `translator()`, and FormBeforeDelete's Choice pattern (`tr.Choice(towel.WithLocale(ctx, schemaLocale(ctx, tr)), key, n, params)`). It does not implement `pact.AdminPartialData` yet and has no `db()`.
|
||||
- `modules/cabana/backend_users_admin.go`: `usersAdmin.db()` (app pool via `app.Lookup[*gorm.DB]()`), `scopeAdministrators(ctx, db)` (Unscoped, plus `backend_users.is_superuser = ?` false unless `actsAsSuperuser(principal)`), and `usersAdmin.PartialData` (the pattern to mirror: unknown name returns an error, nil record returns an empty view).
|
||||
- `modules/cabana/partial_render.go` `service.partial`: with `?id=` the name must be a form partial and the record is loaded through `readScopedRecord` (404 outside scope); the ctx handed to PartialData already carries the request locale; `refusedViewModel` refuses GORM models; caps are 64 KiB output and 2000 nodes. `compilePartials` (extension.go) fails boot if a `type: partial` field has no `{ConfigDir}/_<path>.htm` or the controller lacks `pact.AdminPartialData`.
|
||||
- Partial style kit classes (README "Partial style kit"): `summer-table-wrap`, `summer-table`, `summer-table__sub`. Anchors need an href that starts with a single `/`.
|
||||
- SPA routes: the record preview is `/:vendor/:plugin/:controller/:id/preview` under the SPA base, which is `backend.uri` (`cabana.AdminPrefix`). The Administrators screen has a preview block.
|
||||
- Existing phrase keys to reuse: `backend::lang.user.menu_label` (Administrators), `user.login`, `user.first_name`, `user.last_name`, `user.email`, `user.superuser`, `user.state_deleted`, `user.state_deactivated`. pl plurals use one/few/many/other.
|
||||
- Test harness (package cabana_test, Docker): `newScreensEnv`, `seedRole(roleSeed)`, `seedAdmin(adminSeed{Name, Superuser, Inactive, Deleted, Role, RolePermissions})`, `login`, `expect`, `expectRefusal`, `rolesPath`, `usersPath`; partial bodies are checked with `fmt.Sprint(body["data"])` (see `hintText` in administrators_lifecycle_postgres_test.go). Internal (package cabana, -short): `compileWithScreens(t, plugins)` in backend_screens_test.go, `nodesJSON` in phase101_render_test.go, `compiledPartial.render(ctx, tr, data)`, `refusedViewModel(cc, vm)`.
|
||||
|
||||
Concurrency notes (CLAUDE.md "Commits and concurrency"): at planning time `git status` showed no uncommitted edits under `modules/cabana`, `modules/phrasebook` or `docs/`. Sibling batch item 261028-rqf also edits `modules/cabana/README.md` and `docs/backend/users-and-permissions.md`. The batch coordinator sequences overlapping files, so Read both files fresh right before editing and never revert rqf's lines. 261028-rqb may add SPA strings to the phrasebook lang files; append only inside the `user.role` block. Run `git status --short modules/cabana modules/phrasebook docs` before each task and stage by explicit path, never `git add -A`. Commit messages carry no co-author trailer (user's global CLAUDE.md overrides the attribution reminder).
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: End-to-end holders list on the Roles update form (partial + phrases + docs + smoke)</name>
|
||||
<files>modules/cabana/backend_roles_admin.go, modules/cabana/backendscreens/controllers/roles/_users.htm, modules/cabana/backendscreens/models/userrole/fields.yaml, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, modules/cabana/README.md, docs/backend/users-and-permissions.md, modules/cabana/backend_screens_smoke_test.go</files>
|
||||
<action>
|
||||
Wire one path, from the fields.yaml partial field through the partial route, `rolesAdmin.PartialData`, the scoped holders query and the template, to the rendered nodes. The design is the read-only holders list from the todo; it honours 19.6.1 D-03 (superusers hidden from non-superusers) and stays consistent with D-17 (the delete refusal counts deleted holders).
|
||||
|
||||
1. fields.yaml (`backendscreens/models/userrole/fields.yaml`): after `permissions`, add a field named `users` with `label: backend::lang.user.role.users_field`, `comment: backend::lang.user.role.users_comment`, `type: partial`, `path: users`, `context: update`, `span: full` and `tab: backend::lang.user.menu_label`. This mirrors Winter's `_users@update` on the Administrators tab. If boot refuses `comment` on a partial field, drop the key and render that phrase as the first paragraph of the template instead; say so in the SUMMARY.
|
||||
|
||||
2. backend_roles_admin.go:
|
||||
- Add `_ pact.AdminPartialData = rolesAdmin{}` to the assertion block.
|
||||
- Add `rolesAdmin.db()` mirroring `usersAdmin.db()`: the application pool, nil without an app or pool.
|
||||
- Add an unexported const `roleHoldersLimit = 100` and the curated view model types `roleHoldersView` (fields `Rows []roleHolderRow`, `Empty bool`, `Note string`) and `roleHolderRow` (fields `Login`, `FirstName`, `LastName`, `Email`, `Link string`, `States []string`). They take no gorm tags and no TableName, so `refusedViewModel` accepts them.
|
||||
- Implement `PartialData(ctx, name, record)`:
|
||||
- A name other than `users` returns an error naming it, the way usersAdmin does.
|
||||
- A record that is not a `*BackendUserRole` with a non-zero ID returns the zero `roleHoldersView`, which renders nothing.
|
||||
- A nil `db()` returns an error.
|
||||
- Build the base query as `scopeAdministrators(ctx, db.WithContext(ctx).Model(&BackendUser{}))` filtered by `backend_users.role_id = role.ID`. That is the D-03 scope, with Unscoped so deleted holders appear (D-17). Count total on a Session copy of that query.
|
||||
- Load at most `roleHoldersLimit` rows into `[]BackendUser`. Select only id, login, first_name, last_name, email, is_activated, is_superuser and deleted_at, never password, persist or reset codes or permissions. Order by `backend_users.login` then `backend_users.id`.
|
||||
- Map each loaded user to a `roleHolderRow`. States are phrase keys in this order: `backend::lang.user.superuser` when IsSuperuser (only reachable for superuser editors), `backend::lang.user.state_deleted` when DeletedAt.Valid, `backend::lang.user.state_deactivated` when not IsActivated.
|
||||
- Link: only when `Allows(principal, []string{PermissionManageUsers})` (principal from `bouncer.User(ctx)`), set it to `AdminPrefix(c.screens.application())` + `/` + `strings.ReplaceAll(AdministratorsControllerID, ".", "/")` + `/` + id + `/preview`. An AdminPrefix error is returned as the error.
|
||||
- `Empty` is total == 0. When total > roleHoldersLimit, `Note` is `backend::lang.user.role.users_more` translated through `c.translator()` Choice with count total and params `shown` (the limit) and `count` (the total), using the FormBeforeDelete pattern. Without a translator the key itself is used.
|
||||
- Doc-comment every new identifier in the file's voice, naming D-03 and that assignment stays on the Administrators form.
|
||||
|
||||
3. `backendscreens/controllers/roles/_users.htm`:
|
||||
- Start with a `{{- /* ... */ -}}` header comment like `_hint.htm`.
|
||||
- When `.Data.Rows` is non-empty, render a `summer-table-wrap` div holding a `summer-table` table. The thead row has `th scope="col"` cells with `trans` of `backend::lang.user.login`, `backend::lang.user.first_name`, `backend::lang.user.last_name` and `backend::lang.user.email`.
|
||||
- Each tbody row's login cell holds an anchor with `href` `.Link` around `.Login` when Link is set, else plain `.Login`, followed by one `div class="summer-table__sub"` per state with `trans` of the state. The other cells hold the first name, last name and email.
|
||||
- After the table, render `.Data.Note` in a `p` when it is set.
|
||||
- When `.Data.Empty`, render a `p` with `trans "backend::lang.user.role.users_empty"`.
|
||||
- Otherwise render nothing. Use only allowlisted elements and attributes (README Partials).
|
||||
|
||||
4. Phrases, inside the `user.role` block after `code_taken`, in both en and pl:
|
||||
- en: `users_field` "Administrators with this role"; `users_comment` "Read-only. Roles are assigned on the Administrators screen."; `users_empty` "No administrator has this role."; `users_more` with one/other forms, both reading "Showing the first :shown of :count administrators. Filter the Administrators list by this role to see all of them." (one says "administrator").
|
||||
- pl: `users_field` "Administratorzy z tą rolą"; `users_comment` "Tylko do odczytu. Role przydziela się na ekranie Administratorzy."; `users_empty` "Żaden administrator nie ma tej roli."; `users_more` with one/few/many/other, "Pokazano pierwszych :shown z :count administratorów. Aby zobaczyć wszystkich, przefiltruj listę Administratorzy według tej roli." (one/other use "administratora").
|
||||
|
||||
5. Docs, in the same commit:
|
||||
- modules/cabana/README.md: extend the "Roles screen" Features bullet with one sentence. The update form's Administrators tab lists the role's holders read-only (a form partial). Superusers are listed only for superuser editors, deleted and deactivated holders are marked, the first 100 are shown with a count note, logins link to the Administrators preview for editors holding `cabana.PermissionManageUsers`, and roles are assigned only on the Administrators form.
|
||||
- docs/backend/users-and-permissions.md, section "The Roles screen": add a bullet with the same facts in user-facing words.
|
||||
- Name only exported identifiers in backticks (the docs checker resolves them). Read both files fresh first because rqf may have edited them.
|
||||
|
||||
6. Tracer smoke (backend_screens_smoke_test.go `TestRolesSmoke`): right after the holder is assigned through the Administrators form, GET `rolesPath + "/partials/users?id=<roleID>"` with rootToken, expect 200, and assert that `fmt.Sprint(body["data"])` contains `holder.Login` and `/summercms/cabana/users/<holder.ID>/preview`.
|
||||
|
||||
Commit code, template, phrases, README, docs and smoke together as one `feat(cabana): ... (quick-261028-rqg)` commit.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./modules/cabana/... ./modules/phrasebook/... && go test -short ./modules/cabana ./modules/phrasebook && go test ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check && go test ./modules/cabana -run 'TestRolesSmoke$' -count=1</automated>
|
||||
</verify>
|
||||
<done>Boot compiles the Roles form with the `users` partial (the -short screens tests pass). TestRolesSmoke (Docker; use `sudo -u golem` if the socket is denied) proves that the assigned holder's login and preview link render through the partial route. The phrasebook tests pass with en/pl keys in parity, the docs checker is green, and one feat commit contains exactly the Task 1 files.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Unit and Postgres tests for the holders list (D-03, links, states, cap)</name>
|
||||
<files>modules/cabana/backend_screens_test.go, modules/cabana/roles_postgres_test.go, modules/cabana/README.md</files>
|
||||
<behavior>
|
||||
- Internal (-short) TestRolesHoldersPartial: the compiled Roles form has a field `users` of type partial, Path `users`, context update and tab `backend::lang.user.menu_label`. `cc.partials["users"]` is compiled and `cc.formPartials["users"]` is true. `refusedViewModel(cc, roleHoldersView{...})` is empty.
|
||||
- Internal render: a row with Link renders an `a` with that href; a row without Link renders no `a`; a Login of `<script>x</script>` comes back as escaped text, not an element; each state renders one `summer-table__sub` line; Empty renders the users_empty key text; Note is rendered; the zero view renders zero nodes.
|
||||
- Postgres TestRolesHolders, superuser editor: the role holds active, deactivated, deleted and superuser administrators, and an admin of another role is absent. All four holders are listed with Superuser, Deactivated and Deleted lines, and each login links to `/backend/summercms/cabana/users/<id>/preview`.
|
||||
- Postgres, manage_roles-only non-superuser: the superuser holder's login is absent (D-03) and no `/summercms/cabana/users/` href appears. A manage_roles + manage_users non-superuser gets the links, still without the superuser.
|
||||
- Postgres: a role with no holders shows "No administrator has this role."; an admin without manage_roles gets 403 on the partial route; a role with 101 holders shows 100 logins and "Showing the first 100 of 101 administrators".
|
||||
</behavior>
|
||||
<action>
|
||||
1. backend_screens_test.go (package cabana, Docker-free): add `TestRolesHoldersPartial`.
|
||||
- Compile through `compileWithScreens(t, nil)` and get the Roles controller with `reg.Get(RolesControllerID)`.
|
||||
- Assert the field facts from the behavior block, then build `roleHoldersView` values and render them through `cc.partials["users"].render(context.Background(), nil, view)`.
|
||||
- Assert with `nodesJSON` (string contains and not-contains on `"tag":"a"`, the href, the escaped script text and `summer-table__sub`).
|
||||
|
||||
2. roles_postgres_test.go (package cabana_test, Docker): add `TestRolesHolders` on `newScreensEnv(t)`.
|
||||
- Seed the role with `seedRole`. Seed the holders with `seedAdmin(adminSeed{Role: role.ID, ...})` using Inactive, Deleted and Superuser, plus one admin of another role.
|
||||
- Seed the editors: a superuser with its own role, `RolePermissions` `{cabana.PermissionManageRoles: 1}`, the same plus `cabana.PermissionManageUsers`, and a Developer-role admin for the 403. Sign them in with `env.login`.
|
||||
- Fetch `fmt.Sprintf("%s/partials/users?id=%d", rolesPath, role.ID)` with `env.expect` (or `env.expectRefusal` for the 403) and check `fmt.Sprint(body["data"])`.
|
||||
- For the cap, seed 101 holders on a fresh role in a loop of `seedAdmin` (bcrypt cost 4 keeps it fast). Count login occurrences by the unique name prefix and assert 100, plus the note text.
|
||||
- Write the D-03 assertion's failure message so it names D-03.
|
||||
|
||||
3. README Testing section: one sentence naming `TestRolesHoldersPartial` (Docker-free, in backend_screens_test.go) and `TestRolesHolders` (Postgres, in roles_postgres_test.go) and what they cover. Read the file fresh first because rqf may have added its own Testing line.
|
||||
|
||||
Commit as `test(cabana): ... (quick-261028-rqg)`. If a test exposes a bug in Task 1's code, fix it in this commit and mention the fix in the SUMMARY.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go test -short ./modules/cabana -run 'TestRolesHoldersPartial|TestBackendScreens' -count=1 && go test ./modules/cabana -run 'TestRolesHolders|TestRolesLifecycle|TestRolesSmoke' -count=1 && go test ./cmd/summer -run TestDocsTree && go vet ./... && go test -short ./...</automated>
|
||||
</verify>
|
||||
<done>Both new tests pass. The Docker roles tests pass (`sudo -u golem` if the socket is denied). `go vet ./...` and `go test -short ./...` are green. One test commit contains exactly the Task 2 files.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| admin SPA → GET .../roles/partials/users?id= | Authenticated administrator with backend.manage_roles; `id` is untrusted request input |
|
||||
| backend_users rows → html/template partial | Holder login, names and email are administrator-editable strings rendered into the admin page |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-rqg-01 | Information disclosure | rolesAdmin.PartialData holders query and count | high | mitigate | Both go through `scopeAdministrators` (Unscoped plus `is_superuser = false` unless `actsAsSuperuser`), so a non-superuser never sees a superuser holder as a row or in the total (D-03). TestRolesHolders asserts it. |
|
||||
| T-rqg-02 | Information disclosure | holder columns shown to manage_roles editors | low | accept | As in WinterCMS UserRoles, editors who can change a role's grants see who holds it (login, names, email). Secret columns are never selected (explicit column list, no password, persist or reset codes, no permissions). |
|
||||
| T-rqg-03 | Tampering (stored XSS) | _users.htm rendering holder strings | medium | mitigate | Curated plain-string view model (no template.HTML types, so `refusedViewModel` passes), html/template autoescaping and the server and SPA allowlists. The internal test renders a script-tag login and asserts escaped text. |
|
||||
| T-rqg-04 | Elevation of privilege | holder login links | low | mitigate | Links are emitted only when `cabana.Allows(principal, PermissionManageUsers)`, and the Administrators routes enforce their own permission and D-03 scope regardless. |
|
||||
| T-rqg-05 | Denial of service | partial render caps (64 KiB / 2000 nodes) | low | mitigate | The query is limited to roleHoldersLimit = 100 rows, well under both caps, with a count note for the rest. TestRolesHolders covers 101 holders. |
|
||||
| T-rqg-06 | Spoofing / IDOR | `?id=` of an arbitrary role | low | accept | The partial route loads the role through `readScopedRecord` behind backend.manage_roles. Roles have no per-record scope, and every role is already openable on this screen. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `go vet ./...` and `go test -short ./...` green; Docker-backed `go test ./modules/cabana -run 'TestRoles'` green.
|
||||
- `go test ./cmd/summer -run TestDocsTree` and `go run ./cmd/summer docs:build --check` green.
|
||||
- `git diff --stat HEAD~2 -- admin modules/boardwalk` is empty (no SPA or dist change).
|
||||
- `git log -2 --format=%B` shows no co-author trailer.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- The Roles update form shows an Administrators tab with the role's holders, read-only, with superusers hidden from non-superuser editors (D-03). Deleted and deactivated holders are marked, logins link to the Administrators preview only for manage_users holders, there is an empty state, and a note appears past 100 holders.
|
||||
- No exported API change, no admin/ or dist change; README and docs updated in the code commit.
|
||||
- Two commits (feat, test), staged by explicit path.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/261028-rqg-show-the-administrators-holding-a-role-on-the-roles-screen-t/261028-rqg-SUMMARY.md` when done.
|
||||
</output>
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
phase: quick-261028-rqg
|
||||
plan: 01
|
||||
subsystem: cabana (backend Roles screen)
|
||||
tags: [admin, roles, partial, D-03, D-17]
|
||||
status: complete
|
||||
requires: [scopeAdministrators, partial pipeline (partial_render.go)]
|
||||
provides: [Roles update form Administrators tab with read-only holders list]
|
||||
affects: [modules/cabana, modules/phrasebook, docs/backend]
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: [schema-driven type partial field + pact.AdminPartialData curated view model]
|
||||
key-files:
|
||||
created:
|
||||
- modules/cabana/backendscreens/controllers/roles/_users.htm
|
||||
modified:
|
||||
- modules/cabana/backend_roles_admin.go
|
||||
- modules/cabana/backendscreens/models/userrole/fields.yaml
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- modules/cabana/README.md
|
||||
- docs/backend/users-and-permissions.md
|
||||
- modules/cabana/backend_screens_smoke_test.go
|
||||
- modules/cabana/backend_screens_test.go
|
||||
- modules/cabana/roles_postgres_test.go
|
||||
decisions:
|
||||
- "Holders list is a type: partial form field (users) served by rolesAdmin.PartialData through scopeAdministrators, not the relation-manager widget (no scope hook there, would leak superusers)"
|
||||
metrics:
|
||||
completed: 2026-10-28
|
||||
actuals:
|
||||
tokens: 8300
|
||||
tasks: 2
|
||||
commits: 2
|
||||
plan_head_before: c8a22e3e846625b37e440f7e8305b1a3f67ece56
|
||||
plan_head_after: 653d95a5b9f6032eee2325e32e536feaadbe8127
|
||||
---
|
||||
|
||||
# Quick 261028-rqg: Roles screen holders list Summary
|
||||
|
||||
The Roles update form has a new Administrators tab. It shows a read-only table (login, first name, last name, email) of the administrators who hold the role. The table comes from a `users` form partial that `rolesAdmin.PartialData` serves through `scopeAdministrators`, so superusers are hidden from non-superuser editors (D-03). Deleted and deactivated holders are listed and marked (D-17), at most 100 rows are shown with a note giving the total, and logins link to the Administrators preview only for editors who hold manage_users.
|
||||
|
||||
## Commits
|
||||
|
||||
| Task | Commit | Message |
|
||||
|------|--------|---------|
|
||||
| 1 | 8cb56a4 | feat(cabana): list the administrators holding a role on the Roles form (quick-261028-rqg) |
|
||||
| 2 | 653d95a | test(cabana): cover the Roles form's holders list (quick-261028-rqg) |
|
||||
|
||||
## What was built
|
||||
|
||||
- `rolesAdmin` implements `pact.AdminPartialData` and gains `db()`, `roleHoldersLimit = 100`, `roleHoldersView` and `roleHolderRow`. A count and a query run on separate sessions of the same scoped base. The query selects an explicit column list (no password, codes or permissions) ordered by login, then id. States are Superuser, Deleted and Deactivated. The note is translated with `Choice` and the params `shown`/`count`.
|
||||
- `_users.htm` renders a summer-table, the empty line or nothing.
|
||||
- The `fields.yaml` `users` field uses `type: partial`, `context: update` and `tab: backend::lang.user.menu_label`. Boot accepted `comment` on the partial field, so the key stays.
|
||||
- Phrases `users_field`, `users_comment`, `users_empty` and `users_more` (plural) were added in en and pl, inside the `user.role` block only.
|
||||
- README Features and Testing are updated, and the docs page "The Roles screen" has a new bullet.
|
||||
- TestRolesSmoke asserts the holder login and the preview link through the partial route.
|
||||
- New tests: TestRolesHoldersPartial (internal, -short) and TestRolesHolders (Postgres).
|
||||
|
||||
## Verification
|
||||
|
||||
- `go vet ./...` and `go test -short ./...` pass at both commits.
|
||||
- `go test ./modules/cabana -count=1` passes with the Docker tests (85.9s). TestRolesHolders, TestRolesSmoke, TestRolesLifecycle, TestRolesPermissionCeiling and TestRolesCode* pass.
|
||||
- `go test ./cmd/summer -run TestDocsTree` passes, and `summer docs:build --check` reports no problems.
|
||||
- `git diff --stat HEAD~2 -- admin modules/boardwalk` is empty. No SPA or dist change, no new route, no exported identifier.
|
||||
- Neither commit has a co-author trailer.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
- The 403 editor in TestRolesHolders is an admin with an empty role of its own instead of a Developer-role admin. It proves the same thing (no manage_roles) and does not depend on the developer role's stored grants.
|
||||
- In the 101-holder cap assertion, each login appears twice in the rendered data, once as the login and once in the email. The test therefore expects 200 occurrences of the unique prefix, which means 100 rows.
|
||||
|
||||
Otherwise the plan was executed as written.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: modules/cabana/backendscreens/controllers/roles/_users.htm
|
||||
- FOUND: 8cb56a4, 653d95a
|
||||
@@ -52,7 +52,7 @@ This extends `.planning/research/go-ecosystem.md` (2026-09-16 GitHub-API-verifie
|
||||
| `charm.land/bubbletea/v2` | v2.1.0 | Runtime for the live spinner, process and progress views | Decided in .planning/notes/2026-10-21-console-charm-stack.md (Phase 19.8 D-02); imported only by modules/bonfire and modules/diary |
|
||||
| `charm.land/bubbles/v2` | v2.2.1 | Spinner and progress components | Decided in .planning/notes/2026-10-21-console-charm-stack.md (Phase 19.8 D-02); imported only by modules/bonfire and modules/diary |
|
||||
| `charm.land/huh/v2` | v2.0.3 | Interactive prompts on a terminal | Decided in .planning/notes/2026-10-21-console-charm-stack.md (Phase 19.8 D-02); imported only by modules/bonfire and modules/diary |
|
||||
| `charm.land/fang/v2` | v2.0.1 | Cobra error rendering hook (used narrowly) | Decided in .planning/notes/2026-10-21-console-charm-stack.md (Phase 19.8 D-02); imported only by modules/bonfire and modules/diary |
|
||||
| ~~`charm.land/fang/v2`~~ | ~~v2.0.1~~ | **Removed** (quick 261028-rqd, IN-06): was the cobra error rendering hook | Decided in .planning/notes/2026-10-21-console-charm-stack.md (Phase 19.8 D-02); was imported only by modules/bonfire. `bonfire.Execute` now runs the root itself and enables Windows VT processing through `lipgloss.EnableLegacyWindowsANSI` (see the note's 2026-10-28 addendum) |
|
||||
| `github.com/charmbracelet/colorprofile` | v0.4.3 | Color profile detection and downsampling writer (`--ansi`, `--no-ansi`, `NO_COLOR`) | Decided in .planning/notes/2026-10-21-console-charm-stack.md (Phase 19.8 D-02); imported only by modules/bonfire and modules/diary |
|
||||
| `github.com/modelcontextprotocol/go-sdk` (packages `mcp`, `auth`, `oauthex`, `jsonrpc`) | v1.8.0 | MCP server, sessions, streamable HTTP transport, bearer middleware, RFC 9728 metadata, test client for the `pier` module | Decided in .planning/notes/2026-10-22-mcp-go-sdk.md (Phase 19.7 D-24, D-33); imported only by modules/pier and the tagged modules/surf/pier_on.go, linked only with the `summercms_mcp` build tag |
|
||||
| `github.com/google/jsonschema-go` (package `jsonschema`) | v0.4.3 | Reflects MCP tool input and output schemas from Go structs and validates call arguments | Decided in .planning/notes/2026-10-22-mcp-go-sdk.md (Phase 19.7 D-24); the schema library the SDK itself uses; imported only by modules/pier |
|
||||
|
||||
@@ -35,3 +35,5 @@ TBD. Ideas:
|
||||
The site half shipped in vue-demo-app (demo.summercms.io, plan 03): `app/plugins/manifest-update.client.ts` listens for Nuxt's `app:manifest:update` (the app manifest, checked every 5 minutes) and re-checks `builds/latest.json` on `visibilitychange`; `UpdateBanner` offers the reload and never reloads by itself. `isNewerBuild` (`app/utils/buildUpdate.ts`) is unit-tested in plan 05 (`buildUpdate.test.ts`). Other Nuxt site apps can copy the pattern.
|
||||
|
||||
Still open, so the todo stays pending: the admin half. boardwalk exposes a build version (a header on admin API replies or a `/version` endpoint, from the dist hash), the admin SPA compares it and shows the same non-blocking banner, plus the chunk-load reload-once guard. It needs a framework tag and a changelog post.
|
||||
|
||||
Closed 2026-10-28 by quick 261028-rqb (291b18f..c8a22e3): admin SPA half — boardwalk build id (dist + binary build info), X-Summer-Build header, build.json, reload banner, chunk-load reload guard.
|
||||
@@ -30,3 +30,7 @@ WinterCMS's `File::getDiskName` behaves the same way (inherited, not introduced)
|
||||
|
||||
- A PNG uploaded as `x.html` is stored with a `.png` disk name and its URL ends in `.png`.
|
||||
- A regression test covers that upload.
|
||||
|
||||
Closed 2026-10-28 by quick 261028-r16 (ebdfd78, 36092f5).
|
||||
|
||||
Framework side already done in 2e22e9f (phase 19.3); the fonoteka.go call sites moved to 2026-10-28-fonoteka-uploads-use-disk-extension.md, the S3 Content-Type risk to 2026-10-28-attach-store-public-content-type.md.
|
||||
@@ -39,3 +39,5 @@ TBD. Options, possibly combined:
|
||||
- Check whether diary installs itself as `slog.Default` early enough. If it did, these lines would at least be formatted and levelled through diary.
|
||||
- Let bonfire handle `--help`, `help` and `list` before plugin `Boot`, or give non-long-running commands a default console level that hides Info boot records unless `-v` is set.
|
||||
- Sweep the other shared plugins for `slog.Default()` calls in `Register`/`Boot`.
|
||||
|
||||
Closed 2026-10-28 (framework side) by quick 261028-rqc (6b9becd, 6c606c6): help-only runs boot with the console at warn unless -v. Plugin side moved to 2026-10-28-plugins-log-boot-details-at-debug.md.
|
||||
@@ -142,3 +142,5 @@ The 19.8 code review (`.planning/phases/19.8-console-commands-overhaul/19.8-REVI
|
||||
**Problem:** Plan 19.8-05 specified exit code 1 for an app binary that dies by a signal, and quick task 261022-4z8 kept it while masking the argv in summer's error block (WR-10). The re-review points out that Ctrl+C is the normal way to stop `summer serve` or `summer queue:work`: since the WR-01 relay re-raises SIGINT, a child that dies by the signal produces an ERROR block and exit 1, where shells and CI expect 130 (128+SIGINT).
|
||||
|
||||
**Suggested fix:** Map signal deaths to 128+n: treat SIGINT and SIGTERM as already reported (`bonfire.Reported(bonfire.WithExitCode(err, 128+n))`, no block), and give other signals exit 128+n with summer's masked block (`argmask.Join(argv)`). `docs/console/introduction.md` documents exit code 1 for a signal death and must change with it, as must `TestDelegateExitCodePassthrough`'s signal subtests.
|
||||
|
||||
Closed 2026-10-28 by quick 261028-rqd (8476a65..202d38b): IN-01..IN-13 and the delegate 128+n exit code; dispositions in .planning/phases/19.8-console-commands-overhaul/19.8-REVIEW-DISPOSITION.md. Consumer names in older gates moved to 2026-10-28-earlier-phase-gates-name-consuming-apps.md.
|
||||
@@ -53,3 +53,5 @@ Released 2026-10-22: sm-user-plugin v0.1.8 (930b158, with SummerCMS v0.4.3); poi
|
||||
pushed in sm-demo-app (e587980, vue-demo-app 8f8e70d), sm-bm-app, sm-grzybyfunkcjonalne-app and
|
||||
sm-summercmsio-app; fonoteka.go not bumped (34 unpushed commits from another session). Remaining:
|
||||
deploy, then the hand check above; close the todo and the debug session after it.
|
||||
|
||||
Closed 2026-10-28: manual post-deploy check (cookie-only logout, old cookie answers 401) done by the user.
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
title: attach.Store saves the sniffed Content-Type, so a public S3 bucket renders HTML stored as .txt
|
||||
date: 2026-10-28
|
||||
created: 2026-10-28T18:47:20.000Z
|
||||
area: lagoon/attach
|
||||
severity: minor
|
||||
priority: normal
|
||||
g15office: q1xe3vzb
|
||||
files:
|
||||
- modules/lagoon/attach
|
||||
- docs/services/storage.md
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Found by quick 261028-r16. `attach.Store` writes the sniffed content type as the blob's Content-Type. A public S3 bucket or CDN serves that stored type rather than one picked from the extension, so an HTML body uploaded as `note.txt` is served as `text/html`. The risk is documented in `docs/services/storage.md#deploy-note` (separate origin, nosniff, attachment disposition), not fixed.
|
||||
|
||||
## Solution
|
||||
|
||||
- For types that are not meant to render inline, store a non-rendering Content-Type or `Content-Disposition: attachment`.
|
||||
- This changes what Store writes (additive config or a default change), so decide the affected types first; no breaking change for existing consumers. Update the lagoon README and the storage page.
|
||||
|
||||
Closed 2026-10-28 by quick 261028-rqe (ba8432c, 4957e5c): non-inline uploads stored with Content-Disposition attachment; attach.IsInlineContentType shared with the surf route. StaticHandler follow-up: 2026-10-28-attach-static-handler-disposition.md.
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
title: make:plugin drops the vendor and scaffolds plugins/<name> instead of plugins/<vendor>/<name>
|
||||
date: 2026-10-28
|
||||
created: 2026-10-28T17:24:01.309Z
|
||||
area: summercms.go internal/build
|
||||
severity: major
|
||||
g15office: keu1ecio
|
||||
files:
|
||||
- internal/build/scaffold.go:70-90
|
||||
- internal/build/scaffold.go:195
|
||||
- internal/build/artifact.go:454
|
||||
- internal/build/leaf.go:26
|
||||
- internal/build/new.go:456-462
|
||||
- cmd/summer/main.go:95-130
|
||||
- docs/console/scaffolding.md:82
|
||||
- docs/architecture/go-modules-and-workspaces.md:93
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
`summer make:plugin pixelpixel.servers`, run in another application on 2026-10-28, created `plugins/servers` with module `<app>/plugins/servers`. The expected location was `plugins/pixelpixel/servers`. `makePlugin` in `internal/build/scaffold.go` joins `appDir/plugins/pluginName(id)`, which keeps only the part after the dot. As a result, two vendors' plugins with the same name (`acme.blog`, `pixelpixel.blog`) cannot live in one application.
|
||||
|
||||
The help text (`cmd/summer/main.go`, "Create plugins/blog with the ID acme.blog") and the docs (`docs/console/scaffolding.md`, `docs/architecture/go-modules-and-workspaces.md`) describe this layout as intended. Elsewhere, plugin paths include the vendor:
|
||||
|
||||
- Shared plugins are mounted as submodules at `plugins/<vendor>/<name>` (`.planning/notes/core-plugins-own-repos.md`).
|
||||
- WinterCMS-style YAML paths have the form `~/plugins/<vendor>/<plugin>/...` (`docs/backend/relation-manager.md`).
|
||||
|
||||
## Solution
|
||||
|
||||
- Scaffold at `plugins/<vendor>/<name>` with module path `<app>/plugins/<vendor>/<name>`. Update the existing-directory hint (`plugin:add plugins/<vendor>/<name>`) and the help examples.
|
||||
- `summer new`'s `addStarterPlugin` (`internal/build/new.go`) uses the same code; decide whether the starter moves to `plugins/golem15/<name>`.
|
||||
- The other `make:*` commands find a plugin through the manifest's module first and then fall back to `plugins/<name>` (`internal/build/artifact.go:454`, and `leaf.go:26` for the models-leaf check). Add a `plugins/<vendor>/<name>` fallback and keep the old one, so existing applications keep working (no breaking change).
|
||||
- Update the docs pages and the tests that pin `plugins/<name>`. Add an upgrading note for applications that already have `plugins/<name>`: moving one is optional, but needs `plugin:add` or a `go.mod`/`go.work` path update.
|
||||
@@ -9,3 +9,5 @@ g15office: xkujbsv4
|
||||
cabana logs bulk action runs with `admin_id` (`modules/cabana/actions.go:166`), but a toolbar action such as pgsub's `sync_all_to_stripe` only logs failures (`runAction`, `actions.go:277-315`). A successful run leaves no record of which administrator triggered it; pgsub's per-plan "plan synced" line has no admin id.
|
||||
|
||||
Found by the Phase 18.1 security audit (T-18.1-22, accepted as AR-18.1-03 in `.planning/phases/18.1-payment-gateway-subscriptions/18.1-SECURITY.md`). Options the user named: a separate audit-log plugin, or extending cabana so every action run (toolbar, bulk, record) is logged or emitted as an event with the admin id, action name and controller.
|
||||
|
||||
Closed 2026-10-28 by quick 261028-rqf (61d1043): successful toolbar and field action runs log admin_id, controller, action and kind.
|
||||
@@ -11,3 +11,5 @@ The `lagoon.OnDatabase` example in `modules/lagoon/README.md` registers its crea
|
||||
Found while writing `docs/database/transactions.md` (Phase 11.1 plan 04). The page registers the callback before GORM's commit callback and says why.
|
||||
|
||||
Suggested fix: change the README example to register before `gorm:commit_or_rollback_transaction`, and consider making `lagoon.AfterCommit` warn when it buffers work on a statement whose `lagoon:after_commit` callback has already run. The README was read-only in plan 11.1-04 (Phase 11 gap plan 11-08 owned it). While there, correct the `lagoon.RegisterJoinTable` comment and README line that say pivot reads use `Preload(field)` plus `.Order(...)` on the pivot's columns: GORM preloads many-to-many targets in a query without the join table, so ordering by a pivot column there fails with "missing FROM-clause entry"; an explicit join does work.
|
||||
|
||||
Closed 2026-10-28 by quick 261028-r16 (ebdfd78, 36092f5).
|
||||
@@ -6,3 +6,5 @@ area: cabana
|
||||
---
|
||||
|
||||
From the 19.6.1 UAT (test 6): the Roles form and preview show a role's code, name and permissions but not who holds it. Add a list of the role's administrators, using the relation widget if cabana's relation field can render a has-many list for `summercms.cabana.roles` (WinterCMS `UserRoles` shows the users as a relation list). It is read-only at minimum; assignment stays on the Administrators form. Respect D-03: superusers stay hidden from non-superuser editors in that list as well.
|
||||
|
||||
Closed 2026-10-28 by quick 261028-rqg (8cb56a4, 653d95a): read-only Administrators tab on the Roles update form, D-03 and D-17 respected.
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
title: attach.StaticHandler does not send the stored Content-Disposition
|
||||
date: 2026-10-28
|
||||
created: 2026-10-28T20:50:55.000Z
|
||||
area: lagoon/attach
|
||||
severity: minor
|
||||
priority: normal
|
||||
g15office: knuu7fil
|
||||
files:
|
||||
- modules/lagoon/attach
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Follow-up from quick 261028-rqe (ba8432c, 4957e5c). `attach.Store` saves `Content-Disposition: attachment` for non-inline types and the surf uploads route sends it, but `attach.StaticHandler` and `attach.StaticHandlerPublic` serve blobs without the stored disposition, so an app that mounts them directly serves an HTML body inline.
|
||||
|
||||
## Solution
|
||||
|
||||
- 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`).
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
created: 2026-10-28T20:00:00.000Z
|
||||
title: Earlier phase gate scripts still name consuming applications
|
||||
area: framework
|
||||
severity: minor
|
||||
g15office: 9aqs1lyo
|
||||
files:
|
||||
- scripts/check-phase2.sh
|
||||
- scripts/check-phase3.sh
|
||||
- scripts/check-phase8.sh
|
||||
- scripts/check-phase9.sh
|
||||
- scripts/check-phase10.sh
|
||||
- scripts/check-phase10.1.sh
|
||||
- scripts/check-phase10.2.sh
|
||||
- scripts/check-phase11.sh
|
||||
- scripts/check-phase11.1.sh
|
||||
- scripts/check-phase11.2.sh
|
||||
- scripts/check-phase12.sh
|
||||
- scripts/check-phase12.1.sh
|
||||
- scripts/check-phase12.2.sh
|
||||
- scripts/check-phase13.sh
|
||||
- scripts/check-phase14.sh
|
||||
- scripts/check-phase14.2.sh
|
||||
- scripts/check-phase14.2.1.sh
|
||||
- scripts/check-phase14.3.sh
|
||||
- scripts/check-phase15.sh
|
||||
- scripts/check-phase16.sh
|
||||
- scripts/check-phase17.sh
|
||||
- scripts/check-phase19.sh
|
||||
- scripts/check-phase19.3.sh
|
||||
- scripts/check-phase19.4.sh
|
||||
- scripts/check-phase19.5.sh
|
||||
- scripts/check-phase19.6.sh
|
||||
- scripts/check-phase19.6.1.sh
|
||||
- scripts/check-phase19.7.sh
|
||||
- scripts/check-phase32.sh
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Quick 261028-rqd (console review finding IN-09) moved every sibling application path and the forbidden-name list of `scripts/check-phase19.8.sh` into `PHASE198_*` environment variables, so that gate names no consuming application (CLAUDE.md: framework code never names a consuming application). A grep of the other gate scripts (`scripts/check-phase*.sh` other than 19.8) for the consuming application names and checkout paths still matches the 29 scripts listed above: default sibling paths, application labels or name lists.
|
||||
|
||||
## Solution
|
||||
|
||||
Apply the 19.8 pattern to each script that is still run: read the application paths and names from environment variables with no defaults, print `NOT RUN <stage>: set <VAR>` when one is unset, and fail instead under a `REQUIRE_SIBLINGS`-style switch. Scripts for phases whose gates are no longer run can be left as historical records; decide per script.
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
title: fonoteka.go uploads still name files after the client extension (stored XSS)
|
||||
date: 2026-10-28
|
||||
created: 2026-10-28T18:47:20.000Z
|
||||
area: fonoteka.go uploads
|
||||
severity: major
|
||||
priority: high
|
||||
g15office: cf81chjk
|
||||
files:
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collection_media_controller.go:251 (storeUpload)
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_files.go:35 (StorePublicFile)
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Split from `2026-10-11-uploads-keep-client-extension-stored-xss.md` (closed by quick 261028-r16, framework side). `attach.Store` and `attach.DiskExtension` in the framework name files from the sniffed type, but fonoteka.go's own upload paths still append `filepath.Ext(<client name>)` to the disk name. A PNG with `<script>` in a tEXt chunk uploaded as `x.html` is stored as `<hex>.html` and rendered by the web server as HTML.
|
||||
|
||||
## Solution
|
||||
|
||||
- In both call sites, sniff first, then name with `attach.DiskExtension(contentType, filepath.Ext(name))`. Keep the client name only in `file_name`.
|
||||
- Uploads without an extension then get `.bin` / `.txt` instead of none.
|
||||
- Regression test: the x.html PNG is stored and served as `.png`.
|
||||
- Optional cleanup, already safe: `sm-feedback-plugin` `screenshotExtension` and `sm-user-plugin` `avatarExt` could reuse the helper.
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
title: Shared plugins log boot details at Info instead of Debug
|
||||
date: 2026-10-28
|
||||
created: 2026-10-28T20:50:55.000Z
|
||||
area: sm-paymentgateway-plugin, sm-paymentgatewaysubscriptions-plugin, shared plugins
|
||||
severity: minor
|
||||
g15office: ykzlrpmp
|
||||
files:
|
||||
- ../sm-paymentgateway-plugin/boot.go:129
|
||||
- ../sm-paymentgatewaysubscriptions-plugin/boot.go:86
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Plugin side of `2026-10-21-quiet-plugin-boot-logs-on-console-commands.md`. The framework side (quick 261028-rqc, 6b9becd/6c606c6) boots help-only runs with the console at warn, but the boot records themselves are routine details logged at Info: `paymentgateway: operators registered` and `paymentgatewaysubscriptions: registries collected` (the latter through `slog.Default()`). They still print on every `serve`/`migrate` start and on apps pinned to an older framework.
|
||||
|
||||
## Solution
|
||||
|
||||
- Log both at Debug through `diary.For`.
|
||||
- Sweep the other shared plugins for Info logs in Register/Boot.
|
||||
- Not breaking. Plugin repos first, then the pointer bumps in the apps.
|
||||
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
||||
<meta name="summer-admin-build" content="__SUMMER_ADMIN_BUILD__" />
|
||||
<link rel="icon" href="./favicon.ico" sizes="48x48" />
|
||||
<title>SummerCMS</title>
|
||||
</head>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, watchEffect } from 'vue'
|
||||
import { RouterView, useRoute } from 'vue-router'
|
||||
import { documentTitleForRoute } from './app/pageTitle'
|
||||
import AppShell from './components/shell/AppShell.vue'
|
||||
import UpdateBanner from './components/ui/UpdateBanner.vue'
|
||||
import { remountRevision } from './state/useRemount'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -17,11 +18,13 @@ watchEffect(() => {
|
||||
<!-- Keyed by path, not full path: a new controller or record remounts its
|
||||
view, while list query changes (search, sort, page) keep it. The
|
||||
remount revision is part of the key, so a view can remount itself in
|
||||
place (Create and new). -->
|
||||
place (Create and new). The new-version banner sits outside the
|
||||
v-if, so it also shows on the login screen. -->
|
||||
<AppShell v-if="inShell">
|
||||
<RouterView v-slot="{ Component, route: current }">
|
||||
<component :is="Component" :key="`${current.path}#${remountRevision}`" />
|
||||
</RouterView>
|
||||
</AppShell>
|
||||
<RouterView v-else />
|
||||
<UpdateBanner />
|
||||
</template>
|
||||
|
||||
142
admin/src/app/buildUpdate.ts
Normal file
142
admin/src/app/buildUpdate.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
// New-version check. An admin tab opened before a deploy keeps running the
|
||||
// old bundle against the new backend, sometimes for days, because the session
|
||||
// refreshes silently; a plugin field type the old bundle does not know then
|
||||
// shows as "Unsupported field type". The server (boardwalk) writes the build
|
||||
// id into the served index and answers it at {base}/build.json. The tab
|
||||
// remembers the build it booted with (runtime.build), re-checks on focus, on
|
||||
// visibility and on an interval, and on a different build shows a
|
||||
// non-blocking banner (UpdateBanner). It never reloads by itself, so unsaved
|
||||
// edits stay where they are.
|
||||
import { readonly, ref } from 'vue'
|
||||
import { runtime } from './runtime'
|
||||
|
||||
/** Re-check interval while the tab is visible: 5 minutes. */
|
||||
export const BUILD_CHECK_INTERVAL = 5 * 60 * 1000
|
||||
/** Minimum gap between two checks: 30 seconds. */
|
||||
export const BUILD_CHECK_MIN_GAP = 30 * 1000
|
||||
|
||||
/**
|
||||
* Whether seen is a different build than booted. Ids are not ordered: any
|
||||
* difference is a different deploy, a rollback included. An empty id on
|
||||
* either side is never a new build.
|
||||
*/
|
||||
export function isNewBuild(booted: string, seen: string): boolean {
|
||||
return booted !== '' && seen !== '' && booted !== seen
|
||||
}
|
||||
|
||||
/**
|
||||
* The current build from {base}/build.json, or "" when the request fails,
|
||||
* the response is not ok or the body has no string build. It never throws.
|
||||
*/
|
||||
export async function fetchBuild(): Promise<string> {
|
||||
try {
|
||||
// Resolve fetch per call so tests can replace globalThis.fetch.
|
||||
const response = await globalThis.fetch(`${runtime.base}/build.json`, {
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
if (!response.ok) {
|
||||
return ''
|
||||
}
|
||||
const body: unknown = await response.json()
|
||||
if (typeof body !== 'object' || body === null) {
|
||||
return ''
|
||||
}
|
||||
const build = (body as { build?: unknown }).build
|
||||
return typeof build === 'string' ? build : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const available = ref(false)
|
||||
let latest = ''
|
||||
let dismissed = ''
|
||||
|
||||
/** True while a build different from the booted one waits for a reload. */
|
||||
export const newBuildAvailable = readonly(available)
|
||||
|
||||
/**
|
||||
* Records a build seen on the server. A build different from the booted one
|
||||
* shows the banner, unless it is the build the administrator dismissed.
|
||||
*/
|
||||
export function noteBuild(seen: string, booted: string = runtime.build): void {
|
||||
if (isNewBuild(booted, seen) && seen !== dismissed) {
|
||||
latest = seen
|
||||
available.value = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Hides the banner until a build different from the current one is seen. */
|
||||
export function dismissNewBuild(): void {
|
||||
dismissed = latest
|
||||
available.value = false
|
||||
}
|
||||
|
||||
/** Clears the module state; for tests. */
|
||||
export function resetBuildUpdate(): void {
|
||||
latest = ''
|
||||
dismissed = ''
|
||||
available.value = false
|
||||
}
|
||||
|
||||
export interface BuildWatchOptions {
|
||||
/** The build the tab booted with; default runtime.build. */
|
||||
booted?: string
|
||||
/** Reads the current build; default fetchBuild. */
|
||||
check?: () => Promise<string>
|
||||
/** Interval between checks while visible; default BUILD_CHECK_INTERVAL. */
|
||||
interval?: number
|
||||
/** Minimum gap between two checks; default BUILD_CHECK_MIN_GAP. */
|
||||
minGap?: number
|
||||
win?: Pick<Window, 'addEventListener' | 'removeEventListener' | 'setInterval' | 'clearInterval'>
|
||||
doc?: Pick<Document, 'addEventListener' | 'removeEventListener' | 'visibilityState'>
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the new-version check and returns its stop function. With no booted
|
||||
* build (the served index carries none, as under the dev server) it installs
|
||||
* nothing. A check runs on visibilitychange to visible, on window focus and
|
||||
* on the interval, skipped while the tab is hidden, while a check is in
|
||||
* flight or within minGap of the last check.
|
||||
*/
|
||||
export function startBuildWatch(options: BuildWatchOptions = {}): () => void {
|
||||
const booted = options.booted ?? runtime.build
|
||||
if (booted === '') {
|
||||
return () => undefined
|
||||
}
|
||||
const check = options.check ?? fetchBuild
|
||||
const interval = options.interval ?? BUILD_CHECK_INTERVAL
|
||||
const minGap = options.minGap ?? BUILD_CHECK_MIN_GAP
|
||||
const win = options.win ?? window
|
||||
const doc = options.doc ?? document
|
||||
const now = options.now ?? Date.now
|
||||
let inFlight = false
|
||||
let lastStart = Number.NEGATIVE_INFINITY
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
if (doc.visibilityState === 'hidden' || inFlight || now() - lastStart < minGap) {
|
||||
return
|
||||
}
|
||||
lastStart = now()
|
||||
inFlight = true
|
||||
try {
|
||||
noteBuild(await check(), booted)
|
||||
} finally {
|
||||
inFlight = false
|
||||
}
|
||||
}
|
||||
const trigger = (): void => {
|
||||
run().catch(() => undefined)
|
||||
}
|
||||
|
||||
doc.addEventListener('visibilitychange', trigger)
|
||||
win.addEventListener('focus', trigger)
|
||||
const timer = win.setInterval(trigger, interval)
|
||||
return () => {
|
||||
doc.removeEventListener('visibilitychange', trigger)
|
||||
win.removeEventListener('focus', trigger)
|
||||
win.clearInterval(timer)
|
||||
}
|
||||
}
|
||||
63
admin/src/app/chunkReload.ts
Normal file
63
admin/src/app/chunkReload.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// Reload once when a chunk fails to load. After a deploy, an old tab that
|
||||
// asks for a hashed chunk the new build removed fails the dynamic import, and
|
||||
// Vite's preload helper dispatches vite:preloadError on window. One reload
|
||||
// loads the new index and its chunks. A timestamp in sessionStorage stops a
|
||||
// reload loop when the reload does not help, and without usable storage the
|
||||
// guard never reloads. It does not call preventDefault: the error still
|
||||
// reaches the importer (RichEditorField switches to source mode), so when the
|
||||
// guard declines, the existing failure state shows.
|
||||
|
||||
export const CHUNK_RELOAD_KEY = 'summer-admin.chunk-reload'
|
||||
/** No second automatic reload within 60 seconds of the last one. */
|
||||
export const CHUNK_RELOAD_WINDOW = 60_000
|
||||
|
||||
/**
|
||||
* Whether the guard may reload now. It records now under CHUNK_RELOAD_KEY
|
||||
* when it says yes. Null storage, a reload within CHUNK_RELOAD_WINDOW or
|
||||
* storage that throws means no.
|
||||
*/
|
||||
export function shouldReload(storage: Storage | null, now: number): boolean {
|
||||
if (storage === null) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const last = Number.parseInt(storage.getItem(CHUNK_RELOAD_KEY) ?? '', 10)
|
||||
if (Number.isFinite(last) && now - last >= 0 && now - last < CHUNK_RELOAD_WINDOW) {
|
||||
return false
|
||||
}
|
||||
storage.setItem(CHUNK_RELOAD_KEY, String(now))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function sessionStore(): Storage | null {
|
||||
try {
|
||||
return window.sessionStorage
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export interface ChunkReloadOptions {
|
||||
target?: Pick<EventTarget, 'addEventListener' | 'removeEventListener'>
|
||||
storage?: () => Storage | null
|
||||
reload?: () => void
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
/** Installs the vite:preloadError guard and returns its uninstall function. */
|
||||
export function installChunkReloadGuard(options: ChunkReloadOptions = {}): () => void {
|
||||
const target = options.target ?? window
|
||||
const storage = options.storage ?? sessionStore
|
||||
const reload = options.reload ?? (() => window.location.reload())
|
||||
const now = options.now ?? Date.now
|
||||
const listener = (): void => {
|
||||
if (shouldReload(storage(), now())) {
|
||||
reload()
|
||||
}
|
||||
}
|
||||
target.addEventListener('vite:preloadError', listener)
|
||||
return () => target.removeEventListener('vite:preloadError', listener)
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
// Runtime configuration read once from the served index.html. The Go server
|
||||
// (boardwalk) writes the configured backend.uri into the summer-admin-base
|
||||
// meta; the SPA derives its router base and API base from it (D-02, D-03).
|
||||
// It also writes the build id into the summer-admin-build meta, the build the
|
||||
// tab booted with (buildUpdate.ts compares it with build.json).
|
||||
export const DEFAULT_BASE = '/backend'
|
||||
const TOKEN = '__SUMMER_ADMIN_BASE__'
|
||||
const BUILD_TOKEN = '__SUMMER_ADMIN_BUILD__'
|
||||
const BUILD_SHAPE = /^[0-9a-f]{6,64}$/
|
||||
|
||||
export function readBase(doc: Document = document): string {
|
||||
const content = doc.querySelector<HTMLMetaElement>('meta[name="summer-admin-base"]')?.content.trim() ?? ''
|
||||
@@ -13,6 +17,18 @@ export function readBase(doc: Document = document): string {
|
||||
return trimmed === '' ? DEFAULT_BASE : trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* The build id from the summer-admin-build meta, or "" when the meta is
|
||||
* missing, empty, the unreplaced token (the dev server) or not lowercase hex.
|
||||
*/
|
||||
export function readBuild(doc: Document = document): string {
|
||||
const content = doc.querySelector<HTMLMetaElement>('meta[name="summer-admin-build"]')?.content.trim() ?? ''
|
||||
if (content === BUILD_TOKEN || !BUILD_SHAPE.test(content)) {
|
||||
return ''
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
const base = readBase()
|
||||
|
||||
export const runtime = {
|
||||
@@ -20,4 +36,6 @@ export const runtime = {
|
||||
base,
|
||||
/** Admin API root, the mount path plus /api/v1. */
|
||||
api: `${base}/api/v1`,
|
||||
/** The build id this tab booted with; "" turns the version check off. */
|
||||
build: readBuild(),
|
||||
} as const
|
||||
|
||||
41
admin/src/components/ui/UpdateBanner.vue
Normal file
41
admin/src/components/ui/UpdateBanner.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { X } from '@lucide/vue'
|
||||
import { dismissNewBuild, newBuildAvailable } from '../../app/buildUpdate'
|
||||
import { t } from '../../app/i18n'
|
||||
import { page } from '../../state/usePreferences'
|
||||
|
||||
// New-version banner (buildUpdate.ts): shown while the server answers a build
|
||||
// different from the one this tab booted with. It never reloads by itself;
|
||||
// Reload goes through page.reload, so a dirty form's beforeunload prompt
|
||||
// still asks, and Close hides it until another build is deployed.
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Bottom right, 88px up so it clears the sticky form footer, like the toasts. -->
|
||||
<div
|
||||
v-if="newBuildAvailable"
|
||||
data-update-banner
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="fixed right-4 bottom-[88px] z-50 flex max-w-[420px] items-center gap-3 rounded-[14px] bg-[#1d2740] py-3 pr-2.5 pl-3.5 text-white shadow-toast"
|
||||
>
|
||||
<span class="font-medium">{{ t('backend::lang.version.available') }}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-update-reload
|
||||
class="shrink-0 rounded-pager bg-accent px-3 py-1.5 font-medium text-on-accent transition-opacity duration-150 ease-out hover:opacity-90"
|
||||
@click="page.reload()"
|
||||
>
|
||||
{{ t('backend::lang.version.reload') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-update-close
|
||||
:aria-label="t('backend::lang.form.close')"
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-pager text-[#c3cbda] transition-colors duration-150 ease-out hover:bg-side-hover hover:text-white"
|
||||
@click="dismissNewBuild()"
|
||||
>
|
||||
<X :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,6 +2,8 @@ import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import { createAdminRouter } from './app/router'
|
||||
import { onUnauthorized } from './api/client'
|
||||
import { startBuildWatch } from './app/buildUpdate'
|
||||
import { installChunkReloadGuard } from './app/chunkReload'
|
||||
import { loadFieldTypes } from './app/fieldTypes'
|
||||
import { loadStrings } from './app/i18n'
|
||||
import { expireSession, me } from './state/useAuth'
|
||||
@@ -11,6 +13,8 @@ import { loadSettings } from './state/useSettings'
|
||||
import './styles/main.css'
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
// A chunk that fails to load after a deploy reloads the page once.
|
||||
installChunkReloadGuard()
|
||||
// The last theme choice (system unless the admin picked light or dark) is
|
||||
// applied before the first await, so nothing renders in the wrong theme;
|
||||
// me() then applies the admin's stored preference.
|
||||
@@ -37,6 +41,9 @@ async function boot(): Promise<void> {
|
||||
app.use(router)
|
||||
await router.isReady()
|
||||
app.mount('#app')
|
||||
// The new-version check; it stays off when the served index carries no
|
||||
// build (the dev server).
|
||||
startBuildWatch()
|
||||
}
|
||||
|
||||
void boot()
|
||||
|
||||
236
admin/tests/app/buildUpdate.test.ts
Normal file
236
admin/tests/app/buildUpdate.test.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
BUILD_CHECK_INTERVAL,
|
||||
BUILD_CHECK_MIN_GAP,
|
||||
dismissNewBuild,
|
||||
fetchBuild,
|
||||
isNewBuild,
|
||||
newBuildAvailable,
|
||||
noteBuild,
|
||||
resetBuildUpdate,
|
||||
startBuildWatch,
|
||||
} from '../../src/app/buildUpdate'
|
||||
|
||||
const BOOTED = 'aaaaaaaaaaaa'
|
||||
const NEXT = 'bbbbbbbbbbbb'
|
||||
const THIRD = 'cccccccccccc'
|
||||
|
||||
/** A document stand-in: an event target with a settable visibilityState. */
|
||||
function docStub(visibilityState: DocumentVisibilityState = 'visible') {
|
||||
const target = new EventTarget()
|
||||
return Object.assign(target, { visibilityState })
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(typeof body === 'string' ? body : JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetBuildUpdate()
|
||||
})
|
||||
|
||||
describe('isNewBuild', () => {
|
||||
it.each([
|
||||
['', NEXT, false],
|
||||
[BOOTED, '', false],
|
||||
[BOOTED, BOOTED, false],
|
||||
[BOOTED, NEXT, true],
|
||||
])('(%j, %j) is %s', (booted, seen, want) => {
|
||||
expect(isNewBuild(booted, seen)).toBe(want)
|
||||
})
|
||||
|
||||
it('uses a 5 minute interval and a 30 second gap', () => {
|
||||
expect(BUILD_CHECK_INTERVAL).toBe(300_000)
|
||||
expect(BUILD_CHECK_MIN_GAP).toBe(30_000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchBuild', () => {
|
||||
const original = globalThis.fetch
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = original
|
||||
})
|
||||
|
||||
it('asks build.json under the admin base without the cache', async () => {
|
||||
const fetch = vi.fn().mockResolvedValue(jsonResponse({ build: NEXT }))
|
||||
globalThis.fetch = fetch
|
||||
expect(await fetchBuild()).toBe(NEXT)
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
const [url, init] = fetch.mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe('/admin-test/build.json')
|
||||
expect(init.cache).toBe('no-store')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a 500', () => Promise.resolve(jsonResponse({ build: NEXT }, 500))],
|
||||
['invalid JSON', () => Promise.resolve(jsonResponse('not json'))],
|
||||
['a non-string build', () => Promise.resolve(jsonResponse({ build: 12 }))],
|
||||
['a JSON null body', () => Promise.resolve(jsonResponse('null'))],
|
||||
['a rejected fetch', () => Promise.reject(new TypeError('offline'))],
|
||||
])('is empty for %s', async (_label, answer) => {
|
||||
globalThis.fetch = vi.fn(answer) as typeof fetch
|
||||
expect(await fetchBuild()).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('noteBuild and dismissNewBuild', () => {
|
||||
it('shows a different build and ignores the booted one', () => {
|
||||
noteBuild(BOOTED, BOOTED)
|
||||
expect(newBuildAvailable.value).toBe(false)
|
||||
noteBuild('', BOOTED)
|
||||
expect(newBuildAvailable.value).toBe(false)
|
||||
noteBuild(NEXT, BOOTED)
|
||||
expect(newBuildAvailable.value).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a dismissed build hidden until a third build appears', () => {
|
||||
noteBuild(NEXT, BOOTED)
|
||||
dismissNewBuild()
|
||||
expect(newBuildAvailable.value).toBe(false)
|
||||
noteBuild(NEXT, BOOTED)
|
||||
expect(newBuildAvailable.value).toBe(false)
|
||||
noteBuild(THIRD, BOOTED)
|
||||
expect(newBuildAvailable.value).toBe(true)
|
||||
})
|
||||
|
||||
it('takes the booted build from the runtime by default, which is empty in tests', () => {
|
||||
noteBuild(NEXT)
|
||||
expect(newBuildAvailable.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('startBuildWatch', () => {
|
||||
let clock = 0
|
||||
const now = () => clock
|
||||
|
||||
beforeEach(() => {
|
||||
clock = 1_000_000
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function watch(check: () => Promise<string>, doc = docStub(), win: EventTarget & Partial<Window> = window) {
|
||||
const stop = startBuildWatch({
|
||||
booted: BOOTED,
|
||||
check,
|
||||
doc: doc as unknown as Document,
|
||||
win: win as unknown as Window,
|
||||
now,
|
||||
interval: 1000,
|
||||
minGap: 100,
|
||||
})
|
||||
return { stop, doc }
|
||||
}
|
||||
|
||||
it('installs nothing without a booted build', async () => {
|
||||
const check = vi.fn().mockResolvedValue(NEXT)
|
||||
const doc = docStub()
|
||||
const add = vi.spyOn(doc, 'addEventListener')
|
||||
const stop = startBuildWatch({ booted: '', check, doc: doc as unknown as Document, now })
|
||||
doc.dispatchEvent(new Event('visibilitychange'))
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
await Promise.resolve()
|
||||
expect(add).not.toHaveBeenCalled()
|
||||
expect(check).not.toHaveBeenCalled()
|
||||
stop()
|
||||
})
|
||||
|
||||
it('checks when the tab becomes visible, never while hidden', async () => {
|
||||
const check = vi.fn().mockResolvedValue(BOOTED)
|
||||
const { stop, doc } = watch(check, docStub('hidden'))
|
||||
doc.dispatchEvent(new Event('visibilitychange'))
|
||||
await Promise.resolve()
|
||||
expect(check).not.toHaveBeenCalled()
|
||||
doc.visibilityState = 'visible'
|
||||
doc.dispatchEvent(new Event('visibilitychange'))
|
||||
await Promise.resolve()
|
||||
expect(check).toHaveBeenCalledTimes(1)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('checks on window focus', async () => {
|
||||
const check = vi.fn().mockResolvedValue(BOOTED)
|
||||
const { stop } = watch(check)
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
await Promise.resolve()
|
||||
expect(check).toHaveBeenCalledTimes(1)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('checks on the interval', async () => {
|
||||
vi.useFakeTimers()
|
||||
const check = vi.fn().mockResolvedValue(BOOTED)
|
||||
const { stop } = watch(check)
|
||||
expect(check).not.toHaveBeenCalled()
|
||||
clock += 1000
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(check).toHaveBeenCalledTimes(1)
|
||||
clock += 1000
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(check).toHaveBeenCalledTimes(2)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('runs one check for a focus and a visibility change within the gap', async () => {
|
||||
const check = vi.fn().mockResolvedValue(BOOTED)
|
||||
const { stop, doc } = watch(check)
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
await Promise.resolve()
|
||||
clock += 50
|
||||
doc.dispatchEvent(new Event('visibilitychange'))
|
||||
await Promise.resolve()
|
||||
expect(check).toHaveBeenCalledTimes(1)
|
||||
clock += 100
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
await Promise.resolve()
|
||||
expect(check).toHaveBeenCalledTimes(2)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('runs one check at a time', async () => {
|
||||
let resolve: (build: string) => void = () => undefined
|
||||
const check = vi.fn(() => new Promise<string>((done) => (resolve = done)))
|
||||
const { stop } = watch(check)
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
clock += 500
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
expect(check).toHaveBeenCalledTimes(1)
|
||||
resolve(BOOTED)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
expect(check).toHaveBeenCalledTimes(2)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('shows the banner for a different build and not for the same one', async () => {
|
||||
const check = vi.fn().mockResolvedValueOnce(BOOTED).mockResolvedValueOnce(NEXT)
|
||||
const { stop } = watch(check)
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
await vi.waitFor(() => expect(check).toHaveBeenCalledTimes(1))
|
||||
await Promise.resolve()
|
||||
expect(newBuildAvailable.value).toBe(false)
|
||||
clock += 100
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
await vi.waitFor(() => expect(newBuildAvailable.value).toBe(true))
|
||||
stop()
|
||||
})
|
||||
|
||||
it('stops listening and clears the interval', async () => {
|
||||
vi.useFakeTimers()
|
||||
const check = vi.fn().mockResolvedValue(BOOTED)
|
||||
const { stop, doc } = watch(check)
|
||||
stop()
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
doc.dispatchEvent(new Event('visibilitychange'))
|
||||
clock += 5000
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
expect(check).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
99
admin/tests/app/chunkReload.test.ts
Normal file
99
admin/tests/app/chunkReload.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
CHUNK_RELOAD_KEY,
|
||||
CHUNK_RELOAD_WINDOW,
|
||||
installChunkReloadGuard,
|
||||
shouldReload,
|
||||
} from '../../src/app/chunkReload'
|
||||
|
||||
/** An in-memory Storage. */
|
||||
function memoryStorage(): Storage {
|
||||
const items = new Map<string, string>()
|
||||
return {
|
||||
get length() {
|
||||
return items.size
|
||||
},
|
||||
clear: () => items.clear(),
|
||||
getItem: (key) => items.get(key) ?? null,
|
||||
key: (index) => [...items.keys()][index] ?? null,
|
||||
removeItem: (key) => void items.delete(key),
|
||||
setItem: (key, value) => void items.set(key, String(value)),
|
||||
}
|
||||
}
|
||||
|
||||
describe('shouldReload', () => {
|
||||
it('reloads once and records the time', () => {
|
||||
const storage = memoryStorage()
|
||||
expect(shouldReload(storage, 1000)).toBe(true)
|
||||
expect(storage.getItem(CHUNK_RELOAD_KEY)).toBe('1000')
|
||||
})
|
||||
|
||||
it('declines a second reload within the window and allows one after it', () => {
|
||||
const storage = memoryStorage()
|
||||
expect(shouldReload(storage, 1000)).toBe(true)
|
||||
expect(shouldReload(storage, 1000 + CHUNK_RELOAD_WINDOW - 1)).toBe(false)
|
||||
expect(shouldReload(storage, 1000 + CHUNK_RELOAD_WINDOW)).toBe(true)
|
||||
expect(storage.getItem(CHUNK_RELOAD_KEY)).toBe(String(1000 + CHUNK_RELOAD_WINDOW))
|
||||
})
|
||||
|
||||
it('reloads over a stored value that is not a timestamp', () => {
|
||||
const storage = memoryStorage()
|
||||
storage.setItem(CHUNK_RELOAD_KEY, 'garbage')
|
||||
expect(shouldReload(storage, 1000)).toBe(true)
|
||||
})
|
||||
|
||||
it('never reloads without storage', () => {
|
||||
expect(shouldReload(null, 1000)).toBe(false)
|
||||
})
|
||||
|
||||
it('never reloads when storage throws', () => {
|
||||
const reading = memoryStorage()
|
||||
reading.getItem = () => {
|
||||
throw new Error('denied')
|
||||
}
|
||||
expect(shouldReload(reading, 1000)).toBe(false)
|
||||
const writing = memoryStorage()
|
||||
writing.setItem = () => {
|
||||
throw new Error('quota')
|
||||
}
|
||||
expect(shouldReload(writing, 1000)).toBe(false)
|
||||
})
|
||||
|
||||
it('keys the flag under summer-admin.* with a 60 second window', () => {
|
||||
expect(CHUNK_RELOAD_KEY).toBe('summer-admin.chunk-reload')
|
||||
expect(CHUNK_RELOAD_WINDOW).toBe(60_000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('installChunkReloadGuard', () => {
|
||||
it('reloads once on vite:preloadError and leaves the event to the importer', () => {
|
||||
const target = new EventTarget()
|
||||
const storage = memoryStorage()
|
||||
const reload = vi.fn()
|
||||
let clock = 5000
|
||||
const uninstall = installChunkReloadGuard({ target, storage: () => storage, reload, now: () => clock })
|
||||
|
||||
const first = new Event('vite:preloadError', { cancelable: true })
|
||||
target.dispatchEvent(first)
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
expect(first.defaultPrevented).toBe(false)
|
||||
|
||||
clock += 1000
|
||||
target.dispatchEvent(new Event('vite:preloadError', { cancelable: true }))
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
|
||||
uninstall()
|
||||
clock += CHUNK_RELOAD_WINDOW
|
||||
target.dispatchEvent(new Event('vite:preloadError', { cancelable: true }))
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not reload when no storage is available', () => {
|
||||
const target = new EventTarget()
|
||||
const reload = vi.fn()
|
||||
const uninstall = installChunkReloadGuard({ target, storage: () => null, reload })
|
||||
target.dispatchEvent(new Event('vite:preloadError'))
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
uninstall()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DEFAULT_BASE, readBase, runtime } from '../../src/app/runtime'
|
||||
import { DEFAULT_BASE, readBase, readBuild, runtime } from '../../src/app/runtime'
|
||||
|
||||
function docWith(content: string | null): Document {
|
||||
const doc = document.implementation.createHTMLDocument('test')
|
||||
@@ -39,3 +39,36 @@ describe('runtime base', () => {
|
||||
expect(DEFAULT_BASE).toBe('/backend')
|
||||
})
|
||||
})
|
||||
|
||||
function docWithBuild(content: string | null): Document {
|
||||
const doc = document.implementation.createHTMLDocument('test')
|
||||
if (content !== null) {
|
||||
const meta = doc.createElement('meta')
|
||||
meta.setAttribute('name', 'summer-admin-build')
|
||||
meta.setAttribute('content', content)
|
||||
doc.head.appendChild(meta)
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
describe('runtime build', () => {
|
||||
it('reads a hex build id from the served meta', () => {
|
||||
expect(readBuild(docWithBuild('0123456789ab'))).toBe('0123456789ab')
|
||||
expect(readBuild(docWithBuild(' 0123456789ab '))).toBe('0123456789ab')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a missing meta', null],
|
||||
['an empty value', ''],
|
||||
['the unreplaced build token', '__SUMMER_ADMIN_BUILD__'],
|
||||
['uppercase hex', '0123456789AB'],
|
||||
['a value with markup', '<b>0123456789ab</b>'],
|
||||
['a 65-character hex value', 'a'.repeat(65)],
|
||||
])('is empty for %s', (_label, content) => {
|
||||
expect(readBuild(docWithBuild(content))).toBe('')
|
||||
})
|
||||
|
||||
it('is empty in the test document, which carries no build', () => {
|
||||
expect(runtime.build).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
6
admin/tests/fixtures/lang.json
vendored
6
admin/tests/fixtures/lang.json
vendored
@@ -878,6 +878,12 @@
|
||||
},
|
||||
"backend::lang.shortcuts.title": {
|
||||
"other": "Skróty klawiaturowe"
|
||||
},
|
||||
"backend::lang.version.available": {
|
||||
"other": "Dostępna jest nowa wersja panelu."
|
||||
},
|
||||
"backend::lang.version.reload": {
|
||||
"other": "Odśwież"
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
|
||||
53
admin/tests/ui/UpdateBanner.test.ts
Normal file
53
admin/tests/ui/UpdateBanner.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import { noteBuild, resetBuildUpdate } from '../../src/app/buildUpdate'
|
||||
import UpdateBanner from '../../src/components/ui/UpdateBanner.vue'
|
||||
import { page } from '../../src/state/usePreferences'
|
||||
import { resetState } from '../helpers'
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
resetBuildUpdate()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('UpdateBanner', () => {
|
||||
it('is hidden while no new build is known', () => {
|
||||
const wrapper = mount(UpdateBanner)
|
||||
expect(wrapper.find('[data-update-banner]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows the new-version text and reloads only on Reload', async () => {
|
||||
const reload = vi.spyOn(page, 'reload').mockImplementation(() => undefined)
|
||||
const wrapper = mount(UpdateBanner)
|
||||
noteBuild('bbbbbbbbbbbb', 'aaaaaaaaaaaa')
|
||||
await nextTick()
|
||||
const banner = wrapper.find('[data-update-banner]')
|
||||
expect(banner.exists()).toBe(true)
|
||||
expect(banner.attributes('role')).toBe('status')
|
||||
expect(banner.attributes('aria-live')).toBe('polite')
|
||||
expect(banner.text()).toContain('Dostępna jest nowa wersja panelu.')
|
||||
expect(wrapper.find('[data-update-reload]').text()).toBe('Odśwież')
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
await wrapper.find('[data-update-reload]').trigger('click')
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('hides on Close', async () => {
|
||||
const reload = vi.spyOn(page, 'reload').mockImplementation(() => undefined)
|
||||
const wrapper = mount(UpdateBanner)
|
||||
noteBuild('bbbbbbbbbbbb', 'aaaaaaaaaaaa')
|
||||
await nextTick()
|
||||
const close = wrapper.find('[data-update-close]')
|
||||
expect(close.attributes('aria-label')).toBe('Zamknij')
|
||||
await close.trigger('click')
|
||||
expect(wrapper.find('[data-update-banner]').exists()).toBe(false)
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -96,14 +96,14 @@ func makePluginCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "make:plugin",
|
||||
Description: "Scaffold a compiling plugin module from a vendor.plugin id",
|
||||
Long: `Creates plugins/<plugin> in the current application: a Go module with plugin.go
|
||||
Long: `Creates plugins/<vendor>/<plugin> in the current application: a Go module with plugin.go
|
||||
(the plugin ID, Register and Boot) and go.mod, with a replace for a local
|
||||
framework checkout when the application uses one. Register it with plugin:add
|
||||
so the next summer build compiles it in. Run it inside an application; the
|
||||
directory must not exist yet.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "make:plugin acme.blog", Description: "Create plugins/blog with the ID acme.blog"},
|
||||
{Command: "plugin:add plugins/blog", Description: "Then register it in summer.yaml, go.mod and go.work"},
|
||||
{Command: "make:plugin acme.blog", Description: "Create plugins/acme/blog with the ID acme.blog"},
|
||||
{Command: "plugin:add plugins/acme/blog", Description: "Then register it in summer.yaml, go.mod and go.work"},
|
||||
},
|
||||
Args: []bonfire.Arg{{
|
||||
Name: "id",
|
||||
@@ -142,7 +142,7 @@ plugin picks them up on the next build. Existing files and names are never
|
||||
overwritten.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "make:model acme.blog Post", Description: "A Post model and its create-table migration"},
|
||||
{Command: "make:model Post --no-migration", Description: "Inside plugins/blog: only the model"},
|
||||
{Command: "make:model Post --no-migration", Description: "Inside plugins/acme/blog: only the model"},
|
||||
},
|
||||
Args: makeArtifactArgs(),
|
||||
Flags: []bonfire.Flag{{
|
||||
@@ -179,7 +179,7 @@ created them. registry.gen.go adds it to the plugin's migrations; run migrate
|
||||
to apply it.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "make:migration acme.blog AddSlugToPosts", Description: "An empty migration in acme.blog"},
|
||||
{Command: "make:migration AddSlugToPosts", Description: "The same from inside plugins/blog"},
|
||||
{Command: "make:migration AddSlugToPosts", Description: "The same from inside plugins/acme/blog"},
|
||||
},
|
||||
Args: makeArtifactArgs(),
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
@@ -207,12 +207,13 @@ func makeCommandCommand() bonfire.Command {
|
||||
Long: `Writes console/<name>.go with a bonfire.Command named <plugin>:<name> in the
|
||||
plugin with this ID or the plugin whose directory you are in; the generated
|
||||
registry adds it to the commands the plugin returns. The generated function
|
||||
takes no arguments, so a command that needs the database or another service
|
||||
takes a parameter instead and is returned from the plugin's Commands by hand
|
||||
(see the porting guide's blog:publish).`,
|
||||
receives the application the plugin kept from Boot (*backpack.App), which
|
||||
gives it the config and the services plugins publish. A function with another
|
||||
parameter, such as the porting guide's withDB for blog:publish, is returned
|
||||
from the plugin's Commands by hand.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "make:command acme.blog Publish", Description: "A blog:publish command in acme.blog"},
|
||||
{Command: "make:command Publish", Description: "The same from inside plugins/blog"},
|
||||
{Command: "make:command Publish", Description: "The same from inside plugins/acme/blog"},
|
||||
},
|
||||
Args: makeArtifactArgs(),
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
@@ -243,7 +244,7 @@ generated registry adds it to the jobs the plugin returns. Dispatch it with
|
||||
conga.Manager.Dispatch; queue:work or serve runs it.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "make:job acme.blog SendDigest", Description: "A SendDigest job in acme.blog"},
|
||||
{Command: "make:job SendDigest", Description: "The same from inside plugins/blog"},
|
||||
{Command: "make:job SendDigest", Description: "The same from inside plugins/acme/blog"},
|
||||
},
|
||||
Args: makeArtifactArgs(),
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
@@ -269,16 +270,24 @@ func makeAdminControllerCommand() bonfire.Command {
|
||||
Name: "make:admin-controller",
|
||||
Description: "Generate an admin controller with fields.yaml and columns.yaml",
|
||||
Long: `Writes controllers/<name>.go, its config_form.yaml and config_list.yaml, and
|
||||
starter fields.yaml and columns.yaml under models/<name>/, in the plugin with
|
||||
this ID or the plugin whose directory you are in. The controller requires the
|
||||
permission <plugin>.access_<name>, which you declare in the plugin's
|
||||
Permissions(), and its NewRecord returns nil until you return your model, so
|
||||
the screens stay closed until you finish it.`,
|
||||
starter fields.yaml and columns.yaml under models/<model>/, in the plugin with
|
||||
this ID or the plugin whose directory you are in. The model is the singular of
|
||||
the name (Posts edits Post) unless --model names it. When models/ already has
|
||||
that model, NewRecord returns it; otherwise NewRecord returns nil until you run
|
||||
make:model and return it, so the screens stay closed until you finish it. The
|
||||
list offers create and delete. The controller requires the permission
|
||||
<plugin>.access_<name>, which you declare in the plugin's Permissions(). A
|
||||
scaffolded plugin serves the YAML through AdminFS from registry.gen.go.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "make:admin-controller acme.blog Posts", Description: "A Posts controller in acme.blog"},
|
||||
{Command: "make:admin-controller Posts", Description: "The same from inside plugins/blog"},
|
||||
{Command: "make:admin-controller acme.blog Posts", Description: "A Posts controller for the Post model in acme.blog"},
|
||||
{Command: "make:admin-controller Posts", Description: "The same from inside plugins/acme/blog"},
|
||||
{Command: "make:admin-controller acme.blog People --model Person", Description: "A People controller for the Person model"},
|
||||
},
|
||||
Args: makeArtifactArgs(),
|
||||
Args: makeArtifactArgs(),
|
||||
Flags: []bonfire.Flag{{
|
||||
Name: "model",
|
||||
Description: "The model the controller edits (default: the singular of the name)",
|
||||
}},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
@@ -288,7 +297,8 @@ the screens stay closed until you finish it.`,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := build.MakeAdminController(ctx, dir, id, name)
|
||||
model, _ := in.Flag("model")
|
||||
result, err := build.MakeAdminController(ctx, dir, id, name, model)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -118,10 +118,10 @@ func TestMakeCommandsViaCLI(t *testing.T) {
|
||||
}
|
||||
|
||||
out := runSummer("make:plugin", "golem15.demo")
|
||||
if !strings.Contains(out, filepath.Join("plugins", "demo")) {
|
||||
if !strings.Contains(out, filepath.Join("plugins", "golem15", "demo")) {
|
||||
t.Fatalf("make:plugin output missing plugin dir:\n%s", out)
|
||||
}
|
||||
pluginDir := filepath.Join(dir, "plugins", "demo")
|
||||
pluginDir := filepath.Join(dir, "plugins", "golem15", "demo")
|
||||
if _, err := os.Stat(filepath.Join(pluginDir, "plugin.go")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -139,6 +139,11 @@ func TestMakeCommandsViaCLI(t *testing.T) {
|
||||
t.Fatalf("make:job output missing reindex.go:\n%s", out)
|
||||
}
|
||||
|
||||
out = runSummer("make:admin-controller", "golem15.demo", "People", "--model", "Person")
|
||||
if !strings.Contains(out, filepath.Join("models", "person", "fields.yaml")) {
|
||||
t.Fatalf("make:admin-controller --model output missing models/person:\n%s", out)
|
||||
}
|
||||
|
||||
t.Chdir(pluginDir)
|
||||
out = runSummer("make:command", "Purge")
|
||||
if !strings.Contains(out, filepath.Join("console", "purge.go")) {
|
||||
@@ -193,7 +198,7 @@ func TestNewCommandViaCLI(t *testing.T) {
|
||||
t.Fatalf("new demo --no-git: %v\n%s", err, out)
|
||||
}
|
||||
app := filepath.Join(parent, "sm-demo-app")
|
||||
for _, rel := range []string{"summer.yaml", "main.go", "plugins/demo/routes.go"} {
|
||||
for _, rel := range []string{"summer.yaml", "main.go", "plugins/golem15/demo/routes.go"} {
|
||||
if _, err := os.Stat(filepath.Join(app, rel)); err != nil {
|
||||
t.Fatalf("%s missing: %v", rel, err)
|
||||
}
|
||||
|
||||
@@ -150,33 +150,38 @@ func runAppBinary(ctx context.Context, argv []string, out bonfire.Output) error
|
||||
// streams, so it sees the terminal: it asks the production question, prints
|
||||
// styled summaries and animates its widgets. Otherwise its stdout goes
|
||||
// through out (tests and bonfire.Call roots capture it) and its stderr to
|
||||
// summer's stderr, so log lines and error blocks never mix into data such
|
||||
// as --json output.
|
||||
// the root's error stream (bonfire.ErrWriter), which is os.Stderr for the
|
||||
// real summer, so log lines and error blocks never mix into data such as
|
||||
// --json output, and a root built on buffers captures them.
|
||||
func appStreams(out bonfire.Output, terminal bool) (stdout, stderr io.Writer) {
|
||||
if terminal {
|
||||
return os.Stdout, os.Stderr
|
||||
}
|
||||
return out, os.Stderr
|
||||
return out, bonfire.ErrWriter(out)
|
||||
}
|
||||
|
||||
// runAppCmd runs cmd, the app binary started with argv, forwarding SIGINT
|
||||
// and SIGTERM to it. When the binary exits with a code, the error carries
|
||||
// that code (bonfire.WithExitCode) and is marked as already reported. The
|
||||
// error names the command through argmask.Join, with the values of
|
||||
// secret-looking flags masked, on every branch: a binary that fails to
|
||||
// start or is ended by a signal gets summer's error block, and a signal
|
||||
// death keeps exit code 1.
|
||||
// that code (bonfire.WithExitCode) and is marked as already reported. A
|
||||
// binary ended by a signal makes summer exit 128 plus the signal number
|
||||
// (signalExit). The error names the command through argmask.Join, with the
|
||||
// values of secret-looking flags masked, on every branch that prints it: a
|
||||
// binary that fails to start gets summer's error block and exit code 1.
|
||||
func runAppCmd(cmd *exec.Cmd, argv []string) error {
|
||||
sigs := make(chan os.Signal, 2)
|
||||
signal.Notify(sigs, os.Interrupt, syscall.SIGTERM)
|
||||
defer signal.Stop(sigs)
|
||||
if err := runChild(cmd, sigs); err != nil {
|
||||
err = fmt.Errorf("summer: %s: %w", argmask.Join(argv), err)
|
||||
var exit *exec.ExitError
|
||||
if errors.As(err, &exit) {
|
||||
if ws, ok := exit.Sys().(syscall.WaitStatus); ok && ws.Signaled() {
|
||||
return signalExit(argv, err, ws.Signal())
|
||||
}
|
||||
}
|
||||
err = fmt.Errorf("summer: %s: %w", argmask.Join(argv), err)
|
||||
if errors.As(err, &exit) && exit.Exited() {
|
||||
// The binary printed its own error; summer exits with its
|
||||
// code and prints nothing more. A binary ended by a signal
|
||||
// printed nothing, so it gets summer's error block.
|
||||
// code and prints nothing more.
|
||||
return bonfire.Reported(bonfire.WithExitCode(err, exit.ExitCode()))
|
||||
}
|
||||
return err
|
||||
@@ -184,6 +189,21 @@ func runAppCmd(cmd *exec.Cmd, argv []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// signalExit is the error for an app binary, started with argv, that died
|
||||
// by sig: it carries exit code 128 plus the signal number, as a shell
|
||||
// reports such a death (130 for Ctrl+C, 143 for SIGTERM, 137 for SIGKILL).
|
||||
// SIGINT and SIGTERM are what stopping summer or the binary sends, so they
|
||||
// count as already reported and print nothing more; any other signal gets
|
||||
// summer's error block, naming the command through argmask.Join with the
|
||||
// values of secret-looking flags masked.
|
||||
func signalExit(argv []string, err error, sig syscall.Signal) error {
|
||||
err = bonfire.WithExitCode(fmt.Errorf("summer: %s: %w", argmask.Join(argv), err), 128+int(sig))
|
||||
if sig == syscall.SIGINT || sig == syscall.SIGTERM {
|
||||
return bonfire.Reported(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// appShutdownGrace is how long a cancelled app binary may take to exit after
|
||||
// SIGTERM before it is killed: the grace summer dev gives serve as well,
|
||||
// longer than the 10-second shutdown timeout of serve and the workers.
|
||||
|
||||
@@ -293,7 +293,8 @@ func TestDelegateExamples(t *testing.T) {
|
||||
|
||||
// TestExitHelperProcess is not a real test: TestDelegateExitCodePassthrough
|
||||
// re-executes the test binary with SUMMER_EXIT_HELPER set to get a child
|
||||
// that prints an error on stderr and exits 2, or that is killed by SIGKILL.
|
||||
// that prints an error on stderr and exits 2, or that is killed by SIGKILL
|
||||
// or SIGTERM.
|
||||
func TestExitHelperProcess(t *testing.T) {
|
||||
switch os.Getenv("SUMMER_EXIT_HELPER") {
|
||||
case "exit2":
|
||||
@@ -304,6 +305,12 @@ func TestExitHelperProcess(t *testing.T) {
|
||||
_ = p.Signal(syscall.SIGKILL)
|
||||
time.Sleep(10 * time.Second)
|
||||
os.Exit(3)
|
||||
case "sigterm":
|
||||
signal.Reset(syscall.SIGTERM)
|
||||
p, _ := os.FindProcess(os.Getpid())
|
||||
_ = p.Signal(syscall.SIGTERM)
|
||||
time.Sleep(10 * time.Second)
|
||||
os.Exit(3)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,8 +330,8 @@ func exitHelperRunner(t *testing.T, mode string) {
|
||||
|
||||
// TestDelegateExitCodePassthrough (CR-02, WR-10): summer exits with the
|
||||
// app binary's exit code and prints no error block of its own; a binary
|
||||
// killed by a signal gets summer's error block, with secret flag values
|
||||
// masked, and exit 1.
|
||||
// killed by a signal exits 128 plus the signal number: SIGKILL gets
|
||||
// summer's error block, with secret flag values masked, SIGTERM none.
|
||||
func TestDelegateExitCodePassthrough(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("SIGKILL is POSIX-only")
|
||||
@@ -346,6 +353,11 @@ func TestDelegateExitCodePassthrough(t *testing.T) {
|
||||
if code != 2 {
|
||||
t.Errorf("exit %d, want the binary's 2", code)
|
||||
}
|
||||
// IN-13: the binary's own error block reaches the root's error
|
||||
// writer.
|
||||
if n := strings.Count(errOut, "ERROR the app binary failed"); n != 1 {
|
||||
t.Errorf("the binary's error block reached the root's error writer %d times, want 1:\nstdout %q\nstderr %q", n, out, errOut)
|
||||
}
|
||||
for _, s := range []string{out, errOut} {
|
||||
if strings.Contains(s, " ERROR ") || strings.Contains(s, "summer: migrate") {
|
||||
t.Errorf("summer printed an error block of its own:\nstdout %q\nstderr %q", out, errOut)
|
||||
@@ -356,8 +368,8 @@ func TestDelegateExitCodePassthrough(t *testing.T) {
|
||||
|
||||
t.Run("killed by a signal", func(t *testing.T) {
|
||||
out, errOut, code := run(t, "sigkill", "migrate", "--bogus")
|
||||
if code != bonfire.ExitFailure {
|
||||
t.Errorf("exit %d, want 1", code)
|
||||
if code != 137 {
|
||||
t.Errorf("exit %d, want 137 (128+SIGKILL)", code)
|
||||
}
|
||||
if n := strings.Count(errOut, " ERROR "); n != 1 || !strings.Contains(errOut, "summer: migrate --bogus: signal: killed") {
|
||||
t.Errorf("want one error block naming the signal, got %d:\nstdout %q\nstderr %q", n, out, errOut)
|
||||
@@ -366,8 +378,8 @@ func TestDelegateExitCodePassthrough(t *testing.T) {
|
||||
|
||||
t.Run("killed by a signal masks secrets", func(t *testing.T) {
|
||||
out, errOut, code := run(t, "sigkill", "migrate", "--password", "hunter2")
|
||||
if code != bonfire.ExitFailure {
|
||||
t.Errorf("exit %d, want 1", code)
|
||||
if code != 137 {
|
||||
t.Errorf("exit %d, want 137 (128+SIGKILL)", code)
|
||||
}
|
||||
if n := strings.Count(errOut, " ERROR "); n != 1 || !strings.Contains(errOut, "--password ***") {
|
||||
t.Errorf("want one error block with the password masked, got %d:\nstdout %q\nstderr %q", n, out, errOut)
|
||||
@@ -376,6 +388,56 @@ func TestDelegateExitCodePassthrough(t *testing.T) {
|
||||
t.Errorf("the password reached the output:\nstdout %q\nstderr %q", out, errOut)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("terminated by SIGTERM", func(t *testing.T) {
|
||||
out, errOut, code := run(t, "sigterm", "migrate", "--password", "hunter2")
|
||||
if code != 143 {
|
||||
t.Errorf("exit %d, want 143 (128+SIGTERM)", code)
|
||||
}
|
||||
if strings.Contains(out+errOut, " ERROR ") || strings.Contains(out+errOut, "hunter2") {
|
||||
t.Errorf("summer printed a block for SIGTERM:\nstdout %q\nstderr %q", out, errOut)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestDelegateSignalExitCodes (WR-10 follow-up): a delegate that died by a
|
||||
// signal makes summer exit 128 plus the signal number. SIGINT (130) and
|
||||
// SIGTERM (143) count as already reported and print nothing; any other
|
||||
// signal (SIGKILL, 137) gets summer's error block with secret flag values
|
||||
// masked. The mapping is driven directly, so no signal is delivered.
|
||||
func TestDelegateSignalExitCodes(t *testing.T) {
|
||||
argv := []string{"migrate", "--password", "hunter2"}
|
||||
cases := []struct {
|
||||
sig syscall.Signal
|
||||
code int
|
||||
block bool
|
||||
}{
|
||||
{syscall.SIGINT, 130, false},
|
||||
{syscall.SIGTERM, 143, false},
|
||||
{syscall.SIGKILL, 137, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.sig.String(), func(t *testing.T) {
|
||||
cmd := bonfire.Command{Name: "migrate", Passthrough: true, Run: func(context.Context, bonfire.Input, bonfire.Output) error {
|
||||
return signalExit(argv, errors.New("signal: "+tc.sig.String()), tc.sig)
|
||||
}}
|
||||
var out, errW syncBuffer
|
||||
root, err := bonfire.NewRootIO("summer", []bonfire.Command{cmd}, strings.NewReader(""), &out, &errW)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code := bonfire.Execute(context.Background(), root, argv); code != tc.code {
|
||||
t.Errorf("exit %d, want %d", code, tc.code)
|
||||
}
|
||||
got := errW.String()
|
||||
if !tc.block && got != "" {
|
||||
t.Errorf("printed a block:\n%s", got)
|
||||
}
|
||||
if tc.block && (strings.Count(got, " ERROR ") != 1 || !strings.Contains(got, "summer: migrate --password ***") || strings.Contains(got, "hunter2")) {
|
||||
t.Errorf("want one block with the argv masked:\n%s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunAppCmdMasksSecrets (WR-10): the error of an app binary killed by a
|
||||
@@ -419,9 +481,9 @@ func TestRunAppCmdMasksSecrets(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestDelegateStreams (CR-03): off a terminal the app binary's stdout goes
|
||||
// through summer's Output and its stderr to summer's stderr, never into
|
||||
// summer's stdout.
|
||||
// TestDelegateStreams (CR-03, IN-13): off a terminal the app binary's
|
||||
// stdout goes through summer's Output and its stderr to the root's error
|
||||
// writer (bonfire.ErrWriter), never into summer's stdout.
|
||||
func TestDelegateStreams(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("the fake app binary is a shell script")
|
||||
@@ -438,23 +500,42 @@ func TestDelegateStreams(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Chdir(app)
|
||||
out, err := runSummer(t, "route:list", "--json")
|
||||
var stdout, stderr syncBuffer
|
||||
root, err := bonfire.NewRootIO("summer", toolCommands(), strings.NewReader(""), &stdout, &stderr)
|
||||
if err != nil {
|
||||
t.Fatalf("route:list: %v\n%s", err, out)
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "data-line") {
|
||||
t.Errorf("the binary's stdout did not reach summer's Output: %q", out)
|
||||
if code := bonfire.Execute(context.Background(), root, []string{"route:list", "--json"}); code != bonfire.ExitOK {
|
||||
t.Fatalf("route:list: exit %d\nstdout %q\nstderr %q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
if strings.Contains(out, "log-line") {
|
||||
t.Errorf("the binary's stderr landed in summer's stdout: %q", out)
|
||||
if !strings.Contains(stdout.String(), "data-line") {
|
||||
t.Errorf("the binary's stdout did not reach summer's Output: %q", stdout.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), "log-line") {
|
||||
t.Errorf("the binary's stderr landed in summer's stdout: %q", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "log-line") {
|
||||
t.Errorf("the binary's stderr did not reach the root's error writer: %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppStreams (CR-03): on a terminal the app binary gets summer's own
|
||||
// stdout and stderr; off a terminal its stdout goes through the Output and
|
||||
// its stderr to summer's stderr.
|
||||
// TestAppStreams (CR-03, IN-13): on a terminal the app binary gets
|
||||
// summer's own stdout and stderr; off a terminal its stdout goes through the
|
||||
// Output and its stderr to the Output's error writer (bonfire.ErrWriter),
|
||||
// which is os.Stderr for the real summer.
|
||||
func TestAppStreams(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
var buf, errBuf bytes.Buffer
|
||||
sep := bonfire.NewOutput(strings.NewReader(""), &buf, &errBuf)
|
||||
if _, stderr := appStreams(sep, false); stderr != io.Writer(&errBuf) {
|
||||
t.Errorf("not a terminal: stderr %T, want the Output's error writer", stderr)
|
||||
}
|
||||
real, err := bonfire.NewRoot("summer", nil, os.Stdout)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if real.ErrOrStderr() != os.Stderr {
|
||||
t.Errorf("the real summer root's error stream is %v, want os.Stderr", real.ErrOrStderr())
|
||||
}
|
||||
out := bonfire.NewOutput(strings.NewReader(""), &buf, &buf)
|
||||
if bonfire.IsTerminal(out) {
|
||||
t.Fatal("a buffer console reports a terminal")
|
||||
@@ -464,7 +545,7 @@ func TestAppStreams(t *testing.T) {
|
||||
t.Errorf("terminal: streams %v, %v; want os.Stdout, os.Stderr", stdout, stderr)
|
||||
}
|
||||
stdout, stderr = appStreams(out, false)
|
||||
if stdout != io.Writer(out) || stderr != os.Stderr {
|
||||
t.Errorf("not a terminal: streams %v, %v; want the Output, os.Stderr", stdout, stderr)
|
||||
if stdout != io.Writer(out) || stderr != io.Writer(&buf) {
|
||||
t.Errorf("not a terminal: streams %T, %T; want the Output and its error writer", stdout, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,9 +76,9 @@ module: git.golem15.com/acme/acme
|
||||
binary: acme
|
||||
plugins:
|
||||
- id: acme.user
|
||||
module: git.golem15.com/acme/acme/plugins/user
|
||||
module: git.golem15.com/acme/acme/plugins/acme/user
|
||||
- id: acme.blog
|
||||
module: git.golem15.com/acme/acme/plugins/blog
|
||||
module: git.golem15.com/acme/acme/plugins/acme/blog
|
||||
build:
|
||||
tags:
|
||||
- summercms_s3
|
||||
@@ -90,17 +90,17 @@ build:
|
||||
|
||||
## Local plugins in a workspace
|
||||
|
||||
A plugin you develop inside the application lives in `plugins/<name>` as its own module. `summer make:plugin acme.blog` creates it there, and `summer plugin:add plugins/blog` registers it:
|
||||
A plugin you develop inside the application lives in `plugins/<vendor>/<name>` as its own module. `summer make:plugin acme.blog` creates it in `plugins/acme/blog`, and `summer plugin:add plugins/acme/blog` registers it:
|
||||
|
||||
- it adds the plugin to `summer.yaml`;
|
||||
- it adds a `require` and a `replace` pointing at the local directory to the application's `go.mod`;
|
||||
- it adds the plugin directory to the nearest `go.work` as a path relative to that file, such as `./plugins/blog`, creating a `go.work` in the application root when there is none.
|
||||
- it adds the plugin directory to the nearest `go.work` as a path relative to that file, such as `./plugins/acme/blog`, creating a `go.work` in the application root when there is none.
|
||||
|
||||
With a `go.work` in place, `go build`, `go test` and your editor see every local plugin module together. `summer build` uses the nearest `go.work` it finds; without one it builds in module mode.
|
||||
|
||||
```sh
|
||||
summer make:plugin acme.blog
|
||||
summer plugin:add plugins/blog
|
||||
summer plugin:add plugins/acme/blog
|
||||
summer build
|
||||
```
|
||||
|
||||
|
||||
@@ -152,9 +152,10 @@ func (p *BlogPlugin) Settings() []pact.SettingsItem {
|
||||
|
||||
```sh
|
||||
summer make:admin-controller acme.blog Posts
|
||||
summer make:admin-controller acme.blog People --model Person
|
||||
```
|
||||
|
||||
The generated controller requires the permission `acme.blog.access_posts`, so declare it in the plugin's `pact.HasPermissions` or the admin API refuses to start with an unknown-permission error. Its `NewRecord` returns `nil` until you return the model, and the list and every write answer 500 until then. The controller ID maps to the admin API path: `acme.blog.posts` is served under `<prefix>/api/v1/acme/blog/posts`. The admin prefix is `backend.uri`, `/backend` by default. A model's `Fillable` method decides which form fields the API may write; see [Forms](forms.md). The admin finds a model's columns through its struct fields, including those of an embedded struct such as `gorm.Model`. A field with a `gorm:"column:..."` tag is known by that column alone; an untagged field is known by GORM's default column name.
|
||||
The controller edits the singular of its name, `Post` for `Posts`, unless `--model` names the model: `ModelName` and both `modelClass` values carry it, and `fields.yaml` and `columns.yaml` go under `models/post/`, as in WinterCMS. When the plugin's `models` package already declares the model, `NewRecord` returns it; otherwise `NewRecord` returns `nil` and the list and every write answer 500 until you run `summer make:model` and return the model. The list toolbar offers `create` and `delete`, with `showCheckboxes: true`, which the `delete` button needs. A plugin scaffolded with `summer make:plugin` implements `pact.AdminAssets`: its `AdminFS` returns `generatedAdminFS()`, the controller and model YAML `registry.gen.go` embeds; for a plugin without an `AdminFS` method the command prints a note. The generated controller requires the permission `acme.blog.access_posts`, so declare it in the plugin's `pact.HasPermissions` or the admin API refuses to start with an unknown-permission error. The controller ID maps to the admin API path: `acme.blog.posts` is served under `<prefix>/api/v1/acme/blog/posts`. The admin prefix is `backend.uri`, `/backend` by default. A model's `Fillable` method decides which form fields the API may write; see [Forms](forms.md). The admin finds a model's columns through its struct fields, including those of an embedded struct such as `gorm.Model`. A field with a `gorm:"column:..."` tag is known by that column alone; an untagged field is known by GORM's default column name.
|
||||
|
||||
## Compilation at boot
|
||||
|
||||
|
||||
@@ -16,8 +16,10 @@ cabana mounts the SPA under the admin prefix, `backend.uri` (`/backend` by defau
|
||||
- Paths under `api/` that no API route matches return the admin API's JSON `not_found` error, never the SPA.
|
||||
- Hashed files under `assets/` are cached for a long time; `index.html` is never cached.
|
||||
- Every response carries a restrictive Content-Security-Policy (`frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'; form-action 'self'; frame-src 'none'`: scripts only from the admin origin, no native form post to another origin, no framed content), frame denial, `nosniff`, a same-origin referrer policy and `noindex, nofollow`.
|
||||
- Every response also carries the build id in the `X-Summer-Build` header (`boardwalk.BuildHeader`), the `api/` misses included.
|
||||
- `GET <prefix>/build.json` answers `{"build":"<id>"}` with `Cache-Control: no-store`, so an open tab can ask which build the server runs.
|
||||
|
||||
The build is path-agnostic: `index.html` holds a placeholder (`boardwalk.BaseToken`) that `boardwalk.RewriteIndex` replaces with the prefix once, when the handler is built. A build without the placeholder fails the start-up.
|
||||
The build is path-agnostic: `index.html` holds a placeholder (`boardwalk.BaseToken`) that `boardwalk.RewriteIndex` replaces with the prefix once, when the handler is built. A build without the placeholder fails the start-up. A second placeholder, `boardwalk.BuildToken`, is replaced with the build id (`boardwalk.BuildID`: the admin build plus the binary's build information, so every redeploy of a different binary gets a new id); an index without it is served as it is and the SPA's version check stays off.
|
||||
|
||||
A plugin route under the admin prefix also fails the start-up: the SPA and the admin API own that whole path.
|
||||
|
||||
@@ -39,6 +41,12 @@ The button at the top of the plugin rail switches the theme: each click moves fr
|
||||
|
||||
When the server offers more than one admin language (`meta.locales` of `GET .../lang`), the user menu lists them under Language, each named in its own language. Choosing one saves it as the administrator's `locale` preference, caches it in `localStorage` (`summer-admin.locale`) and reloads the page, so the strings, navigation, settings, schemas and validation messages all come back in that language, in any browser and whatever `Accept-Language` says. A form with unsaved changes asks before the reload.
|
||||
|
||||
## New versions
|
||||
|
||||
A tab opened before a deploy would otherwise keep running the old admin against the new server, because the session refreshes silently. The SPA reads the build it booted with from the `summer-admin-build` meta of the served `index.html` and asks `<prefix>/build.json` again when the window gets focus, when the tab becomes visible and every 5 minutes while it is visible, at most once every 30 seconds. When the server answers a different build, a banner at the bottom right says that a new version is available, with Reload and Close buttons. The SPA never reloads by itself: Reload goes through the usual page reload, so a form with unsaved changes still asks first, and Close hides the banner until yet another build is deployed. When the served index carries no build, as under the Vite dev server, the check is off.
|
||||
|
||||
A tab that still runs the old build may ask for a script chunk the new build no longer has. A failed chunk load (Vite's `vite:preloadError` event) reloads the page once, which loads the new build. A timestamp in `sessionStorage` (`summer-admin.chunk-reload`) blocks a second automatic reload within 60 seconds, so a reload that does not help never loops, and without usable `sessionStorage` the SPA does not reload at all.
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
As in WinterCMS, a shortcut presses a button on the screen. Ctrl and Cmd are both accepted on every platform; the tooltips name Cmd (⌘) on macOS.
|
||||
|
||||
@@ -72,7 +72,7 @@ The admin registers two permissions of its own on the System tab of the permissi
|
||||
|
||||
An administrator's grants are the role's `permissions` merged with the administrator's own `backend_users.permissions`, the way WinterCMS merges them: the administrator's value for a code replaces the role's, and only `1` grants. A `-1` (or `0`) on the administrator therefore removes a permission the role grants, so rows copied from a WinterCMS database keep their denies. As in WinterCMS the merge compares codes exactly, so denying `acme.blog.access_posts` does not take it back from a role that grants `acme.blog.*`.
|
||||
|
||||
Actions registered through `pact.HasAdminActions`, bulk actions (`pact.HasAdminBulkActions`) and record actions (`pact.HasAdminRecordActions`) may each name extra permissions, checked on top of the controller's. An administrator who lacks them does not get the action in the list schema or in a record's `meta.actions`, and posting it answers 403 and is written to the authentication log. A bulk or record action that ran is logged with the controller, the action, the administrator and the affected count or record id, without record contents.
|
||||
Actions registered through `pact.HasAdminActions`, bulk actions (`pact.HasAdminBulkActions`) and record actions (`pact.HasAdminRecordActions`) may each name extra permissions, checked on top of the controller's. An administrator who lacks them does not get the action in the list schema or in a record's `meta.actions`, and posting it answers 403 and is written to the authentication log. A bulk, record, toolbar or widget action that ran is logged with the controller, the action and the administrator, plus the affected count for a bulk action, the record id for a record action (and for a widget action posted from a saved record), and the kind `toolbar` or `field` for the other two, without record contents, posted values or payload.
|
||||
|
||||
A permission denial and a refusal are different answers with the same status. A denial comes from the framework: the administrator lacks a permission code, and the 403 carries the framework's fixed text. A refusal comes from controller code that returns a `cabana.ForbiddenError`: the administrator may run the route, but this change is not allowed for this record, and the 403 carries the plugin's translated message and, optionally, messages per field. See [Refusing a write](admin-controllers.md#refusing-a-write).
|
||||
|
||||
@@ -186,6 +186,7 @@ Settings → System → Roles (`summercms.cabana.roles`, `cabana.RolesController
|
||||
- **System roles** are the roles with `is_system` set (the seeded developer and publisher roles) and every role whose code plugins grant permissions to through `pact.Permission` `Roles`. Their code cannot be changed and they cannot be deleted; name, description and stored grants stay editable. A role created on this screen is never a system role. The grants plugins declare in code are merged with the stored ones when an administrator signs in, so they apply whether or not they are stored. The role's editor shows them checked and locked, with the note that a plugin grants them; a save keeps them as stored.
|
||||
- An administrator who is not a superuser cannot grant more than they hold: permissions they lack are locked in the role's editor, so they can neither add nor remove them on any role.
|
||||
- A role that any administrator holds, deleted administrators included, cannot be deleted; the refusal names the role and how many administrators hold it. Give them another role first. A refused role in a bulk delete rolls back the whole selection.
|
||||
- The Administrators tab of a saved role lists the administrators who hold it, with login, first name, last name and email, sorted by login. It is read-only: roles are assigned on the Administrators form. Superusers appear only to an editor who is a superuser; deleted and deactivated administrators are listed and marked, so the list matches the count of the delete refusal. Up to 100 administrators are shown, with a note giving the total beyond that. For an editor who also holds `backend.manage_users` (`cabana.PermissionManageUsers`), each login opens that administrator's preview.
|
||||
|
||||
### Operator commands
|
||||
|
||||
|
||||
@@ -91,8 +91,11 @@ The application binary reads these options before it loads `config/` and starts
|
||||
```sh
|
||||
./bin/acme -vv migrate
|
||||
./bin/acme migrate:status --env staging
|
||||
./bin/acme -v serve --help
|
||||
```
|
||||
|
||||
An invocation that only lists or describes commands (no command, `list`, `help`, `--help` or `-h`, and shell completion requests) starts the plugins with the console log at warnings, as `-q` would, so the info lines plugins log while they register and boot do not print above the help; `-v` shows them, as in the last line above. `serve`, `queue:work`, `migrate` and every other command boot at the configured levels, and log files keep their levels. It works for both programs and needs no rebuild of the generated `main.go`, only the framework version. A command that passes its arguments through unparsed counts as asking for help when `--help` follows it, but its console log is back at the configured levels before it runs.
|
||||
|
||||
### Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
@@ -143,7 +146,7 @@ summer admin:token:create jane --scope journal.posts --json
|
||||
|
||||
The binary writes to your terminal directly, so it asks the production question, prints its styled startup summary and animates its spinners as if you had run it yourself. Its error stream stays separate from its output: `summer route:list --json > routes.json` captures only the JSON, while warnings and log lines still reach the terminal on stderr.
|
||||
|
||||
When the binary fails, `summer` exits with the binary's exit code (a usage error stays `2`) and prints nothing more: the binary has already printed its error. A binary killed by a signal gets an error block from `summer` and exit code `1`; the block names the command with the values of secret-looking flags (`--password`, `--api-token=...`) masked as `***`.
|
||||
When the binary fails, `summer` exits with the binary's exit code (a usage error stays `2`) and prints nothing more: the binary has already printed its error. A binary that dies by a signal makes `summer` exit with 128 plus the signal number, as a shell reports it: `130` for Ctrl+C (SIGINT), `143` for SIGTERM, `137` for SIGKILL. After SIGINT or SIGTERM `summer` prints nothing more; any other signal gets an error block from `summer` that names the command with the values of secret-looking flags (`--password`, `--api-token=...`) masked as `***`.
|
||||
|
||||
When `summer` receives SIGINT or SIGTERM while the binary runs, it passes the signal on and waits for the binary to shut down, so stopping `summer serve` with Ctrl+C or `kill` frees its port.
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ The application gets these files:
|
||||
| `compose.yaml` | PostgreSQL 16 on `127.0.0.1:54320`, with credentials matching `.env`. |
|
||||
| `.gitignore` | `bin/`, `tmp/`, `storage/`, `.env` and `go.work.sum`. |
|
||||
| `CLAUDE.md`, `AGENTS.md` | Instructions for AI coding agents: the commands, the layout, the admin, the links to the documentation for agents and the naming convention. `AGENTS.md` is a symlink to `CLAUDE.md`. |
|
||||
| `plugins/<name>` | The starter plugin `golem15.<name>`, created by the same code as `summer make:plugin` and registered by the same code as `summer plugin:add`. Its `routes.go` answers `GET /` with a JSON greeting. |
|
||||
| `plugins/golem15/<name>` | The starter plugin `golem15.<name>`, created by the same code as `summer make:plugin` and registered by the same code as `summer plugin:add`. Its `routes.go` answers `GET /` with a JSON greeting. |
|
||||
| `main.go`, `plugins.gen.go` | Generated from `summer.yaml`, as `summer build` does. |
|
||||
|
||||
Unless you pass `--no-git`, `summer new` initialises a git repository on `main` and commits everything except `.env`. When git has no user name or email configured, that first commit uses the author `SummerCMS <summer@localhost>` and prints a warning; your git configuration is not changed. Finally it prints the next steps to run inside `sm-acme-app`:
|
||||
@@ -79,15 +79,30 @@ Golem15 names repositories by what they hold: `sm-<name>-app` for an application
|
||||
|
||||
## Creating a plugin
|
||||
|
||||
`summer make:plugin` takes a plugin ID in `vendor.plugin` form and creates the plugin module in `plugins/<plugin>` of the current application, with the directory layout described in [Plugin registration](../plugins/registration.md):
|
||||
`summer make:plugin` takes a plugin ID in `vendor.plugin` form and creates the plugin module in `plugins/<vendor>/<plugin>` of the current application, with the directory layout described in [Plugin registration](../plugins/registration.md). `acme.blog` becomes `plugins/acme/blog` with the module path `<application module>/plugins/acme/blog` and `package blog`, so `acme.blog` and `pixelpixel.blog` can live in one application:
|
||||
|
||||
```sh
|
||||
summer make:plugin acme.blog
|
||||
summer plugin:add plugins/blog
|
||||
summer plugin:add plugins/acme/blog
|
||||
```
|
||||
|
||||
`summer plugin:add` then registers the local module: it adds the plugin to `summer.yaml`, adds a `require` and a local `replace` to the application's `go.mod`, and adds the directory to `go.work`. The next `summer build` compiles the plugin in.
|
||||
|
||||
### Upgrading: plugins without a vendor directory
|
||||
|
||||
Before this layout, `summer make:plugin` wrote `plugins/<plugin>` without the vendor, and `summer new` put its starter plugin there. Such a plugin keeps working: `summer build` follows `summer.yaml` and the `replace` lines of the application's `go.mod`, and the `make:` commands look for a plugin in `summer.yaml` first, then in `plugins/<vendor>/<plugin>`, then in `plugins/<plugin>`.
|
||||
|
||||
Moving it is optional. To move `acme.blog` from `plugins/blog` to `plugins/acme/blog`:
|
||||
|
||||
```sh
|
||||
mkdir -p plugins/acme
|
||||
mv plugins/blog plugins/acme/blog
|
||||
go work edit -dropuse ./plugins/blog
|
||||
summer plugin:add plugins/acme/blog
|
||||
```
|
||||
|
||||
`summer plugin:add` points the application's `replace` and `go.work` at the new directory; the module path and the `summer.yaml` entry stay as they were. If the plugin's own `go.mod` has a relative `replace` for the framework (or another local module), add one `../` to it, for example `../../../summercms` becomes `../../../../summercms`. Then run `summer build`.
|
||||
|
||||
## Generating plugin parts
|
||||
|
||||
The other `make:` commands add one artifact to an existing plugin. Each takes the plugin ID and an exported Go name:
|
||||
@@ -103,7 +118,7 @@ summer make:admin-controller acme.blog Posts
|
||||
When you run a command inside a plugin directory, leave the ID out and pass only the name. The tool finds the plugin from the nearest `plugin.go` above the current directory:
|
||||
|
||||
```sh
|
||||
cd plugins/blog
|
||||
cd plugins/acme/blog
|
||||
summer make:model Comment
|
||||
```
|
||||
|
||||
@@ -111,15 +126,17 @@ summer make:model Comment
|
||||
|---------|--------|-------|
|
||||
| `summer make:model` | `models/<name>.go` and `updates/<timestamp>_create_<table>.go` | The table name is the plugin ID and the plural name in snake case, such as `acme_blog_posts`. `--no-migration` skips the migration. |
|
||||
| `summer make:migration` | `updates/<timestamp>_<name>.go` | An empty gormigrate migration with up and down steps to fill in. |
|
||||
| `summer make:command` | `console/<name>.go` | A `bonfire.Command` named `<plugin>:<name>`, such as `blog:publish`. |
|
||||
| `summer make:command` | `console/<name>.go` | A `bonfire.Command` named `<plugin>:<name>`, such as `blog:publish`, from a function that takes the `*backpack.App` the plugin kept from `Boot`. A scaffolded plugin's `Commands` returns `generatedCommands(p.app)`; when `plugin.go` still calls `generatedCommands()` without the app, the command notes that the function receives `nil`. |
|
||||
| `summer make:job` | `jobs/<name>.go` | A typed job built with `conga.Job`; the plugin never imports the queue library. |
|
||||
| `summer make:admin-controller` | `controllers/<name>.go`, `controllers/<name>/config_form.yaml`, `controllers/<name>/config_list.yaml`, `models/<name>/fields.yaml`, `models/<name>/columns.yaml` | A `pact.AdminController` with WinterCMS-shaped form and list configuration. It already requires the permission `<plugin>.access_<name>`, which you declare in the plugin's `Permissions()`, and its `NewRecord` returns `nil` until you return your model, so the screens stay closed until you finish it. |
|
||||
| `summer make:admin-controller` | `controllers/<name>.go`, `controllers/<name>/config_form.yaml`, `controllers/<name>/config_list.yaml`, `models/<model>/fields.yaml`, `models/<model>/columns.yaml` | A `pact.AdminController` with WinterCMS-shaped form and list configuration for the model `<model>`: the singular of the name (`Posts` edits `Post`, with its YAML in `models/post/`), or the model `--model` names. When `models/` already declares the model, `NewRecord` returns it; otherwise it returns `nil` and the command says to run `make:model`, so the screens stay closed until you finish it. The list offers `create` and `delete` (`showCheckboxes: true`). The controller requires the permission `<plugin>.access_<name>`, which you declare in the plugin's `Permissions()`. |
|
||||
|
||||
File names are the snake-case form of the name: `AddPublishedAt` becomes `add_published_at`. Migration file names start with a 14-digit timestamp, so they sort in the order you created them. A second migration with the same name in the same second gets the next second's timestamp, but migrations with different names created in the same second share one timestamp and sort by name; check the order in `updates/`, as [Porting a plugin](../setup/porting-a-plugin.md) describes.
|
||||
File names are the snake-case form of the name: `AddPublishedAt` becomes `add_published_at`. Migration file names start with a 14-digit UTC timestamp. Each new migration gets a timestamp after every file already in `updates/`: the current second, or one second past the newest existing migration when that is not earlier. File names therefore sort in creation order, and so does the list in `registry.gen.go`, even for migrations made within one second.
|
||||
|
||||
After writing the files, every `make:` command regenerates the plugin's `registry.gen.go`, which lists the plugin's models, migrations, commands, jobs and admin controllers, and runs `go mod tidy` in the plugin. The capability methods of a scaffolded `plugin.go` return those generated lists. If your `plugin.go` was written by hand and does not call them, the command prints a note naming the accessors to add.
|
||||
Every file these commands write for you to edit starts with the line `//summer:make <kind>`, such as `//summer:make model` or `//summer:make admin-controller`. Keep it: `registry.gen.go` lists only the files that carry it, or the older `// Code generated by summer make. DO NOT EDIT.` header, which earlier versions wrote and which is still recognised. `registry.gen.go` itself keeps that header, so it is the only file linters treat as generated.
|
||||
|
||||
Each command prints `Created <path>` for every file it writes. A command refuses to overwrite an existing file or to declare a name the package already has.
|
||||
After writing the files, every `make:` command regenerates the plugin's `registry.gen.go`, which lists the plugin's models, migrations, commands, jobs and admin controllers and embeds the admin YAML (`controllers/*/*.yaml` and `models/*/*.yaml`, once they exist), and runs `go mod tidy` in the plugin. The capability methods of a scaffolded `plugin.go` return those generated lists, and its `AdminFS` (`pact.AdminAssets`) returns `generatedAdminFS()`, an empty tree until the first `make:admin-controller`. If your `plugin.go` was written by hand and does not call them, the command prints a note naming the accessors to add.
|
||||
|
||||
Each command prints `Created <path>` for every file it writes. These are success lines, so `-q` hides them; run without `-q` when a script reads the created paths. A command refuses to overwrite an existing file or to declare a name the package already has.
|
||||
|
||||
When a `make:` command cannot do its work, the error says why and what to run instead. Outside an application (no `summer.yaml` in the directory or above it) it reads:
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ SummerCMS acme v1.4.0 (framework v0.4.0)
|
||||
|
||||
The database line shows the host, port and database name, never the user or password. Pending migrations read `3 pending — run bin/acme migrate`. The admin line shows the admin URL, or `off` with the steps to add an admin controller; a warning naming `summer admin:create` follows when the `backend_users` table is empty. `-v` adds one line per plugin with the plugins it requires and its pending migrations, and lets every component's info logs through; `-q` prints no summary, only warnings and errors.
|
||||
|
||||
Off a terminal (systemd, Docker, a redirected log) nothing is printed on stdout: the same facts are logged as one `startup summary` record and one record whose message is exactly `listening on <addr>`, both from the `serve` component, so journald and the daily files keep them and scripts can wait for the second one. On a terminal the console does not repeat the two records after the block; a file channel whose level lets Info through still gets them (the default daily channel keeps only warnings and errors).
|
||||
Off a terminal (systemd, Docker, a redirected log) nothing is printed on stdout: the same facts are logged as one `startup summary` record and one record whose message is exactly `listening on <addr>`, both from the `serve` component, so journald and the daily files keep them and scripts can wait for the second one. On a terminal the console does not repeat the two records after the block; a redirected stderr keeps them, also with `--ansi` or `FORCE_COLOR`; a file channel whose level lets Info through still gets them (the default daily channel keeps only warnings and errors).
|
||||
|
||||
### Shutting down
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ order: 40
|
||||
---
|
||||
# Writing commands
|
||||
|
||||
A plugin adds console commands to the application binary the way a WinterCMS plugin calls `registerConsoleCommand`. In SummerCMS a command is a plain `bonfire.Command` value, and the plugin returns its commands from `pact.HasCommands`. `summer make:command acme.blog Publish` generates a starting point in `console/publish.go`.
|
||||
A plugin adds console commands to the application binary the way a WinterCMS plugin calls `registerConsoleCommand`. In SummerCMS a command is a plain `bonfire.Command` value, and the plugin returns its commands from `pact.HasCommands`. `summer make:command acme.blog Publish` generates a starting point in `console/publish.go`: a `PublishCommand(app *backpack.App) bonfire.Command` function. A command that needs the database, the config or a service another plugin publishes reaches it through `app`, for example with `backpack.App.Lookup`.
|
||||
|
||||
## Defining a command
|
||||
|
||||
@@ -14,11 +14,11 @@ A `bonfire.Command` has a name, a description, its positional arguments and flag
|
||||
|
||||
- The name is in `namespace:verb` form, such as `blog:publish`. Plugin commands must use this form; only a few framework commands have bare names.
|
||||
- Each `bonfire.Arg` is a positional argument with a name, a description and a `Required` marker. The usage line shows required arguments as `<name>` and optional ones as `[name]`.
|
||||
- Each `bonfire.Flag` is a string flag. Set `bonfire.Flag.Bare` for a switch such as `--dry-run` that stores `true` when given alone, and `bonfire.Flag.Repeatable` for a flag that can be given several times.
|
||||
- Each `bonfire.Flag` is a string flag. Set `bonfire.Flag.Bare` for a switch such as `--dry-run` that stores `true` when given alone, and read it with `bonfire.FlagBool`, which takes the values `strconv.ParseBool` accepts, so `--dry-run=1` and `--dry-run=false` work too. Set `bonfire.Flag.Repeatable` for a flag that can be given several times.
|
||||
- `bonfire.Command.Run` receives the context, a `bonfire.Input` and a `bonfire.Output`.
|
||||
- `bonfire.Command.Long` is optional extended help, shown under "Help:" by `help <command>` and `<command> --help`. `bonfire.Command.Examples` and `bonfire.Command.Hidden` are described below. Set `bonfire.Command.Passthrough` for a command that forwards to another program: flag parsing is turned off, so every argument after the command name, including `--help`, `-h` and `--`, reaches `bonfire.Input.Args` unchanged. Declared flags then appear in the usage text only, and declared arguments are not count-checked.
|
||||
|
||||
Read arguments with `bonfire.Input.Argument`, scalar and bare flags with `bonfire.Input.Flag`, and repeatable flags with `bonfire.Input.Flags`, which returns the values in the order given:
|
||||
Read arguments with `bonfire.Input.Argument`, scalar flags with `bonfire.Input.Flag`, bare switches with `bonfire.FlagBool`, and repeatable flags with `bonfire.Input.Flags`, which returns the values in the order given:
|
||||
|
||||
```go src=modules/bonfire/example_test.go#ExampleCatalog
|
||||
publish := bonfire.Command{
|
||||
@@ -31,8 +31,8 @@ publish := bonfire.Command{
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
slug, _ := in.Argument("slug")
|
||||
dryRun, _ := in.Flag("dry-run")
|
||||
out.Printf("publish %s, tags %v, dry run %s\n", slug, in.Flags("tag"), dryRun)
|
||||
dryRun := bonfire.FlagBool(in, "dry-run")
|
||||
out.Printf("publish %s, tags %v, dry run %v\n", slug, in.Flags("tag"), dryRun)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -50,7 +50,7 @@ if err := catalog.Call(context.Background(), "blog:publish", args, os.Stdout); e
|
||||
|
||||
Return the commands from the plugin's `Commands` method, which implements `pact.HasCommands`. The generated `main` appends every plugin's commands after the framework's runtime commands. Command names must be unique: when two commands share a name, or a command is named `list` or `help`, `bonfire.NewRoot` fails with `bonfire.ErrDuplicateCommand` naming the command, and the binary does not start. Keep your commands in your plugin's own namespace, such as `blog:`. The global options are reserved as well: a flag named `verbose`, `quiet`, `ansi`, `no-ansi`, `no-interaction`, `env` or `help`, or with the shorthand `v`, `q`, `n` or `h`, makes `bonfire.NewRoot` fail with `bonfire.ErrReservedFlag`.
|
||||
|
||||
A scaffolded plugin's `Commands` method returns the generated list of everything in `console/`, so commands created with `summer make:command` are registered without editing `plugin.go`.
|
||||
A scaffolded plugin keeps the application in `Boot`, and its `Commands` method returns `generatedCommands(p.app)`, the generated list of the commands in `console/`, so commands created with `summer make:command` are registered without editing `plugin.go`. The list holds every function in a file that starts with `//summer:make` that returns a `bonfire.Command` and takes no parameter or a single `*backpack.App`; the generated code passes it the application. `Commands` is collected after `Boot`, so the application is set by then. A command function with other parameters is returned from `Commands` by hand. In an older `plugin.go` that calls `generatedCommands()` without the application, the generated functions receive `nil`; `summer make:command` prints a note when it finds that call.
|
||||
|
||||
## Output
|
||||
|
||||
|
||||
@@ -95,7 +95,9 @@ Originals in JPEG, PNG, GIF and WebP can be thumbnailed. The thumbnailer cannot
|
||||
|
||||
The client file name never reaches a blob key. The disk name is 22 random hexadecimal characters plus an extension, and the client's lower-cased extension must be one to ten letters or digits from the allowed list: `Limits.Extensions`, or, when that is empty, `attach.DefaultImageExtensions` in image mode (jpg, jpeg, png, gif and webp) and `attach.DefaultFileExtensions` otherwise. The file list is WinterCMS's default list without the types that can carry script: svg, js, map, css, less, scss, swf and xml. A refused extension returns `attach.ErrFileType`.
|
||||
|
||||
The disk name's extension follows the content, not the client's name, because a web server or CDN in front of the bucket picks the Content-Type from the extension. `attach.DiskExtension` derives it 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ WinterCMS plugins keep their schema in the `updates/` directory and list the ste
|
||||
summer make:migration acme.blog AddPublishedAt
|
||||
```
|
||||
|
||||
The file is `updates/<timestamp>_add_published_at.go`, with a migration ID that starts with the same timestamp. `summer build` generates the plugin's list of migrations in file name order, so the timestamp is also the order in which they run. The scaffolded plugin returns that generated list from its `Migrations` method.
|
||||
The file is `updates/<timestamp>_add_published_at.go`, with a migration ID that starts with the same timestamp. The timestamp is always later than every migration already in `updates/`, even when you create several in one second. `summer build` generates the plugin's list of migrations in file name order, so the timestamp is also the order in which they run. The scaffolded plugin returns that generated list from its `Migrations` method.
|
||||
|
||||
A migration has an ID, a `Migrate` function and a `Rollback` function, both given the transaction to run in. Write DDL as SQL with `tx.Exec`: the migration then says exactly what the database gets, and it keeps working when the model struct changes later. This is the whole set of an example plugin, written out by hand:
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ return lagoon.OnDatabase(app, func(_ *sql.DB, gdb *gorm.DB) error {
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> Register such a callback with a `Before("gorm:commit_or_rollback_transaction")` constraint, as above. lagoon runs a single-statement write's after-commit work from its own callback right after that commit step; a callback that GORM sorts after it buffers work that is never run, without an error.
|
||||
> Register such a callback with a `Before("gorm:commit_or_rollback_transaction")` constraint, as above. lagoon runs a single-statement write's after-commit work from its own callback right after that commit step; a callback that GORM sorts after it buffers work that is never run. The write still succeeds, and lagoon logs a warning when that happens.
|
||||
|
||||
## Boot-time work that needs the database
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Code generated by summer make. DO NOT EDIT.
|
||||
//summer:make command
|
||||
|
||||
package console
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Code generated by summer make. DO NOT EDIT.
|
||||
//summer:make admin-controller
|
||||
|
||||
package controllers
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: acme.blog::lang.posts.post
|
||||
form: ~/plugins/acme/blog/models/posts/fields.yaml
|
||||
form: ~/plugins/acme/blog/models/post/fields.yaml
|
||||
modelClass: Post
|
||||
defaultRedirect: acme/blog/posts
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
list: ~/plugins/acme/blog/models/posts/columns.yaml
|
||||
list: ~/plugins/acme/blog/models/post/columns.yaml
|
||||
modelClass: Post
|
||||
title: acme.blog::lang.posts.title
|
||||
recordUrl: acme/blog/posts/update/:id
|
||||
|
||||
@@ -62,7 +62,7 @@ func TestPostsControllerDeclaration(t *testing.T) {
|
||||
if list.ModelClass != "Post" || list.DefaultSort == nil {
|
||||
t.Errorf("list = %+v, want modelClass Post with a default sort", list)
|
||||
}
|
||||
for _, name := range []string{"controllers/posts/config_form.yaml", "controllers/posts/config_list.yaml", "models/posts/fields.yaml", "models/posts/columns.yaml"} {
|
||||
for _, name := range []string{"controllers/posts/config_form.yaml", "controllers/posts/config_list.yaml", "models/post/fields.yaml", "models/post/columns.yaml"} {
|
||||
if _, err := fs.Stat(fsys, name); err != nil {
|
||||
t.Errorf("AdminFS is missing %s: %v", name, err)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Code generated by summer make. DO NOT EDIT.
|
||||
//summer:make model
|
||||
|
||||
package models
|
||||
|
||||
|
||||
@@ -38,9 +38,6 @@ var langFS embed.FS
|
||||
//go:embed views/mail
|
||||
var mailFS embed.FS
|
||||
|
||||
//go:embed controllers/*/*.yaml models/*/*.yaml
|
||||
var adminFS embed.FS
|
||||
|
||||
// Plugin is the acme.blog plugin, the Go form of Plugin.php.
|
||||
type Plugin struct {
|
||||
app *backpack.App
|
||||
@@ -72,15 +69,16 @@ func (p *Plugin) AdminControllers() []pact.AdminController {
|
||||
return generatedAdminControllers()
|
||||
}
|
||||
|
||||
// Commands returns the generated commands plus blog:publish, which needs the
|
||||
// database and so is built here with the plugin's withDB.
|
||||
// Commands returns the generated commands, which receive the application
|
||||
// kept in Boot, plus blog:publish. That one takes the narrower withDB, which
|
||||
// its tests replace, so it is added here by hand.
|
||||
func (p *Plugin) Commands() []bonfire.Command {
|
||||
return append(generatedCommands(), console.PublishCommand(p.withDB))
|
||||
return append(generatedCommands(p.app), console.PublishCommand(p.withDB))
|
||||
}
|
||||
|
||||
// AdminFS is the admin YAML the controllers read: controllers/posts and
|
||||
// models/posts.
|
||||
func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
// AdminFS is the admin YAML the controllers read, controllers/posts and
|
||||
// models/post, which registry.gen.go embeds.
|
||||
func (p *Plugin) AdminFS() fs.FS { return generatedAdminFS() }
|
||||
|
||||
// Permissions replaces registerPermissions().
|
||||
func (p *Plugin) Permissions() []pact.Permission {
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
package blog
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
|
||||
"git.golem15.com/golem15/summercms/docs/examples/blog/controllers"
|
||||
"git.golem15.com/golem15/summercms/docs/examples/blog/models"
|
||||
"git.golem15.com/golem15/summercms/docs/examples/blog/updates"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
@@ -24,7 +28,9 @@ func generatedMigrations() []*gormigrate.Migration {
|
||||
}
|
||||
}
|
||||
|
||||
func generatedCommands() []bonfire.Command {
|
||||
// generatedCommands returns the plugin's console commands. Commands that
|
||||
// take a *backpack.App receive the first app passed in, or nil when none is.
|
||||
func generatedCommands(app ...*backpack.App) []bonfire.Command {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -37,3 +43,16 @@ func generatedAdminControllers() []pact.AdminController {
|
||||
controllers.PostsController(),
|
||||
}
|
||||
}
|
||||
|
||||
// generatedAdminTree holds the admin YAML of the plugin: the controller
|
||||
// configuration and the model fields and columns. Go evaluates the go:embed
|
||||
// globs at compile time, so YAML files added later in these directories are
|
||||
// included; the next make: command rewrites the line when a glob starts or
|
||||
// stops matching a file.
|
||||
//
|
||||
//go:embed controllers/*/*.yaml models/*/*.yaml
|
||||
var generatedAdminTree embed.FS
|
||||
|
||||
// generatedAdminFS returns the admin YAML for pact.AdminAssets: an empty tree
|
||||
// until the first admin controller is scaffolded.
|
||||
func generatedAdminFS() fs.FS { return generatedAdminTree }
|
||||
|
||||
@@ -39,14 +39,14 @@ func TestScaffoldLayout(t *testing.T) {
|
||||
if _, err := build.MakeMigration(ctx, app, "acme.blog", "AddPublishedAt"); err != nil {
|
||||
t.Fatalf("MakeMigration: %v", err)
|
||||
}
|
||||
if _, err := build.MakeAdminController(ctx, app, "acme.blog", "Posts"); err != nil {
|
||||
if _, err := build.MakeAdminController(ctx, app, "acme.blog", "Posts", ""); err != nil {
|
||||
t.Fatalf("MakeAdminController: %v", err)
|
||||
}
|
||||
if _, err := build.MakeCommand(ctx, app, "acme.blog", "Publish"); err != nil {
|
||||
t.Fatalf("MakeCommand: %v", err)
|
||||
}
|
||||
|
||||
scaffolded := fileSet(t, filepath.Join(app, "plugins", "blog"), func(rel string) bool {
|
||||
scaffolded := fileSet(t, filepath.Join(app, "plugins", "acme", "blog"), func(rel string) bool {
|
||||
return rel != "go.mod" && rel != "go.sum"
|
||||
})
|
||||
walkthrough := fileSet(t, ".", func(rel string) bool {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Code generated by summer make. DO NOT EDIT.
|
||||
//summer:make migration
|
||||
|
||||
package updates
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Code generated by summer make. DO NOT EDIT.
|
||||
//summer:make migration
|
||||
|
||||
package updates
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Plugins are the foundation of every SummerCMS application. A plugin adds models,
|
||||
|
||||
Every plugin has an ID in `vendor.plugin` form: two lower-case parts, each starting with a letter and containing only letters and digits, such as `acme.blog`. The ID is how the manifest lists the plugin, how other plugins require it and how its configuration is namespaced (`acme.blog.posts_per_page`). WinterCMS writes the same identifier as `Acme.Blog`; in SummerCMS it is always lower case.
|
||||
|
||||
The scaffolder derives the package and directory name from the second part, so `summer make:plugin acme.blog` creates `plugins/blog` with `package blog`.
|
||||
The scaffolder puts the plugin in a directory per vendor and names the package after the second part, so `summer make:plugin acme.blog` creates `plugins/acme/blog` with `package blog`, and `acme.blog` and `pixelpixel.blog` can sit side by side.
|
||||
|
||||
## The plugin type
|
||||
|
||||
@@ -104,11 +104,11 @@ Configuration defaults, translations and mail templates ship inside the binary t
|
||||
`summer make:plugin acme.blog` writes a plugin that compiles and follows the WinterCMS directory layout, with each directory as a Go subpackage:
|
||||
|
||||
```text
|
||||
plugins/blog/
|
||||
plugins/acme/blog/
|
||||
├── go.mod the plugin module, requiring the framework
|
||||
├── plugin.go the Plugin type, its capabilities and init registration
|
||||
├── routes.go the Routes method
|
||||
├── registry.gen.go generated lists of models, migrations, commands, jobs and admin controllers
|
||||
├── registry.gen.go generated lists of models, migrations, commands, jobs and admin controllers, and the embedded admin YAML
|
||||
├── classes/ services and hooks
|
||||
├── config/config.yaml default configuration
|
||||
├── console/ console commands
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user