From 860c41d829c5847d0537d27e6047d3543bde285e Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 28 Oct 2026 21:51:41 +0100 Subject: [PATCH] docs(quick-batch-261028-rqa): 6 framework items complete, close their todos, record follow-ups --- .planning/STATE.md | 6 + .../18.1-SECURITY.md | 2 +- .planning/quick-batches/261028-rqa/BATCH.json | 212 +++++++++++ .../261028-rqb-PLAN.md | 312 ++++++++++++++++ .../261028-rqb-SUMMARY.md | 94 +++++ .../261028-rqc-PLAN.md | 197 ++++++++++ .../261028-rqc-SUMMARY.md | 102 +++++ .../261028-rqd-PLAN.md | 346 +++++++++++++++++ .../261028-rqd-SUMMARY.md | 158 ++++++++ .../261028-rqe-PLAN.md | 352 ++++++++++++++++++ .../261028-rqe-SUMMARY.md | 108 ++++++ .../261028-rqf-PLAN.md | 198 ++++++++++ .../261028-rqf-SUMMARY.md | 83 +++++ .../261028-rqg-PLAN.md | 209 +++++++++++ .../261028-rqg-SUMMARY.md | 81 ++++ ...pen-apps-when-a-new-version-is-deployed.md | 2 + ...et-plugin-boot-logs-on-console-commands.md | 2 + ...2026-10-22-console-review-info-findings.md | 2 + ...-10-28-attach-store-public-content-type.md | 2 + .../admin-action-audit-log.md | 2 + .../roles-screen-holders-list.md | 2 + ...10-28-attach-static-handler-disposition.md | 21 ++ ...earlier-phase-gates-name-consuming-apps.md | 1 + ...10-28-plugins-log-boot-details-at-debug.md | 21 ++ 24 files changed, 2514 insertions(+), 1 deletion(-) create mode 100644 .planning/quick-batches/261028-rqa/BATCH.json create mode 100644 .planning/quick/261028-rqb-admin-spa-reload-on-deploy-todo-planning-todos-pending-2026/261028-rqb-PLAN.md create mode 100644 .planning/quick/261028-rqb-admin-spa-reload-on-deploy-todo-planning-todos-pending-2026/261028-rqb-SUMMARY.md create mode 100644 .planning/quick/261028-rqc-quiet-plugin-boot-logs-on-console-commands-like-help-framewo/261028-rqc-PLAN.md create mode 100644 .planning/quick/261028-rqc-quiet-plugin-boot-logs-on-console-commands-like-help-framewo/261028-rqc-SUMMARY.md create mode 100644 .planning/quick/261028-rqd-console-review-info-findings-in-01-in-13-and-the-delegate-si/261028-rqd-PLAN.md create mode 100644 .planning/quick/261028-rqd-console-review-info-findings-in-01-in-13-and-the-delegate-si/261028-rqd-SUMMARY.md create mode 100644 .planning/quick/261028-rqe-attach-store-writes-non-inline-types-with-a-non-rendering-di/261028-rqe-PLAN.md create mode 100644 .planning/quick/261028-rqe-attach-store-writes-non-inline-types-with-a-non-rendering-di/261028-rqe-SUMMARY.md create mode 100644 .planning/quick/261028-rqf-audit-log-successful-admin-action-runs-with-the-admin-id-tod/261028-rqf-PLAN.md create mode 100644 .planning/quick/261028-rqf-audit-log-successful-admin-action-runs-with-the-admin-id-tod/261028-rqf-SUMMARY.md create mode 100644 .planning/quick/261028-rqg-show-the-administrators-holding-a-role-on-the-roles-screen-t/261028-rqg-PLAN.md create mode 100644 .planning/quick/261028-rqg-show-the-administrators-holding-a-role-on-the-roles-screen-t/261028-rqg-SUMMARY.md rename .planning/todos/{pending => completed}/2026-10-11-reload-open-apps-when-a-new-version-is-deployed.md (94%) rename .planning/todos/{pending => completed}/2026-10-21-quiet-plugin-boot-logs-on-console-commands.md (91%) rename .planning/todos/{pending => completed}/2026-10-22-console-review-info-findings.md (97%) rename .planning/todos/{pending => completed}/2026-10-28-attach-store-public-content-type.md (80%) rename .planning/todos/{pending => completed}/admin-action-audit-log.md (85%) rename .planning/todos/{pending => completed}/roles-screen-holders-list.md (81%) create mode 100644 .planning/todos/pending/2026-10-28-attach-static-handler-disposition.md create mode 100644 .planning/todos/pending/2026-10-28-plugins-log-boot-details-at-debug.md diff --git a/.planning/STATE.md b/.planning/STATE.md index 5f942b2a..f8dca4f5 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -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 | diff --git a/.planning/phases/18.1-payment-gateway-subscriptions/18.1-SECURITY.md b/.planning/phases/18.1-payment-gateway-subscriptions/18.1-SECURITY.md index 04a8ac01..91cd1a17 100644 --- a/.planning/phases/18.1-payment-gateway-subscriptions/18.1-SECURITY.md +++ b/.planning/phases/18.1-payment-gateway-subscriptions/18.1-SECURITY.md @@ -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 | diff --git a/.planning/quick-batches/261028-rqa/BATCH.json b/.planning/quick-batches/261028-rqa/BATCH.json new file mode 100644 index 00000000..cd12e330 --- /dev/null +++ b/.planning/quick-batches/261028-rqa/BATCH.json @@ -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 + } + ] +} diff --git a/.planning/quick/261028-rqb-admin-spa-reload-on-deploy-todo-planning-todos-pending-2026/261028-rqb-PLAN.md b/.planning/quick/261028-rqb-admin-spa-reload-on-deploy-todo-planning-todos-pending-2026/261028-rqb-PLAN.md new file mode 100644 index 00000000..dc001804 --- /dev/null +++ b/.planning/quick/261028-rqb-admin-spa-reload-on-deploy-todo-planning-todos-pending-2026/261028-rqb-PLAN.md @@ -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\":\"\"}" + - "The served index.html carries \">: 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\\(" +--- + + +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 .` 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. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@/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. + + + + + + Task 1: End-to-end build id, from the boardwalk meta, header and build.json to the SPA watch and the reload banner + 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 + + - 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) + + +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 `), the main module (`main `), each of the settings `vcs.revision` and `vcs.modified` that is present (`setting =`, in that fixed key order), and one line per dependency (`dep `, followed by ` => ` 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 ``, 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 `` 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`: `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 `` 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 `/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. + + + 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 + + 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. + + + + Task 2: Reload once on a Vite chunk-load error, guarded by a sessionStorage timestamp + admin/src/app/chunkReload.ts, admin/src/main.ts, docs/backend/admin-spa.md + + - 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) + + +- 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)`. + + + 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 + + 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. + + + + Task 3: Unit tests for the build id, banner and chunk guard; rebuild and commit modules/boardwalk/dist + 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/ + admin/node_modules exists; if it does not, run npm --prefix admin ci 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 + + + - 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 + + +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. + + + 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 + + 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. + + + + + +## 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 | + + + +- 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 + + + +- 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 + + + +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. + diff --git a/.planning/quick/261028-rqb-admin-spa-reload-on-deploy-todo-planning-todos-pending-2026/261028-rqb-SUMMARY.md b/.planning/quick/261028-rqb-admin-spa-reload-on-deploy-todo-planning-todos-pending-2026/261028-rqb-SUMMARY.md new file mode 100644 index 00000000..894a111f --- /dev/null +++ b/.planning/quick/261028-rqb-admin-spa-reload-on-deploy-todo-planning-todos-pending-2026/261028-rqb-SUMMARY.md @@ -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) diff --git a/.planning/quick/261028-rqc-quiet-plugin-boot-logs-on-console-commands-like-help-framewo/261028-rqc-PLAN.md b/.planning/quick/261028-rqc-quiet-plugin-boot-logs-on-console-commands-like-help-framewo/261028-rqc-PLAN.md new file mode 100644 index 00000000..3cc49d1b --- /dev/null +++ b/.planning/quick/261028-rqc-quiet-plugin-boot-logs-on-console-commands-like-help-framewo/261028-rqc-PLAN.md @@ -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/ 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/ with no command word, list, list --format=json, help, help , 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" +--- + + +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. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@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. + + +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. + + + + + + + Task 1: End-to-end "serve --help boots with the console at warn" through the unchanged generated main + modules/diary/helponly.go, modules/diary/diary.go, modules/diary/bootquiet_smoke_test.go, modules/diary/README.md, docs/services/logging.md + + - 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. + + +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 ` --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. + + + 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 + + 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. + + + + Task 2: list, help, no command and completion requests boot quietly too; docs for the behaviour + 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 + + - 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. + + +Tests first (RED), then extend `helpOnly` (GREEN). Expansion of Task 1's single path: after this task `bin/ list`, `bin/ help migrate`, a bare `bin/` 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=` 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. + + + 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 + + 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. + + + + + +## 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. | + + + +- `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/ serve --help` shows only the help, `bin/ -v serve --help` shows the boot lines first, `bin/ serve` logs them as before. + + + +- 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. + + + +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`). + diff --git a/.planning/quick/261028-rqc-quiet-plugin-boot-logs-on-console-commands-like-help-framewo/261028-rqc-SUMMARY.md b/.planning/quick/261028-rqc-quiet-plugin-boot-logs-on-console-commands-like-help-framewo/261028-rqc-SUMMARY.md new file mode 100644 index 00000000..c805faa6 --- /dev/null +++ b/.planning/quick/261028-rqc-quiet-plugin-boot-logs-on-console-commands-like-help-framewo/261028-rqc-SUMMARY.md @@ -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= and -h= 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/ 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 diff --git a/.planning/quick/261028-rqd-console-review-info-findings-in-01-in-13-and-the-delegate-si/261028-rqd-PLAN.md b/.planning/quick/261028-rqd-console-review-info-findings-in-01-in-13-and-the-delegate-si/261028-rqd-PLAN.md new file mode 100644 index 00000000..7f464f79 --- /dev/null +++ b/.planning/quick/261028-rqd-console-review-info-findings-in-01-in-13-and-the-delegate-si/261028-rqd-PLAN.md @@ -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\\)" +--- + + +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): (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 -- `, then `git commit -m "…" -- `), 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: : …` 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. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@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 -- `. 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 `), 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. + + + + + + Task 1: bonfire root, flags and errors: IN-08, IN-03, IN-06, IN-02, IN-04 (five commits, plus one planning-docs commit) + 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 + + - 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 "�". "\xffok" gives "�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. + + +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, "�") 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. + + + 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' + + 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. + + + + Task 2: live views and diary: IN-11, IN-10, IN-12, IN-01, IN-07 (five commits) + 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 + + - 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. + + +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. + + + 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' + + 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. + + + + 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) + 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 + + - 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. + + +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 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 `", 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 : set `. 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. + + + 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' + + 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. + + + + + +## 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 | + + + +- `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). + + + +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). + + + +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) + diff --git a/.planning/quick/261028-rqd-console-review-info-findings-in-01-in-13-and-the-delegate-si/261028-rqd-SUMMARY.md b/.planning/quick/261028-rqd-console-review-info-findings-in-01-in-13-and-the-delegate-si/261028-rqd-SUMMARY.md new file mode 100644 index 00000000..f78747f6 --- /dev/null +++ b/.planning/quick/261028-rqd-console-review-info-findings-in-01-in-13-and-the-delegate-si/261028-rqd-SUMMARY.md @@ -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: : …` 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 : set `, 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`. diff --git a/.planning/quick/261028-rqe-attach-store-writes-non-inline-types-with-a-non-rendering-di/261028-rqe-PLAN.md b/.planning/quick/261028-rqe-attach-store-writes-non-inline-types-with-a-non-rendering-di/261028-rqe-PLAN.md new file mode 100644 index 00000000..898e1c31 --- /dev/null +++ b/.planning/quick/261028-rqe-attach-store-writes-non-inline-types-with-a-non-rendering-di/261028-rqe-PLAN.md @@ -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 .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\\(" +--- + + +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. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@.planning/STATE.md +@./CLAUDE.md +@.planning/todos/pending/2026-10-28-attach-store-public-content-type.md + + +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. + + + + + + + Task 1: attach.IsInlineContentType and the attachment disposition on Store's blob write (tracer through real blob drivers) + modules/lagoon/attach/inline.go, modules/lagoon/attach/inline_test.go, modules/lagoon/attach/store.go, modules/lagoon/attach/thumb_test.go + + - 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) + + + - 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. + + +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. + + + 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/... + + + - `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. + + 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. + + + + Task 2: Postgres-backed Store assertion, lagoon README and storage/attachments docs; commit Tasks 1 and 2 + modules/lagoon/attach/store_test.go, modules/lagoon/README.md, docs/services/storage.md, docs/database/attachments.md + 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. + + - 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) + + + - 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 .txt, as well as the docx/zip, ics/text, html, svg/xml, both js, pdf and mkv/webm cases. + + +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 .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). + + + 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 + + + - `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. + + 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. + + + + Task 3: surf uploads route takes its inline types from attach.IsInlineContentType + modules/surf/uploads.go, modules/surf/README.md, docs/services/storage.md + + - 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) + + +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. + + + 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 + + + - `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. + + 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. + + + + + +## 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 | + + + +- 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. + + + +- 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. + + + +Create `.planning/quick/261028-rqe-attach-store-writes-non-inline-types-with-a-non-rendering-di/261028-rqe-SUMMARY.md` when done + diff --git a/.planning/quick/261028-rqe-attach-store-writes-non-inline-types-with-a-non-rendering-di/261028-rqe-SUMMARY.md b/.planning/quick/261028-rqe-attach-store-writes-non-inline-types-with-a-non-rendering-di/261028-rqe-SUMMARY.md new file mode 100644 index 00000000..2e1a00c0 --- /dev/null +++ b/.planning/quick/261028-rqe-attach-store-writes-non-inline-types-with-a-non-rendering-di/261028-rqe-SUMMARY.md @@ -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 diff --git a/.planning/quick/261028-rqf-audit-log-successful-admin-action-runs-with-the-admin-id-tod/261028-rqf-PLAN.md b/.planning/quick/261028-rqf-audit-log-successful-admin-action-runs-with-the-admin-id-tod/261028-rqf-PLAN.md new file mode 100644 index 00000000..24e715b3 --- /dev/null +++ b/.planning/quick/261028-rqf-audit-log-successful-admin-action-runs-with-the-admin-id-tod/261028-rqf-PLAN.md @@ -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=, 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\\(\\)\\)" +--- + + +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). + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@.planning/STATE.md +@./CLAUDE.md +@.planning/todos/pending/admin-action-audit-log.md + + +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). + + + + + + + Task 1: Log successful toolbar and widget action runs with the admin id, controller, action and kind + modules/cabana/action_run_log_test.go, modules/cabana/actions.go, modules/cabana/README.md, docs/backend/users-and-permissions.md + + - 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 + + + - 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=, 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= 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. + + +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. + + + 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 + + + - `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. + + 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. + + + + + +## 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 | + + + +- 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. + + + +- 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). + + + +Create `.planning/quick/261028-rqf-audit-log-successful-admin-action-runs-with-the-admin-id-tod/261028-rqf-SUMMARY.md` when done + diff --git a/.planning/quick/261028-rqf-audit-log-successful-admin-action-runs-with-the-admin-id-tod/261028-rqf-SUMMARY.md b/.planning/quick/261028-rqf-audit-log-successful-admin-action-runs-with-the-admin-id-tod/261028-rqf-SUMMARY.md new file mode 100644 index 00000000..ef306ec4 --- /dev/null +++ b/.planning/quick/261028-rqf-audit-log-successful-admin-action-runs-with-the-admin-id-tod/261028-rqf-SUMMARY.md @@ -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 diff --git a/.planning/quick/261028-rqg-show-the-administrators-holding-a-role-on-the-roles-screen-t/261028-rqg-PLAN.md b/.planning/quick/261028-rqg-show-the-administrators-holding-a-role-on-the-roles-screen-t/261028-rqg-PLAN.md new file mode 100644 index 00000000..5d117a49 --- /dev/null +++ b/.planning/quick/261028-rqg-show-the-administrators-holding-a-role-on-the-roles-screen-t/261028-rqg-PLAN.md @@ -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 /summercms/cabana/users//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 /api/v1/summercms/cabana/roles/partials/users?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)" +--- + + +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. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@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}/_.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). + + + + + + Task 1: End-to-end holders list on the Roles update form (partial + phrases + docs + smoke) + 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 + +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="` with rootToken, expect 200, and assert that `fmt.Sprint(body["data"])` contains `holder.Login` and `/summercms/cabana/users//preview`. + +Commit code, template, phrases, README, docs and smoke together as one `feat(cabana): ... (quick-261028-rqg)` commit. + + + 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 + + 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. + + + + Task 2: Unit and Postgres tests for the holders list (D-03, links, states, cap) + modules/cabana/backend_screens_test.go, modules/cabana/roles_postgres_test.go, modules/cabana/README.md + + - 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 `` 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//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". + + +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. + + + 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 ./... + + 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. + + + + + +## 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. | + + + +- `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. + + + +- 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. + + + +Create `.planning/quick/261028-rqg-show-the-administrators-holding-a-role-on-the-roles-screen-t/261028-rqg-SUMMARY.md` when done. + diff --git a/.planning/quick/261028-rqg-show-the-administrators-holding-a-role-on-the-roles-screen-t/261028-rqg-SUMMARY.md b/.planning/quick/261028-rqg-show-the-administrators-holding-a-role-on-the-roles-screen-t/261028-rqg-SUMMARY.md new file mode 100644 index 00000000..22b4c2a3 --- /dev/null +++ b/.planning/quick/261028-rqg-show-the-administrators-holding-a-role-on-the-roles-screen-t/261028-rqg-SUMMARY.md @@ -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 diff --git a/.planning/todos/pending/2026-10-11-reload-open-apps-when-a-new-version-is-deployed.md b/.planning/todos/completed/2026-10-11-reload-open-apps-when-a-new-version-is-deployed.md similarity index 94% rename from .planning/todos/pending/2026-10-11-reload-open-apps-when-a-new-version-is-deployed.md rename to .planning/todos/completed/2026-10-11-reload-open-apps-when-a-new-version-is-deployed.md index df2d8851..bc2d24a1 100644 --- a/.planning/todos/pending/2026-10-11-reload-open-apps-when-a-new-version-is-deployed.md +++ b/.planning/todos/completed/2026-10-11-reload-open-apps-when-a-new-version-is-deployed.md @@ -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. diff --git a/.planning/todos/pending/2026-10-21-quiet-plugin-boot-logs-on-console-commands.md b/.planning/todos/completed/2026-10-21-quiet-plugin-boot-logs-on-console-commands.md similarity index 91% rename from .planning/todos/pending/2026-10-21-quiet-plugin-boot-logs-on-console-commands.md rename to .planning/todos/completed/2026-10-21-quiet-plugin-boot-logs-on-console-commands.md index 880a0d2e..766c35b1 100644 --- a/.planning/todos/pending/2026-10-21-quiet-plugin-boot-logs-on-console-commands.md +++ b/.planning/todos/completed/2026-10-21-quiet-plugin-boot-logs-on-console-commands.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. diff --git a/.planning/todos/pending/2026-10-22-console-review-info-findings.md b/.planning/todos/completed/2026-10-22-console-review-info-findings.md similarity index 97% rename from .planning/todos/pending/2026-10-22-console-review-info-findings.md rename to .planning/todos/completed/2026-10-22-console-review-info-findings.md index 3ccd06cc..973aa26e 100644 --- a/.planning/todos/pending/2026-10-22-console-review-info-findings.md +++ b/.planning/todos/completed/2026-10-22-console-review-info-findings.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. diff --git a/.planning/todos/pending/2026-10-28-attach-store-public-content-type.md b/.planning/todos/completed/2026-10-28-attach-store-public-content-type.md similarity index 80% rename from .planning/todos/pending/2026-10-28-attach-store-public-content-type.md rename to .planning/todos/completed/2026-10-28-attach-store-public-content-type.md index ff39419a..dfdfec43 100644 --- a/.planning/todos/pending/2026-10-28-attach-store-public-content-type.md +++ b/.planning/todos/completed/2026-10-28-attach-store-public-content-type.md @@ -19,3 +19,5 @@ Found by quick 261028-r16. `attach.Store` writes the sniffed content type as the - 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. diff --git a/.planning/todos/pending/admin-action-audit-log.md b/.planning/todos/completed/admin-action-audit-log.md similarity index 85% rename from .planning/todos/pending/admin-action-audit-log.md rename to .planning/todos/completed/admin-action-audit-log.md index ce0ff7cf..7590f2dc 100644 --- a/.planning/todos/pending/admin-action-audit-log.md +++ b/.planning/todos/completed/admin-action-audit-log.md @@ -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. diff --git a/.planning/todos/pending/roles-screen-holders-list.md b/.planning/todos/completed/roles-screen-holders-list.md similarity index 81% rename from .planning/todos/pending/roles-screen-holders-list.md rename to .planning/todos/completed/roles-screen-holders-list.md index e50a5b5a..0044cd72 100644 --- a/.planning/todos/pending/roles-screen-holders-list.md +++ b/.planning/todos/completed/roles-screen-holders-list.md @@ -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. diff --git a/.planning/todos/pending/2026-10-28-attach-static-handler-disposition.md b/.planning/todos/pending/2026-10-28-attach-static-handler-disposition.md new file mode 100644 index 00000000..b10a5fda --- /dev/null +++ b/.planning/todos/pending/2026-10-28-attach-static-handler-disposition.md @@ -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`). diff --git a/.planning/todos/pending/2026-10-28-earlier-phase-gates-name-consuming-apps.md b/.planning/todos/pending/2026-10-28-earlier-phase-gates-name-consuming-apps.md index c235f37e..9ac5c4bf 100644 --- a/.planning/todos/pending/2026-10-28-earlier-phase-gates-name-consuming-apps.md +++ b/.planning/todos/pending/2026-10-28-earlier-phase-gates-name-consuming-apps.md @@ -3,6 +3,7 @@ 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 diff --git a/.planning/todos/pending/2026-10-28-plugins-log-boot-details-at-debug.md b/.planning/todos/pending/2026-10-28-plugins-log-boot-details-at-debug.md new file mode 100644 index 00000000..70f2483c --- /dev/null +++ b/.planning/todos/pending/2026-10-28-plugins-log-boot-details-at-debug.md @@ -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.