Compare commits
139 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee5b8844bf | ||
|
|
1e9927fa4b | ||
|
|
6267edb7c5 | ||
|
|
eaeb2347c0 | ||
|
|
4141cd916f | ||
|
|
b1fba7e24c | ||
|
|
139f5e3942 | ||
|
|
f5e9ee508b | ||
|
|
a48afbd11e | ||
|
|
0691d26f1f | ||
|
|
57a849d114 | ||
|
|
17c722da46 | ||
|
|
707372bf65 | ||
|
|
fce232d7c3 | ||
|
|
1ac75ad59a | ||
|
|
48e13f9b57 | ||
|
|
7c1509581b | ||
|
|
a28957f7d0 | ||
|
|
36e2240891 | ||
|
|
9516a2b36f | ||
|
|
9563bc836a | ||
|
|
28ad561481 | ||
|
|
0ebed687a0 | ||
|
|
349e2f810b | ||
|
|
ead8e42211 | ||
|
|
cf061857a9 | ||
|
|
8020b177ff | ||
|
|
89c8325ac4 | ||
|
|
d311d029c3 | ||
|
|
1bf64b009f | ||
|
|
020fe838ea | ||
|
|
a9025c0d12 | ||
|
|
39677f1819 | ||
|
|
c586c6f8a9 | ||
|
|
9b62285654 | ||
|
|
fc7706dfff | ||
|
|
9c89705747 | ||
|
|
323413c741 | ||
|
|
d151a252c5 | ||
|
|
86e4e270d3 | ||
|
|
b077dc3598 | ||
|
|
e8f5b1c89c | ||
|
|
dd123821d2 | ||
|
|
0b1d7e5a79 | ||
|
|
6c2164ad38 | ||
|
|
8bacc22a85 | ||
|
|
2ca68f897d | ||
|
|
295c1c1ee7 | ||
|
|
a7431c34f0 | ||
|
|
72b8383c10 | ||
|
|
2baec56420 | ||
|
|
6720caebd3 | ||
|
|
fd5286884e | ||
|
|
eeeee59ead | ||
|
|
7b58ad5fa5 | ||
|
|
9641a1be59 | ||
|
|
efb043e4ff | ||
|
|
db83a14056 | ||
|
|
24f4643283 | ||
|
|
9b2fa6c324 | ||
|
|
b906a31c36 | ||
|
|
54a58ec7cc | ||
|
|
2e4f85fc50 | ||
|
|
053c643527 | ||
|
|
77cf27eb24 | ||
|
|
fff19e5e33 | ||
|
|
a37bdbc4eb | ||
|
|
a0ad2b5fa3 | ||
|
|
39c0c42db7 | ||
|
|
336ec2e0b6 | ||
|
|
2ad7f539e3 | ||
|
|
25ee333930 | ||
|
|
5c474b4a4f | ||
|
|
c8cbcd5cc7 | ||
|
|
c1f5ded59d | ||
|
|
99b6df44d6 | ||
|
|
9ab28e1d27 | ||
|
|
fdc89b107d | ||
|
|
08a52890c6 | ||
|
|
ff4e0fa7ad | ||
|
|
f1fa9e05ba | ||
|
|
d3323efbe2 | ||
|
|
ff0e3a6c69 | ||
|
|
8af7c22cfc | ||
|
|
074a8f250e | ||
|
|
e819f7b865 | ||
|
|
5ddb23d241 | ||
|
|
20ecc4167d | ||
|
|
b57d2e109d | ||
|
|
8c7794efa2 | ||
|
|
7e4387311e | ||
|
|
c2c8c6a2f9 | ||
|
|
30e0cd983d | ||
|
|
4895b250e1 | ||
|
|
205764300e | ||
|
|
7170c5bac6 | ||
|
|
33460607db | ||
|
|
9d06a78f62 | ||
|
|
168d65cd1b | ||
|
|
c38dc1fe48 | ||
|
|
f0ee26f39f | ||
|
|
128d1985a1 | ||
|
|
3f38862b22 | ||
|
|
cb38c493ce | ||
|
|
fb32886775 | ||
|
|
80fae5c279 | ||
|
|
8d4d7ce7a7 | ||
|
|
b7c07591cb | ||
|
|
7dbf6d1a45 | ||
|
|
44291adcca | ||
|
|
71b99317ad | ||
|
|
e6b20e2cb6 | ||
|
|
5b09b32512 | ||
|
|
e903cc32be | ||
|
|
e6039b445f | ||
|
|
bde7d9fcb7 | ||
|
|
deff424c4f | ||
|
|
a999055cbf | ||
|
|
b73f9d971f | ||
|
|
85f0814e0a | ||
|
|
cdd647243c | ||
|
|
f38713f540 | ||
|
|
1d5dc364dd | ||
|
|
cdddaa384f | ||
|
|
09fd4d0712 | ||
|
|
e830298862 | ||
|
|
2c2ff81abd | ||
|
|
52f17e41fc | ||
|
|
18598c17d7 | ||
|
|
63a85a8a65 | ||
|
|
6b8f0600c8 | ||
|
|
671d388ca1 | ||
|
|
b4c0e0d2a2 | ||
|
|
bc5a9dbc17 | ||
|
|
877bf0a425 | ||
|
|
0a87d64d4e | ||
|
|
7f7f5fc171 | ||
|
|
ecc77a3f6b | ||
|
|
c996790808 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,6 +1,10 @@
|
||||
# Go
|
||||
/bin/
|
||||
/examples/hello/bin/
|
||||
|
||||
# Application log files (diary daily channel)
|
||||
/storage/
|
||||
/examples/hello/storage/
|
||||
/dist/
|
||||
/site/
|
||||
*.test
|
||||
|
||||
@@ -46,5 +46,7 @@ A new phase, inserted or added, gets its task when it is added to `ROADMAP.md`.
|
||||
| 14 | cwlaryop | 30 | 0rkcboo0 |
|
||||
| 14.1 | v0j5lwvq | 31 | hgol7otp |
|
||||
| | | 19.4.1 | g02hcroj |
|
||||
| | | 19.6.1 | hioyvbxa |
|
||||
| | | 19.8 | t9k2efpy |
|
||||
|
||||
Backlog phase 999.1 (newsletter) has no task until it is promoted into the roadmap.
|
||||
|
||||
@@ -63,6 +63,10 @@ Validated in Phase 19.4: OIDC provider on wristband
|
||||
|
||||
- [x] `wristband` has an OpenID Connect flavour (discovery, JWKS with RS256/ES256 and key rotation, id_token with nonce and at_hash, userinfo, resumable authorize with consent and account switching, pairwise subjects, opt-in refresh), and the new sm-oauthserver-plugin hosts it with PHP-path routes, branded consent and error pages, Clients and Branding admin screens and a Winter import that keeps kids, tokens and secrets; `scripts/check-phase19.4.sh` is the fail-closed gate (UAT and security 2026-10-20). PHP parity recording deferred to the WavePath cutover (D-14 amended)
|
||||
|
||||
Validated in Phase 19.5: Social login
|
||||
|
||||
- [x] The new sm-sociallogin-plugin signs users in with Google, GitHub, Microsoft and Facebook through PHP's `/oauth/{provider}` paths, with a provider registry other plugins extend, the SPA one-time-code handoff, the pending-registration branch and linking through a one-time link ticket; a verified email auto-links only onto an account that proved its address (`users.email_verified_at` in sm-user-plugin, a deliberate divergence from PHP); `scripts/check-phase19.5.sh` is the fail-closed gate (UAT and security 2026-10-21). The email-proof fix (sm-user-plugin v0.1.5, sm-sociallogin-plugin v0.1.2) is released with the 19.8 release
|
||||
|
||||
### Active
|
||||
|
||||
Framework kernel
|
||||
@@ -202,4 +206,4 @@ This document evolves at phase transitions and milestone boundaries.
|
||||
4. Update Context with current state
|
||||
|
||||
---
|
||||
*Last updated: 2026-10-20 after Phase 19.4 completion*
|
||||
*Last updated: 2026-10-21 after Phase 19.5 completion*
|
||||
|
||||
@@ -39,9 +39,11 @@ Decimal phases appear between their surrounding integers in numeric order.
|
||||
- [ ] **Phase 19.3: Framework transport and infrastructure for WavePath** - Cookie refresh, credentialed CORS, cache and Redis rate limits, S3 option, OpenAPI page, Turnstile
|
||||
- [x] **Phase 19.4: OIDC provider on wristband** - Discovery, JWKS with key rotation, id_token, userinfo, consent and pairwise subjects (completed 2026-10-20)
|
||||
- [ ] **Phase 19.4.1: Early public demo on demo.summercms.io** - sm-demo-app with today's shared plugins on rome: open registration, guided tour, journal, bm's presentation system, quizzes, payments; backend by request
|
||||
- [ ] **Phase 19.5: Social login in sm-user-plugin** - Google and Facebook login with the SPA one-time-code handoff
|
||||
- [x] **Phase 19.5: Social login in sm-user-plugin** - Google and Facebook login with the SPA one-time-code handoff (completed 2026-10-21)
|
||||
- [ ] **Phase 19.6: Two-factor auth and device flow in sm-user-plugin** - TOTP, WebAuthn, email and recovery codes, trusted devices, device authorization
|
||||
- [ ] **Phase 19.6.1: Backend Administrators screen** - Admins and roles CRUD in cabana, WinterCMS-style, with server-side privilege rules
|
||||
- [ ] **Phase 19.7: Generic MCP module** - Opt-in MCP endpoint, plugin-declared tools filtered by permissions, one connector per person via OAuth and a verified account link
|
||||
- [ ] **Phase 19.8: Console commands overhaul** - `diary` logging module, Artisan-grade global flags, help and `list` in `bonfire`, sweep of every command
|
||||
- [ ] **Phase 20: Demo instance with every shared plugin** - Add the chat family (chat, channels, video calls, chatbots, forum, herald, friends) to the live demo from Phase 19.4.1
|
||||
- [ ] **Phase 30: Płytarium cutover** - Parity harness green, both real clients run unchanged in production
|
||||
- [ ] **Phase 31: Chat polish against WavePath clients** - Replay WavePath's chat traffic through `tide` against the ported chat plugins until green
|
||||
@@ -1362,20 +1364,23 @@ Plans:
|
||||
3. Identities are linked on login and listed and unlinked through the existing identity routes. Both applications that consume the plugin still build and pass their tests.
|
||||
4. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 1/4 plans executed
|
||||
**Plans:** 5/5 plans complete
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
- [x] 19.5-01-PLAN.md — Foundations: goth decision note, `cooler.Cache.Pull` with README and docs, sm-user-plugin hooks (provider-list source, row-locked PHP unlink rule, sign-in helpers, user:backfill-handles opens its DB), human goth package check (wave 1)
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [ ] 19.5-02-PLAN.md — New repo sm-sociallogin-plugin: fake-provider tracer, PHP matching and pending registration, link ticket and request validation, Google/Facebook/GitHub/Microsoft through goth with a settings override, release go-ahead and pushes in D-21 order (wave 2)
|
||||
- [x] 19.5-02-PLAN.md — New repo sm-sociallogin-plugin: fake-provider tracer, PHP matching and pending registration, link ticket and request validation, Google/Facebook/GitHub/Microsoft through goth with a settings override, release go-ahead and pushes in D-21 order (wave 2)
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [ ] 19.5-03-PLAN.md — Adoption: sm-demo-app mount next to the OIDC provider, sm-bm-app Microsoft 365 (backend, `/oauth` reserved, frontend callback, consent and link views), fonoteka.go green at the bumped user-plugin pin (wave 3)
|
||||
- [x] 19.5-03-PLAN.md — Adoption: sm-demo-app mount next to the OIDC provider, sm-bm-app Microsoft 365 (backend, `/oauth` reserved, frontend callback, consent and link views), fonoteka.go green at the bumped user-plugin pin (wave 3)
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [ ] 19.5-04-PLAN.md — Unit tests for every decision, PHP social-auth test map, security review, scripts/check-phase19.5.sh (wave 4)
|
||||
- [x] 19.5-04-PLAN.md — Unit tests for every decision, PHP social-auth test map, security review, scripts/check-phase19.5.sh (wave 4)
|
||||
|
||||
**Wave 5** *(gap closure, blocked on Wave 4 completion)*
|
||||
- [x] 19.5-05-PLAN.md — Gap closure (D-26..D-28): `users.email_verified_at` proof in sm-user-plugin, auto-link only onto a proven account in sociallogin, bm OAuthCallbackView vitest test, gate extended, release v0.1.5 / v0.1.2 and pointer bumps under the go-ahead (wave 5)
|
||||
|
||||
### Phase 19.6: Two-factor auth and device flow in sm-user-plugin (INSERTED)
|
||||
|
||||
@@ -1391,15 +1396,49 @@ Plans:
|
||||
3. Device authorization (`devices/initiate`, `devices/status/{token}`, `devices/authorize`, and the device list and revoke) works on the `DeviceAuthSession` model.
|
||||
4. The models TwoFactorMethod, TwoFactorChallenge, TwoFactorRecoveryCode, TrustedDevice, WebAuthnCredential and DeviceAuthSession and their migrations are ported. The phase gets a security review. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 0 plans
|
||||
**Plans:** 4 plans
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run /msd-plan-phase 19.6 to break down)
|
||||
**Wave 1**
|
||||
- [ ] 19.6-01-PLAN.md — Foundations: decision note for pquerna/otp, go-webauthn and boombuler/barcode, sm-user-plugin seams (login gates in Login with the restore after the gate, login and password-changed events, reset-2FA slot and permission), device authorization flow with the JTI followed through refresh, human package check (wave 1)
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [ ] 19.6-02-PLAN.md — New repo sm-mfa-plugin: TOTP tracer through the challenge, recovery and email codes with the D-18 hardening, trusted devices, settings screen, revocation on password change, admin reset service (wave 2)
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [ ] 19.6-03-PLAN.md — WebAuthn second factor and passwordless passkeys, social sign-in through the gate, demo 2FA screens in vue-demo-app, release go-ahead and publishing in D-17 order, adoption in sm-demo-app and sm-bm-app, fonoteka.go pointer and guard tests (wave 3)
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [ ] 19.6-04-PLAN.md — Unit tests for every decision, login-path invariant, PHP security test map, security review with the explicit D-14 section, scripts/check-phase19.6.sh, gated release of post-release fixes (wave 4)
|
||||
|
||||
### Phase 19.6.1: Backend Administrators screen (INSERTED)
|
||||
|
||||
**Goal:** cabana gets WinterCMS's Administrators screens, so backend admins and their roles are managed in the admin SPA instead of only through `admin:*` console commands. Kept short (1-2 plans): it reuses cabana's CRUD, permission field and record actions, and follows the sm-user-plugin Users/Groups screens and WinterCMS `Backend\Controllers\Users`/`UserRoles`.
|
||||
**Depends on:** Phase 8 (cabana); 19.7 builds on it (the frontend-link record action lands on this screen)
|
||||
**Repos:** `summercms.go`
|
||||
**Requirements**: TBD
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. An admin with the new manage-administrators permission lists, creates, edits and deactivates backend users (login, email, name, role, password set/reset) and edits roles with the permission editor; existing cabana payloads and `admin:*` commands are unchanged.
|
||||
2. Privilege rules hold on the server: only a superuser can create, edit or grant superuser; an admin cannot raise their own role or permissions; the last active superuser cannot be deactivated, demoted or deleted.
|
||||
3. The record-action seam on the Administrators list is usable by plugins (19.7's link action), and unit tests cover the privilege rules.
|
||||
|
||||
**Plans:** 3 plans
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
- [ ] 19.6.1-01-PLAN.md — server side: the pact action-extension seam, the internal summercms.cabana pseudo-plugin with the Administrators and Roles screens, every privilege rule in the save transaction (superuser hiding, ceilings, self-edit, outranking, last-superuser lock, revocation), lang keys and docs
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [ ] 19.6.1-02-PLAN.md — SPA support (D-21): per-record locked permission values and the field `trigger`, regenerated OpenAPI and schema.d.ts, rebuilt modules/boardwalk/dist
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [ ] 19.6.1-03-PLAN.md — unit, Postgres and SPA tests for every rule (incl. the held-lock last-superuser race), the seam and D-21, plus the check-phase19.6.1.sh gate
|
||||
|
||||
### Phase 19.7: Generic MCP module (INSERTED)
|
||||
|
||||
**Goal:** A SummerCMS application can expose its plugins' APIs as an opt-in MCP endpoint inside the binary. Plugins declare their tools in Go, and an editor connects one MCP client once to reach both frontend-user tools and the backend tools of their linked admin account.
|
||||
**Depends on:** Phase 8; todos `2026-10-17-plugin-registered-frontend-permissions` and `2026-10-17-frontend-backend-account-link`
|
||||
**Depends on:** Phase 8, Phase 19.6.1; todos `2026-10-17-plugin-registered-frontend-permissions` and `2026-10-17-frontend-backend-account-link`
|
||||
**Repos:** `summercms.go`, `sm-user-plugin`
|
||||
**Design:** `.planning/notes/2026-10-17-generic-mcp-module.md`
|
||||
**Requirements**: TBD
|
||||
@@ -1415,6 +1454,39 @@ Plans:
|
||||
Plans:
|
||||
- [ ] TBD (run /msd-plan-phase 19.7 to break down)
|
||||
|
||||
### Phase 19.8: Console commands overhaul (INSERTED)
|
||||
|
||||
**Goal:** Console commands in `summer` and in application binaries reach the bar set by Laravel's Artisan. The command logic stays; what changes is logging, the shared console layer in `bonfire`, and every command's help and output. A user sees what a command did, can ask for more or less of it, and learns from `--help` how to use it without opening the docs. Example of today's gap: `serve` prints raw `slog` lines from plugins and River mixed with styled `ℹ` lines, says nothing about what it booted (plugins, migrations state, mail driver, cache, realtime, scheduler), and has no way to see more or less. Logging today is one hard-coded `slog.NewTextHandler` at INFO in `sunscreen.Install`, with no level, format or destination config.
|
||||
**Mode:** mvp
|
||||
**Depends on:** none (framework `diary`, `bonfire` and the commands that modules and plugins register; can run in parallel with the 19.x phases; added as Phase 32 on 2026-10-20 and renumbered 2026-10-21)
|
||||
**Repos:** `summercms.go`; shared plugin repos only for their commands' help text and logger names, without breaking changes to command names or flags; `sm-summercmsio-app` / `vue-summercmsio-app` for the landing site and roadmap presentation
|
||||
**Planned shape:** 4 plans: (1) `diary` logging module, (2) `bonfire` global flags, help and `list`, (3) sweep of every command, (4) unit tests. Docs, landing-site and roadmap-presentation updates ride along or go to `/msd-quick` tasks.
|
||||
**Requirements**: TBD
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. A new `diary` module (beach-themed README, root table row) owns the application's logging, modelled on Laravel's `config/logging.php`: `log.default`, `log.level`, `log.channels` and `log.components` config keys, channels for stderr, a `stack` of several channels, a daily file with retention and JSON output, a console handler that renders records in `bonfire`'s style with a component prefix, and named component loggers. `sunscreen` redaction wraps every channel; `sunscreen.Install` keeps working as a compatibility shim. Daily files under `storage/logs/` are on by default. The console uses the charm stack (lipgloss, bubbles/bubbletea, huh, fang) after SSU's terminal UX, with a decision note for the new dependencies before code.
|
||||
2. `bonfire` provides global flags on every command, modelled on Artisan: `-v`/`-vv`/`-vvv`, `-q`/`--quiet`, `--no-ansi`/`--ansi`, `-n`/`--no-interaction` and `--env`. Verbosity sets the `diary` level at runtime, so framework and third-party logs (River, plugins) are hidden or formatted consistently at the default level and shown in full at `-vv`.
|
||||
3. Every command has a description, a long help with usage examples, and documented arguments and options with defaults. `list` output is grouped by namespace like `php artisan list`, and `summer help <command>` and `<binary> help <command>` work. A coverage check reports registered commands lacking a description or examples: it fails for the framework's own commands and warns in plugin repos.
|
||||
4. A sweep of all existing commands (framework modules and shared plugins) checks them against an Artisan feature checklist and fixes the gaps found, without changing command logic or breaking existing names and flags. The checklist covers confirmation with `--force` on destructive actions, `--pretend`/dry-run where it applies, progress bars and tables for bulk work, and `--json` output for scripting. Long-running commands such as `serve` and `queue:work` print a startup summary of what booted (environment, plugins, database and migrations state, queues, scheduler, mail, cache and realtime drivers, URLs) and report what they did on shutdown. One-shot commands end with a result line and a meaningful exit code.
|
||||
5. The `diary` and `bonfire` READMEs, the docs console and logging pages, the summercms.io landing site and its roadmap presentation reflect the new console. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 6/6 plans executed
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
- [x] 19.8-01-PLAN.md — charm-stack decision note, then the `diary` module (component loggers, Laravel-shaped channels, daily files, redaction) wired into the generated main; River, GORM and framework modules routed through it
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [x] 19.8-02-PLAN.md — `bonfire`: global flags, Artisan list and help, Examples/Hidden, JSON listing, CheckDocs, styled errors with hints and exit codes, duplicate-name rejection, SSU widgets on charm v2
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [x] 19.8-03-PLAN.md — sweep of every command (checklist, hints, serve/queue:work/dev summaries and reports, http request log), docs, landing and roadmap deck; release checkpoint, then plugin commits and app pointer bumps
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [x] 19.8-04-PLAN.md — unit and golden tests for all new code, `scripts/check-phase19.8.sh` gate across repos, post-release fixes go-ahead
|
||||
- [x] 19.8-05-PLAN.md — gap closure: fixes for the code review's critical and warning findings (live-view output order, summer delegates' exit codes and terminal, Ctrl+C cursor, dev shutdown grace, diary file retry, escape stripping, migration sets), each with a pinning test
|
||||
- [x] 19.8-06-PLAN.md — gap closure: v0.4.2 release go-ahead, tag and changelog, demo roadmap sync, full gate
|
||||
|
||||
### Phase 20: Demo instance with every shared plugin
|
||||
|
||||
**Goal:** `sm-demo-app` runs every shared Golem15 plugin collected so far in one binary, and its public Nuxt site shows them working: chat and channels with realtime, video calls, presentations, quizzes, journal, FAQ, subscriptions and the rest. The admin backend is not public; the site carries a call to action to contact Golem15 for a backend tour. Phase 19.4.1 deploys the demo early with the plugins available then; this phase adds the chat family to that live instance.
|
||||
@@ -1469,31 +1541,10 @@ Plans:
|
||||
Plans:
|
||||
- [ ] TBD (run /msd-plan-phase 31 to break down)
|
||||
|
||||
### Phase 32: Console commands overhaul
|
||||
|
||||
**Goal:** Console commands in `summer` and in application binaries reach the bar set by Laravel's Artisan. The command logic stays; what changes is logging, the shared console layer in `bonfire`, and every command's help and output. A user sees what a command did, can ask for more or less of it, and learns from `--help` how to use it without opening the docs. Example of today's gap: `serve` prints raw `slog` lines from plugins and River mixed with styled `ℹ` lines, says nothing about what it booted (plugins, migrations state, mail driver, cache, realtime, scheduler), and has no way to see more or less. Logging today is one hard-coded `slog.NewTextHandler` at INFO in `sunscreen.Install`, with no level, format or destination config.
|
||||
**Mode:** mvp
|
||||
**Depends on:** none (framework `diary`, `bonfire` and the commands that modules and plugins register; can run in parallel with the 19.x phases)
|
||||
**Repos:** `summercms.go`; shared plugin repos only for their commands' help text and logger names, without breaking changes to command names or flags; `sm-summercmsio-app` / `vue-summercmsio-app` for the landing site and roadmap presentation
|
||||
**Planned shape:** 4 plans: (1) `diary` logging module, (2) `bonfire` global flags, help and `list`, (3) sweep of every command, (4) unit tests. Docs, landing-site and roadmap-presentation updates ride along or go to `/msd-quick` tasks.
|
||||
**Requirements**: TBD
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. A new `diary` module (beach-themed README, root table row) owns the application's logging, modelled on Laravel's `config/logging.php`: `log.level` and `log.channel` config keys, channels for stderr, a `stack` of several channels, a daily file with retention and JSON output, a console handler that renders records in `bonfire`'s style with a component prefix, and named component loggers. `sunscreen` redaction wraps every channel; `sunscreen.Install` keeps working as a compatibility shim. Standard library only, or a decision note for any new dependency.
|
||||
2. `bonfire` provides global flags on every command, modelled on Artisan: `-v`/`-vv`/`-vvv`, `-q`/`--quiet`, `--no-ansi`/`--ansi`, `-n`/`--no-interaction` and `--env`. Verbosity sets the `diary` level at runtime, so framework and third-party logs (River, plugins) are hidden or formatted consistently at the default level and shown in full at `-vv`.
|
||||
3. Every command has a description, a long help with usage examples, and documented arguments and options with defaults. `list` output is grouped by namespace like `php artisan list`, and `summer help <command>` and `<binary> help <command>` work. A test fails when a registered command lacks a description or examples.
|
||||
4. A sweep of all existing commands (framework modules and shared plugins) checks them against an Artisan feature checklist and fixes the gaps found, without changing command logic or breaking existing names and flags. The checklist covers confirmation with `--force` on destructive actions, `--pretend`/dry-run where it applies, progress bars and tables for bulk work, and `--json` output for scripting. Long-running commands such as `serve` and `queue:work` print a startup summary of what booted (environment, plugins, database and migrations state, queues, scheduler, mail, cache and realtime drivers, URLs) and report what they did on shutdown. One-shot commands end with a result line and a meaningful exit code.
|
||||
5. The `diary` and `bonfire` READMEs, the docs console and logging pages, the summercms.io landing site and its roadmap presentation reflect the new console. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 0 plans
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run /msd-plan-phase 32 to break down)
|
||||
|
||||
## Progress
|
||||
|
||||
**Execution Order:**
|
||||
Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12 → 13 → 14 → 14.1 → 14.2 → 14.2.1 → 14.3 → 15 → 16 → 17 → 18 → 18.1 → 19 → 19.1 → 19.3 → 19.4 → 19.4.1 → 19.5 → 19.6 → 19.7 → 20 → (21 to 29, added as planned) → 30 → 31
|
||||
Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12 → 13 → 14 → 14.1 → 14.2 → 14.2.1 → 14.3 → 15 → 16 → 17 → 18 → 18.1 → 19 → 19.1 → 19.3 → 19.4 → 19.4.1 → 19.5 → 19.6 → 19.7 → 19.8 → 20 → (21 to 29, added as planned) → 30 → 31
|
||||
(Phase 2 depends on Phase 1's command kernel; the two are no longer parallel.)
|
||||
|
||||
| Phase | Plans Complete | Status | Completed |
|
||||
@@ -1535,13 +1586,14 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
|
||||
| 19.3. Framework transport and infrastructure for WavePath | 6/6 | In Progress| |
|
||||
| 19.4. OIDC provider on wristband | 4/4 | Complete | 2026-10-20 |
|
||||
| 19.4.1. Early public demo on demo.summercms.io | 5/5 | In Progress| |
|
||||
| 19.5. Social login in sm-user-plugin | 1/4 | In Progress| |
|
||||
| 19.5. Social login in sm-user-plugin | 5/5 | Complete | 2026-10-21 |
|
||||
| 19.6. Two-factor auth and device flow in sm-user-plugin | 0/TBD | Not started | - |
|
||||
| 19.6.1. Backend Administrators screen (INSERTED) | 0/TBD | Not started | - |
|
||||
| 19.7. Generic MCP module | 0/TBD | Not started | - |
|
||||
| 19.8. Console commands overhaul (INSERTED) | 6/6 | In Progress| |
|
||||
| 20. Demo instance with every shared plugin | 0/TBD | Not started | - |
|
||||
| 30. Płytarium cutover | 0/TBD | Not started | - |
|
||||
| 31. Chat polish against WavePath clients | 0/TBD | Not started | - |
|
||||
| 32. Console commands overhaul | 0/TBD | Not started | - |
|
||||
|
||||
## Backlog
|
||||
|
||||
|
||||
81
.planning/debug/user-logout-session-stays-valid.md
Normal file
81
.planning/debug/user-logout-session-stays-valid.md
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
status: awaiting_human_verify
|
||||
trigger: "User plugin logout: auth cookies not invalidated at once; requests keep authenticating ~60 min until token expiry (todo 2026-10-22-user-logout-cookie-stays-valid)"
|
||||
created: 2026-10-22T00:00:00Z
|
||||
updated: 2026-10-22T00:00:00Z
|
||||
---
|
||||
|
||||
## Current Focus
|
||||
|
||||
bug_class: Bohrbug (deterministic)
|
||||
reasoning_checkpoint:
|
||||
hypothesis: "Logout reads only the bearer header (cookie sessions get 401, nothing revoked, cookie kept, jwt guard accepts the cookie until exp = 60 min), never writes a forget cookie, and blacklists the jti only until the access exp (so /refresh revives a logged-out token after exp + sweep)"
|
||||
confirming_evidence:
|
||||
- "probe: cookie-only logout 401, then GET /tokens with the cookie 200"
|
||||
- "probe: jwt_blacklist.expires_at == exp; after the sweep, /refresh of the logged-out token 200"
|
||||
- "bearer logout already refuses the bearer at once (guard checks blacklist) - so server-side check exists; the gap is what logout revokes and for how long"
|
||||
falsification_test: "if cookie-only logout after the fix still leaves GET /tokens 200, or /refresh after the sweep still 200, hypothesis/fix wrong"
|
||||
fix_rationale: "read the token exactly as the jwt guard does, revoke it (and any other session token the request carries) until the refresh window closes, accept an expired-but-refreshable token like cabana does, and expire the session cookies with auth.cookie attributes"
|
||||
blind_spots: "demo frontend never calls /logout (app-side, not fixable here); auth_token cookie is frontend-owned, its real attributes unknown (forget uses auth.cookie path/domain)"
|
||||
candidate_causes:
|
||||
- "code: Logout bearer-only extraction + exp-only blacklist expiry"
|
||||
- "config: blacklist store not published (no DB) -> no revocation; ruled out, Boot publishes PostgresBlacklist when *sql.DB/*gorm.DB exist and bearer logout works"
|
||||
- "environment/frontend: demo frontend logout only clears its own cookie and never calls /logout"
|
||||
and_gate: "no for the cookie case (extraction alone suffices); the refresh revival is an independent second defect"
|
||||
next_action: human verification in a real app (cookie-only logout, then a jwt.auth request with the old cookie must be 401); after confirmation archive to resolved/ and add the KB entry
|
||||
|
||||
## Symptoms
|
||||
|
||||
expected: after POST /_user/api/v1/logout the old token/cookie is refused (401) on the next request, and the cookies are cleared
|
||||
actual: requests keep authenticating for about 60 minutes, until the token expires
|
||||
errors: none
|
||||
reproduction: login, logout, repeat an authenticated request with the old cookie or token
|
||||
started: reported 2026-10-22
|
||||
|
||||
## Eliminated
|
||||
|
||||
## Evidence
|
||||
|
||||
- timestamp: 2026-10-22
|
||||
checked: bouncer jwtGuard.Authenticate (summercms.go modules/bouncer/jwt.go)
|
||||
found: access JWTs are checked for HS256 signature, exp, audience, the subject user (GormUsers.FindByID), the jti blacklist (BlacklistStore) and users.tokens_valid_after; token taken from bearer first, then cookies "token", "auth_token" (plugin.go Boot)
|
||||
implication: server-side revocation exists (jwt_blacklist); a blacklisted jti is refused on the next request
|
||||
- timestamp: 2026-10-22
|
||||
checked: controllers.Logout (api_controller.go)
|
||||
found: reads bearerToken(r) only (Authorization header); no cookie fallback; never writes a Set-Cookie; blacklists with Add(jti, exp, now) i.e. storage expiry = access exp
|
||||
implication: cookie-only logout => 401, nothing revoked, cookie kept; blacklist row is dropped after exp although the refresh window (20160 min) is still open
|
||||
- timestamp: 2026-10-22
|
||||
checked: sociallogin writeSession (token_cookie: true) and bouncer.CookieConfig.TokenCookie
|
||||
found: sets httpOnly `token` cookie (auth.cookie.* name/path/domain/samesite, Max-Age = refresh_ttl) and leaves token out of the body, so the browser cannot send a bearer header
|
||||
implication: such a frontend can only log out with the cookie => H1 path
|
||||
- timestamp: 2026-10-22
|
||||
checked: bouncer.refreshAudience comment and blacklist storage expiry
|
||||
found: Refresh stores the old jti until max(exp, iat+refreshTTL)+1min precisely so "a logged-out token cannot be refreshed again"; Logout does not
|
||||
implication: H2 plausible
|
||||
- timestamp: 2026-10-22
|
||||
checked: PHP ApiController::logout + AuthManager::logout(true)
|
||||
found: token from Bearer or jwt_token param; JWTAuth::invalidate(forceForever=true) => blacklisted forever; body {"message":"Logged out"}
|
||||
implication: Go logout should keep the jti blocked at least for the refresh window
|
||||
|
||||
- timestamp: 2026-10-22
|
||||
checked: cabana admin logout (summercms.go modules/cabana/auth.go logout)
|
||||
found: framework reference: always expires the cookie, reads bearer then cookie, VerifyRefreshableClaimsAudience, blacklists until max(exp, iat+refreshTTL)+1min
|
||||
implication: the plugin's logout lags the framework's own pattern
|
||||
- timestamp: 2026-10-22
|
||||
checked: Docker probe through the assembled router (newAdminEnv), pre-fix
|
||||
found: (A) cookie-only POST /logout -> 401 {"error":true,"message":"Unauthorized"}, no Set-Cookie; GET /tokens (jwt.auth) with the same cookie afterwards -> 200; token exp-iat = 1h0m0s. (B) bearer logout -> 200 and the bearer is refused at once (401), but jwt_blacklist.expires_at == token exp; the sweep after exp deletes the row and POST /refresh with the logged-out token -> 200 with a fresh token
|
||||
implication: H1 and H2 CONFIRMED. New test logout_test.go fails 7/8 subtests pre-fix (bearer passes)
|
||||
|
||||
## Resolution
|
||||
|
||||
root_cause: "controllers.Logout read the token only from the Authorization bearer header, although the jwt guard (and RequestUser) also accept the token/auth_token cookies: a cookie-carried session (e.g. the httpOnly token cookie sociallogin token_cookie sets) got 401 at /logout, nothing was blacklisted and no cookie was cleared, so the cookie kept authenticating jwt.auth routes until exp (golem15.user.jwt.ttl, 60 min). Logout also never wrote a forget cookie; and it (and EndRequestSession) kept the jwt_blacklist row only until the access exp, so after the sweep /refresh revived a logged-out token for the rest of the 20160-min refresh window."
|
||||
fix: "classes.EndSession (new export): revokes every session JWT the request carries (bearer, token, auth_token) that verifies - unexpired, or user-audience within its refresh window - until max(exp, iat+refresh_ttl)+1m; always writes the auth.cookie forget cookie plus forgets for other carried session cookies with the same attributes; returns the guard-order subject. Logout uses it (responses unchanged: 200 Logged out / 401 Unauthorized / 500); EndRequestSession is now its best-effort wrapper."
|
||||
verification:
|
||||
target_test: { result: pass, test: "logout_test.go TestLogoutEndsTheSession (10 subtests); TestEndRequestSession updated" }
|
||||
mutation_check: { result: pass, reason_if_skipped: "no Stryker for Go - manual mutants", mutant_killed: "7/7 (expiry-at-exp, bearer-only, no-forget, no-refreshable-verify, primary-only revoke, config-cookie-only forget, no legacy fallback)" }
|
||||
no_op_deletion: { result: pass, deletion_justified_by_rca: true }
|
||||
adjacent_tests: { result: pass, suites_run: ["sm-user-plugin go test ./... (Docker, all ok)", "sm-demo-app workspace: oauthserver + sociallogin go test (only TestClientRegistry/verify_secret and TestPHPMethodMap fail: they read ../summercms.go and ../fonoteka.go paths that exist only in the plugins' own repo layout; unrelated to the change)"] }
|
||||
revert_and_reconfirm: { result: pass, bug_returned_on_revert: true, fixed_on_reapply: true }
|
||||
guardrail_verdict: accepted
|
||||
commit: 358ee5d (sm-user-plugin, unpushed)
|
||||
files_changed: [classes/session_user.go, controllers/api_controller.go, session_user_test.go, logout_test.go, README.md]
|
||||
@@ -35,6 +35,6 @@ This note supersedes the zitadel/oidc row of `.planning/research/STACK.md` for O
|
||||
## Consequences
|
||||
|
||||
- The framework `go.mod` gains no module; `grep -E 'zitadel|go-jose|coreos' go.mod` stays empty.
|
||||
- `wristband` stays a protocol leaf: no ORM, no framework module, no application package. Storage, keys at rest, pages and routes belong to `sm-oauthserver-plugin` (D-01).
|
||||
- `wristband` stays a protocol leaf: no ORM, no framework module except `diary`, no application package. Logging through `diary` is required (amended 2026-10-21, phase 19.8: every framework module logs through diary components). Storage, keys at rest, pages and routes belong to `sm-oauthserver-plugin` (D-01).
|
||||
- The OIDC flavour owns every byte a relying party sees (discovery field order, redirect encoding, error bodies, headers), so parity with the PHP provider is a matter of tests and `tide` recordings, not of fighting library defaults.
|
||||
- Replacing golang-jwt later means re-checking the id_token header (`alg`, `kid`) and the string `aud` against the go-oidc interop test.
|
||||
|
||||
44
.planning/notes/2026-10-21-console-charm-stack.md
Normal file
44
.planning/notes/2026-10-21-console-charm-stack.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Decision: the console runs on charm's v2 stack, logging stays on stdlib slog
|
||||
|
||||
**Date:** 2026-10-21
|
||||
**Status:** Accepted
|
||||
**Context:** Phase 19.8 (console commands overhaul: D-01, D-02, D-05, D-08 and the CONTEXT discretion item on the console handler), 19.8-RESEARCH.md Standard Stack, Alternatives Considered, Package Legitimacy Audit and the probes quoted there
|
||||
|
||||
## Decision
|
||||
|
||||
The console layer (`modules/bonfire`) and the new logging module (`modules/diary`) adopt charm's v2 libraries on the `charm.land` vanity paths. Log routing itself stays on the standard library's `log/slog`.
|
||||
|
||||
- **Modules and versions.** All six were checked with `go list -m -versions` on 2026-10-21; each is the newest tag, so the research pins stand:
|
||||
- `charm.land/lipgloss/v2` v2.0.6: styles, layout and `lipgloss/v2/table` for list, help, errors, tables and log lines.
|
||||
- `charm.land/bubbletea/v2` v2.1.0: program runtime for the live spinner, process and progress views.
|
||||
- `charm.land/bubbles/v2` v2.2.1: `spinner` (MiniDot, the frames `bonfire` already draws), `progress` (the cyan-to-green bar), `key` and `help`.
|
||||
- `charm.land/huh/v2` v2.0.3: Ask, Confirm, Choice and Secret on an interactive terminal, with accessible mode.
|
||||
- `charm.land/fang/v2` v2.0.1: cobra error rendering hook and Windows VT enabling.
|
||||
- `github.com/charmbracelet/colorprofile` v0.4.3: imported directly for `colorprofile.Writer` and `colorprofile.Detect` (already a transitive dependency of all of the above).
|
||||
- **v2, not SSU's v1 pins.** SSU (`/media/nvme/dev/pxpx/ssu`) pins `github.com/charmbracelet/lipgloss` v1.0.0, `bubbletea` v1.3.0 and `bubbles` v0.20.0. fang v1 already depends on a `charm.land/lipgloss/v2` beta, so v1 widgets plus any fang would compile two lipgloss majors into every binary, and the v1 line is maintenance-only. SSU's widgets are ported with the v2 renames: `View()` returns `tea.View`, key events are `tea.KeyPressMsg`, the progress gradient is `progress.WithColors` / `WithDefaultBlend`, and widths are set with `SetWidth`.
|
||||
- **Who imports them.** Only `modules/bonfire` (all five charm libraries plus colorprofile) and `modules/diary` (lipgloss and colorprofile) import charm modules. Plugins and other framework modules reach them through `bonfire.Output` and `diary.For`, never directly.
|
||||
- **fang is used narrowly.** `fang.Execute` runs with `fang.WithoutVersion()` (its `-v`/`--version` would collide with the verbosity flags), `fang.WithoutManpage()` (it would add a hidden `man` command to the list and to duplicate checks), `fang.WithoutCompletions()`, and `fang.WithErrorHandler` set to bonfire's own error renderer. The help and list layouts are bonfire's own Artisan layouts (D-14); fang's help func never reaches subcommands because bonfire sets a help func on every command.
|
||||
- **Color is applied at the writer.** lipgloss v2 `Render` always emits ANSI, so `--ansi`, `--no-ansi`, `NO_COLOR`, `TERM=dumb` and `FORCE_COLOR` are applied through `colorprofile.Writer` with a profile that bonfire detects.
|
||||
- **The diary console handler is our own** lipgloss-styled `slog.Handler`. charm's slog logger module (`charm.land/log/v2` v2.0.1) is rejected: it fails `testing/slogtest` (`WithGroup` treated as a prefix, empty attributes printed, attributes of empty-key groups not inlined), and it brings its own level and prefix model that duplicates diary's component levels. This settles the CONTEXT discretion item.
|
||||
- **Daily files use a stdlib writer** of about 120 lines (one file per day, `name-YYYY-MM-DD.log`, retention by days, mode 0640). `gopkg.in/natefinch/lumberjack.v2`, which SSU uses, is rejected: it rotates by size, has no per-day naming, and would need its own note.
|
||||
- **Fan-out and GORM.** The `stack` driver is the standard library's `slog.NewMultiHandler` (Go 1.26+), and GORM logs through gorm's own `logger.NewSlogLogger` (already in gorm v1.31.2). No logging library is added.
|
||||
- **Cost.** The probe measured about +2.1 MB on a stripped binary (`-trimpath -s -w`): 2,527,495 bytes for cobra and x/term alone, 4,649,223 bytes with lipgloss/table, bubbletea, the bubbles spinner and progress, huh and fang.
|
||||
- **Toolchain fit.** The module go directives are at most 1.26 (lipgloss 1.25.0, bubbletea 1.26.0, bubbles 1.25.0, huh 1.25.8, fang 1.25.0) and fit this module's `go 1.27.0`. Minimal version selection keeps `github.com/spf13/cobra` v1.10.2, `github.com/spf13/pflag` v1.0.9 and `golang.org/x/text` v0.42.0 (fang asks for older cobra and pflag).
|
||||
- **YAML rule.** `gopkg.in/yaml.v3` enters the module graph only as testify's indirect test dependency of fang and huh. Nothing in this repository imports it; YAML stays on `goccy/go-yaml`.
|
||||
- **Package legitimacy.** The MSD package-legitimacy seam rejects the Go ecosystem, so the six modules were checked by hand against proxy.golang.org (`@latest`, `.mod`, `@v/list`) and the `charm.land` go-import meta tags, whose origins are `github.com/charmbracelet/*`. Verdict: approved. fang is young (two v2 tags, repository first tagged 2025-06-18), which is acceptable because it is used only for the error hook.
|
||||
- **Install order.** Plan 19.8-01 installs `charm.land/lipgloss/v2` and `github.com/charmbracelet/colorprofile` with the code that imports them (the palette and the diary console handler); plan 19.8-02 installs bubbletea, bubbles, huh and fang with the widgets. `go mod tidy` drops requirements that no package imports, so each plan adds only what it uses.
|
||||
|
||||
**Rejected alternatives.** SSU's v1 pins were rejected for the two-majors problem above. `charm.land/log/v2` was rejected for the slogtest failures and the duplicated level model. lumberjack was rejected because it rotates by size and names files differently. A hand-written fan-out handler (SSU's `MultiHandler`) was rejected in favour of `slog.NewMultiHandler`, which clones records, honours `Enabled` and joins errors.
|
||||
|
||||
## Related dependencies
|
||||
|
||||
- **Standard library only for logging:** `log/slog` (`NewMultiHandler`, `JSONHandler`, `TextHandler`, `DiscardHandler`, `LevelVar`), `testing/slogtest` for handler conformance tests, `os` and `path/filepath` for the daily writer.
|
||||
- **gorm.io/gorm** v1.31.2 (already required) for `logger.NewSlogLogger`.
|
||||
- **github.com/spf13/cobra** v1.10.2 (already required) stays the command parser under bonfire.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Every application binary grows by about 2.1 MB once plan 19.8-02 lands the widgets.
|
||||
- Removing the stack later means rewriting bonfire's widgets and diary's console handler; D-01 and D-02 are rated costly for that reason.
|
||||
- Plugins never import charm modules directly, so a later change of styling library touches only `modules/bonfire` and `modules/diary`.
|
||||
- A check in the plan's acceptance criteria (`go list` over every package's imports) keeps the import boundary honest.
|
||||
@@ -0,0 +1,300 @@
|
||||
---
|
||||
phase: 19.5-social-login-in-sm-user-plugin
|
||||
plan: 02
|
||||
subsystem: auth
|
||||
tags: [goth, oauth, social-login, sm-sociallogin-plugin, aes-gcm, cooler, release, changelog]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 19.5-01
|
||||
provides: "goth v1.82.0 approval, cooler.Cache.Pull, sm-user-plugin OAuthProviderSource, FinishLogin, RegisterOptions.PasswordLess, PHP last-identity unlink"
|
||||
provides:
|
||||
- "New repository golem15/sm-sociallogin-plugin (private), module git.golem15.com/golem15/sm-sociallogin-plugin, package sociallogin, plugin golem15.sociallogin, tag v0.1.0"
|
||||
- "GET /oauth/{provider} and /oauth/{provider}/callback (throttle:20,1) for google, facebook, github, microsoft; sealed AES-256-GCM state cookie summer_oauth_<provider>"
|
||||
- "GET /_user/api/v1/oauth-complete, POST /_user/api/v1/oauth-register-complete, POST /_user/api/v1/oauth-link-ticket"
|
||||
- "Provider, Profile, ProviderConfig, Factory, Registry (Register, Names, Label, Build, Has); goth only in classes/providers_goth.go"
|
||||
- "Admin settings singleton golem15_sociallogin_settings (SettingsCode sociallogin) with encrypted write-only secrets overriding config per field"
|
||||
- "summercms v0.3.1 tag (pushed, changelog post), sm-user-plugin v0.1.4 tag (pushed)"
|
||||
affects: [19.5-03, 19.5-04, sm-demo-app, sm-bm-app, fonoteka.go, sm-summercmsio-app]
|
||||
|
||||
actuals:
|
||||
tokens: 46300
|
||||
tasks: 6
|
||||
commits: 6
|
||||
plan_head_before: null
|
||||
plan_head_after: adfcfbff0af65c3b0eee19ab55d02ffec13e0604
|
||||
# No ledger in summercms.go: this plan made no code commit there (only this SUMMARY).
|
||||
# Measured per repo:
|
||||
# sm-sociallogin-plugin: created by this plan, git rev-list --count master = 5 (root c15e8b9 .. adfcfbf)
|
||||
# sm-summercmsio-app: git rev-list --count 752099d..8581576 = 1 (unpushed)
|
||||
# tokens: chars/4 over the tracked files of sm-sociallogin-plugin without go.sum (185152 chars), plus a one-line script change.
|
||||
|
||||
tech-stack:
|
||||
added:
|
||||
- "github.com/markbates/goth v1.82.0 (core + google, facebook, github, azureadv2; never gothic), sm-sociallogin-plugin only"
|
||||
- "golang.org/x/oauth2 v0.36.0 (token exchange errors), sm-sociallogin-plugin only"
|
||||
patterns:
|
||||
- "Plugin-owned provider interface and Registry; third-party OAuth library wrapped in one file so no exported identifier mentions it"
|
||||
- "Per-browser OAuth state sealed with AES-256-GCM under an HKDF key from the app key, AAD the cookie name"
|
||||
- "One-time handoff codes in cooler consumed with Cache.Pull; validation before the pull so a 422 never consumes"
|
||||
- "Config defaults overridden per field by a settings singleton read per request"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-sociallogin-plugin/plugin.go
|
||||
- ../sm-sociallogin-plugin/routes.go
|
||||
- ../sm-sociallogin-plugin/config.go
|
||||
- ../sm-sociallogin-plugin/service.go
|
||||
- ../sm-sociallogin-plugin/admin_settings.go
|
||||
- ../sm-sociallogin-plugin/admin_permissions.go
|
||||
- ../sm-sociallogin-plugin/classes/provider.go
|
||||
- ../sm-sociallogin-plugin/classes/config.go
|
||||
- ../sm-sociallogin-plugin/classes/credentials.go
|
||||
- ../sm-sociallogin-plugin/classes/statecookie.go
|
||||
- ../sm-sociallogin-plugin/classes/callback.go
|
||||
- ../sm-sociallogin-plugin/classes/codes.go
|
||||
- ../sm-sociallogin-plugin/classes/flow.go
|
||||
- ../sm-sociallogin-plugin/classes/registration.go
|
||||
- ../sm-sociallogin-plugin/classes/messages.go
|
||||
- ../sm-sociallogin-plugin/classes/providers_goth.go
|
||||
- ../sm-sociallogin-plugin/controllers/redirect.go
|
||||
- ../sm-sociallogin-plugin/controllers/callback.go
|
||||
- ../sm-sociallogin-plugin/controllers/complete.go
|
||||
- ../sm-sociallogin-plugin/controllers/register_complete.go
|
||||
- ../sm-sociallogin-plugin/controllers/link_ticket.go
|
||||
- ../sm-sociallogin-plugin/controllers/helpers.go
|
||||
- ../sm-sociallogin-plugin/models/registry.go
|
||||
- ../sm-sociallogin-plugin/models/settings.go
|
||||
- ../sm-sociallogin-plugin/models/settings/fields.yaml
|
||||
- ../sm-sociallogin-plugin/updates/registry.go
|
||||
- ../sm-sociallogin-plugin/updates/202610200001_create_golem15_sociallogin_settings.go
|
||||
- ../sm-sociallogin-plugin/config/config.yaml
|
||||
- ../sm-sociallogin-plugin/lang/en/lang.yaml
|
||||
- ../sm-sociallogin-plugin/lang/pl/lang.yaml
|
||||
- ../sm-sociallogin-plugin/testmain_test.go
|
||||
- ../sm-sociallogin-plugin/harness_test.go
|
||||
- ../sm-sociallogin-plugin/fakeprovider_test.go
|
||||
- ../sm-sociallogin-plugin/tracer_smoke_test.go
|
||||
- ../sm-sociallogin-plugin/flow_smoke_test.go
|
||||
- ../sm-sociallogin-plugin/link_smoke_test.go
|
||||
- ../sm-sociallogin-plugin/providers_smoke_test.go
|
||||
- ../sm-sociallogin-plugin/settings_smoke_test.go
|
||||
- ../sm-sociallogin-plugin/README.md
|
||||
- ../sm-sociallogin-plugin/go.mod
|
||||
- ../sm-sociallogin-plugin/go.sum
|
||||
- ../sm-sociallogin-plugin/go.work
|
||||
- ../sm-sociallogin-plugin/.gitignore
|
||||
modified:
|
||||
- ../sm-summercmsio-app/scripts/changelog-post.py
|
||||
|
||||
key-decisions:
|
||||
- "Task 5 reply: release v0.3.1 (Recommended); framework v0.3.1, sm-user-plugin v0.1.4, sm-sociallogin-plugin v0.1.0, private repository"
|
||||
- "Changelog blocker reply: fix-ua (Recommended); changelog-post.py sends User-Agent summercms-changelog-post/1 (Cloudflare bans Python-urllib)"
|
||||
- "sm-user-plugin tagged v0.1.4, not the planned v0.1.2 (v0.1.2 and v0.1.3 already existed)"
|
||||
- "SettingsCode is sociallogin (cabana rejects dotted settings codes)"
|
||||
- "The pending registration code is pulled inside the final DB transaction after every 422 check; a lost race answers 410, a 422 never consumes"
|
||||
- "Inactive or banned users get the real messages (A7); Polish copy for every message, PHP Polish byte-for-byte where PHP has it"
|
||||
- "providers.microsoft.tenant defaults to empty, which falls back to common"
|
||||
|
||||
patterns-established:
|
||||
- "Plugin-owned provider interface wrapping a third-party OAuth library in a single file"
|
||||
- "Sealed per-browser state cookie with HKDF purpose label separate from lagoon's"
|
||||
|
||||
requirements-completed: [SC-1, SC-2, SC-3, D-01, D-02, D-03, D-04, D-05, D-06, D-07, D-08, D-09, D-10, D-11, D-12, D-14, D-16, D-17, D-18, D-19, D-20, D-21, D-22, D-23]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Returning user signs in through a fake provider: sealed state, identity match, one-time oauth-complete, oauth-providers lists the provider"
|
||||
requirement: "SC-1"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-sociallogin-plugin/tracer_smoke_test.go#TestTracerSmoke (Postgres)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Email matching, pending registration, account creation at the callback and at oauth-register-complete, consent, throttle, beta gate, token cookie"
|
||||
requirement: "SC-2"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-sociallogin-plugin/flow_smoke_test.go#TestFlowSmoke (5 subtests, Postgres)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Linking through the link ticket and bearer; start-request errors land on the SPA"
|
||||
requirement: "SC-3"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-sociallogin-plugin/link_smoke_test.go#TestLinkSmoke (8 subtests, Postgres)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Google, Facebook, GitHub and Microsoft wrappers with the D-11 email-verified rules against fake provider APIs"
|
||||
requirement: "D-11"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-sociallogin-plugin/providers_smoke_test.go#TestGothProvidersSmoke (8 subtests, no Docker)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Admin settings override config per field; secrets never returned by the admin JSON"
|
||||
requirement: "D-16"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-sociallogin-plugin/settings_smoke_test.go#TestSettingsOverrideSmoke (Postgres)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Remote operations in D-21 order: framework tag + changelog, sm-user-plugin push + tag, new repository + tag"
|
||||
requirement: "D-21"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "git ls-remote --exit-code --tags origin v0.3.1 (summercms.go); ... v0.1.4 (sm-user-plugin); ... v0.1.0 and refs/heads/master (sm-sociallogin-plugin); curl https://summercms.io/changelog/summercms-0-3-1 = 200"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "The release train needed the human go-ahead (Task 5) and a decision on the changelog blocker (fix-ua)"
|
||||
|
||||
duration: 25min
|
||||
completed: 2026-10-21
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 19.5 Plan 02: Social sign-in plugin Summary
|
||||
|
||||
**New private repository sm-sociallogin-plugin v0.1.0: PHP-compatible /oauth/{provider} sign-in for Google, Facebook, GitHub and Microsoft through goth wrapped in one file, a sealed AES-256-GCM state cookie, one-time SPA handoff, pending registration and link-ticket linking, released after summercms v0.3.1 (changelog posted) and sm-user-plugin v0.1.4**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 25 min from the first plan commit (2026-10-20T21:42:34Z) to the last tag push (about 22:06Z), not counting the work before the first commit; includes the Task 5 and changelog checkpoint waits
|
||||
- **Started:** 2026-10-20T21:42:34Z (first plan commit)
|
||||
- **Completed:** 2026-10-20T22:06:00Z (2026-10-21 local)
|
||||
- **Tasks:** 6 of 6 (Task 5 is the human go-ahead)
|
||||
- **Files:** 43 tracked files created in sm-sociallogin-plugin, 1 modified in sm-summercmsio-app
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- sm-sociallogin-plugin requires `golem15.user`; an application without it has no goth dependency, no /oauth routes and no sociallogin config (framework go.mod has no goth).
|
||||
- Its own `Provider`/`Profile`/`ProviderConfig`/`Registry`; goth imported only in `classes/providers_goth.go`; no gothic, no gorilla/sessions, no gopkg.in/yaml.v3 in the dependency graph.
|
||||
- PHP branches ported: identity login, no-email pending (fb_no_email), verified email auto-link, unverified match pending, direct creation on register with consent, throttle and beta gate; action=link through a 60-second link ticket or the request user; no JWT in a URL.
|
||||
- Admin settings screen with encrypted, write-only client secrets overriding config per field without a restart.
|
||||
- Release train in D-21 order: summercms v0.3.1 tagged, pushed and posted to the changelog; sm-user-plugin pushed and tagged v0.1.4; the plugin pinned to both, repository created, pushed and tagged v0.1.0.
|
||||
|
||||
## Task Commits
|
||||
|
||||
sm-sociallogin-plugin (`/media/nvme/dev/golem15/summercms.io/summercms/sm-sociallogin-plugin`):
|
||||
|
||||
1. **Task 1: tracer through a fake provider** - `c15e8b9` (feat)
|
||||
2. **Task 2: pending registration, account creation and email matching** - `f588a38` (feat)
|
||||
3. **Task 3: identity linking with a one-time link ticket and start-request errors** - `62c8586` (feat)
|
||||
4. **Task 4: Google, Facebook, GitHub and Microsoft through goth with a settings override** - `65244a9` (feat)
|
||||
5. **Task 5: go-ahead** - no commit (reply below)
|
||||
6. **Task 6: require summercms v0.3.1 and sm-user-plugin v0.1.4** - `adfcfbf` (chore)
|
||||
|
||||
sm-summercmsio-app (`/media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app`):
|
||||
|
||||
- **Task 6 (fix-ua): changelog-post sends its own User-Agent** - `8581576` (fix), **not pushed** (push not authorised)
|
||||
|
||||
**Plan metadata:** this SUMMARY commit, `docs(19.5-02): ...` (summercms.go, local, not pushed)
|
||||
|
||||
## Task 5 reply
|
||||
|
||||
> release v0.3.1 (Recommended)
|
||||
|
||||
Recorded verbatim. Chosen framework tag: **v0.3.1**. Interpreted with the orchestrator as: framework v0.3.1, sm-user-plugin v0.1.4, sm-sociallogin-plugin v0.1.0, private repository via `tea repos create --owner golem15 --name sm-sociallogin-plugin`.
|
||||
|
||||
Per the Task 5 resume signal, the reply also authorises **plan 03** to push sm-bm-plugin before its pointer bump and to push sm-demo-app, sm-bm-app and fonoteka.go after its commits, each only when the unpushed commits belong to this phase. Nothing is ever force-pushed or deployed.
|
||||
|
||||
## Changelog blocker reply
|
||||
|
||||
> fix-ua (Recommended)
|
||||
|
||||
Recorded verbatim. The first publish failed with Cloudflare 403, error 1010 `browser_signature_banned` (ray `a4db4c6b7efc60a4`, 2026-10-20T22:00:59Z), because `scripts/changelog-post.py` sent the default `Python-urllib` User-Agent. The orchestrator confirmed `GET https://summercms.io/_journal/api/v1/posts` answers 403 with `Python-urllib/3.13` and 200 with `summercms-changelog-post/1`. Fix: one `req.add_header("User-Agent", "summercms-changelog-post/1")` line in `call()`, committed as `8581576`; the retried publish succeeded.
|
||||
|
||||
## Remote operations (Task 6, D-21 order)
|
||||
|
||||
1. **summercms.go** (previous continuation): `git push origin master` `dd047eb..5cf5c4f`; annotated tag `v0.3.1` (tag object `e13723c`, on `5cf5c4f`) pushed. The push also carried another session's planning commit `5cf5c4f` "docs(roadmap): add phase 32 console commands overhaul" (that session has since renumbered it to 19.8 in the unpushed local commit `c996790`, not ours).
|
||||
- Changelog post: id 34, https://summercms.io/changelog/summercms-0-3-1 (published_at 2026-10-20T22:00:28Z; GET answers 200). Excerpt: "One-time cache values with Cache.Pull and branded error pages for browser visitors."
|
||||
2. **sm-user-plugin** (`fonoteka.go/plugins/golem15/user`): fetched; 0 behind, 3 ahead (`78a867b`, `1741618`, `c43ba87`, all plan 19.5-01's); v0.1.4 absent locally and on origin; vet and short tests green. `git push origin master` `1fc0ce7..c43ba87`; annotated tag `v0.1.4` (tag object `2e32ed0`) pushed. `origin/master..master` empty.
|
||||
3. **sm-sociallogin-plugin**: go.mod requires `summercms v0.3.1` and `sm-user-plugin v0.1.4` (the go.mod replace of summercms to `../summercms.go` kept; go.work keeps both local replaces); `GOWORK=off go mod tidy` downloaded sm-user-plugin v0.1.4 from origin; vet, short and full suites green, `GOWORK=off go vet/build` green; README Dependencies names both versions; committed `adfcfbf`. `tea repos create --owner golem15 --name sm-sociallogin-plugin --private` created https://git.golem15.com/golem15/sm-sociallogin-plugin (anonymous GET answers 404, so private). `git remote add origin git@git.golem15.com:golem15/sm-sociallogin-plugin.git`; `git push -u origin master` (new branch, `c15e8b9..adfcfbf`, 5 commits); annotated tag `v0.1.0` (tag object `76c65fb`) pushed. `ls-remote --exit-code` for `refs/heads/master` and `v0.1.0` both exit 0.
|
||||
|
||||
Nothing deployed. Nothing force-pushed.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
sm-sociallogin-plugin (new repository):
|
||||
- `plugin.go`, `routes.go`, `config.go`, `service.go` - plugin, routes, config loader, boot wiring
|
||||
- `admin_settings.go`, `admin_permissions.go`, `models/settings.go`, `models/settings/fields.yaml`, `updates/*` - settings singleton and migration
|
||||
- `classes/provider.go`, `models/registry.go` - provider interface and Registry
|
||||
- `classes/credentials.go`, `classes/config.go` - credentials (config, env, settings overlay), config types
|
||||
- `classes/statecookie.go` - sealed state cookie
|
||||
- `classes/callback.go`, `classes/flow.go`, `classes/registration.go`, `classes/codes.go`, `classes/messages.go` - callback branches, account creation, one-time codes, messages
|
||||
- `classes/providers_goth.go` - the only goth importer
|
||||
- `controllers/*` - redirect, callback, oauth-complete, oauth-register-complete, oauth-link-ticket
|
||||
- `config/config.yaml`, `lang/en/lang.yaml`, `lang/pl/lang.yaml`
|
||||
- `*_test.go` - harness, fake provider, five smoke tests
|
||||
- `README.md`, `go.mod`, `go.sum`, `go.work`, `.gitignore`
|
||||
|
||||
sm-summercmsio-app:
|
||||
- `scripts/changelog-post.py` - User-Agent header
|
||||
|
||||
## Verification
|
||||
|
||||
- sm-sociallogin-plugin after the require bump: `go vet ./...`, `go test -count=1 -short ./...`, `go test -count=1 ./...` green; `-v` shows PASS for TestTracerSmoke, TestFlowSmoke, TestLinkSmoke, TestSettingsOverrideSmoke, TestGothProvidersSmoke (none skipped).
|
||||
- At the Task 5 checkpoint: vet, short and full suites green (TestTracerSmoke, TestFlowSmoke 5, TestLinkSmoke 8, TestSettingsOverrideSmoke, TestGothProvidersSmoke 8); goth only in `classes/providers_goth.go`, no gothic, UseProviders, gorilla/sessions or yaml.v3; framework go.mod has no goth; summercms.go vet and short suite green; fonoteka.go builds.
|
||||
- sm-user-plugin before push: vet and short suite green.
|
||||
- Acceptance: `git ls-remote --exit-code --tags origin v0.3.1` (summercms.go), `... v0.1.4` (sm-user-plugin), `... v0.1.0` (sm-sociallogin-plugin) all exit 0; `grep -c 'git.golem15.com/golem15/sm-user-plugin v0.1.4' go.mod` = 1 (the plan's v0.1.2 string is replaced by v0.1.4, deviation 13); `origin/master..master` empty in sm-user-plugin.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues and adjustments
|
||||
|
||||
1. **[Rule 1] SettingsCode `sociallogin`** instead of `golem15.sociallogin.settings`: cabana rejects dotted settings codes. (`65244a9`)
|
||||
2. **[Rule 2] `models.Settings` has `Rules()`** for admin validation. (`65244a9`)
|
||||
3. **[Rule 3] Config types live in `classes`** (root `sociallogin.Config` is an alias plus `LoadConfig` wrapper) to avoid an import cycle. (`c15e8b9`)
|
||||
4. **[Rule 3] `classes.Message(ctx, app, key, params)`** plus `RegisterMessage` for localized messages. (`c15e8b9`, `f588a38`)
|
||||
5. **[Rule 2] `classes.ClientIP`** (surf.ClientIP with trusted proxies) for last IP, creation IP and the register throttle. (`f588a38`)
|
||||
6. **[Rule 1] Pending code pulled inside the final DB transaction after every 422 check**: a lost race answers 410 (the winner 200); a 422 never consumes. (`f588a38`)
|
||||
7. **[Rule 1] `providers.microsoft.tenant` defaults to ""**, which falls back to `common` (`DefaultTenant`). (`65244a9`)
|
||||
8. **[Rule 2] Beta key carried from any start request** into the sealed cookie and the pending payload (Pitfall 14). (`f588a38`)
|
||||
9. **[Rule 2] Inactive or banned link user answers `must_be_logged_in`.** (`62c8586`)
|
||||
10. **[Rule 2] Real messages for inactive and banned users (A7)**, Polish copy for every message, PHP Polish byte-for-byte where PHP has it. (`f588a38`, `62c8586`)
|
||||
11. **[Rule 3] `bootSocial` returns `*socialEnv`** to share the wiring between Boot and tests. (`c15e8b9`)
|
||||
12. **[Rule 3] Extra exported identifiers** beyond the plan's list: `ErrInvalidName`, `Registry.Has`, `ErrInvalidState`, `StateCookieSecure`, `WithQuery`, `CallbackRoute`, `NewHostSet`, `OutcomePending`, `ReasonNoEmail`, `ReasonUnverifiedMatch`, `PendingPayload.Profile()`, `CompletePending`, `ErrPendingConsumed`, `CheckBetaKey`, `ValidationError`, `IsValidation`, `PasswordBootstrapSource`, `ClientIP`, `DefaultTenant`, `models.Providers`, `models.ConfigSecretKey`, `Settings.Credentials`. README documents them.
|
||||
13. **[Rule 3] sm-user-plugin tag v0.1.4 instead of the plan's v0.1.2**: v0.1.2 and v0.1.3 already existed. The plugin's requires were initially `summercms v0.3.0` / `sm-user-plugin v0.1.3`, bumped to `v0.3.1` / `v0.1.4` in `adfcfbf`.
|
||||
14. **[Rule 3, human decision] Changelog blocked by the Cloudflare User-Agent ban**, fixed in sm-summercmsio-app (`8581576`, reply "fix-ua (Recommended)"); the commit is **unpushed** because pushing sm-summercmsio-app was not authorised.
|
||||
15. **[Context] Extra commit pushed with summercms.go master**: another session's planning commit `5cf5c4f` "docs(roadmap): add phase 32 console commands overhaul" went out with the v0.3.1 push.
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 15 (no architectural changes)
|
||||
**Impact on plan:** Contracts and routes as planned; names extended, not changed; version numbers adjusted to existing tags.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- The first changelog publish failed (Cloudflare 403, error 1010); resolved by deviation 14 and a successful retry.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None beyond the plan's threat model (all new surface is the planned /oauth and /_user/api/v1/oauth-* routes and the settings screen).
|
||||
|
||||
## User Setup Required
|
||||
|
||||
To enable a provider in an application: set `golem15.sociallogin.providers.<p>.client_id` and `client_secret` (or the legacy env names, or the admin settings screen) and register the redirect URI `app.url + /oauth/<p>/callback` with the provider.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 03 can mount `git@git.golem15.com:golem15/sm-sociallogin-plugin.git` (v0.1.0) as a submodule and bump sm-user-plugin pointers to `c43ba87` (v0.1.4, pushed); its pushes are authorised as recorded above.
|
||||
- Open, outside this plan: sm-summercmsio-app `8581576` unpushed; summercms.go has this SUMMARY commit and another session's `c996790` unpushed.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Files: all sm-sociallogin-plugin key files tracked (43); `sm-summercmsio-app/scripts/changelog-post.py` carries the header.
|
||||
- Commits: `c15e8b9`, `f588a38`, `62c8586`, `65244a9`, `adfcfbf` (sm-sociallogin-plugin, all on origin/master), `8581576` (sm-summercmsio-app), `c43ba87` (sm-user-plugin, on origin/master) exist.
|
||||
- Tags on origin: summercms `v0.3.1`, sm-user-plugin `v0.1.4`, sm-sociallogin-plugin `v0.1.0`.
|
||||
|
||||
---
|
||||
*Phase: 19.5-social-login-in-sm-user-plugin*
|
||||
*Completed: 2026-10-21*
|
||||
@@ -0,0 +1,243 @@
|
||||
---
|
||||
phase: 19.5-social-login-in-sm-user-plugin
|
||||
plan: 03
|
||||
subsystem: auth
|
||||
tags: [sociallogin, oauth, microsoft-365, entra, adoption, sm-demo-app, sm-bm-app, fonoteka]
|
||||
|
||||
requires:
|
||||
- phase: 19.5-02
|
||||
provides: "sm-sociallogin-plugin v0.1.0 (private), sm-user-plugin v0.1.4 (c43ba87), summercms v0.3.1, push go-ahead (release v0.3.1)"
|
||||
provides:
|
||||
- "sm-demo-app mounts golem15.sociallogin v0.1.0 next to golem15.oauthserver (go.mod requires it; providers off until credentials are set)"
|
||||
- "sm-bm-app mounts golem15.sociallogin for Microsoft 365 with tenant and redirect from the environment, DEPLOY.md Entra section"
|
||||
- "bm frontend: Microsoft 365 button, /auth/oauth/callback, /auth/oauth/rejestracja consent view, profile link through the link ticket"
|
||||
- "sm-bm-plugin: /oauth reserved from the SPA fallback; rebuilt public/"
|
||||
- "fonoteka.go pinned to sm-user-plugin c43ba87 (relaxed unlink rule), sociallogin not mounted"
|
||||
affects: [19.5-04, sm-demo-app, sm-bm-app, sm-bm-plugin, fonoteka.go]
|
||||
|
||||
actuals:
|
||||
tokens: 10560
|
||||
tasks: 4
|
||||
commits: 9
|
||||
plan_head_before: 7f7f5fcb
|
||||
plan_head_after: 7f7f5fcb
|
||||
# No code commit in summercms.go (only this SUMMARY). Measured per repository:
|
||||
# sm-demo-app: git rev-list --count 4b16f50..d5a98d9 = 2 (pushed)
|
||||
# sm-bm-plugin: git rev-list --count 2536bcb..85cae45 = 2 (pushed)
|
||||
# sm-bm-app: git rev-list --count 1d446cc..0374c3f = 4 (not pushed)
|
||||
# fonoteka.go: git rev-list --count 687e25a..f4d5728 = 1 (not pushed)
|
||||
# tokens: chars/4 over the changed lines of the four repositories, without go.sum and the generated public/ build.
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Private shared plugin fetched in module mode through an env-scoped git insteadOf (GIT_CONFIG_COUNT), no global config change"
|
||||
- "Workspace-level framework replace when a workspace module's go.mod replaces the framework with its own sibling path"
|
||||
- "SPA OAuth callback view that replaces the route before redeeming a single-use code once"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-demo-app/sociallogin_test.go
|
||||
- ../sm-bm-app/sociallogin_test.go
|
||||
- ../sm-bm-app/config/golem15.sociallogin.yaml
|
||||
- ../sm-bm-app/frontend/src/lib/oauth.ts
|
||||
- ../sm-bm-app/frontend/src/lib/oauth.test.ts
|
||||
- ../sm-bm-app/frontend/src/views/OAuthCallbackView.vue
|
||||
- ../sm-bm-app/frontend/src/views/OAuthRegisterView.vue
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-USER-SETUP.md
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/deferred-items.md
|
||||
modified:
|
||||
- ../sm-demo-app/{.gitmodules,go.mod,go.sum,go.work,summer.yaml,plugins.gen.go,boot_test.go,deploy/env.example,README.md}
|
||||
- ../sm-bm-app/{.gitmodules,go.mod,go.sum,go.work,summer.yaml,plugins.gen.go,realtime_test.go,scripts/build.sh,deploy/env.example,DEPLOY.md,README.md}
|
||||
- ../sm-bm-app/frontend/{vite.config.ts,src/router.ts,src/api/user.ts,src/stores/auth.ts,src/views/LoginView.vue,src/views/ProfileView.vue,src/pwa/sw.js,src/pwa/pwa.test.ts}
|
||||
- ../sm-bm-app/plugins/jz/bm/{static.go,static_test.go,public/}
|
||||
- ../fonoteka.go/plugins/golem15/user (pointer)
|
||||
|
||||
key-decisions:
|
||||
- "bm keeps providers.microsoft.tenant empty in committed config (empty means common): the MICROSOFT_TENANT legacy variable only fills an empty key, so a committed 'common' would shadow it"
|
||||
- "sm-demo-app go.mod requires sm-sociallogin-plugin v0.1.0, sm-user-plugin v0.1.4 and summercms v0.3.1 (TestGoModRequiresTags and check-pins.sh demand a tag for every summer.yaml plugin)"
|
||||
- "sm-bm-app go.work replaces the framework for the whole workspace, because sm-sociallogin-plugin's go.mod replaces it with a sibling path that conflicts with bm's"
|
||||
- "The bm service worker skips /oauth like the other reserved prefixes"
|
||||
- "sm-bm-app and fonoteka.go not pushed: both carry other sessions' unpushed commits"
|
||||
|
||||
patterns-established:
|
||||
- "An application that mounts sociallogin reserves /oauth in its SPA fallback and service worker"
|
||||
|
||||
requirements-completed: [SC-1, SC-3, D-01, D-04, D-05, D-06, D-11, D-14, D-17, D-18, D-21, D-23, D-24]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "sm-demo-app mounts sociallogin; Google redirect with prompt=select_account, state and a summer_oauth_google cookie on /oauth; oauth-providers lists google"
|
||||
requirement: "D-21"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-demo-app/sociallogin_test.go#TestSocialLoginRedirect"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "../sm-demo-app/boot_test.go#TestBootAllPlugins"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Pitfall 9: /oauth/jwks, /oauth/authorize, /oauth/userinfo stay with oauthserver; GET /oauth/token and /oauth/nope answer 404 without a state cookie"
|
||||
requirement: "SC-1"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-demo-app/sociallogin_test.go#TestSocialLoginCoexistsWithOAuthServer"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "sm-bm-app: Microsoft redirect to login.microsoftonline.com/<tenant>/oauth2/v2.0/authorize with offline_access from MICROSOFT_* env, provider list {microsoft, Microsoft}, unconfigured provider errors to the callback; /oauth reserved from the SPA fallback"
|
||||
requirement: "D-04"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "../sm-bm-app/sociallogin_test.go#TestSocialLoginMounted"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "../sm-bm-app/plugins/jz/bm/static_test.go#TestStaticReservedPaths"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "bm frontend start URLs and callback parsing (login, register consent, link ticket, return_to encoding, array queries)"
|
||||
requirement: "D-06"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-bm-app/frontend/src/lib/oauth.test.ts (10 tests)"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "npm run typecheck; npm run build; TestEmbeddedPublicHasIndex"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "bm callback, consent and profile link views behave in a browser against a live Entra registration"
|
||||
requirement: "D-23"
|
||||
human_judgment: true
|
||||
rationale: "Needs a real Entra app registration and a browser; no credentials exist in this session"
|
||||
- id: D6
|
||||
description: "fonoteka.go builds and passes at sm-user-plugin c43ba87"
|
||||
requirement: "D-24"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "go -C ../fonoteka.go vet ./... && build ./... && test -count=1 -short ./..."
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "go -C ../fonoteka.go test -count=1 ./plugins/golem15/user/... ./plugins/golem15/fonoteka/... (user plugin all ok; one unrelated cabana inventory failure, deferred-items.md)"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "TestPhase09SecurityRoutes fails on five summercms.cabana admin routes unrelated to the user plugin; the verifier should confirm it is pre-existing"
|
||||
|
||||
duration: 18min
|
||||
completed: 2026-10-21
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 19.5 Plan 03: Adoption in sm-demo-app, sm-bm-app and fonoteka.go Summary
|
||||
|
||||
**sm-sociallogin-plugin v0.1.0 mounted in the demo next to the OIDC provider and in bm for Microsoft 365 (tenant and redirect from the environment), the bm student app signs in, registers and links Microsoft through the one-time code and link ticket, and fonoteka.go is pinned green to sm-user-plugin v0.1.4**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 18 min
|
||||
- **Started:** 2026-10-20T22:09:59Z
|
||||
- **Completed:** 2026-10-20T22:28:22Z (2026-10-21 local)
|
||||
- **Tasks:** 4 of 4
|
||||
- **Files:** about 45 source files across four repositories, plus the regenerated `public/` build (62 files) in sm-bm-plugin
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Demo: `golem15.sociallogin` boots without a database next to all 16 other plugins; GET `/oauth/google` redirects to Google's account chooser with a sealed state cookie; the OIDC provider's `/oauth/*` routes are untouched and its reserved names answer 404 from sociallogin.
|
||||
- bm: Microsoft 365 with a configurable tenant (default common); `/oauth` reserved from the SPA fallback and the service worker; DEPLOY.md explains the Entra registration, the env values, D-11 (no auto-link by email), linking from the profile and the one-instance cache caveat.
|
||||
- bm frontend: login button shown only when the provider is configured, a callback view that strips the code from the address bar before redeeming it once, a consent view for first sign-ins (with the unverified-match explanation), and a profile button that links through `oauth-link-ticket`. No new npm dependency.
|
||||
- fonoteka.go: only the user pointer moved; vet, build and the short suite green; the Docker-backed user plugin suite green.
|
||||
|
||||
## Task Commits
|
||||
|
||||
**sm-demo-app** (`/media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app`), pushed `4b16f50..d5a98d9`:
|
||||
1. Task 1a: `9eb8492` chore: bump golem15.user for social sign-in hooks (pointer 7b9faa3 to c43ba87, go.mod `sm-user-plugin v0.1.4`)
|
||||
2. Task 1b: `d5a98d9` feat: mount golem15.sociallogin
|
||||
|
||||
**sm-bm-plugin** (`sm-bm-app/plugins/jz/bm`), pushed `2536bcb..85cae45` before the pointer bump:
|
||||
3. Task 2: `438c0b9` fix: reserve /oauth for social sign-in
|
||||
4. Task 3: `85cae45` build: frontend with Microsoft 365 sign-in
|
||||
|
||||
**sm-bm-app** (`/media/nvme/dev/golem15/summercms.io/summercms/sm-bm-app`), **not pushed**:
|
||||
5. Task 2: `bb800a5` chore: bump golem15.user for social sign-in hooks (pointer 24b16b1 to c43ba87, realtime test expectation)
|
||||
6. Task 2: `117ef8f` feat: mount golem15.sociallogin for Microsoft 365
|
||||
7. Task 3: `e76f8bc` feat: Microsoft 365 sign-in in the frontend
|
||||
8. Task 3: `0374c3f` chore: bump jz.bm for /oauth and the rebuilt frontend (pointer 85cae45)
|
||||
|
||||
**fonoteka.go** (`/media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go`), **not pushed**:
|
||||
9. Task 4: `f4d5728` chore: bump golem15.user to the social sign-in hooks (pointer cf5c6b9 to c43ba87; only that path staged)
|
||||
|
||||
**Plan metadata:** this SUMMARY commit in summercms.go, `docs(19.5-03): ...` (local).
|
||||
|
||||
## Pushes
|
||||
|
||||
- **sm-demo-app:** fetched; `origin/master..master` listed only `9eb8492`, `d5a98d9`; pushed. Now in sync.
|
||||
- **sm-bm-plugin:** fetched; only `438c0b9`, `85cae45`; pushed before `0374c3f`. Now in sync; the committed pointer equals the pushed head.
|
||||
- **sm-bm-app: left to the user.** `origin/master..master` also holds another session's `3a97157` (bump golem15.presentations, summer theme option) and `1d446cc` (feat: add the summer presentation theme), so it was not pushed.
|
||||
- **fonoteka.go: left to the user.** 30 other unpushed commits (phase 14.2, 14.1, 12.1 and parity work) sit below `f4d5728`.
|
||||
- Nothing force-pushed, nothing deployed.
|
||||
|
||||
## Checked-out submodule commits
|
||||
|
||||
| Repository | Path | Commit |
|
||||
|---|---|---|
|
||||
| sm-demo-app | plugins/golem15/sociallogin | adfcfbf (v0.1.0) |
|
||||
| sm-demo-app | plugins/golem15/user | c43ba87 (v0.1.4) |
|
||||
| sm-bm-app | plugins/golem15/sociallogin | adfcfbf (v0.1.0) |
|
||||
| sm-bm-app | plugins/golem15/user | c43ba87 (v0.1.4) |
|
||||
| sm-bm-app | plugins/jz/bm | 85cae45 |
|
||||
| fonoteka.go | plugins/golem15/user | c43ba87 (v0.1.4) |
|
||||
|
||||
## Verification
|
||||
|
||||
- Task 1: `go build ./...`, `go vet .`, `go test -count=1 -short .` green; `TestBootAllPlugins`, `TestSocialLoginRedirect`, `TestSocialLoginCoexistsWithOAuthServer` = 3 PASS; the full root suite with Docker (`go test -count=1 .`) green; `GOWORK=off GOPRIVATE='git.golem15.com/*' go vet ./...` green; `scripts/check-pins.sh` ok (15 modules); `scripts/check-deploy.sh` ok.
|
||||
- Task 2: `TestStaticReservedPaths` PASS; sm-bm-plugin `go test ./...` green; bm `go build ./...`, `go vet .`, short tests green; `TestSocialLoginMounted` PASS; `GOWORK=off go build ./...` now works (it needed `go mod tidy` before this plan); a simulation of `scripts/build.sh`'s generated go.work vets sociallogin and builds bm.
|
||||
- Task 3: vitest 17 files / 211 tests green (oauth.test.ts 10 tests); `vue-tsc --noEmit` clean; `vite build` green and byte-identical on a second run; `TestEmbeddedPublicHasIndex` and `TestStaticReservedPaths` = 2 PASS; `go test -short ./plugins/jz/bm/...` green.
|
||||
- Task 4: fonoteka.go `go vet ./...`, `go build ./...`, `go test -count=1 -short ./...` green. Docker-backed run of `./plugins/golem15/user/... ./plugins/golem15/fonoteka/...` (Docker available, no sudo needed): every sm-user-plugin package ok; the fonoteka plugin packages ok except `TestPhase09SecurityRoutes` (see Issues).
|
||||
- Acceptance criteria: all pass except `grep -c 'tenant: common' config/golem15.sociallogin.yaml` (0, deviation 1).
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
1. **[Rule 1 - Bug] bm config keeps the tenant empty instead of `tenant: common`.** Found in Task 2: the plugin copies `MICROSOFT_TENANT` only onto an empty key (`copyLegacyEnv`), so a committed `common` would silently override the deployment's tenant and `TestSocialLoginMounted` (MICROSOFT_TENANT=organizations) would fail. Empty means common (`DefaultTenant`); the file says so. The acceptance grep for `tenant: common` prints 0. (`117ef8f`)
|
||||
2. **[Rule 3 - Blocking] sm-demo-app go.mod and go.sum changed** (not in the plan's file list): `TestGoModRequiresTags` and `check-pins.sh` require a tag for every summer.yaml plugin, so the user bump requires `sm-user-plugin v0.1.4` and the mount requires `sm-sociallogin-plugin v0.1.0`, which moves `summercms` to v0.3.1 and adds goth, `golang.org/x/oauth2` and `cloud.google.com/go/compute/metadata` (goth's graph only). (`9eb8492`, `d5a98d9`)
|
||||
3. **[Rule 3 - Blocking] The private module needs SSH for module-mode fetches.** `go get` over https asked for credentials. Fetched with an env-scoped rewrite (`GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=url.git@git.golem15.com:.insteadOf GIT_CONFIG_VALUE_0=https://git.golem15.com/`), no global git config change; the module is now in this machine's cache. Other machines need the rewrite or a public repository (19.5-USER-SETUP.md).
|
||||
4. **[Rule 3 - Blocking] sm-bm-app go.work gained `replace summercms => ../summercms.go`.** The plugin's go.mod replaces the framework with its own sibling path, which conflicts with bm's replace in workspace mode. (`117ef8f`)
|
||||
5. **[Rule 1 - Bug] bm realtime tests expected only the `presentations` namespace.** sm-user-plugin registers a `user` namespace since v0.1.2, so the bump turned `TestRealtimeDefaultDriver` and `TestRealtimeRoutes` red; the expectation is now `[presentations user]`. (`bb800a5`)
|
||||
6. **[Rule 2 - Missing critical] `scripts/build.sh` lists the sociallogin submodule** so the release build's go.work uses it, vets it and refuses its uncommitted changes. (`117ef8f`)
|
||||
7. **[Rule 2 - Missing critical] The bm service worker skips `/oauth`.** Its skip list mirrors the reserved prefixes; without it, `/oauth` navigations would go through the worker's offline fallback. `pwa.test.ts` covers `/oauth/microsoft` and its callback. (`e76f8bc`)
|
||||
8. **[Context] The rebuilt `public/` includes the summer presentation theme** from another session's committed but unpushed sm-bm-app commit `1d446cc` (fonts and CSS from the frontend sources). It is the frontend HEAD's build output; the pushed sm-bm-plugin therefore carries that theme before sm-bm-app's source commit is pushed. (`85cae45`)
|
||||
9. **[Context] Demo nginx does not route `/oauth` to Go** (`check-deploy.sh` refuses it on purpose), so the demo cannot reach social sign-in live; the env template says so and nothing in deploy/ was changed. Not needed for D-21 (mount and config).
|
||||
10. **[Context] g15office comment not posted:** no g15office tool in this executor session; the orchestrator posts it on `onunbz3l` (as for plans 01 and 02).
|
||||
|
||||
**Total deviations:** 10 (2 bug, 3 blocking, 2 missing critical, 3 context). **Impact:** contracts as planned; the bm tenant is configured only through the environment.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- fonoteka.go `TestPhase09SecurityRoutes` fails in the Docker-backed run: five `summercms.cabana` admin routes (account preferences, reorder, singleton) are missing from fonoteka's route inventory. All belong to cabana, none to golem15.user, so the bump does not cause it. Logged in `deferred-items.md`.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None beyond the plan's threat model. Templates and config hold no credential values (`grep -cE 'CLIENT_SECRET=[^ ]+'` and `client_secret: *[^ ]+` both 0); the tests use test-only strings.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
See `19.5-USER-SETUP.md`: Entra registration and four env values for bm, Google credentials for the demo (live use only after the demo's nginx routes `/oauth`), and SSH access for module downloads of the private plugin.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 04 (unit tests and the PHP test map) can run; all three applications build at the new pins.
|
||||
- Left to the user: push sm-bm-app (after the summer theme commits are cleared for push) and fonoteka.go; deploy bm with the Entra values.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Files: sm-demo-app/sociallogin_test.go, sm-bm-app/sociallogin_test.go, sm-bm-app/config/golem15.sociallogin.yaml, frontend/src/lib/oauth.ts, oauth.test.ts, OAuthCallbackView.vue, OAuthRegisterView.vue exist.
|
||||
- Commits: 9eb8492, d5a98d9 (sm-demo-app, on origin), 438c0b9, 85cae45 (sm-bm-plugin, on origin), bb800a5, 117ef8f, e76f8bc, 0374c3f (sm-bm-app), f4d5728 (fonoteka.go) exist.
|
||||
|
||||
---
|
||||
*Phase: 19.5-social-login-in-sm-user-plugin*
|
||||
*Completed: 2026-10-21*
|
||||
@@ -0,0 +1,370 @@
|
||||
---
|
||||
phase: 19.5-social-login-in-sm-user-plugin
|
||||
plan: 04
|
||||
subsystem: auth
|
||||
tags: [sociallogin, oauth, unit-tests, php-map, security-review, gate, release, pre-account-takeover]
|
||||
|
||||
requires:
|
||||
- phase: 19.5-01
|
||||
provides: "cooler.Cache.Pull, sm-user-plugin social sign-in hooks (v0.1.4)"
|
||||
- phase: 19.5-02
|
||||
provides: "sm-sociallogin-plugin v0.1.0, summercms v0.3.1"
|
||||
- phase: 19.5-03
|
||||
provides: "sm-demo-app and sm-bm-app mounting sociallogin v0.1.0, fonoteka.go at sm-user-plugin v0.1.4"
|
||||
provides:
|
||||
- "scripts/check-phase19.5.sh: fail-closed gate across six repositories (go, userplugin, sociallogin, demo, bm, fonoteka, docker, docs, self-test, all)"
|
||||
- "Unit tests: cooler Pull (4), sm-user-plugin (6 + 1 PHP-named subtest), sm-sociallogin-plugin (26 decision tests + TestPHPMethodMap + 5 TestPHP* ports)"
|
||||
- "testdata/PHP_METHOD_MAP.md: 38 PHP social-auth methods, 17 ported, 18 adapted, 3 not-applicable"
|
||||
- "19.5-SECURITY-REVIEW.md: every T-19.5 threat pinned or accepted; T-19.5-38 found and fixed"
|
||||
- "sm-sociallogin-plugin v0.1.1 (three fixes), pinned in sm-demo-app and sm-bm-app"
|
||||
affects: [sm-sociallogin-plugin, sm-user-plugin, sm-demo-app, sm-bm-app, /msd-verify-work]
|
||||
|
||||
estimate:
|
||||
tokens: 320000
|
||||
tasks: 7
|
||||
actuals:
|
||||
tokens: 78000
|
||||
tasks: 7
|
||||
commits: 9
|
||||
plan_head_before: 877bf0a425d952df8b323a56d1a0b284474ff97e
|
||||
plan_head_after: f38713f
|
||||
# commits counts this plan's summercms.go commits before this SUMMARY: git rev-list --count 877bf0a..f38713f = 13,
|
||||
# of which 4 belong to another session (bc5a9db, 63a85a8, 09fd4d0, cdddaa3, phase 19.8 docs), so 9 are this plan's.
|
||||
# Other repositories (measured from their ledgers):
|
||||
# sm-sociallogin-plugin: git rev-list --count adfcfbf..d7ee28d = 6 (pushed, tag v0.1.1)
|
||||
# sm-user-plugin: git rev-list --count c43ba87..c7635c5 = 2 (pushed, no tag)
|
||||
# sm-demo-app: 61a00e9 (pushed); sm-bm-app: 99af8c2 (pushed with the approved earlier commits)
|
||||
# tokens: chars/4 over the test files, the gate script, the three fixes and the planning docs written by this plan.
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Fail-closed phase gate: go test -json detector (fail, skip, zero, no-tests, missing name), vitest detector, NOT RUN docker rule, --self-test on planted output"
|
||||
- "Forced races: explicit transactions with row locks in both orders; start barriers with N goroutines; mutation checks on the mitigation"
|
||||
- "Built-in goth wrappers under test through a registry published before the plugin registers (bootSocialWith) with a fake http.RoundTripper"
|
||||
- "Transaction-scoped advisory locks (pg_advisory_xact_lock) to serialise concurrent account creations of one identity and email"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- scripts/check-phase19.5.sh
|
||||
- modules/cooler/pull_test.go
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md
|
||||
- .planning/todos/pending/oauth-password-bootstrap-change-password.md
|
||||
- ../fonoteka.go/plugins/golem15/user/oauth_providers_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/oauth_identities_rule_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/social_hooks_test.go
|
||||
- ../sm-sociallogin-plugin/unit_helpers_test.go
|
||||
- ../sm-sociallogin-plugin/registry_test.go
|
||||
- ../sm-sociallogin-plugin/statecookie_test.go
|
||||
- ../sm-sociallogin-plugin/normalize_test.go
|
||||
- ../sm-sociallogin-plugin/codes_test.go
|
||||
- ../sm-sociallogin-plugin/routes_test.go
|
||||
- ../sm-sociallogin-plugin/matching_test.go
|
||||
- ../sm-sociallogin-plugin/registration_test.go
|
||||
- ../sm-sociallogin-plugin/link_test.go
|
||||
- ../sm-sociallogin-plugin/complete_test.go
|
||||
- ../sm-sociallogin-plugin/register_complete_test.go
|
||||
- ../sm-sociallogin-plugin/settings_test.go
|
||||
- ../sm-sociallogin-plugin/providers_goth_test.go
|
||||
- ../sm-sociallogin-plugin/php_port_test.go
|
||||
- ../sm-sociallogin-plugin/php_method_map_test.go
|
||||
- ../sm-sociallogin-plugin/testdata/php_methods.txt
|
||||
- ../sm-sociallogin-plugin/testdata/PHP_METHOD_MAP.md
|
||||
modified:
|
||||
- modules/cooler/docker_test.go
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-VALIDATION.md
|
||||
- ../fonoteka.go/plugins/golem15/user/profile_test.go
|
||||
- ../sm-sociallogin-plugin/classes/flow.go
|
||||
- ../sm-sociallogin-plugin/classes/registration.go
|
||||
- ../sm-sociallogin-plugin/models/settings.go
|
||||
- ../sm-sociallogin-plugin/harness_test.go
|
||||
- ../sm-sociallogin-plugin/README.md
|
||||
- ../sm-demo-app/plugins/golem15/sociallogin (pointer), go.mod, go.sum
|
||||
- ../sm-bm-app/plugins/golem15/sociallogin (pointer)
|
||||
|
||||
key-decisions:
|
||||
- "Task 6 reply: release sm-sociallogin-plugin v0.1.1; push sm-user-plugin test commits; push sm-bm-app including the earlier approved commits; fix T-19.5-38 with option (b)"
|
||||
- "T-19.5-38 option (b): a verified email auto-links only onto an account with a self-set password; a password-less account goes pending and its completion answers email_exists_link_account (deliberate divergence from PHP)"
|
||||
- "Gate scripts and .planning docs count as non-production for the Post-release fixes table (no release needed)"
|
||||
- "Concurrent account creations are serialised with transaction-scoped advisory locks on the provider identity and the email"
|
||||
- "The relaxed change-password bootstrap of the PHP plugin is not ported (3 not-applicable map rows); tracked as a todo"
|
||||
|
||||
patterns-established:
|
||||
- "A phase's last plan ships a fail-closed gate script plus a PHP method map with a checker that fails on missing, extra or nonexistent tests"
|
||||
|
||||
requirements-completed: [SC-4, D-03, D-04, D-06, D-07, D-08, D-09, D-10, D-11, D-12, D-13, D-14, D-15, D-16, D-17, D-19, D-20, D-21, D-23, D-25]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Fail-closed gate across six repositories, detectors proven on planted output"
|
||||
requirement: "SC-4"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "scripts/check-phase19.5.sh --all (exit 0, docker stage run) and --self-test"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "cooler.Cache.Pull single winner, expiry and prefix isolation on memory, miniredis and redis:7-alpine"
|
||||
requirement: "D-08"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/cooler/pull_test.go#TestPullConcurrent, TestPullExpired, TestPullPrefixIsolation; docker_test.go#TestPullDocker"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "sm-user-plugin provider source, unlink rule and race, sign-in helpers"
|
||||
requirement: "D-13"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "sm-user-plugin oauth_providers_test.go, oauth_identities_rule_test.go, social_hooks_test.go (Postgres)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "One unit test per sociallogin decision, incl. nOAuth, state cookie, concurrent completion, pre-account takeover"
|
||||
requirement: "D-10"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "sm-sociallogin-plugin gate --sociallogin (9 tests) and --docker (28 tests)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "PHP social-auth suite mapped case by case (38 methods) with a checker"
|
||||
requirement: "D-25"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "sm-sociallogin-plugin php_method_map_test.go#TestPHPMethodMap (mutation-checked) and the five TestPHP* ports"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Security review: every T-19.5 threat pinned by a test or accepted; T-19.5-38 fixed"
|
||||
requirement: "D-11"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: ".planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md; TestPreAccountTakeover"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "Review completeness and the T-19.5-38 product decision are judgments; the verifier should read the register"
|
||||
- id: D7
|
||||
description: "Post-release fixes released as v0.1.1 and contained in every application pin"
|
||||
requirement: "D-21"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "git ls-remote --exit-code --tags origin v0.1.1; merge-base --is-ancestor of 21f3fe5, c99f5f9, d7ee28d against the sociallogin gitlinks of sm-demo-app and sm-bm-app (all exit 0); gate --demo and --bm at the new pointers"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D8
|
||||
description: "Live sign-in with real provider credentials in the demo and bm"
|
||||
requirement: "D-21"
|
||||
human_judgment: true
|
||||
rationale: "Needs real provider registrations; manual row in VALIDATION.md"
|
||||
|
||||
duration: 1h42m
|
||||
completed: 2026-10-21
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 19.5 Plan 04: Unit tests, PHP map, security review and v0.1.1 Summary
|
||||
|
||||
**A fail-closed six-repository gate, unit tests for every social sign-in decision (cooler Pull, sm-user-plugin hooks, 26 plugin tests on Postgres and fake providers), the 38-method PHP social-auth map with a checker, and a security review that found three defects in sm-sociallogin-plugin (500s on concurrent completions and on every settings save, and a pre-account takeover through unverified-email accounts), fixed and released as v0.1.1 into sm-demo-app and sm-bm-app**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 1h42m (2026-10-20T22:31:38Z to 2026-10-21T00:14Z), including the Task 6 checkpoint wait
|
||||
- **Tasks:** 7 of 7 (Task 6 the user's go-ahead, Task 7 the release)
|
||||
- **Files:** about 40 created or modified across six repositories
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `scripts/check-phase19.5.sh --all` exits 0 with every stage PASS, including docker (no NOT RUN).
|
||||
- Every decision of the validation map has a unit test, and the gate runs it by name.
|
||||
- The PHP social-auth suite is mapped: 17 ported, 18 adapted, 3 not-applicable (the relaxed change-password bootstrap, now a todo).
|
||||
- The security review pins every threat T-19.5-01..37 and T-19.5-SC, or accepts it with a reason; it lists every log call.
|
||||
- Three production defects found by the new tests and the review were fixed, released as sm-sociallogin-plugin v0.1.1 and pinned in both applications.
|
||||
|
||||
## Task Commits
|
||||
|
||||
summercms.go:
|
||||
1. Task 1: fail-closed phase gate. `b4c0e0d`
|
||||
2. Task 2: cooler Pull tests. `671d388`
|
||||
3. Task 2: gate update. `6b8f060`
|
||||
4. Task 3: gate update. `18598c1`
|
||||
5. Task 4: password-bootstrap todo. `52f17e4`
|
||||
6. Task 4: gate the PHP map. `2c2ff81`
|
||||
7. Task 5: security review and validation map. `e830298`
|
||||
8. Post-checkpoint: gate `TestPreAccountTakeover`. `1d5dc36`
|
||||
9. Post-checkpoint: close T-19.5-38 in the review. `f38713f`
|
||||
|
||||
sm-user-plugin (`fonoteka.go/plugins/golem15/user`), pushed `c43ba87..c7635c5`, no tag:
|
||||
- Task 2: provider source, unlink rule and race, sign-in helpers. `d6d8f11`
|
||||
- Task 4: `TestChangePassword/real_password_account_still_requires_current_password`. `c7635c5`
|
||||
|
||||
sm-sociallogin-plugin, pushed `adfcfbf..d7ee28d`, tag **v0.1.1** (tag object `dd1c1de`, on `d7ee28d`):
|
||||
- Task 3: fix concurrent pending completions (410, not 500). `21f3fe5`
|
||||
- Task 3: fix the admin settings save. `c99f5f9`
|
||||
- Task 3: unit tests for every decision. `f18830d`
|
||||
- Task 4: PHP map and ports. `d9a72a7`
|
||||
- Task 5: key domain-separation pins. `b7fa407`
|
||||
- Task 7 (after the Task 6 reply): fix the pre-account takeover (T-19.5-38). `d7ee28d`
|
||||
|
||||
Applications:
|
||||
- sm-demo-app: `61a00e9` chore: bump sm-sociallogin-plugin to v0.1.1 (gitlink and go.mod require v0.1.1), pushed `d5a98d9..61a00e9`.
|
||||
- sm-bm-app: `99af8c2` chore: bump sm-sociallogin-plugin to v0.1.1, pushed `9d5a371..99af8c2`. The push carried, as approved, another session's `3a97157` and `1d446cc` and plan 03's `bb800a5`, `117ef8f`, `e76f8bc`, `0374c3f`. Nothing else was unpushed, and every submodule pointer in the push was already on its remote.
|
||||
- fonoteka.go: not touched (user pin unchanged at c43ba87).
|
||||
|
||||
## Gate output
|
||||
|
||||
`scripts/check-phase19.5.sh --all`, last run after the T-19.5-38 fix: exit 0.
|
||||
|
||||
- self-test: PASS
|
||||
- go: PASS (4 cooler tests)
|
||||
- userplugin: PASS (2 tests; additive check against 4c5d3b8, 10 declarations added, no migration changed)
|
||||
- sociallogin: PASS (9 tests; no gorilla/sessions or gopkg.in/yaml.v3; goth only in `classes/providers_goth.go`)
|
||||
- demo: PASS (3)
|
||||
- bm: PASS (Go 1 + 2; vitest 211 passed; typecheck)
|
||||
- fonoteka: PASS (vet, short suite, sociallogin not mounted)
|
||||
- docs: PASS
|
||||
- docker: PASS (`TestPullDocker`; 10 user-plugin tests; 28 sociallogin tests)
|
||||
|
||||
After the release: `--demo`, `--bm` and `--sociallogin` passed again at the new pointers. `--fonoteka` was not rerun: nothing it tests changed since the `--all` run.
|
||||
|
||||
## Post-release fixes
|
||||
|
||||
| Repo | Commit | Subject | Found by | Pins that lacked it | Outcome |
|
||||
|---|---|---|---|---|---|
|
||||
| sm-sociallogin-plugin | `21f3fe5` | fix: concurrent pending completions answer 410, not 500 | `TestRegisterComplete/concurrent_submit_one_winner` (T-19.5-12) | sm-demo-app and sm-bm-app `plugins/golem15/sociallogin` (adfcfbf) | released in v0.1.1; both pins at d7ee28d; `merge-base --is-ancestor` exit 0 |
|
||||
| sm-sociallogin-plugin | `c99f5f9` | fix: the admin settings form saves (tenant checked without a regex rule) | `TestSettings/admin_json_hides_secrets` (T-19.5-16) | same two pins | released in v0.1.1; exit 0 for both |
|
||||
| sm-sociallogin-plugin | `d7ee28d` | fix: a verified sign-in never links onto a password-less account | security review finding T-19.5-38, pinned by `TestPreAccountTakeover` | same two pins | released in v0.1.1; exit 0 for both |
|
||||
|
||||
sm-user-plugin and summercms.go had no production change (test, gate and planning commits only). So there was no user-plugin or framework tag, no changelog post for the framework, and no change to the plugin's requires (still summercms v0.3.1, sm-user-plugin v0.1.4).
|
||||
|
||||
## Task 6 reply
|
||||
|
||||
> release sm-sociallogin-plugin v0.1.1, approve both extra pushes, fix T-19.5-38 with option (b)
|
||||
|
||||
As relayed by the coordinator from the user's answers in the main session. Interpreted as:
|
||||
- tag v0.1.1 after fixing T-19.5-38;
|
||||
- push sm-user-plugin `d6d8f11` and `c7635c5` without a tag;
|
||||
- push sm-bm-app including the earlier approved commits;
|
||||
- no changelog post from this executor (the main session handles it).
|
||||
|
||||
## Tags and final pins
|
||||
|
||||
| Repository | Tag | Commit |
|
||||
|---|---|---|
|
||||
| sm-sociallogin-plugin | v0.1.1 (new, pushed) | d7ee28d |
|
||||
| sm-user-plugin | v0.1.4 (unchanged; master pushed to c7635c5) | c43ba87 |
|
||||
| summercms.go | v0.3.1 (unchanged) | |
|
||||
|
||||
| Application | sociallogin | user |
|
||||
|---|---|---|
|
||||
| sm-demo-app | d7ee28d (v0.1.1; go.mod v0.1.1) | c43ba87 (v0.1.4) |
|
||||
| sm-bm-app | d7ee28d (v0.1.1; go.mod replace to the submodule) | c43ba87 (v0.1.4) |
|
||||
| fonoteka.go | not mounted | c43ba87 (v0.1.4) |
|
||||
|
||||
v0.1.1 tag message (user-facing):
|
||||
|
||||
```
|
||||
sm-sociallogin-plugin v0.1.1: security and reliability fixes
|
||||
|
||||
- a verified sign-in (Google, GitHub) no longer links onto an existing
|
||||
account that has no self-set password: an account created from an email
|
||||
the provider did not verify (Facebook, Microsoft) goes to the pending
|
||||
screen instead, and its owner links further providers from the settings
|
||||
or proves the address with a password reset (closes a pre-account
|
||||
takeover)
|
||||
- the admin settings page saves again: the Microsoft tenant is checked by
|
||||
the model instead of an unsupported validation rule, and a malformed
|
||||
tenant is a validation error on the field
|
||||
- two simultaneous submissions of one pending registration answer one 200
|
||||
and one 410 instead of a 500
|
||||
- unit tests for every decision and the PHP social-auth test map
|
||||
- still requires summercms v0.3.1 and sm-user-plugin v0.1.4
|
||||
```
|
||||
|
||||
## PHP map
|
||||
|
||||
38 methods: 17 ported, 18 adapted, 3 not-applicable. 26 rows point to sm-sociallogin-plugin tests and 9 to sm-user-plugin tests.
|
||||
- The three not-applicable rows are PHP's relaxed change-password bootstrap; the todo `oauth-password-bootstrap-change-password.md` tracks it.
|
||||
- `test_verified_provider_email_still_auto_links` is adapted, marking the deliberate T-19.5-38 divergence.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- T-19.5-38 option (b): `ownerVerifiedEmail` = the matched account has a self-set password. A password reset through the mailed code sets it, so the owner can prove the address. No sm-user-plugin change was needed; the fix only reads the existing `HasSelfSetPassword` field.
|
||||
- The refused auto-link reuses the existing shapes: pending `reason=unverified_match` at the callback, 422 `email_exists_link_account` at the completion.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] Concurrent pending completions answered 500**
|
||||
- **Found during:** Task 3 (`TestRegisterComplete/concurrent_submit_one_winner`: 15 × 500 instead of 410)
|
||||
- **Fix:** advisory locks on the identity and the email during account creation; a completion that loses the race finds the identity and returns `ErrPendingConsumed`
|
||||
- **Files modified:** `classes/flow.go`, `classes/registration.go`
|
||||
- **Commit:** `21f3fe5`
|
||||
|
||||
**2. [Rule 1 - Bug] Every admin settings save answered 500**
|
||||
- **Found during:** Task 3 (`TestSettings`). lagoon's model validation has no `regex:` rule.
|
||||
- **Fix:** tenant format checked in `Settings.BeforeValidate` (422); README updated
|
||||
- **Files modified:** `models/settings.go`, `README.md`
|
||||
- **Commit:** `c99f5f9`
|
||||
|
||||
**3. [Rule 4 - User decision] Pre-account takeover (T-19.5-38), found by the security review**
|
||||
- **Process:** raised at the Task 6 checkpoint; the user chose option (b)
|
||||
- **Fix:** a verified email auto-links only onto an account with a self-set password
|
||||
- **Test:** `TestPreAccountTakeover` (mutation-checked)
|
||||
- **Docs:** README matching table and security notes; PHP map row adapted
|
||||
- **Commit:** `d7ee28d`
|
||||
|
||||
### Other deviations
|
||||
|
||||
4. **[Process] One context for Tasks 1–5** instead of the planned hand-off after Task 3 (the budget allowed). At the Task 6 checkpoint, the Post-release fixes table went into the checkpoint return instead of a draft SUMMARY, as the coordinator instructed.
|
||||
5. **[Rule 3] Harness changes in sm-sociallogin-plugin:**
|
||||
- the harness trusts the loopback proxy, so tests choose the client address with `X-Forwarded-For` (deterministic throttle tests);
|
||||
- `bootSocialWith` publishes a test registry before the plugins register;
|
||||
- added `unit_helpers_test.go`.
|
||||
6. **[Rule 2] User-plugin Postgres tests skip under `-short`** (CLAUDE.md rule); the gate runs them by name in the docker stage.
|
||||
7. **[Scope] Non-production commits not counted as post-release fixes:** gate scripts and `.planning` docs, though the plan's literal rule would list them.
|
||||
8. **[Context] Normaliser difference from PHP:** a percent-encoded callback path whose decoded form equals the callback path is accepted and rebuilt to the canonical path. Safe, since the output is canonical; PHP refuses it. Pinned by `TestNormalizeFrontendCallback/encoded_path_rebuilt`.
|
||||
9. **[Context] `TestErrorMessages`** lives in `settings_test.go` (no `messages_test.go` in the plan's file list).
|
||||
10. **[Context] Pre-existing fonoteka failure:** `TestPhase09SecurityRoutes` (deferred-items.md) is outside the gate. The fonoteka stage runs `-short`, and the docker stage runs only this phase's named tests, so it never blocked a stage.
|
||||
11. **[Context] summercms.go push:** summercms.go was not pushed (not in the reply). Its unpushed range mixes this plan's commits with another session's 19.8 commits.
|
||||
12. **[Context] No g15office:** no g15office task for the new todo and no milestone comment (no tool in this session; the main session posts it).
|
||||
|
||||
**Total deviations:** 12 (2 bug fixes, 1 user-decided security fix, 9 process or context notes). **Impact:** three production fixes released as v0.1.1; contracts otherwise unchanged.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None open. T-19.5-38 is closed.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. No new network surface beyond the plan's threat model; the fixes narrow existing paths.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None new (see 19.5-USER-SETUP.md for live provider credentials).
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Phase complete; ready for `/msd-verify-work 19.5`.
|
||||
- The manual row in VALIDATION.md (live sign-in with real providers) remains.
|
||||
- The main session may post the v0.1.1 notes and the g15office comment.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Files exist: `scripts/check-phase19.5.sh`, `19.5-SECURITY-REVIEW.md`, `19.5-VALIDATION.md`, the todo, `sm-sociallogin-plugin/testdata/PHP_METHOD_MAP.md` and `php_methods.txt`, `php_method_map_test.go`, `php_port_test.go`, the user-plugin test files.
|
||||
- Commits exist in their repositories:
|
||||
- summercms.go: `b4c0e0d 671d388 6b8f060 18598c1 52f17e4 2c2ff81 e830298 1d5dc36 f38713f`
|
||||
- sm-user-plugin: `d6d8f11 c7635c5` (on origin)
|
||||
- sm-sociallogin-plugin: `21f3fe5 c99f5f9 f18830d d9a72a7 b7fa407 d7ee28d` (on origin)
|
||||
- sm-demo-app: `61a00e9`; sm-bm-app: `99af8c2` (both on origin)
|
||||
- `git ls-remote --exit-code --tags origin v0.1.1` exits 0.
|
||||
- `origin/master..master` is empty in sm-sociallogin-plugin, sm-user-plugin, sm-demo-app and sm-bm-app.
|
||||
|
||||
---
|
||||
*Phase: 19.5-social-login-in-sm-user-plugin*
|
||||
*Completed: 2026-10-21*
|
||||
@@ -0,0 +1,671 @@
|
||||
---
|
||||
phase: 19.5-social-login-in-sm-user-plugin
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 5
|
||||
depends_on: ["19.5-04"]
|
||||
gap_closure: true
|
||||
files_modified:
|
||||
- ../fonoteka.go/plugins/golem15/user/updates/202610210001_add_users_email_verified_at.go
|
||||
- ../fonoteka.go/plugins/golem15/user/updates/organisations_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/updates/email_verified_at_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/models/user.go
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/codes.go
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go
|
||||
- ../fonoteka.go/plugins/golem15/user/email_verified_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/README.md
|
||||
- ../sm-sociallogin-plugin/classes/flow.go
|
||||
- ../sm-sociallogin-plugin/harness_test.go
|
||||
- ../sm-sociallogin-plugin/owner_verified_test.go
|
||||
- ../sm-sociallogin-plugin/matching_test.go
|
||||
- ../sm-sociallogin-plugin/testdata/PHP_METHOD_MAP.md
|
||||
- ../sm-sociallogin-plugin/README.md
|
||||
- ../sm-sociallogin-plugin/go.mod
|
||||
- ../sm-sociallogin-plugin/go.sum
|
||||
- ../sm-bm-app/frontend/src/views/OAuthCallbackView.test.ts
|
||||
- ../sm-bm-app/plugins/golem15/user
|
||||
- ../sm-bm-app/plugins/golem15/sociallogin
|
||||
- ../sm-bm-app/go.mod
|
||||
- ../sm-bm-app/go.sum
|
||||
- ../sm-demo-app/plugins/golem15/user
|
||||
- ../sm-demo-app/plugins/golem15/sociallogin
|
||||
- ../sm-demo-app/go.mod
|
||||
- ../sm-demo-app/go.sum
|
||||
- ../fonoteka.go/plugins/golem15/user
|
||||
- scripts/check-phase19.5.sh
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-VALIDATION.md
|
||||
- .planning/todos/pending/email-proof-release.md
|
||||
autonomous: false
|
||||
requirements: [D-26, D-27, D-28, SC-3]
|
||||
estimate:
|
||||
tokens: 180000
|
||||
raw_tokens: 180000
|
||||
tasks: 7
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-26: on an email match, a verified provider's identity is linked automatically only when the matched account's users.email_verified_at is set. POST /_user/api/v1/register in activate_mode=auto followed by a verified Google sign-in for the same email ends on the pending screen with reason=unverified_match and provider=google, links no identity, and its oauth-register-complete answers 422 email_exists_link_account without consuming the pending code (TestOwnerVerifiedEmail/register_auto_then_google_pending)."
|
||||
- "D-26: an account whose owner activated it with the mailed code (activate-by-code or activate) or completed a mailed reset-code password reset receives the verified identity and is signed in (TestOwnerVerifiedEmail/mail_activated_by_code_links, reset_code_password_links; TestPreAccountTakeover/after_mailbox_proof_links drives the real reset routes)."
|
||||
- "D-26: accounts without recorded proof take the pending path: /register in auto or admin mode, an administrator's create and Activate action, a social sign-in (even from a verifying provider), and every account that existed before sm-user-plugin v0.1.5. No migration or code path backfills the column."
|
||||
- "D-27: users.email_verified_at is a nullable TIMESTAMPTZ added by migration 202610210001_add_users_email_verified_at (ADD COLUMN IF NOT EXISTS, no row written, no-op rollback). classes.VerifyActivationCode and the ResetPassword completion stamp it; /update and the admin Users form clear it when the email changes (a case-only change keeps it); it is not fillable, not in any user API payload and not in the admin form or list. No route, payload key or config key of sm-user-plugin changes."
|
||||
- "D-27 wording: the READMEs of sm-user-plugin and sm-sociallogin-plugin, the PHP map row of test_verified_provider_email_still_auto_links and 19.5-SECURITY-REVIEW.md describe proof as email_verified_at (mail activation or the reset code); none of them says a self-set password proves the address."
|
||||
- "D-28: src/views/OAuthCallbackView.test.ts mounts the real view in vitest and proves that router.replace drops ?code= before the single completeOAuth call (the route has no code when the call starts), that a second mount with the same code does not call completeOAuth again, that ?error= renders its message in the alert and that ?pending_registration= replaces to the oauth-register route with pending, provider, reason and email. frontend/package.json and package-lock.json are unchanged."
|
||||
- "scripts/check-phase19.5.sh fails closed on the new tests (TestOwnerVerifiedEmail, TestEmailVerifiedAt, TestAdminEmailVerifiedAt, TestEmailVerifiedAtMigration, TestCreateOrganisationsMigration, src/views/OAuthCallbackView.test.ts). Its migration check accepts exactly the one added migration file and refuses any other added, modified, renamed or deleted non-test file under updates/, and a migration that writes or drops anything; --self-test proves both rules; PHASE195_REQUIRE_DOCKER=1 --all passes."
|
||||
- "SC-3 and D-21 order: under the Task 6 go-ahead, sm-user-plugin v0.1.5 and then sm-sociallogin-plugin v0.1.2 (requiring sm-user-plugin v0.1.5) are pushed and tagged. sm-demo-app and sm-bm-app move plugins/golem15/user and then plugins/golem15/sociallogin in separate commits, and fonoteka.go moves plugins/golem15/user. Each application's gate stage is green at its new pins. On hold, nothing is pushed or tagged and a todo with a g15office task tracks the release."
|
||||
artifacts:
|
||||
- path: "../fonoteka.go/plugins/golem15/user/updates/202610210001_add_users_email_verified_at.go"
|
||||
provides: "the additive users.email_verified_at column"
|
||||
contains: "ADD COLUMN IF NOT EXISTS email_verified_at"
|
||||
- path: "../fonoteka.go/plugins/golem15/user/models/user.go"
|
||||
provides: "User.EmailVerifiedAt"
|
||||
contains: "gorm:\"column:email_verified_at\" json:\"-\""
|
||||
- path: "../fonoteka.go/plugins/golem15/user/email_verified_test.go"
|
||||
provides: "TestEmailVerifiedAt, TestAdminEmailVerifiedAt"
|
||||
contains: "TestAdminEmailVerifiedAt"
|
||||
- path: "../fonoteka.go/plugins/golem15/user/updates/email_verified_at_test.go"
|
||||
provides: "TestEmailVerifiedAtMigration"
|
||||
contains: "TestEmailVerifiedAtMigration"
|
||||
- path: "../sm-sociallogin-plugin/classes/flow.go"
|
||||
provides: "ownerVerifiedEmail reads the proof column"
|
||||
contains: "EmailVerifiedAt != nil"
|
||||
- path: "../sm-sociallogin-plugin/owner_verified_test.go"
|
||||
provides: "TestOwnerVerifiedEmail"
|
||||
contains: "register_auto_then_google_pending"
|
||||
- path: "../sm-bm-app/frontend/src/views/OAuthCallbackView.test.ts"
|
||||
provides: "the bm callback view test (D-28)"
|
||||
contains: "completeOAuth"
|
||||
- path: "scripts/check-phase19.5.sh"
|
||||
provides: "the fail-closed gate with the new tests and the migration allow-list"
|
||||
contains: "USER_ADDED_MIGRATIONS"
|
||||
key_links:
|
||||
- from: "../fonoteka.go/plugins/golem15/user/classes/codes.go"
|
||||
to: "users.email_verified_at"
|
||||
via: "VerifyActivationCode stamps the column in the same column write that activates the account"
|
||||
pattern: "email_verified_at"
|
||||
- from: "../fonoteka.go/plugins/golem15/user/controllers/api_controller.go"
|
||||
to: "users.email_verified_at"
|
||||
via: "ResetPassword stamps it in the write that stores the new password; Update clears it on an email change"
|
||||
pattern: "email_verified_at"
|
||||
- from: "../sm-sociallogin-plugin/classes/flow.go"
|
||||
to: "../fonoteka.go/plugins/golem15/user/models/user.go"
|
||||
via: "ownerVerifiedEmail reads User.EmailVerifiedAt in HandleCallback and CompletePending"
|
||||
pattern: "EmailVerifiedAt"
|
||||
- from: "scripts/check-phase19.5.sh"
|
||||
to: "../sm-bm-app/frontend/src/views/OAuthCallbackView.test.ts"
|
||||
via: "the bm stage requires the view test file to pass by name (REQUIRE_FILES)"
|
||||
pattern: "OAuthCallbackView.test.ts"
|
||||
- from: "../sm-sociallogin-plugin/go.mod"
|
||||
to: "sm-user-plugin v0.1.5"
|
||||
via: "Task 7 requires the tag that carries the column; app pins move user first, then sociallogin"
|
||||
pattern: "sm-user-plugin v0.1.5"
|
||||
prohibitions:
|
||||
- statement: "sm-user-plugin stays additive (core plugin, D-27): no route, user API payload key, config key, existing migration file or migration ID changes; the one new migration adds a nullable column and writes no rows."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Proof is recorded only by the mailed activation code and the completed reset-code password reset. /register in auto or admin mode, RegisterUser, the admin create, the admin Activate action, change-password and every sociallogin path never write email_verified_at."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No new npm dependency in sm-bm-app/frontend and no new Go module anywhere. If the view cannot be mounted with the installed packages, the plan stops at a checkpoint instead of adding one."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed or tagged before the Task 6 reply. Nothing is force-pushed or deployed. fonoteka.go and summercms.go are never pushed by this plan, and only plugins/golem15/user is staged in fonoteka.go."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal: `sm-user-plugin` signs users in with Google and Facebook through `/oauth/{provider}`, with the SPA one-time-code handoff and the pending-registration branch, matching the PHP user plugin's contract. Per CONTEXT D-01, the code is delivered by `sm-sociallogin-plugin`. The goal is not a user story, and plans 01-04 recorded the decision not to invent one, so this plan does not invent one either. SC-3 ("identities are linked on login ... both applications still build and pass their tests") is the success criterion this gap closure touches.
|
||||
|
||||
This plan (5 of 5, wave 5, `gap_closure: true`) closes the two open items of 19.5-VERIFICATION.md under the user decisions D-26, D-27 and D-28 (CONTEXT, "Decisions after verification"):
|
||||
|
||||
1. **Human verification item 1, the residual pre-account takeover** (19.5-REVIEW CR-01). `ownerVerifiedEmail` treats a self-set password as proof. A `/register` in `activate_mode: auto` (the default) creates an activated account with a self-set password and no mailbox check, so a later verified Google or GitHub sign-in for the same email is linked onto the account of whoever registered it. D-26 and D-27 replace the password flag with recorded proof, `users.email_verified_at`.
|
||||
2. **Human verification item 2 / truth 16, the bm callback view.** `OAuthCallbackView.vue` has no test. D-28 adds a vitest view test.
|
||||
|
||||
Item 3 (the prohibition verdicts) stays a manual sign-off and is not touched here.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
spec-less probe fallback skipped: the phase has no REQUIREMENTS.md IDs (visible skip). `must_haves` derive from D-26..D-28 and SC-3.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion inside D-26..D-28)
|
||||
|
||||
- **Proof writers.** `classes.VerifyActivationCode` stamps `email_verified_at` in the same column write that activates the account. It serves `POST /activate`, `POST /activate-by-code` and the administrator's invitation link, which mails the same activation code, so an invited user who follows the mail has proved the address. `controllers.ResetPassword` stamps it in the same write that stores the new password, so a reset that fails records nothing (D-27: "a completed reset-code password reset"). `classes.VerifyResetCode` is unchanged. The value is the time of the proof and overwrites an earlier one.
|
||||
- **Not proof (D-26).**
|
||||
- `/register` in `auto` or `admin` mode and `RegisterUser`: nobody read a mail.
|
||||
- The admin create (`FormBeforeCreate`) and the admin Activate action (`classes.ActivateUsers`): an administrator typing or vouching for an address does not show that the user controls the mailbox.
|
||||
- `ChangePassword`: it needs the current password, not the mailbox.
|
||||
- Any sociallogin path, including an account created from a verifying provider: D-27 names exactly two proof sources, and the provider's claim is already the other half of the auto-link test.
|
||||
- **The proof follows the address.** `/update` and the admin Users form set `email_verified_at` to NULL when the email changes. A change of letter case only (`strings.EqualFold`) keeps it, because matching is case-insensitive. Without this, an account could prove `attacker@…`, switch to the victim's address and receive the victim's verified identity: the same takeover by another route (T-19.5-40). This changes only an internal column, with no payload, route or config change, so D-27's additive rule holds.
|
||||
- **Migration shape.** ID `202610210001_add_users_email_verified_at`, `ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ NULL`, rollback a no-op. This follows the `202610160002_add_handle_to_users` precedent for a shared `users` column that may already exist: a Laravel-derived schema can carry `email_verified_at`, and its values mean the same thing, an address confirmed by a mailed link. The migration writes no rows.
|
||||
- **No backfill (D-26 default).** Accounts from before v0.1.5, PHP-imported data included, have no proof and take the pending path; their owners link from the settings or reset the password once. The README tells an application that trusts its imported addresses to set the column in its own data migration. Neither plugin does that.
|
||||
- **Field.** `models.User.EmailVerifiedAt *time.Time` with `gorm:"column:email_verified_at" json:"-"`. It is not added to `Fillable()`, to `apiArray`, or to the admin `fields.yaml` and `columns.yaml`.
|
||||
- **Matching.** sociallogin's `ownerVerifiedEmail(u)` becomes `u != nil && u.EmailVerifiedAt != nil` and no longer reads the self-set-password flag. sm-user-plugin's D-13 unlink rule still uses that flag, which is unrelated and unchanged.
|
||||
- **Test fixtures.** sociallogin's harness `e.user` now creates a proven account (`EmailVerifiedAt` set, as after a mail activation), so the verified-link cases of plans 02-04 keep testing the link path. A new `e.unprovenUser` models an account without proof.
|
||||
- **bm view test without a new dependency.** The bm frontend has no `@vue/test-utils`, jsdom or happy-dom (`test.environment: 'node'`). The test mounts the real SFC with `createRenderer` from `vue`, using a small in-memory host. It uses a vue-router `createMemoryHistory` router whose routes include `oauth-register`, and `vi.mock` stubs for `@/stores/auth` and `@/stores/toast`. Those stubs are required because `@/stores/auth` imports `@/router`, whose `createWebHistory` needs `window`. If that cannot work with the installed packages, the plan stops (Task 4).
|
||||
- **Versions.** sm-user-plugin **v0.1.5** and sm-sociallogin-plugin **v0.1.2**. If either tag already exists on its remote, use the next free patch and record the change as a deviation. summercms.go gets no production change, so it gets no framework tag and no changelog post (the CLAUDE.md changelog rule covers framework tags, as in 19.5-04).
|
||||
- **Out of scope, surfaced instead of decided.** 19.5-REVIEW CR-02 is not covered by D-26..D-28 and has no user decision. In that scenario, a pre-planted unverified identity survives the owner's reset and keeps signing in. Task 5 records it in 19.5-SECURITY-REVIEW.md as an open finding awaiting a decision, with no disposition invented.
|
||||
- **Context budget and hand-offs.** The estimate is 180k across 7 tasks, above the 100k smart zone. The user confirmed one plan, so execution splits it into three contexts, as plan 04 did:
|
||||
1. The first agent runs Tasks 1-3, writes `19.5-05-SUMMARY.md` as a draft headed `Status: draft (hand-off after Task 3)`, and stops. The draft lists the commits per repository with hashes, the test names and the gate stages with their state.
|
||||
2. A fresh agent runs Tasks 4 and 5 and stops at the Task 6 checkpoint.
|
||||
3. A continuation agent runs Task 7 after the reply and finalizes the SUMMARY.
|
||||
|
||||
## Source coverage audit
|
||||
|
||||
| Source | Item | Covered by |
|
||||
|---|---|---|
|
||||
| GOAL / SC-3 | Identities linked on login; consuming apps build and pass | Tasks 1, 3 (linking rule), 7 (app pins and gates) |
|
||||
| CONTEXT | D-26 (proof required; auto-mode and admin-created are not proof; existing accounts pending) | Tasks 1, 2, 3 |
|
||||
| CONTEXT | D-27 (additive column, two writers, v0.1.5 then v0.1.2 then pointer bumps, wording corrected) | Tasks 1, 2, 3, 5, 6, 7 |
|
||||
| CONTEXT | D-28 (bm view test, no new npm dependency) | Task 4 |
|
||||
| VERIFICATION | human item 1 / item 2 (truth 16) | Tasks 1-3 / Task 4 |
|
||||
| REQ | none (phase has no REQUIREMENTS.md IDs) | n/a |
|
||||
| RESEARCH | no new item for this follow-up | n/a |
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- **sm-user-plugin** (`../fonoteka.go/plugins/golem15/user`):
|
||||
- migration file `updates/202610210001_add_users_email_verified_at.go` (variable `usersEmailVerifiedAtMigrations`, ID `202610210001_add_users_email_verified_at`) and column `users.email_verified_at TIMESTAMPTZ NULL`;
|
||||
- field `models.User.EmailVerifiedAt`;
|
||||
- stamping in `classes.VerifyActivationCode` and `controllers.ResetPassword`, clearing in `controllers.Update` and `usersAdminController.FormBeforeUpdate`;
|
||||
- tests `TestEmailVerifiedAt` and `TestAdminEmailVerifiedAt` (root package), `TestEmailVerifiedAtMigration` (`updates`), and an updated `TestCreateOrganisationsMigration`;
|
||||
- README: the Features bullet "Address proof" and a migrations row;
|
||||
- tag `v0.1.5`.
|
||||
- **sm-sociallogin-plugin**:
|
||||
- `ownerVerifiedEmail` reads `EmailVerifiedAt`;
|
||||
- harness `e.user` (proven) and `e.unprovenUser`;
|
||||
- `owner_verified_test.go` with `TestOwnerVerifiedEmail`, subtests `register_auto_then_google_pending`, `mail_activated_by_code_links`, `reset_code_password_links`, `legacy_account_pending`, `register_action_pending`, `admin_activation_not_proof`, `email_change_drops_proof` and `unverified_provider_never_links_proven_account`;
|
||||
- `TestPreAccountTakeover/after_mailbox_proof_links` now drives `/forgot-password` and `/reset-password`;
|
||||
- the PHP map reason, the README matching table, security notes and Dependencies;
|
||||
- commit `chore: require sm-user-plugin v0.1.5`; tag `v0.1.2`.
|
||||
- **sm-bm-app**:
|
||||
- `frontend/src/views/OAuthCallbackView.test.ts`;
|
||||
- commits `chore: bump sm-user-plugin to v0.1.5` and `chore: bump sm-sociallogin-plugin to v0.1.2`.
|
||||
- **sm-demo-app**: the same two bump commits.
|
||||
- **fonoteka.go**: `chore: bump sm-user-plugin to v0.1.5`, staging only `plugins/golem15/user`, committed locally.
|
||||
- **summercms.go**:
|
||||
- `scripts/check-phase19.5.sh`:
|
||||
- array `USER_ADDED_MIGRATIONS`;
|
||||
- functions `refused_migration_changes` and `migration_writes_rows`;
|
||||
- array `DOCKER_USER_UPDATES_TESTS` and the `REQUIRE_FILES` list in `detect_vitest`;
|
||||
- the new required names and new `--self-test` cases;
|
||||
- `19.5-SECURITY-REVIEW.md`: T-19.5-38 corrected, T-19.5-39..T-19.5-46 added, T-19.5-26 closed by the view test, CR-02 recorded as open;
|
||||
- `19.5-VALIDATION.md`: rows `19.5-05-01`..`-07` and D-26, D-27 and D-28.
|
||||
- **On "hold" only:** todo `.planning/todos/pending/email-proof-release.md` with its g15office task.
|
||||
|
||||
<objective>
|
||||
Close the password-registration pre-account takeover by making recorded proof of the address, `users.email_verified_at`, the only owner-side condition for auto-linking a verified provider (D-26, D-27). Pin the bm OAuth callback view's ordering with a vitest view test (D-28). Extend the fail-closed gate to every new test. Then release in D-21 order under the user's go-ahead.
|
||||
|
||||
Purpose: VERIFICATION human items 1 and 2; SC-3's linking rule made safe under the default `activate_mode: auto`.
|
||||
Output: commits in sm-user-plugin, sm-sociallogin-plugin, sm-bm-app and summercms.go (gate and planning docs). Under the Task 6 reply, also tags v0.1.5 and v0.1.2 and the pointer bumps in sm-demo-app, sm-bm-app and fonoteka.go.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-CONTEXT.md
|
||||
@.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-VERIFICATION.md
|
||||
@.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-04-SUMMARY.md
|
||||
|
||||
Repos and paths (verified with `git ls-files` at planning time):
|
||||
- sm-user-plugin is the checkout at `../fonoteka.go/plugins/golem15/user` (remote `git@git.golem15.com:golem15/sm-user-plugin.git`, HEAD c7635c5, latest tag v0.1.4). It is tested through the fonoteka.go workspace.
|
||||
- sm-sociallogin-plugin is at `../sm-sociallogin-plugin` (HEAD d7ee28d = v0.1.1). Its committed `go.work` replaces sm-user-plugin with `../fonoteka.go/plugins/golem15/user`, so the plugin compiles against the new field before the tag exists; its go.mod still requires v0.1.4 until Task 7.
|
||||
- sm-bm-app frontend: `../sm-bm-app/frontend`; the user and sociallogin plugins are submodules at `plugins/golem15/user` and `plugins/golem15/sociallogin` (both pinned c43ba87 / d7ee28d); go.mod replaces them with the submodule paths.
|
||||
- sm-demo-app: submodules at the same paths and pins; go.work replaces sm-user-plugin with `./plugins/golem15/user`; go.mod requires `sm-user-plugin v0.1.4` and `sm-sociallogin-plugin v0.1.1`, which `TestGoModRequiresTags` and `scripts/check-pins.sh` hold to tags.
|
||||
- fonoteka.go: gitlink `plugins/golem15/user` at c43ba87. Its worktree already shows other sessions' modified gitlinks (`plugins/golem15/feedback`, `plugins/golem15/golem`), and `origin/master..master` holds commits that are not this phase's, so this plan commits there locally and never pushes.
|
||||
|
||||
Rules: no co-author tags; stage by path, never `git add -A`; one logical change per commit; planning docs and code in separate commits. Before touching a shared file (the gate script, the planning docs), check `git status` for another session's uncommitted edits and leave them alone. Docker-backed tests skip under `-short`; run them on a Docker host (through `sudo -u golem` when the socket is denied). Keep `go vet` and `go test -short` green in every repository at every commit.
|
||||
|
||||
<interfaces>
|
||||
- sm-user-plugin:
|
||||
- `models.User` (models/user.go): fields `HasSelfSetPassword bool`, `ActivatedAt *time.Time`, `LastSeen *time.Time` (`json:"-"` precedent); `Fillable()` excludes timestamps.
|
||||
- `classes.VerifyActivationCode(ctx, db, userID, code) (*models.User, bool)` (classes/codes.go:117): the `UpdateColumns` map with `is_activated`, `activated_at`, `activation_code`, `activation_code_issued_at`.
|
||||
- `classes.VerifyResetCode` (codes.go:94) sets `has_self_set_password` and clears the code.
|
||||
- `controllers.ResetPassword` (api_controller.go:290): `Updates(map{password, tokens_valid_after})` after `VerifyResetCode`.
|
||||
- `controllers.Update` (api_controller.go:439): `Updates(map{name, surname, email})` on the authenticated user.
|
||||
- `controllers.Register` (:634) with `registrationSettings` mode auto|user|admin.
|
||||
- `usersAdminController.FormBeforeUpdate` (users_admin_controller.go:215) and `guardCredentials` (:468), which already plucks the stored email; cabana writes the record with `tx.Unscoped().Save(target)` (summercms.go `modules/cabana/crud.go:830`), so a field set on the model in `FormBeforeUpdate` is persisted.
|
||||
- `classes.ActivateUsers` (admin_actions.go:64).
|
||||
- Migrations self-register through `updates.Register` in `init()`, ordered by file name; `TestCreateOrganisationsMigration` (updates/organisations_test.go) asserts the exact history list and rolls back 11 steps.
|
||||
- Test helpers: `sessionApp`, `insertUser`, `loginToken`, `postJSON`, `bearer` (session_test.go), `bearerJSON`, `captureMail`; `newAdminEnv`, `env.admin`, `env.seedUser`, `env.reload`, `env.bulk`, `env.mails` (admin_harness_test.go); `dedicatedDB` (updates/postgres_test.go); `TestAdminUserInvite` (admin_users_test.go:1105) shows the invitation flow.
|
||||
- sm-sociallogin-plugin:
|
||||
- `ownerVerifiedEmail` (classes/flow.go:262), used in `HandleCallback` (:98) and `CompletePending` (:203).
|
||||
- Harness: `bootSocial`, `bootSocialWith`, `e.user` (harness_test.go:176), `e.credentials`, `e.setConfig`, `e.identityOwner`, `e.complete`, `e.do`, `e.browser` (harness_test.go); `e.flow`, `registerQuery`, `spaQuery`, `freshIP`, `fromIP`, `e.postRegisterComplete` (unit_helpers_test.go); `gothFakeRegistry(t, apis)` and `e.flowAt` (php_port_test.go:38-75); `newFakeAPIs`, `fakeToken` (providers_goth_test.go); `e.putPending`, `e.pendingExists` (register_complete_test.go); `e.userCount` (registration_test.go).
|
||||
- `TestPreAccountTakeover` (matching_test.go:561).
|
||||
- The Google userinfo route `www.googleapis.com/oauth2/v2/userinfo` with `verified_email`.
|
||||
- The en message for a refused completion: "An account with this email already exists. Please sign in and link your Google account from settings." (label substituted).
|
||||
- bm frontend:
|
||||
- `src/views/OAuthCallbackView.vue`: a module-level `redeemed` Set; `onMounted` replaces to `oauth-register` for pending, else `router.replace({ path })` then `auth.completeOAuth(code)`.
|
||||
- `src/lib/oauth.ts` `parseOAuthCallback`; `src/stores/auth.ts` imports `@/router`; `src/router.ts` uses `createWebHistory`.
|
||||
- `vite.config.ts` `test: { environment: 'node', include: ['src/**/*.test.ts'] }`; tsconfig includes `src/**/*.ts` (tests are typechecked, `noUnusedLocals`).
|
||||
- package.json devDependencies: `@vitejs/plugin-vue`, `typescript`, `vite`, `vitest`, `vue-tsc` only.
|
||||
- Gate: `scripts/check-phase19.5.sh` (`run_userplugin` migration check at :467-478, `detect_vitest` at :215 with `REQUIRE_FILE`, `run_bm` at :521, `run_docker` at :570, `run_self_test` at :349).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: users.email_verified_at end to end: mail activation and the reset code stamp it, sociallogin links a verified email only onto a proven account, and an auto-mode /register account goes pending</name>
|
||||
<files>../fonoteka.go/plugins/golem15/user/updates/202610210001_add_users_email_verified_at.go, ../fonoteka.go/plugins/golem15/user/updates/organisations_test.go, ../fonoteka.go/plugins/golem15/user/models/user.go, ../fonoteka.go/plugins/golem15/user/classes/codes.go, ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go, ../fonoteka.go/plugins/golem15/user/README.md, ../sm-sociallogin-plugin/classes/flow.go, ../sm-sociallogin-plugin/harness_test.go, ../sm-sociallogin-plugin/owner_verified_test.go, ../sm-sociallogin-plugin/matching_test.go, ../sm-sociallogin-plugin/README.md, scripts/check-phase19.5.sh</files>
|
||||
<read_first>../fonoteka.go/plugins/golem15/user/updates/202610160002_add_handle_to_users.go, ../fonoteka.go/plugins/golem15/user/updates/registry.go, ../fonoteka.go/plugins/golem15/user/updates/organisations_test.go, ../fonoteka.go/plugins/golem15/user/models/user.go, ../fonoteka.go/plugins/golem15/user/classes/codes.go, ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (ResetPassword :290-342), ../fonoteka.go/plugins/golem15/user/README.md (Features, migrations tables :141-160), ../sm-sociallogin-plugin/classes/flow.go (:55-120, :190-265), ../sm-sociallogin-plugin/harness_test.go, ../sm-sociallogin-plugin/unit_helpers_test.go, ../sm-sociallogin-plugin/php_port_test.go (:30-75), ../sm-sociallogin-plugin/matching_test.go (:555-640), ../sm-sociallogin-plugin/README.md (:40-60, :138-155), scripts/check-phase19.5.sh, 19.5-CONTEXT.md (D-26, D-27), 19.5-REVIEW.md (CR-01)</read_first>
|
||||
<action>The thinnest path through every layer, per D-26 and D-27: the column, two writers, the reader in the matching, one end-to-end test, and the gate.
|
||||
|
||||
sm-user-plugin:
|
||||
- New migration file `updates/202610210001_add_users_email_verified_at.go`, shaped like `202610160002_add_handle_to_users.go`:
|
||||
- variable `usersEmailVerifiedAtMigrations`, ID `202610210001_add_users_email_verified_at`;
|
||||
- Migrate runs `ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ NULL` and nothing else;
|
||||
- Rollback is a no-op, with the handle file's reason (a shared users column that may predate the plugin);
|
||||
- registered in `init()`;
|
||||
- the doc comment says the column records when the owner proved the address (D-27), that the migration writes no rows (no backfill, D-26) and that an existing column keeps its values.
|
||||
- `models.User` gains `EmailVerifiedAt *time.Time` with tag `gorm:"column:email_verified_at" json:"-"` and a doc comment naming its writers. Do not add it to `Fillable()`.
|
||||
- `classes.VerifyActivationCode`: add `email_verified_at: now` to the existing `UpdateColumns` map and set `user.EmailVerifiedAt` (one write: activation and proof together). Update its doc comment.
|
||||
- `controllers.ResetPassword`: add `email_verified_at: now` to the `Updates` map that stores the new password and `tokens_valid_after`, so the proof exists only when the reset completed.
|
||||
- `updates/organisations_test.go`: append the new ID to the expected history and roll back 12 steps instead of 11, updating the comment.
|
||||
- README:
|
||||
- a Features bullet "Address proof": the column, its two writers, that /register in auto or admin mode, the admin create and Activate action and social sign-ins record none, and no backfill;
|
||||
- a table "Migrations added for address proof, all additive" with the row for `202610210001_add_users_email_verified_at` (`TIMESTAMPTZ` NULL, `ADD COLUMN IF NOT EXISTS`, no backfill, rollback a no-op).
|
||||
- One commit: `feat: users.email_verified_at records a proved address (mail activation, reset code)`.
|
||||
|
||||
sm-sociallogin-plugin:
|
||||
- `ownerVerifiedEmail` returns `u != nil && u.EmailVerifiedAt != nil`. Rewrite its doc comment: proof means a mailed activation code or a completed reset-code password reset (D-26); a self-set password alone is not proof (`/register` in auto mode, admin-created); accounts without proof go to the pending screen and their owner links from the settings. Keep the line in `HandleCallback`'s doc comment that names `ownerVerifiedEmail`.
|
||||
- Harness:
|
||||
- `e.user` sets `EmailVerifiedAt` to now and its doc comment says the account is proven (as after a mail activation), so the verified-link cases of plans 02-04 keep testing the link path;
|
||||
- add `e.unprovenUser(t, email)`: activated, self-set password, no proof.
|
||||
- New `owner_verified_test.go` with helpers on `*socialEnv`, each going through the user plugin's real routes on the test server with `fromIP(nil, freshIP())`:
|
||||
- `registerWithPassword` posts JSON to `/_user/api/v1/register` (email, password, password_confirmation);
|
||||
- `activateByMail` reads `activation_code` from the users row and posts `{id}!{code}` to `/_user/api/v1/activate-by-code`;
|
||||
- `resetByMail` posts `/_user/api/v1/forgot-password`, reads `reset_password_code` and posts `/_user/api/v1/reset-password`;
|
||||
- a Google sign-in helper through `gothFakeRegistry` (published in `bootSocialWith`), `e.credentials(t, "google")`, the token route `oauth2.googleapis.com/token` and a userinfo body with `verified_email` true, driven with `e.flowAt`.
|
||||
- `TestOwnerVerifiedEmail` has three subtests:
|
||||
- `register_auto_then_google_pending`:
|
||||
- with `golem15.user.activation.activate_mode` set to `auto` explicitly, `/register` answers 200 with a token, and the row has `is_activated` true and `email_verified_at` NULL;
|
||||
- a verified Google sign-in for the same email (upper-cased to prove the case-insensitive match) redirects with `pending_registration`, `reason=unverified_match`, `provider=google` and no `code`, and no google identity exists;
|
||||
- posting that code to `oauth-register-complete` answers 422 with the en `email_exists_link_account` message for Google, and `e.pendingExists` stays true;
|
||||
- `action=register` with consent for the same email also goes pending with no new users row (`e.userCount` 1).
|
||||
- `mail_activated_by_code_links`: with `activate_mode` user, `/register` answers "Activation email sent"; `activateByMail` sets the column; the verified Google sign-in redirects with `code`, the identity owner is that user, and `e.complete` returns that user's id.
|
||||
- `reset_code_password_links`: an auto-mode `/register` account, then `resetByMail`, then the verified Google sign-in links and signs in.
|
||||
- `TestPreAccountTakeover`:
|
||||
- its setup also requires the squatted account's `EmailVerifiedAt` to be nil;
|
||||
- `after_mailbox_proof_links` no longer writes the self-set-password column directly; it calls `resetByMail` for the squatted account, then asserts the link;
|
||||
- its doc comment says the owner proves the address with the mailed reset (`email_verified_at`).
|
||||
- README:
|
||||
- the "Account matching" rows: a match links only when the provider verified the email and the account's owner proved the address (`users.email_verified_at`: mail activation or a password set through the mailed reset code);
|
||||
- an account without proof (`/register` with `activate_mode: auto`, created by an administrator or by a social sign-in, or from before sm-user-plugin v0.1.5) goes pending with `unverified_match` even for a verified email, completion 422; the owner links from the settings or proves the address with a password reset;
|
||||
- the "Pre-account takeover" security note now explains this;
|
||||
- Dependencies name golem15.user v0.1.5 or newer (`users.email_verified_at`).
|
||||
- One commit: `fix: a verified sign-in links only onto an account that proved its address`.
|
||||
|
||||
Gate (`scripts/check-phase19.5.sh`):
|
||||
- Add `USER_ADDED_MIGRATIONS=(updates/202610210001_add_users_email_verified_at.go)` with a comment (D-27 relaxes D-22 for this one additive migration).
|
||||
- Add `refused_migration_changes`: it reads `git diff --name-status` lines and prints every line whose status is not `A` or whose path is not in `USER_ADDED_MIGRATIONS`.
|
||||
- Add `migration_writes_rows`: it reads a Go file, drops comment lines (`^\s*//`), and prints any line matching the uppercase SQL words that write or drop data.
|
||||
- In `run_userplugin`:
|
||||
- use `git diff --name-status "$USER_BASE" -- updates/ ':(exclude)*_test.go'`, keeping the existing capture of git's exit status;
|
||||
- refuse what `refused_migration_changes` prints, with the message "(D-22, D-27: only the email_verified_at migration may be added)";
|
||||
- for each allowed file present, require `ADD COLUMN IF NOT EXISTS email_verified_at` and refuse anything `migration_writes_rows` prints;
|
||||
- update the header comment of the userplugin stage.
|
||||
- `--self-test` cases:
|
||||
- accepted: an `A` line of the allowed file;
|
||||
- refused: an `M` line of `updates/00_base.go`; an `A` line of another file; a `D` line; an `R100` rename;
|
||||
- `migration_writes_rows` refuses a planted Exec of a row update and accepts a planted file whose only such word is in a `//` comment.
|
||||
- Append `TestOwnerVerifiedEmail` to `DOCKER_SOCIAL_TESTS` under a "Plan 05 (D-26)" comment.
|
||||
- Commit: `test(19.5): gate the email_verified_at migration and TestOwnerVerifiedEmail`.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-sociallogin-plugin test -count=1 -run '^(TestOwnerVerifiedEmail|TestPreAccountTakeover)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>The count of top-level "--- PASS: Test" lines is not 2: the auto-mode account was linked, a proven account was not linked, a subtest failed or skipped (Docker unreachable: rerun through sudo -u golem), or the package does not compile against the workspace user plugin.</fails_when>
|
||||
<automated>bash scripts/check-phase19.5.sh --self-test && bash scripts/check-phase19.5.sh --userplugin && bash scripts/check-phase19.5.sh --sociallogin</automated>
|
||||
<fails_when>Non-zero exit: a planted migration case is not caught or the allowed file is refused, the user plugin's vet or short tests fail, a removed export is found, a non-allowed file under updates/ changed, the new migration writes rows or lacks the ADD COLUMN, or a sociallogin Docker-free test fails.</fails_when>
|
||||
<automated>bash scripts/check-phase19.5.sh --docker</automated>
|
||||
<fails_when>Non-zero exit: an existing Postgres test of the user plugin or sociallogin (for example TestMatching, TestFlowSmoke, TestPHPPendingRegistrationTakeover) no longer passes with the proven-fixture semantics, TestOwnerVerifiedEmail is missing or skipped, or no Docker daemon answered (rerun through sudo -u golem; otherwise record NOT RUN).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'ADD COLUMN IF NOT EXISTS email_verified_at' ../fonoteka.go/plugins/golem15/user/updates/202610210001_add_users_email_verified_at.go` prints 1, and `grep -v '^\s*//' ../fonoteka.go/plugins/golem15/user/updates/202610210001_add_users_email_verified_at.go | grep -cE '\b(UPDATE|INSERT|DELETE|DROP)\b'` prints 0.
|
||||
- `grep -c 'gorm:"column:email_verified_at" json:"-"' ../fonoteka.go/plugins/golem15/user/models/user.go` prints 1.
|
||||
- `grep -c 'email_verified_at' ../fonoteka.go/plugins/golem15/user/classes/codes.go` prints at least 1 and `grep -c 'email_verified_at' ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go` prints at least 1.
|
||||
- `grep -c '202610210001_add_users_email_verified_at' ../fonoteka.go/plugins/golem15/user/updates/organisations_test.go` prints 1.
|
||||
- `grep -c 'EmailVerifiedAt != nil' ../sm-sociallogin-plugin/classes/flow.go` prints at least 1 and `grep -c 'HasSelfSetPassword' ../sm-sociallogin-plugin/classes/flow.go` prints 0.
|
||||
- `grep -cE 't.Run\("(register_auto_then_google_pending|mail_activated_by_code_links|reset_code_password_links)"' ../sm-sociallogin-plugin/owner_verified_test.go` prints 3.
|
||||
- `grep -c 'and the account has a self-set password' ../sm-sociallogin-plugin/README.md` prints 0 and `grep -c 'email_verified_at' ../sm-sociallogin-plugin/README.md` prints at least 2.
|
||||
- `grep -c 'USER_ADDED_MIGRATIONS' scripts/check-phase19.5.sh` prints at least 2 and `grep -c 'TestOwnerVerifiedEmail' scripts/check-phase19.5.sh` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>A password-registered account without mailbox proof no longer receives a verified provider's identity, a mail-activated or reset account does, and the gate runs the end-to-end test by name.</done>
|
||||
<reversibility rating="costly">A published migration in a shared core plugin: once v0.1.5 runs in an application, the column exists there for good (the rollback is a no-op on purpose). The user approved the column in D-27.</reversibility>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: sm-user-plugin: the proof follows the address (an email change through /update or the admin form clears it), only the two mailed flows write it, and the migration writes no rows: TestEmailVerifiedAt, TestAdminEmailVerifiedAt, TestEmailVerifiedAtMigration</name>
|
||||
<files>../fonoteka.go/plugins/golem15/user/controllers/api_controller.go, ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go, ../fonoteka.go/plugins/golem15/user/email_verified_test.go, ../fonoteka.go/plugins/golem15/user/updates/email_verified_at_test.go, ../fonoteka.go/plugins/golem15/user/README.md, scripts/check-phase19.5.sh</files>
|
||||
<read_first>../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (Update :439-486, Register :634-700, ChangePassword :492-588, apiArray :829), ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go (FormBeforeUpdate :215-237, FormBeforeCreate :243, guardCredentials :468-496, FormAfterCreate invite :305-350), ../fonoteka.go/plugins/golem15/user/classes/admin_actions.go (ActivateUsers :64), ../fonoteka.go/plugins/golem15/user/account_test.go, ../fonoteka.go/plugins/golem15/user/register_test.go, ../fonoteka.go/plugins/golem15/user/session_test.go (helpers :359-540), ../fonoteka.go/plugins/golem15/user/admin_harness_test.go, ../fonoteka.go/plugins/golem15/user/admin_users_test.go (TestAdminUserInvite :1105, TestAdminUserActions :575), ../fonoteka.go/plugins/golem15/user/updates/postgres_test.go, ../fonoteka.go/plugins/golem15/user/updates/admin_columns_test.go, ../fonoteka.go/plugins/golem15/user/README.md, scripts/check-phase19.5.sh</read_first>
|
||||
<behavior>
|
||||
- /register in auto mode and in admin mode leaves email_verified_at NULL; a submitted email_verified_at field is ignored (not fillable) on /register and on /update.
|
||||
- activate-by-code with the right code stamps it; /activate with the right code stamps it and with a wrong code leaves NULL; the response bodies carry no email_verified_at key.
|
||||
- reset-password with a valid code stamps it; a wrong or malformed code answers 422 and leaves NULL; change-password never stamps it.
|
||||
- /update with a different email clears a stamped proof; the same email in another letter case keeps it; the response body carries no email_verified_at key.
|
||||
- Admin: a user created on the Users form has NULL; the activate bulk action activates but leaves NULL; an invitation followed through activate-by-code stamps it; a form update that changes the email clears it; a form update that keeps the email keeps it; the admin form and list JSON carry no email_verified_at key.
|
||||
- Migration: the column is TIMESTAMPTZ and nullable; rows that exist before it runs stay NULL; a pre-existing column keeps its values; rollback leaves the column in place; the ID appears once in updates.All().
|
||||
</behavior>
|
||||
<action>Production changes, in one commit: `fix: an email change drops the address proof`.
|
||||
- `controllers.Update`: when the submitted email differs from the authenticated user's current email under `strings.EqualFold`, add `email_verified_at: nil` to the same `Updates` map and clear `user.EmailVerifiedAt`. Otherwise leave the column alone. The user payload is unchanged.
|
||||
- `usersAdminController.FormBeforeUpdate`: after the existing guards, when the stored email (the value `guardCredentials` already reads; an unexported helper may return it) differs from the submitted one under `strings.EqualFold`, set `user.EmailVerifiedAt` to nil. cabana's `Save` persists it.
|
||||
- Write the proof nowhere else (D-26): `Register`, `RegisterUser`, `FormBeforeCreate`, `classes.ActivateUsers` and `ChangePassword` stay as they are.
|
||||
- README: extend the "Address proof" bullet. An email change through /update or the admin form clears the proof. The column is never mass-assigned and never in a payload. An application that trusts its imported addresses may set the column in its own data migration; this plugin does not.
|
||||
|
||||
Tests, in one commit: `test: email_verified_at writers, non-writers and the migration`.
|
||||
- Root package `email_verified_test.go` (Postgres, skips under `-short`):
|
||||
- `TestEmailVerifiedAt` covers the API cases of the behavior block, one subtest each, through `sessionApp`, `insertUser`, `loginToken`, `postJSON`, `bearerJSON`, the controller handlers, `classes.IssueActivationCode` and `classes.IssueResetCode`. Subtest names: `register_auto_no_proof`, `register_admin_mode_no_proof`, `register_ignores_submitted_proof`, `activate_by_code_stamps`, `activate_stamps`, `activate_wrong_code_no_proof`, `reset_password_stamps`, `reset_wrong_code_no_proof`, `change_password_no_proof`, `update_email_change_clears`, `update_same_email_keeps`, `update_ignores_submitted_proof`. Each stamp is asserted to lie between the times taken before and after the request.
|
||||
- `TestAdminEmailVerifiedAt` covers the admin cases through `newAdminEnv`, `env.admin(t, permAccessUsers)`, the form create and update calls, `env.bulk` with the activate action and the invitation mail as in `TestAdminUserInvite`. Subtest names: `admin_create_no_proof`, `admin_activate_action_no_proof`, `admin_invite_link_stamps`, `admin_email_change_clears`, `admin_save_keeps_proof`, `admin_json_hides_proof`.
|
||||
- `updates/email_verified_at_test.go`: `TestEmailVerifiedAtMigration` on `dedicatedDB`, with subtests:
|
||||
- `column_shape`: information_schema `data_type` is `timestamp with time zone` and `is_nullable` is `YES`;
|
||||
- `no_backfill`: gormigrate `MigrateTo("202610160002_add_handle_to_users")`, insert activated users with self-set passwords, then `Migrate()`; every row has NULL;
|
||||
- `existing_column_kept`: on a fresh database, add the column by hand with a value before the migration runs; afterwards the migration ran and the value is unchanged;
|
||||
- `rollback_keeps_column`: `RollbackMigration(usersEmailVerifiedAtMigrations[0])` leaves the column;
|
||||
- `history_once`.
|
||||
|
||||
Gate, in one commit: `test(19.5): gate the user-plugin proof tests`.
|
||||
- Append `TestEmailVerifiedAt` and `TestAdminEmailVerifiedAt` to `DOCKER_USER_ROOT_TESTS` under a "Plan 05 (D-26, D-27)" comment.
|
||||
- Add `DOCKER_USER_UPDATES_TESTS=(TestEmailVerifiedAtMigration TestCreateOrganisationsMigration)` and run it in `run_docker` with `go_named "$FONOTEKA" ./plugins/golem15/user/updates "-timeout 20m"`.
|
||||
- Update the docker stage's header comment.</action>
|
||||
<verify>
|
||||
<automated>go -C ../fonoteka.go test -count=1 -run '^(TestEmailVerifiedAt|TestAdminEmailVerifiedAt)$' -v ./plugins/golem15/user 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>The count of top-level "--- PASS: Test" lines is not 2: a writer or non-writer behaves differently from the behavior block, an email change keeps the proof, a payload gained a key, or the tests skipped (Docker unreachable: rerun through sudo -u golem).</fails_when>
|
||||
<automated>go -C ../fonoteka.go test -count=1 -run '^(TestEmailVerifiedAtMigration|TestCreateOrganisationsMigration)$' -v ./plugins/golem15/user/updates 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>The count is not 2: the column has the wrong type or nullability, existing rows were stamped, a pre-existing column lost its value, the rollback removed the column, or the history list is wrong.</fails_when>
|
||||
<automated>bash scripts/check-phase19.5.sh --userplugin && bash scripts/check-phase19.5.sh --fonoteka && bash scripts/check-phase19.5.sh --docker</automated>
|
||||
<fails_when>Non-zero exit: the user plugin's vet or short suite, the additive and migration checks, the fonoteka.go short suite at the changed user plugin, or a required Docker test (including the new names) fails, skips or is missing.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'EqualFold' ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go` prints at least 1 and `grep -c 'EmailVerifiedAt' ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go` prints at least 1.
|
||||
- `grep -c 'func TestEmailVerifiedAt(' ../fonoteka.go/plugins/golem15/user/email_verified_test.go` prints 1 and `grep -c 'func TestAdminEmailVerifiedAt(' ../fonoteka.go/plugins/golem15/user/email_verified_test.go` prints 1.
|
||||
- `grep -cE 't.Run\("(update_email_change_clears|admin_email_change_clears|admin_activate_action_no_proof|register_auto_no_proof|reset_password_stamps|activate_by_code_stamps)"' ../fonoteka.go/plugins/golem15/user/email_verified_test.go` prints 6.
|
||||
- `grep -c 'func TestEmailVerifiedAtMigration(' ../fonoteka.go/plugins/golem15/user/updates/email_verified_at_test.go` prints 1 and `grep -c 't.Run("no_backfill"' ../fonoteka.go/plugins/golem15/user/updates/email_verified_at_test.go` prints 1.
|
||||
- `grep -c 'DOCKER_USER_UPDATES_TESTS' scripts/check-phase19.5.sh` prints at least 2 and `grep -cE 'TestEmailVerifiedAt|TestAdminEmailVerifiedAt' scripts/check-phase19.5.sh` prints at least 2.
|
||||
- `git -C ../fonoteka.go/plugins/golem15/user diff --name-only v0.1.4 -- routes.go config/ lang/ models/user/` prints nothing (no route, config key, phrase or admin YAML changed).
|
||||
</acceptance_criteria>
|
||||
<done>The proof column has exactly two writers, follows the address it proves, never leaks into a payload, and its migration is additive with no backfill. The gate runs every case by name.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: sm-sociallogin-plugin: the full owner-proof table end to end (legacy, register action, admin activation, email change, unverified provider), with the PHP map row and README wording corrected</name>
|
||||
<files>../sm-sociallogin-plugin/owner_verified_test.go, ../sm-sociallogin-plugin/testdata/PHP_METHOD_MAP.md, ../sm-sociallogin-plugin/README.md</files>
|
||||
<read_first>../sm-sociallogin-plugin/owner_verified_test.go (Task 1), ../sm-sociallogin-plugin/harness_test.go, ../sm-sociallogin-plugin/unit_helpers_test.go, ../sm-sociallogin-plugin/php_port_test.go (:120-160, :340-360), ../sm-sociallogin-plugin/testdata/PHP_METHOD_MAP.md (row test_verified_provider_email_still_auto_links), ../sm-sociallogin-plugin/php_method_map_test.go, ../sm-sociallogin-plugin/README.md, ../fonoteka.go/plugins/golem15/user/classes/admin_actions.go (ActivateUsers), ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (Update)</read_first>
|
||||
<behavior>
|
||||
- legacy_account_pending: an account made with e.unprovenUser (self-set password, no proof: an account from before v0.1.5) gets pending unverified_match from a verified Google sign-in; no identity; completion 422 keeps the code.
|
||||
- register_action_pending: action=register with consent for an unproven account's email goes pending unverified_match and creates no second users row.
|
||||
- admin_activation_not_proof: an account registered in activate_mode admin and activated through userclasses.ActivateUsers stays without proof and goes pending.
|
||||
- email_change_drops_proof: a proven account (activated by mail) changes its email to the victim's through POST /_user/api/v1/update with its bearer token; the row's proof is NULL; a verified Google sign-in for the victim's email goes pending and links nothing.
|
||||
- unverified_provider_never_links_proven_account: a Facebook sign-in (never verified, D-11) whose email matches a proven account goes pending unverified_match.
|
||||
</behavior>
|
||||
<action>Extend `TestOwnerVerifiedEmail` with the five subtests of the behavior block (D-26), using the Task 1 helpers. The Facebook case uses `gothFakeRegistry` with the Graph routes as in `TestPHPSocialAuthGate`. Every pending case also asserts that `e.identityOwner` is 0 for the provider id and that the redirect carries no `code`. One commit in the plugin: `test: owner proof table for verified sign-ins`.
|
||||
|
||||
`testdata/PHP_METHOD_MAP.md`, row `test_verified_provider_email_still_auto_links`: keep it `adapted` and rewrite its reason. The account in the port is proven (its email_verified_at is set, as after a mail activation). Go auto-links a verified email only onto an account whose owner proved the address (`users.email_verified_at`: mail activation or the reset code, D-26), a deliberate divergence from PHP against pre-account takeover (`TestPreAccountTakeover`, `TestOwnerVerifiedEmail`).
|
||||
|
||||
README:
|
||||
- In the "Account matching" section, add a sentence that an email change through golem15.user's /update or admin form drops the proof.
|
||||
- In "Security notes", the "Pre-account takeover" bullet covers both variants: an account created from an unverified provider email, and an account registered with a password but never proved. It says why a self-set password is not proof under `activate_mode: auto`.
|
||||
- One commit with the map: `docs: proof of address in the matching table and the PHP map`.
|
||||
|
||||
`TestPHPMethodMap` must stay green.
|
||||
|
||||
Hand-off point: after these commits, write the `19.5-05-SUMMARY.md` draft described under "Decisions taken in this plan" (context budget). This context ends here; a fresh agent runs Tasks 4 and 5.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-sociallogin-plugin test -count=1 -run '^(TestOwnerVerifiedEmail|TestPreAccountTakeover|TestPHPPendingRegistrationTakeover)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>The count of top-level "--- PASS: Test" lines is not 3: an unproven, admin-activated or email-changed account was linked, a proven account stopped linking, a subtest skipped (Docker unreachable: rerun through sudo -u golem), or the PHP port no longer passes.</fails_when>
|
||||
<automated>bash scripts/check-phase19.5.sh --sociallogin && bash scripts/check-phase19.5.sh --docker</automated>
|
||||
<fails_when>Non-zero exit: TestPHPMethodMap fails on the edited map, a Docker-free plugin test fails, or a required Postgres test of any repository fails, skips or is missing.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -cE 't.Run\("(legacy_account_pending|register_action_pending|admin_activation_not_proof|email_change_drops_proof|unverified_provider_never_links_proven_account)"' ../sm-sociallogin-plugin/owner_verified_test.go` prints 5.
|
||||
- `grep 'test_verified_provider_email_still_auto_links' ../sm-sociallogin-plugin/testdata/PHP_METHOD_MAP.md | grep -c 'email_verified_at'` prints 1.
|
||||
- `grep -c 'activate_mode: auto' ../sm-sociallogin-plugin/README.md` prints at least 1.
|
||||
- `test -f .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-05-SUMMARY.md` exits 0 and `grep -c 'Status: draft (hand-off after Task 3)' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-05-SUMMARY.md` prints 1 (until Task 7 finalizes it).
|
||||
</acceptance_criteria>
|
||||
<done>Every way an account can reach an email match without proof ends on the pending screen, and every mailed proof links. The README and the PHP map say so.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: bm: vitest view test for OAuthCallbackView (code out of the URL before one redeem, no second redeem, error message, pending route) with no new npm dependency, required by name in the gate</name>
|
||||
<files>../sm-bm-app/frontend/src/views/OAuthCallbackView.test.ts, scripts/check-phase19.5.sh</files>
|
||||
<read_first>19.5-05-SUMMARY.md draft (hand-off from Tasks 1-3), ../sm-bm-app/frontend/src/views/OAuthCallbackView.vue, ../sm-bm-app/frontend/src/lib/oauth.ts, ../sm-bm-app/frontend/src/lib/oauth.test.ts, ../sm-bm-app/frontend/src/stores/auth.ts, ../sm-bm-app/frontend/src/stores/toast.ts, ../sm-bm-app/frontend/src/router.ts (:1-20), ../sm-bm-app/frontend/src/api/http.ts (ApiError), ../sm-bm-app/frontend/src/api/http.test.ts (test style), ../sm-bm-app/frontend/vite.config.ts, ../sm-bm-app/frontend/tsconfig.json, ../sm-bm-app/frontend/package.json, scripts/check-phase19.5.sh (detect_vitest :213-232, run_bm :521-551, self-test :374-383)</read_first>
|
||||
<action>D-28. Create `src/views/OAuthCallbackView.test.ts` in `../sm-bm-app/frontend`, with no new dependency.
|
||||
- Stubs: `vi.mock('@/stores/auth')` returns a stub whose `completeOAuth` is a `vi.fn` and whose `loggedIn` is false. `vi.mock('@/stores/toast')` returns a stub with `show` as a `vi.fn`. This is needed because the real auth store imports `@/router`, which calls `createWebHistory` and needs `window`.
|
||||
- Mounting: mount the real `OAuthCallbackView.vue` with an app from `createRenderer` (exported by `vue`), over a minimal in-memory host defined in the test file: element and text nodes, `insert`, `remove`, `createElement`, `createText`, `createComment`, `setText`, `setElementText`, `parentNode`, `nextSibling`, and a `patchProp` that stores attributes. Use a router from `createRouter` with `createMemoryHistory` and the routes `/` (name dashboard), `/auth/oauth/callback` (this view), `/auth/oauth/rejestracja` (name `oauth-register`, a stub component), `/profil` and `/logowanie`. Push `/` first, then the callback URL, await `router.isReady()`, mount, and flush pending promises until the view settles.
|
||||
- A text helper reads the element whose `role` is `alert`.
|
||||
- Cases, one `it` each, each with a distinct code because the view's `redeemed` set lives at module level:
|
||||
1. `replaces the route before the single redeem`: `router.replace` (a `vi.spyOn`) is invoked before `completeOAuth` (compare `mock.invocationCallOrder`); inside the `completeOAuth` stub, `router.currentRoute.value.query.code` is undefined; `completeOAuth` is called exactly once with the code; afterwards `router.back()` lands on `/`, not on a URL with `code`.
|
||||
2. `does not redeem a code twice`: unmount and mount again on the same callback URL with the same code; `completeOAuth` is still called once in total.
|
||||
3. `shows the error message`: `?error=Test%20message` renders `Test message` in the alert, and `completeOAuth` is not called.
|
||||
4. `routes a pending registration`: `?pending_registration=p1&provider=microsoft&reason=unverified_match&email=a%40b.test` ends on the route named `oauth-register` with query pending, provider, reason and email, through `replace` (Back does not return to the callback); `completeOAuth` is not called.
|
||||
5. `explains an expired code`: `completeOAuth` rejects with `new ApiError(410, 'gone')`, and the alert reads `Link logowania wygasł lub został już użyty.`
|
||||
- The file must pass `npm run typecheck` (strict, `noUnusedLocals`).
|
||||
- If mounting turns out to be impossible with the installed packages (for example the SFC cannot run under the custom renderer in the node environment): do not add a dependency and do not change `vite.config.ts`'s environment. Stop and return `## CHECKPOINT REACHED` with the error and the dependency that would be needed, as the user asked.
|
||||
- Commit in sm-bm-app (stage only the test file): `test(frontend): OAuth callback view drops the code before one redeem`.
|
||||
|
||||
Gate:
|
||||
- `detect_vitest` takes `REQUIRE_FILES` (space-separated), and every listed file must appear as a passing `✓ <file> (N tests)` line. Keep reading `REQUIRE_FILE` as a one-entry fallback.
|
||||
- `run_bm` passes `src/lib/oauth.test.ts src/views/OAuthCallbackView.test.ts`.
|
||||
- The self-test adapts the five existing vitest cases and adds `missing-view-file`: output with only oauth.test.ts passing while both are required gives exit 5.
|
||||
- Update the bm stage's header comment.
|
||||
- Commit: `test(19.5): gate the bm callback view test`.</action>
|
||||
<verify>
|
||||
<automated>npm --prefix ../sm-bm-app/frontend run test -- src/views/OAuthCallbackView.test.ts 2>&1 | grep -qE '✓ src/views/OAuthCallbackView.test.ts \([5-9] tests?\)' && npm --prefix ../sm-bm-app/frontend run typecheck</automated>
|
||||
<fails_when>No passing line for the view test file with at least 5 tests (a case failed, the view could not mount, or the file is missing), or vue-tsc reports an error in the test file.</fails_when>
|
||||
<automated>git -C ../sm-bm-app diff --quiet HEAD -- frontend/package.json frontend/package-lock.json && bash scripts/check-phase19.5.sh --self-test && bash scripts/check-phase19.5.sh --bm</automated>
|
||||
<fails_when>Non-zero exit: package.json or the lockfile changed (a new dependency), the vitest detector does not refuse the planted missing-view-file output, or the bm stage (Go tests, the whole vitest suite with both required files, typecheck) fails.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `test -f ../sm-bm-app/frontend/src/views/OAuthCallbackView.test.ts` exits 0 and `grep -c 'createRenderer' ../sm-bm-app/frontend/src/views/OAuthCallbackView.test.ts` prints at least 1.
|
||||
- `grep -c 'createMemoryHistory' ../sm-bm-app/frontend/src/views/OAuthCallbackView.test.ts` prints at least 1 and `grep -c 'oauth-register' ../sm-bm-app/frontend/src/views/OAuthCallbackView.test.ts` prints at least 1.
|
||||
- `git -C ../sm-bm-app log -1 --format=%H -- frontend/package.json frontend/package-lock.json` prints the same hash before and after this task (record both in the SUMMARY): no commit of this plan touches the dependency files.
|
||||
- `grep -c 'OAuthCallbackView.test.ts' scripts/check-phase19.5.sh` prints at least 2 and `grep -c 'REQUIRE_FILES' scripts/check-phase19.5.sh` prints at least 2.
|
||||
</acceptance_criteria>
|
||||
<done>The bm callback view's ordering, single redeem, error display and pending routing are pinned by a test that runs in the gate, with the frontend's dependencies unchanged.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: Security review and validation map record the proof-of-address fix, the whole gate is green with Docker required, and the release table is ready for the go-ahead</name>
|
||||
<files>.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md, .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-VALIDATION.md</files>
|
||||
<read_first>19.5-05-SUMMARY.md draft, .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md, .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-VALIDATION.md, .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-REVIEW.md (CR-01, CR-02), this plan's threat_model, .planning/G15OFFICE.md</read_first>
|
||||
<action>19.5-SECURITY-REVIEW.md (D-27: correct the wording):
|
||||
- Add one sentence to the Result paragraph naming 19.5-05: it closes the password-registration variant through `users.email_verified_at`.
|
||||
- In the defects table, row d7ee28d: replace the closing claim about the reset with a note that 19.5-05 superseded the self-set-password test (T-19.5-39).
|
||||
- In the T-19.5-38 "New finding" row:
|
||||
- remove the parenthetical that says the reset works by setting the self-set-password flag;
|
||||
- describe the proof as `email_verified_at` (mail activation or a completed reset-code reset);
|
||||
- status "fixed in d7ee28d; the password-registration variant fixed in 19.5-05 (T-19.5-39)".
|
||||
- Rows T-19.5-10 and T-19.5-27: point their "creation path" notes at T-19.5-38 and T-19.5-39.
|
||||
- Row T-19.5-26: closed, with `src/views/OAuthCallbackView.test.ts` (B) as the view half's test.
|
||||
- Add register rows T-19.5-39 to T-19.5-46 from this plan's threat model, each with the implementation (file and function) and its test (name and repository) or the release control.
|
||||
- Under a new `## Open findings` heading, record 19.5-REVIEW CR-02 (a pre-planted unverified identity survives the owner's reset): status "open, no user decision; not covered by D-26..D-28". Invent no disposition.
|
||||
- Extend "Checks the plan names" with "the proof follows the address".
|
||||
|
||||
19.5-VALIDATION.md:
|
||||
- Rows `19.5-05-01` to `19.5-05-07` (task, decision, test names, gate stage, status). Tasks 6 and 7 are remote operations that name Task 7's acceptance criteria.
|
||||
- Decision-coverage rows D-26, D-27 and D-28.
|
||||
- The manual-only table keeps the live provider sign-in. The bm callback view is now automated (D-28), so note it under the table.
|
||||
- Keep `nyquist_compliant: true` and `status: validated`.
|
||||
|
||||
Run `PHASE195_REQUIRE_DOCKER=1 bash scripts/check-phase19.5.sh --all` on a Docker host (through `sudo -u golem` if needed) until every stage passes, and record each stage's result in the SUMMARY draft.
|
||||
|
||||
Commit the two planning docs together: `docs(19.5): proof of address in the security review and validation map`.
|
||||
|
||||
Then add a `## Release table` to the SUMMARY draft:
|
||||
- per repository (sm-user-plugin, sm-sociallogin-plugin, sm-bm-app, sm-demo-app, fonoteka.go, summercms.go): `git log --oneline origin/master..master` after `git fetch origin`, each commit marked as this plan's or foreign;
|
||||
- the proposed tags, each confirmed free with `git ls-remote --tags origin <tag>` printing nothing (else the next free patch);
|
||||
- the pointer-bump commits Task 7 would make.
|
||||
|
||||
The Task 6 checkpoint follows.</action>
|
||||
<verify>
|
||||
<automated>PHASE195_REQUIRE_DOCKER=1 bash scripts/check-phase19.5.sh --all</automated>
|
||||
<fails_when>Non-zero exit: any stage (self-test, go, userplugin, sociallogin, demo, bm, fonoteka, docs, docker) reports FAIL, a required test or test file is missing or skipped, or no Docker daemon answered.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -cE 'T-19\.5-(39|4[0-6])' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md` prints at least 8.
|
||||
- `grep -c 'email_verified_at' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md` prints at least 3 and `grep -c 'which sets .has_self_set_password' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md` prints 0.
|
||||
- `grep -c 'CR-02' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md` prints at least 1 and `grep -c 'OAuthCallbackView.test.ts' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md` prints at least 1.
|
||||
- `grep -cE '^\| 19\.5-05-0[1-7] ' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-VALIDATION.md` prints 7 and `grep -cE '^\| D-2[678] ' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-VALIDATION.md` prints at least 3.
|
||||
- `grep -c '^## Release table' .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-05-SUMMARY.md` prints 1.
|
||||
</acceptance_criteria>
|
||||
<done>The phase's security record matches the code, the validation map names a test for D-26..D-28, the whole gate is green across six repositories with Docker required, and the release table is ready for the checkpoint.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 6: Go-ahead to release sm-user-plugin v0.1.5 and sm-sociallogin-plugin v0.1.2 and to move the application pins (D-21 order)</name>
|
||||
<read_first>19.5-05-SUMMARY.md draft (Release table, gate results), 19.5-04-SUMMARY.md (Tags and final pins), 19.5-CONTEXT.md (D-21, D-27), CLAUDE.md (Commits and concurrency)</read_first>
|
||||
<acceptance_criteria>
|
||||
- The SUMMARY records the reply verbatim under a `## Task 6 reply` heading.
|
||||
- Before the reply, `T=$(git -C ../fonoteka.go/plugins/golem15/user tag --points-at master) && test -z "$T"` and `T=$(git -C ../sm-sociallogin-plugin tag --points-at master) && test -z "$T"` exit 0, and `git -C ../sm-sociallogin-plugin ls-remote --tags origin v0.1.2` prints nothing (no tag before the go-ahead).
|
||||
</acceptance_criteria>
|
||||
<decision>Do the email-proof fix and the bm view test go out now, in D-21 order?</decision>
|
||||
<context>Tasks 1-5 are committed locally and the gate is green. Nothing is pushed yet.
|
||||
|
||||
What applications pin today:
|
||||
- sm-demo-app and sm-bm-app: sm-user-plugin v0.1.4 (c43ba87) and sm-sociallogin-plugin v0.1.1 (d7ee28d);
|
||||
- fonoteka.go: v0.1.4.
|
||||
|
||||
Until a release, the applications that mount sociallogin keep the password-registration takeover (VERIFICATION item 1). The Release table lists every unpushed commit per repository and flags any that are not this plan's.
|
||||
|
||||
Proposed, in order, never force-pushed and never deployed:
|
||||
1. sm-user-plugin: push master, tag v0.1.5 (the migration adds a nullable column and writes nothing; existing accounts get no proof).
|
||||
2. sm-sociallogin-plugin: commit `chore: require sm-user-plugin v0.1.5`, push, tag v0.1.2.
|
||||
3. sm-demo-app and sm-bm-app: commit `chore: bump sm-user-plugin to v0.1.5`, then `chore: bump sm-sociallogin-plugin to v0.1.2`, each with its gate stage green, then push. The sm-bm-app push includes the Task 4 test commit and any foreign commit listed in the table only if you allow it.
|
||||
4. fonoteka.go: commit `chore: bump sm-user-plugin to v0.1.5` locally (its unpushed range holds other work, so it is not pushed).
|
||||
|
||||
There is no framework release, so no changelog post.</context>
|
||||
<options>
|
||||
<option id="release">
|
||||
<name>release</name>
|
||||
<pros>Both applications that mount sociallogin get the fix as reproducible tags; the plugin's go.mod requires the user plugin that carries the column; D-21 holds.</pros>
|
||||
<cons>Two tags and pushes of four repositories now; the first boot of each application runs the new migration.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>hold</name>
|
||||
<pros>Nothing leaves this machine.</pros>
|
||||
<cons>The applications keep the takeover path; the SUMMARY records the unreleased fix as a deviation and a todo with a g15office task tracks the release.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "release" or "hold", optionally naming other versions or allowing the foreign commits in the sm-bm-app push. Nothing is ever force-pushed or deployed.</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 7: Under the Task 6 reply, release in D-21 order: sm-user-plugin v0.1.5, sm-sociallogin-plugin v0.1.2 requiring it, then separate pointer-bump commits (user first, then sociallogin) in sm-demo-app and sm-bm-app and the user pointer in fonoteka.go</name>
|
||||
<precondition>19.5-05-SUMMARY.md (draft) has a "Task 6 reply" heading with "release" or "hold".</precondition>
|
||||
<files>../sm-sociallogin-plugin/go.mod, ../sm-sociallogin-plugin/go.sum, ../sm-demo-app/plugins/golem15/user, ../sm-demo-app/plugins/golem15/sociallogin, ../sm-demo-app/go.mod, ../sm-demo-app/go.sum, ../sm-bm-app/plugins/golem15/user, ../sm-bm-app/plugins/golem15/sociallogin, ../sm-bm-app/go.mod, ../sm-bm-app/go.sum, ../fonoteka.go/plugins/golem15/user, .planning/todos/pending/email-proof-release.md</files>
|
||||
<read_first>19.5-05-SUMMARY.md draft (Release table, Task 6 reply), 19.5-02-SUMMARY.md ("Remote operations": GOWORK=off go mod tidy, annotated tags), 19.5-03-SUMMARY.md (how the app pointers and go.mod lines were bumped, check-pins.sh, TestGoModRequiresTags), 19.5-04-SUMMARY.md (Tags and final pins), CLAUDE.md (Commits and concurrency), .planning/G15OFFICE.md</read_first>
|
||||
<action>Act only as the reply allows. Before every push:
|
||||
- run `git fetch origin`;
|
||||
- `git status` shows no uncommitted change to this plan's paths;
|
||||
- `git log --oneline origin/master..master` lists only this plan's commits, plus foreign commits only when the reply allows them. Otherwise stop and report.
|
||||
Never force-push, never deploy, stage by path.
|
||||
|
||||
On "release" (D-21 order):
|
||||
1. sm-user-plugin (`../fonoteka.go/plugins/golem15/user`):
|
||||
- vet and the short suite are green through the fonoteka.go workspace;
|
||||
- push master;
|
||||
- create an annotated tag v0.1.5 whose user-facing message says: users.email_verified_at records a proved address (mail activation, reset code); an email change clears it; no backfill; the routes, payloads and config are unchanged;
|
||||
- push the tag.
|
||||
2. sm-sociallogin-plugin:
|
||||
- set the go.mod require to `git.golem15.com/golem15/sm-user-plugin v0.1.5`, keeping the summercms replace;
|
||||
- run `GOWORK=off GOPRIVATE='git.golem15.com/*' go mod tidy`; vet, the short suite and `GOWORK=off go build ./...` are green;
|
||||
- commit `chore: require sm-user-plugin v0.1.5`;
|
||||
- push master;
|
||||
- create an annotated tag v0.1.2 whose user-facing message says: a verified sign-in links only onto an account whose owner proved the address; accounts registered with a password in auto mode, created by an administrator or created before golem15.user v0.1.5 go to the pending screen; requires sm-user-plugin v0.1.5;
|
||||
- push the tag.
|
||||
3. sm-demo-app, two commits:
|
||||
- in `plugins/golem15/user`, fetch tags and check out v0.1.5; set the go.mod require to v0.1.5 and update go.sum as 19.5-03 did; `bash scripts/check-phase19.5.sh --demo` and `scripts/check-pins.sh` are green; commit `chore: bump sm-user-plugin to v0.1.5` (gitlink, go.mod, go.sum);
|
||||
- then the same for `plugins/golem15/sociallogin` at v0.1.2: commit `chore: bump sm-sociallogin-plugin to v0.1.2`;
|
||||
- push.
|
||||
4. sm-bm-app: the same two commits in the same order. Change the go.mod require lines only where they name versions, keep the replace lines, and add the go.sum change Go asks for to the commit it belongs to. `bash scripts/check-phase19.5.sh --bm` is green after each commit. Push, including the Task 4 test commit.
|
||||
5. fonoteka.go: `plugins/golem15/user` is already the v0.1.5 commit (check with `git -C plugins/golem15/user describe --exact-match`). Run `bash scripts/check-phase19.5.sh --fonoteka`. Stage only `plugins/golem15/user` and commit `chore: bump sm-user-plugin to v0.1.5`. Do not push. Other sessions' changes to feedback and golem stay untouched.
|
||||
6. Post a short comment on the g15office Phase 19.5 milestone task `onunbz3l`: the gap closure executed and v0.1.5 and v0.1.2 were released, awaiting re-verification. Set its status to Testing (.planning/G15OFFICE.md). If the tool is unavailable, record that in the SUMMARY.
|
||||
|
||||
On "hold": push nothing, tag nothing, move no pointer.
|
||||
- In the SUMMARY, record a deviation that lists the unreleased commits and the pins that lack them.
|
||||
- Create `.planning/todos/pending/email-proof-release.md` in the existing todo frontmatter style: priority high (security), area sm-user-plugin and sm-sociallogin-plugin. Its body holds the Release table and steps 1-5 above.
|
||||
- Create its g15office task per .planning/G15OFFICE.md and write the ID into the frontmatter.
|
||||
- Commit the todo alone: `docs(19.5): capture the email-proof release todo`.
|
||||
|
||||
Finalize the SUMMARY: tags, pushed ranges, the pointer-bump commits per application, final pins, and remove the draft status line.</action>
|
||||
<verify>
|
||||
<automated>bash scripts/check-phase19.5.sh --sociallogin && bash scripts/check-phase19.5.sh --demo && bash scripts/check-phase19.5.sh --bm && bash scripts/check-phase19.5.sh --fonoteka</automated>
|
||||
<fails_when>Non-zero exit: the plugin no longer builds at its new require, or an application no longer builds or passes its named tests at the new pins (for example, sociallogin v0.1.2 checked out before the user pin moved). On "hold", this command is not run and the SUMMARY says so.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- On "release":
|
||||
- `git -C ../fonoteka.go/plugins/golem15/user ls-remote --exit-code --tags origin v0.1.5` and `git -C ../sm-sociallogin-plugin ls-remote --exit-code --tags origin v0.1.2` exit 0 (or the versions the reply named);
|
||||
- `grep -c 'git.golem15.com/golem15/sm-user-plugin v0.1.5' ../sm-sociallogin-plugin/go.mod` prints 1.
|
||||
- On "release", for A in sm-demo-app and sm-bm-app:
|
||||
- `test "$(git -C ../A rev-parse HEAD:plugins/golem15/user)" = "$(git -C ../fonoteka.go/plugins/golem15/user rev-parse 'v0.1.5^{commit}')"` exits 0;
|
||||
- `test "$(git -C ../A rev-parse HEAD:plugins/golem15/sociallogin)" = "$(git -C ../sm-sociallogin-plugin rev-parse 'v0.1.2^{commit}')"` exits 0;
|
||||
- `git -C ../A log --format=%s -2` prints `chore: bump sm-sociallogin-plugin to v0.1.2` above `chore: bump sm-user-plugin to v0.1.5`;
|
||||
- `L=$(git -C ../A log --oneline origin/master..master) && test -z "$L"` exits 0.
|
||||
- On "release", for fonoteka.go:
|
||||
- `test "$(git -C ../fonoteka.go rev-parse HEAD:plugins/golem15/user)" = "$(git -C ../fonoteka.go/plugins/golem15/user rev-parse 'v0.1.5^{commit}')"` exits 0;
|
||||
- `git -C ../fonoteka.go show --name-only --format= HEAD` prints only `plugins/golem15/user`.
|
||||
- On "hold", `test -f .planning/todos/pending/email-proof-release.md` exits 0 and the SUMMARY's deviations name every unreleased commit.
|
||||
</acceptance_criteria>
|
||||
<done>The applications that mount sociallogin run the email-proof rule at tagged versions, fonoteka.go carries the new user plugin pin locally, or the user's hold is recorded with a tracked todo.</done>
|
||||
<reversibility rating="costly">Pushed tags and pointer bumps of shared plugins are consumed by other applications. A fix needs a new patch tag, never a moved tag.</reversibility>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| anonymous client to /_user/api/v1/register and /update | anyone can register any address in auto mode and change an account's email; neither proves the mailbox |
|
||||
| provider callback to account matching | a verified provider email meets an existing account; linking hands that account the provider's sign-in |
|
||||
| administrator to the Users screen | an administrator creates, activates and edits accounts; none of that proves the user's mailbox |
|
||||
| migration to production users tables | a shared core plugin's migration runs on every application that bumps it |
|
||||
| gate script to the phase verdict | a missing or skipped test must fail the gate |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.5-39 | Elevation of privilege | sociallogin `ownerVerifiedEmail` (password-registration pre-account takeover, 19.5-REVIEW CR-01) | high | mitigate | Auto-link requires `users.email_verified_at`, which only the mailed activation code and the completed reset code write. Proved by `TestOwnerVerifiedEmail/register_auto_then_google_pending` (callback, register action and completion all refuse) and `legacy_account_pending`. |
|
||||
| T-19.5-40 | Spoofing | sm-user-plugin `Update` and `FormBeforeUpdate` (proof carried over an email change) | high | mitigate | An email change (not case-only) sets the column to NULL in the same write. Proved by `TestEmailVerifiedAt/update_email_change_clears`, `TestAdminEmailVerifiedAt/admin_email_change_clears` and `TestOwnerVerifiedEmail/email_change_drops_proof`. |
|
||||
| T-19.5-41 | Tampering | /register and /update request bodies (mass assignment of the proof) | medium | mitigate | `email_verified_at` is not in `Fillable()`, and Update writes an explicit map. Proved by `TestEmailVerifiedAt/register_ignores_submitted_proof` and `update_ignores_submitted_proof`. |
|
||||
| T-19.5-42 | Elevation of privilege | admin create and Activate action, `/register` auto and admin modes, social sign-in (non-mailbox paths treated as proof) | medium | mitigate | None of these paths writes the column. Proved by `TestEmailVerifiedAt/register_auto_no_proof`, `register_admin_mode_no_proof`, `TestAdminEmailVerifiedAt/admin_create_no_proof`, `admin_activate_action_no_proof` and `TestOwnerVerifiedEmail/admin_activation_not_proof`. |
|
||||
| T-19.5-43 | Information disclosure | user API payloads and admin JSON (contract drift in a core plugin) | low | mitigate | `json:"-"`; `apiArray` and the admin YAML are unchanged. Proved by the payload-key assertions in `TestEmailVerifiedAt` and by `TestAdminEmailVerifiedAt/admin_json_hides_proof`; the Task 2 acceptance diff shows routes, config, lang and admin YAML unchanged since v0.1.4. |
|
||||
| T-19.5-44 | Tampering | migration `202610210001_add_users_email_verified_at` (data rewrite or a broken upgrade) | medium | mitigate | `ADD COLUMN IF NOT EXISTS`, no row writes, no-op rollback. The gate's `migration_writes_rows` and `refused_migration_changes` enforce it, and `TestEmailVerifiedAtMigration` (`no_backfill`, `existing_column_kept`, `rollback_keeps_column`) proves it. |
|
||||
| T-19.5-45 | Repudiation | scripts/check-phase19.5.sh (false green for the new tests) | medium | mitigate | The new names are required in the docker stage. `REQUIRE_FILES` refuses a vitest run without a passing view test file. `--self-test` covers the planted missing-view-file output and every migration-check case. |
|
||||
| T-19.5-46 | Tampering | release and pointer bumps (Task 7) | medium | mitigate | Runs only after the blocking-human Task 6 reply. The user pointer moves before sociallogin's, so each commit builds. Each app's gate stage runs after every bump. There is a fetch and `origin/master..master` check before every push, with a stop on foreign commits the reply did not allow. Never force-pushed or deployed. fonoteka.go and summercms.go are not pushed. |
|
||||
| T-19.5-SC | Tampering | module and npm installs | high | mitigate | No Go module and no npm package is added. The bm view test uses `vue`'s own `createRenderer`, and Task 4 refuses a package.json or lockfile change. The gate's `--sociallogin` dependency check still refuses gorilla/sessions and gopkg.in/yaml.v3. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: TestOwnerVerifiedEmail and TestPreAccountTakeover by name; --self-test, --userplugin, --sociallogin, --docker.
|
||||
- Task 2: TestEmailVerifiedAt, TestAdminEmailVerifiedAt; TestEmailVerifiedAtMigration and TestCreateOrganisationsMigration; --userplugin, --fonoteka, --docker.
|
||||
- Task 3: TestOwnerVerifiedEmail, TestPreAccountTakeover and TestPHPPendingRegistrationTakeover; --sociallogin (TestPHPMethodMap), --docker.
|
||||
- Task 4: the view test file and typecheck; no package.json or lockfile change; --self-test, --bm.
|
||||
- Task 5: `PHASE195_REQUIRE_DOCKER=1 bash scripts/check-phase19.5.sh --all`; the security review and validation map counts.
|
||||
- Task 6: the reply recorded; no tag before it. Task 7: --sociallogin, --demo, --bm, --fonoteka at the new pins; tags on the remotes; pins equal the tags.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- D-26: a verified email auto-links only onto an account that proved its address; every no-proof path, including `/register` in auto mode, goes pending, and every mailed proof links.
|
||||
- D-27: `users.email_verified_at` is additive (no payload, route or config change), has exactly two writers, follows the address and is not backfilled. The READMEs and the security review no longer claim that a self-set password proves the address.
|
||||
- D-28: the bm callback view's ordering, single redeem, error and pending behaviour are tested without a new npm dependency.
|
||||
- The gate fails closed on every new test, and `--all` passes with Docker required.
|
||||
- SC-3 and D-21: under the go-ahead, v0.1.5 and v0.1.2 are tagged and both applications that mount sociallogin, plus fonoteka.go, are green at the new pins; or the hold is recorded and tracked.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-05-SUMMARY.md`. It starts as the Task 3 hand-off draft. Include:
|
||||
- the commits per repository;
|
||||
- the gate output per stage (PASS, or NOT RUN with the reason);
|
||||
- the test names added and their subtests;
|
||||
- any production defect found, with its fix commit;
|
||||
- the Release table and the Task 6 reply verbatim;
|
||||
- the tags, the pushed ranges, the pointer bumps and the final pins (or the hold todo and its g15office ID);
|
||||
- the g15office comment;
|
||||
- any deviation, including a fallback version number or a Task 4 checkpoint.
|
||||
</output>
|
||||
@@ -0,0 +1,263 @@
|
||||
---
|
||||
phase: 19.5-social-login-in-sm-user-plugin
|
||||
plan: 05
|
||||
subsystem: auth
|
||||
tags: [sociallogin, sm-user-plugin, email-proof, pre-account-takeover, postgres, gormigrate, gate, vitest, bm]
|
||||
status: complete
|
||||
requires:
|
||||
- phase: 19.5-04
|
||||
provides: "sm-user-plugin v0.1.4 social sign-in hooks, sm-sociallogin-plugin v0.1.1 matching (ownerVerifiedEmail), scripts/check-phase19.5.sh"
|
||||
provides:
|
||||
- "users.email_verified_at (migration 202610210001_add_users_email_verified_at), written only by VerifyActivationCode and ResetPassword, cleared on an email change"
|
||||
- "sociallogin ownerVerifiedEmail reads EmailVerifiedAt (D-26)"
|
||||
- "TestOwnerVerifiedEmail (8 subtests), TestEmailVerifiedAt, TestAdminEmailVerifiedAt, TestEmailVerifiedAtMigration"
|
||||
- "bm src/views/OAuthCallbackView.test.ts (D-28): the real view mounted with vue's createRenderer, no new npm dependency"
|
||||
- "gate: USER_ADDED_MIGRATIONS, refused_migration_changes, migration_writes_rows, DOCKER_USER_UPDATES_TESTS, detect_vitest REQUIRE_FILES"
|
||||
- "19.5-SECURITY-REVIEW.md T-19.5-39..46 and CR-02 open; 19.5-VALIDATION.md rows 19.5-05-01..07, D-26..D-28"
|
||||
affects: [".planning/todos/pending/email-proof-release.md (release on hold, hand-off to the 19.8 session)", "sm-demo-app and sm-bm-app pins (unchanged until the release)"]
|
||||
actuals:
|
||||
tokens: 33300 # chars/4: Tasks 1-3 diffs 18621 + Tasks 4-5 diffs 12208 + Task 7 (hold) todo about 2470
|
||||
tasks: 7 # Tasks 1-5 executed, Task 6 answered "hold", Task 7 recorded the hold
|
||||
commits: 9 # MEASURED: git rev-list --count 3346060..20ecc41; 7 are this plan's (7170c5b, 30e0cd9, c2c8c6a, 7e43873, 8c7794e, b57d2e1, 20ecc41), 2 are foreign 19.8-03 commits (2057643, 4895b25); the final SUMMARY commit comes on top
|
||||
plan_head_before: 33460607db95276e8180487a8ece7e4f0070b1e7
|
||||
plan_head_after: 20ecc41
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Proof of address as a nullable timestamp column, stamped in the same write as the mailed-code action that proves it"
|
||||
- "Gate migration allow-list: git diff --name-status filtered through refused_migration_changes, plus a comment-blind SQL write scan"
|
||||
- "SFC view test without a DOM package: vue createRenderer over an in-memory host, the template compiled for the client with the script's binding metadata, createMemoryHistory router"
|
||||
key-files:
|
||||
created:
|
||||
- ../fonoteka.go/plugins/golem15/user/updates/202610210001_add_users_email_verified_at.go
|
||||
- ../fonoteka.go/plugins/golem15/user/updates/email_verified_at_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/email_verified_test.go
|
||||
- ../sm-sociallogin-plugin/owner_verified_test.go
|
||||
- ../sm-bm-app/frontend/src/views/OAuthCallbackView.test.ts
|
||||
modified:
|
||||
- ../fonoteka.go/plugins/golem15/user/models/user.go
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/codes.go
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go
|
||||
- ../fonoteka.go/plugins/golem15/user/updates/organisations_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/README.md
|
||||
- ../sm-sociallogin-plugin/classes/flow.go
|
||||
- ../sm-sociallogin-plugin/harness_test.go
|
||||
- ../sm-sociallogin-plugin/matching_test.go
|
||||
- ../sm-sociallogin-plugin/testdata/PHP_METHOD_MAP.md
|
||||
- ../sm-sociallogin-plugin/README.md
|
||||
- scripts/check-phase19.5.sh
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-VALIDATION.md
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/deferred-items.md
|
||||
key-decisions:
|
||||
- "Proof writers are exactly VerifyActivationCode (activate, activate-by-code, invitation link) and ResetPassword's password write; RegisterUser, admin create, ActivateUsers, ChangePassword and every sociallogin path write nothing"
|
||||
- "An email change through /update or the admin form clears email_verified_at; a case-only change (strings.EqualFold) keeps it"
|
||||
- "Harness e.user is a proven account so plan 02-04 link cases keep testing the link path; e.unprovenUser models accounts without proof"
|
||||
- "The bm view test compiles the view's own template for the client at test time (vitest's node environment gives the SFC only ssrRender) and provides an empty SSR context; vite.config.ts and the dependencies stay unchanged"
|
||||
- "Task 6 answered hold: nothing pushed or tagged, no pointer moved; the release is handed to the 19.8 session to decide with the framework v0.4.0 release (todo email-proof-release)"
|
||||
requirements-completed: [D-26, D-27, D-28] # in local commits; SC-3 (tags and app pins, D-21 release) is open under the hold, tracked by email-proof-release.md
|
||||
duration: 75min
|
||||
completed: 2026-10-21
|
||||
---
|
||||
|
||||
# Phase 19.5 Plan 05: Proof of Address for Social Sign-in Linking Summary
|
||||
|
||||
**users.email_verified_at (stamped only by the mailed activation code and the completed reset-code password reset, cleared on an email change) is now the owner-side condition for auto-linking a verified provider, so an auto-mode `/register` account goes pending instead of handing its sign-in to a squatter; the bm OAuth callback view's code handling is pinned by a vitest view test without a new dependency**
|
||||
|
||||
All 7 tasks are done. Task 6 was answered **hold**, so Task 7 recorded the hold: the fix lives in local commits in four repositories, nothing is pushed or tagged, no application pin moved, and `.planning/todos/pending/email-proof-release.md` hands the release to the 19.8 session (decided together with the framework v0.4.0 release).
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 29 min (Tasks 1-3) + 39 min (Tasks 4-5, about 25 of them spent on two gate runs that timed out on blocked loopback; see Issues)
|
||||
- **Started:** 2026-10-21T11:37:14Z
|
||||
- **Second context:** 2026-10-21T12:08Z to 12:47Z
|
||||
- **Third context (Task 7, hold):** about 7 min
|
||||
- **Tasks:** 7 of 7 (Task 7 on the hold branch)
|
||||
- **Files modified:** 20 across four repositories (plus deferred-items.md and the release todo)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Migration `202610210001_add_users_email_verified_at`: `ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ NULL`, no row written, no-op rollback; `User.EmailVerifiedAt` with `json:"-"`, not fillable.
|
||||
- `classes.VerifyActivationCode` stamps the proof in the same `UpdateColumns` write that activates; `controllers.ResetPassword` stamps it in the `Updates` that stores the new password.
|
||||
- `controllers.Update` and `usersAdminController.FormBeforeUpdate` clear it when the email changes (case-only change keeps it). `storedEmail` is an unexported helper now shared with `guardCredentials`.
|
||||
- sociallogin `ownerVerifiedEmail(u)` is `u != nil && u.EmailVerifiedAt != nil`; `classes/flow.go` no longer reads `HasSelfSetPassword`.
|
||||
- bm `src/views/OAuthCallbackView.test.ts`: the real view mounted with `createRenderer` from `vue` on an in-memory host and a `createMemoryHistory` router; 5 cases (route replaced before the single redeem, no second redeem, `?error=` in the alert, pending registration replaced to `oauth-register`, expired code explained). `frontend/package.json` and `package-lock.json` unchanged (last commit touching them `fe967c4` before and after).
|
||||
- Gate: the userplugin stage accepts exactly the one added migration and refuses any other change under `updates/` and a migration that writes or drops data; the new Go tests are required by name; `detect_vitest` takes `REQUIRE_FILES` and the bm stage requires both `src/lib/oauth.test.ts` and `src/views/OAuthCallbackView.test.ts`.
|
||||
- 19.5-SECURITY-REVIEW.md: T-19.5-38 describes the proof as `email_verified_at`, T-19.5-39..T-19.5-46 added, T-19.5-26 closed by the view test, CR-02 under "Open findings" (open, no user decision). 19.5-VALIDATION.md: rows 19.5-05-01..07, D-26, D-27, D-28, the view check moved from manual to automated.
|
||||
|
||||
## Commits per repository
|
||||
|
||||
**sm-user-plugin** (`../fonoteka.go/plugins/golem15/user`, base c7635c5 = v0.1.4+1, nothing pushed):
|
||||
|
||||
| Task | Commit | Message |
|
||||
|------|--------|---------|
|
||||
| 1 | `add1f7a` | feat: users.email_verified_at records a proved address (mail activation, reset code) |
|
||||
| 2 | `a99cb46` | fix: an email change drops the address proof |
|
||||
| 2 | `35cf166` | test: email_verified_at writers, non-writers and the migration |
|
||||
|
||||
**sm-sociallogin-plugin** (`../sm-sociallogin-plugin`, base d7ee28d = v0.1.1, nothing pushed; go.mod still requires sm-user-plugin v0.1.4 until Task 7, the committed go.work points at the workspace checkout):
|
||||
|
||||
| Task | Commit | Message |
|
||||
|------|--------|---------|
|
||||
| 1 | `3bb5650` | fix: a verified sign-in links only onto an account that proved its address |
|
||||
| 3 | `962190b` | test: owner proof table for verified sign-ins |
|
||||
| 3 | `c4ebf05` | docs: proof of address in the matching table and the PHP map |
|
||||
|
||||
**sm-bm-app** (base 99af8c2, nothing pushed):
|
||||
|
||||
| Task | Commit | Message |
|
||||
|------|--------|---------|
|
||||
| 4 | `5fd0cc3` | test(frontend): OAuth callback view drops the code before one redeem |
|
||||
|
||||
**summercms.go** (base 3346060, never pushed by this plan):
|
||||
|
||||
| Task | Commit | Message |
|
||||
|------|--------|---------|
|
||||
| 1 | `7170c5b` | test(19.5): gate the email_verified_at migration and TestOwnerVerifiedEmail |
|
||||
| 2 | `30e0cd9` | test(19.5): gate the user-plugin proof tests |
|
||||
| hand-off | `c2c8c6a` | docs(19.5-05): hand-off draft after Task 3 (email proof in user and sociallogin) |
|
||||
| 4 | `7e43873` | test(19.5): gate the bm callback view test |
|
||||
| 5 | `8c7794e` | docs(19.5): proof of address in the security review and validation map |
|
||||
| 6 | `b57d2e1` | docs(19.5-05): draft at the Task 6 checkpoint (bm view test, review, release table) |
|
||||
| 7 | `20ecc41` | docs(19.5): capture the email-proof release todo |
|
||||
|
||||
Two foreign commits of another session (`2057643`, `4895b25`, both `fix(19.8-03)`) landed between them on summercms.go master; they are not this plan's.
|
||||
|
||||
**sm-demo-app, fonoteka.go:** no commit (release on hold). fonoteka.go's worktree shows `plugins/golem15/user` modified (the checkout is at 35cf166); it stays unstaged until the release.
|
||||
|
||||
## Tests added (all pass)
|
||||
|
||||
- **sm-sociallogin-plugin** `TestOwnerVerifiedEmail` (owner_verified_test.go): `register_auto_then_google_pending`, `mail_activated_by_code_links`, `reset_code_password_links` (Task 1); `legacy_account_pending`, `register_action_pending`, `admin_activation_not_proof`, `email_change_drops_proof`, `unverified_provider_never_links_proven_account` (Task 3). Helpers on `*socialEnv`: `ownerEnv`, `postUserAPI`, `registerWithPassword`, `userRow`, `activateByMail`, `resetByMail`, `googleSignIn`, `assertPendingNoLink`, `assertLinkedAndSignedIn`. Harness: `e.user` (proven), `e.unprovenUser`.
|
||||
- **sm-sociallogin-plugin** `TestPreAccountTakeover`: setup also requires `EmailVerifiedAt == nil`; `after_mailbox_proof_links` now drives `/forgot-password` and `/reset-password` through `resetByMail`.
|
||||
- **sm-user-plugin** `TestEmailVerifiedAt` (email_verified_test.go): `register_auto_no_proof`, `register_admin_mode_no_proof`, `register_ignores_submitted_proof`, `activate_by_code_stamps`, `activate_stamps`, `activate_wrong_code_no_proof`, `reset_password_stamps`, `reset_wrong_code_no_proof`, `change_password_no_proof`, `update_email_change_clears`, `update_same_email_keeps`, `update_ignores_submitted_proof`.
|
||||
- **sm-user-plugin** `TestAdminEmailVerifiedAt`: `admin_create_no_proof`, `admin_activate_action_no_proof`, `admin_invite_link_stamps`, `admin_email_change_clears`, `admin_save_keeps_proof`, `admin_json_hides_proof`.
|
||||
- **sm-user-plugin** `TestEmailVerifiedAtMigration` (updates/email_verified_at_test.go): `column_shape`, `no_backfill`, `existing_column_kept`, `rollback_keeps_column`, `history_once`. `TestCreateOrganisationsMigration` expects the new ID last and rolls back 12 steps.
|
||||
- **sm-bm-app** vitest `src/views/OAuthCallbackView.test.ts`, describe `OAuthCallbackView`: `replaces the route before the single redeem`, `does not redeem a code twice`, `shows the error message`, `routes a pending registration`, `explains an expired code`.
|
||||
- **summercms.go** gate self-test: vitest cases on both required files, `missing-view-file` (exit 5), `single-file-fallback` (REQUIRE_FILE, exit 5).
|
||||
|
||||
RED evidence:
|
||||
- With the old `ownerVerifiedEmail` (self-set password), `TestOwnerVerifiedEmail/register_auto_then_google_pending` failed (the redirect carried a `code`, i.e. the auto-mode account was linked).
|
||||
- With the Task 2 controllers reverted to HEAD, exactly `TestEmailVerifiedAt/update_email_change_clears` and `TestAdminEmailVerifiedAt/admin_email_change_clears` failed.
|
||||
- View mutations, each reverted: removing the `router.replace({ path })` before the redeem fails 3 cases (`replaces the route…`, `does not redeem…`, `shows the error…`); dropping the `redeemed` check fails `does not redeem a code twice`; `push` instead of `replace` for the pending route fails `routes a pending registration`.
|
||||
|
||||
## Gate stages (scripts/check-phase19.5.sh)
|
||||
|
||||
Final run, 14:42-14:46 local: `PHASE195_REQUIRE_DOCKER=1 TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 bash scripts/check-phase19.5.sh --all`, exit 0.
|
||||
|
||||
| Stage | State | Notes |
|
||||
|-------|-------|-------|
|
||||
| --self-test | PASS | detectors, migration-check cases, vitest `REQUIRE_FILES` cases incl. `missing-view-file`, docker NOT RUN rule |
|
||||
| --go | PASS | summercms.go vet and short suite; cooler 4 named tests |
|
||||
| --userplugin | PASS | 2 named tests; "13 declarations added; 1 allowed migration(s) added, column only" |
|
||||
| --sociallogin | PASS | 9 named Docker-free tests incl. TestPHPMethodMap; deps and goth importer checks |
|
||||
| --demo | PASS | build, vet, 3 named tests |
|
||||
| --bm | PASS | build, vet, 1 + 2 named tests; vitest 216 passed (216) with both required files; typecheck |
|
||||
| --fonoteka | PASS | vet and short suite of the workspace at the changed user plugin |
|
||||
| --docs | PASS | TestDocsTree, docs:build --check, decision note, no consumer names |
|
||||
| --docker | PASS | cooler 1, user root 12, user updates 2, sociallogin 29 top-level tests |
|
||||
|
||||
## Decisions Made
|
||||
|
||||
Followed the plan's decisions. Within them:
|
||||
- `storedEmail` was extracted from `guardCredentials` so `FormBeforeUpdate` compares the stored and submitted email without a second copy of the query; `guardCredentials`' behaviour is unchanged.
|
||||
- `TestOwnerVerifiedEmail` boots its own environment with `gothFakeRegistry` (helper `ownerEnv`) so the Google and Facebook wrappers run on fake APIs, as `TestPHPSocialAuthGate` does.
|
||||
- `mountAt(url, router)` builds a fresh router and app per mount (vue-router resets its state when its last app unmounts), so the second-mount case pushes the same callback URL again on a new router while the view module's `redeemed` set carries over.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
**1. [Rule 3 - Blocking] The bm view test compiles the view's template at test time**
|
||||
- **Found during:** Task 4
|
||||
- **Issue:** Under vitest's `environment: 'node'`, @vitejs/plugin-vue compiles SFCs in SSR mode: the imported component has `setup` and `ssrRender` but no `render`, so `createRenderer` alone mounts nothing. The SSR module's setup also calls `useSSRContext()` and fails without one. A client-compiled template without binding metadata cannot read `<script setup>` bindings (they are not exposed on the render context).
|
||||
- **Fix:** In the test file only: `parse` and `compileScript` from `vue/compiler-sfc` (part of the `vue` package) read the view's own source (`?raw`), `compile` from `vue` builds the client render function with `bindingMetadata` and `prefixIdentifiers`, and the app provides an empty SSR context (`ssrContextKey`). The `<script setup>` that runs is the real module, `redeemed` set included. No dependency added, `vite.config.ts` unchanged, so the plan's "stop at a checkpoint" branch did not apply.
|
||||
- **Files modified:** `../sm-bm-app/frontend/src/views/OAuthCallbackView.test.ts`
|
||||
- **Commit:** `5fd0cc3`
|
||||
|
||||
**2. [Task 6 hold] The fix is not released; the applications keep the takeover path**
|
||||
- **Found during:** Task 6 / Task 7
|
||||
- **Issue:** The user answered the Task 6 checkpoint with "hold". SC-3 and D-21 (tags v0.1.5 and v0.1.2, app pins at them) are not met by this plan.
|
||||
- **Unreleased commits:** sm-user-plugin `add1f7a`, `a99cb46`, `35cf166`; sm-sociallogin-plugin `3bb5650`, `962190b`, `c4ebf05` (plus the `chore: require sm-user-plugin v0.1.5` commit still to be made); sm-bm-app `5fd0cc3`.
|
||||
- **Pins that lack them:** sm-demo-app and sm-bm-app `plugins/golem15/user` c43ba87 (v0.1.4) and `plugins/golem15/sociallogin` d7ee28d (v0.1.1), with the matching go.mod requires; fonoteka.go gitlink c43ba87 (worktree at 35cf166, unstaged). Until they move, an auto-mode `/register` account squatting an address is still linked on the owner's verified Google sign-in in both apps that mount sociallogin.
|
||||
- **Tracked by:** `.planning/todos/pending/email-proof-release.md` (commit `20ecc41`), addressed to the 19.8 session to review and decide with the framework v0.4.0 release. Task 7's verify command (the `--sociallogin`, `--demo`, `--bm`, `--fonoteka` stages at the new pins) was not run, because no pin moved.
|
||||
|
||||
Environment-only adjustments (no repository change):
|
||||
- Docker-backed runs used `TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1` and ran outside the sandbox.
|
||||
- `change_password_no_proof` sets `has_self_set_password` before calling change-password, as `TestChangePassword` does (`insertUser` creates the row with the flag false, which change-password answers with an opaque 500).
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- **Docker loopback blocked by the NordLayer VPN.** The VPN's nftables table drops loopback traffic to published container ports, so pgx timed out against the testcontainers Postgres (`127.0.0.1:<port>: failed to receive message: timeout`). The first two `--all` runs of this context failed at `--userplugin`: `sm-user-plugin/classes` hit `go test`'s 10-minute timeout (`TestPermissionSetScan` starts Postgres even under `-short`) and `updates` failed after 2 minutes. The user restored loopback with `sudo nft insert rule inet nordlayer input iifname "lo" accept` and `... output oifname "lo" accept`. These rules disappear when the VPN reconnects, and Task 7's gate runs need them again. The third run passed in 3.5 minutes.
|
||||
- **Pre-existing:** sm-user-plugin `updates/postgres_test.go` `testShort` scans `os.Args`, and `classes.TestPermissionSetScan` has no `-short` skip, so `go test -short` still starts Postgres for those packages. Out of scope, both logged in deferred-items.md, as is the VPN loopback rule.
|
||||
|
||||
## Release table
|
||||
|
||||
Fetched `origin` in every repository during Task 5 (about 12:25Z). `git log --oneline origin/master..master`:
|
||||
|
||||
| Repository | Unpushed commits | This plan's? |
|
||||
|------------|------------------|--------------|
|
||||
| sm-user-plugin | `35cf166`, `a99cb46`, `add1f7a` | all this plan's |
|
||||
| sm-sociallogin-plugin | `c4ebf05`, `962190b`, `3bb5650` | all this plan's |
|
||||
| sm-bm-app | `5fd0cc3` | this plan's (Task 4) |
|
||||
| sm-demo-app | `b325460` feat(demo): roadmap deck describes the new console | **foreign** (another session, 13:27 today; touches only `plugins/demo/content/ROADMAP.md` and `plugins/demo/roadmap/`) |
|
||||
| fonoteka.go | 31 commits (`f4d5728` … `ca746fc`: 14.1, 14.2, 12.1, parity and earlier bump commits) | all foreign to this plan; fonoteka.go is never pushed by this plan |
|
||||
| summercms.go | 49 commits before this draft's commit; this plan's: `7170c5b`, `30e0cd9`, `c2c8c6a`, `7e43873`, `8c7794e` | the rest foreign (19.8 and earlier 19.5 planning); summercms.go is never pushed by this plan |
|
||||
|
||||
Proposed tags (each `git ls-remote --tags origin <tag>` printed nothing; no tag points at either plugin's master):
|
||||
|
||||
| Repository | Tag | Points at | Free? |
|
||||
|------------|-----|-----------|-------|
|
||||
| sm-user-plugin | `v0.1.5` | `35cf166` | yes (also `v0.1.6` free) |
|
||||
| sm-sociallogin-plugin | `v0.1.2` | the Task 7 commit `chore: require sm-user-plugin v0.1.5` on top of `c4ebf05` | yes (also `v0.1.3` free) |
|
||||
|
||||
Current pins: sm-demo-app and sm-bm-app `plugins/golem15/user` c43ba87 (v0.1.4), `plugins/golem15/sociallogin` d7ee28d (v0.1.1); sm-demo-app go.mod requires sm-user-plugin v0.1.4 and sm-sociallogin-plugin v0.1.1; sm-bm-app go.mod requires sm-user-plugin v0.1.4 and sm-sociallogin-plugin v0.0.0 (replace to the submodule; only the user line names a version); fonoteka.go gitlink c43ba87.
|
||||
|
||||
Pointer-bump commits a release makes (not made under the hold; the todo carries them):
|
||||
1. sm-sociallogin-plugin: `chore: require sm-user-plugin v0.1.5` (go.mod, go.sum), then tag v0.1.2.
|
||||
2. sm-demo-app: `chore: bump sm-user-plugin to v0.1.5` (gitlink, go.mod, go.sum), then `chore: bump sm-sociallogin-plugin to v0.1.2` (gitlink, go.mod, go.sum); `--demo` and `scripts/check-pins.sh` green after each; push. **The push would include the foreign `b325460` unless the reply excludes it.**
|
||||
3. sm-bm-app: the same two commits (only the versioned require line changes); `--bm` green after each; push including `5fd0cc3`.
|
||||
4. fonoteka.go: `chore: bump sm-user-plugin to v0.1.5` (only `plugins/golem15/user`), local only.
|
||||
|
||||
## Task 6 reply
|
||||
|
||||
> hold
|
||||
|
||||
Added by the user with the reply: the 19.8 session (console commands overhaul) is on hold awaiting this gap closure, and the 19.8 agent will review and decide on the release together with its framework v0.4.0 release.
|
||||
|
||||
## Task 7 (hold)
|
||||
|
||||
- Pushed: nothing. Tagged: nothing. Pointers moved: none. fonoteka.go `plugins/golem15/user` stays unstaged at 35cf166.
|
||||
- Todo: `.planning/todos/pending/email-proof-release.md` (commit `20ecc41`, `docs(19.5): capture the email-proof release todo`). It holds the release table, the D-21 steps (sm-user-plugin v0.1.5 at `35cf166`; sm-sociallogin-plugin `chore: require sm-user-plugin v0.1.5` then v0.1.2; sm-demo-app and sm-bm-app user bump then sociallogin bump, gate green after each, then push; fonoteka.go user bump local only), the note that plugin tags live in the plugins' own repositories apart from the framework v0.4.0 tag, the foreign unpushed `b325460` in sm-demo-app, the NordLayer loopback rules for Docker gate runs, and the open takeover path in the apps until the release. It does not set `resolves_phase`, so completing Phase 19.5 does not close it.
|
||||
- g15office task: **not created.** No g15office tool was available in the executing session (the g15office MCP server is not exposed to subagents). The todo's frontmatter says so; the orchestrator or the next session with the tool creates the task (type Bug or Task, description ending `Local: .planning/todos/pending/email-proof-release.md`), writes its ID into the todo, and makes it a subtask of the Phase 19.5 milestone task `onunbz3l`.
|
||||
- Task 7's verify command was not run (hold: no pin moved).
|
||||
|
||||
## Release (2026-10-21, after the hold)
|
||||
|
||||
The 19.8 session released the fix under the user's go-ahead for the v0.4.0 release (19.8-03 Task 6 reply "release v0.4.0", which delegated this decision), following `email-proof-release.md` steps 0-5. Docker gate: the probe `TestPermissionSetScan` passed in about 6 s with the NordLayer loopback rules in place.
|
||||
|
||||
| Repository | Pushed range | Tag | Checks |
|
||||
|------------|--------------|-----|--------|
|
||||
| sm-user-plugin | `c7635c5..35cf166` (`add1f7a`, `a99cb46`, `35cf166`) | `v0.1.5` (annotated) at `35cf166` | vet and short suite green through the fonoteka.go workspace |
|
||||
| sm-sociallogin-plugin | `d7ee28d..9fd58db` (`3bb5650`, `962190b`, `c4ebf05`, `79cebb9`, `9fd58db` chore: require sm-user-plugin v0.1.5) | `v0.1.2` (annotated) at `9fd58db` | vet, short suite, `GOWORK=off go build ./...` green |
|
||||
| sm-demo-app | `61a00e9..a5703f9` (`b325460` approved foreign deck commit, `f16ef6c` user bump, `1afd7ff` sociallogin bump, `a5703f9` snapshot sync from summercms `9ab28e1`) | none | `--demo` and `scripts/check-pins.sh` green after each bump; `sync-roadmap.sh --check` ok |
|
||||
| sm-bm-app | `99af8c2..cc4b23f` (`5fd0cc3`, `1e09c23` user bump, `cc4b23f` sociallogin bump) | none | `--bm` green after each bump (vitest 216 passed, typecheck) |
|
||||
| fonoteka.go | not pushed; local `2be9e1f` chore: bump sm-user-plugin to v0.1.5 | none | `--fonoteka` green; `describe --exact-match` names v0.1.5 |
|
||||
|
||||
Final pins: sm-demo-app `plugins/golem15/user` `35cf166` (v0.1.5) and `plugins/golem15/sociallogin` `9fd58db` (v0.1.2), go.mod requires sm-user-plugin v0.1.5 and sm-sociallogin-plugin v0.1.2; sm-bm-app the same gitlinks, go.mod requires sm-user-plugin v0.1.5 (sociallogin stays `v0.0.0` with its directory replace, so only its gitlink moved; go.sum unchanged); fonoteka.go gitlink `35cf166`.
|
||||
|
||||
Notes:
|
||||
- sm-sociallogin-plugin's `GOWORK=off go mod tidy` also added the framework's new indirect dependencies (charm.land v2 modules and their transitive set), because its go.mod replaces the framework with `../summercms.go`, whose master carries the 19.8 console work.
|
||||
- sm-sociallogin-plugin cannot be fetched over anonymous https; sm-demo-app's `go get` of v0.1.2 ran with a process-scoped `url.git@git.golem15.com:.insteadOf https://git.golem15.com/` (environment only, no config change). Machines that resolve the module with `GOWORK=off` need the same SSH access.
|
||||
- sm-bm-app: `go mod tidy` would also add the framework's 19.8 indirect dependencies through its `../summercms.go` replace; it was not run, so the commit changes only the require line.
|
||||
- Still open: g15office (comment on `onunbz3l`, set it to Testing, move this todo's task `sqnwn1zi` to Live) needs a session with the g15office tool; the user re-runs `/msd-verify-work 19.5`.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None.
|
||||
|
||||
---
|
||||
*Phase: 19.5-social-login-in-sm-user-plugin*
|
||||
*Completed: 2026-10-21 (Task 7 on the hold branch)*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All 14 commits listed exist (U 3, S 3, B 1, F 7); the five created files and the todo exist; no draft status line remains; the Release table heading is present once; no tag points at either plugin master and v0.1.2 is absent on the remote.
|
||||
@@ -82,6 +82,12 @@ These answer RESEARCH.md's Open Questions 1, 4 and 6.
|
||||
- **D-24:** **fonoteka gets the relaxed D-13 unlink rule.** There is no opt-out flag. If a fonoteka parity or test case covers the old 409, it is updated.
|
||||
- **D-25:** **The phase has 4 plans.** (1) Foundations: `cooler.Pull`, the goth decision note, the user-plugin changes D-13 (unlink with a `SELECT … FOR UPDATE` lock on the user row) and D-15, and the remaining folded-todo fix. (2) sm-sociallogin-plugin. (3) Adoption in sm-demo-app, sm-bm-app and fonoteka. (4) Unit tests and the PHP test map.
|
||||
|
||||
### Decisions after verification (2026-10-21, gap closure)
|
||||
|
||||
- **D-26:** **Close the password-registration pre-account takeover (VERIFICATION human item 1).** An email match gets a verified provider's identity automatically only when the account has proved its address: activated by mail (`Activate` / `ActivateByCode`) or set its password through the mailed reset code. A self-set password alone (`/register` with `activate_mode=auto`, admin-created) is no longer proof; such a match goes to the pending screen with reason `unverified_match`, and the owner links from the settings. Diverges from PHP D-10 on purpose. Existing accounts with no recorded proof take the pending path.
|
||||
- **D-27:** **Proof is an additive column in sm-user-plugin: `users.email_verified_at` (nullable timestamp), set by mail activation and by a completed reset-code password reset.** This relaxes D-22's "no sm-user-plugin migration" for this follow-up only; the change is additive (no API payload, route or config key changes) and ships as sm-user-plugin v0.1.5, then sm-sociallogin-plugin v0.1.2, then pointer bumps in sm-demo-app, sm-bm-app and fonoteka.go (D-21 order, push and tags on the user's go-ahead). README and 19.5-SECURITY-REVIEW.md wording that a self-set password "proved the address" is corrected.
|
||||
- **D-28:** **bm's `OAuthCallbackView` gets a vitest view test** (VERIFICATION truth 16): `router.replace` drops `?code=` before the single `oauth-complete` call, a second mount does not redeem again, `?error=` shows the message and `?pending_registration=` routes to `oauth-register`. No new npm dependency.
|
||||
|
||||
### Claude's Discretion
|
||||
- RESEARCH.md's recommendations settle the other open questions:
|
||||
- Login touches last-seen and the IP address, with no new event.
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
---
|
||||
phase: 19.5-social-login-in-sm-user-plugin
|
||||
reviewed: 2026-10-21T12:00:00Z
|
||||
depth: standard
|
||||
files_reviewed: 49
|
||||
files_reviewed_list:
|
||||
- summercms.go/modules/cooler/cooler.go
|
||||
- summercms.go/modules/cooler/memory.go
|
||||
- summercms.go/modules/cooler/rediscache.go
|
||||
- summercms.go/modules/cooler/pull_smoke_test.go
|
||||
- summercms.go/modules/cooler/pull_test.go
|
||||
- summercms.go/modules/cooler/docker_test.go
|
||||
- summercms.go/scripts/check-phase19.5.sh
|
||||
- fonoteka.go/plugins/golem15/user/classes/oauth_providers.go
|
||||
- fonoteka.go/plugins/golem15/user/console/backfill_handles.go
|
||||
- fonoteka.go/plugins/golem15/user/controllers/api_controller.go
|
||||
- fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go
|
||||
- fonoteka.go/plugins/golem15/user/controllers/registration.go
|
||||
- fonoteka.go/plugins/golem15/user/README.md
|
||||
- sm-sociallogin-plugin/plugin.go
|
||||
- sm-sociallogin-plugin/service.go
|
||||
- sm-sociallogin-plugin/routes.go
|
||||
- sm-sociallogin-plugin/config.go
|
||||
- sm-sociallogin-plugin/admin_settings.go
|
||||
- sm-sociallogin-plugin/admin_permissions.go
|
||||
- sm-sociallogin-plugin/classes/flow.go
|
||||
- sm-sociallogin-plugin/classes/registration.go
|
||||
- sm-sociallogin-plugin/classes/callback.go
|
||||
- sm-sociallogin-plugin/classes/statecookie.go
|
||||
- sm-sociallogin-plugin/classes/codes.go
|
||||
- sm-sociallogin-plugin/classes/providers_goth.go
|
||||
- sm-sociallogin-plugin/classes/provider.go
|
||||
- sm-sociallogin-plugin/classes/credentials.go
|
||||
- sm-sociallogin-plugin/classes/config.go
|
||||
- sm-sociallogin-plugin/classes/messages.go
|
||||
- sm-sociallogin-plugin/controllers/callback.go
|
||||
- sm-sociallogin-plugin/controllers/redirect.go
|
||||
- sm-sociallogin-plugin/controllers/helpers.go
|
||||
- sm-sociallogin-plugin/controllers/complete.go
|
||||
- sm-sociallogin-plugin/controllers/link_ticket.go
|
||||
- sm-sociallogin-plugin/controllers/register_complete.go
|
||||
- sm-sociallogin-plugin/models/settings.go
|
||||
- sm-sociallogin-plugin/updates/202610200001_create_golem15_sociallogin_settings.go
|
||||
- sm-bm-app/frontend/src/lib/oauth.ts
|
||||
- sm-bm-app/frontend/src/views/OAuthCallbackView.vue
|
||||
- sm-bm-app/frontend/src/views/OAuthRegisterView.vue
|
||||
- sm-bm-app/frontend/src/views/LoginView.vue
|
||||
- sm-bm-app/frontend/src/views/ProfileView.vue
|
||||
- sm-bm-app/frontend/src/stores/auth.ts
|
||||
- sm-bm-app/frontend/src/pwa/sw.js
|
||||
- sm-bm-app/sociallogin_test.go
|
||||
- sm-bm-app/config/golem15.sociallogin.yaml
|
||||
- sm-demo-app/sociallogin_test.go
|
||||
- sm-demo-app/deploy/env.example
|
||||
- sm-demo-app/go.work
|
||||
findings:
|
||||
critical: 2
|
||||
warning: 5
|
||||
info: 9
|
||||
total: 16
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# Phase 19.5: Code Review Report
|
||||
|
||||
**Reviewed:** 2026-10-21
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 49 (listed in the frontmatter; paths prefixed with the repo dir)
|
||||
**Status:** issues_found
|
||||
|
||||
## Summary
|
||||
|
||||
Scope: `cooler.Cache.Pull` (5e0a04e, 671d388), the gate script, the sm-user-plugin diff 1fc0ce7..c7635c5, the whole of sm-sociallogin-plugin, the bm frontend and wiring (e76f8bc, 117ef8f) and the demo wiring (9eb8492, d5a98d9, 61a00e9).
|
||||
|
||||
The low-level pieces are solid. AES-GCM sealing with HKDF purpose keys, the cookie name as AAD, constant-time state compare, `GETDEL` and otter `Compute` for Pull, the `FOR UPDATE` unlink and the callback-target rebuild all match what the security review says. I found no defect in them.
|
||||
|
||||
The account-linking model is still not safe, and the review lists it as closed:
|
||||
|
||||
- **CR-01 (T-19.5-38 mitigation broken under default config).** `ownerVerifiedEmail` treats `has_self_set_password` as proof that the owner controls the address. With golem15.user's default `activate_mode: auto`, `POST /register` creates an activated account with a self-set password for any email, and nobody proves the address. An attacker who registers the victim's email first gets the victim's later verified Google or GitHub sign-in linked onto the attacker's account. This is the same pre-account takeover, now via password registration.
|
||||
- **CR-02 (the "reset proves the address" path leaves the attacker's identity linked).** The fix's recovery path, a password reset, does not remove a pre-planted Microsoft or Facebook identity. bm runs Microsoft with tenant `common`, so the attacker keeps permanent access to an account the victim "recovered".
|
||||
|
||||
The warnings cover:
|
||||
|
||||
- the one-time handoff code is not bound to the browser that started the flow (login CSRF; in `token_cookie` mode a plain GET is enough);
|
||||
- registration consent, including marketing, comes from a GET that any site can trigger;
|
||||
- the reusable pending code and the email sit in URLs and browser history;
|
||||
- `localhost` and `127.0.0.1` are always allowed as callback hosts in production;
|
||||
- the gate's additive-API check has blind spots.
|
||||
|
||||
Core-plugin check (sm-user-plugin): no exported declaration was removed or changed in signature. The only behaviour change is the relaxed unlink rule and the wider default allow-list (IN-01). It is intentional (D-13), documented in the README, and not raised as critical.
|
||||
|
||||
## Narrative Findings (AI reviewer)
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### CR-01: A verified sign-in still auto-links onto an attacker-created account, because a self-set password does not prove the address under `activate_mode: auto` (T-19.5-38 not actually closed)
|
||||
|
||||
**File:** `sm-sociallogin-plugin/classes/flow.go:97-101`, `sm-sociallogin-plugin/classes/flow.go:202-207`, `sm-sociallogin-plugin/classes/flow.go:262-264`; root cause `fonoteka.go/plugins/golem15/user/controllers/api_controller.go:658` with `fonoteka.go/plugins/golem15/user/config/config.yaml:10` (`activate_mode: auto`, the default; neither sm-bm-app nor sm-demo-app overrides it)
|
||||
|
||||
**Issue:** `ownerVerifiedEmail(u)` returns `u.HasSelfSetPassword`. Its doc comment and the security review assume a self-set password means "registered with one, or set it through the mailed reset code", i.e. the owner proved the address. That is false for `POST /_user/api/v1/register` with the default `activate_mode: auto`. `RegisterUser(..., RegisterOptions{Activate: true})` stores `has_self_set_password = true` and an activated account, and no mail is ever sent to the address.
|
||||
|
||||
Failure scenario (default config, any app with Google or GitHub enabled):
|
||||
1. The attacker calls `POST /register` with `email=victim@gmail.com` and a password of their choice. The account is activated immediately (auto mode) with `has_self_set_password = true`.
|
||||
2. Later the victim clicks "Sign in with Google". Google asserts `email_verified = true` for `victim@gmail.com`.
|
||||
3. `HandleCallback` finds the email match. `profile.EmailVerified && ownerVerifiedEmail(match)` is true, so `loginWithIdentity` links the victim's Google identity onto the attacker's account and signs the victim in.
|
||||
4. The victim now uses an account the attacker can enter at any time with the password. This is the classic-federated-merge pre-account takeover that T-19.5-38 was meant to close. `CompletePending` (lines 202-207) has the same hole.
|
||||
|
||||
`TestPreAccountTakeover` only covers the password-less (social-created) variant, so it stays green.
|
||||
|
||||
**Fix:** Auto-link only on proof of address ownership, not on a password flag. Options, from simplest:
|
||||
```go
|
||||
// Simplest and safest: never auto-link on an email match. Always go pending
|
||||
// (unverified_match / 422 email_exists_link_account); the owner signs in and
|
||||
// links from the profile with a link ticket, as bm already tells its users.
|
||||
if match != nil {
|
||||
return pendingOutcome(provider, profile, returnTo, ReasonUnverifiedMatch, st.BetaKey), nil
|
||||
}
|
||||
```
|
||||
Alternatively, keep auto-link but gate it on a real proof signal: an additive `email_verified_at` column in golem15.user, set by `VerifyActivationCode`, `VerifyResetCode` and admin activation, never by auto-mode registration. Then use `ownerVerifiedEmail(u) = u.EmailVerifiedAt != nil`. At minimum, refuse auto-link when `RegistrationSettings` reports `mode == "auto"`. Update README rows 49-51 and the security review's T-19.5-38 entry.
|
||||
|
||||
### CR-02: An account pre-created from an unverified provider email keeps the attacker's identity after the victim "proves the address" with a password reset
|
||||
|
||||
**File:** `sm-sociallogin-plugin/classes/registration.go:141` (`Activate: true` for every social account, also when `p.EmailVerified` is false); `fonoteka.go/plugins/golem15/user/classes/codes.go:94-114` (`VerifyResetCode` sets `has_self_set_password` and leaves `golem15_user_oauth_identities` alone); the documented recovery path in `sm-sociallogin-plugin/README.md:50` and `sm-sociallogin-plugin/classes/flow.go:254-261`
|
||||
|
||||
**Issue:** The T-19.5-38 fix stops the *auto-link* onto a password-less account. It still creates an **activated** account from an email the provider did not verify (Microsoft, Facebook, or Google without the flag). Its recovery advice ("the owner proves the address with the mailed password reset, after which verified providers link again") hands the victim an account that still carries the attacker's identity.
|
||||
|
||||
Failure scenario on sm-bm-app (`config/golem15.sociallogin.yaml`, tenant `common` by default; DEPLOY.md lists `common` as a valid choice):
|
||||
1. The attacker controls an Entra tenant and sets `mail = student@uwr.edu.pl` on a user. They sign in at `/oauth/microsoft`, get pending (no match), accept the consent, and `CompletePending` creates an activated password-less account for `student@uwr.edu.pl` with the attacker's Microsoft identity linked.
|
||||
2. The real student tries to register: the email is taken. They use "Nie pamiętam hasła", receive the reset code (the address really is theirs) and set a password. `VerifyResetCode` sets `has_self_set_password = true` and nothing else.
|
||||
3. The student uses the account (quizzes, presentations, profile). The attacker signs in with their Microsoft identity whenever they like (`identityUser` → `loginWithIdentity`), because the identity was never removed. bm's profile does not list linked identities, so the student cannot see it.
|
||||
|
||||
**Fix:** Close both ends.
|
||||
- Do not create an activated account from an unverified email. Use `RegisterOptions{Activate: p.EmailVerified, ...}`. When it is false, send golem15.user's activation mail (as `activate_mode: user` does) and answer the frontend with "check your mail". `checkCanSignIn` already refuses inactive users.
|
||||
- When a password-less account's owner proves the address (activation or reset code), revoke what was attached before the proof: delete the user's `oauth_identities` rows and set `tokens_valid_after = now`. sm-user-plugin is a core plugin, so do it additively: fire a new event (e.g. `user.EmailProven`) from `VerifyResetCode` and `VerifyActivationCode` when `HasSelfSetPassword` flips from false, and have golem15.sociallogin drop the identities in its listener. Add a test: pre-created Microsoft account → reset → the Microsoft identity no longer signs in.
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: The one-time completion code is not bound to the browser that started the flow (login CSRF; a plain GET in `token_cookie` mode)
|
||||
|
||||
**File:** `sm-sociallogin-plugin/controllers/callback.go:113-124`, `sm-sociallogin-plugin/controllers/complete.go:17-35`, `sm-sociallogin-plugin/controllers/helpers.go:148-168`; `sm-bm-app/frontend/src/views/OAuthCallbackView.vue:36-52`
|
||||
|
||||
**Issue:** The state cookie protects `/oauth/{provider}/callback`, but the second leg does not. `?code=` on the frontend callback, redeemed by `GET /_user/api/v1/oauth-complete`, is a bearer value that any browser can redeem.
|
||||
|
||||
An attacker runs the flow with their own provider account, stops at `https://app/auth/oauth/callback?code=X` and sends that link to the victim (or iframes or redirects to it). The bm view redeems it and calls `setSession(token, user, true)`: the victim is now persistently signed into the attacker's account. If the victim was already signed in, their session is silently replaced. Anything they upload or enter (quiz answers, presentations) lands in the attacker's account.
|
||||
|
||||
With `golem15.sociallogin.token_cookie: true` it is worse. A top-level GET to `/_user/api/v1/oauth-complete?code=X` sets the session cookie directly, with no SPA involved.
|
||||
|
||||
**Fix:** Bind the code to the initiating browser while keeping the PHP response shape. The frontend generates a random verifier, keeps it in `sessionStorage` and sends `code_challenge = base64url(SHA-256(verifier))` with the start request. Seal the challenge in `State` and copy it into `CompletionPayload` and `PendingPayload`. `oauth-complete` and `oauth-register-complete` then require a matching `code_verifier` and answer 410 otherwise. Clients that send no challenge can keep the old behaviour behind a config flag during migration. For same-site deployments, a `__Host-` binding cookie set at `/oauth/{provider}` and checked at `oauth-complete` also works.
|
||||
|
||||
### WR-02: Registration consent (terms, privacy and **marketing**) is read from a cross-site-triggerable GET and recorded as given
|
||||
|
||||
**File:** `sm-sociallogin-plugin/controllers/redirect.go:69-72`, `sm-sociallogin-plugin/controllers/redirect.go:143-155`; `sm-sociallogin-plugin/classes/flow.go:103-119`; `sm-sociallogin-plugin/classes/registration.go:127-129`, `:165-177`
|
||||
|
||||
**Issue:** `action=register&terms_accepted=1&privacy_accepted=1&marketing_consent=1` on `GET /oauth/{provider}` is sealed into the state cookie as the user's consent. On callback the account is created with `marketing_consent = true`, and the register event records the consent with the victim's IP and user agent.
|
||||
|
||||
Any page can start that navigation. GitHub, and Microsoft or Google with an existing session, often return without showing anything new, so a victim who never saw the terms ends up with an account and a recorded opt-in to marketing. That is a forged GDPR consent record, and the "repudiation" protection in T-19.5-21 records the forgery as genuine.
|
||||
|
||||
**Fix:** Do not take consent from the start request. Always route a first sign-in through the pending screen and collect consent with the POST `oauth-register-complete`, which already exists. If the PHP contract must keep `action=register`, at least ignore `marketing_consent` on the GET and require a same-origin or CSRF-bound nonce (e.g. the WR-01 challenge) before honouring the GET's consent.
|
||||
|
||||
### WR-03: The pending registration code (reusable for 10 minutes) and the user's email are kept in URLs and browser history
|
||||
|
||||
**File:** `sm-sociallogin-plugin/controllers/callback.go:102-110`; `sm-bm-app/frontend/src/views/OAuthCallbackView.vue:27-31`; `sm-bm-app/frontend/src/views/OAuthRegisterView.vue:17-18`
|
||||
|
||||
**Issue:** The callback view takes the completion code out of the URL, but for the pending case it does `router.replace({ name: 'oauth-register', query: { pending, provider, reason, email } })`. The pending code and the email stay in the address bar and history.
|
||||
|
||||
The pending code is not consumed by reads (`GetPending`). It stays valid for `PendingTTL` (10 min) until someone completes it, and completing it returns a session token for the new account. On a shared or lab computer, through history sync or screen sharing, or in a separate frontend host's access log (the Nuxt apps that will use this plugin), a third party can finish the registration first and take the session. The email in the query is also PII in logs. T-19.5-26 covers only the completion code.
|
||||
|
||||
**Fix:** In the frontend, move `pending`, `provider`, `reason` and `email` into `history.state` (`router.replace({ name: 'oauth-register', state: {...} })`) or `sessionStorage`, and keep the URL bare. On the server, keep the PHP redirect shape but consider shortening `PendingTTL` and binding the pending code to the browser as in WR-01.
|
||||
|
||||
### WR-04: `localhost` and `127.0.0.1` are always allowed `frontend_callback` hosts, also in production
|
||||
|
||||
**File:** `sm-sociallogin-plugin/classes/callback.go:20-36` (lines 22-23)
|
||||
|
||||
**Issue:** `NewHostSet` always adds `localhost` and `127.0.0.1`. A crafted link `https://app/oauth/github?frontend_callback=http://localhost:4444/auth/oauth/callback` sends the victim's one-time login code to whatever listens on that port on the victim's machine: another local user's process on a shared machine, a dev server or a malicious local app. Providers that auto-approve (GitHub, or Microsoft with an existing session) need no interaction beyond the click. The config comment presents this as intended, but nothing ties it to development.
|
||||
|
||||
**Fix:** Add the loopback hosts only outside production (e.g. when `app.debug` is true or `SUMMER_ENV` is `local`/`testing`), or only through an explicit `allowed_hosts` entry:
|
||||
```go
|
||||
if cfg.AllowLoopback { // golem15.sociallogin.allow_loopback, default false; true in dev config
|
||||
s.Add("localhost"); s.Add("127.0.0.1")
|
||||
}
|
||||
```
|
||||
|
||||
### WR-05: The gate's "additive only" check for the core user plugin misses several kinds of breaking change
|
||||
|
||||
**File:** `summercms.go/scripts/check-phase19.5.sh:328-330` (`removed_exports`), `:427-437`
|
||||
|
||||
**Issue:** `removed_exports` only catches a removed line that starts with `func <Exported>`, `func (recv) <Exported>` or `type <Exported>`. T-19.5-04 relies on it as a guarantee, but it is green for:
|
||||
- a removed or renamed exported struct field, such as a field of `RegisterOptions` or `OAuthIdentitiesOptions`;
|
||||
- an exported `const` or `var`, including members of `const (...)` and `var (...)` blocks;
|
||||
- a method **added** to an exported interface, which breaks every implementer;
|
||||
- a changed config key in `config/*.yaml`, a route path or middleware in `routes.go`, or a JSON payload key.
|
||||
|
||||
The check is limited to `*.go` lines starting with `func`/`type`. So the claim "no route, payload, table, migration or config key changed" is checked only for migrations.
|
||||
|
||||
**Fix:** Compare the exported API of the two commits instead of grepping diff lines. For example, run `go doc -all ./... | sort` (or `apidiff` from golang.org/x/exp, which would need a dependency note) at `$USER_BASE` (via `git worktree`) and at HEAD, and refuse any removed or changed line. Also add a `git diff --name-only "$USER_BASE" -- config/ routes.go` check that requires review.
|
||||
|
||||
## Info
|
||||
|
||||
### IN-01: Core plugin behaviour change in `OAuthIdentitiesDestroy` (intentional, D-13)
|
||||
|
||||
**File:** `fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go:31-35`, `:116-121`, `:126-134`
|
||||
**Issue:** A user with a self-set password and a single identity used to get 409 and now gets 204. When no `Providers` option is set, the allow-list is now the provider source's `KnownOAuthProviders()`, or the default list, which now includes `microsoft`. The change is documented in the README and was decided in D-13. I note it only so that consumers that relied on the old 409 (fonoteka, the PHP-ported frontends) are checked. There is also a small race: an identity deleted between the `First` lookup (line 82) and the guarded DELETE yields 409 `last_method_blocked` instead of 404.
|
||||
**Fix:** None required for the rule. For the race, return 404 when `deleted == 0` and the row no longer exists (re-check inside the transaction after the lock).
|
||||
|
||||
### IN-02: The registration throttle fails open
|
||||
|
||||
**File:** `fonoteka.go/plugins/golem15/user/controllers/api_controller.go:744-750` (used by `sm-sociallogin-plugin/classes/registration.go:76`)
|
||||
**Issue:** `RegistrationsFromIP` ignores the query error and returns 0, and `checkRegistrationAllowed` skips the throttle when `ClientIP` is empty. On a database hiccup both creation paths stop throttling. This matches `/register`, but the exported helper now carries the behaviour into a second plugin.
|
||||
**Fix:** Have the exported helper return `(int64, error)` (a new function, to stay additive) and fail closed in golem15.sociallogin.
|
||||
|
||||
### IN-03: The `fb_no_email` pending registration can never complete
|
||||
|
||||
**File:** `sm-sociallogin-plugin/classes/flow.go:90-91`, `:197-229`; `sm-sociallogin-plugin/controllers/register_complete.go:37-189`
|
||||
**Issue:** A provider without an email goes pending. `oauth-register-complete` accepts no email field and the payload's email is empty, so `RegisterUser` always answers 422 `errors.email` (pinned by `register_complete_test.go:187`). bm's consent form shows no email field error, so the user is stuck on an unsolvable form.
|
||||
**Fix:** Either accept an `email` field when `payload.Email == ""` (treated as unverified, sent through activation), or have the frontend show "your provider shared no email, register with a password" for `reason=fb_no_email`.
|
||||
|
||||
### IN-04: The Microsoft tenant is validated loosely in the admin form and not at all from config or env
|
||||
|
||||
**File:** `sm-sociallogin-plugin/models/settings.go:79-90`; `sm-sociallogin-plugin/classes/credentials.go:38`
|
||||
**Issue:** `^[A-Za-z0-9.-]+$` accepts `.` and `..`, and the tenant is put into the authority path (`login.microsoftonline.com/<tenant>/oauth2/...`). A tenant from config or `MICROSOFT_TENANT` is not checked at all. Only administrators control it, so the impact is a misdirected authority URL, not an attack.
|
||||
**Fix:** Require `common|organizations|consumers|<GUID>|<domain with at least one dot, no leading or trailing dot>`, and apply the same check in `ResolveCredentials` (refuse the provider as not configured on a bad value).
|
||||
|
||||
### IN-05: The Redis `Pull` needs Redis 6.2 or newer, with no boot-time check
|
||||
|
||||
**File:** `summercms.go/modules/cooler/rediscache.go:55-69`
|
||||
**Issue:** On an older server `GETDEL` fails with "unknown command" at request time. Every oauth-complete, register-complete and link ticket then answers 500. The requirement is only documented.
|
||||
**Fix:** Check the version once (`INFO server` → `redis_version`) when the Redis cache is built, and log or refuse a version below 6.2. Alternatively, fall back to a `MULTI GET DEL EXEC` transaction.
|
||||
|
||||
### IN-06: An added method on an exported interface shipped as a patch release
|
||||
|
||||
**File:** `summercms.go/modules/cooler/cooler.go:77-81`
|
||||
**Issue:** Adding `Pull` to `cooler.Cache` breaks any out-of-tree `Cache` implementation (decorators, test fakes) and shipped as v0.3.0 → v0.3.1. No implementation exists in the sibling repositories (checked), so nothing breaks today.
|
||||
**Fix:** Bump the minor version for interface additions, or mention it in the changelog post for the tag.
|
||||
|
||||
### IN-07: The state cookie is not `Secure` on sm-bm-app
|
||||
|
||||
**File:** `sm-sociallogin-plugin/classes/statecookie.go:79-88`; `sm-bm-app/config/golem15.sociallogin.yaml`
|
||||
**Issue:** `StateCookieSecure` falls back to `auth.cookie.secure`, else an https `app.url`. bm has no `app.url`, so the sealed state cookie goes out over plain http too. The value is encrypted and short-lived, so the impact is low.
|
||||
**Fix:** Set `auth.cookie.secure: true` (or `app.url`) in bm's production config, or derive Secure from `r.TLS` / a trusted `X-Forwarded-Proto` when nothing is configured.
|
||||
|
||||
### IN-08: The app-level social login tests depend on the host environment
|
||||
|
||||
**File:** `sm-bm-app/sociallogin_test.go:26-29`, `:99-102`; `sm-demo-app/sociallogin_test.go` (`socialServer`)
|
||||
**Issue:** `copyLegacyEnv` reads `GOOGLE_CLIENT_ID`, `FACEBOOK_*`, `GITHUB_*`, `MICROSOFT_*`, `BETATESTER_KEY` and `FRONTEND_URL` from the process environment. A developer or CI host with any of them set changes the provider list ("only microsoft") or the redirect assertions, which makes the tests flaky on such hosts.
|
||||
**Fix:** `t.Setenv(name, "")` for every `legacyEnv` variable the test does not set on purpose (export the list from the plugin, or copy it into a test helper).
|
||||
|
||||
### IN-09: The gate's vitest detector refuses on words in test titles or logs
|
||||
|
||||
**File:** `summercms.go/scripts/check-phase19.5.sh:221`
|
||||
**Issue:** `\bfailed\b|\bskipped\b|\btodo\b|\bFAIL\b` is matched against the whole output. A test titled "shows an error when the login failed", or a component logging "FAIL" to stderr, fails the gate although every test passed. This fails closed, so it gives a false red, never a false green. Separately, `mktemp` logs in `go_json` and `run_bm` are not removed when the script is interrupted (no `trap`).
|
||||
**Fix:** Run vitest with `--reporter=json --outputFile=...` and check `numFailedTests`, `numPendingTests` and `numTodoTests` and the per-file status. Add `trap 'rm -f "$log" "$err"' RETURN` in `go_json`.
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-10-21_
|
||||
_Reviewer: Claude (msd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
@@ -0,0 +1,146 @@
|
||||
# Phase 19.5 Security Review: social sign-in (sm-sociallogin-plugin)
|
||||
|
||||
**Date:** 2026-10-21
|
||||
**Level:** OWASP ASVS L1 (RESEARCH "Security Domain"), block on high; D-11 asks for this review of the matching and state code.
|
||||
**Scope:** `cooler.Cache.Pull` (summercms.go `modules/cooler`), this phase's sm-user-plugin additions (provider source, row-locked unlink, exported sign-in helpers), the new `sm-sociallogin-plugin` (registry, goth wrappers, state cookie, callback rules, one-time codes, matching, registration, linking, settings), the adoption in sm-demo-app, sm-bm-app and fonoteka.go, and this plan's gate.
|
||||
**Threats reviewed:** T-19.5-01..T-19.5-37 and T-19.5-SC from the threat models of plans 01, 02, 03 and 04, plus RESEARCH's Security Domain table; T-19.5-39..T-19.5-46 from plan 05 (gap closure, D-26..D-28).
|
||||
**Result:** every threat is closed by a test that fails when the mitigation is removed, or accepted with a reason (process controls). Two defects found by the new tests were fixed in their own commits (one of them on T-19.5-12). One new finding, T-19.5-38 (pre-account takeover through an account created from an unverified provider email, the PHP behaviour D-10 ported), was raised at the Task 6 checkpoint; the user chose option (b), and it is closed by commit d7ee28d with `TestPreAccountTakeover`. Plan 19.5-05 closes its password-registration variant (19.5-REVIEW CR-01, T-19.5-39): the owner-side proof is now `users.email_verified_at`, which only the mailed activation code and a completed reset-code password reset write, instead of a self-set password. 19.5-REVIEW CR-02 stays open (section "Open findings").
|
||||
|
||||
Repositories and abbreviations: **F** = summercms.go, **U** = sm-user-plugin (`fonoteka.go/plugins/golem15/user`), **S** = `sm-sociallogin-plugin`, **D** = sm-demo-app, **B** = sm-bm-app (with `plugins/jz/bm` and `frontend/`), **A** = the fonoteka.go workspace.
|
||||
|
||||
## Method
|
||||
|
||||
1. For each threat, read the code that implements the mitigation (file and line below) and find the test that fails without it. Where a test was missing, add it as a separate commit in the owning repository.
|
||||
2. Mutation checks on the race-sensitive mitigations: making `memoryCache.Pull` a Get followed by a delete fails `TestPullConcurrent/memory`; removing `FOR UPDATE` from `OAuthIdentitiesDestroy` fails `TestOAuthIdentitiesDestroyRace` (both handler variants and the concurrent handlers); six edits of `PHP_METHOD_MAP.md` (missing, extra and unknown rows, a wrong subtest, a wrong repository, a missing reason) each fail `TestPHPMethodMap`. Every mutation was reverted.
|
||||
3. Sweeps: every non-test `slog` call (section "Log calls"), every response that carries a credential, the provider HTTP clients, the URL of every redirect.
|
||||
4. Run the whole gate: `scripts/check-phase19.5.sh --all` on a Docker host.
|
||||
|
||||
## Defects fixed during this plan
|
||||
|
||||
| Commit | Repo | Threat | Found by | Fix |
|
||||
|--------|------|--------|----------|-----|
|
||||
| 21f3fe5 | S | T-19.5-12 | `TestRegisterComplete/concurrent_submit_one_winner` | Two submissions of one pending code that both reached account creation raced on the `users.email` unique index: the loser answered **500** instead of 410. Account creation now takes transaction-scoped advisory locks on the provider identity and the email (`lockCreation`), and a completion that gets them after a winner committed finds the identity and returns `ErrPendingConsumed` (410), writing nothing. |
|
||||
| c99f5f9 | S | T-19.5-16 (settings write path) | `TestSettings/admin_json_hides_secrets` | Every save of the admin settings page answered 500: `Settings.Rules()` used `regex:`, which lagoon's model validation does not support, so the encrypted secrets could not be managed from the admin at all. The tenant format moved to `Settings.BeforeValidate` (422 on `microsoft_tenant`); README updated. |
|
||||
| d7ee28d | S | T-19.5-38 | the review (finding), pinned by `TestPreAccountTakeover` | A verified sign-in linked onto a password-less account created from an unverified provider email (pre-account takeover). The callback and the pending completion now auto-link a verified email only onto an account with a self-set password (`ownerVerifiedEmail`); otherwise pending `unverified_match` and 422 `email_exists_link_account`. Deliberate divergence from PHP (user decision, option b). 19.5-05 superseded the self-set-password test with `users.email_verified_at` (T-19.5-39). |
|
||||
|
||||
All three are production changes after plan 02's release (v0.1.0); they reach the applications through the v0.1.1 release (T-19.5-36).
|
||||
|
||||
## Test pins added by the review
|
||||
|
||||
| Commit | Repo | Threats | What the test pins |
|
||||
|--------|------|---------|--------------------|
|
||||
| b7fa407 | S | T-19.5-08, T-19.5-12 | `TestStateCookie/key_domain_separated` and `TestCodes/key_domain_separated`: a state cookie opens only under HKDF(app.key, `golem15.sociallogin.oauth-state.v1`), a code value only under HKDF(app.key, `golem15.sociallogin.code.v1`); neither opens with the raw application key, lagoon's column key (`summercms.lagoon.encrypted.v1`) or the other purpose's key. |
|
||||
|
||||
The rest of the pins are this plan's unit tests (Tasks 2 to 4); the register names them.
|
||||
|
||||
## Threat register
|
||||
|
||||
| Threat | Component | Mitigation as built | Implementation | Test (repo) | Status |
|
||||
|--------|-----------|---------------------|----------------|-------------|--------|
|
||||
| T-19.5-01 | `cooler.Cache.Pull` | Redis `GETDEL` and otter `Compute` with `InvalidateOp`: get and delete are one step; an expired entry is removed and reported not found. | F `modules/cooler/rediscache.go:57`, `memory.go:90` | `TestPullConcurrent` (64 pullers, 20 rounds, memory and miniredis), `TestPullExpired`, `TestPullPrefixIsolation`, `TestPullDocker` (redis:7-alpine) (F) | closed (mutation-checked) |
|
||||
| T-19.5-02 | `OAuthIdentitiesDestroy` (DoS: zero sign-in methods) | `SELECT id FROM users WHERE id = ? FOR UPDATE` before the guarded DELETE, in one transaction. | U `controllers/oauth_identities.go:101-111` | `TestOAuthIdentitiesDestroyRace` (two transactions in both orders, the handler waiting for the lock, concurrent handlers), `TestOAuthIdentitiesDestroyConcurrentSmoke` (U) | closed (mutation-checked) |
|
||||
| T-19.5-03 | unlink rule relaxation (D-13) | The DELETE is scoped to the JWT principal's user id and the path provider; it succeeds only with a self-set password or another identity; 409 and 404 bodies unchanged. | U `controllers/oauth_identities.go:32-35`, `:71-124` | `TestOAuthIdentitiesDestroyRule` (table incl. another user's identity 404, EN and PL 409 bodies) (U) | closed |
|
||||
| T-19.5-04 | sm-user-plugin core contract | Additive exports only; no route, payload, table, migration or config key changed. | U commits 78a867b, 1741618, c43ba87 | gate `--userplugin`: no exported declaration removed and no file under `updates/` changed since 4c5d3b8; `--fonoteka` green (F) | closed |
|
||||
| T-19.5-05 | `FinishLogin` restoring a soft-deleted account | FinishLogin authenticates nobody; S calls it only after an identity or a verified-email match (`signIn`), and the reactivate mail tells the owner. | U `controllers/registration.go:99`; S `classes/flow.go:269-305` | `TestFinishLogin/trashed_user_restored_with_one_mail` (U); `TestSoftDeleted/unverified_match_pending_and_no_second_row` (an unverified match never restores) (S) | closed |
|
||||
| T-19.5-06 | logs of the user-plugin additions | The one new log call logs `user_id` and a database error. | U `controllers/registration.go:121` | section "Log calls" | accepted: a single fixed-shape log line with no token, secret or code; nothing to pin beyond the review |
|
||||
| T-19.5-07 | callback (login CSRF, injected code) | Sealed per-browser state cookie; empty state refused; `subtle.ConstantTimeCompare`; provider bound by the cookie name (AAD) and the `Provider` field; cookie cleared on every outcome. | S `controllers/callback.go:27`, `:35-45`; `classes/statecookie.go:134-162` | `TestStateCookie/callback` (missing cookie, empty and unequal state, replay after clear, another provider's callback, cleared on success, provider error, failed exchange, expired code) (S) | closed |
|
||||
| T-19.5-08 | `SealState` / `OpenState` | AES-256-GCM, fresh nonce, HKDF purpose key separate from lagoon's, AAD the cookie name, version and iat (600 s, 60 s skew) checked, 3800-byte bound. | S `classes/statecookie.go:93-129`, `:180-220` | `TestStateCookie/refused` (16 cases), `age_window`, `seal_limits`, `key_domain_separated` (new) (S) | closed |
|
||||
| T-19.5-09 | `NormalizeFrontendCallback`, `NormalizeReturnTo` | Exact-host allow-list, fixed path, the target rebuilt from scheme, host and port; return_to local only; invalid input falls back to `default_callback`. The callback uses the target sealed at the start, never a request value. | S `classes/callback.go:56-107`; `controllers/callback.go:36-39` | `TestNormalizeFrontendCallback` (44 subtests), `TestNormalizeReturnTo` (22 subtests) (S); `TestLinkSmoke/bad_callback_falls_back` (S) | closed |
|
||||
| T-19.5-10 | email matching (account takeover) | Verified only where the provider asserts it: Google a boolean `verified_email`/`email_verified`, GitHub the primary verified `/user/emails` address, Facebook and Microsoft never; an unverified match goes pending `unverified_match`; case-insensitive match. | S `classes/providers_goth.go:126-254` (Facebook `:154`, Microsoft `:251`); `classes/flow.go:92-101` | `TestEmailVerified`, `TestNOAuth` (Microsoft mail of an existing user in four tenants: pending, no identity, completion 422), `TestMatching`, `TestPHPSocialAuthGate` (S) | closed (the creation paths: T-19.5-38 and T-19.5-39, closed) |
|
||||
| T-19.5-11 | `RegisterComplete` (pending hijack) | Completion re-checks identity and email match; `email_verified` false or absent gives 422 `email_exists_link_account`, never a login; payloads sealed. | S `classes/flow.go:175-228` | `TestRegisterComplete/email_exists_link_account_keeps_code`, `TestPHPPendingRegistrationTakeover` (incl. a payload sealed without the flag) (S) | closed |
|
||||
| T-19.5-12 | one-time codes (replay, race) | 64-character `crypto/rand` codes sealed under the cache key, consumed with `Pull`; TTLs 5 min, 10 min, 60 s; register-complete decides before it pulls; concurrent completions are serialised (21f3fe5). | S `classes/codes.go:81-224`; `controllers/register_complete.go:80-87`; `classes/registration.go:192` | `TestCodes` (incl. moved values, 32 concurrent pulls), `TestOAuthComplete`, `TestRegisterComplete/concurrent_submit_one_winner` (S) | fixed in commit 21f3fe5 (with its test) |
|
||||
| T-19.5-13 | beta gate (D-12) | Constant-time comparison on both creation paths; the start request's key carried in the sealed cookie and pending payload. | S `classes/registration.go:55-67`; `controllers/register_complete.go:71`, `:116-125` | `TestBetaGate` (S) | closed |
|
||||
| T-19.5-14 | `action=link`, link ticket | Link user from `RequestUser` (blacklist, `tokens_valid_after`, soft delete) or a single-use 60 s ticket issued to an authenticated POST; the user id sealed in the cookie; another user's identity refused; the callback re-checks the user. | S `controllers/redirect.go:93-106`; `controllers/link_ticket.go:19-42`; `classes/flow.go:127-163` | `TestLink` (bearer, both cookies, blacklisted token, `tokens_valid_after`, trashed at start and at callback, token in query ignored), `TestLinkTicket`, `TestPHPOAuthLinkCookie` (S) | closed |
|
||||
| T-19.5-15 | URLs and logs | No JWT in a URL (ticket, one-time code); the access log records no path or query; no log line carries a token, secret, code, ticket or provider error body. | S controllers (section "Log calls"); F `modules/surf/accesslog.go:42-49`; S `classes/providers_goth.go:85-99` | `TestLinkTicket/no_jwt_in_locations`, `TestSettings/no_secret_in_logs` (a provider whose errors carry the secret and a token), `TestGothProviders/expired_code_without_body`, `other_errors_without_body` (S); `TestAccessLogSmoke` (F) | closed |
|
||||
| T-19.5-16 | client secrets and provider tokens at rest | Secrets in `lagoon.Encrypted` write-only columns, never in the admin JSON; provider tokens in the identity's encrypted columns; cache values sealed. The settings page saves again (c99f5f9). | S `models/settings.go:35-48`, `:84-92`; `classes/flow.go:367-404` | `TestSettings/admin_json_hides_secrets` (stored, env and empty states; a saved secret is ciphertext), `TestIdentityTokens/encrypted_tokens`, `TestCodes/completion_sealed_and_consumed_once` (S) | fixed in commit c99f5f9 (the write path; confidentiality held) |
|
||||
| T-19.5-17 | provider calls and routes (DoS) | Every provider client has a timeout (`http_timeout`, default 10 s; a fallback client too); `throttle:20,1` on /oauth, `throttle:user-api` on the API routes. | S `classes/credentials.go:32`; `classes/providers_goth.go:116-121`; `routes.go:13-23` | `TestGothProviders/timeout` (all four wrappers against a stalled transport, the configured timeout), `TestRouteMiddleware` (route table, 21st request 429) (S) | closed |
|
||||
| T-19.5-18 | route shadowing with the OIDC provider | Reserved names refused by the registry; unknown names answer 404 without a cookie. | S `classes/provider.go:76`, `:122-149`; `controllers/redirect.go:30-34` | `TestRegistry/reserved_names`, `TestRoutes/unknown_and_reserved_404` (S); `TestSocialLoginCoexistsWithOAuthServer` (D) | closed |
|
||||
| T-19.5-19 | inactive or banned users | `checkCanSignIn` (activation, `user_throttle.is_banned`) on identity, verified-match, link and pending paths; restore only on identity or verified match. | S `classes/flow.go:340-355`, `:127-163`, `:269-290` | `TestInactiveAndBanned` (identity, verified match, link, pending completion), `TestSoftDeleted` (S) | closed |
|
||||
| T-19.5-20 | account creation flood | `allow_registration` and the per-IP register throttle (3 an hour) on both creation paths. | S `classes/registration.go:71-80` | `TestRegistrationGates` (S) | closed |
|
||||
| T-19.5-21 | consent records (repudiation) | Consent flags, policy versions, IP, user agent, provider and `password_bootstrap_source` in the register event's payload; `marketing_consent` column on opt-in; no password. | S `classes/registration.go:151-171` | `TestIdentityTokens/register_event_payload`, `TestRegisterComplete/form_encoded` (S) | closed |
|
||||
| T-19.5-22 | stale consent | Callback consent at most 600 s old. | S `classes/registration.go:85-96` | `TestConsent/expired_after_600s` (forged 601 s and 590 s cookies) (S) | closed |
|
||||
| T-19.5-23 | remote operations of plan 02 | Blocking go-ahead, fetch and range check before every push, never forced, no deploy. | 19.5-02-SUMMARY "Remote operations" | release control | accepted: a process control, recorded in 19.5-02-SUMMARY |
|
||||
| T-19.5-24 | bm SPA fallback | `/oauth` among the reserved prefixes of the static handler and the service worker. | B `plugins/jz/bm/static.go`; `frontend/src/pwa/sw.js` | `TestStaticReservedPaths` (B, gate `--bm`); `pwa.test.ts` (B) | closed |
|
||||
| T-19.5-25 | committed configuration and env templates | Credentials only in `.env` or the encrypted settings. | D, B `config/`, `deploy/` | grep in this review: no non-empty `CLIENT_SECRET=` or `client_secret:` in D or B config and templates | accepted: verified by grep; no runtime behaviour to test |
|
||||
| T-19.5-26 | bm callback view (code in history) | The view replaces the route before redeeming the code once; the server consumes it with `Pull`, so a code left in history is dead. | B `frontend/src/views/OAuthCallbackView.vue`; S `controllers/complete.go:24` | `TestOAuthComplete/once_with_shape` (second use 410) (S); `oauth.test.ts` (B); `src/views/OAuthCallbackView.test.ts` (B, 5fd0cc3: the real view mounted with vue's `createRenderer` and a memory-history router; the code is out of the route when the single `completeOAuth` starts, a second mount does not redeem again, `?error=` shows in the alert, a pending registration replaces to `oauth-register`; D-28) | closed (the view half by `OAuthCallbackView.test.ts`, plan 19.5-05) |
|
||||
| T-19.5-27 | Microsoft on bm (nOAuth, tenant) | Microsoft never verified, whatever the tenant; existing students link through the ticket. | S `classes/providers_goth.go:225-254` | `TestNOAuth` (four tenants) (S); `TestSocialLoginMounted` (B) | closed (the creation paths: T-19.5-38 and T-19.5-39, closed) |
|
||||
| T-19.5-28 | link ticket in the bm profile flow | Ticket from an authenticated POST, 60 s, single use, the only value in the URL. | S `controllers/link_ticket.go`; B `frontend/src/lib/oauth.ts` | `TestLinkTicket` (S); `oauth.test.ts` (B) | closed |
|
||||
| T-19.5-29 | pushes of shared repositories (plan 03) | Push only under plan 02's reply, plugin before pointer, after fetch and a range check. | 19.5-03-SUMMARY "Pushes" | release control | accepted: a process control, recorded in 19.5-03-SUMMARY |
|
||||
| T-19.5-30 | fonoteka regression from D-13 | fonoteka's vet and short suite at the new pointer; the Docker-backed user plugin suite. | A | gate `--fonoteka` (F); `TestOAuthIdentitiesDestroyRule` (U) | closed (the unrelated `TestPhase09SecurityRoutes` failure is outside this phase, deferred-items.md) |
|
||||
| T-19.5-31 | gate false green | go test -json detector fails on fail, skip (subtests too), zero tests, "no tests to run", a missing name, a prefix or a subtest standing in for a name; the vitest detector refuses skips and a missing required file (`oauth.test.ts`, `OAuthCallbackView.test.ts`); NOT RUN docker is loud and fails with `PHASE195_REQUIRE_DOCKER=1`. | F `scripts/check-phase19.5.sh` | `scripts/check-phase19.5.sh --self-test` (F) | closed |
|
||||
| T-19.5-32 | race tests (D-13, D-08) | Forced with explicit transactions and start barriers, both orders, repeated. | F `modules/cooler/pull_test.go`; U `oauth_identities_rule_test.go` | the mutation checks above (F, U) | closed |
|
||||
| T-19.5-33 | completeness of this review | Every T-19.5 ID of plans 01-04 appears here with a test or an accepted reason. | this document | the plan's acceptance count | closed |
|
||||
| T-19.5-34 | test fixtures and logs | Test-only literal secrets; provider HTTP faked through `ProviderConfig.HTTPClient`; the fake transport refuses hosts outside its table and the tests check every request stayed on the provider's hosts. | S `providers_goth_test.go`, `settings_test.go` | `TestGothProviders/fake_transport_refuses_unknown_hosts`, `TestSettings/no_secret_in_logs` (S) | closed |
|
||||
| T-19.5-35 | PHP map honesty | The map is checked against the list, the live PHP files and the Go test sources of S and U. | S `php_method_map_test.go` | `TestPHPMethodMap` (mutation-checked) (S) | closed |
|
||||
| T-19.5-36 | post-release fixes | The plan's "Post-release fixes" table lists 21f3fe5, c99f5f9 and d7ee28d against every pin that lacks them; the blocking-human Task 6 asks for the go-ahead; Task 7 releases in D-21 order and checks every pin with `merge-base --is-ancestor`. | 19.5-04 Tasks 5-7 | Task 7's acceptance criteria | release control: Task 6 reply "release"; v0.1.1 and the pointer bumps checked by Task 7's acceptance criteria (19.5-04-SUMMARY) |
|
||||
| T-19.5-37 | remote operations of Task 7 | Only after the Task 6 reply; fetch and range check before every push; never forced or deployed; only `plugins/golem15/user` staged in fonoteka.go. | 19.5-04 Task 7 | Task 7's acceptance criteria | release control: recorded with Task 7's acceptance criteria |
|
||||
| T-19.5-39 | sociallogin `ownerVerifiedEmail` (password-registration pre-account takeover, 19.5-REVIEW CR-01) | A verified provider email auto-links only onto an account whose `users.email_verified_at` is set; only the mailed activation code and a completed reset-code password reset write it. An auto-mode `/register` account, an admin-created or admin-activated account and every account from before sm-user-plugin v0.1.5 go pending `unverified_match`; the completion answers 422 `email_exists_link_account` and keeps the code. No backfill. | S `classes/flow.go:266` `ownerVerifiedEmail` (used by `HandleCallback` `:98` and `CompletePending` `:203`), 3bb5650; U `classes/codes.go:119` `VerifyActivationCode`, `controllers/api_controller.go:290` `ResetPassword` (add1f7a) | `TestOwnerVerifiedEmail/register_auto_then_google_pending`, `register_action_pending`, `legacy_account_pending`, `mail_activated_by_code_links`, `reset_code_password_links`; `TestPreAccountTakeover/after_mailbox_proof_links` (drives `/forgot-password` and `/reset-password`) (S) | closed (RED: the self-set-password rule fails `register_auto_then_google_pending`) |
|
||||
| T-19.5-40 | U `Update` and `FormBeforeUpdate` (proof carried over an email change) | An email change sets `email_verified_at` to NULL in the same write; a change of letter case only (`strings.EqualFold`) keeps it. | U `controllers/api_controller.go:444` `Update` (`:485`), `controllers/users_admin_controller.go:217` `FormBeforeUpdate` with the shared `storedEmail` (a99cb46) | `TestEmailVerifiedAt/update_email_change_clears`, `update_same_email_keeps`, `TestAdminEmailVerifiedAt/admin_email_change_clears`, `admin_save_keeps_proof` (U); `TestOwnerVerifiedEmail/email_change_drops_proof` (S) | closed (RED: the controllers before a99cb46 fail exactly the two `*_email_change_clears` cases) |
|
||||
| T-19.5-41 | `/register` and `/update` bodies (mass assignment of the proof) | `EmailVerifiedAt` is not in `Fillable()`; `Update` writes an explicit column map. | U `models/user.go:50`; `controllers/api_controller.go:444` | `TestEmailVerifiedAt/register_ignores_submitted_proof`, `update_ignores_submitted_proof` (U) | closed |
|
||||
| T-19.5-42 | non-mailbox paths treated as proof (`/register` auto and admin modes, `RegisterUser`, admin create, admin Activate action, change-password, social sign-in) | None of these paths writes the column. | U `controllers.Register`, `classes.RegisterUser`, `usersAdminController.FormBeforeCreate`, `classes.ActivateUsers`, `controllers.ChangePassword`; S `classes/registration.go` (account creation) | `TestEmailVerifiedAt/register_auto_no_proof`, `register_admin_mode_no_proof`, `change_password_no_proof`, `TestAdminEmailVerifiedAt/admin_create_no_proof`, `admin_activate_action_no_proof` (U); `TestOwnerVerifiedEmail/admin_activation_not_proof`, `unverified_provider_never_links_proven_account`, `TestPreAccountTakeover` (a social-created account has no proof) (S) | closed |
|
||||
| T-19.5-43 | user API payloads and admin JSON (contract drift in a core plugin) | `json:"-"`; `apiArray`, the admin `fields.yaml` and `columns.yaml`, routes, config and lang unchanged since v0.1.4. | U `models/user.go:50` | the payload-key assertion in `TestEmailVerifiedAt` (no `email_verified_at` in any answer), `TestAdminEmailVerifiedAt/admin_json_hides_proof` (U); gate `--userplugin` additive check (F) | closed |
|
||||
| T-19.5-44 | migration `202610210001_add_users_email_verified_at` (data rewrite, broken upgrade) | `ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ NULL`, no row written, no-op rollback. The gate accepts exactly this one added file under `updates/` and refuses a migration that writes or drops data. | U `updates/202610210001_add_users_email_verified_at.go:20-22`; F `scripts/check-phase19.5.sh` `USER_ADDED_MIGRATIONS`, `refused_migration_changes`, `migration_writes_rows` (7170c5b) | `TestEmailVerifiedAtMigration` (`column_shape`, `no_backfill`, `existing_column_kept`, `rollback_keeps_column`, `history_once`), `TestCreateOrganisationsMigration` (U); `--self-test` migration cases (allowed add, modified base, other add, delete, rename, planted row update, comment-only word) (F) | closed |
|
||||
| T-19.5-45 | scripts/check-phase19.5.sh (false green for the plan 05 tests) | The new Go tests are required by name (`DOCKER_USER_UPDATES_TESTS` and the root, sociallogin lists); `detect_vitest` takes `REQUIRE_FILES` and `run_bm` requires `src/views/OAuthCallbackView.test.ts` next to `src/lib/oauth.test.ts`. | F `scripts/check-phase19.5.sh` (7170c5b, 30e0cd9, 7e43873) | `--self-test` (`missing-view-file` exits 5, the migration-check cases) (F) | closed |
|
||||
| T-19.5-46 | release and pointer bumps of plan 05 (Task 7) | Only after the blocking-human Task 6 reply; the user pointer moves before sociallogin's; each application's gate stage after every bump; fetch and `origin/master..master` check before every push; never forced or deployed; fonoteka.go and summercms.go not pushed. | 19.5-05 Tasks 6-7 | Task 7's acceptance criteria | release control: awaiting the Task 6 reply |
|
||||
| T-19.5-SC | module installs | goth v1.82.0 approved in plan 01 Task 4; no module installed in plan 04 (S `go.mod` and `go.sum` unchanged since adfcfbf); goth imported only by `classes/providers_goth.go`; no gorilla/sessions or gopkg.in/yaml.v3 in `go list -deps .`. | S `go.mod`; `classes/providers_goth.go:13-18` | gate `--sociallogin` (deps and importer checks) (F) | closed |
|
||||
|
||||
## New finding
|
||||
|
||||
| Threat | Category | Component | Severity | Description | Status |
|
||||
|--------|----------|-----------|----------|-------------|--------|
|
||||
| T-19.5-38 | Elevation of privilege | account creation from an unverified provider email (`HandleCallback` with `action=register`, `CompletePending`) | high | An account is created **activated** with an email the provider did not verify (Facebook, Microsoft, a Google answer without the flag). An attacker who controls such a provider account with a victim's address (any Entra tenant can set `mail`; nOAuth) creates the account before the victim does; when the victim later signs in with a verifying provider (Google, GitHub), the verified match linked that identity onto the attacker's account, and the attacker kept access through the original identity (pre-account takeover, classic-federated merge). PHP's SocialAuth behaves the same (D-10). Mitigation (user decision at the Task 6 checkpoint, option b, a deliberate divergence from PHP): the callback and the pending completion auto-link a verified email only onto an account whose owner proved the address (S `classes/flow.go` `ownerVerifiedEmail`, used in `HandleCallback` and `CompletePending`); an account without proof goes pending `unverified_match` and its completion answers 422 `email_exists_link_account`. The proof is `users.email_verified_at` (19.5-05, D-27), written only by the mail activation code and a completed reset-code password reset; d7ee28d had used a self-set password, which an auto-mode `/register` also sets (CR-01, T-19.5-39). After the owner proves the address, verified providers link again. Test: `TestPreAccountTakeover` (S; mutation-checked: an always-true `ownerVerifiedEmail` fails it), `TestOwnerVerifiedEmail` (S). | fixed in d7ee28d; the password-registration variant fixed in 19.5-05 (T-19.5-39) |
|
||||
|
||||
## Open findings
|
||||
|
||||
| Finding | Severity | Description | Status |
|
||||
|---------|----------|-------------|--------|
|
||||
| 19.5-REVIEW CR-02 | high (review) | An account pre-created from an unverified provider email (sociallogin creates it activated, S `classes/registration.go`) keeps the attacker's identity after the real owner proves the address with a reset-code password reset: the reset stamps `email_verified_at` and sets a password but leaves `golem15_user_oauth_identities` alone, so the attacker keeps signing in through the pre-planted Microsoft or Facebook identity. | accepted by the user 2026-10-21 (wording fixed in sm-sociallogin-plugin 79cebb9; 19.5-SECURITY.md AR-19.5-01) |
|
||||
|
||||
## Log calls
|
||||
|
||||
Every non-test `slog` call of S and of this phase's U changes, with what it logs. None logs a token, secret, code, ticket, cookie value, request URL or provider response body; the `err` attributes carry configuration, cache or database errors whose texts the review read (pgx errors carry the SQLSTATE message without row values; `exchangeError` drops `RetrieveError` bodies; the provider's own errors are never logged).
|
||||
|
||||
| Call | Level | Attributes | What `err` can hold |
|
||||
|------|-------|-----------|---------------------|
|
||||
| S `controllers/callback.go:30` "config" | Error | err | `LoadConfig`: a malformed `callback_path`, `default_callback` or `http_timeout` (configuration values, no secrets) |
|
||||
| S `controllers/callback.go:52` "credentials" | Error | provider, err | `ResolveCredentials`: only `LoadConfig` errors (the settings overlay never fails the request) |
|
||||
| S `controllers/callback.go:59` "build provider" | Error | provider, err | a registry factory's error (built-ins never fail; an application's own provider controls its text) |
|
||||
| S `controllers/callback.go:69` "provider refused the sign-in" | Warn | provider | nothing else: the provider's error is not logged (`TestSettings/no_secret_in_logs`) |
|
||||
| S `controllers/callback.go:83` "callback" | Error | provider, err | database errors of the matching and linking (pgx/GORM, no values), `IssueToken` and `APIArray` errors |
|
||||
| S `controllers/callback.go:98` "store pending registration" | Error | err | cache or app-key errors (`putSealed`) |
|
||||
| S `controllers/callback.go:120` "store completion code" | Error | err | cache or app-key errors |
|
||||
| S `controllers/complete.go:26` "pull completion code" | Error | err | cache errors, or the fixed "undecodable code payload" |
|
||||
| S `controllers/link_ticket.go:23` "link ticket user" | Error | err | `RequestUser` infrastructure errors (database, blacklist store) |
|
||||
| S `controllers/link_ticket.go:33` "store link ticket" | Error | err | cache or app-key errors |
|
||||
| S `controllers/register_complete.go:39` "read pending registration" | Error | err | cache errors, or the fixed "undecodable code payload" |
|
||||
| S `controllers/register_complete.go:67` "config" | Error | err | `LoadConfig` (as above) |
|
||||
| S `controllers/register_complete.go:105` "complete pending registration" | Error | err | database errors of `CompletePending` (validation and flow errors are answered, not logged) |
|
||||
| S `controllers/redirect.go:37` "config" | Error | err | `LoadConfig` (as above) |
|
||||
| S `controllers/redirect.go:53` "credentials" | Error | provider, err | `LoadConfig` (as above) |
|
||||
| S `controllers/redirect.go:75` "resolve link user" | Error | err | `PullLinkTicket` cache errors or `RequestUser` infrastructure errors |
|
||||
| S `controllers/redirect.go:113` "build provider" | Error | provider, err | a registry factory's error (as above) |
|
||||
| S `controllers/redirect.go:124` "begin" | Error | provider, err | a provider's `Begin` error (goth's `BeginAuth` has none for the built-ins) |
|
||||
| S `controllers/redirect.go:132` "seal state" | Error | provider, err | the fixed size error ("state cookie of N bytes exceeds 3800") or app-key errors |
|
||||
| S `controllers/helpers.go:160` "token cookie config" | Error | err | `bouncer.LoadCookieConfig`: an invalid `auth.cookie.*` value |
|
||||
| S `classes/credentials.go:76` "settings unavailable, using the configuration" | Debug | err | the settings query's database error |
|
||||
| U `controllers/registration.go:121` "stamp last_seen on login" | Warn | user_id, err | the `last_seen` update's database error |
|
||||
|
||||
## Checks the plan names
|
||||
|
||||
- State compare is constant time and refuses an empty state: S `controllers/callback.go:41-42`; `TestStateCookie/callback/empty_state`, `unequal_state`.
|
||||
- The cookie key is domain-separated from lagoon's: S `classes/statecookie.go:35`, `:180-197`; `TestStateCookie/key_domain_separated`.
|
||||
- The callback target is rebuilt, never echoed: S `classes/callback.go:74-81`; the callback reads the target sealed at the start (`controllers/callback.go:36-39`); `TestNormalizeFrontendCallback/userinfo_dropped`, `query_and_fragment_dropped`, `encoded_path_rebuilt`.
|
||||
- Every login path checks activation and ban: identity, verified match and pending completion through `loginWithIdentity`, link through `linkIdentity` (`classes/flow.go:138`, `:270`); `TestInactiveAndBanned`.
|
||||
- Microsoft and Facebook can never set `EmailVerified`: `classes/providers_goth.go:154`, `:251`; `TestEmailVerified`.
|
||||
- Register-complete decides before it pulls: every 422 path returns before `consume`, which runs last inside the final transaction (`classes/flow.go:183-188`, `classes/registration.go:140-145`); `TestRegisterComplete` (consent, beta, `email_exists_link_account` and validation 422s keep the code).
|
||||
- Link tickets are single use: `classes/codes.go:141-148`; `TestLinkTicket/links_once`.
|
||||
- No JWT, provider token, secret, code or `RetrieveError` body reaches a log line: section "Log calls"; `TestSettings/no_secret_in_logs`, `TestGothProviders/expired_code_without_body`.
|
||||
- The unlink lock is taken before the DELETE: U `controllers/oauth_identities.go:102`; `TestOAuthIdentitiesDestroyRace` (mutation-checked).
|
||||
- goth has a timeout on every provider: `classes/credentials.go:32`, `classes/providers_goth.go:116-121`; `TestGothProviders/timeout`.
|
||||
- The proof follows the address: an email change through `/update` or the admin Users form clears `email_verified_at` in the same write, a case-only change keeps it (U `controllers/api_controller.go:485`, `controllers/users_admin_controller.go:217`); `TestEmailVerifiedAt/update_email_change_clears`, `TestAdminEmailVerifiedAt/admin_email_change_clears`, `TestOwnerVerifiedEmail/email_change_drops_proof`.
|
||||
|
||||
## Accepted residuals (low)
|
||||
|
||||
- Without `app.url`, the redirect URI sent to the provider is completed from the request's `Host` and `X-Forwarded-Proto` (`controllers/helpers.go:50-67`); a client can only change its own flow, and the provider refuses an unregistered redirect URI. Applications set `app.url`.
|
||||
- A registry factory or `Begin` error of an application-provided provider is logged as its author wrote it; the built-in wrappers never put a secret in an error.
|
||||
- `oauth-register-complete` ignores a `parental_authority` sent as false, as PHP's completion did (the callback path refuses it); the value is recorded in the register event's payload.
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
phase: "19.5"
|
||||
slug: "social-login-in-sm-user-plugin"
|
||||
status: verified
|
||||
# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate)
|
||||
threats_open: 0
|
||||
asvs_level: 1
|
||||
created: "2026-10-21"
|
||||
---
|
||||
|
||||
# Phase 19.5 — Security
|
||||
|
||||
> Per-phase security contract: threat register, accepted risks, and audit trail. The per-threat evidence (implementation lines, the test that fails without each mitigation, mutation checks, log sweep) is in `19.5-SECURITY-REVIEW.md`; this file is the register and sign-off.
|
||||
|
||||
---
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description | Data Crossing |
|
||||
|----------|-------------|---------------|
|
||||
| Browser → `/oauth/{provider}`, `/oauth/{provider}/callback` | Untrusted query (state, code, callback, return_to, action, link_ticket) and the sealed state cookie | OAuth state, provider authorization code |
|
||||
| Server → OAuth providers (Google, GitHub, Microsoft, Facebook) | goth wrappers over HTTPS; provider profile and email-verified claims are untrusted input | Provider tokens, profile, email |
|
||||
| SPA → `/_user/api/v1/oauth-*` | One-time completion, pending-registration and link-ticket codes | JWT, user payload, consent |
|
||||
| sociallogin → golem15.user | Account matching, creation, linking under row locks; `users.email_verified_at` proof | User records, identities (encrypted tokens) |
|
||||
| Admin → settings | Client ids and encrypted, write-only secrets | Client secrets |
|
||||
|
||||
---
|
||||
|
||||
## Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|
||||
|-----------|----------|-----------|----------|-------------|------------|--------|
|
||||
| T-19.5-01 | Spoofing | cooler.Cache.Pull (memory.go, rediscache.go) | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestPullConcurrent` (64 pullers, 20 rounds, memory and miniredis), `TestPullExpired`, `TestPullPrefixIsolation`, `TestPullDocker` (redis:7-alpine) (F) | closed (mutation-checked) |
|
||||
| T-19.5-02 | Denial of service | OAuthIdentitiesDestroy | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestOAuthIdentitiesDestroyRace` (two transactions in both orders, the handler waiting for the lock, concurrent handlers), `TestOAuthIdentitiesDestroyConcurrent | closed (mutation-checked) |
|
||||
| T-19.5-03 | Elevation of privilege | OAuthIdentitiesDestroy rule relaxation (D-13) | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestOAuthIdentitiesDestroyRule` (table incl. another user's identity 404, EN and PL 409 bodies) (U) | closed |
|
||||
| T-19.5-04 | Tampering | sm-user-plugin core contract | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: gate `--userplugin`: no exported declaration removed and no file under `updates/` changed since 4c5d3b8; `--fonoteka` green (F) | closed |
|
||||
| T-19.5-05 | Spoofing | controllers.FinishLogin restoring a soft-deleted account | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestFinishLogin/trashed_user_restored_with_one_mail` (U); `TestSoftDeleted/unverified_match_pending_and_no_second_row` (an unverified match never restores) (S) | closed |
|
||||
| T-19.5-06 | Information disclosure | logs of the new code | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: section "Log calls" | closed: accepted: a single fixed-shape log line with no token, secret or code; nothing to pin beyond the review |
|
||||
| T-19.5-07 | Spoofing | Callback (login CSRF, injected code) | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestStateCookie/callback` (missing cookie, empty and unequal state, replay after clear, another provider's callback, cleared on success, provider error, failed | closed |
|
||||
| T-19.5-08 | Tampering | SealState and OpenState | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestStateCookie/refused` (16 cases), `age_window`, `seal_limits`, `key_domain_separated` (new) (S) | closed |
|
||||
| T-19.5-09 | Tampering | NormalizeFrontendCallback, NormalizeReturnTo | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestNormalizeFrontendCallback` (44 subtests), `TestNormalizeReturnTo` (22 subtests) (S); `TestLinkSmoke/bad_callback_falls_back` (S) | closed |
|
||||
| T-19.5-10 | Elevation of privilege | HandleCallback email matching (account takeover through an unverified or attacker-set email) | critical | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestEmailVerified`, `TestNOAuth` (Microsoft mail of an existing user in four tenants: pending, no identity, completion 422), `TestMatching`, `TestPHPSocialAuth | closed (the creation paths: T-19.5-38 and T-19.5-39, closed) |
|
||||
| T-19.5-11 | Elevation of privilege | RegisterComplete (pending registration hijack) | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestRegisterComplete/email_exists_link_account_keeps_code`, `TestPHPPendingRegistrationTakeover` (incl. a payload sealed without the flag) (S) | closed |
|
||||
| T-19.5-12 | Spoofing | PullCompletion, PullPending, PullLinkTicket (code replay or race) | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestCodes` (incl. moved values, 32 concurrent pulls), `TestOAuthComplete`, `TestRegisterComplete/concurrent_submit_one_winner` (S) | closed: fixed in commit 21f3fe5 (with its test) |
|
||||
| T-19.5-13 | Elevation of privilege | beta gate (D-12) | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestBetaGate` (S) | closed |
|
||||
| T-19.5-14 | Spoofing | action=link and LinkTicket (linking a provider to the wrong account) | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestLink` (bearer, both cookies, blacklisted token, `tokens_valid_after`, trashed at start and at callback, token in query ignored), `TestLinkTicket`, `TestPHP | closed |
|
||||
| T-19.5-15 | Information disclosure | URLs and logs | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestLinkTicket/no_jwt_in_locations`, `TestSettings/no_secret_in_logs` (a provider whose errors carry the secret and a token), `TestGothProviders/expired_code_w | closed |
|
||||
| T-19.5-16 | Information disclosure | client secrets and provider tokens at rest | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestSettings/admin_json_hides_secrets` (stored, env and empty states; a saved secret is ciphertext), `TestIdentityTokens/encrypted_tokens`, `TestCodes/completi | closed: fixed in commit c99f5f9 (the write path; confidentiality held) |
|
||||
| T-19.5-17 | Denial of service | provider HTTP calls and /oauth routes | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestGothProviders/timeout` (all four wrappers against a stalled transport, the configured timeout), `TestRouteMiddleware` (route table, 21st request 429) (S) | closed |
|
||||
| T-19.5-18 | Tampering | route shadowing with sm-oauthserver-plugin | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestRegistry/reserved_names`, `TestRoutes/unknown_and_reserved_404` (S); `TestSocialLoginCoexistsWithOAuthServer` (D) | closed |
|
||||
| T-19.5-19 | Elevation of privilege | login of inactive or banned users | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestInactiveAndBanned` (identity, verified match, link, pending completion), `TestSoftDeleted` (S) | closed |
|
||||
| T-19.5-20 | Denial of service | account creation flood | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestRegistrationGates` (S) | closed |
|
||||
| T-19.5-21 | Repudiation | consent records | low | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestIdentityTokens/register_event_payload`, `TestRegisterComplete/form_encoded` (S) | closed |
|
||||
| T-19.5-22 | Spoofing | stale consent | low | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestConsent/expired_after_600s` (forged 601 s and 590 s cookies) (S) | closed |
|
||||
| T-19.5-23 | Tampering | remote operations (Task 6) | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: release control | closed: accepted: a process control, recorded in 19.5-02-SUMMARY |
|
||||
| T-19.5-24 | Tampering | jz.bm static SPA fallback | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestStaticReservedPaths` (B, gate `--bm`); `pwa.test.ts` (B) | closed |
|
||||
| T-19.5-25 | Information disclosure | committed configuration and env templates | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: grep in this review: no non-empty `CLIENT_SECRET=` or `client_secret:` in D or B config and templates | closed: accepted: verified by grep; no runtime behaviour to test |
|
||||
| T-19.5-26 | Information disclosure | OAuthCallbackView (code in history and logs) | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestOAuthComplete/once_with_shape` (second use 410) (S); `oauth.test.ts` (B); `src/views/OAuthCallbackView.test.ts` (B, 5fd0cc3: the real view mounted with vue | closed (the view half by `OAuthCallbackView.test.ts`, plan 19.5-05) |
|
||||
| T-19.5-27 | Spoofing | Microsoft sign-in on bm (nOAuth, tenant misconfiguration) | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestNOAuth` (four tenants) (S); `TestSocialLoginMounted` (B) | closed (the creation paths: T-19.5-38 and T-19.5-39, closed) |
|
||||
| T-19.5-28 | Information disclosure | link ticket in the bm profile flow | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestLinkTicket` (S); `oauth.test.ts` (B) | closed |
|
||||
| T-19.5-29 | Tampering | pushes of shared repositories | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: release control | closed: accepted: a process control, recorded in 19.5-03-SUMMARY |
|
||||
| T-19.5-30 | Denial of service | fonoteka regression from the D-13 relaxation | low | mitigate | see 19.5-SECURITY-REVIEW.md; tests: gate `--fonoteka` (F); `TestOAuthIdentitiesDestroyRule` (U) | closed (the unrelated `TestPhase09SecurityRoutes` failure is outside this phase, deferred-items.md) |
|
||||
| T-19.5-31 | Repudiation | scripts/check-phase19.5.sh (false green) | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `scripts/check-phase19.5.sh --self-test` (F) | closed |
|
||||
| T-19.5-32 | Tampering | race tests (D-13, D-08) | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: the mutation checks above (F, U) | closed |
|
||||
| T-19.5-33 | Elevation of privilege | security review completeness | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: the plan's acceptance count | closed |
|
||||
| T-19.5-34 | Information disclosure | test fixtures and logs | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestGothProviders/fake_transport_refuses_unknown_hosts`, `TestSettings/no_secret_in_logs` (S) | closed |
|
||||
| T-19.5-35 | Spoofing | PHP map honesty | low | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestPHPMethodMap` (mutation-checked) (S) | closed |
|
||||
| T-19.5-36 | Elevation of privilege | post-release fixes (Tasks 5-7) | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: Task 7's acceptance criteria | closed: release control: Task 6 reply "release"; v0.1.1 and the pointer bumps checked by Task 7's acceptance criteria (19.5-04-SUMMARY) |
|
||||
| T-19.5-37 | Tampering | remote operations in Task 7 | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: Task 7's acceptance criteria | closed: release control: recorded with Task 7's acceptance criteria |
|
||||
| T-19.5-39 | Elevation of privilege | sociallogin `ownerVerifiedEmail` (password-registration pre-account takeover, 19.5-REVIEW CR-01) | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestOwnerVerifiedEmail/register_auto_then_google_pending`, `register_action_pending`, `legacy_account_pending`, `mail_activated_by_code_links`, `reset_code_pas | closed (RED: the self-set-password rule fails `register_auto_then_google_pending`) |
|
||||
| T-19.5-40 | Spoofing | sm-user-plugin `Update` and `FormBeforeUpdate` (proof carried over an email change) | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestEmailVerifiedAt/update_email_change_clears`, `update_same_email_keeps`, `TestAdminEmailVerifiedAt/admin_email_change_clears`, `admin_save_keeps_proof` (U); | closed (RED: the controllers before a99cb46 fail exactly the two `*_email_change_clears` cases) |
|
||||
| T-19.5-41 | Tampering | /register and /update request bodies (mass assignment of the proof) | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestEmailVerifiedAt/register_ignores_submitted_proof`, `update_ignores_submitted_proof` (U) | closed |
|
||||
| T-19.5-42 | Elevation of privilege | admin create and Activate action, `/register` auto and admin modes, social sign-in (non-mailbox paths treated as proof) | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestEmailVerifiedAt/register_auto_no_proof`, `register_admin_mode_no_proof`, `change_password_no_proof`, `TestAdminEmailVerifiedAt/admin_create_no_proof`, `adm | closed |
|
||||
| T-19.5-43 | Information disclosure | user API payloads and admin JSON (contract drift in a core plugin) | low | mitigate | see 19.5-SECURITY-REVIEW.md; tests: the payload-key assertion in `TestEmailVerifiedAt` (no `email_verified_at` in any answer), `TestAdminEmailVerifiedAt/admin_json_hides_proof` (U); gate `--userpl | closed |
|
||||
| T-19.5-44 | Tampering | migration `202610210001_add_users_email_verified_at` (data rewrite or a broken upgrade) | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `TestEmailVerifiedAtMigration` (`column_shape`, `no_backfill`, `existing_column_kept`, `rollback_keeps_column`, `history_once`), `TestCreateOrganisationsMigrati | closed |
|
||||
| T-19.5-45 | Repudiation | scripts/check-phase19.5.sh (false green for the new tests) | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: `--self-test` (`missing-view-file` exits 5, the migration-check cases) (F) | closed |
|
||||
| T-19.5-46 | Tampering | release and pointer bumps (Task 7) | medium | mitigate | see 19.5-SECURITY-REVIEW.md; tests: Task 7's acceptance criteria | closed: no remote operation ran (user answered hold); control carried by the release todo (g15office sqnwn1zi) |
|
||||
| T-19.5-SC | Tampering | Go module installs | high | mitigate | see 19.5-SECURITY-REVIEW.md; tests: gate `--sociallogin` (deps and importer checks) (F) | closed |
|
||||
|
||||
*Status: open · closed · open — below high threshold (non-blocking)*
|
||||
*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open*
|
||||
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
|
||||
|
||||
---
|
||||
|
||||
## Accepted Risks Log
|
||||
|
||||
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|
||||
|---------|------------|-----------|-------------|------|
|
||||
| AR-19.5-01 | 19.5-REVIEW CR-02 | An account sociallogin created from an unverified provider email (Facebook, Microsoft) keeps that identity after the real owner proves the address with a reset-code password reset. Behaviour accepted; the sociallogin README (79cebb9) states that proving the address removes no identity already linked and that the owner removes it from the identities list. | user | 2026-10-21 |
|
||||
| AR-19.5-02 | T-19.5-39 (release) | The email-proof fix is committed but not released (user answered hold at 19.5-05 Task 6); deployed sm-demo-app and sm-bm-app keep the password-registration takeover path until `.planning/todos/pending/email-proof-release.md` (g15office sqnwn1zi) ships with the 19.8 release. | user | 2026-10-21 |
|
||||
| AR-19.5-03 | low residuals | Redirect URI from Host/X-Forwarded-Proto without `app.url`; provider factory errors logged as written; `parental_authority=false` ignored at completion (PHP parity). See the review's "Accepted residuals (low)". | review (low, below block threshold) | 2026-10-21 |
|
||||
|
||||
---
|
||||
|
||||
## Security Audit Trail
|
||||
|
||||
| Audit Date | Threats Total | Closed | Open | Run By |
|
||||
|------------|---------------|--------|------|--------|
|
||||
| 2026-10-21 | 46 | 46 | 0 | 19.5-04/05 security review (msd-executor) + /msd-secure-phase (orchestrator, ASVS L1 register check) |
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [x] All threats have a disposition (mitigate / accept / transfer)
|
||||
- [x] Accepted risks documented in Accepted Risks Log
|
||||
- [x] `threats_open: 0` confirmed
|
||||
- [x] `status: verified` set in frontmatter
|
||||
|
||||
**Approval:** verified 2026-10-21
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
status: complete
|
||||
phase: 19.5-social-login-in-sm-user-plugin
|
||||
source: [19.5-VERIFICATION.md]
|
||||
started: 2026-10-21T00:24:54.347Z
|
||||
updated: 2026-10-21T13:40:00.000Z
|
||||
---
|
||||
|
||||
## Current Test
|
||||
|
||||
[testing complete]
|
||||
|
||||
## Tests
|
||||
|
||||
### 1. Decide on the residual pre-account-takeover path left open by the T-19.5-38 fix. ownerVerifiedEmail treats has_self_set_password=true as proof the owner controls the address, but POST /register with golem15.user activation.activate_mode=auto (the default) creates an activated account with has_self_set_password=true and no email check. An attacker who registers victim@example.com with a password before the victim's first Google/GitHub sign-in gets the victim's verified identity auto-linked onto the attacker's account, and keeps password access.
|
||||
expected: Either accept it explicitly (it is exactly PHP's D-10 behaviour, and the variant T-19.5-38 closed was the social-created one), or schedule a follow-up (for example: auto-link only when the account was activated by mail or set its password through the reset code, or require activate_mode=user for apps mounting sociallogin). Also correct 19.5-SECURITY-REVIEW.md / README wording that a self-set password means the owner 'proved the address'.
|
||||
result: pass
|
||||
reported: "(a) Tighten auto-link" (Column in sm-user-plugin; chosen 2026-10-21 during /msd-plan-phase 19.5 --gaps)
|
||||
retested_by: 19.5-05
|
||||
|
||||
### 2. Walk the bm callback view in a browser (see behavior_unverified_items).
|
||||
expected: As in behavior_unverified_items.
|
||||
result: pass
|
||||
reported: "yes" (2026-10-21: record as passed on the automated view test)
|
||||
evidence: verified by the automated view test sm-bm-app frontend/src/views/OAuthCallbackView.test.ts (5/5, required by name in scripts/check-phase19.5.sh); no manual browser walk was done
|
||||
retested_by: 19.5-05
|
||||
|
||||
### 3. Review the judgment-tier prohibition verdicts in the 'Prohibitions' section of this report (non-authoritative LLM-judge verdicts).
|
||||
expected: Confirm or dispute each verdict; all are recorded as no violation found.
|
||||
result: pass
|
||||
reported: "prohibitions ok"
|
||||
|
||||
### 4. Decide on 19.5-REVIEW CR-02: an account pre-created by sociallogin from an unverified provider email (created activated, S classes/registration.go) keeps the attacker's Microsoft or Facebook identity after the real owner proves the address with a reset-code password reset.
|
||||
expected: Either accept it (PHP parity on account creation) and correct sm-sociallogin-plugin README.md:50, which offers 'proves the address with a password reset' as the recovery; or schedule the follow-up the review proposes (create the account inactive unless p.EmailVerified, and/or drop unverified identities when the reset stamps email_verified_at).
|
||||
result: pass
|
||||
reported: "fix README wording quickly" (behaviour accepted; sm-sociallogin-plugin 79cebb9 says a reset does not unlink identities linked before the proof and points to the identities list)
|
||||
|
||||
## Summary
|
||||
|
||||
total: 4
|
||||
passed: 4
|
||||
issues: 0
|
||||
pending: 0
|
||||
skipped: 0
|
||||
blocked: 0
|
||||
|
||||
## Gaps
|
||||
@@ -0,0 +1,47 @@
|
||||
# Phase 19.5: User Setup Required
|
||||
|
||||
**Generated:** 2026-10-21
|
||||
**Phase:** 19.5-social-login-in-sm-user-plugin
|
||||
**Status:** Incomplete
|
||||
|
||||
These items are needed only for live use of social sign-in. The tests need no credentials. Claude automated everything else; these need access to external dashboards and the servers.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Status | Variable | Source | Add to |
|
||||
|--------|----------|--------|--------|
|
||||
| [ ] | `MICROSOFT_CLIENT_ID` | Entra admin center, App registrations, the bm app, Overview (Application (client) ID) | `.env` of bm on rome |
|
||||
| [ ] | `MICROSOFT_CLIENT_SECRET` | Entra admin center, App registrations, the bm app, Certificates & secrets (the value, not the ID) | `.env` of bm on rome |
|
||||
| [ ] | `MICROSOFT_TENANT` | `common`, `organizations` or the university's directory (tenant) ID | `.env` of bm on rome |
|
||||
| [ ] | `SUMMER_GOLEM15__SOCIALLOGIN__PROVIDERS__MICROSOFT__REDIRECT` | `https://bm.jakubzych.com/oauth/microsoft/callback` (template value in `sm-bm-app/deploy/env.example`) | `.env` of bm on rome |
|
||||
| [ ] | `SUMMER_GOLEM15__SOCIALLOGIN__PROVIDERS__GOOGLE__CLIENT_ID` (or `GOOGLE_CLIENT_ID`) | Google Cloud console, APIs & Services, Credentials (OAuth client, web) | `/srv/summercms-demo/.env` |
|
||||
| [ ] | `SUMMER_GOLEM15__SOCIALLOGIN__PROVIDERS__GOOGLE__CLIENT_SECRET` (or `GOOGLE_CLIENT_SECRET`) | same OAuth client | `/srv/summercms-demo/.env` |
|
||||
|
||||
## Dashboard Configuration
|
||||
|
||||
- [ ] **Entra redirect URI and permissions (bm)**
|
||||
- Location: Entra admin center, App registrations, the bm app, Authentication and API permissions
|
||||
- Set to: Web redirect URI `https://bm.jakubzych.com/oauth/microsoft/callback`; delegated Microsoft Graph permissions `openid`, `profile`, `email`, `offline_access`, `User.Read`
|
||||
- Notes: note the client secret's expiry date; details in `sm-bm-app/DEPLOY.md`, "Microsoft 365 sign-in".
|
||||
- [ ] **Google OAuth client (demo)**
|
||||
- Location: Google Cloud console, APIs & Services, Credentials
|
||||
- Set to: authorised redirect URI `https://demo.summercms.io/oauth/google/callback`
|
||||
- Notes: the demo's nginx does not route `/oauth` to Go yet (`sm-demo-app/scripts/check-deploy.sh` refuses it), so the demo cannot use social sign-in live until that guard and the nginx site change.
|
||||
|
||||
## Access to the private module
|
||||
|
||||
- [ ] **Module downloads of sm-sociallogin-plugin**
|
||||
- The repository is private, so `GOWORK=off` builds (sm-demo-app releases) download it over SSH only. On a machine without the module in its cache, run once: `git config --global url."git@git.golem15.com:".insteadOf "https://git.golem15.com/"`, or make the repository public like the other shared plugins.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
curl -sI https://bm.jakubzych.com/oauth/microsoft | grep -i '^location'
|
||||
```
|
||||
|
||||
Expected results:
|
||||
- The Location points at `login.microsoftonline.com/<tenant>/oauth2/v2.0/authorize`; without credentials it points at `/auth/oauth/callback?error=…`.
|
||||
|
||||
---
|
||||
|
||||
**Once all items complete:** Mark status as "Complete" at top of file.
|
||||
@@ -3,10 +3,11 @@ phase: "19.5"
|
||||
slug: "social-login-in-sm-user-plugin"
|
||||
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
|
||||
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
status: validated
|
||||
nyquist_compliant: true
|
||||
wave_0_complete: true
|
||||
created: "2026-10-19"
|
||||
validated: "2026-10-21"
|
||||
---
|
||||
|
||||
# Phase 19.5 — Validation Strategy
|
||||
@@ -20,10 +21,10 @@ created: "2026-10-19"
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | Go `testing` + `httptest`; testcontainers-go Postgres (skips under `-short`); miniredis and `redis:7-alpine` for cooler; vitest for the bm frontend |
|
||||
| **Config file** | none (Go). New `sm-sociallogin-plugin/testmain_test.go` + `harness_test.go`, copied from `sm-oauthserver-plugin` |
|
||||
| **Config file** | none (Go). `sm-sociallogin-plugin/testmain_test.go` + `harness_test.go` (`bootSocial`, `bootSocialWith`, `freshDB`), `fakeprovider_test.go`, `unit_helpers_test.go` |
|
||||
| **Quick run command** | `go vet ./... && go test -short ./...` in the touched repo |
|
||||
| **Full suite command** | `go vet ./... && go test ./...` in summercms.go, the user plugin checkout, sm-sociallogin-plugin, sm-demo-app, sm-bm-app (+ `npm --prefix frontend run test && npm --prefix frontend run typecheck`) and fonoteka.go |
|
||||
| **Estimated runtime** | ~30 seconds quick; ~5 minutes full (Docker) |
|
||||
| **Full suite command** | `scripts/check-phase19.5.sh --all` (summercms.go, sm-user-plugin through the fonoteka.go workspace, sm-sociallogin-plugin, sm-demo-app, sm-bm-app Go and frontend, fonoteka.go; the docker stage on a Docker host) |
|
||||
| **Estimated runtime** | ~30 seconds quick per repo; ~6 minutes for `--all` with Docker |
|
||||
|
||||
---
|
||||
|
||||
@@ -31,35 +32,71 @@ created: "2026-10-19"
|
||||
|
||||
- **After every task commit:** Run the quick command in the touched repo
|
||||
- **After every plan wave:** Run the full command in every touched repo
|
||||
- **Before `/msd-verify-work`:** Full suite must be green in all six repos
|
||||
- **Before `/msd-verify-work`:** `scripts/check-phase19.5.sh --all` green (docker stage run, not NOT RUN)
|
||||
- **Max feedback latency:** 60 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
Filled in by the planner and executor. Decision → test map from RESEARCH.md § Validation Architecture:
|
||||
One row per task of plans 01-05. Gate stages are modes of `scripts/check-phase19.5.sh`; repositories: F summercms.go, U sm-user-plugin, S sm-sociallogin-plugin, D sm-demo-app, B sm-bm-app, A fonoteka.go.
|
||||
|
||||
| Item | Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|------|----------|-----------|-------------------|-------------|--------|
|
||||
| D-08 | `Pull` returns and removes once; expired → not found; N concurrent pullers → one winner (memory, miniredis, Redis) | unit + docker | `go test ./modules/cooler -run 'TestPull'` | ❌ W0 | ⬜ pending |
|
||||
| D-13 | password user unlinks last identity → 204; password-less last → 409; two concurrent unlinks → one survives | integration | user plugin `go test ./... -run 'TestOAuthIdentitiesDestroy'` | ⚠ update existing | ⬜ pending |
|
||||
| D-15 | no sociallogin → `{"success":true,"providers":[]}`; enabled fakes → `[{name,label}]`; disabled excluded | unit | `-run 'TestOAuthProviders|TestProvidersList'` | ❌ | ⬜ pending |
|
||||
| D-03 | fake provider drives redirect → callback → code → complete end to end | integration | sociallogin `-run TestFlow` | ❌ | ⬜ pending |
|
||||
| D-04 | unknown/reserved name → 404; disabled → `?error=`; registry rejects reserved names | unit | `-run 'TestRoutes|TestRegistry'` | ❌ | ⬜ pending |
|
||||
| D-07 | missing/tampered/expired/mismatched state → `invalid_state`; cookie cleared; attributes | unit | `-run TestStateCookie` | ❌ | ⬜ pending |
|
||||
| D-06/D-17 | callback and `return_to` normalisation tables | unit | `-run TestNormalize` | ❌ | ⬜ pending |
|
||||
| D-09 | complete 422/410/200/second-use 410; cookie mode sets token cookie | integration | `-run TestOAuthComplete` | ❌ | ⬜ pending |
|
||||
| D-09 | register-complete 422 consent keeps code; 410; `email_exists_link_account`; concurrent submit → one 200 | integration | `-run TestRegisterComplete` | ❌ | ⬜ pending |
|
||||
| D-10 | matching branch table | integration | `-run TestMatching` | ❌ | ⬜ pending |
|
||||
| D-11 | email-verified per provider, nOAuth case | unit + integration | `-run 'TestEmailVerified|TestNOAuth'` | ❌ | ⬜ pending |
|
||||
| D-12 | beta gate off/wrong/pending path | integration | `-run TestBetaGate` | ❌ | ⬜ pending |
|
||||
| D-14 | link via bearer/`token`/`auth_token`; no JWT; owned by another user | integration | `-run TestLink` | ❌ | ⬜ pending |
|
||||
| D-16 | settings override, secret never echoed, enabled iff id+secret, live change | integration | `-run TestSettings` | ❌ | ⬜ pending |
|
||||
| D-19 | provider tokens encrypted with `token_expires_at` | integration | `-run TestIdentityTokens` | ❌ | ⬜ pending |
|
||||
| D-20 | route middleware throttles | unit | `-run TestRouteMiddleware` | ❌ | ⬜ pending |
|
||||
| PHP map | one Go test per PHP social-auth test case | integration | `-run TestPHP` | ❌ | ⬜ pending |
|
||||
| D-21 | demo and bm boot with sociallogin; bm `/oauth` reserved from SPA fallback; fonoteka green at new pin | app tests | per-app full commands | ⚠ update existing | ⬜ pending |
|
||||
| Task | Decision | Test names | Gate stage | Status |
|
||||
|------|----------|------------|------------|--------|
|
||||
| 19.5-01-01 | D-02, D-08 | `TestPullSmoke`, `TestPullConcurrent`, `TestPullExpired`, `TestPullPrefixIsolation` (F); `TestPullDocker` (F); decision note names goth v1.82.0 | `--go`, `--docker`, `--docs` | ✅ green |
|
||||
| 19.5-01-02 | D-15, D-22 | `TestOAuthProvidersSourceSmoke`, `TestOAuthProviders` (U); `TestSocialHooksSmoke`, `TestRegisterUserPasswordLess`, `TestFinishLogin`, `TestRegistrationExports` (U) | `--userplugin`, `--docker` | ✅ green |
|
||||
| 19.5-01-03 | D-13, D-24 | `TestOAuthIdentitiesDestroyRule`, `TestOAuthIdentitiesDestroyRace`, `TestOAuthIdentitiesDestroyConcurrentSmoke`, `TestOAuthIdentitiesDestroyLastIdentityWithPassword`, `TestBackfillHandlesOpensFromConfigWhenUnpublished` (U); additive and migration check | `--docker`, `--userplugin` | ✅ green |
|
||||
| 19.5-01-04 | D-02 (package check) | human checkpoint, reply "approved" (19.5-01-SUMMARY); `go list -deps .` has no gorilla/sessions or gopkg.in/yaml.v3, goth only in `classes/providers_goth.go` (S) | `--sociallogin` | ✅ green |
|
||||
| 19.5-02-01 | D-03, D-07, D-09 | `TestTracerSmoke`, `TestRegistry`, `TestStateCookie`, `TestCodes`, `TestOAuthComplete` (S) | `--sociallogin`, `--docker` | ✅ green |
|
||||
| 19.5-02-02 | D-10, D-11, D-12, D-19, D-23 | `TestFlowSmoke`, `TestMatching`, `TestNOAuth`, `TestSoftDeleted`, `TestInactiveAndBanned`, `TestRegistrationGates`, `TestConsent`, `TestBetaGate`, `TestIdentityTokens`, `TestRegisterComplete`, `TestTokenCookieMode` (S) | `--docker` | ✅ green |
|
||||
| 19.5-02-03 | D-04, D-06, D-14, D-17 | `TestLinkSmoke`, `TestLink`, `TestLinkTicket`, `TestRoutes`, `TestNormalizeFrontendCallback`, `TestNormalizeReturnTo` (S) | `--sociallogin`, `--docker` | ✅ green |
|
||||
| 19.5-02-04 | D-02, D-11, D-16, D-20 | `TestGothProvidersSmoke`, `TestGothProviders`, `TestEmailVerified`, `TestErrorMessages` (S); `TestSettingsOverrideSmoke`, `TestSettings`, `TestRouteMiddleware`, `TestProvidersList` (S) | `--sociallogin`, `--docker` | ✅ green |
|
||||
| 19.5-02-05 | D-21 (go-ahead) | remote operation: human checkpoint, reply "release v0.3.1 (Recommended)" (19.5-02-SUMMARY) | (none: remote operation) | ✅ recorded |
|
||||
| 19.5-02-06 | D-21 (release) | remote operation: `git ls-remote --exit-code --tags origin` for summercms v0.3.1, sm-user-plugin v0.1.4, sm-sociallogin-plugin v0.1.0 (19.5-02-SUMMARY) | (none: remote operation) | ✅ recorded |
|
||||
| 19.5-03-01 | D-21 (demo) | `TestBootAllPlugins`, `TestSocialLoginRedirect`, `TestSocialLoginCoexistsWithOAuthServer` (D) | `--demo` | ✅ green |
|
||||
| 19.5-03-02 | D-04, D-21 (bm backend) | `TestSocialLoginMounted` (B); `TestStaticReservedPaths`, `TestEmbeddedPublicHasIndex` (B `plugins/jz/bm`) | `--bm` | ✅ green |
|
||||
| 19.5-03-03 | D-06, D-23 (bm frontend) | vitest `src/lib/oauth.test.ts` and the frontend suite, `npm run typecheck` (B) | `--bm` | ✅ green |
|
||||
| 19.5-03-04 | D-24 (fonoteka) | fonoteka.go `go vet ./...`, `go test -short ./...`; `summer.yaml` does not mount golem15.sociallogin (A) | `--fonoteka` | ✅ green |
|
||||
| 19.5-04-01 | gate (T-19.5-31) | `scripts/check-phase19.5.sh --self-test` (F) | `--self-test` | ✅ green |
|
||||
| 19.5-04-02 | D-08, D-13, D-15, D-22 | the cooler and sm-user-plugin unit tests of rows 19.5-01-01 to -03 (F, U) | `--go`, `--userplugin`, `--docker` | ✅ green |
|
||||
| 19.5-04-03 | D-03..D-20, D-23 | the 25 plugin unit tests of rows 19.5-02-01 to -04 (S) | `--sociallogin`, `--docker` | ✅ green |
|
||||
| 19.5-04-04 | PHP map | `TestPHPMethodMap` (S); `TestPHPSocialAuthGate`, `TestPHPOAuthLinkCookie`, `TestPHPPendingRegistrationTakeover`, `TestPHPPasswordBootstrap`, `TestPHPProfileOAuthLink` (S); `TestChangePassword/real_password_account_still_requires_current_password` (U) | `--sociallogin`, `--docker` | ✅ green |
|
||||
| 19.5-04-05 | D-11 review, SC-4 | `19.5-SECURITY-REVIEW.md` (every T-19.5 threat to a test or an accepted reason); the whole gate | `--all` | ✅ green |
|
||||
| 19.5-04-06 | D-21 (post-release go-ahead) | remote operation: Task 7's acceptance criteria (no patch tag before the reply; the reply recorded verbatim) | (none: remote operation) | ⬜ awaiting the Task 6 reply |
|
||||
| 19.5-04-07 | D-21 (post-release release) | remote operation: Task 7's acceptance criteria (`merge-base --is-ancestor` per fix and pin, tags on the remotes, `origin/master..master` empty) and `--sociallogin`, `--demo`, `--bm`, `--fonoteka` at the new pins | `--sociallogin`, `--demo`, `--bm`, `--fonoteka` | ⬜ awaiting the Task 6 reply |
|
||||
| 19.5-05-01 | D-26, D-27 (tracer) | `TestOwnerVerifiedEmail/register_auto_then_google_pending`, `mail_activated_by_code_links`, `reset_code_password_links`; `TestPreAccountTakeover` (S); `TestEmailVerifiedAtMigration`, `TestCreateOrganisationsMigration` (U); migration allow-list self-test cases (F) | `--self-test`, `--userplugin`, `--sociallogin`, `--docker` | ✅ green |
|
||||
| 19.5-05-02 | D-26, D-27 (writers, clearing) | `TestEmailVerifiedAt` (12 subtests), `TestAdminEmailVerifiedAt` (6 subtests), `TestEmailVerifiedAtMigration` (5 subtests) (U) | `--userplugin`, `--fonoteka`, `--docker` | ✅ green |
|
||||
| 19.5-05-03 | D-26, D-27 (owner proof table, wording) | `TestOwnerVerifiedEmail` (8 subtests), `TestPreAccountTakeover`, `TestPHPPendingRegistrationTakeover`, `TestPHPMethodMap` (S) | `--sociallogin`, `--docker` | ✅ green |
|
||||
| 19.5-05-04 | D-28 | vitest `src/views/OAuthCallbackView.test.ts` (5 tests), `npm run typecheck`, package.json and lockfile unchanged (B); `--self-test` `missing-view-file` (F) | `--self-test`, `--bm` | ✅ green |
|
||||
| 19.5-05-05 | D-27 wording, gate | `19.5-SECURITY-REVIEW.md` (T-19.5-39..46, CR-02 open), this map; the whole gate with `PHASE195_REQUIRE_DOCKER=1` | `--all` | ✅ green |
|
||||
| 19.5-05-06 | D-21, D-27 (release go-ahead) | remote operation: Task 7's acceptance criteria (no tag on either plugin before the reply; the reply recorded verbatim) | (none: remote operation) | ⬜ awaiting the Task 6 reply |
|
||||
| 19.5-05-07 | D-21, D-27, SC-3 (release) | remote operation: Task 7's acceptance criteria (v0.1.5 and v0.1.2 on the remotes, sociallogin go.mod requires sm-user-plugin v0.1.5, app pins equal the tags, user bump before sociallogin bump, `origin/master..master` empty) and `--sociallogin`, `--demo`, `--bm`, `--fonoteka` at the new pins | `--sociallogin`, `--demo`, `--bm`, `--fonoteka` | ⬜ awaiting the Task 6 reply |
|
||||
|
||||
Decision coverage (from RESEARCH § Validation Architecture):
|
||||
|
||||
| Item | Behavior | Tests (repo) | Gate stage | Status |
|
||||
|------|----------|--------------|------------|--------|
|
||||
| D-08 | `Pull` returns and removes once; expired → not found; 64 concurrent pullers → one winner (memory, miniredis, Redis) | `TestPullConcurrent`, `TestPullExpired`, `TestPullPrefixIsolation`, `TestPullDocker` (F) | `--go`, `--docker` | ✅ green |
|
||||
| D-13 | self-set password unlinks the last identity → 204; password-less last → 409; two concurrent unlinks → one survives | `TestOAuthIdentitiesDestroyRule`, `TestOAuthIdentitiesDestroyRace` (U) | `--docker` | ✅ green |
|
||||
| D-15 | no source → `{"providers":[],"success":true}`; enabled providers in order; disabled excluded | `TestOAuthProviders` (U), `TestProvidersList` (S) | `--userplugin`, `--docker` | ✅ green |
|
||||
| D-03 | fake provider drives redirect → callback → code → complete | `TestTracerSmoke`, `TestRegistry` (S) | `--sociallogin`, `--docker` | ✅ green |
|
||||
| D-04 | unknown/reserved name → 404; disabled → `?error=`; registry refuses reserved names | `TestRoutes`, `TestRegistry` (S) | `--sociallogin`, `--docker` | ✅ green |
|
||||
| D-07 | missing/tampered/expired/mismatched state → `invalid_state`; cookie cleared; attributes; key separation | `TestStateCookie` (S) | `--docker` | ✅ green |
|
||||
| D-06/D-17 | callback and `return_to` normalisation tables | `TestNormalizeFrontendCallback`, `TestNormalizeReturnTo` (S) | `--sociallogin` | ✅ green |
|
||||
| D-09 | complete 422/410/200/second-use 410; cookie mode sets the token cookie | `TestOAuthComplete`, `TestTokenCookieMode` (S) | `--docker` | ✅ green |
|
||||
| D-09 | register-complete 422 consent keeps the code; 410; `email_exists_link_account`; 16 concurrent submits → one 200 | `TestRegisterComplete` (S) | `--docker` | ✅ green |
|
||||
| D-10 | matching branch table, soft-deleted, inactive and banned, no auto-link onto a password-less account (T-19.5-38) | `TestMatching`, `TestSoftDeleted`, `TestInactiveAndBanned`, `TestPreAccountTakeover` (S) | `--docker` | ✅ green |
|
||||
| D-11 | email-verified per provider, nOAuth case | `TestEmailVerified` (S, `--sociallogin`), `TestNOAuth` (S, `--docker`) | `--sociallogin`, `--docker` | ✅ green |
|
||||
| D-12 | beta gate off/wrong/pending path | `TestBetaGate` (S) | `--docker` | ✅ green |
|
||||
| D-14 | link via bearer/`token`/`auth_token`/ticket; no JWT in URLs; owned by another user | `TestLink`, `TestLinkTicket` (S) | `--docker` | ✅ green |
|
||||
| D-16 | settings override, secret never echoed or logged, enabled iff id+secret, live change, legacy env | `TestSettings` (S) | `--docker` | ✅ green |
|
||||
| D-19 | provider tokens encrypted with `token_expires_at`; profile data only | `TestIdentityTokens` (S) | `--docker` | ✅ green |
|
||||
| D-20 | route middleware throttles | `TestRouteMiddleware` (S) | `--docker` | ✅ green |
|
||||
| PHP map | one Go test per PHP social-auth test case (38 methods) | `TestPHPMethodMap` and the five `TestPHP*` ports (S) | `--sociallogin`, `--docker` | ✅ green |
|
||||
| D-21 | demo and bm boot with sociallogin; bm `/oauth` reserved from the SPA fallback; fonoteka green at the new pin | `TestBootAllPlugins`, `TestSocialLoginRedirect`, `TestSocialLoginCoexistsWithOAuthServer` (D); `TestSocialLoginMounted`, `TestStaticReservedPaths` (B); fonoteka short suite (A) | `--demo`, `--bm`, `--fonoteka` | ✅ green |
|
||||
| D-26 | a verified email auto-links only onto an account with recorded proof; `/register` auto and admin modes, admin create and Activate, social sign-in and legacy accounts go pending; mail activation and the reset code link | `TestOwnerVerifiedEmail` (8 subtests), `TestPreAccountTakeover` (S); `TestEmailVerifiedAt`, `TestAdminEmailVerifiedAt` (U) | `--docker` | ✅ green |
|
||||
| D-27 | `users.email_verified_at` additive (nullable column, no backfill, no-op rollback), two writers, cleared on an email change, not fillable, not in any payload; READMEs and the security review describe proof as `email_verified_at` | `TestEmailVerifiedAtMigration`, `TestCreateOrganisationsMigration`, `TestEmailVerifiedAt`, `TestAdminEmailVerifiedAt` (U); `TestPHPMethodMap` (S); migration allow-list and additive check (F) | `--userplugin`, `--sociallogin`, `--docker` | ✅ green |
|
||||
| D-28 | the bm callback view drops `?code=` before the single redeem, never redeems twice, shows `?error=`, replaces to `oauth-register` for a pending registration, explains an expired code; no new npm dependency | vitest `src/views/OAuthCallbackView.test.ts` (B) | `--bm` | ✅ green |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
@@ -67,10 +104,10 @@ Filled in by the planner and executor. Decision → test map from RESEARCH.md §
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `sm-sociallogin-plugin/testmain_test.go`, `harness_test.go` (Postgres harness booting `golem15.user` + `golem15.sociallogin`, memory cooler)
|
||||
- [ ] Fake `Provider` test double + `http.RoundTripper` fake for the goth wrappers
|
||||
- [ ] `modules/cooler` Pull tests (memory, miniredis, docker Redis)
|
||||
- [ ] User plugin unlink race test with two explicit transactions
|
||||
- [x] `sm-sociallogin-plugin/testmain_test.go`, `harness_test.go` (Postgres harness booting `golem15.user` + `golem15.sociallogin`, memory cooler)
|
||||
- [x] Fake `Provider` test double + `http.RoundTripper` fake for the goth wrappers
|
||||
- [x] `modules/cooler` Pull tests (memory, miniredis, docker Redis)
|
||||
- [x] User plugin unlink race test with two explicit transactions
|
||||
|
||||
---
|
||||
|
||||
@@ -80,15 +117,17 @@ Filled in by the planner and executor. Decision → test map from RESEARCH.md §
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Live sign-in with Google, Facebook, GitHub and Microsoft | D-04, D-21 | Needs real provider app registrations and secrets | Configure credentials in sm-demo-app (and Entra in sm-bm-app), sign in through the SPA, confirm the callback lands and the identity is listed |
|
||||
|
||||
The bm callback view's history handling (the code leaves the URL before the single redeem) was a manual check until plan 05; it is now automated by `src/views/OAuthCallbackView.test.ts` (D-28, row 19.5-05-04).
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 60s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
- [x] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [x] Wave 0 covers all MISSING references
|
||||
- [x] No watch-mode flags
|
||||
- [x] Feedback latency < 60s
|
||||
- [x] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
**Approval:** validated 2026-10-21 (plan 19.5-04; plan 19.5-05 rows added the same day); rows 19.5-04-06 and -07 and 19.5-05-06 and -07 are remote operations that follow their plan's Task 6 reply.
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
---
|
||||
phase: 19.5-social-login-in-sm-user-plugin
|
||||
verified: 2026-10-21T12:59:50Z
|
||||
status: passed
|
||||
score: 23/23 must-haves verified
|
||||
covered_files:
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-01-PLAN.md
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-01-SUMMARY.md
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-02-PLAN.md
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-02-SUMMARY.md
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-03-PLAN.md
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-03-SUMMARY.md
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-04-PLAN.md
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-04-SUMMARY.md
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-05-PLAN.md
|
||||
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-05-SUMMARY.md
|
||||
- docs/services/caching.md
|
||||
- modules/cooler/README.md
|
||||
- modules/cooler/cooler.go
|
||||
- modules/cooler/docker_test.go
|
||||
- modules/cooler/example_test.go
|
||||
- modules/cooler/memory.go
|
||||
- modules/cooler/pull_smoke_test.go
|
||||
- modules/cooler/pull_test.go
|
||||
- modules/cooler/rediscache.go
|
||||
- scripts/check-phase19.5.sh
|
||||
covered_digest: "v2:sha256:73370391391a90bf632aabe78b77fb693a74535477d0245c0a2f279c06a698fa"
|
||||
# Files outside this repository are pinned by commit instead of digest:
|
||||
# sm-user-plugin 35cf166 (local, unpushed; released v0.1.4 = c43ba87), sm-sociallogin-plugin c4ebf05 (local, unpushed;
|
||||
# released v0.1.1 = d7ee28d), sm-bm-app 5fd0cc3 (local, unpushed), sm-demo-app b325460 (foreign 19.8 commit on 61a00e9),
|
||||
# fonoteka.go f4d5728 (worktree plugins/golem15/user at 35cf166, unstaged).
|
||||
# RELEASE HOLD (user decision at 19.5-05 Task 6): the D-26/D-27 fix is not in any app pin. sm-demo-app and sm-bm-app
|
||||
# still run sm-user-plugin v0.1.4 + sm-sociallogin-plugin v0.1.1, i.e. the password-registration takeover path stays
|
||||
# open in the deployed apps until the release in .planning/todos/pending/email-proof-release.md (g15office sqnwn1zi).
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
re_verification:
|
||||
previous_status: human_needed
|
||||
previous_score: 15/16
|
||||
gaps_closed:
|
||||
- "Truth 16 (bm OAuthCallbackView: code dropped before the single redeem, pending and error routing): was PRESENT_BEHAVIOR_UNVERIFIED, now VERIFIED by src/views/OAuthCallbackView.test.ts (D-28)"
|
||||
- "Human item 1 (password-registration pre-account takeover): user chose (a) tighten auto-link; implemented as users.email_verified_at (D-26, D-27) and verified below"
|
||||
gaps_remaining: []
|
||||
regressions: []
|
||||
human_verified:
|
||||
- test: "Decide on the residual pre-account-takeover path left open by the T-19.5-38 fix. ownerVerifiedEmail treats has_self_set_password=true as proof the owner controls the address, but POST /register with golem15.user activation.activate_mode=auto (the default) creates an activated account with has_self_set_password=true and no email check. An attacker who registers victim@example.com with a password before the victim's first Google/GitHub sign-in gets the victim's verified identity auto-linked onto the attacker's account, and keeps password access."
|
||||
uat_row: 1
|
||||
recorded_at: "2026-10-21T12:54:14.000Z"
|
||||
human_verification:
|
||||
- test: "Review the judgment-tier prohibition verdicts in the 'Prohibitions' section of this report (non-authoritative LLM-judge verdicts)."
|
||||
expected: "Confirm or dispute each verdict, including the four 19.5-05 prohibitions added in this re-verification; all are recorded as no violation found."
|
||||
why_human: "Judgment-tier prohibitions never pass silently (ADR-550 D4)."
|
||||
- test: "Decide on 19.5-REVIEW CR-02: an account pre-created by sociallogin from an unverified provider email (created activated, S classes/registration.go) keeps the attacker's Microsoft or Facebook identity after the real owner proves the address with a reset-code password reset."
|
||||
expected: "Either accept it (PHP parity on account creation) and correct sm-sociallogin-plugin README.md:50, which offers 'proves the address with a password reset' as the recovery; or schedule the follow-up the review proposes (create the account inactive unless p.EmailVerified, and/or drop unverified identities when the reset stamps email_verified_at)."
|
||||
why_human: "Open high-severity review finding with no user decision; not covered by D-26..D-28. Since D-26 makes the reset the route to auto-linking, the documented recovery hands the owner an account that still carries the attacker's identity. bm runs Microsoft with tenant common."
|
||||
---
|
||||
|
||||
# Phase 19.5: Social login in sm-user-plugin Verification Report
|
||||
|
||||
**Phase Goal:** `sm-user-plugin` signs users in with Google and Facebook through `/oauth/{provider}` and its callback, with a provider registry open to more providers, the SPA one-time-code handoff and the pending-registration branch, matching the PHP user plugin's contract. Per CONTEXT D-01 the code lives in the new `sm-sociallogin-plugin`, with hooks in sm-user-plugin. The 19.5-05 gap closure adds D-26..D-28.
|
||||
**Verified:** 2026-10-21T12:59:50Z
|
||||
**Status:** human_needed
|
||||
**Re-verification:** Yes, after gap-closure plan 19.5-05 (previous: human_needed, 15/16)
|
||||
|
||||
**Release state.** The user answered the 19.5-05 Task 6 checkpoint with **hold**. I confirmed it in the repositories. The unpushed commits are sm-user-plugin `add1f7a`, `a99cb46`, `35cf166`; sm-sociallogin-plugin `3bb5650`, `962190b`, `c4ebf05`; sm-bm-app `5fd0cc3`. Neither `v0.1.5` (user) nor a new sociallogin tag exists on the remotes. The app gitlinks are unchanged: sm-demo-app and sm-bm-app `plugins/golem15/user` = c43ba87 (v0.1.4) and `plugins/golem15/sociallogin` = d7ee28d (v0.1.1); the fonoteka.go gitlink is c43ba87. sm-sociallogin-plugin go.mod still requires sm-user-plugin v0.1.4. I count this as a recorded user decision, and plan 05's must-have has an explicit hold branch for it. It is not a code gap. **But the deployed sm-demo-app and sm-bm-app keep the password-registration pre-account-takeover path (CR-01 / T-19.5-39) until the release in `.planning/todos/pending/email-proof-release.md` (g15office sqnwn1zi) ships.** The demo and bm stages of the gate ran at the old pins. Plan 05 Task 7's check at the new pins has not run.
|
||||
|
||||
**MVP-mode note:** As in the previous report, the ROADMAP goal is not a user story. All five plans recorded the decision not to invent one, so this report runs standard goal-backward verification against SC-1..SC-4, D-01..D-28 and the plan must-haves.
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
Truths 1-15 come from the previous report and got a regression check here: the full gate, plus spot reads of the code paths 19.5-05 touched. Truth 16 was rerun in full. Truths 17-23 are plan 19.5-05's must-haves, and each was verified at all three levels.
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | SC-1: `/oauth/{google,facebook,github,microsoft}` and `/callback` keep the PHP paths with `throttle:20,1` | VERIFIED (regression) | Gate `--sociallogin` and `--docker`: `TestRoutes`, `TestRouteMiddleware`, `TestTracerSmoke`; `--demo` `TestSocialLoginRedirect`; `--bm` `TestSocialLoginMounted`. All PASS in my run. |
|
||||
| 2 | SC-2a: SPA one-time code, `oauth-complete` exchanges once (200 / 422 / 410) | VERIFIED (regression) | `TestTracerSmoke`, `TestOAuthComplete`, `TestCodes` in the 29 Docker sociallogin tests (PASS). |
|
||||
| 3 | SC-2b: pending registration via `?pending_registration=`, `oauth-register-complete` with PHP fields, 422 does not consume the code | VERIFIED (regression) | `TestFlowSmoke`, `TestRegisterComplete`, `TestPHPPendingRegistrationTakeover` (PASS). |
|
||||
| 4 | SC-3a: identities linked on login (identity match, verified owner-proved email match, `action=link`) | VERIFIED | Matching now also requires owner proof (truth 17). `TestMatching`, `TestLink`, `TestLinkTicket` PASS. The harness's `e.user` carries proof, so link-path tests still exercise linking, and `e.unprovenUser` models accounts without it. |
|
||||
| 5 | SC-3b: list and unlink via host-mounted identity routes, PHP unlink rule, race-safe | VERIFIED (regression) | U `controllers/oauth_identities.go` unchanged by 05 (`git diff c7635c5 --stat` does not list it); `TestOAuthIdentitiesDestroyConcurrentSmoke` in the 12 Docker user tests PASS. |
|
||||
| 6 | SC-3c: the consuming applications still build and pass their tests | VERIFIED (at current pins) | `--demo` (build, vet, 3 tests), `--bm` (build, vet, 1+2 Go tests, vitest 216/216, typecheck), `--fonoteka` (vet and short suite with the user plugin worktree at 35cf166) all PASS. The apps at the *new* pins are not tested (release on hold). |
|
||||
| 7 | SC-4: the new code has unit tests in the last plan | VERIFIED | 19.5-04 delivered the phase suite. 19.5-05, the gap closure and now the last plan, adds `TestOwnerVerifiedEmail` (8), `TestEmailVerifiedAt` (12), `TestAdminEmailVerifiedAt` (6), `TestEmailVerifiedAtMigration` (5) and the 5-case view test. All are required by name in the gate and all PASS. |
|
||||
| 8 | D-03 provider registry open, no goth type exported | VERIFIED (regression) | Gate deps check: "goth only in classes/providers_goth.go"; `TestRegistry` PASS. |
|
||||
| 9 | D-01/D-02 separate plugin, goth v1.82.0 without gothic, no gorilla/sessions, framework go.mod untouched by this phase | VERIFIED | Deps check PASS. The framework go.mod's latest commits (e903cc3, 8d4d7ce, 3f38862) belong to phase 19.8 (diary, bonfire), not 19.5. |
|
||||
| 10 | D-07 sealed state cookie | VERIFIED (regression) | `TestStateCookie` PASS. |
|
||||
| 11 | D-08 atomic `cooler.Cache.Pull`; plugin pins a release with it | VERIFIED (regression) | Covered files are unchanged; `TestPullConcurrent`, `TestPullDocker` PASS. |
|
||||
| 12 | D-10/D-11 matching table, Google flag, GitHub `/user/emails`, Facebook and Microsoft never verified (nOAuth); the owner side is now recorded proof (D-26) | VERIFIED | S `classes/flow.go:98` (`HandleCallback`) and `:203` (`CompletePending`) both gate on `profile.EmailVerified && ownerVerifiedEmail(match)`; `ownerVerifiedEmail` at `:266` is `u != nil && u.EmailVerifiedAt != nil`; `HasSelfSetPassword` is no longer read in S non-test code (grep). `TestMatching`, `TestEmailVerified`, `TestNOAuth`, `TestPreAccountTakeover` PASS. |
|
||||
| 13 | D-15/D-22 `oauth-providers` stays in sm-user-plugin; D-22's no-migration rule now relaxed only by D-27 | VERIFIED | Gate additive check: "no exported func, method or type removed and no migration changed beyond the allowed additions in the user plugin since 4c5d3b8 (13 declarations added; 1 allowed migration(s) added, column only)". `TestOAuthProvidersSourceSmoke`, `TestOAuthProviders` PASS. |
|
||||
| 14 | D-16/D-18 credentials and settings overlay | VERIFIED (regression) | `TestSettings`, `TestSettingsOverrideSmoke` PASS. |
|
||||
| 15 | D-21/D-23 sm-demo-app and sm-bm-app mount sociallogin v0.1.1; bm links Microsoft via the link ticket | VERIFIED (released state) | Gitlinks: sociallogin d7ee28d and user c43ba87 in both apps; `TestLinkTicket` PASS. Remotes are no longer in sync: S, U and B each carry the unpushed 19.5-05 commits (hold), and sm-demo-app carries foreign `b325460` (19.8). |
|
||||
| 16 | D-28 / T-19.5-26: the bm callback view removes `?code=` before the single redeem, never redeems twice, shows `?error=`, replaces to `oauth-register` for pending; no new npm dependency | VERIFIED (behavioral test) | `sm-bm-app/frontend/src/views/OAuthCallbackView.test.ts` mounts the real `<script setup>` module (module-level `redeemed` set included) on a `createMemoryHistory` router. It asserts that the route at the moment `completeOAuth` starts has no `code` and fullPath `/auth/oauth/callback`, that `replace` comes before `completeOAuth` in `invocationCallOrder`, and that Back lands on `/`. Remounting with the same code gives no second call; the alert text equals the `?error=`; pending gives `oauth-register` with `{pending, provider, reason, email}` and Back lands on `/`; a 410 shows the expiry message. I ran it: 5/5 PASS, `oauth.test.ts` 10/10. The gate's `--bm` stage requires both files (`REQUIRE_FILES`). The last commit touching `package.json`/`package-lock.json` is `fe967c4`, from before the phase. |
|
||||
| 17 | D-26: `/register` in `activate_mode=auto` then a verified Google sign-in for the same email goes pending `reason=unverified_match`, `provider=google`, links nothing; `oauth-register-complete` answers 422 `email_exists_link_account` and keeps the code | VERIFIED | `owner_verified_test.go:173` drives the real `/register`, asserts the account is activated with `EmailVerifiedAt == nil`, then signs in with an uppercase email, asserts pending with no link, posts the completion (422 + `linkMessage`), and checks `pendingExists` and that the identity has no owner. It also covers `action=register`. `TestOwnerVerifiedEmail` PASS in my Docker run. SUMMARY records RED against the old rule. |
|
||||
| 18 | D-26: a mail-activated (activate / activate-by-code) or reset-code-reset account receives the verified identity and is signed in | VERIFIED | U `classes/codes.go:133-145`: `VerifyActivationCode` sets `email_verified_at` in the same `UpdateColumns` that activates (callers `api_controller.go:369` `/activate`, `:420` `/activate-by-code`). U `controllers/api_controller.go:336-344`: `ResetPassword` sets it in the `Updates` that stores the new password, after `VerifyResetCode` and hashing. A failed reset writes nothing. `TestOwnerVerifiedEmail/mail_activated_by_code_links`, `reset_code_password_links`, and `TestPreAccountTakeover/after_mailbox_proof_links` (real `/forgot-password` + `/reset-password`) PASS. |
|
||||
| 19 | D-26: accounts without proof go pending: `/register` auto/admin, admin create and Activate action, social-created accounts, pre-v0.1.5 accounts; nothing backfills | VERIFIED | `classes.ActivateUsers` (`admin_actions.go:64-90`) writes no `email_verified_at`; the S sources write nothing (grep); the migration has no UPDATE. Tests: `TestOwnerVerifiedEmail/legacy_account_pending`, `register_action_pending`, `admin_activation_not_proof`, `unverified_provider_never_links_proven_account`; `TestEmailVerifiedAt/register_auto_no_proof`, `register_admin_mode_no_proof`, `register_ignores_submitted_proof`, `change_password_no_proof`; `TestAdminEmailVerifiedAt/admin_create_no_proof`, `admin_activate_action_no_proof`; `TestEmailVerifiedAtMigration/no_backfill`. All PASS. |
|
||||
| 20 | D-27: `users.email_verified_at` nullable TIMESTAMPTZ via migration `202610210001_add_users_email_verified_at` (ADD COLUMN IF NOT EXISTS, no rows, no-op rollback); two writers; cleared on email change through `/update` and the admin form (case-only change keeps it); not fillable, in no payload, not in admin YAML; no route, payload or config key change | VERIFIED | Migration file: exactly that SQL, registered once (`history_once`). `models/user.go:50`: `gorm:"column:email_verified_at" json:"-"`, not in `Fillable()` (`models/user.go:88`). No YAML under the plugin mentions it (grep). `api_controller.go:483-496` and `users_admin_controller.go:229-236` clear on `!strings.EqualFold`. Tests: `update_email_change_clears`, `update_same_email_keeps`, `update_ignores_submitted_proof`, `admin_email_change_clears`, `admin_save_keeps_proof`, `admin_json_hides_proof`, `column_shape`, `rollback_keeps_column`, `existing_column_kept` all PASS. The gate's migration allow-list accepts only this file (`refused_migration_changes`, self-tested). |
|
||||
| 21 | D-27 wording: the READMEs, the PHP-map row and 19.5-SECURITY-REVIEW describe proof as `email_verified_at`; none says a self-set password proves the address | VERIFIED | U `README.md:34` and S `README.md:49-55,145` ("a self-set password is not proof"); `PHP_METHOD_MAP.md:26`; SECURITY-REVIEW T-19.5-38/-39/-42. The only remaining "self-set password" mentions concern the unlink rule (D-13), which is correct. |
|
||||
| 22 | The gate fails closed on the new tests and the migration rules; `PHASE195_REQUIRE_DOCKER=1 --all` passes | VERIFIED | `scripts/check-phase19.5.sh`: `USER_ADDED_MIGRATIONS` (`:82`), `refused_migration_changes` (`:378`), `migration_writes_rows` (`:398`), and self-tests for both (`:487-512`); `DOCKER_USER_UPDATES_TESTS` (`:145`); `REQUIRE_FILES` with `missing-view-file` (`:434-439`, `:638`). My run of `PHASE195_REQUIRE_DOCKER=1 TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 bash scripts/check-phase19.5.sh --all` exited 0. All nine stages PASS, and every new test appears by name in a passed run. |
|
||||
| 23 | SC-3/D-21 release, hold branch: nothing pushed or tagged, a todo with a g15office task tracks the release | VERIFIED (hold branch) | `ls-remote --tags`: user tags end at v0.1.4, sociallogin tags at v0.1.1. No pin moved, and nothing is staged in fonoteka.go. The todo `email-proof-release.md` exists, carries `g15office: sqnwn1zi` (e819f7b) and the full D-21 step list. **The release-branch half of this truth (tags, pin bumps, gate at new pins) has not happened by user decision.** |
|
||||
|
||||
**Score:** 23/23 truths verified (0 present, behavior-unverified)
|
||||
|
||||
### Required Artifacts (19.5-05)
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| U `updates/202610210001_add_users_email_verified_at.go` | `ADD COLUMN IF NOT EXISTS email_verified_at` | VERIFIED | 33 lines, registered via `init()`, wired into `All()` (`TestCreateOrganisationsMigration` expects it last) |
|
||||
| U `models/user.go` | `gorm:"column:email_verified_at" json:"-"` | VERIFIED | Read by S `flow.go:267` |
|
||||
| U `email_verified_test.go` | `TestEmailVerifiedAt`, `TestAdminEmailVerifiedAt` | VERIFIED | 377 lines; run by name in `--docker` |
|
||||
| U `updates/email_verified_at_test.go` | `TestEmailVerifiedAtMigration` | VERIFIED | 5 subtests; run by name |
|
||||
| S `classes/flow.go` | `EmailVerifiedAt != nil` | VERIFIED | Used in both match sites |
|
||||
| S `owner_verified_test.go` | `register_auto_then_google_pending` | VERIFIED | 8 subtests driving real user-plugin routes |
|
||||
| B `frontend/src/views/OAuthCallbackView.test.ts` | `completeOAuth` | VERIFIED | 5 cases; required by the gate |
|
||||
| `scripts/check-phase19.5.sh` | `USER_ADDED_MIGRATIONS` | VERIFIED | Self-test plus `--all` exit 0 |
|
||||
|
||||
### Key Link Verification (19.5-05)
|
||||
|
||||
| From | To | Via | Status |
|
||||
|------|----|-----|--------|
|
||||
| U `classes/codes.go` `VerifyActivationCode` | `users.email_verified_at` | same `UpdateColumns` as `is_activated` | WIRED |
|
||||
| U `controllers/api_controller.go` `ResetPassword` / `Update` | `users.email_verified_at` | stamped with the new password; cleared on a changed email | WIRED |
|
||||
| U `users_admin_controller.go` `FormBeforeUpdate` | `users.email_verified_at` | `user.EmailVerifiedAt = nil`, persisted by cabana Save (`admin_email_change_clears` PASS, RED recorded) | WIRED |
|
||||
| S `classes/flow.go` | U `models.User.EmailVerifiedAt` | `ownerVerifiedEmail` in `HandleCallback` and `CompletePending`; `userByEmail` loads the full row (no `Select`) | WIRED |
|
||||
| `scripts/check-phase19.5.sh` | B `OAuthCallbackView.test.ts` | `REQUIRE_FILES` in the bm stage | WIRED |
|
||||
| S `go.mod` | sm-user-plugin v0.1.5 | Task 7 require | NOT YET (hold); S builds against the workspace checkout through go.work, while go.mod still says v0.1.4 |
|
||||
|
||||
### Data-Flow Trace (Level 4)
|
||||
|
||||
| Artifact | Data | Source | Real data | Status |
|
||||
|----------|------|--------|-----------|--------|
|
||||
| auto-link decision | `match.EmailVerifiedAt` | `userByEmail` → Postgres `users.email_verified_at`, written only by the mail activation code and the reset-password write | yes | FLOWING |
|
||||
| bm callback view | `code`, `error`, `pending_registration` | `route.query` → `parseOAuthCallback` → `auth.completeOAuth` (stubbed in the view test; real store → `userApi.oauthComplete`, as in the previous report) | yes | FLOWING |
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| Docker loopback usable | `TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 go test -short -run '^TestPermissionSetScan$' ./plugins/golem15/user/classes` (fonoteka.go) | ok 4.66 s (the VPN did not block it) | PASS |
|
||||
| Whole phase gate, Docker required | `PHASE195_REQUIRE_DOCKER=1 TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 bash scripts/check-phase19.5.sh --all` | exit 0; self-test, go (4), userplugin (2 + additive/migration check), sociallogin (9), demo (3), bm (1+2, vitest 216/216, typecheck), fonoteka, docs, docker (cooler 1, user 12, user updates 2, sociallogin 29) | PASS |
|
||||
| bm view test directly | `npx vitest run src/views/OAuthCallbackView.test.ts src/lib/oauth.test.ts` | 15/15 | PASS |
|
||||
| Hold intact | `git ls-remote --tags origin` in U and S; `git ls-tree HEAD` app gitlinks | no v0.1.5 or new sociallogin tag; pins c43ba87 / d7ee28d | PASS |
|
||||
|
||||
### Probe Execution
|
||||
|
||||
| Probe | Command | Result | Status |
|
||||
|-------|---------|--------|--------|
|
||||
| (none declared; no `scripts/*/tests/probe-*.sh`) | n/a | the phase gate above plays this role | SKIPPED |
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
No REQUIREMENTS.md IDs. Plan 05 declares D-26, D-27, D-28 and SC-3. The previous report covered SC-1..SC-4 and D-01..D-25, and every one is still satisfied (regression above).
|
||||
|
||||
| Requirement | Source Plan | Status | Evidence |
|
||||
|-------------|-------------|--------|----------|
|
||||
| D-26 | 05 | SATISFIED (in local commits) | Truths 12, 17-19 |
|
||||
| D-27 | 05 | SATISFIED (in local commits; release pending per hold) | Truths 13, 20, 21; the "ships as v0.1.5 / v0.1.2 / pointer bumps" part is the held release (truth 23) |
|
||||
| D-28 | 05 | SATISFIED | Truth 16 |
|
||||
| SC-3 | 01-05 | SATISFIED at current pins | Truths 4-6; the D-26 fix is not in the apps until the release |
|
||||
|
||||
No orphaned requirements.
|
||||
|
||||
### Prohibitions (judgment tier; non-authoritative LLM-judge verdicts, unverified-prohibition: human review recommended)
|
||||
|
||||
The verdicts for plans 01-04 are unchanged from the previous report (all "no violation found"), with one update: the framework go.mod has new commits, but they are 19.8's (e903cc3, 8d4d7ce, 3f38862), not 19.5's. Plan 05 adds four:
|
||||
|
||||
| Plan | Prohibition | Judge verdict | Evidence |
|
||||
|------|-------------|---------------|----------|
|
||||
| 05 | sm-user-plugin stays additive: no route, payload key, config key, existing migration file or ID changes; the new migration adds a nullable column and writes no rows | no violation found | `git diff c7635c5 -- updates/` lists only the new migration and its test, plus the history list in `organisations_test.go`. The gate's additive and migration checks pass. `json:"-"`; no YAML hit. |
|
||||
| 05 | Proof is recorded only by the mailed activation code and the completed reset-code reset | no violation found | grep: the only writers are `codes.go:139` and `api_controller.go:339`; `ActivateUsers`, `Register`, `ChangePassword` and S write nothing. The non-writer tests pass. |
|
||||
| 05 | No new npm dependency in sm-bm-app/frontend and no new Go module anywhere | no violation found | `package.json`/lockfile last touched in `fe967c4` (before the phase). U and S go.mod are unchanged since their release tags (S `git diff d7ee28d --stat` does not list go.mod). |
|
||||
| 05 | Nothing pushed or tagged before the Task 6 reply; no force-push or deploy; fonoteka.go and summercms.go never pushed; only `plugins/golem15/user` staged in fonoteka.go | no violation found | Remote tags unchanged. The unpushed ranges hold the 05 commits. fonoteka.go has nothing staged; the user gitlink is modified but unstaged, and feedback/golem belong to other sessions. |
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| (all 05-changed files in U, S, B and the gate) | | No TBD, FIXME, XXX, TODO, HACK or placeholder markers | | |
|
||||
| S `README.md` | 50 | Offers "proves the address with a password reset" as the recovery for an unproven match. Per CR-02, a reset does not remove a pre-planted unverified identity on a social-created account | Warning | Recovery advice overstates the result for that case; see Human Verification 2 |
|
||||
| `19.5-VALIDATION.md` | 72-73 | Rows 19.5-05-06/-07 still read "awaiting the Task 6 reply"; the reply was "hold" | Info | Stale status text; the todo tracks the release |
|
||||
| `sm-bm-app/sm-bm-app` | | 70 MB untracked binary left in the bm worktree by the gate's `go build ./...` (single main package writes its binary); not gitignored | Info | Risk of an accidental commit; the gate could build with `-o /dev/null` or into a temp dir |
|
||||
| B `OAuthCallbackView.test.ts` | | Compiles the view's template at test time and uses memory history with stubbed stores | Info | The `<script setup>` ordering invariant is tested on the real module. Real `window.history` and the build-compiled template are not, which is acceptable for D-28 as written |
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
UAT row 1 (the takeover decision) passed on 2026-10-21 with decision (a), and its implementation is verified above (truths 17-21), so it is recorded under `human_verified`. A behavioral test now covers UAT row 2 (bm callback view in a browser): truth 16 is VERIFIED, so this report does not ask for it again. The orchestrator may mark row 2 as superseded by `OAuthCallbackView.test.ts`.
|
||||
|
||||
#### 1. Prohibition verdicts (UAT row 3)
|
||||
|
||||
**Test:** Review the judgment-tier prohibition verdicts in the 'Prohibitions' section of this report (non-authoritative LLM-judge verdicts).
|
||||
**Expected:** Confirm or dispute each verdict, including the four new 19.5-05 rows.
|
||||
**Why human:** Judgment-tier prohibitions need human sign-off.
|
||||
|
||||
#### 2. CR-02 decision (new)
|
||||
|
||||
**Test:** Read 19.5-REVIEW CR-02 and 19.5-SECURITY-REVIEW "Open findings".
|
||||
**Expected:** Either accept the behaviour and correct the S README recovery wording, or schedule the follow-up (inactive account unless `p.EmailVerified`, or removal of unverified identities when a reset stamps the proof).
|
||||
**Why human:** This is a security and product decision on an open high-severity finding outside D-26..D-28.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No blocking gaps in code. D-26, D-27 and D-28 are implemented as specified. The proof column has exactly two writers and two clearers; the sociallogin matcher reads it at both match sites; nothing backfills it; the bm callback view's ordering invariant is pinned by a passing test. I re-ran the full fail-closed gate with Docker required, and it exited 0 with every new test passing by name.
|
||||
|
||||
What is not done is deliberate: the **release is on hold** by user decision. sm-user-plugin v0.1.5, sm-sociallogin-plugin v0.1.2 and the app pin bumps wait in `email-proof-release.md` (g15office sqnwn1zi) for the 19.8 session. Until then, sm-demo-app and sm-bm-app run v0.1.4/v0.1.1, where an auto-mode `/register` account squatting an address still receives the owner's verified Google or GitHub identity.
|
||||
|
||||
Two items remain for a human: sign-off on the judgment-tier prohibitions, and a decision on CR-02.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-10-21T12:59:50Z_
|
||||
_Verifier: Claude (msd-verifier)_
|
||||
@@ -0,0 +1,14 @@
|
||||
# Deferred items (Phase 19.5)
|
||||
|
||||
Out-of-scope discoveries logged by executors; not fixed in this phase.
|
||||
|
||||
## From plan 19.5-03
|
||||
|
||||
- **fonoteka.go `TestPhase09SecurityRoutes` (plugins/golem15/fonoteka/admin_phase09_security_test.go:52) fails in the Docker-backed run.** The admin route inventory does not list five `summercms.cabana` routes the framework working tree now mounts: `GET`/`PUT /plytadmin/api/v1/account/preferences`, `GET`/`POST /plytadmin/api/v1/{vendor}/{plugin}/{controller}/reorder` and `GET /plytadmin/api/v1/{vendor}/{plugin}/{controller}/singleton`. Every reported route belongs to cabana, none to golem15.user, so the sm-user-plugin bump does not cause it; the inventory needs the framework's newer admin routes (the pattern of fonoteka's earlier "list the post-Phase 9 cabana admin routes" commit).
|
||||
|
||||
## From plan 19.5-05
|
||||
|
||||
- **sm-user-plugin `updates/postgres_test.go` `testShort` scans `os.Args` for `-test.short`.** `go test` passes `-test.short=true`, so `go test -short ./plugins/golem15/user/...` still starts the Postgres container for the `updates` package (the pitfall CLAUDE.md names). Pre-existing, not caused by plan 05; the fix is `flag.Parse()` and `testing.Short()` in its `TestMain`.
|
||||
- **Docker host: published container ports do not answer on `[::1]`.** On this host testcontainers resolves `localhost`, pgx dials `[::1]` first and times out, and every Docker-backed suite fails with `postgres unavailable`. Plan 05 ran the Docker stages with `TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1`; Task 5's `--all` needs the same until the host's IPv6 loopback forwarding is fixed.
|
||||
- **sm-user-plugin `classes.TestPermissionSetScan` starts Postgres under `-short`.** It calls `throttleDB` without a `testing.Short()` skip, so the gate's `--userplugin` short run needs a working Docker host; when loopback to the container is blocked the package hangs until `go test`'s 10-minute timeout. Pre-existing, not caused by plan 05.
|
||||
- **NordLayer VPN drops loopback traffic to container ports.** With the VPN connected, testcontainers Postgres times out on `127.0.0.1` too. The user's workaround is `sudo nft insert rule inet nordlayer input iifname "lo" accept` and `sudo nft insert rule inet nordlayer output oifname "lo" accept`; both rules disappear when the VPN reconnects.
|
||||
@@ -0,0 +1,420 @@
|
||||
---
|
||||
phase: 19.6-two-factor-auth-and-device-flow-in-sm-user-plugin
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- .planning/notes/2026-10-21-mfa-otp-webauthn.md
|
||||
- .planning/research/STACK.md
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/login_gate.go
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/events.go
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/two_factor_admin.go
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/device_auth.go
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/login_gates.go
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/registration.go
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/users/config_form.yaml
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/devices.go
|
||||
- ../fonoteka.go/plugins/golem15/user/models/device_auth_session.go
|
||||
- ../fonoteka.go/plugins/golem15/user/updates/202610210002_create_device_auth_sessions.go
|
||||
- ../fonoteka.go/plugins/golem15/user/admin_permissions.go
|
||||
- ../fonoteka.go/plugins/golem15/user/plugin.go
|
||||
- ../fonoteka.go/plugins/golem15/user/routes.go
|
||||
- ../fonoteka.go/plugins/golem15/user/config/config.yaml
|
||||
- ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml
|
||||
- ../fonoteka.go/plugins/golem15/user/lang/pl/lang.yaml
|
||||
- ../fonoteka.go/plugins/golem15/user/login_gate_smoke_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/password_changed_smoke_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/admin_registration_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/reset_two_factor_smoke_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/device_flow_smoke_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/device_revoke_smoke_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/README.md
|
||||
autonomous: false
|
||||
requirements: [SC-2, SC-3, SC-4, D-02, D-03, D-04, D-06, D-07, D-11, D-12a, D-12b, D-12c, D-15, D-16, D-17, D-18]
|
||||
estimate:
|
||||
tokens: 290000
|
||||
raw_tokens: 290000
|
||||
tasks: 5
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-04: a decision note .planning/notes/2026-10-21-mfa-otp-webauthn.md names github.com/pquerna/otp v1.5.0, github.com/go-webauthn/webauthn v0.18.2 and github.com/boombuler/barcode v1.1.0 (QR, with the plugin's own SVG writer), the measured +2,269,184-byte binary delta, the nine newly linked modules, the go-viper/mapstructure v2.5.0 bump, the clean govulncheck run, the D-08 JSON-shape deviation and the blocking human package check; .planning/research/STACK.md lists the three pins; the note is committed before any go.mod requires one of them, and no go.mod in sm-user-plugin, the framework or fonoteka.go ever requires them."
|
||||
- "D-02: sm-user-plugin publishes a *classes.LoginGates registry in Register; Login consults it after CheckAndRecordLogin(..., true) and the bcrypt rehash and before the soft-delete restore and the mint; a gate result answers its own status and JSON body (HTTP 200 two_factor_required for a challenge) and no token is minted; a gate error answers the opaque 500; an empty registry leaves Login's responses byte-identical to v0.1.5; JWT refresh never consults the gate."
|
||||
- "D-18 (A10): with a gate that challenges, a soft-deleted account stays soft-deleted and no reactivate mail is sent; the restore and the reactivate mail run only after the gate passed."
|
||||
- "D-02, D-05 support: every successful password login fires classes.LoginEvent (Source password) exactly once, after the token is minted; a challenged login fires none; controllers.FireLoginEvent and controllers.CheckLoginGates are exported for sm-mfa-plugin and sm-sociallogin-plugin."
|
||||
- "D-07: classes.PasswordChangedEvent fires inside the password-write transaction from ResetPassword (Source reset), ChangePassword (Source change) and the admin password set (Source admin), and a listener error rolls the write back; the bcrypt rehash on login and RegisterUser fire none."
|
||||
- "D-15: the user plugin registers the backend permission golem15.users.reset_two_factor (no default role) and a reset_two_factor record action on the Users screen with Danger true; Applies is false when no classes.TwoFactorAdmin service is published, so applications without sm-mfa-plugin never show it; Run calls the published service's Reset inside cabana's transaction."
|
||||
- "D-03, D-11, D-16: POST /_user/api/v1/devices/initiate, GET devices/status/{token}, POST devices/authorize, GET devices and DELETE devices/{id} (id [0-9]+) live in the user-api group; the session row is golem15_user_device_auth_sessions with PHP's column names, a 64-character token stored as its SHA-256 hex (D-06), an XXXX-XXXX short code over 23456789ABCDEFGHJKMNPQRSTUVWXYZ, a 5-minute TTL, PHP's status set pending|confirmed|used|expired|revoked and an added jwt_jti column; auth_url comes from golem15.user.device_auth.auth_url (default {frontend_url}/device/scan/{token})."
|
||||
- "D-12a: the first poll after confirmation answers {success:true, data:{status:'confirmed', user_id, message:'Device authorized - complete login', token, user}} through one guarded confirmed-to-used UPDATE; of concurrent polls exactly one receives a token and the others answer status used; used and revoked sessions never turn into expired."
|
||||
- "D-12b: DELETE devices/{id} marks the row revoked and adds its jwt_jti to the JWT blacklist; Refresh moves the row's jwt_jti from the old token's jti to the new one, so a device that refreshed before the revoke is signed out too."
|
||||
- "D-12c: POST devices/authorize needs only a valid, non-blacklisted bearer JWT (PHP 401 body {\"error\":\"Unauthorized\"} otherwise) and binds a pending session with no user to the caller through one guarded pending-to-confirmed UPDATE; there is no second-factor re-check."
|
||||
- "D-17 support: every change to sm-user-plugin is additive (no route, payload, table, column, migration ID or config key of v0.1.5 changes); fonoteka.go, sm-sociallogin-plugin and sm-oauthserver-plugin compile against the checkout; nothing is pushed or tagged in this plan."
|
||||
artifacts:
|
||||
- path: ".planning/notes/2026-10-21-mfa-otp-webauthn.md"
|
||||
provides: "D-04 decision note naming the three pinned libraries and the human package check"
|
||||
contains: "go-webauthn/webauthn v0.18.2"
|
||||
- path: "../fonoteka.go/plugins/golem15/user/classes/login_gate.go"
|
||||
provides: "LoginAttempt, LoginGateResult, LoginGate, LoginGates"
|
||||
contains: "type LoginGate interface"
|
||||
- path: "../fonoteka.go/plugins/golem15/user/controllers/devices.go"
|
||||
provides: "device authorization handlers"
|
||||
contains: "func DeviceStatus"
|
||||
- path: "../fonoteka.go/plugins/golem15/user/updates/202610210002_create_device_auth_sessions.go"
|
||||
provides: "golem15_user_device_auth_sessions table"
|
||||
contains: "golem15_user_device_auth_sessions"
|
||||
- path: "../fonoteka.go/plugins/golem15/user/login_gate_smoke_test.go"
|
||||
provides: "TestLoginGatesSmoke and TestLoginGateSmoke"
|
||||
contains: "TestLoginGateSmoke"
|
||||
key_links:
|
||||
- from: "../fonoteka.go/plugins/golem15/user/controllers/api_controller.go"
|
||||
to: "../fonoteka.go/plugins/golem15/user/classes/login_gate.go"
|
||||
via: "Login calls CheckLoginGates between CheckAndRecordLogin and the restore and mint"
|
||||
pattern: "CheckLoginGates\\("
|
||||
- from: "../fonoteka.go/plugins/golem15/user/controllers/api_controller.go"
|
||||
to: "golem15_user_device_auth_sessions.jwt_jti"
|
||||
via: "Refresh hands the device row's jti over to the refreshed token"
|
||||
pattern: "HandOverDeviceJTI"
|
||||
- from: "../fonoteka.go/plugins/golem15/user/controllers/devices.go"
|
||||
to: "bouncer.BlacklistStore"
|
||||
via: "DeviceDestroy blacklists the row's jwt_jti"
|
||||
pattern: "Lookup\\[bouncer.BlacklistStore\\]"
|
||||
- from: "../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go"
|
||||
to: "../fonoteka.go/plugins/golem15/user/classes/two_factor_admin.go"
|
||||
via: "reset_two_factor looks up the published TwoFactorAdmin per request"
|
||||
pattern: "Lookup\\[classes.TwoFactorAdmin\\]"
|
||||
prohibitions:
|
||||
- statement: "No JWT, device token, short code, challenge token or password is ever written to a log line or an error string by the code this plan adds."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "sm-user-plugin changes are additive only: no existing route, response body, table, column, migration ID or config key changes; the only new migration is the device-session table (core plugin contract, 19.4 D-05)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No go.mod in sm-user-plugin, summercms.go or fonoteka.go requires pquerna/otp, go-webauthn or boombuler/barcode, and none is installed anywhere before the Task 5 reply."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed or tagged and no remote repository is created in this plan (plan 03 asks for the go-ahead)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Framework code and plugin READMEs name no consuming application (neutral wording: the application)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
<!-- planner-discipline-allow: pquerna/otp -->
|
||||
<!-- planner-discipline-allow: go-webauthn -->
|
||||
<!-- planner-discipline-allow: boombuler -->
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; MVP mode would take a story from `/msd mvp-phase 19.6`, which was not run, so no story is invented here, as in 19.5): `sm-user-plugin` has the PHP user plugin's second factors and device login: TOTP, WebAuthn, email codes, recovery codes and trusted devices, a challenge step during login, and QR/code device authorization. CONTEXT D-01 moves the second factors into a new shared plugin, `sm-mfa-plugin`; D-03 keeps the device flow in `sm-user-plugin`.
|
||||
|
||||
This plan's slice (plan 1 of 4, wave 1, "Foundations"): the decision note for the three libraries lands first. Then sm-user-plugin gets its additive seams: the login-gate registry wired into Login (the tracer), the login and password-changed events, the admin reset slot, and the whole device authorization flow with the JTI hand-over on refresh. The plan ends with the blocking human package check, so plan 02 can install the libraries.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip). No SPEC.md exists and ROADMAP lists "Requirements: TBD". `must_haves` derive from the ROADMAP success criteria SC-1..SC-4 and CONTEXT decisions D-01..D-20 (with D-12a, D-12b, D-12c); `requirements` lists those IDs for traceability.
|
||||
|
||||
<assumption_delta_decision>
|
||||
- Primary noun for authentication: before this phase a sign-in was "password (or social identity) → token". After it, it is "first factor (password, social identity) → optional second factors (TOTP, email code, recovery code, WebAuthn) → token", plus a passkey as an alternative first factor that satisfies the second factor by itself (D-14).
|
||||
- Decision: **add-alongside**. Password login stays the primary path and its contract is unchanged when no gate is registered (D-02). Second factors are layered on through a registry of gates that sm-mfa-plugin fills; applications without sm-mfa-plugin behave exactly as before (D-01).
|
||||
- Accepted debt: token mints that do not consult the gate, each by decision: JWT refresh (D-02, PHP), POST /register and activate-by-code (a new or not-yet-activated account cannot have 2FA yet), sociallogin's action=link (the caller is already signed in), the device poll (D-12c: the authorizing session already passed 2FA), the challenge completion and the passwordless passkey login (they are the second factor), and fonoteka's onboarding IssueToken (fonoteka does not mount mfa, D-17). Social sign-in consults the gate only when the mfa setting turns it on (D-09, default skip).
|
||||
- Invariant test (plan 04): `TestEveryLoginPathIsGated` in sm-mfa-plugin drives every first-factor path (password; social with the toggle on) for a user with TOTP enabled and asserts a challenge and no token, and asserts that passwordless issues a token only for a user-verified discoverable passkey and the device poll only after an authorize by a bearer JWT.
|
||||
</assumption_delta_decision>
|
||||
|
||||
## Phase source audit (all four plans)
|
||||
|
||||
| SOURCE | ID | Item | Plan | Status |
|
||||
|---|---|---|---|---|
|
||||
| GOAL | — | Second factors, challenge step and device login of the PHP user plugin | 01-03 | COVERED |
|
||||
| ROADMAP | SC-1 | TOTP, WebAuthn, email codes, recovery codes, trusted devices; WebAuthn decision note first | 01 (note), 02, 03 | COVERED |
|
||||
| ROADMAP | SC-2 | Challenge step behind the 2fa-verify bucket | 01 (gate), 02 | COVERED |
|
||||
| ROADMAP | SC-3 | Device authorization on DeviceAuthSession | 01 | COVERED |
|
||||
| ROADMAP | SC-4 | Six models and migrations, security review, unit tests last | 01, 02, 03, 04 | COVERED |
|
||||
| CONTEXT | D-01 | Separate sm-mfa-plugin requiring the user plugin | 02, 03 (repo creation) | COVERED |
|
||||
| CONTEXT | D-02 | Login-gate registry | 01 | COVERED |
|
||||
| CONTEXT | D-03 | Device flow in sm-user-plugin | 01 | COVERED |
|
||||
| CONTEXT | D-04 | Libraries, decision note, QR pick | 01, 02, 03 | COVERED |
|
||||
| CONTEXT | D-05 | PHP paths, fields, shapes, bucket, TTLs, events | 02, 03 | COVERED |
|
||||
| CONTEXT | D-06 | SHA-256 tokens at rest, encrypted TOTP secret, slow hash | 01, 02 | COVERED |
|
||||
| CONTEXT | D-07 | Revocation on password change and last-method disable | 01, 02 | COVERED |
|
||||
| CONTEXT | D-08 | Standard WebAuthn JSON shapes | 03 | COVERED |
|
||||
| CONTEXT | D-09 | Social login through the challenge behind a setting | 02 (setting), 03 | COVERED |
|
||||
| CONTEXT | D-10 | Trusted devices | 02 | COVERED |
|
||||
| CONTEXT | D-11, D-12a, D-12b, D-12c | Device flow contract, one-time JWT, revoke, authorize | 01 | COVERED |
|
||||
| CONTEXT | D-12 | Settings screen | 02 | COVERED |
|
||||
| CONTEXT | D-13 | No child exemption | 02 | COVERED |
|
||||
| CONTEXT | D-14 | Passwordless passkey routes and security review | 03, 04 | COVERED |
|
||||
| CONTEXT | D-15 | Admin Reset 2FA | 01 (slot), 02 (service) | COVERED |
|
||||
| CONTEXT | D-16 | Six tables with PHP names | 01, 02, 03 | COVERED |
|
||||
| CONTEXT | D-17 | Adoption in demo and bm, fonoteka guard test | 03 | COVERED |
|
||||
| CONTEXT | D-18 | A6, A8, A9, A10 hardening | 01 (A10), 02 | COVERED |
|
||||
| CONTEXT | D-19 | First recovery set returned | 02 | COVERED |
|
||||
| CONTEXT | D-20 | Demo 2FA UI in vue-demo-app | 03 | COVERED |
|
||||
| RESEARCH | Q7 | Refresh lineage gap (JTI hand-over) | 01 | COVERED |
|
||||
| RESEARCH | Pitfalls 1-21 | Throttle reset, replay, races, RP ID, used-not-expired, name bounds, table growth, Where binding, postcard ownership, settings types, boot without DB | 01-03 | COVERED |
|
||||
| RESEARCH | Validation Architecture | check-phase19.6.sh, PHP map, decision-to-test map | 04 | COVERED |
|
||||
|
||||
Deferred and not planned (CONTEXT Deferred Ideas): per-group exemptions, notification mails on 2FA changes, the Winter import of the 2FA and device tables.
|
||||
|
||||
## Decisions taken in this plan (recorded assumptions and Claude's discretion)
|
||||
|
||||
- Gate identifiers (CONTEXT discretion): `classes.LoginAttempt{User *models.User; Source string; Fields map[string]any; Request *http.Request}`; source constants `classes.LoginSourcePassword` ("password"), `LoginSourceSocial` ("social"), `LoginSourcePasskey` ("passkey"), `LoginSourceTwoFactor` ("two_factor"), `LoginSourceDevice` ("device"); `classes.LoginGateResult{Status int; Body map[string]any}`; `classes.LoginGate` with one method `CheckLogin(ctx context.Context, a LoginAttempt) (*LoginGateResult, error)`; `classes.LoginGates` with `NewLoginGates()`, `Add(LoginGate)` and `Check(ctx, LoginAttempt) (*LoginGateResult, error)`; `controllers.CheckLoginGates(ctx, app, a)`. A result carries a status so sm-mfa-plugin can refuse a new challenge with a 401 when the D-18 A6 failure cap is reached; a challenge is Status 200.
|
||||
- Login order after this plan (D-02 refined by D-18 A10): lookup, throttle check, password check, `CheckAndRecordLogin(..., true)`, bcrypt rehash (before the gate, so users who are always challenged still get rehashed; a rehash is not a password change, Pitfall 3), gate, soft-delete restore with the reactivate mail, `TouchLastSeen`, mint, `LoginEvent`, response.
|
||||
- Event names: `classes.LoginEventName = "golem15.user.login"` (PHP's name, D-05 "then the user login event"); `classes.PasswordChangedEventName = "user.PasswordChanged"` (Go-originated, the style of `user.Created`); sources `classes.PasswordChangeSourceReset`, `PasswordChangeSourceChange`, `PasswordChangeSourceAdmin`.
|
||||
- Device routes authenticate inside the handlers, as PHP's controller does (its routes carry no jwt.auth), with PHP's 401 body `{"error":"Unauthorized"}`; the bearer token is parsed in the handler anyway because `is_current` compares its jti with the row's `jwt_jti` (PHP compared a CMS session id).
|
||||
- The device table keeps PHP's column name `token` for the SHA-256 hex (64 characters fit PHP's VARCHAR(64), RESEARCH Q4), with Go field `TokenHash`; `session_id` is kept (always NULL) so a Winter import stays a near-straight copy (D-16).
|
||||
- `device_name` in `GET devices` uses PHP's model accessor (stored name, else the user-agent parse: iPhone, iPad, Android Device, Windows PC, Mac, Linux PC, Unknown Device). PHP's list read a nonexistent `device_name_attribute` and always answered null; the key and type stay, the value is filled (recorded deviation).
|
||||
- The device hand-over fires `LoginEvent` with Source device (PHP's CMS component logged the device in through Auth).
|
||||
- Re-authorizing a session that the same caller already confirmed answers PHP's success body (PHP's `confirm()` returned false silently); any other non-pending session answers 400 `Invalid or expired code`.
|
||||
- New config keys (additive): `golem15.user.frontend_url` (default empty, meaning app.url), `golem15.user.device_auth.auth_url` (default `{frontend_url}/device/scan/{token}`), `golem15.user.device_auth.ttl_minutes` (default 5).
|
||||
- Migration ID `202610210002_create_device_auth_sessions` (after the existing `202610210001_add_users_email_verified_at`).
|
||||
- Reversibility: D-02 and D-15 add exported seams other plugins implement (costly, flagged); the device table becomes one-way only when released, which plan 03's go-ahead gates. D-01, D-08 and D-14 were chosen by the user in CONTEXT, so no checkpoint re-asks them.
|
||||
- Concurrency with Phase 19.8: its plan 03 Task 8 may sweep this same sm-user-plugin checkout and only runs when the checkout is clean and in sync with origin. This plan therefore starts only when the checkout is clean and `origin/master..master` is empty (Task 1 precondition), and it never pushes.
|
||||
- Context budget (estimate 290k, 5 tasks; the project's lean-planning rule fixes four plans): the first agent runs Tasks 1-3; if its context runs low after Task 3, it writes `19.6-01-SUMMARY.md` as a draft (`Status: draft (hand-off after Task 3)`, commits with hashes, test names, NOT RUN notes) and a fresh agent runs Task 4 and the Task 5 checkpoint from this plan, the draft and the repositories on disk.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Decision note `.planning/notes/2026-10-21-mfa-otp-webauthn.md`; three rows in `.planning/research/STACK.md` Supporting Libraries.
|
||||
- sm-user-plugin (`git.golem15.com/golem15/sm-user-plugin`):
|
||||
- classes: `LoginAttempt`, `LoginGateResult`, `LoginGate`, `LoginGates`, `NewLoginGates`, `(*LoginGates).Add`, `(*LoginGates).Check`; constants `LoginSourcePassword`, `LoginSourceSocial`, `LoginSourcePasskey`, `LoginSourceTwoFactor`, `LoginSourceDevice`; `LoginEvent{User, Source}`, `LoginEventName`; `PasswordChangedEvent{User, Tx, Source}`, `PasswordChangedEventName`, `PasswordChangeSourceReset`, `PasswordChangeSourceChange`, `PasswordChangeSourceAdmin`; `TwoFactorAdmin` interface (`HasTwoFactor(ctx context.Context, tx *gorm.DB, userID uint) (bool, error)`, `Reset(ctx context.Context, tx *gorm.DB, user *models.User) error`); `PermissionResetTwoFactor` (`golem15.users.reset_two_factor`); device helpers `DeviceShortCodeAlphabet`, `NewDeviceToken() (string, error)`, `NewDeviceShortCode() (string, error)`, `NormalizeDeviceShortCode(s string) (string, bool)`, `DeviceAuthURL(app *backpack.App, token string) string`, `DeviceAuthTTL(app *backpack.App) time.Duration`, `DeviceDisplayName(stored *string, userAgent string) string`, `HandOverDeviceJTI(ctx context.Context, db *gorm.DB, oldJTI, newJTI string) error`.
|
||||
- controllers: `CheckLoginGates(ctx context.Context, app *backpack.App, a classes.LoginAttempt) (*classes.LoginGateResult, error)`, `FireLoginEvent(ctx context.Context, app *backpack.App, user *models.User, source string) error`, `FirePasswordChangedEvent(ctx context.Context, app *backpack.App, user *models.User, tx *gorm.DB, source string) error`, handler factories `DeviceInitiate`, `DeviceStatus`, `DeviceAuthorize`, `DeviceIndex`, `DeviceDestroy`; record action `reset_two_factor` on the Users screen.
|
||||
- models: `DeviceAuthSession` (table `golem15_user_device_auth_sessions`), constants `DeviceStatusPending`, `DeviceStatusConfirmed`, `DeviceStatusUsed`, `DeviceStatusExpired`, `DeviceStatusRevoked`.
|
||||
- migration `202610210002_create_device_auth_sessions`.
|
||||
- routes (user-api group, throttle:user-api): `POST /_user/api/v1/devices/initiate`, `GET /_user/api/v1/devices/status/{token}`, `POST /_user/api/v1/devices/authorize`, `GET /_user/api/v1/devices`, `DELETE /_user/api/v1/devices/{id}`.
|
||||
- config keys `golem15.user.frontend_url`, `golem15.user.device_auth.auth_url`, `golem15.user.device_auth.ttl_minutes`; permission `golem15.users.reset_two_factor`; lang keys `golem15.user::lang.plugin.reset_two_factor`, `golem15.user::lang.users.reset_two_factor`, `.reset_two_factor_confirm`, `.reset_two_factor_success`.
|
||||
- smoke tests `TestLoginGatesSmoke`, `TestLoginGateSmoke`, `TestPasswordChangedEventSmoke`, `TestResetTwoFactorSlotSmoke`, `TestDeviceCodesSmoke`, `TestDeviceFlowSmoke`, `TestDeviceRevokeSmoke`.
|
||||
|
||||
<objective>
|
||||
Record the library decision, then give sm-user-plugin every additive seam the phase builds on: the login gate (tracer), the login and password-changed events, the admin reset slot and the complete device authorization flow with JTI tracking through refresh; stop for the human package check.
|
||||
|
||||
Purpose: D-02, D-03, D-04, D-06 (device tokens), D-07 (hook), D-11, D-12a, D-12b, D-12c, D-15 (slot), D-16 (device table), D-17 (additive, fonoteka compiles), D-18 A10; SC-2 (the gate), SC-3 (device authorization), SC-4 (DeviceAuthSession and its migration).
|
||||
Output: one planning-doc commit (note and STACK.md), four sm-user-plugin commits each with its README change, the recorded human reply.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-CONTEXT.md
|
||||
@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-RESEARCH.md
|
||||
@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-PATTERNS.md
|
||||
|
||||
Repos and concurrency: this plan writes in summercms.go (planning docs only) and in the sm-user-plugin repository checked out at ../fonoteka.go/plugins/golem15/user (a submodule of fonoteka.go with its own git; use `git -C ../fonoteka.go/plugins/golem15/user`). Never stage anything in the fonoteka.go superproject (its feedback and golem pointer moves belong to other sessions; plan 03 commits the user pointer). Phase 19.8 plan 03 may sweep the same checkout: before Task 1 run `git -C ../fonoteka.go/plugins/golem15/user fetch origin`, then `status --porcelain` and `log --oneline origin/master..master`; both must be empty, else stop and report. fonoteka.go's own fonoteka-plugin tests `TestNoDeferredRoutesResolve` (bans /devices) and the user-model count in `plugins/golem15/fonoteka/classes/hidden_marshal_test.go` turn red once the device routes and model exist in this checkout; that is expected until plan 03's fonoteka pointer-bump commit updates them (D-17), so this plan's verify commands run the user plugin's suite and fonoteka's build, not the fonoteka plugin's suite. No co-author tags; stage by explicit path; planning docs and code in separate commits; README in the same commit as the API it describes. Keep `go vet` and `go test -short` of the user plugin green at every commit. Docker-backed tests skip under -short; run them on a Docker host (through `sudo -u golem` if the socket is denied); Phase 19.8 found this host's container networking unreachable, so a Docker test that cannot run is recorded as NOT RUN in the SUMMARY, never as passed.
|
||||
|
||||
PHP source of truth (read-only): /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/controllers/ApiController.php (login challenge :55-76, refresh :149-174, device list :1175-1202, revoke :1208-1227, authorize :1233-1270, initiate :1277-1308, status :1315-1347), models/DeviceAuthSession.php (status constants :86-90, short code :142-160, findValidShortCode :168, findValidToken :204, confirm :262, device name accessor :337-361, getAuthUrl :388), updates/v2.1.0-v2.7.1 device migrations, routes.php :191-225. RESEARCH Q4 quotes them; the PHP files break ties. The only device-flow client is /media/nvme/dev/golem15/queststream.online/vue-queststream-app/composables/useDeviceAuth.ts (reads `response.data || response`, polls every 2 s, stops on expired or confirmed).
|
||||
|
||||
<interfaces>
|
||||
sm-user-plugin v0.1.5 (paths relative to ../fonoteka.go/plugins/golem15/user):
|
||||
- controllers/api_controller.go: `Login` :47-127 (readFields :53, restore block :87-93, `CheckAndRecordLogin(ctx, gdb, user, ip, true)` :94-101, rehash :102-110, `TouchLastSeen` :112, `mintFor` :115, final `writeJSON(w, http.StatusOK, map[string]any{"token": token, "user": payload})` :125); `Refresh` :179-213 (`bouncer.Refresh(secret, raw, refreshTTL(app), bl, blacklistGrace(app), requestURL(r))` :194, best-effort TouchLastSeen block :204-210); `ResetPassword` password write :327-343; `ChangePassword` :580-597; `authenticate` :795-825 (VerifyClaims, NewJWTGuard(...).Authenticate, user load); `sessionDeps` :827; `mintFor` :877-884 (discards the jti); `RemoteIP` :1077; `writeUnauthorized` :1104 (`{"error": true, "message": "Unauthorized"}`, not PHP's device body); `writeValidation` :946; `Logout` blacklist call (`bl.Add(r.Context(), jti, exp, time.Now())`).
|
||||
- controllers/registration.go: `FinishLogin` :99, `FireDeactivatingEvent` :166-171 (pattern for the new Fire helpers), `IssueToken` :176, `APIArray` :189.
|
||||
- controllers/users_admin_controller.go: `usersAdminController{app func() *backpack.App}` :37; admin password set in FormBeforeUpdate :238-247 (`changed, err := c.storePassword(ctx, user)`); `AdminRecordActions` :665-730 (the `action(...)` closure, `cabana.TxFromContext`); `requestDB(ctx, c.db)` in controllers/request_db.go.
|
||||
- controllers/users/config_form.yaml :17 `recordActions: [activate, unban, unsuspend]`; controllers/api_tokens.go (List :66-85 with `wire.Slice`, Destroy :88-111 with scoped `Where("user_id = ?", ...)`).
|
||||
- classes/events.go (event types and name constants, DeactivatingEvent :59-67); classes/oauth_providers.go (seam doc with the explicit-type Publish warning); classes/api_tokens.go `HashAPIToken` :96 (SHA-256 hex, the D-06 scheme); classes/privileged.go :16 `PermissionManagePrivilegedGroups`.
|
||||
- plugin.go: `Register` :62-65 (publish the registry here), `Boot` (typed `app.Publish[bouncer.BlacklistStore](bl)`), `Buckets` user-api :150-171; routes.go :17-37 (`g.Delete("/tokens/{id}", ...)` then `g.Where("id", "[0-9]+")`); admin_permissions.go (manage_privileged_groups without Roles); config/config.yaml; updates/202610040001_create_api_tokens.go (raw-SQL migration with `Register(...)` in init); models/api_token.go (model style).
|
||||
- tests: session_test.go `sessionApp` :446, `insertUser` :382, `loginToken` :364, `bearer` :405, `TestLogin` :54, `TestRefresh` :225; admin_harness_test.go `newAdminEnv` :131, `(*adminEnv).admin` :197, `call` :242, `expect` :371, `mails` :325.
|
||||
- Framework: `bouncer.Mint(secret, sub, issuerURL string, ttl time.Duration) (token, jti string, err error)` (mint.go:31), `bouncer.VerifyClaims(token, secret) (sub string, iat, exp time.Time, jti string, err error)` (jwt.go:194), `bouncer.BlacklistStore.Add(ctx, jti string, expiresAt, validUntil time.Time) error` (blacklist.go:16), `app.Events.Fire(ctx, &T{})`, `app.Events.Listen[*T](owner, fn)` (festival), `wire.Time`, `wire.Slice`, `cabana.TxFromContext`, `pact.AdminRecordAction{Name, Label, Confirm, Permissions, Danger, Applies, Run}`.
|
||||
- RESEARCH Code Examples 1 (registry), 7 (device hand-over), 8 (refresh hand-over); Patterns 1, 2, 4, 5; Pitfalls 1, 2, 3, 13, 14, 15, 16, 17, 21.
|
||||
- Decision-note structure: .planning/notes/2026-10-20-sociallogin-goth.md (Decision, Date, Status, Context, Decision bullets, measurements, rejected alternatives, Related dependencies, Package legitimacy, Consequences).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: the library note lands first, then a password login passes through a registered gate end to end: no gate keeps the old answer, a challenging gate replaces the token with its body and leaves a deactivated account untouched, an erroring gate fails closed, and a successful login fires the login event</name>
|
||||
<precondition>`git -C ../fonoteka.go/plugins/golem15/user status --porcelain` prints nothing and, after `git -C ../fonoteka.go/plugins/golem15/user fetch origin`, `git -C ../fonoteka.go/plugins/golem15/user log --oneline origin/master..master` prints nothing.</precondition>
|
||||
<reversibility rating="costly">D-02: LoginGate, LoginGates, LoginAttempt and CheckLoginGates become exported API of the shared core plugin that sm-mfa-plugin and sm-sociallogin-plugin implement and call.</reversibility>
|
||||
<files>.planning/notes/2026-10-21-mfa-otp-webauthn.md, .planning/research/STACK.md, ../fonoteka.go/plugins/golem15/user/classes/login_gate.go, ../fonoteka.go/plugins/golem15/user/classes/events.go, ../fonoteka.go/plugins/golem15/user/controllers/login_gates.go, ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go, ../fonoteka.go/plugins/golem15/user/controllers/registration.go, ../fonoteka.go/plugins/golem15/user/plugin.go, ../fonoteka.go/plugins/golem15/user/login_gate_smoke_test.go, ../fonoteka.go/plugins/golem15/user/README.md</files>
|
||||
<read_first>.planning/notes/2026-10-20-sociallogin-goth.md (structure to copy), .planning/research/STACK.md (Supporting Libraries table), 19.6-RESEARCH.md (Summary, Q1, Q2, Q3, Package Legitimacy Audit, Pattern 1, Code Example 1, Pitfalls 1-3 and 21, Assumptions A1 and A13), 19.6-CONTEXT.md (D-02, D-04, D-08, D-18), ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (Login :47-127), ../fonoteka.go/plugins/golem15/user/controllers/registration.go, ../fonoteka.go/plugins/golem15/user/classes/events.go, ../fonoteka.go/plugins/golem15/user/classes/oauth_providers.go, ../fonoteka.go/plugins/golem15/user/plugin.go (:56-110), ../fonoteka.go/plugins/golem15/user/session_test.go (TestLogin, sessionApp, insertUser), ../fonoteka.go/plugins/golem15/user/README.md (Features, API reference)</read_first>
|
||||
<action>Planning doc first (D-04). Write `.planning/notes/2026-10-21-mfa-otp-webauthn.md` in the structure of the 2026-10-20 note: title "Decision: second factors use pquerna/otp, go-webauthn and boombuler/barcode, in sm-mfa-plugin only"; Date 2026-10-21; Status Accepted; Context Phase 19.6 D-01, D-04, D-06, D-08, D-14 and 19.6-RESEARCH.md Q1-Q3 and the Package Legitimacy Audit. Decision bullets: `github.com/pquerna/otp` v1.5.0 (published 2024-12-31, Apache-2.0, packages totp and hotp; issuer the app name, account the user's email, window plus or minus one step, SHA1, 6 digits, 30 s); `github.com/go-webauthn/webauthn` v0.18.2 (published 2026-09-19, BSD-3-Clause, packages webauthn and protocol; attestation none, timeout 60 s, user verification preferred for second-factor use and required for passkeys); `github.com/boombuler/barcode` v1.1.0 (MIT, package qr only; already required by pquerna/otp, so it adds no module) with a small SVG serializer in sm-mfa-plugin that keeps PHP's `{secret, qr_code_svg, qr_code_url}` contract (round-trip proven by an independent decoder; RESEARCH A1); the alternative piglig/go-qr v1.1.0 is rejected (67 stars, one maintainer, a new module for what boombuler already provides). Slow hash: bcrypt through `bouncer.HashPassword`/`CheckPassword` at `golem15.user.password.bcrypt_cost`, as the user plugin hashes passwords (no argon2; RESEARCH Q3, 631 ms per 10 compares). Measurements from RESEARCH Q1: a stripped demo binary grows by 2,269,184 bytes (+2.16 MiB, 51,802,375 to 54,071,559); newly linked modules boombuler/barcode v1.1.0, fxamacker/cbor/v2 v2.9.4, go-webauthn/webauthn v0.18.2, go-webauthn/x v0.3.1, google/go-tpm v0.9.8, philhofer/fwd v1.2.0, pquerna/otp v1.5.0, tinylib/msgp v1.6.4, x448/float16 v0.8.4; minimal version selection moves go-viper/mapstructure/v2 from v2.4.0 to v2.5.0 in the graphs of applications that mount the plugin; govulncheck v1.9.0 reports no reachable vulnerability (the module-level GO-2026-5932 sits in an unimported x/crypto package). go-webauthn's go.mod lists the archived YAML v3 module as an indirect, test-only requirement outside the import graph; plugin code never imports it (CLAUDE.md: goccy only). D-08 deviation recorded here: WebAuthn requests and responses use go-webauthn's standard base64url shapes, not PHP's lbuchs plain-base64 layout, because no client implements the PHP layout yet; PHP stored PEM public keys while go-webauthn verifies COSE keys, so the Phase 30 import must convert or drop WebAuthn rows. Package legitimacy: the seam cannot check Go modules, so the three are [ASSUMED] and gated by the blocking human check (plan 19.6-01 Task 5) before any `go get`. Consequences: only sm-mfa-plugin's go.mod requires them; sm-user-plugin, the framework and fonoteka.go require none; applications without the plugin link none of the nine modules (D-01); go-webauthn is pre-1.0 and the research is valid until 2026-11-20, so the executor re-checks the pin with `go list -m -json github.com/go-webauthn/webauthn@latest` before installing after that date. In .planning/research/STACK.md add three Supporting Libraries rows in the existing style (library with packages | version | purpose | "Decided in .planning/notes/2026-10-21-mfa-otp-webauthn.md (Phase 19.6 D-04); imported only by sm-mfa-plugin"). Commit the note and STACK.md together, alone: `docs(19.6): decide the OTP, WebAuthn and QR libraries`.
|
||||
|
||||
Then the tracer in sm-user-plugin (D-02, RESEARCH Code Example 1 and Pattern 1). classes/login_gate.go: the types and constants in this plan's Decisions with doc comments: a gate inspects a first-factor sign-in that already succeeded; a nil result allows it; a non-nil result replaces the token response with its Status (0 means 200) and Body; an error must fail the sign-in closed; another plugin adds a gate in its Boot, because every Register runs before every Boot; Fields is the already-read login body (Pitfall 2) and is nil on social sign-in. `LoginGates` guards its slice with a sync.RWMutex; `Check` on a nil receiver returns nil, nil; it clones the slice under the read lock and returns the first non-nil result or the first error, never calling later gates after either. controllers/login_gates.go: `CheckLoginGates(ctx, app, a)` looks up `*classes.LoginGates` per call and returns nil, nil when none is published. plugin.go Register publishes `classes.NewLoginGates()` with `app.Publish` (keyed on its concrete pointer type, looked up the same way).
|
||||
|
||||
classes/events.go adds `LoginEventName = "golem15.user.login"` and `LoginEvent{User *models.User; Source string}` (doc: fired after a sign-in issued a token: password login, a completed second factor, a passkey or a device hand-over; not on refresh). controllers/registration.go adds `FireLoginEvent(ctx, app, user, source)` modelled on FireDeactivatingEvent.
|
||||
|
||||
Login (controllers/api_controller.go) per D-02 and D-18 A10: keep everything up to and including `CheckAndRecordLogin(ctx, gdb, user, ip, true)` unchanged; move the bcrypt rehash block directly after it (unchanged code); then `res, err := CheckLoginGates(ctx, app, classes.LoginAttempt{User: user, Source: classes.LoginSourcePassword, Fields: fields, Request: r})`: err → `writeOpaque500(w)` and return; res non-nil → `writeJSON(w, status, res.Body)` with status res.Status or 200 when it is 0, and return (no restore, no mail, no last_seen, no token, no event); then the soft-delete restore block moved here unchanged (with its reactivate mail), TouchLastSeen, mint, apiArray, then `FireLoginEvent(ctx, app, user, classes.LoginSourcePassword)` (a listener error is logged with the user id and never fails the login), then the unchanged final writeJSON. A comment cites D-02 and D-18 A10. Refresh is not touched in this task and never consults the gate.
|
||||
|
||||
README (same commit as the code): Features gains "Login gates" (a plugin adds a classes.LoginGate to the published classes.LoginGates in its Boot; Login consults gates after the password and throttle checks and before restoring a deactivated account and minting; a gate answer replaces the token response; gate errors fail closed; refresh never consults gates) and "Login event"; the API reference gains rows for every identifier this task adds. Neutral wording ("a two-factor plugin").
|
||||
|
||||
Smoke tests in login_gate_smoke_test.go. `TestLoginGatesSmoke` (no database, runs under -short): a nil *LoginGates returns nil, nil; an empty registry returns nil, nil; gates A (nil) then B (result) return B's result; a gate returning an error stops the chain before a following gate (a counter proves the later gate was not called). `TestLoginGateSmoke` (Postgres via sessionApp, skips under -short), subtests: `no_gate` (registry published and empty: login answers 200 with token and user and a LoginEvent listener records exactly one event with Source password); `challenge` (a fake gate returns Status 200 with body two_factor_required true, challenge_token of 64 x characters, available_methods ["totp"], expires_at "2026-10-21T12:00:00+00:00": the response body decodes to exactly that map with no token key, the gate saw Source password and Fields containing trusted_device_token sent in the login body, and no LoginEvent fired); `refusal` (Status 401 with {"error":"Invalid or expired challenge."} is answered as such); `gate_error` (opaque 500, no token key); `soft_deleted_stays` (a soft-deleted user with a challenging gate: deleted_at is still set afterwards and the captured mail has no reactivate message; with the registry emptied the same login restores the account and sends one reactivate mail, as before). If sessionApp does not run Register, the test publishes `classes.NewLoginGates()` itself. Commit in sm-user-plugin: `feat: login gates and the login event`.</action>
|
||||
<verify>
|
||||
<automated>go -C ../fonoteka.go vet ./plugins/golem15/user/... && go -C ../fonoteka.go test -count=1 -short ./plugins/golem15/user/... && go -C ../fonoteka.go test -count=1 -short -run '^TestLoginGatesSmoke$' -v ./plugins/golem15/user 2>&1 | grep -q -- '^--- PASS: TestLoginGatesSmoke' && go -C ../fonoteka.go build ./... && go -C ../sm-sociallogin-plugin build ./... && go -C ../sm-oauthserver-plugin build ./...</automated>
|
||||
<fails_when>Non-zero exit: vet or the short user-plugin suite is red, no "--- PASS: TestLoginGatesSmoke" line in the verbose run, or fonoteka.go, sm-sociallogin-plugin or sm-oauthserver-plugin (all three resolve sm-user-plugin to this checkout) no longer compile (a non-additive change).</fails_when>
|
||||
<automated>go -C ../fonoteka.go test -count=1 -run '^(TestLoginGateSmoke|TestLogin|TestRefresh)$' -v ./plugins/golem15/user 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>The count of top-level "--- PASS: Test" lines is not 3: the gate smoke, the unchanged TestLogin or TestRefresh failed or skipped (a "--- SKIP" means Docker was unreachable: rerun through sudo -u golem, else record NOT RUN in the SUMMARY).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `test -n "$(git log --diff-filter=A --format=%H -- .planning/notes/2026-10-21-mfa-otp-webauthn.md)"` exits 0, and the note contains each of `pquerna/otp v1.5.0`, `go-webauthn/webauthn v0.18.2`, `boombuler/barcode v1.1.0`, `2,269,184` and `go-viper/mapstructure` (one grep -c per literal, each at least 1).
|
||||
- `grep -c '2026-10-21-mfa-otp-webauthn.md' .planning/research/STACK.md` prints at least 3.
|
||||
- `grep -c 'type LoginGate interface' ../fonoteka.go/plugins/golem15/user/classes/login_gate.go` prints at least 1 and `grep -c 'CheckLoginGates(' ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go` prints at least 1.
|
||||
- `grep -c 'LoginEventName = "golem15.user.login"' ../fonoteka.go/plugins/golem15/user/classes/events.go` prints 1.
|
||||
- `grep -cE 'LoginGates|LoginEvent|CheckLoginGates' ../fonoteka.go/plugins/golem15/user/README.md` prints at least 3.
|
||||
- `D=$(git -C ../fonoteka.go/plugins/golem15/user diff --name-only 35cf166 -- updates) && test -z "$D"` exits 0 (no migration touched by this task).
|
||||
- `test "$(grep -c 'pquerna\|webauthn\|boombuler' ../fonoteka.go/plugins/golem15/user/go.mod)" -eq 0` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>The libraries are on record before any code, and a password login can be held back by a registered gate without changing anything for applications that register none.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Password changes announce themselves inside their transaction, and an administrator with the new permission gets a "Reset 2FA" action that appears only when a two-factor plugin publishes its service</name>
|
||||
<reversibility rating="costly">D-07 and D-15: PasswordChangedEvent, TwoFactorAdmin and the golem15.users.reset_two_factor permission become API and backend-role data that other plugins and imported roles reference.</reversibility>
|
||||
<files>../fonoteka.go/plugins/golem15/user/classes/events.go, ../fonoteka.go/plugins/golem15/user/classes/two_factor_admin.go, ../fonoteka.go/plugins/golem15/user/controllers/registration.go, ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go, ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go, ../fonoteka.go/plugins/golem15/user/controllers/users/config_form.yaml, ../fonoteka.go/plugins/golem15/user/admin_permissions.go, ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/user/lang/pl/lang.yaml, ../fonoteka.go/plugins/golem15/user/password_changed_smoke_test.go, ../fonoteka.go/plugins/golem15/user/reset_two_factor_smoke_test.go, ../fonoteka.go/plugins/golem15/user/admin_registration_test.go, ../fonoteka.go/plugins/golem15/user/README.md</files>
|
||||
<read_first>../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (ResetPassword :300-345, ChangePassword :560-600), ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go (:37-45, :215-320, :665-730), ../fonoteka.go/plugins/golem15/user/controllers/request_db.go, ../fonoteka.go/plugins/golem15/user/controllers/users/config_form.yaml, ../fonoteka.go/plugins/golem15/user/admin_permissions.go, ../fonoteka.go/plugins/golem15/user/classes/privileged.go, ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml (users and plugin sections), ../fonoteka.go/plugins/golem15/user/admin_harness_test.go (newAdminEnv, admin, call, expect), ../fonoteka.go/plugins/golem15/user/admin_users_test.go (TestAdminUserActions :575, TestAdminUserPassword :977), ../fonoteka.go/plugins/golem15/user/admin_registration_test.go (TestAdminRegistration :29: exact permission codes, the no-default-role rule, phrase keys in en and pl), ../fonoteka.go/plugins/golem15/user/deactivating_event_test.go, summercms.go modules/cabana/actions.go (:236-252 record-action permission and Applies handling), 19.6-RESEARCH.md (Q5 password-write sites, Q9, Pattern 5, Pitfall 3)</read_first>
|
||||
<action>D-07 hook. classes/events.go adds `PasswordChangedEventName = "user.PasswordChanged"`, the three source constants and `PasswordChangedEvent{User *models.User; Tx *gorm.DB; Source string}` with the doc: fired inside the transaction that stores a new password the user or an administrator chose (reset, change, admin set), never for a rehash on login or a new account; a listener error rolls the write back; a two-factor plugin revokes trusted devices here. controllers/registration.go adds `FirePasswordChangedEvent(ctx, app, user, tx, source)` like FireDeactivatingEvent. ResetPassword and ChangePassword: wrap the existing `Updates(...)` and a `FirePasswordChangedEvent` call in one `gdb.WithContext(ctx).Transaction(func(tx *gorm.DB) error {...})`; the Updates map and every response body stay exactly as they are; a transaction error answers the opaque 500 the handler already uses for a failed write. Admin set (FormBeforeUpdate): when `changed` is true, after setting TokensValidAfter, call `FirePasswordChangedEvent(ctx, c.app(), user, requestDB(ctx, c.db), classes.PasswordChangeSourceAdmin)` (requestDB yields cabana's write transaction) and return its error so cabana rolls the save back. Never fire from the Login rehash or RegisterUser (Pitfall 3); a comment at the rehash says so.
|
||||
|
||||
D-15 slot (RESEARCH Q9). classes/two_factor_admin.go: `TwoFactorAdmin` interface (methods in Artifacts) with a doc that a two-factor plugin publishes it with an explicit type argument (`app.Publish[classes.TwoFactorAdmin](svc)`), the users screen looks it up per request, and Reset disables every method, deletes WebAuthn credentials and recovery codes, revokes trusted devices and fires the plugin's disabled event, all on the given transaction. classes/privileged.go style constant `PermissionResetTwoFactor = "golem15.users.reset_two_factor"` in classes. admin_permissions.go adds the permission (Tab userPermissionTab, Label `golem15.user::lang.plugin.reset_two_factor`, no Roles; doc: only a superuser or a role granted it explicitly). config_form.yaml: `recordActions: [activate, unban, unsuspend, reset_two_factor]`. AdminRecordActions appends one action built like the existing closure but with `Permissions: []string{classes.PermissionResetTwoFactor}`, `Danger: true`, labels `reset_two_factor`, `reset_two_factor_confirm`, `reset_two_factor_success`; Applies looks up `classes.TwoFactorAdmin` through `c.app()` and returns false, nil when none is published (no two-factor plugin mounted), else `HasTwoFactor(ctx, requestDB(ctx, c.db), user.ID)`; Run takes `cabana.TxFromContext(ctx)` (error when missing, as the other actions) and calls `svc.Reset(ctx, tx, user)`; a missing service in Run returns an error, never a silent success. TestAdminRegistration pins the permission catalog: its `wantCodes` gains `golem15.users.reset_two_factor` after manage_privileged_groups and its no-default-role rule covers both codes without roles (a test update for an additive code, in this commit). Lang en and pl: `plugin.reset_two_factor` ("Reset two-factor authentication of users" / Polish), `users.reset_two_factor` ("Reset 2FA"), `users.reset_two_factor_confirm` (the user signs in with the password alone until they set up 2FA again), `users.reset_two_factor_success`.
|
||||
|
||||
README (same commit): Features gains "Password-changed event" and "Admin: Reset 2FA" (shown only when a two-factor plugin publishes classes.TwoFactorAdmin; permission golem15.users.reset_two_factor without a default role); the Admin section lists the action; the API reference gains the new identifiers; the permissions list gains the code.
|
||||
|
||||
Smoke tests. password_changed_smoke_test.go `TestPasswordChangedEventSmoke` (Postgres) with subtests: `reset` (a forgot/reset flow through the handler fires one event with Source reset), `change` (change-password fires one with Source change), `admin_set` (newAdminEnv: an admin update with a new password fires one with Source admin; an update without a password fires none), `rehash_fires_none` (set bcrypt_cost one above the stored hash's cost, log in, the stored hash changes and no event fired), `listener_error_rolls_back` (a listener returning an error makes change-password answer 500 and the stored hash is unchanged). reset_two_factor_smoke_test.go `TestResetTwoFactorSlotSmoke` (admin harness, Postgres): without a published TwoFactorAdmin the user's record response offers no reset_two_factor action; with a fake service whose HasTwoFactor is true and an admin holding golem15.users.access_users and golem15.users.reset_two_factor, the action is offered with danger true and running it calls Reset once with a non-nil transaction and the user's id; an admin holding only access_users is refused by cabana's permission check. Commit in sm-user-plugin: `feat: password-changed event and the reset-2FA admin slot`.</action>
|
||||
<verify>
|
||||
<automated>go -C ../fonoteka.go vet ./plugins/golem15/user/... && go -C ../fonoteka.go test -count=1 -short ./plugins/golem15/user/... && go -C ../fonoteka.go build ./... && go -C ../sm-sociallogin-plugin build ./... && go -C ../sm-oauthserver-plugin build ./...</automated>
|
||||
<fails_when>Non-zero exit: vet or the short suite is red (cabana boot validation of the new record action or permission fails inside an admin test), or a dependent repository no longer compiles.</fails_when>
|
||||
<automated>go -C ../fonoteka.go test -count=1 -run '^(TestPasswordChangedEventSmoke|TestResetTwoFactorSlotSmoke|TestAdminUserActions|TestAdminUserPassword|TestAdminRegistration)$' -v ./plugins/golem15/user 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 5</automated>
|
||||
<fails_when>The count of top-level "--- PASS: Test" lines is not 5: a new smoke, an existing admin action or password test, or the permission-catalog test failed or skipped (Docker unreachable: rerun through sudo -u golem, else record NOT RUN).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'type PasswordChangedEvent struct' ../fonoteka.go/plugins/golem15/user/classes/events.go` prints 1 and `grep -c 'FirePasswordChangedEvent(' ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go` prints at least 2 and `grep -c 'FirePasswordChangedEvent(' ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go` prints at least 1.
|
||||
- `grep -c 'reset_two_factor' ../fonoteka.go/plugins/golem15/user/controllers/users/config_form.yaml` prints 1 and `grep -c 'golem15.users.reset_two_factor' ../fonoteka.go/plugins/golem15/user/admin_permissions.go ../fonoteka.go/plugins/golem15/user/classes/*.go | awk -F: '{s+=$2} END {print s}'` prints at least 1.
|
||||
- `grep -c 'Lookup\[classes.TwoFactorAdmin\]' ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go` prints at least 1.
|
||||
- `grep -c 'reset_two_factor' ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml` prints at least 3 and the same count for lang/pl/lang.yaml is at least 3.
|
||||
- `grep -cE 'PasswordChangedEvent|TwoFactorAdmin|reset_two_factor' ../fonoteka.go/plugins/golem15/user/README.md` prints at least 3.
|
||||
</acceptance_criteria>
|
||||
<done>Every user or administrator password change is announced inside its own write, and the Users screen can offer a two-factor reset that only appears where a two-factor plugin is mounted.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: A new device signs in by QR or short code: initiate shows a token, short code and auth_url, a signed-in user authorizes it, and the device's first poll receives a one-time JWT exactly once</name>
|
||||
<reversibility rating="costly">D-11, D-16: the golem15_user_device_auth_sessions table and the five device routes become part of the core plugin's published schema and contract once plan 03 releases them; a later change needs a migration.</reversibility>
|
||||
<files>../fonoteka.go/plugins/golem15/user/updates/202610210002_create_device_auth_sessions.go, ../fonoteka.go/plugins/golem15/user/models/device_auth_session.go, ../fonoteka.go/plugins/golem15/user/classes/device_auth.go, ../fonoteka.go/plugins/golem15/user/controllers/devices.go, ../fonoteka.go/plugins/golem15/user/routes.go, ../fonoteka.go/plugins/golem15/user/config/config.yaml, ../fonoteka.go/plugins/golem15/user/device_flow_smoke_test.go, ../fonoteka.go/plugins/golem15/user/README.md</files>
|
||||
<read_first>../fonoteka.go/plugins/golem15/user/updates/202610040001_create_api_tokens.go, ../fonoteka.go/plugins/golem15/user/updates/registry.go, ../fonoteka.go/plugins/golem15/user/models/api_token.go, ../fonoteka.go/plugins/golem15/user/models/registry.go, ../fonoteka.go/plugins/golem15/user/classes/api_tokens.go (:48-111), ../fonoteka.go/plugins/golem15/user/controllers/api_tokens.go, ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (authenticate :795-825, mintFor :877-884, requestURL, writeJSON), ../fonoteka.go/plugins/golem15/user/routes.go, ../fonoteka.go/plugins/golem15/user/config/config.yaml, ../fonoteka.go/plugins/golem15/user/updates/postgres_test.go (migration test harness), /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/controllers/ApiController.php (:1233-1347), /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/models/DeviceAuthSession.php, /media/nvme/dev/golem15/queststream.online/vue-queststream-app/composables/useDeviceAuth.ts, 19.6-RESEARCH.md (Q4 device flow and tables, Pattern 2, Pattern 4, Code Example 7, Pitfalls 13, 14, 16, 17)</read_first>
|
||||
<action>Per D-03, D-06, D-11, D-12a, D-12c, D-16; additive only.
|
||||
|
||||
Migration updates/202610210002_create_device_auth_sessions.go (raw SQL like the api-tokens migration, registered in init): `CREATE TABLE golem15_user_device_auth_sessions` with id BIGSERIAL PRIMARY KEY; token VARCHAR(64) NOT NULL UNIQUE (the SHA-256 hex of the 64-character token, D-06; PHP's column name, D-16); short_code VARCHAR(9) NULL UNIQUE; user_id INTEGER NULL REFERENCES users(id) ON DELETE CASCADE; status VARCHAR(16) NOT NULL DEFAULT 'pending' with a CHECK on the five PHP values; expires_at TIMESTAMPTZ NULL; device_ip VARCHAR(255) NULL; device_user_agent TEXT NULL; device_name VARCHAR(255) NULL; auth_ip VARCHAR(255) NULL; confirmed_at, used_at, last_activity_at TIMESTAMPTZ NULL; session_id VARCHAR(255) NULL (PHP column kept for the import); jwt_jti VARCHAR(64) NULL (D-12b); created_at and updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); indexes on user_id, status, expires_at and jwt_jti. Rollback drops the table. models/device_auth_session.go: `DeviceAuthSession` with explicit column tags (TokenHash `gorm:"column:token"` and `json:"-"`), TableName, Fillable, Hidden as models/api_token.go, the five status constants, registered in init. If a user-plugin test counts models or migrations, update its expected count in the same commit with a comment naming this table.
|
||||
|
||||
classes/device_auth.go: `DeviceShortCodeAlphabet = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"`; `NewDeviceToken()` = 48 bytes from crypto/rand as base64.RawURLEncoding (64 characters); `NewDeviceShortCode()` = 8 characters drawn uniformly from the alphabet with crypto/rand (rejection sampling, no modulo bias), formatted XXXX-XXXX; `NormalizeDeviceShortCode(s)` uppercases, removes spaces and hyphens, requires exactly 8 alphabet characters and returns the XXXX-XXXX form (false otherwise); `DeviceAuthTTL(app)` from `golem15.user.device_auth.ttl_minutes` (default 5); `DeviceAuthURL(app, token)` replaces `{frontend_url}` (golem15.user.frontend_url trimmed of a trailing slash, else app.url) and `{token}` in `golem15.user.device_auth.auth_url` (default `{frontend_url}/device/scan/{token}`); `DeviceDisplayName(stored, userAgent)` ports PHP's accessor (:337-361). Hash tokens with the existing `HashAPIToken`. config/config.yaml documents the three new keys with comments (frontend_url empty means app.url; the template placeholders; the TTL).
|
||||
|
||||
controllers/devices.go (handler factories taking *backpack.App, JSON through the plugin's writeJSON, timestamps through wire.Time so `+00:00` matches PHP's toIso8601String):
|
||||
- `DeviceInitiate` (unauthenticated): reads `device_name` from JSON or form, trims it and rejects more than 255 characters with 422 through writeValidation (Pitfall 14); first deletes at most 500 rows with status pending or expired whose expires_at is older than one hour (Pitfall 16; one bounded DELETE ... WHERE id IN (SELECT ... LIMIT 500)); generates token and short code, retrying the insert up to 5 times on a short_code unique violation; stores the token hash, status pending, expires_at now + TTL, device_ip `RemoteIP(r)`, device_user_agent; answers 200 `{"success":true,"data":{"token","short_code","auth_url","expires_at"}}`.
|
||||
- `DeviceStatus` (unauthenticated, GET devices/status/{token}): look up by HashAPIToken of the path value; unknown → 404 `{"error":"Session not found"}`; status pending or confirmed with expires_at in the past → one guarded `UPDATE ... SET status='expired' WHERE id=? AND status IN ('pending','confirmed')` and the flat body `{"success":true,"status":"expired","message":"Authorization session has expired"}`; status used, revoked or expired → `{"success":true,"data":{"status":<status>}}` (used never becomes expired, Pitfall 13); pending → `{"success":true,"data":{"status":"pending"}}`; confirmed with a user → the D-12a hand-over (RESEARCH Code Example 7): load the user (not soft-deleted; if missing, mark the row revoked and answer status revoked), mint with `bouncer.Mint(secret, strconv.FormatUint(uint64(user.ID), 10), requestURL(r), ttl)` using the same ttl rule as mintFor (an unexported helper `mintWithJTI` beside mintFor), then `UPDATE golem15_user_device_auth_sessions SET status='used', used_at=NOW(), last_activity_at=NOW(), jwt_jti=?, updated_at=NOW() WHERE id=? AND status='confirmed' AND expires_at > NOW()`; RowsAffected 1 → `FinishLogin(ctx, app, gdb, user, RemoteIP(r))`, `FireLoginEvent(..., classes.LoginSourceDevice)` (logged on error) and 200 `{"success":true,"data":{"status":"confirmed","user_id":<id>,"message":"Device authorized - complete login","token":<jwt>,"user":<APIArray>}}`; RowsAffected 0 → the minted token is discarded unsent and the handler re-reads the row and answers its current status as above.
|
||||
- `DeviceAuthorize` (POST, bearer JWT checked inside the handler like `authenticate` but answering PHP's 401 `{"error":"Unauthorized"}` for a missing, invalid, expired, blacklisted or stale token; D-12c, no second-factor check): with a `short_code` field present, strip hyphens and require length 8 else 422 `{"error":"Please enter a valid 8-character code"}`, then match `NormalizeDeviceShortCode`; else with a `token` field match its hash; then one guarded `UPDATE ... SET status='confirmed', user_id=?, confirmed_at=NOW(), auth_ip=?, updated_at=NOW() WHERE id=? AND status='pending' AND (user_id IS NULL OR user_id=?) AND expires_at > NOW()`; RowsAffected 1, or the row already confirmed for this caller → 200 `{"success":true,"message":"Device authorized successfully"}`; nothing matched or not updatable → 400 `{"error":"Invalid or expired code"}`.
|
||||
routes.go: inside the existing user-api group add `g.Post("/devices/initiate", ...)`, `g.Get("/devices/status/{token}", ...)`, `g.Post("/devices/authorize", ...)` (no route middleware; authentication is in the handler).
|
||||
|
||||
README (same commit): a "Device authorization" section (QR and short-code flow, the five statuses, the one-time token on the first poll after confirmation, the configurable auth_url, tokens stored hashed, PHP's response bodies), Configuration rows for the three keys, API reference rows. Smoke tests in device_flow_smoke_test.go: `TestDeviceCodesSmoke` (no database, -short: 1000 tokens are 64 base64url characters and distinct; short codes match `^[2-9A-HJKMNP-Z]{4}-[2-9A-HJKMNP-Z]{4}$`; normalize accepts lower case, spaces and a missing hyphen and refuses 7 or 9 characters and excluded letters such as O, I, L, 0 and 1; DeviceAuthURL with and without frontend_url and with a custom template). `TestDeviceFlowSmoke` (Postgres): initiate answers the data block with a 64-character token, a short code, an auth_url containing the token and an expires_at in `+00:00` form, and the stored token column equals HashAPIToken(token); status is pending; authorize with the lower-cased short code and a valid bearer answers success; the next status answers confirmed with a token that `bouncer.VerifyClaims` accepts for the authorizing user and stores its jti; the following status answers used without a token; 10 concurrent polls on another confirmed session yield exactly one token; authorize without a bearer answers 401 `{"error":"Unauthorized"}`; a session past expires_at answers the flat expired body. Commit in sm-user-plugin: `feat: device authorization by QR or short code`.</action>
|
||||
<verify>
|
||||
<automated>go -C ../fonoteka.go vet ./plugins/golem15/user/... && go -C ../fonoteka.go test -count=1 -short ./plugins/golem15/user/... && go -C ../fonoteka.go test -count=1 -short -run '^TestDeviceCodesSmoke$' -v ./plugins/golem15/user 2>&1 | grep -q -- '^--- PASS: TestDeviceCodesSmoke' && go -C ../fonoteka.go build ./... && go -C ../sm-sociallogin-plugin build ./... && go -C ../sm-oauthserver-plugin build ./...</automated>
|
||||
<fails_when>Non-zero exit: vet or the short suite is red, no "--- PASS: TestDeviceCodesSmoke" line, or a dependent repository no longer compiles.</fails_when>
|
||||
<automated>go -C ../fonoteka.go test -count=1 -run '^(TestDeviceFlowSmoke|TestLoginGateSmoke)$' -v ./plugins/golem15/user/... 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>The count of top-level "--- PASS: Test" lines is not 2: the device flow or the gate smoke failed or skipped (Docker unreachable: rerun through sudo -u golem, else record NOT RUN).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'golem15_user_device_auth_sessions' ../fonoteka.go/plugins/golem15/user/updates/202610210002_create_device_auth_sessions.go` prints at least 2 and `grep -c 'jwt_jti' ../fonoteka.go/plugins/golem15/user/updates/202610210002_create_device_auth_sessions.go` prints at least 1.
|
||||
- `S=$(git -C ../fonoteka.go/plugins/golem15/user diff --name-status 35cf166 -- updates) && test "$(printf '%s\n' "$S" | grep -c '^[MDR]')" -eq 0` exits 0 (under updates/ only files were added).
|
||||
- `grep -c "status = 'confirmed'" ../fonoteka.go/plugins/golem15/user/controllers/devices.go` prints at least 1 and `grep -c "status = 'pending'" ../fonoteka.go/plugins/golem15/user/controllers/devices.go` prints at least 1 (guarded transitions).
|
||||
- `grep -c '"/devices/status/{token}"' ../fonoteka.go/plugins/golem15/user/routes.go` prints 1 and `grep -c '"/devices/initiate"' ../fonoteka.go/plugins/golem15/user/routes.go` prints 1.
|
||||
- `grep -c 'device_auth' ../fonoteka.go/plugins/golem15/user/config/config.yaml` prints at least 1 and `grep -c 'frontend_url' ../fonoteka.go/plugins/golem15/user/config/config.yaml` prints at least 1.
|
||||
- `grep -c 'Device authorization' ../fonoteka.go/plugins/golem15/user/README.md` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>A television or desktop app can show a QR or short code, a signed-in user approves it from another device, and the waiting device receives one token exactly once.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: A user lists the devices they signed in and revokes one, and the revoked device is signed out at once, also after it refreshed its token</name>
|
||||
<files>../fonoteka.go/plugins/golem15/user/controllers/devices.go, ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go, ../fonoteka.go/plugins/golem15/user/classes/device_auth.go, ../fonoteka.go/plugins/golem15/user/routes.go, ../fonoteka.go/plugins/golem15/user/device_revoke_smoke_test.go, ../fonoteka.go/plugins/golem15/user/README.md</files>
|
||||
<read_first>../fonoteka.go/plugins/golem15/user/controllers/devices.go and classes/device_auth.go (Task 3), ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (Logout blacklist call, Refresh :179-213, refreshTTL, blacklistGrace), ../fonoteka.go/plugins/golem15/user/controllers/api_tokens.go (List and Destroy), summercms.go modules/bouncer/refresh.go (:92-155), modules/bouncer/blacklist.go, /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/controllers/ApiController.php (:1175-1227), 19.6-RESEARCH.md (Q7, Pattern 4, Code Example 8, Pitfalls 15, 17)</read_first>
|
||||
<action>Per D-12b and D-11. `DeviceIndex` (GET devices, bearer checked in the handler with PHP's 401 body): rows of the caller with status confirmed or used ordered by last_activity_at DESC NULLS LAST, then id DESC; each item `{id, device_name: DeviceDisplayName(row.DeviceName, row.DeviceUserAgent), device_ip, authorized_at: confirmed_at as wire.Time or null, last_activity: last_activity_at as wire.Time or null, is_current: row.jwt_jti equals the bearer token's jti}`; answer `{"success":true,"devices":[...]}` with wire.Slice so an empty list is `[]`. `DeviceDestroy` (DELETE devices/{id}): parse the id, find the row scoped by `user_id` of the caller (else 404 `{"error":"Device not found"}`); in one transaction set status revoked and updated_at with a guarded UPDATE scoped by id and user_id, and when the row has a jwt_jti add it to the published `bouncer.BlacklistStore` with expiresAt = now + refreshTTL + the JWT ttl + 1 minute and validUntil = now (so the token and every refresh of it are refused at once; RESEARCH Q7); a blacklist error answers the opaque 500 and rolls the status back; success answers `{"success":true,"message":"Device revoked successfully"}`. Revoking an already revoked row answers the same success (PHP's revoke() saved unconditionally). routes.go: `g.Get("/devices", ...)` and `g.Delete("/devices/{id}", ...)` immediately followed by `g.Where("id", "[0-9]+")` (surf binds Where to the last route, Pitfall 17).
|
||||
|
||||
Refresh JTI hand-over (RESEARCH Q7, Code Example 8). classes/device_auth.go `HandOverDeviceJTI(ctx, db, oldJTI, newJTI)` runs `UPDATE golem15_user_device_auth_sessions SET jwt_jti = ?, last_activity_at = NOW(), updated_at = NOW() WHERE jwt_jti = ? AND status = 'used'` and does nothing for empty values. In `Refresh`, after `bouncer.Refresh` succeeded: read the old token's jti from the raw token's payload segment with the standard library (split on dots, base64.RawURLEncoding decode of the middle part, JSON field jti; safe because bouncer.Refresh has just verified the signature), read the new jti from `bouncer.VerifyClaims(next, secret)` (the block that already parses it for TouchLastSeen), and call HandOverDeviceJTI; an error is logged with no token or jti value and never fails the refresh; the response body stays `{"token": next}`. A comment documents Pitfall 15: with golem15.user.jwt.blacklist_grace above 0 two concurrent refreshes of one token can fork a device's lineage and only one fork is tracked; a password change still ends both through tokens_valid_after.
|
||||
|
||||
README (same commit): the Device authorization section gains the list and revoke routes, is_current, the immediate sign-out on revoke including refreshed tokens, and the blacklist_grace caveat; API reference row for HandOverDeviceJTI. Smoke test device_revoke_smoke_test.go `TestDeviceRevokeSmoke` (Postgres) with subtests: `list` (two used sessions and one pending of the caller plus one of another user: the list has the caller's two, newest activity first, device_name from the stored name or the parsed user agent, is_current true only for the session whose token is the bearer); `revoke` (DELETE answers success; GET /fetch with the device token answers 401; DELETE of another user's id answers 404; DELETE /devices/abc does not match the route); `revoke_after_refresh` (the device refreshes its token, the row's jwt_jti now equals the new jti, revoke, then both the old and the refreshed token answer 401 on /fetch and refreshing the refreshed token fails). Commit in sm-user-plugin: `feat: device list and revoke with the jti followed through refresh`.</action>
|
||||
<verify>
|
||||
<automated>go -C ../fonoteka.go vet ./plugins/golem15/user/... && go -C ../fonoteka.go test -count=1 -short ./plugins/golem15/user/... && go -C ../fonoteka.go build ./... && go -C ../sm-sociallogin-plugin build ./... && go -C ../sm-oauthserver-plugin build ./...</automated>
|
||||
<fails_when>Non-zero exit: vet or the short suite is red, or a dependent repository no longer compiles.</fails_when>
|
||||
<automated>go -C ../fonoteka.go test -count=1 -run '^(TestDeviceRevokeSmoke|TestDeviceFlowSmoke|TestRefresh|TestLogout)$' -v ./plugins/golem15/user 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 4</automated>
|
||||
<fails_when>The count of top-level "--- PASS: Test" lines is not 4: revoke, the device flow, or the unchanged refresh or logout tests failed or skipped (Docker unreachable: rerun through sudo -u golem, else record NOT RUN).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'HandOverDeviceJTI(' ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go` prints at least 1.
|
||||
- `grep -c 'Lookup\[bouncer.BlacklistStore\]' ../fonoteka.go/plugins/golem15/user/controllers/devices.go` prints at least 1.
|
||||
- `grep -A1 '"/devices/{id}"' ../fonoteka.go/plugins/golem15/user/routes.go | grep -c 'Where("id", "\[0-9\]+")'` prints 1 (the constraint follows the route directly).
|
||||
- `grep -c '"Device revoked successfully"' ../fonoteka.go/plugins/golem15/user/controllers/devices.go` prints 1 and `grep -c '"Device not found"' ../fonoteka.go/plugins/golem15/user/controllers/devices.go` prints 1.
|
||||
- `grep -c 'blacklist_grace' ../fonoteka.go/plugins/golem15/user/README.md` prints at least 1.
|
||||
- `test -z "$(git -C ../fonoteka.go/plugins/golem15/user status --porcelain)"` exits 0 after the commit, and `C=$(git -C ../fonoteka.go/plugins/golem15/user rev-list --count 35cf166..HEAD) && test "$C" -ge 4` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>A user sees which devices they signed in, and revoking one ends its session immediately, even if it refreshed its token in the meantime.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking-human">
|
||||
<name>Task 5: Package legitimacy check before installing github.com/pquerna/otp v1.5.0, github.com/go-webauthn/webauthn v0.18.2 and github.com/boombuler/barcode v1.1.0 (runtime dependencies of sm-mfa-plugin only)</name>
|
||||
<read_first>.planning/notes/2026-10-21-mfa-otp-webauthn.md, 19.6-RESEARCH.md (Q1, Q2, Package Legitimacy Audit, Assumption A13)</read_first>
|
||||
<acceptance_criteria>
|
||||
- The SUMMARY records the human's reply verbatim ("approved" or "rejected") under a "Task 5 reply" heading.
|
||||
- Until the reply, `test ! -e ../sm-mfa-plugin/go.mod` exits 0 (plan 02 has not started), and `grep -c 'go-webauthn' go.mod ../fonoteka.go/plugins/golem15/user/go.mod ../fonoteka.go/go.mod | awk -F: '{s+=$2} END {print s}'` prints 0.
|
||||
</acceptance_criteria>
|
||||
<what-built>Nothing installed. Tasks 1-4 are committed locally: the decision note naming the three libraries, and sm-user-plugin's login gates, login and password-changed events, reset-2FA slot and device authorization flow. RESEARCH verified the three modules on proxy.golang.org (pquerna/otp v1.5.0 published 2024-12-31, Apache-2.0, 2,963 stars; go-webauthn/webauthn v0.18.2 published 2026-09-19, BSD-3-Clause, 1,351 stars, pushed 2026-10-20; boombuler/barcode v1.1.0 published 2025-07-22, MIT, 1,563 stars and already a requirement of pquerna/otp), measured the +2.16 MiB binary delta and the nine newly linked modules, and ran govulncheck (clean). The package-legitimacy seam cannot check Go modules, so all three carry [ASSUMED] and need your check (19.4 and 19.5 precedent). They will be required only by sm-mfa-plugin and appear in the module graph of applications that mount it (sm-demo-app, sm-bm-app). Never auto-approved.</what-built>
|
||||
<how-to-verify>
|
||||
1. Open https://pkg.go.dev/github.com/pquerna/otp, https://pkg.go.dev/github.com/go-webauthn/webauthn and https://pkg.go.dev/github.com/boombuler/barcode and their GitHub repositories: confirm the owners, the licences and that v1.5.0, v0.18.2 and v1.1.0 are tagged releases.
|
||||
2. In a scratch directory run `go list -m -json github.com/go-webauthn/webauthn@v0.18.2` (and the same for the other two) and confirm Origin.URL points at the GitHub repository and Origin.Ref at the tag.
|
||||
3. Confirm the three are acceptable as runtime dependencies of sm-mfa-plugin only (never in the framework, sm-user-plugin or fonoteka.go).
|
||||
</how-to-verify>
|
||||
<resume-signal>Reply "approved" to let plan 02 run `go get` for the three pins in sm-mfa-plugin, or "rejected" to stop: plan 02 must then not start, because D-04 locks pquerna/otp and go-webauthn, and the phase returns to /msd-discuss-phase 19.6.</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| first-factor login to token mint | a correct password must not yield a token while a second factor is pending |
|
||||
| sm-user-plugin to other plugins | gates, the admin reset service and event listeners run code from sm-mfa-plugin inside the user plugin's requests and transactions |
|
||||
| unauthenticated device to devices/initiate and devices/status | anyone can create sessions and poll with a token |
|
||||
| signed-in user to devices/authorize, list and revoke | a bearer JWT binds or ends device sessions |
|
||||
| JWT refresh to device revocation | a refreshed device token must stay revocable |
|
||||
| administrator to the Users screen | a privileged action removes a user's second factors |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.6-01 | Elevation of privilege | Login gate consultation (controllers/api_controller.go, CheckLoginGates) | high | mitigate | gate errors answer the opaque 500 and never fall through to the mint; a non-nil result returns before restore, last_seen, mint and event; TestLoginGateSmoke subtests challenge, refusal and gate_error |
|
||||
| T-19.6-02 | Tampering | soft-delete restore before the second factor (Pitfall 21, D-18 A10) | medium | mitigate | the restore and reactivate mail run after the gate; TestLoginGateSmoke soft_deleted_stays |
|
||||
| T-19.6-03 | Spoofing | trusted devices surviving a password change (D-07) | medium | mitigate | PasswordChangedEvent fires inside the password-write transaction from reset, change and admin set, a listener error rolls the write back, the rehash fires none; TestPasswordChangedEventSmoke |
|
||||
| T-19.6-04 | Elevation of privilege | reset_two_factor record action (D-15) | medium | mitigate | new permission without a default role, Danger confirm, cabana's permission check and audit log, Applies false without a published service, Run errors without one; TestResetTwoFactorSlotSmoke |
|
||||
| T-19.6-05 | Tampering | short-code guessing to bind a victim's pending device | medium | mitigate | 31^8 code space from crypto/rand without modulo bias, 5-minute TTL, user-api bucket, guarded pending-to-confirmed UPDATE that requires user_id NULL or the caller |
|
||||
| T-19.6-06 | Spoofing | device-code phishing (attacker shows its QR to a victim who approves it) | medium | accept | D-12c keeps PHP's authorize-with-JWT rule; the device list shows name and IP and revoke signs the device out at once; the frontend is expected to show device name and IP before confirming |
|
||||
| T-19.6-07 | Elevation of privilege | device hand-over race (two polls, two tokens) | high | mitigate | single guarded confirmed-to-used UPDATE; a poll that loses never sends its minted token; TestDeviceFlowSmoke 10 concurrent polls yield one token |
|
||||
| T-19.6-08 | Elevation of privilege | revoked device keeps refreshing (RESEARCH Q7) | high | mitigate | Refresh hands the row's jwt_jti to the new token; revoke blacklists the current jti for the refresh window; TestDeviceRevokeSmoke revoke_after_refresh; blacklist_grace fork documented (Pitfall 15) |
|
||||
| T-19.6-09 | Information disclosure | device tokens at rest and in URLs | medium | mitigate | only the SHA-256 hex is stored (D-06); the framework access log records no paths; reverse-proxy logging of devices/status/{token} is documented in the README; tokens live 5 minutes and are one-time |
|
||||
| T-19.6-10 | Denial of service | unauthenticated devices/initiate filling the table (Pitfall 16) | low | mitigate | user-api bucket (120/min) and a bounded cleanup of stale pending and expired rows on each initiate |
|
||||
| T-19.6-11 | Tampering | attacker-chosen device_name (Pitfall 14) | low | mitigate | trimmed, max 255 with a 422, rendered by clients as text; the parsed user agent and IP shown beside it |
|
||||
| T-19.6-12 | Tampering | core plugin contract (19.4 D-05, CLAUDE.md) | medium | mitigate | only added exports, routes, config keys and one added migration; TestLogin, TestRefresh, TestLogout and the admin tests pass unchanged; fonoteka.go, sm-sociallogin-plugin and sm-oauthserver-plugin compile in every verify |
|
||||
| T-19.6-SC | Tampering | Go module installs | high | mitigate | no module is installed in this plan; the three pins wait for the Task 5 blocking human check (never auto-approved); acceptance criteria grep go.mod files for go-webauthn |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: the note precedes the code; TestLoginGatesSmoke (short), TestLoginGateSmoke, TestLogin and TestRefresh; dependent repositories compile.
|
||||
- Task 2: TestPasswordChangedEventSmoke, TestResetTwoFactorSlotSmoke, TestAdminUserActions, TestAdminUserPassword.
|
||||
- Task 3: TestDeviceCodesSmoke (short), TestDeviceFlowSmoke; only added files under updates/.
|
||||
- Task 4: TestDeviceRevokeSmoke, TestDeviceFlowSmoke, TestRefresh, TestLogout.
|
||||
- Task 5: the human reply recorded; no go.mod requires the libraries before it.
|
||||
- Feedback sampling (19.6-VALIDATION.md): after each commit `go -C ../fonoteka.go vet ./plugins/golem15/user/... && go -C ../fonoteka.go test -short ./plugins/golem15/user/...`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- D-04: decision note on record before any library code; libraries gated by a human check.
|
||||
- D-02 and D-18 A10: Login consults gates after the password step and before restore and mint; unchanged without gates.
|
||||
- D-07 and D-15: password-changed event from three sites; reset slot offered only with a published service.
|
||||
- D-03, D-11, D-12a, D-12b, D-12c, D-16: device flow with PHP's paths and bodies, one-time JWT, revoke across refresh.
|
||||
- Additive: every v0.1.5 test passes, dependent repositories compile, nothing pushed.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-01-SUMMARY.md` when done, with the commits in order and their repositories (planning docs first), the base commit of sm-user-plugin the phase started from (plan 04's additive check uses it), the final exported identifiers and config keys (plans 02 and 03 call them), the Task 5 reply verbatim, which Docker-backed tests ran or were NOT RUN, and any deviation from this plan's names. Post a short comment on the Phase 19.6 milestone task in g15office (`avoroeb4`, .planning/G15OFFICE.md) naming plan 19.6-01's results; if the g15office tool is not available in this session, record that in the SUMMARY for the orchestrator.
|
||||
</output>
|
||||
@@ -0,0 +1,356 @@
|
||||
---
|
||||
phase: 19.6-two-factor-auth-and-device-flow-in-sm-user-plugin
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["19.6-01"]
|
||||
files_modified:
|
||||
- ../sm-mfa-plugin/.gitignore
|
||||
- ../sm-mfa-plugin/go.mod
|
||||
- ../sm-mfa-plugin/go.sum
|
||||
- ../sm-mfa-plugin/go.work
|
||||
- ../sm-mfa-plugin/plugin.go
|
||||
- ../sm-mfa-plugin/routes.go
|
||||
- ../sm-mfa-plugin/buckets.go
|
||||
- ../sm-mfa-plugin/mail.go
|
||||
- ../sm-mfa-plugin/admin_settings.go
|
||||
- ../sm-mfa-plugin/admin_permissions.go
|
||||
- ../sm-mfa-plugin/config/config.yaml
|
||||
- ../sm-mfa-plugin/lang/en/lang.yaml
|
||||
- ../sm-mfa-plugin/lang/pl/lang.yaml
|
||||
- ../sm-mfa-plugin/views/mail/two_factor_code.htm
|
||||
- ../sm-mfa-plugin/views/mail/two_factor_code-en.htm
|
||||
- ../sm-mfa-plugin/classes/config.go
|
||||
- ../sm-mfa-plugin/classes/gate.go
|
||||
- ../sm-mfa-plugin/classes/challenge.go
|
||||
- ../sm-mfa-plugin/classes/login.go
|
||||
- ../sm-mfa-plugin/classes/totp.go
|
||||
- ../sm-mfa-plugin/classes/qr.go
|
||||
- ../sm-mfa-plugin/classes/recovery.go
|
||||
- ../sm-mfa-plugin/classes/email.go
|
||||
- ../sm-mfa-plugin/classes/trusted.go
|
||||
- ../sm-mfa-plugin/classes/methods.go
|
||||
- ../sm-mfa-plugin/classes/events.go
|
||||
- ../sm-mfa-plugin/classes/admin.go
|
||||
- ../sm-mfa-plugin/controllers/helpers.go
|
||||
- ../sm-mfa-plugin/controllers/challenge.go
|
||||
- ../sm-mfa-plugin/controllers/management.go
|
||||
- ../sm-mfa-plugin/models/registry.go
|
||||
- ../sm-mfa-plugin/models/two_factor_method.go
|
||||
- ../sm-mfa-plugin/models/two_factor_challenge.go
|
||||
- ../sm-mfa-plugin/models/two_factor_recovery_code.go
|
||||
- ../sm-mfa-plugin/models/trusted_device.go
|
||||
- ../sm-mfa-plugin/models/settings.go
|
||||
- ../sm-mfa-plugin/models/string_list.go
|
||||
- ../sm-mfa-plugin/models/settings/fields.yaml
|
||||
- ../sm-mfa-plugin/updates/registry.go
|
||||
- ../sm-mfa-plugin/updates/202610220001_create_golem15_user_two_factor_methods.go
|
||||
- ../sm-mfa-plugin/updates/202610220002_create_golem15_user_two_factor_challenges.go
|
||||
- ../sm-mfa-plugin/updates/202610220003_create_golem15_user_two_factor_recovery_codes.go
|
||||
- ../sm-mfa-plugin/updates/202610220004_create_golem15_user_trusted_devices.go
|
||||
- ../sm-mfa-plugin/updates/202610220005_create_golem15_mfa_settings.go
|
||||
- ../sm-mfa-plugin/testmain_test.go
|
||||
- ../sm-mfa-plugin/harness_test.go
|
||||
- ../sm-mfa-plugin/tracer_smoke_test.go
|
||||
- ../sm-mfa-plugin/challenge_smoke_test.go
|
||||
- ../sm-mfa-plugin/trusted_settings_smoke_test.go
|
||||
- ../sm-mfa-plugin/README.md
|
||||
autonomous: true
|
||||
requirements: [SC-1, SC-2, SC-4, D-01, D-04, D-05, D-06, D-07, D-09, D-10, D-12, D-13, D-15, D-16, D-18, D-19]
|
||||
estimate:
|
||||
tokens: 340000
|
||||
raw_tokens: 340000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-01: a new local repository sm-mfa-plugin (module git.golem15.com/golem15/sm-mfa-plugin, package mfa, plugin ID golem15.mfa) requires golem15.user; only its go.mod requires pquerna/otp v1.5.0 and boombuler/barcode v1.1.0 (go-webauthn arrives in plan 03); its Boot needs no database, cache or mailer."
|
||||
- "SC-2, D-02, D-05: with the plugin mounted and TOTP enabled, POST /_user/api/v1/login answers 200 {two_factor_required:true, challenge_token (64 characters), available_methods (the user's enabled methods, unfiltered by the global list), expires_at (+00:00)} and no token; the challenge routes /_user/api/v1/2fa/{verify,recovery,email/send} are unauthenticated and share the 2fa-verify bucket of 10 requests per minute per IP (the 11th answers 429)."
|
||||
- "D-05: POST 2fa/verify, 2fa/recovery and 2fa/email/send answer PHP's validation 422 bodies, PHP's 401 bodies ({\"error\":\"Invalid verification code.\",\"two_factor_failed\":true}, {\"error\":\"Invalid recovery code.\",\"two_factor_failed\":true}, {\"error\":\"Invalid or expired challenge.\"}) and on success {token, user} from the user plugin's helpers, followed by the login event and golem15.user.2fa.verified; a challenge lives two_factor_challenge_ttl minutes (5), allows five verifications (each verify increments attempts in one guarded UPDATE, recorded deviation A5) and completes at most once, so parallel verifies mint at most one token."
|
||||
- "D-04, D-05: POST 2fa/totp/setup answers {secret, qr_code_svg, qr_code_url} (otpauth URL with the app name as issuer and the email as account; SVG 200x200 with a viewBox, drawn from boombuler/barcode's matrix); totp/confirm verifies with a one-step window and stores the secret encrypted (lagoon.Encrypted); a TOTP code is accepted once per time step (last counter in metadata, Pitfall 5); DELETE totp disables it."
|
||||
- "D-05, D-19: recovery codes come in sets of 10 in the form XXXXX-XXXXX over 23456789ABCDEFGHJKMNPQRSTUVWXYZ, stored as bcrypt hashes and consumed once through a guarded UPDATE; GET recovery-codes?password= answers only the remaining count; POST recovery-codes/regenerate replaces the set in one transaction and returns the new codes; when totp/confirm or email/enable creates the first set (no unused code existed), the response adds recovery_codes, otherwise it is PHP's body unchanged."
|
||||
- "D-05, D-18 (A8, A9): the email code is 6 digits stored as a bcrypt hash and mailed through golem15.mfa::mail.two_factor_code (-en for English) with name, code and expiry_minutes = min(two_factor_email_code_ttl, remaining challenge minutes); email/send and verify method=email work only when the user's email method is enabled (else 401 {\"error\":\"Invalid or expired challenge.\"}) and at most 3 sends per challenge."
|
||||
- "D-18 (A6): after 10 failed second-factor verifications of one user within 15 minutes, counted from challenge rows in the database, new challenges are refused at login with 401 {\"error\":\"Invalid or expired challenge.\"} and every verify endpoint answers its generic 401, until the window passes; failed challenge rows are kept for the window."
|
||||
- "D-06: challenge and trusted-device tokens are stored as SHA-256 hex in PHP's token columns, the TOTP secret as lagoon.Encrypted ciphertext, recovery and email codes as bcrypt hashes; no token, code or secret is logged."
|
||||
- "D-07, D-10: with trusted_device_enabled on, a verify with trust_device adds trusted_device_token, a login sending it skips the challenge until trusted_device_ttl_days (30) pass; any PasswordChangedEvent and disabling the user's last method delete the user's trusted devices (the latter also the recovery codes, as PHP); DELETE trusted-devices/{id} and DELETE trusted-devices revoke them."
|
||||
- "D-12, D-13, D-09 (setting): an admin Settings screen (code mfa, permission golem15.mfa.access_settings) holds two_factor_mode (disabled|optional|enforced, default optional), two_factor_available_methods (default [totp,email], allow-list totp,email,webauthn), two_factor_enforce_groups (existing group codes), two_factor_email_code_ttl 10, two_factor_challenge_ttl 5, two_factor_passwordless_login off, trusted_device_enabled off, trusted_device_ttl_days 30 and two_factor_social_login off; config supplies each default; a saved row applies on the next request; only mode disabled exempts a user (no child exemption)."
|
||||
- "D-15: the plugin publishes userclasses.TwoFactorAdmin; the user plugin's reset_two_factor action then disables every method, deletes recovery codes and trusted devices and fires golem15.user.2fa.disabled inside cabana's transaction."
|
||||
- "D-16: migrations create golem15_user_two_factor_methods, golem15_user_two_factor_challenges, golem15_user_two_factor_recovery_codes and golem15_user_trusted_devices with PHP's names and columns (plus the challenge email_sends counter) and golem15_mfa_settings with its seeded row 1."
|
||||
artifacts:
|
||||
- path: "../sm-mfa-plugin/plugin.go"
|
||||
provides: "golem15.mfa plugin, gate registration, published TwoFactorAdmin, listeners"
|
||||
contains: "golem15.mfa"
|
||||
- path: "../sm-mfa-plugin/classes/challenge.go"
|
||||
provides: "challenge creation and guarded attempt and completion UPDATEs"
|
||||
contains: "attempts < max_attempts"
|
||||
- path: "../sm-mfa-plugin/classes/qr.go"
|
||||
provides: "QRSVG over boombuler/barcode"
|
||||
contains: "viewBox"
|
||||
- path: "../sm-mfa-plugin/buckets.go"
|
||||
provides: "2fa-verify bucket"
|
||||
contains: "2fa-verify"
|
||||
- path: "../sm-mfa-plugin/tracer_smoke_test.go"
|
||||
provides: "TestTracerSmoke and TestQRSVGSmoke"
|
||||
contains: "TestTracerSmoke"
|
||||
key_links:
|
||||
- from: "../sm-mfa-plugin/plugin.go"
|
||||
to: "../fonoteka.go/plugins/golem15/user/classes/login_gate.go"
|
||||
via: "Boot adds the mfa gate to the published *userclasses.LoginGates"
|
||||
pattern: "LoginGates"
|
||||
- from: "../sm-mfa-plugin/classes/login.go"
|
||||
to: "../fonoteka.go/plugins/golem15/user/controllers/registration.go"
|
||||
via: "FinishLogin, IssueToken, APIArray and FireLoginEvent after a verified factor"
|
||||
pattern: "FireLoginEvent"
|
||||
- from: "../sm-mfa-plugin/plugin.go"
|
||||
to: "../fonoteka.go/plugins/golem15/user/classes/events.go"
|
||||
via: "PasswordChangedEvent listener deletes trusted devices"
|
||||
pattern: "Listen\\[\\*userclasses.PasswordChangedEvent\\]"
|
||||
- from: "../sm-mfa-plugin/routes.go"
|
||||
to: "../sm-mfa-plugin/buckets.go"
|
||||
via: "challenge group uses throttle:2fa-verify"
|
||||
pattern: "throttle:2fa-verify"
|
||||
prohibitions:
|
||||
- statement: "No token, code, TOTP secret, password or recovery code value is written to a log line or an error string."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "YAML is parsed with goccy/go-yaml only; the archived YAML v3 module is never imported by plugin code."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No notification mail on 2FA enable or disable, no per-group exemption and no Winter import (CONTEXT Deferred Ideas); no child-account exemption (D-13)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed and no remote repository is created in this plan (plan 03 asks for the go-ahead)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "sm-user-plugin is not changed by this plan except to fix a defect a task exposes, in its own commit with its README change, recorded as a deviation."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story invented, see plan 01): `sm-user-plugin` has the PHP user plugin's second factors and device login. CONTEXT D-01 puts the second factors in the new shared plugin `sm-mfa-plugin`, which requires the user plugin and registers itself on plan 01's login gate.
|
||||
|
||||
This plan's slice (plan 2 of 4, wave 2, "sm-mfa-plugin core"): the whole plugin except WebAuthn. The tracer proves the TOTP path end to end: a user turns TOTP on, the next login is challenged, and the TOTP code completes it. Expansion slices add recovery codes, email codes and the D-18 hardening, then trusted devices, the admin settings screen, revocation on password change and the admin reset service.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip). `must_haves` derive from SC-1..SC-4 and D-01..D-20.
|
||||
|
||||
## Decisions taken in this plan (recorded assumptions and Claude's discretion)
|
||||
|
||||
- Layout copies sm-sociallogin-plugin: package `mfa` at the root (plugin.go, routes.go, buckets.go, mail.go, admin_settings.go, admin_permissions.go), `classes`, `controllers`, `models` (with `models/settings/fields.yaml`), `updates`, `config`, `lang`, `views/mail`; README in sociallogin's structure.
|
||||
- Config keys use PHP's setting names under `golem15.mfa.`: `two_factor_mode`, `two_factor_available_methods`, `two_factor_enforce_groups`, `two_factor_email_code_ttl`, `two_factor_challenge_ttl`, `two_factor_passwordless_login`, `trusted_device_enabled`, `trusted_device_ttl_days`, `two_factor_social_login` (new, D-09), plus `issuer` (default app.name), `failure_cap.max` (10) and `failure_cap.window_minutes` (15) for D-18 A6. The settings row (table `golem15_mfa_settings`, nullable columns, NULL means the config default) overrides per request.
|
||||
- Settings field types per RESEARCH Q8: `dropdown` for the mode, `taglist` with `mode: array` on a JSONB column (type `models.StringList` with sql.Scanner and driver.Valuer) plus `BeforeValidate` allow-lists for methods (totp, email, webauthn) and enforce groups (existing group codes), `number`, `switch`. Never checkboxlist or radio (Pitfall 19).
|
||||
- The mode is a typed string with an allow-list; an unknown stored value behaves as `optional` and is refused by validation (the PHP USER-005 type confusion, mapped in plan 04).
|
||||
- Management routes carry `jwt.auth` per route as PHP's group does, and sit in a group with `throttle:user-api` (hardening beyond PHP: the password-confirming recovery-code routes otherwise have no rate limit); PHP's 401 `{"error":"Unauthorized"}` is the handler's fallback when no principal resolves.
|
||||
- Five real attempts per challenge (RESEARCH A5; PHP's increment-then-check gave four). The response shapes are identical either way.
|
||||
- Per-user failure cap (D-18 A6): failures in the window = the sum over the user's challenge rows updated within `failure_cap.window_minutes` of `attempts` for uncompleted rows and `attempts - 1` for completed ones; at `failure_cap.max` or more the gate refuses with Status 401 and the verify endpoints answer their generic 401. Creating a challenge deletes only the user's rows whose expires_at is older than the window, so failures stay counted (PHP deleted expired rows eagerly).
|
||||
- The email send counter is a new column `email_sends SMALLINT NOT NULL DEFAULT 0` on the challenge table (D-18 A8 needs it; D-16's PHP columns are otherwise unchanged).
|
||||
- `available_methods` in a challenge lists the user's enabled methods, dropping webauthn when no enabled credential exists (PHP), unfiltered by the global list. Status `enforced` is informational as in PHP (RESEARCH Open Question 2): mode enforced and either no enforce groups or a shared group code.
|
||||
- Recovery codes are always accepted through `/2fa/recovery`, whatever the available list says (D-05). Recovery input is uppercased, spaces removed, hyphen kept, and capped at 32 characters before any bcrypt compare (Pitfall 9).
|
||||
- The mail template has no layout header (postcard's default layout; the user plugin's `user` alias is not visible to another plugin, Pitfall 18); vars are passed in both cases (`name`/`Name`, `code`/`Code`, `expiry_minutes`/`ExpiryMinutes`).
|
||||
- Admin reset clears each method's secret and metadata as it disables it (PHP left them; a reset must not let an old secret return when the method is re-enabled).
|
||||
- Migration IDs `202610220001`..`202610220005` in this plan and `202610220006` (WebAuthn credentials) in plan 03; they may still change until plan 03's release, after which they are frozen.
|
||||
- The go.mod requires `git.golem15.com/golem15/sm-user-plugin v0.1.5` and the framework tag sm-sociallogin-plugin requires, with go.work replacing both by the local checkouts (sociallogin precedent); plan 03 moves the user-plugin require to the released tag.
|
||||
- Context budget (estimate 340k, 3 tasks; accepted under the project's lean-planning rule): if the first context runs low after Task 2, the executor writes `19.6-02-SUMMARY.md` as a draft (`Status: draft (hand-off after Task 2)`) and a fresh agent runs Task 3 from this plan, the draft and the repository on disk.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Repository `../sm-mfa-plugin` (branch master, no remote until plan 03), module `git.golem15.com/golem15/sm-mfa-plugin`, package `mfa`.
|
||||
- Plugin: `mfa.PluginID` (`golem15.mfa`), `mfa.Plugin` (ID, Requires `golem15.user`, Register, Boot, ConfigFS, LangFS, Routes, Buckets, Migrations, Models, MailTemplatesFS, MailTemplates, AdminFS, Settings, Permissions), `mfa.BucketTwoFactorVerify` (`2fa-verify`), `mfa.SettingsCode` (`mfa`), `mfa.PermissionAccessSettings` (`golem15.mfa.access_settings`).
|
||||
- classes: `Config`, `LoadConfig(app *backpack.App) (Config, error)`, `Effective` and `LoadEffective(ctx context.Context, app *backpack.App, db *gorm.DB) (Effective, error)`; `Gate` (implements `userclasses.LoginGate`); `NewChallengeToken() (string, error)`, `HashToken(raw string) string` (delegates to `userclasses.HashAPIToken`), `CreateChallenge`, `FindChallenge`, `BeginAttempt`, `CompleteChallenge`, `FailureCapReached`; `NewTOTPSecret`, `TOTPURL`, `VerifyTOTP(secret, code string, at time.Time, lastCounter int64) (int64, bool, error)`; `QRSVG(text string, size int) (string, error)`; `RecoveryCodeAlphabet`, `GenerateRecoveryCodes`, `NormalizeRecoveryCode`, `ConsumeRecoveryCode`, `RemainingRecoveryCodes`; `SendEmailCode`, `MaskEmail`, `VerifyEmailCode`; `IssueTrustedDevice`, `CheckTrustedDevice`, `RevokeTrustedDevices`, `ParseDeviceName`; `EnabledMethods`, `EnableMethod`, `DisableMethod`, `Status`; `CompleteLogin`; `Admin` (implements `userclasses.TwoFactorAdmin`); events `ChallengedEvent`, `FailedEvent`, `RecoveryUsedEvent`, `VerifiedEvent`, `EnabledEvent`, `DisabledEvent`, `RecoveryRegeneratedEvent` with constants `ChallengedEventName` (`golem15.user.2fa.challenged`), `FailedEventName`, `RecoveryUsedEventName`, `VerifiedEventName`, `EnabledEventName`, `DisabledEventName`, `RecoveryRegeneratedEventName`; errors `ErrInvalidChallenge`.
|
||||
- controllers: `Verify`, `Recovery`, `EmailSend`, `Status`, `TOTPSetup`, `TOTPConfirm`, `TOTPDisable`, `EmailEnable`, `EmailDisable`, `RecoveryCodesCount`, `RecoveryCodesRegenerate`, `TrustedDeviceRevoke`, `TrustedDevicesRevokeAll`.
|
||||
- models: `TwoFactorMethod`, `TwoFactorChallenge`, `TwoFactorRecoveryCode`, `TrustedDevice`, `Settings`, `StringList`, `LoadSettings(ctx context.Context, db *gorm.DB) (*Settings, error)`, constants `MethodTOTP`, `MethodEmail`, `MethodWebAuthn`, `MethodRecovery`, `ModeDisabled`, `ModeOptional`, `ModeEnforced`, `LangPrefix`.
|
||||
- migrations `202610220001_create_golem15_user_two_factor_methods`, `202610220002_create_golem15_user_two_factor_challenges`, `202610220003_create_golem15_user_two_factor_recovery_codes`, `202610220004_create_golem15_user_trusted_devices`, `202610220005_create_golem15_mfa_settings`.
|
||||
- Routes: challenge group `/_user/api/v1/2fa` with `throttle:2fa-verify`: `POST verify`, `POST recovery`, `POST email/send`; management group `/_user/api/v1/2fa` with `throttle:user-api` and `jwt.auth` per route: `GET status`, `POST totp/setup`, `POST totp/confirm`, `DELETE totp`, `POST email/enable`, `DELETE email`, `GET recovery-codes`, `POST recovery-codes/regenerate`, `DELETE trusted-devices/{id}`, `DELETE trusted-devices`.
|
||||
- Mail templates `golem15.mfa::mail.two_factor_code`, `golem15.mfa::mail.two_factor_code-en`; settings screen `mfa`; permission `golem15.mfa.access_settings`; lang keys `golem15.mfa::lang.settings.*`, `golem15.mfa::lang.permissions.*`, `golem15.mfa::lang.mail.*`.
|
||||
- Smoke tests `TestQRSVGSmoke`, `TestTracerSmoke`, `TestChallengeSmoke`, `TestTrustedSettingsSmoke`; harness `freshDB`, `bootMFA`.
|
||||
|
||||
<objective>
|
||||
Build sm-mfa-plugin without WebAuthn: a TOTP tracer from setup through challenged login to a verified token, then recovery and email codes with the D-18 hardening, then trusted devices, the settings screen, revocation on password change and the admin reset service.
|
||||
|
||||
Purpose: D-01, D-04 (TOTP and QR), D-05, D-06, D-07, D-09 (setting), D-10, D-12, D-13, D-15 (service), D-16 (five tables), D-18 (A6, A8, A9), D-19; SC-1 (TOTP, email, recovery, trusted devices), SC-2 (the challenge step behind 2fa-verify), SC-4 (models and migrations).
|
||||
Output: local commits in the new repository, each green.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-CONTEXT.md
|
||||
@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-RESEARCH.md
|
||||
@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-PATTERNS.md
|
||||
@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-01-SUMMARY.md
|
||||
@.planning/notes/2026-10-21-mfa-otp-webauthn.md
|
||||
|
||||
Repos and concurrency: this plan creates and writes ../sm-mfa-plugin (a sibling of summercms.go, like ../sm-sociallogin-plugin). It reads ../fonoteka.go/plugins/golem15/user (the sm-user-plugin checkout go.work replaces) and summercms.go and changes neither; a defect in sm-user-plugin that blocks a task is fixed there in its own commit with its README change and recorded as a deviation. No co-author tags; stage by explicit path; one logical change per commit. Keep `go vet ./...` and `go test -short ./...` green in the plugin at every commit. Docker-backed tests skip under -short; run them through `sudo -u golem` if the socket is denied, and record NOT RUN when containers are unreachable (Phase 19.8 saw that on this host).
|
||||
|
||||
PHP source of truth (read-only, /media/nvme/dev/jz/wavepath.org/plugins/golem15/user): controllers/TwoFactorApiController.php (validation :37-218, management :229-568, withTrustedDevice :578-588), classes/twofactor/TwoFactorService.php (isEnabledForUser :33-47, getEnabledMethods :54-69, isMethodAvailable :82-90, isEnforcedForUser :95-109, createChallenge and verifyChallenge :116-205, confirmTotp :228-245, enableEmailMethod :250-262, disableMethod :267-294, removeWebAuthnCredential :322-356, trusted devices :455-530, getStatus :549-594, ensureRecoveryCodes :599-608), classes/twofactor/TotpMethod.php (:34-72), classes/twofactor/EmailMethod.php (:52-98), models/TwoFactorChallenge.php (:105-133), models/TwoFactorRecoveryCode.php (:49-112), models/TrustedDevice.php (:36-124), models/Settings.php (:40-63), models/settings/fields.yaml (:90-160), views/mail/two_factor_code.htm, updates/v3.0.0 and v3.1.0 migrations, routes.php (:8-18, :303-337, :372-453). RESEARCH Q4 quotes every body; the PHP files break ties.
|
||||
|
||||
<interfaces>
|
||||
- sm-user-plugin after plan 01 (19.6-01-SUMMARY.md has the final names): `userclasses.LoginGate` (`CheckLogin(ctx, a LoginAttempt) (*LoginGateResult, error)`), `userclasses.LoginGates` (published in the user plugin's Register; add the gate in Boot), `userclasses.LoginAttempt{User, Source, Fields, Request}`, `userclasses.LoginGateResult{Status, Body}`, `userclasses.LoginSourcePassword`, `LoginSourceSocial`, `LoginSourceTwoFactor`, `userclasses.PasswordChangedEvent{User, Tx, Source}`, `userclasses.TwoFactorAdmin`, `userclasses.HashAPIToken`, `userclasses.MailTemplate`, `userclasses.ResolveMailLocale`, `userclasses.UserGroupCodes`, `usercontrollers.FinishLogin`, `IssueToken`, `APIArray`, `RemoteIP`, `FireLoginEvent`, `usermodels.User`, `usermodels.UserGroup` (group codes for the enforce-groups allow-list).
|
||||
- sm-sociallogin-plugin (template, read-only): plugin.go (assertions, embeds, Register/Boot without DB, typed Publish), routes.go, config.go and classes/config.go (LoadConfig), admin_settings.go, admin_permissions.go, models/settings.go (singleton row 1, LoadSettings, BeforeValidate with cabana.ValidationError), models/registry.go, updates/registry.go (`run` helper), updates/202610200001_create_golem15_sociallogin_settings.go, controllers/helpers.go (writeJSON with Cache-Control no-cache, private), testmain_test.go (flag.Parse, testing.Short, postgres:16-alpine, freshDB), harness_test.go (bootSocialWith: freshDB, lagoon.Use, httptest.NewUnstartedServer, temp config dir with app.yaml, party.Activate; blank import of the user plugin), go.mod, go.work, README.md.
|
||||
- Other analogs: ../sm-oauthserver-plugin/buckets.go (:29-111, per-IP bucket with surf.ClientIP and surf.TrustedProxies), ../sm-paymentgateway-plugin/mail.go (MailTemplatesFS and MailTemplates), ../sm-chatbots-plugin/boot.go (:82-98 typed listener using ev.Tx), ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (writeValidation :946, sendTemplateVars and mailVars, bcrypt cost read :102-105), ../fonoteka.go/plugins/golem15/user/controllers/api_tokens.go (apiTokenDeps), ../fonoteka.go/plugins/golem15/user/views/mail/restore-en.htm (template front matter).
|
||||
- Framework: `bouncer.HashPassword(cost, plain)`, `bouncer.CheckPassword(hash, plain)`, `bouncer.User(ctx)`, `lagoon.Encrypted`, `lagoon.Validate`, `wire.Time`, `wire.Slice`, `wire.WriteJSON`, `surf.Bucket{Name, Max, Decay, Key}`, `surf.ClientIP`, `surf.TrustedProxies`, `postcard.Mailer`, `cabana.ValidationError`, `pact.SettingsItem`, `app.Events.Fire`, `app.Events.Listen`.
|
||||
- Libraries (after plan 01 Task 5 approval only): `github.com/pquerna/otp` (`totp.Generate`, `totp.GenerateCodeCustom`, `hotp.ValidateCustom`, `otp.Key.URL()`), `github.com/boombuler/barcode/qr` (`qr.Encode(text, qr.M, qr.Auto)`).
|
||||
- RESEARCH Code Examples 1-4, Patterns 2, 5, Pitfalls 1, 4, 5, 9, 10, 11, 12, 17, 18, 19, 20.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a user turns TOTP on through setup (QR as SVG) and confirm, the next password login answers the challenge instead of a token, and the current TOTP code completes it with {token, user} exactly once behind the 2fa-verify bucket</name>
|
||||
<precondition>19.6-01-SUMMARY.md records the Task 5 reply "approved"; `test ! -e ../sm-mfa-plugin` exits 0 or the directory holds only this plan's earlier work.</precondition>
|
||||
<reversibility rating="costly">D-01: the module path, plugin ID golem15.mfa, the /_user/api/v1/2fa routes and the golem15.mfa config keys become what applications' summer.yaml, config and frontends reference once plan 03 releases the plugin.</reversibility>
|
||||
<files>../sm-mfa-plugin/.gitignore, ../sm-mfa-plugin/go.mod, ../sm-mfa-plugin/go.sum, ../sm-mfa-plugin/go.work, ../sm-mfa-plugin/plugin.go, ../sm-mfa-plugin/routes.go, ../sm-mfa-plugin/buckets.go, ../sm-mfa-plugin/config/config.yaml, ../sm-mfa-plugin/lang/en/lang.yaml, ../sm-mfa-plugin/lang/pl/lang.yaml, ../sm-mfa-plugin/classes/config.go, ../sm-mfa-plugin/classes/gate.go, ../sm-mfa-plugin/classes/challenge.go, ../sm-mfa-plugin/classes/login.go, ../sm-mfa-plugin/classes/totp.go, ../sm-mfa-plugin/classes/qr.go, ../sm-mfa-plugin/classes/methods.go, ../sm-mfa-plugin/classes/events.go, ../sm-mfa-plugin/controllers/helpers.go, ../sm-mfa-plugin/controllers/challenge.go, ../sm-mfa-plugin/controllers/management.go, ../sm-mfa-plugin/models/registry.go, ../sm-mfa-plugin/models/two_factor_method.go, ../sm-mfa-plugin/models/two_factor_challenge.go, ../sm-mfa-plugin/updates/registry.go, ../sm-mfa-plugin/updates/202610220001_create_golem15_user_two_factor_methods.go, ../sm-mfa-plugin/updates/202610220002_create_golem15_user_two_factor_challenges.go, ../sm-mfa-plugin/testmain_test.go, ../sm-mfa-plugin/harness_test.go, ../sm-mfa-plugin/tracer_smoke_test.go, ../sm-mfa-plugin/README.md</files>
|
||||
<read_first>19.6-01-SUMMARY.md (Task 5 reply, final user-plugin identifiers), .planning/notes/2026-10-21-mfa-otp-webauthn.md, ../sm-sociallogin-plugin/plugin.go, ../sm-sociallogin-plugin/routes.go, ../sm-sociallogin-plugin/config.go, ../sm-sociallogin-plugin/classes/config.go, ../sm-sociallogin-plugin/config/config.yaml, ../sm-sociallogin-plugin/go.mod, ../sm-sociallogin-plugin/go.work, ../sm-sociallogin-plugin/testmain_test.go, ../sm-sociallogin-plugin/harness_test.go, ../sm-sociallogin-plugin/controllers/helpers.go, ../sm-sociallogin-plugin/updates/registry.go, ../sm-sociallogin-plugin/models/registry.go, ../sm-sociallogin-plugin/README.md, ../sm-oauthserver-plugin/buckets.go, ../fonoteka.go/plugins/golem15/user/classes/login_gate.go, ../fonoteka.go/plugins/golem15/user/controllers/registration.go, ../fonoteka.go/plugins/golem15/user/updates/202610040001_create_api_tokens.go, ../fonoteka.go/plugins/golem15/user/models/api_token.go, modules/lagoon/encrypted.go, 19.6-RESEARCH.md (Q1, Q2, Q4 login challenge, challenge endpoints, status, TOTP, tables; Patterns 1-2; Code Examples 2-4; Pitfalls 4, 5, 17, 20), /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/classes/twofactor/TotpMethod.php, /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/controllers/TwoFactorApiController.php (:37-66, :229-300)</read_first>
|
||||
<action>Repository (D-01): `git init -b master ../sm-mfa-plugin`, no remote. go.mod: `module git.golem15.com/golem15/sm-mfa-plugin`, `go 1.27.0`, the framework and sm-user-plugin requires as this plan's Decisions state, then `go get github.com/pquerna/otp@v1.5.0 github.com/boombuler/barcode@v1.1.0` and `go mod tidy` (go-webauthn is installed in plan 03). go.work: `use .`, replace the framework with ../summercms.go and sm-user-plugin with ../fonoteka.go/plugins/golem15/user (sociallogin's go.work). .gitignore for test binaries and coverage files.
|
||||
|
||||
plugin.go (Pitfall 20): `PluginID = "golem15.mfa"`, interface assertions (party.Plugin, pact.HasConfig, pact.HasLang, pact.HasRoutes, pact.HasMigrations, pact.HasModels, surf.BucketProvider), `//go:embed config` and `lang`, Requires `golem15.user`, Register stores the app, Boot runs LoadConfig (config only) and adds `&classes.Gate{App: app}` to the `*userclasses.LoginGates` it looks up (an error when the registry is missing, since golem15.user always publishes it), `init() { party.Register(&Plugin{}) }`. buckets.go: `BucketTwoFactorVerify = "2fa-verify"`, Max 10, Decay one minute, keyed `"2fa-verify-ip:" + surf.ClientIP(r, trusted)` with trusted proxies from the app config (oauthserver's per-IP bucket). config/config.yaml with the oauthserver header style (each key's SUMMER_GOLEM15__MFA__... env form) and the defaults listed in Decisions; classes/config.go `Config`, `LoadConfig`, and `Effective`/`LoadEffective` returning the config values for now (Task 3 overlays the settings row without changing the signature).
|
||||
|
||||
Migrations (raw SQL, D-16, RESEARCH Q4 tables): `golem15_user_two_factor_methods` (id BIGSERIAL PK, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, method VARCHAR(20) NOT NULL, is_enabled BOOLEAN NOT NULL DEFAULT TRUE, secret TEXT NULL, metadata JSONB NULL, last_used_at TIMESTAMPTZ NULL, created_at, updated_at, UNIQUE (user_id, method), index (user_id, is_enabled)); `golem15_user_two_factor_challenges` (id BIGSERIAL PK, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, token VARCHAR(64) NOT NULL UNIQUE (SHA-256 hex, D-06), method VARCHAR(20) NULL, code VARCHAR(255) NULL, attempts SMALLINT NOT NULL DEFAULT 0, max_attempts SMALLINT NOT NULL DEFAULT 5, email_sends SMALLINT NOT NULL DEFAULT 0, ip_address VARCHAR(45) NULL, user_agent TEXT NULL, expires_at TIMESTAMPTZ NOT NULL, completed_at TIMESTAMPTZ NULL, created_at, updated_at, index (user_id, expires_at)); rollbacks drop them. Models with explicit column tags: `TwoFactorMethod` (Secret `lagoon.Encrypted` with `json:"-"`, Metadata as a JSON map), `TwoFactorChallenge` (TokenHash `gorm:"column:token"` `json:"-"`, Code `json:"-"`); method and mode constants in models.
|
||||
|
||||
classes: `NewChallengeToken` (48 crypto/rand bytes, base64url, 64 characters) and `HashToken` (userclasses.HashAPIToken). `CreateChallenge(ctx, db, user, ip, userAgent, ttl)` deletes the user's rows whose expires_at is older than the failure window, inserts a row with the hash and returns the plaintext token and the row. `FindChallenge(ctx, db, token)` returns the row only when uncompleted, unexpired and attempts below max. `BeginAttempt(ctx, db, token)` is RESEARCH Code Example 2's guarded UPDATE (`SET attempts = attempts + 1, updated_at = NOW() WHERE token = ? AND completed_at IS NULL AND expires_at > NOW() AND attempts < max_attempts`) returning the row or `ErrInvalidChallenge` when RowsAffected is 0; `CompleteChallenge(ctx, tx, id)` is the guarded `SET completed_at = NOW() WHERE id = ? AND completed_at IS NULL` that fails unless exactly one row changed. totp.go: `NewTOTPSecret(issuer, account)` through `totp.Generate` (pquerna defaults: SHA1, 6 digits, 30 s, 20-byte secret), `TOTPURL`, and `VerifyTOTP(secret, code, at, lastCounter)` per RESEARCH Code Example 3 (counters at-1, at, at+1 with `hotp.ValidateCustom`; a match at or below lastCounter is a replay and fails; returns the matched counter). qr.go `QRSVG(text, size)` per RESEARCH Code Example 4 (qr.Encode level M, 4-module quiet zone, one path of unit squares, width and height = size, viewBox over the module grid, crispEdges). methods.go: `EnabledMethods(ctx, db, userID)` (PHP getEnabledMethods), `EnableMethod`, minimal `Status` (enabled, enforced, methods; Task 3 completes the PHP shape). gate.go `Gate.CheckLogin`: Source password (social waits for Task 3's toggle; passkey, two_factor and device never challenge); mode disabled → nil; no enabled method → nil; else CreateChallenge with the configured TTL, fire ChallengedEvent, return Status 200 with Body `{"two_factor_required": true, "challenge_token": token, "available_methods": wire.Slice(methods), "expires_at": wire.Time(expires)}`. login.go `CompleteLogin(ctx, app, db, r, user)`: `usercontrollers.FinishLogin` (restores a deactivated account only now, D-18 A10), `IssueToken`, `APIArray`, `FireLoginEvent` with `userclasses.LoginSourceTwoFactor` (logged on error), returning `{"token", "user"}`. events.go: the typed structs and PHP names for the events this plan fires (Artifacts list), each carrying User, Method and Tx where a transaction exists.
|
||||
|
||||
controllers (handler factories over *backpack.App; helpers.go copies sociallogin's writeJSON and the user plugin's writeValidation): `TOTPSetup` (jwt.auth; 403 `{"error":"TOTP is not available."}` unless PHP isMethodAvailable holds: mode not disabled and totp in the available methods; answers `{secret, qr_code_svg, qr_code_url}` with issuer `golem15.mfa.issuer` or app.name and account the user's email); `TOTPConfirm` (fields `secret` required string and base32 with length 16 to 64, `code` required string size 6, PHP 422 shapes; VerifyTOTP at now with lastCounter -1 else 422 `{"error":"Invalid verification code."}`; upsert the totp row enabled with the encrypted secret and metadata `{"last_counter": n}`; fire EnabledEvent; answer `{"message":"TOTP enabled successfully.","status":...}`); `Status` (minimal for now); `Verify` (unauthenticated; validation `challenge_token` required|string|size:64, `method` required|string|in:totp,email, `code` required|string|min:6|max:6 with PHP's 422 `{error: first message, errors}`; BeginAttempt else 401 `{"error":"Invalid verification code.","two_factor_failed":true}`; method totp: the user's enabled totp row, VerifyTOTP against its last_counter; failure fires FailedEvent and answers that 401; success runs in one transaction: CompleteChallenge, store the new last_counter and last_used_at, then CompleteLogin and VerifiedEvent after the login event; answer `{token, user}`; method email answers the same 401 until Task 2). routes.go: `r.Group("/_user/api/v1/2fa", surf.Use("throttle:2fa-verify"), ...)` with `g.Post("/verify", ...)`; `r.Group("/_user/api/v1/2fa", surf.Use("throttle:user-api"), ...)` with `g.Get("/status", ..., "jwt.auth")`, `g.Post("/totp/setup", ..., "jwt.auth")`, `g.Post("/totp/confirm", ..., "jwt.auth")`.
|
||||
|
||||
Tests: testmain_test.go and harness_test.go copied from sm-sociallogin-plugin and renamed (database `golem15_mfa`, `bootMFA(t, env map[string]string) (*backpack.App, *httptest.Server)` activating golem15.user and golem15.mfa with the memory cooler and a capturing mailer; helpers to insert an activated user, log in and call with a bearer). tracer_smoke_test.go: `TestQRSVGSmoke` (no database, -short: QRSVG of an otpauth URL has width and height 200 and a viewBox; parsing the path's unit squares back gives exactly the dark modules of `qr.Encode` for the same text; an empty text returns an error). `TestTracerSmoke` (Postgres): a user calls totp/setup and gets a 32-character base32 secret, an `<svg` qr_code_svg and an otpauth URL with issuer and account; totp/confirm with the code from `totp.GenerateCode(secret, now)` answers the PHP message; the stored secret column differs from the secret (ciphertext); POST /_user/api/v1/login answers 200 with two_factor_required true, a 64-character challenge_token, available_methods ["totp"], an expires_at ending in +00:00 and no token; the stored challenge token column equals the SHA-256 hex of challenge_token; verify with the current code answers 200 with a token and user; verify again with the same challenge answers 401 with the PHP body; a fresh challenge verified with the already used code answers 401 (replay); eleven POSTs to /2fa/verify from one IP within a minute end with a 429.
|
||||
|
||||
README in sociallogin's structure (H1 golem15.mfa, summary, import line, module/package/plugin-ID line, Overview, Features, Routes, Configuration, API reference, Security notes, Dependencies, Testing), neutral wording, stating what exists after this task. Commit: `feat: TOTP second factor through the login gate`.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-mfa-plugin vet ./... && go -C ../sm-mfa-plugin test -count=1 -short ./... && go -C ../sm-mfa-plugin test -count=1 -short -run '^TestQRSVGSmoke$' -v . 2>&1 | grep -q -- '^--- PASS: TestQRSVGSmoke'</automated>
|
||||
<fails_when>Non-zero exit: vet or a short test is red, or no "--- PASS: TestQRSVGSmoke" line (the SVG does not reproduce the QR matrix or the test did not run).</fails_when>
|
||||
<automated>go -C ../sm-mfa-plugin test -count=1 -run '^TestTracerSmoke$' -v . 2>&1 | grep -q -- '^--- PASS: TestTracerSmoke'</automated>
|
||||
<fails_when>No "--- PASS: TestTracerSmoke" line: the end-to-end TOTP path failed, or the test skipped because Docker was unreachable (rerun through sudo -u golem; never record a skip as passed).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c '^module git.golem15.com/golem15/sm-mfa-plugin$' ../sm-mfa-plugin/go.mod` prints 1, `grep -c 'github.com/pquerna/otp v1.5.0' ../sm-mfa-plugin/go.mod` prints 1 and `grep -c 'github.com/boombuler/barcode v1.1.0' ../sm-mfa-plugin/go.mod` prints 1.
|
||||
- `NL=$(git log --diff-filter=A --format=%ct -- .planning/notes/2026-10-21-mfa-otp-webauthn.md) && ML=$(git -C ../sm-mfa-plugin log --reverse --format=%ct -S 'pquerna/otp' -- go.mod) && N=$(printf '%s\n' "$NL" | tail -1) && M=$(printf '%s\n' "$ML" | head -1) && test -n "$N" && test -n "$M" && test "$N" -le "$M"` exits 0 (the note was committed before the library was required).
|
||||
- `grep -c '"golem15.mfa"' ../sm-mfa-plugin/plugin.go` prints at least 1 and `grep -c 'throttle:2fa-verify' ../sm-mfa-plugin/routes.go` prints 1.
|
||||
- `grep -c 'attempts < max_attempts' ../sm-mfa-plugin/classes/challenge.go` prints at least 1 and `grep -c 'completed_at IS NULL' ../sm-mfa-plugin/classes/challenge.go` prints at least 2.
|
||||
- `grep -c 'golem15_user_two_factor_challenges' ../sm-mfa-plugin/updates/202610220002_create_golem15_user_two_factor_challenges.go` prints at least 2 and `grep -c 'email_sends' ../sm-mfa-plugin/updates/202610220002_create_golem15_user_two_factor_challenges.go` prints at least 1.
|
||||
- `R=$(git -C ../sm-mfa-plugin remote) && test -z "$R"` exits 0 (no remote yet).
|
||||
- `D=$(go -C ../sm-mfa-plugin list -deps .) && test "$(printf '%s\n' "$D" | grep -c '^gopkg.in/yaml')" -eq 0` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>A user can turn on TOTP with a scannable QR code, and their next login asks for the code and completes only with a valid, unused one.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Recovery codes and email codes: the first set is shown once, a code works once, regenerate replaces the set, email codes go only to users who enabled them, and a per-user failure cap stops brute force across challenges</name>
|
||||
<files>../sm-mfa-plugin/models/two_factor_recovery_code.go, ../sm-mfa-plugin/models/registry.go, ../sm-mfa-plugin/updates/202610220003_create_golem15_user_two_factor_recovery_codes.go, ../sm-mfa-plugin/updates/registry.go, ../sm-mfa-plugin/classes/recovery.go, ../sm-mfa-plugin/classes/email.go, ../sm-mfa-plugin/classes/challenge.go, ../sm-mfa-plugin/classes/gate.go, ../sm-mfa-plugin/classes/methods.go, ../sm-mfa-plugin/classes/events.go, ../sm-mfa-plugin/controllers/challenge.go, ../sm-mfa-plugin/controllers/management.go, ../sm-mfa-plugin/routes.go, ../sm-mfa-plugin/mail.go, ../sm-mfa-plugin/plugin.go, ../sm-mfa-plugin/views/mail/two_factor_code.htm, ../sm-mfa-plugin/views/mail/two_factor_code-en.htm, ../sm-mfa-plugin/lang/en/lang.yaml, ../sm-mfa-plugin/lang/pl/lang.yaml, ../sm-mfa-plugin/challenge_smoke_test.go, ../sm-mfa-plugin/README.md</files>
|
||||
<read_first>../sm-mfa-plugin classes and controllers from Task 1, ../sm-paymentgateway-plugin/mail.go, ../fonoteka.go/plugins/golem15/user/plugin.go (MailTemplates), ../fonoteka.go/plugins/golem15/user/views/mail/restore-en.htm, ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (sendTemplateVars, mailVars, bcrypt cost read), ../fonoteka.go/plugins/golem15/user/classes/mail.go, modules/postcard/templates.go (:115-199 ownership and locale rules), /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/models/TwoFactorRecoveryCode.php (:49-112), /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/classes/twofactor/EmailMethod.php, /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/views/mail/two_factor_code.htm, /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/classes/twofactor/TwoFactorService.php (:116-205, :228-294, :599-608), /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/controllers/TwoFactorApiController.php (:74-138, :300-560), 19.6-RESEARCH.md (Q3, Q4 recovery codes, email method, mail template, method enable/disable; Pitfalls 1, 9, 10, 11, 12)</read_first>
|
||||
<action>Per D-05, D-06, D-07 (last method), D-18 A6/A8/A9, D-19.
|
||||
|
||||
Recovery codes: migration `golem15_user_two_factor_recovery_codes` (id BIGSERIAL PK, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, code VARCHAR(255) NOT NULL, used_at TIMESTAMPTZ NULL, created_at TIMESTAMPTZ NULL, no updated_at, index (user_id, used_at)) and model. classes/recovery.go: `RecoveryCodeAlphabet = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"`; `GenerateRecoveryCodes(ctx, tx, userID, cost)` deletes the user's codes and inserts 10 new ones (each two 5-character parts drawn with crypto/rand without modulo bias, joined by a hyphen, stored as `bouncer.HashPassword(cost, code)` with cost from `golem15.user.password.bcrypt_cost`, default 10) and returns the plaintexts; `NormalizeRecoveryCode` (PHP: uppercase, spaces removed, hyphen kept; empty or longer than 32 characters is no match); `ConsumeRecoveryCode(ctx, tx, userID, input)` compares against the unused codes with `bouncer.CheckPassword`, stops at the first match and marks it with the guarded `UPDATE ... SET used_at = NOW() WHERE id = ? AND used_at IS NULL` (a lost race is no match); `RemainingRecoveryCodes`. Enabling (TOTPConfirm here, EmailEnable below) runs PHP's ensureRecoveryCodes inside its transaction: when the user has no unused code, generate a set and add `recovery_codes` (the 10 plaintexts) to the response (D-19); otherwise the body is PHP's.
|
||||
|
||||
Challenge endpoints: `Recovery` (validation `challenge_token` required|string|size:64, `code` required|string; BeginAttempt; ConsumeRecoveryCode; success → CompleteChallenge, RecoveryUsedEvent with the remaining count, CompleteLogin, VerifiedEvent with method recovery; failure → FailedEvent and 401 `{"error":"Invalid recovery code.","two_factor_failed":true}`). Email (classes/email.go): `MaskEmail` (PHP: first character, `*` for each of the remaining but last, last character, `@domain`; a local part of 2 or fewer characters gives first character plus `***`); `SendEmailCode(ctx, app, db, challenge, user)`: the user's email method must be enabled (A8); a code `fmt.Sprintf("%06d", n)` with n uniform in [0, 999999] from crypto/rand; one guarded `UPDATE ... SET code = ?, method = 'email', email_sends = email_sends + 1, updated_at = NOW() WHERE id = ? AND completed_at IS NULL AND expires_at > NOW() AND email_sends < 3` with the bcrypt hash (RowsAffected 0 → ErrInvalidChallenge); mail `userclasses.MailTemplate("golem15.mfa::mail.two_factor_code", userclasses.ResolveMailLocale(ctx, user))` through `postcard.Mailer` with vars name, code and expiry_minutes = min(two_factor_email_code_ttl, remaining challenge minutes rounded up, at least 1) in both cases (A9); never log the code. `EmailSend` controller: validation (PHP: 422 `{error}` without `errors`), FindChallenge else 401 `{"error":"Invalid or expired challenge."}`, user missing → 401 `{"error":"Invalid challenge."}`, email method not enabled, send cap reached or cap A6 reached → 401 `{"error":"Invalid or expired challenge."}`; success `{"message":"Verification code sent.","email":<masked>}`. `Verify` method email: after BeginAttempt, the email method must be enabled and the row's method email with a code (else 401 `{"error":"Invalid or expired challenge."}`, A8); a bcrypt match completes as TOTP does, a mismatch fires FailedEvent and answers PHP's invalid-code 401. mail.go embeds views/mail and lists both template names; plugin.go asserts pact.HasMailTemplates. Templates: two_factor_code-en.htm with PHP's subject "Your verification code" and body (Hi {{ .name }}, the code in bold, "This code expires in {{ .expiry_minutes }} minutes.", the secure-your-account sentence); two_factor_code.htm the same in Polish (the unsuffixed template is the Polish one, as in the user plugin); no layout header.
|
||||
|
||||
Failure cap (D-18 A6, Pitfall 1): `FailureCapReached(ctx, db, userID, now)` with the formula in this plan's Decisions; the gate returns Status 401 with `{"error":"Invalid or expired challenge."}` and creates no challenge when reached; Verify, Recovery and EmailSend check it after resolving the challenge's user and answer their generic 401 (the attempt still counts).
|
||||
|
||||
Management: `EmailEnable` (403 `{"error":"Email 2FA is not available."}` unless available; upsert enabled; ensureRecoveryCodes with D-19; EnabledEvent; `{"message":"Email 2FA enabled.","status":...}`), `EmailDisable` and `TOTPDisable` through `DisableMethod(ctx, tx, user, method)`: set is_enabled false and fire DisabledEvent; when no enabled method remains, delete the user's recovery codes (PHP) and trusted devices (D-07; the trusted-device table arrives in Task 3, so Task 3 extends this call) in the same transaction; answers `{"message":"TOTP disabled.","status":...}` and `{"message":"Email 2FA disabled.","status":...}`. `RecoveryCodesCount` (GET recovery-codes, `password` query checked with bouncer.CheckPassword against the user's hash else 403 `{"error":"Password confirmation required."}`; `{"recovery_codes_remaining":N,"message":"Recovery codes cannot be viewed after creation. Regenerate to get new codes."}`), `RecoveryCodesRegenerate` (POST with `password`; same 403; GenerateRecoveryCodes in one transaction, RecoveryRegeneratedEvent; `{"recovery_codes":[...],"message":"Save these codes securely. They will not be shown again."}`). Routes: challenge group `g.Post("/recovery", ...)`, `g.Post("/email/send", ...)`; management group `g.Delete("/totp", ...)`, `g.Post("/email/enable", ...)`, `g.Delete("/email", ...)`, `g.Get("/recovery-codes", ...)`, `g.Post("/recovery-codes/regenerate", ...)`, each with jwt.auth.
|
||||
|
||||
README (same commit): Features (recovery codes, email codes, failure cap, first set shown once), Routes, Configuration (failure_cap keys, email TTL), Security notes (bcrypt cost per wrong recovery code, email only when enabled). Smoke test challenge_smoke_test.go `TestChallengeSmoke` (Postgres) with subtests: `recovery_first_set` (totp/confirm returns 10 codes matching `^[2-9A-HJKMNP-Z]{5}-[2-9A-HJKMNP-Z]{5}$`; a second enable of email answers without recovery_codes; the stored codes are bcrypt hashes); `recovery_once` (a code completes a challenge; the same code on a new challenge answers the PHP 401; GET recovery-codes with the password reports 9; without the password 403); `regenerate` (old codes stop working, new ones work); `email_only_when_enabled` (a TOTP-only user: email/send answers 401 Invalid or expired challenge and no mail is sent; after email/enable, send answers the masked email, the captured mail uses the -en template for an English user with a 6-digit code and expiry_minutes at most 5, and verify method email with that code returns a token); `email_send_cap` (the fourth send on one challenge answers 401); `five_attempts` (five wrong codes on one challenge, then the correct code answers 401); `parallel_verify` (20 goroutines verify the same challenge with the correct code: exactly one 200); `failure_cap` (11 wrong verifications across three challenges, then login answers 401 {"error":"Invalid or expired challenge."} and creates no row; moving the rows' updated_at back past the window lets login challenge again); `disable_last_method` (DELETE totp on a TOTP-only user deletes the recovery codes). Commit: `feat: recovery codes, email codes and the per-user failure cap`.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-mfa-plugin vet ./... && go -C ../sm-mfa-plugin test -count=1 -short ./...</automated>
|
||||
<fails_when>Non-zero exit: vet or a short test is red (for example the mail templates fail postcard's ownership check at registration).</fails_when>
|
||||
<automated>go -C ../sm-mfa-plugin test -count=1 -run '^(TestTracerSmoke|TestChallengeSmoke)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>The count of top-level "--- PASS: Test" lines is not 2: a challenge subtest failed, the tracer regressed, or a test skipped because Docker was unreachable (rerun through sudo -u golem, else record NOT RUN).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c '23456789ABCDEFGHJKMNPQRSTUVWXYZ' ../sm-mfa-plugin/classes/recovery.go` prints at least 1 and `grep -c 'used_at IS NULL' ../sm-mfa-plugin/classes/recovery.go` prints at least 1.
|
||||
- `grep -c 'email_sends < 3' ../sm-mfa-plugin/classes/email.go` prints 1.
|
||||
- `grep -c 'golem15.mfa::mail.two_factor_code' ../sm-mfa-plugin/mail.go` prints 2 and `grep -c 'Your verification code' ../sm-mfa-plugin/views/mail/two_factor_code-en.htm` prints 1.
|
||||
- `grep -c 'func FailureCapReached' ../sm-mfa-plugin/classes/challenge.go` prints 1 and `grep -c 'FailureCapReached(' ../sm-mfa-plugin/classes/gate.go` prints at least 1.
|
||||
- `grep -c '"recovery_codes"' ../sm-mfa-plugin/controllers/management.go` prints at least 2 (regenerate and the D-19 first set).
|
||||
- `grep -c '"Invalid recovery code."' ../sm-mfa-plugin/controllers/challenge.go` prints 1 and `grep -c '"Password confirmation required."' ../sm-mfa-plugin/controllers/management.go` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>A user can sign in with a recovery code or an emailed code when they set those up, every code works once, and guessing is stopped per user across challenges and IP addresses.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Trusted devices skip the challenge until a password change or the last method goes, an administrator configures 2FA in a settings screen without restarting, and the Users screen's Reset 2FA clears a user's second factors</name>
|
||||
<files>../sm-mfa-plugin/models/trusted_device.go, ../sm-mfa-plugin/models/settings.go, ../sm-mfa-plugin/models/string_list.go, ../sm-mfa-plugin/models/settings/fields.yaml, ../sm-mfa-plugin/models/registry.go, ../sm-mfa-plugin/updates/202610220004_create_golem15_user_trusted_devices.go, ../sm-mfa-plugin/updates/202610220005_create_golem15_mfa_settings.go, ../sm-mfa-plugin/updates/registry.go, ../sm-mfa-plugin/classes/trusted.go, ../sm-mfa-plugin/classes/config.go, ../sm-mfa-plugin/classes/gate.go, ../sm-mfa-plugin/classes/methods.go, ../sm-mfa-plugin/classes/admin.go, ../sm-mfa-plugin/controllers/challenge.go, ../sm-mfa-plugin/controllers/management.go, ../sm-mfa-plugin/routes.go, ../sm-mfa-plugin/admin_settings.go, ../sm-mfa-plugin/admin_permissions.go, ../sm-mfa-plugin/plugin.go, ../sm-mfa-plugin/lang/en/lang.yaml, ../sm-mfa-plugin/lang/pl/lang.yaml, ../sm-mfa-plugin/trusted_settings_smoke_test.go, ../sm-mfa-plugin/README.md</files>
|
||||
<read_first>../sm-mfa-plugin classes, controllers and models from Tasks 1-2, ../sm-sociallogin-plugin/admin_settings.go, ../sm-sociallogin-plugin/admin_permissions.go, ../sm-sociallogin-plugin/models/settings.go, ../sm-sociallogin-plugin/models/settings/fields.yaml, ../sm-sociallogin-plugin/updates/202610200001_create_golem15_sociallogin_settings.go, ../sm-sociallogin-plugin/settings_smoke_test.go, modules/cabana/settings.go (:98-103), modules/cabana/form_schema.go (:23-30), modules/cabana/field_taglist.go (:124-139), ../sm-chatbots-plugin/boot.go (:82-98), ../fonoteka.go/plugins/golem15/user/classes/two_factor_admin.go, ../fonoteka.go/plugins/golem15/user/classes/events.go, ../fonoteka.go/plugins/golem15/user/classes/user_groups.go, ../fonoteka.go/plugins/golem15/user/reset_two_factor_smoke_test.go, /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/models/TrustedDevice.php, /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/classes/twofactor/TwoFactorService.php (:33-47, :95-109, :455-530, :549-594), /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/models/Settings.php, /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/models/settings/fields.yaml (:90-160), 19.6-RESEARCH.md (Q4 trusted devices, status shape, settings; Q8; Q9; Pattern 5; Pitfalls 2, 19; Assumptions A2, A7)</read_first>
|
||||
<action>Per D-07, D-09 (setting), D-10, D-12, D-13, D-15, D-16.
|
||||
|
||||
Trusted devices: migration `golem15_user_trusted_devices` (id BIGSERIAL PK, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, token VARCHAR(64) NOT NULL UNIQUE (SHA-256 hex), device_name VARCHAR(255) NULL, ip_address VARCHAR(45) NULL, trusted_until TIMESTAMPTZ NOT NULL, last_used_at TIMESTAMPTZ NULL, created_at, updated_at, index (user_id)) and model. classes/trusted.go: `ParseDeviceName(userAgent)` ports PHP's "{browser} on {os}" parse (TrustedDevice.php); `IssueTrustedDevice(ctx, db, user, ip, userAgent, ttlDays)` stores the hash and returns the 64-character token; `CheckTrustedDevice(ctx, db, userID, token, ip)` requires a row of that user with trusted_until in the future, then updates last_used_at and ip_address; `RevokeTrustedDevices(ctx, tx, userID)`. Verify, Recovery (and plan 03's WebAuthn verify) add `trusted_device_token` to the success body only when the request's `trust_device` is truthy and trusted_device_enabled is on (PHP withTrustedDevice). The gate allows (returns nil) when trusted devices are enabled and the attempt's Fields carry a valid `trusted_device_token` for the user (Pitfall 2); social sign-ins carry no fields, so trusted devices never skip a social challenge (A7). DisableMethod's last-method branch now also deletes trusted devices (D-07). plugin.go Boot registers `app.Events.Listen[*userclasses.PasswordChangedEvent](PluginID, ...)` deleting the user's trusted devices on ev.Tx (falling back to the published DB when Tx is nil), returning the delete's error so the password write rolls back on failure. Management routes `g.Delete("/trusted-devices/{id}", ...)` followed directly by `g.Where("id", "[0-9]+")` (Pitfall 17) answering `{"message":"Device revoked.","status":...}` or 404 `{"error":"Device not found."}` scoped by user_id, and `g.Delete("/trusted-devices", ...)` answering `{"message":"All trusted devices revoked.","status":...}`.
|
||||
|
||||
Settings (D-12, RESEARCH Q8): migration `golem15_mfa_settings` (id INTEGER PRIMARY KEY, two_factor_mode VARCHAR(16) NULL, two_factor_available_methods JSONB NULL, two_factor_enforce_groups JSONB NULL, two_factor_email_code_ttl INTEGER NULL, two_factor_challenge_ttl INTEGER NULL, two_factor_passwordless_login BOOLEAN NULL, trusted_device_enabled BOOLEAN NULL, trusted_device_ttl_days INTEGER NULL, two_factor_social_login BOOLEAN NULL, created_at, updated_at; inserts row 1) through the `run` helper. models/string_list.go `StringList` ([]string as JSON with Scan and Value). models/settings.go: singleton like sociallogin's (TableName, Fillable, `Rules()` with in:disabled,optional,enforced for the mode and integer ranges 1-60 for the TTLs and 1-365 for the trust days, `BeforeValidate` refusing unknown methods outside totp, email, webauthn and unknown group codes with `cabana.ValidationError`), `LoadSettings(ctx, db)`. models/settings/fields.yaml: tabs General (mode dropdown with three static options, available methods taglist mode array, enforce groups taglist mode array, social login switch with a comment that the frontend's OAuth callback must handle two_factor_required before it is turned on, D-09), Codes (email and challenge TTL numbers), Passkeys (passwordless switch with a comment that plan 03's routes use it), Trusted devices (switch and days). classes/config.go `LoadEffective` overlays each non-NULL settings value on the config per call (D-12: no restart), treating a missing table or DB as config only. admin_settings.go and admin_permissions.go as sociallogin's (Code `mfa`, Category Users, Order 540, permission `golem15.mfa.access_settings` for the developer role). Gate: reads LoadEffective per call; mode disabled exempts everyone (D-13; there is no other exemption); Source social challenges only when two_factor_social_login is on (D-09, default skip).
|
||||
|
||||
Status (PHP getStatus, TwoFactorService.php:549-594): `{enabled, enforced, methods, webauthn_credentials: [], recovery_codes_remaining, available_methods (the effective global list), trusted_device_enabled, trusted_devices}`; trusted_devices only when enabled, unexpired, ordered last_used_at DESC, items `{id, device_name (default "Unknown Device"), ip_address, last_used_at, trusted_until, created_at}` with Carbon's JSON form `YYYY-MM-DDTHH:MM:SS.000000Z` in UTC (RESEARCH A2; one helper `carbonJSON`), webauthn_credentials stays an empty list until plan 03. Every management answer that embeds `status` uses this.
|
||||
|
||||
Admin reset (D-15): classes/admin.go `Admin{App *backpack.App}` implementing `userclasses.TwoFactorAdmin`: HasTwoFactor is true when the user has an enabled method; Reset, on the given transaction, sets every method row disabled with secret and metadata cleared, deletes recovery codes and trusted devices and fires DisabledEvent with method `all` (plan 03 adds the WebAuthn credentials). Boot publishes it with `app.Publish[userclasses.TwoFactorAdmin](&classes.Admin{App: app})`.
|
||||
|
||||
README (same commit): Features (trusted devices, settings screen, social toggle, admin reset, revocation rules), Configuration (every key with its default and env form), Admin section, API reference rows. Smoke test trusted_settings_smoke_test.go `TestTrustedSettingsSmoke` (Postgres) with subtests: `trusted_skip` (trusted devices enabled in config: verify with trust_device true returns trusted_device_token, the stored token column is its SHA-256 hex, the next login with it in the body answers a token directly; another user's token or an expired one is challenged); `trusted_disabled` (with the setting off, trust_device adds no token); `password_change_revokes` (change-password through the user plugin deletes the user's trusted devices; a listener failure rolls the change back); `settings_row_applies` (saving mode disabled in the row makes the next login answer a token without restarting; setting it back to optional challenges again); `social_toggle` (the gate called with Source social returns nil while the toggle is off and a challenge when on); `allow_lists` (BeforeValidate refuses methods ["sms"] and an unknown group code); `status_shape` (status keys exactly as PHP's, trusted device dates in the Carbon form); `admin_reset` (calling the published TwoFactorAdmin on a user with TOTP, codes and a trusted device leaves no enabled method, no codes, no devices, fires one disabled event, and the next login answers a token). Commit: `feat: trusted devices, the settings screen and the admin reset service`.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-mfa-plugin vet ./... && go -C ../sm-mfa-plugin test -count=1 -short ./...</automated>
|
||||
<fails_when>Non-zero exit: vet or a short test is red (for example cabana refuses the settings form at boot because of an unsupported field type).</fails_when>
|
||||
<automated>go -C ../sm-mfa-plugin test -count=1 -run '^(TestTracerSmoke|TestChallengeSmoke|TestTrustedSettingsSmoke)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>The count of top-level "--- PASS: Test" lines is not 3: a trusted-device, settings or reset subtest failed, an earlier smoke regressed, or a test skipped because Docker was unreachable (rerun through sudo -u golem, else record NOT RUN).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'Listen\[\*userclasses.PasswordChangedEvent\]' ../sm-mfa-plugin/plugin.go` prints 1 and `grep -c 'Publish\[userclasses.TwoFactorAdmin\]' ../sm-mfa-plugin/plugin.go` prints 1.
|
||||
- `grep -cE 'type: (checkboxlist|radio)' ../sm-mfa-plugin/models/settings/fields.yaml` prints 0 and `grep -c 'mode: array' ../sm-mfa-plugin/models/settings/fields.yaml` prints at least 2.
|
||||
- `grep -c 'two_factor_social_login' ../sm-mfa-plugin/updates/202610220005_create_golem15_mfa_settings.go` prints at least 1 and `grep -c 'two_factor_social_login' ../sm-mfa-plugin/config/config.yaml` prints at least 1.
|
||||
- `grep -A1 '"/trusted-devices/{id}"' ../sm-mfa-plugin/routes.go | grep -c 'Where("id", "\[0-9\]+")'` prints 1.
|
||||
- `grep -c '"trusted_device_token"' ../sm-mfa-plugin/controllers/challenge.go` prints at least 1.
|
||||
- `grep -cE 'golem15.mfa.access_settings|two_factor_mode|trusted_device_enabled' ../sm-mfa-plugin/README.md` prints at least 3.
|
||||
- `R=$(git -C ../sm-mfa-plugin remote) && test -z "$R"` exits 0 and `test -z "$(git -C ../sm-mfa-plugin status --porcelain)"` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>Users can trust a device for a month, administrators tune 2FA from the backend with immediate effect, and a password change or a reset by an administrator removes every way around the second factor.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| holder of a correct password to the challenge routes | unauthenticated clients present challenge tokens and guessed codes |
|
||||
| signed-in user to the management routes | a bearer JWT changes the user's own factors and reads recovery-code counts |
|
||||
| plugin to the database | tokens, secrets and codes at rest |
|
||||
| plugin to the mailer | one-time codes leave the system by email |
|
||||
| administrator to the settings screen and the reset action | global 2FA policy and per-user resets |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.6-13 | Spoofing | TOTP and email brute force across challenges and IPs (Pitfall 1) | high | mitigate | 2fa-verify bucket 10/min per IP plus the per-user failure cap counted in the database (10 per 15 min, D-18 A6); five attempts per challenge; TestChallengeSmoke failure_cap and five_attempts |
|
||||
| T-19.6-14 | Tampering | parallel verifies (attempt bypass, double mint) | high | mitigate | guarded attempt increment and single completion UPDATE (Pattern 2); TestChallengeSmoke parallel_verify yields one token |
|
||||
| T-19.6-15 | Spoofing | TOTP code replay within the window (Pitfall 5) | medium | mitigate | last accepted counter in metadata, refused at or below; TestTracerSmoke replay |
|
||||
| T-19.6-16 | Spoofing | email downgrade of a TOTP-only user (Pitfall 10) | high | mitigate | email/send and verify method=email only when the email method is enabled (D-18 A8); TestChallengeSmoke email_only_when_enabled |
|
||||
| T-19.6-17 | Denial of service | mail bombing through email/send | medium | mitigate | at most 3 sends per challenge in one guarded UPDATE, the bucket and the failure cap |
|
||||
| T-19.6-18 | Information disclosure | secrets at rest | high | mitigate | SHA-256 hex for challenge and trusted-device tokens, lagoon.Encrypted TOTP secret, bcrypt recovery and email codes (D-06); TestTracerSmoke and TestTrustedSettingsSmoke compare stored columns |
|
||||
| T-19.6-19 | Information disclosure | logs and error strings | medium | mitigate | handlers log only error types and user ids, never codes, tokens, secrets or passwords; 500 answers are generic |
|
||||
| T-19.6-20 | Spoofing | stolen trusted-device token | medium | mitigate | stored as SHA-256, bound to the user, 30-day expiry, off by default, revoked on any password change and on the last method's removal, listed and revocable per user (D-07, D-10) |
|
||||
| T-19.6-21 | Elevation of privilege | management routes touching another user's rows | high | mitigate | every query scoped by the user id from the verified JWT; {id} routes constrained to digits with Where directly after the route; 404 for foreign ids |
|
||||
| T-19.6-22 | Information disclosure | recovery codes after creation | medium | mitigate | codes shown once (first set D-19, regenerate), listing returns only a count and requires the password; the management group carries throttle:user-api |
|
||||
| T-19.6-23 | Denial of service | bcrypt CPU per wrong recovery code (Pitfall 9) | low | mitigate | input normalized and capped at 32 characters, stop at the first match, bucket and failure cap |
|
||||
| T-19.6-24 | Tampering | settings values (USER-005 type confusion, unknown methods or groups) | low | mitigate | typed mode with an allow-list in Rules, BeforeValidate allow-lists for methods and groups, unknown stored mode treated as optional |
|
||||
| T-19.6-25 | Spoofing | client-chosen TOTP secret at confirm (PHP parity) | low | accept | only for the caller's own account; base32 and length validated; a proof code from the secret is required |
|
||||
| T-19.6-SC | Tampering | Go module installs | high | mitigate | pquerna/otp v1.5.0 and boombuler/barcode v1.1.0 installed only after plan 01 Task 5's recorded approval (precondition); the archived YAML module is absent from `go list -deps` |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: TestQRSVGSmoke (short) and TestTracerSmoke; the note precedes the library require; no remote.
|
||||
- Task 2: TestChallengeSmoke and TestTracerSmoke.
|
||||
- Task 3: TestTrustedSettingsSmoke, TestChallengeSmoke and TestTracerSmoke.
|
||||
- Feedback sampling (19.6-VALIDATION.md): after each commit `go -C ../sm-mfa-plugin vet ./... && go -C ../sm-mfa-plugin test -short ./...`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-1 (without WebAuthn): TOTP setup, confirm and disable; email codes; recovery codes; trusted devices, all with PHP's paths and bodies.
|
||||
- SC-2: login with a second factor answers the challenge; the challenge routes share the 2fa-verify bucket.
|
||||
- D-18: failure cap, email only when enabled, capped sends, mail expiry within the challenge.
|
||||
- D-12, D-13, D-15: settings screen with immediate effect, only mode disabled exempts, admin reset service published.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-02-SUMMARY.md` when done, with the commits in order, the final exported identifiers, config keys and migration IDs (plans 03 and 04 use them), which Docker-backed tests ran or were NOT RUN, and any deviation from this plan's names. Post a short comment on the Phase 19.6 milestone task in g15office (`avoroeb4`); if the tool is unavailable, record that in the SUMMARY.
|
||||
</output>
|
||||
@@ -0,0 +1,446 @@
|
||||
---
|
||||
phase: 19.6-two-factor-auth-and-device-flow-in-sm-user-plugin
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["19.6-01", "19.6-02"]
|
||||
files_modified:
|
||||
- ../sm-mfa-plugin/go.mod
|
||||
- ../sm-mfa-plugin/go.sum
|
||||
- ../sm-mfa-plugin/routes.go
|
||||
- ../sm-mfa-plugin/config/config.yaml
|
||||
- ../sm-mfa-plugin/lang/en/lang.yaml
|
||||
- ../sm-mfa-plugin/lang/pl/lang.yaml
|
||||
- ../sm-mfa-plugin/classes/config.go
|
||||
- ../sm-mfa-plugin/classes/webauthn.go
|
||||
- ../sm-mfa-plugin/classes/passwordless.go
|
||||
- ../sm-mfa-plugin/classes/gate.go
|
||||
- ../sm-mfa-plugin/classes/methods.go
|
||||
- ../sm-mfa-plugin/classes/admin.go
|
||||
- ../sm-mfa-plugin/classes/events.go
|
||||
- ../sm-mfa-plugin/controllers/webauthn.go
|
||||
- ../sm-mfa-plugin/controllers/passwordless.go
|
||||
- ../sm-mfa-plugin/models/webauthn_credential.go
|
||||
- ../sm-mfa-plugin/models/registry.go
|
||||
- ../sm-mfa-plugin/updates/202610220006_create_golem15_user_webauthn_credentials.go
|
||||
- ../sm-mfa-plugin/updates/registry.go
|
||||
- ../sm-mfa-plugin/webauthn_authenticator_test.go
|
||||
- ../sm-mfa-plugin/webauthn_smoke_test.go
|
||||
- ../sm-mfa-plugin/passwordless_smoke_test.go
|
||||
- ../sm-mfa-plugin/README.md
|
||||
- ../sm-sociallogin-plugin/classes/flow.go
|
||||
- ../sm-sociallogin-plugin/classes/codes.go
|
||||
- ../sm-sociallogin-plugin/controllers/complete.go
|
||||
- ../sm-sociallogin-plugin/controllers/register_complete.go
|
||||
- ../sm-sociallogin-plugin/lang/en/lang.yaml
|
||||
- ../sm-sociallogin-plugin/lang/pl/lang.yaml
|
||||
- ../sm-sociallogin-plugin/social_gate_smoke_test.go
|
||||
- ../sm-sociallogin-plugin/README.md
|
||||
- ../sm-sociallogin-plugin/go.mod
|
||||
- ../sm-sociallogin-plugin/go.sum
|
||||
- ../sm-demo-app/vue-demo-app/app/lib/twoFactor.ts
|
||||
- ../sm-demo-app/vue-demo-app/app/lib/twoFactor.test.ts
|
||||
- ../sm-demo-app/vue-demo-app/app/composables/useDemoAuth.ts
|
||||
- ../sm-demo-app/vue-demo-app/app/composables/useTwoFactor.ts
|
||||
- ../sm-demo-app/vue-demo-app/app/pages/login.vue
|
||||
- ../sm-demo-app/vue-demo-app/app/pages/account/security.vue
|
||||
- ../sm-demo-app/vue-demo-app/app/components/SiteHeader.vue
|
||||
- ../sm-demo-app/vue-demo-app/i18n/locales/en.json
|
||||
- ../sm-demo-app/vue-demo-app/i18n/locales/pl.json
|
||||
- ../sm-demo-app/.gitmodules
|
||||
- ../sm-demo-app/plugins/golem15/mfa
|
||||
- ../sm-demo-app/plugins/golem15/user
|
||||
- ../sm-demo-app/plugins/golem15/sociallogin
|
||||
- ../sm-demo-app/vue-demo-app
|
||||
- ../sm-demo-app/go.mod
|
||||
- ../sm-demo-app/go.sum
|
||||
- ../sm-demo-app/go.work
|
||||
- ../sm-demo-app/summer.yaml
|
||||
- ../sm-demo-app/plugins.gen.go
|
||||
- ../sm-demo-app/boot_test.go
|
||||
- ../sm-demo-app/mfa_test.go
|
||||
- ../sm-demo-app/config/golem15.mfa.yaml
|
||||
- ../sm-demo-app/deploy/env.example
|
||||
- ../sm-demo-app/README.md
|
||||
- ../sm-bm-app/.gitmodules
|
||||
- ../sm-bm-app/plugins/golem15/mfa
|
||||
- ../sm-bm-app/plugins/golem15/user
|
||||
- ../sm-bm-app/plugins/golem15/sociallogin
|
||||
- ../sm-bm-app/go.mod
|
||||
- ../sm-bm-app/go.sum
|
||||
- ../sm-bm-app/go.work
|
||||
- ../sm-bm-app/summer.yaml
|
||||
- ../sm-bm-app/plugins.gen.go
|
||||
- ../sm-bm-app/mfa_test.go
|
||||
- ../sm-bm-app/README.md
|
||||
- ../fonoteka.go/plugins/golem15/user
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/phase07_coverage_test.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/classes/hidden_marshal_test.go
|
||||
autonomous: false
|
||||
requirements: [SC-1, SC-2, SC-4, D-01, D-02, D-04, D-05, D-06, D-08, D-09, D-10, D-14, D-16, D-17, D-19, D-20]
|
||||
user_setup:
|
||||
- service: git.golem15.com
|
||||
why: "Create the golem15/sm-mfa-plugin repository and push and tag sm-user-plugin, sm-sociallogin-plugin, sm-mfa-plugin, vue-demo-app, sm-demo-app and sm-bm-app (D-17), only after the Task 4 go-ahead"
|
||||
dashboard_config:
|
||||
- task: "Approve the release train in Task 4; the executor runs tea repos create and git push under that approval"
|
||||
location: "git.golem15.com, organisation golem15"
|
||||
estimate:
|
||||
tokens: 360000
|
||||
raw_tokens: 360000
|
||||
tasks: 6
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-04, D-08: github.com/go-webauthn/webauthn v0.18.2 is required only by sm-mfa-plugin; POST 2fa/webauthn/register/options and 2fa/webauthn/options answer {options: <go-webauthn's standard base64url CredentialCreation or CredentialAssertion>, challenge}; register takes {attestation, challenge, name?} and webauthn/verify takes {challenge_token, assertion, trust_device?} with the standard RegistrationResponseJSON and AuthenticationResponseJSON shapes; attestation none, timeout 60 s, user verification preferred for second-factor use; RP ID golem15.mfa.webauthn.rp_id or the app.url host, RP name the app name, origins golem15.mfa.webauthn.origins or the app.url and golem15.user.frontend_url origins."
|
||||
- "D-05, D-06: the challenge's WebAuthn state lives server-side (the expected challenge in the challenge row's code column; registration and passkey sessions in cooler, consumed once with Pull); a client-sent challenge is only a lookup key; register answers PHP's {message, credential:{id, name, created_at}, status} (plus recovery_codes when it created the first set, D-19); webauthn/verify answers {token, user} (plus trusted_device_token when asked and enabled) or 401 {\"error\":\"WebAuthn verification failed.\",\"two_factor_failed\":true}; DELETE webauthn/{id} answers PHP's bodies and the last credential's removal disables the method with the last-method cleanup."
|
||||
- "D-16: golem15_user_webauthn_credentials keeps PHP's columns (credential_id, public_key, attestation_type, transports, sign_count, name, is_enabled, last_used_at) and adds credential_data (the library's Credential JSON, written back after every assertion with sign count and flags) and passwordless, with a unique index on credential_id; status lists webauthn_credentials as PHP's {id, name, last_used_at, created_at}; admin reset deletes the credentials."
|
||||
- "D-14: POST /_user/api/v1/2fa/webauthn/passwordless/options and .../verify sit in the 2fa-verify bucket, answer 403 {\"error\":\"Passwordless login is not available.\"} while two_factor_passwordless_login is off, accept only enabled credentials registered as passkeys (resident key required, user verification required at registration and at login, credProps rk reported true), enforce activation, ban and suspension, fire the login event with Source passkey and issue a token without a second challenge."
|
||||
- "D-09: sm-sociallogin-plugin consults the login gate in signIn before FinishLogin (Source social); with the mfa toggle off it behaves as v0.1.2; with it on, oauth-complete and oauth-register-complete answer 200 with the two_factor_required body instead of {token, user, action, return_to}; a refusing gate result redirects with the two_factor_unavailable message; gate errors fail closed; action=link is never gated."
|
||||
- "D-20: the demo site (vue-demo-app, mounted in sm-demo-app) lets a signed-in user set up TOTP with the QR code and a confirm code, shows the first recovery codes once, disables TOTP, and answers a login challenge with a TOTP or recovery code; sm-bm-app stays API-only."
|
||||
- "D-17: after the Task 4 go-ahead the remote operations run in order: sm-user-plugin pushed and tagged, then sm-mfa-plugin (new repository, tagged v0.1.0) and sm-sociallogin-plugin pushed and tagged against it, then vue-demo-app pushed; sm-demo-app and sm-bm-app mount golem15.mfa and bump the user and sociallogin pointers in separate commits; fonoteka.go bumps the user pointer and updates TestNoDeferredRoutesResolve (drops /devices, keeps 2fa) and its user-model count in that commit, does not mount mfa, links no WebAuthn module and is never pushed."
|
||||
- "D-01: an application without golem15.mfa (fonoteka.go) has no /2fa route, no 2FA table and no go-webauthn, pquerna/otp or boombuler module in its module graph."
|
||||
artifacts:
|
||||
- path: "../sm-mfa-plugin/classes/webauthn.go"
|
||||
provides: "per-request go-webauthn instance, user adapter, ceremony state"
|
||||
contains: "webauthn.New"
|
||||
- path: "../sm-mfa-plugin/classes/passwordless.go"
|
||||
provides: "D-14 passkey login"
|
||||
contains: "ValidatePasskeyLogin"
|
||||
- path: "../sm-mfa-plugin/updates/202610220006_create_golem15_user_webauthn_credentials.go"
|
||||
provides: "golem15_user_webauthn_credentials"
|
||||
contains: "credential_data"
|
||||
- path: "../sm-sociallogin-plugin/classes/flow.go"
|
||||
provides: "D-09 gate in signIn"
|
||||
contains: "CheckLoginGates"
|
||||
- path: "../sm-demo-app/vue-demo-app/app/pages/account/security.vue"
|
||||
provides: "D-20 TOTP setup, recovery codes, disable"
|
||||
contains: "totp/setup"
|
||||
- path: "../sm-demo-app/mfa_test.go"
|
||||
provides: "TestMFAMounted"
|
||||
contains: "TestMFAMounted"
|
||||
key_links:
|
||||
- from: "../sm-mfa-plugin/controllers/webauthn.go"
|
||||
to: "modules/cooler/cooler.go"
|
||||
via: "registration state consumed once with cooler.Cache.Pull"
|
||||
pattern: "Pull\\("
|
||||
- from: "../sm-sociallogin-plugin/classes/flow.go"
|
||||
to: "../fonoteka.go/plugins/golem15/user/controllers/login_gates.go"
|
||||
via: "signIn calls usercontrollers.CheckLoginGates with Source social before FinishLogin"
|
||||
pattern: "LoginSourceSocial"
|
||||
- from: "../sm-demo-app/vue-demo-app/app/composables/useDemoAuth.ts"
|
||||
to: "/_user/api/v1/2fa/verify"
|
||||
via: "the login page completes a challenge through verify or recovery"
|
||||
pattern: "2fa/verify"
|
||||
- from: "../sm-demo-app/summer.yaml"
|
||||
to: "../sm-mfa-plugin/plugin.go"
|
||||
via: "golem15.mfa mounted, plugins.gen.go regenerated"
|
||||
pattern: "golem15.mfa"
|
||||
prohibitions:
|
||||
- statement: "No assertion, attestation, challenge, token, code or library error detail is logged or echoed; registration failures answer fixed reasons."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The demo renders the server's SVG as an image data URL, never through v-html."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed, tagged, created remotely or deployed before the Task 4 reply; nothing is ever force-pushed; fonoteka.go is never pushed; no application is deployed."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "fonoteka.go does not mount golem15.mfa; sm-bm-app's frontend gets no 2FA UI (D-17, D-20)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The stale, remote-less ../vue-demo-app checkout is not used; the demo UI is committed in ../sm-demo-app/vue-demo-app (origin golem15/vue-demo-app)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
<!-- planner-discipline-allow: golem15.mfa -->
|
||||
<!-- planner-discipline-allow: go-webauthn -->
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story invented, see plan 01): `sm-user-plugin` has the PHP user plugin's second factors and device login. CONTEXT D-01 puts the second factors in `sm-mfa-plugin`; D-14 adds passkey login, D-09 the social-login gate, D-17 the adoption and D-20 the demo UI.
|
||||
|
||||
This plan's slice (plan 3 of 4, wave 3, "WebAuthn and adoption"): the WebAuthn second factor (tracer), passwordless passkey login and the social-login gate, the demo's two-factor screens, then the release go-ahead, the publishing in D-17 order and the adoption in sm-demo-app, sm-bm-app and fonoteka.go.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip). `must_haves` derive from SC-1..SC-4 and D-01..D-20.
|
||||
|
||||
## Decisions taken in this plan (recorded assumptions and Claude's discretion)
|
||||
|
||||
- Passwordless paths (D-14 discretion): `POST /_user/api/v1/2fa/webauthn/passwordless/options` (body `{}`) and `POST /_user/api/v1/2fa/webauthn/passwordless/verify` (body `{challenge, assertion}`), in the challenge group with the 2fa-verify bucket. While the setting is off both answer 403 `{"error":"Passwordless login is not available."}` (the "X is not available." wording of PHP's management errors).
|
||||
- Registration takes an optional `passwordless` boolean (with the setting on) to request a passkey: resident key required, user verification required and the credProps extension. A credential is stored with `passwordless = true` only when it was requested, the authenticator data's UV flag is set and credProps reports rk true; without credProps the credential stays second-factor-only (fail closed).
|
||||
- User handle: the 8-byte big-endian user id, as PHP's `hex2bin(str_pad(dechex(id), 16, '0', STR_PAD_LEFT))` (RESEARCH A4), so a later import maps; it reveals the numeric id to the authenticator (accepted, T-19.6-34).
|
||||
- Credential storage: `credential_data` TEXT holding the JSON of `webauthn.Credential` (RESEARCH A3), plus PHP's display columns; written back after every successful assertion (Pitfall 8). A clone warning (non-zero sign counts that do not increase) refuses the login with the generic failure and logs the credential row id.
|
||||
- Ceremony state (Pitfall 6, Pattern 3): login 2FA stores the base64url challenge in the challenge row's `code` with `method = webauthn` (rebuilt into SessionData at verify); registration and passkey sessions are JSON in `cooler` under `mfa:webauthn:register:<user id>:<challenge>` and `mfa:webauthn:passwordless:-:<challenge>`, TTL 120 s, consumed with `Pull`; the memory driver works within one instance only (A11, README note).
|
||||
- The RP: `golem15.mfa.webauthn.rp_id` (default the app.url host), `golem15.mfa.webauthn.rp_name` (default app.name), `golem15.mfa.webauthn.origins` (default the app.url origin and the `golem15.user.frontend_url` origin). An IP-address host cannot run a ceremony in browsers (Pitfall 7); the README says so. The demo serves the SPA and the API on one host in production, so the defaults fit; locally (127.0.0.1) WebAuthn is not usable, which D-20 allows.
|
||||
- Social sign-in (D-09, RESEARCH Q5): the gate runs in `signIn` before `FinishLogin` so a deactivated account is not restored before the second factor (D-18 A10); `CompletionPayload` and `Outcome` gain `Challenge map[string]any`; `Complete` and `RegisterComplete` answer it as 200 JSON (the token cookie option does not apply to it); a non-200 gate result becomes a FlowError with the new message key `oauth.two_factor_unavailable`. Trusted devices never skip a social challenge (A7).
|
||||
- Demo UI location: CONTEXT D-20 names `../vue-demo-app`, but that sibling is a stale local copy with no remote (2 commits, `32049f7`); the real `golem15/vue-demo-app` repository is the submodule checkout `../sm-demo-app/vue-demo-app` (origin set, `5a8d544`, vitest configured). The UI is committed there and reaches sm-demo-app through a pointer bump. The QR SVG is shown through an `img` data URL. Email codes, WebAuthn and trusted devices in the demo UI are left out (optional in D-20).
|
||||
- Release (D-17, CONTEXT discretion): one go-ahead (Task 4) covers every remote operation of the phase, including plan 04's possible patch releases only if Task 4's reply says so. Tag numbers are chosen in the reply: sm-user-plugin the next patch after its newest tag on origin (v0.1.6 unless Phase 19.8 released one first), sm-sociallogin-plugin the next patch (v0.1.3), sm-mfa-plugin v0.1.0. No framework tag is needed: sm-mfa-plugin requires the framework version sm-sociallogin-plugin requires (v0.3.1, the first tag with `cooler.Cache.Pull`), so mounting it does not move an application's framework pin; a newer tag only if the code needs one, named in the SUMMARY. sm-demo-app's `TestGoModRequiresTags` needs tagged requires, so a push without tags cannot mount mfa in the demo.
|
||||
- Concurrency: Phase 19.8 plan 03 Task 7 may be bumping pointers in sm-demo-app and sm-bm-app; before each commit there, `git status --porcelain` is checked for edits to the files this task touches, and the executor stops and reports when one is another session's.
|
||||
- Context budget (estimate 360k, 6 tasks; accepted under the four-plan decision): Tasks 1-3 run in the first context; the Task 4 checkpoint ends it; a continuation agent runs Tasks 5 and 6 from this plan, the 19.6-01/02 SUMMARYs and the `19.6-03-SUMMARY.md` draft written at the checkpoint (`Status: draft (checkpoint Task 4)`, commits with hashes per repository, NOT RUN notes).
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- sm-mfa-plugin: module requirement `github.com/go-webauthn/webauthn v0.18.2`; classes `WebAuthnFor(ctx context.Context, app *backpack.App) (*webauthn.WebAuthn, error)`, `UserHandle(id uint) []byte`, `NewWebAuthnUser`, `BeginRegistration`, `FinishRegistration`, `BeginAssertion`, `FinishAssertion`, `BeginPasswordless`, `FinishPasswordless`; models `WebAuthnCredential` (table `golem15_user_webauthn_credentials`); migration `202610220006_create_golem15_user_webauthn_credentials`; controllers `WebAuthnRegisterOptions`, `WebAuthnRegister`, `WebAuthnRemove`, `WebAuthnOptions`, `WebAuthnVerify`, `PasswordlessOptions`, `PasswordlessVerify`; events `WebAuthnRegisteredEvent` (`golem15.user.2fa.webauthn.registered`), `WebAuthnRemovedEvent` (`golem15.user.2fa.webauthn.removed`); config keys `golem15.mfa.webauthn.rp_id`, `golem15.mfa.webauthn.rp_name`, `golem15.mfa.webauthn.origins`; routes `POST /_user/api/v1/2fa/webauthn/options`, `POST .../2fa/webauthn/verify`, `POST .../2fa/webauthn/passwordless/options`, `POST .../2fa/webauthn/passwordless/verify` (2fa-verify bucket) and `POST .../2fa/webauthn/register/options`, `POST .../2fa/webauthn/register`, `DELETE .../2fa/webauthn/{id}` (jwt.auth); test helper `virtualAuthenticator`; smoke tests `TestWebAuthnCeremonySmoke`, `TestWebAuthnSmoke`, `TestPasswordlessSmoke`; remote `git@git.golem15.com:golem15/sm-mfa-plugin.git`, tag `v0.1.0`.
|
||||
- sm-sociallogin-plugin: field `classes.CompletionPayload.Challenge`, field `classes.Outcome.Challenge`, lang key `golem15.sociallogin::lang.oauth.two_factor_unavailable`, smoke test `TestSocialGateSmoke`, the next patch tag.
|
||||
- sm-user-plugin: the next patch tag carrying plan 01.
|
||||
- vue-demo-app: `app/lib/twoFactor.ts` (`isTwoFactorChallenge`, `normalizeRecoveryCode`, `isTotpCode`, `challengeExpired`, `svgDataUrl`, types `TwoFactorChallenge`, `TwoFactorStatus`, `TotpSetup`), `app/composables/useTwoFactor.ts` (`useTwoFactor`), `useDemoAuth` gaining `verifyChallenge` and `recoverChallenge` and a challenge-aware `login`, page `/account/security`, header link, i18n keys `auth.twoFactor.*` and `security.*`.
|
||||
- sm-demo-app: submodule `plugins/golem15/mfa`, `config/golem15.mfa.yaml`, `TestMFAMounted` (mfa_test.go), plugin row in README; sm-bm-app: submodule `plugins/golem15/mfa`, `TestMFAMounted` (mfa_test.go); fonoteka.go: updated `TestNoDeferredRoutesResolve` and user-model count.
|
||||
|
||||
<objective>
|
||||
Add WebAuthn as a second factor and as passwordless passkey login, put social sign-in through the gate behind its setting, give the demo its two-factor screens, then, under the go-ahead, publish in D-17 order and mount the plugin in the demo and bm applications while fonoteka.go moves to the new user plugin.
|
||||
|
||||
Purpose: D-04 (go-webauthn), D-05, D-06, D-08, D-09, D-10 (WebAuthn verify), D-14, D-16 (sixth table), D-17, D-19 (register), D-20; SC-1 (WebAuthn registration and login challenges), SC-4.
|
||||
Output: commits in sm-mfa-plugin, sm-sociallogin-plugin, vue-demo-app, sm-demo-app, sm-bm-app and fonoteka.go; after the reply the repository, tags and pushes listed in Task 5.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-CONTEXT.md
|
||||
@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-RESEARCH.md
|
||||
@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-PATTERNS.md
|
||||
@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-01-SUMMARY.md
|
||||
@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-02-SUMMARY.md
|
||||
|
||||
Repos and concurrency: this plan writes sm-mfa-plugin (../sm-mfa-plugin), sm-sociallogin-plugin (../sm-sociallogin-plugin, at v0.1.2 `9fd58db`), the vue-demo-app submodule checkout (../sm-demo-app/vue-demo-app, origin golem15/vue-demo-app, at `5a8d544`), sm-demo-app, sm-bm-app and fonoteka.go, and pushes sm-user-plugin from ../fonoteka.go/plugins/golem15/user. Shared plugins are committed and pushed before the pointer bumps in applications (CLAUDE.md, core-plugins-own-repos.md); never stage files inside a submodule from the application repository; use `ssu` or plain `git submodule` for submodule moves. In fonoteka.go stage only `plugins/golem15/user` and the test files this plan names (its feedback and golem pointer moves belong to other sessions); fonoteka.go is never pushed. sm-bm-app has untracked planning files from other sessions: stage by explicit path. No co-author tags; one logical change per commit. Docker-backed tests skip under -short; run them through `sudo -u golem` if the socket is denied; record NOT RUN when containers are unreachable.
|
||||
|
||||
PHP source of truth (read-only, /media/nvme/dev/jz/wavepath.org/plugins/golem15/user): controllers/TwoFactorApiController.php (:146-218 WebAuthn challenge, :300-470 WebAuthn management), classes/twofactor/WebAuthnMethod.php (:18-20, :125-142, :170-179, :217-220, :316-321), classes/twofactor/TwoFactorService.php (:322-356 removeWebAuthnCredential, :549-594 status), models/WebAuthnCredential.php, updates/v3.0.0 create_webauthn_credentials_table.php, components/TwoFactor.php (:278, :293 passwordless handlers). Clients: /media/nvme/dev/jz/wavepath.org/vue-wavepath-app/src/types/auth.ts (:157-162 TwoFactorChallenge type).
|
||||
|
||||
<interfaces>
|
||||
- sm-mfa-plugin after plan 02 (19.6-02-SUMMARY.md has the final names): `classes.LoadEffective`, `classes.FindChallenge`, `classes.BeginAttempt`, `classes.CompleteChallenge`, `classes.FailureCapReached`, `classes.CompleteLogin`, `classes.EnableMethod`, `classes.DisableMethod`, `classes.Status`, `classes.IssueTrustedDevice`, `classes.Admin`, the ensure-recovery-codes helper, the events, `controllers` helpers (writeJSON, writeValidation, the jwt principal helper), `models.TwoFactorMethod`, `models.MethodWebAuthn`, routes.go groups.
|
||||
- go-webauthn v0.18.2 (RESEARCH Q6): `webauthn.New(&webauthn.Config{RPID, RPDisplayName, RPOrigins, AttestationPreference, Timeouts})`, `BeginRegistration(user, opts...)`, `CreateCredential(user, session, parsed)`, `BeginLogin(user, opts...)`, `ValidateLogin(user, session, parsed)`, `BeginDiscoverableLogin(opts...)`, `ValidatePasskeyLogin(handler, session, parsed)`, `protocol.ParseCredentialCreationResponseBytes`, `protocol.ParseCredentialRequestResponseBytes`, `webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired)`, `webauthn.WithUserVerification(protocol.VerificationRequired|VerificationPreferred)`, `webauthn.WithExclusions`, `webauthn.WithAllowedCredentials`, `webauthn.WithConveyancePreference(protocol.PreferNoAttestation)`, `webauthn.WithExtensions`, `webauthn.User` (WebAuthnID, WebAuthnName, WebAuthnDisplayName, WebAuthnCredentials), `webauthn.SessionData`, `webauthn.Credential` (JSON round-trip).
|
||||
- sm-sociallogin-plugin v0.1.2: classes/flow.go `signIn` :311-324 (FinishLogin then IssueToken and APIArray), `linkIdentity` (never gated), `Outcome`; classes/codes.go `CompletionPayload` :35-40, `PutCompletion`, `PullCompletion`; controllers/complete.go :17-35 (`writeSession`); controllers/register_complete.go; controllers/callback.go (FlowError redirect); classes/messages.go and lang files; tracer_smoke_test.go and harness_test.go (`bootSocial`, fake providers through the Registry).
|
||||
- sm-user-plugin after plan 01: `usercontrollers.CheckLoginGates`, `userclasses.LoginAttempt`, `userclasses.LoginSourceSocial`, `userclasses.LoginSourcePasskey`, `usercontrollers.FinishLogin`, `IssueToken`, `APIArray`, `FireLoginEvent`, `userclasses.RejectIfThrottled`.
|
||||
- vue-demo-app (../sm-demo-app/vue-demo-app): app/composables/useDemoAuth.ts (login, register, logout, formErrors, authErrors), app/pages/login.vue (submit, busy, error, next guard, Turnstile widget), app/pages/subscriptions.vue (logged-out notice), app/components/SiteHeader.vue (:9-47 links and logout), app/utils/authCookie.ts (DEMO_TOKEN_COOKIE), app/assets/css/site.css (classes wrap, section, panel, form, button, form__error, notice, muted, link-button), i18n/locales/en.json and pl.json, vitest.config.ts (Node tests under app/**/*.test.ts), nuxt.config.ts (`/_user` already proxied in dev).
|
||||
- sm-demo-app: summer.yaml, go.work, go.mod (TestGoModRequiresTags in go_mod_test.go), .gitmodules, boot_test.go (`wantPlugins`, `TestBootAllPlugins`, `routed`), sociallogin_test.go (`socialServer` boot without a database), plugins.gen.go, deploy/env.example, README.md, config/app.yaml (`url: http://127.0.0.1:8097`, `jwt.blacklist_grace: 10`), DEPLOY.md (same-host nginx routing on demo.summercms.io); mount precedent commit `d5a98d9` (`git -C ../sm-demo-app show --stat d5a98d9`), bump precedents `1afd7ff`, `f16ef6c`.
|
||||
- sm-bm-app: summer.yaml, go.mod (`v0.0.0` requires with `replace => ./plugins/...`), go.work, .gitmodules, sociallogin_test.go (`TestSocialLoginMounted`), realtime_test.go (boot harness), plugins.gen.go; mount precedent commit `117ef8f`, bump precedents `cc4b23f`, `1e09c23`.
|
||||
- fonoteka.go: plugins/golem15/fonoteka/phase07_coverage_test.go :79-112 (`banned` list), plugins/golem15/fonoteka/classes/hidden_marshal_test.go (`expectedUserModels`), parity/schema_diff_test.go (Go-only table allowances), plugins/golem15/fonoteka/console/dev_seed_test.go and dev_stack_smoke_test.go (table lists); precedent `7fc790d` (a user-plugin model added with the count bumped).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a signed-in user registers a security key, the next login lists webauthn, and a WebAuthn assertion against server-side options completes the challenge with {token, user}</name>
|
||||
<reversibility rating="costly">D-08: the standard base64url WebAuthn request and response shapes become what clients implement; changing them later breaks every client built against them.</reversibility>
|
||||
<files>../sm-mfa-plugin/go.mod, ../sm-mfa-plugin/go.sum, ../sm-mfa-plugin/routes.go, ../sm-mfa-plugin/config/config.yaml, ../sm-mfa-plugin/classes/config.go, ../sm-mfa-plugin/classes/webauthn.go, ../sm-mfa-plugin/classes/gate.go, ../sm-mfa-plugin/classes/methods.go, ../sm-mfa-plugin/classes/admin.go, ../sm-mfa-plugin/classes/events.go, ../sm-mfa-plugin/controllers/webauthn.go, ../sm-mfa-plugin/models/webauthn_credential.go, ../sm-mfa-plugin/models/registry.go, ../sm-mfa-plugin/updates/202610220006_create_golem15_user_webauthn_credentials.go, ../sm-mfa-plugin/updates/registry.go, ../sm-mfa-plugin/webauthn_authenticator_test.go, ../sm-mfa-plugin/webauthn_smoke_test.go, ../sm-mfa-plugin/README.md</files>
|
||||
<read_first>19.6-02-SUMMARY.md, ../sm-mfa-plugin/classes/challenge.go, ../sm-mfa-plugin/classes/gate.go, ../sm-mfa-plugin/classes/methods.go, ../sm-mfa-plugin/controllers/challenge.go, ../sm-mfa-plugin/controllers/management.go, ../sm-mfa-plugin/routes.go, ../sm-sociallogin-plugin/classes/codes.go (:171-215 cooler Set and Pull), modules/cooler/cooler.go, go-webauthn v0.18.2 sources in the module cache (webauthn/types.go, registration.go, login.go, credential.go, types_session.go, doc.go; protocol/options.go, authenticator.go, credential.go, assertion.go), 19.6-RESEARCH.md (Q6, Pattern 3, Code Examples 5 and 6, Pitfalls 6, 7, 8, Assumptions A3, A4, A11, A12), /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/classes/twofactor/WebAuthnMethod.php, /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/controllers/TwoFactorApiController.php (:146-218, :380-470)</read_first>
|
||||
<action>Per D-04, D-05, D-06, D-08, D-16 (sixth table), D-19. Re-check the pin first when today is after 2026-11-20 (`go list -m -json github.com/go-webauthn/webauthn@latest`); install exactly `go get github.com/go-webauthn/webauthn@v0.18.2` unless the user approves another version, then `go mod tidy`; plugin code never imports the archived YAML v3 module.
|
||||
|
||||
Migration `golem15_user_webauthn_credentials` (id BIGSERIAL PK, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, credential_id TEXT NOT NULL (base64url raw id) with a unique index, public_key TEXT NOT NULL (base64url COSE key), attestation_type VARCHAR(255) NULL, transports JSONB NULL, sign_count BIGINT NOT NULL DEFAULT 0, name VARCHAR(255) NULL, is_enabled BOOLEAN NOT NULL DEFAULT TRUE, last_used_at TIMESTAMPTZ NULL, credential_data TEXT NULL, passwordless BOOLEAN NOT NULL DEFAULT FALSE, created_at, updated_at, index (user_id)) and model `WebAuthnCredential` (CredentialData `json:"-"`).
|
||||
|
||||
classes/webauthn.go: config keys rp_id, rp_name, origins in classes/config.go and config/config.yaml with the defaults in Decisions; `WebAuthnFor(ctx, app)` builds a `webauthn.New` per call (RESEARCH Code Example 5: AttestationPreference none, Login and Registration timeouts 60 s enforced) so settings and config changes apply without restart; `UserHandle(id)` 8 bytes big-endian; a user adapter implementing `webauthn.User` (WebAuthnID the handle, name and display name the user's email and name, credentials decoded from the enabled rows' credential_data). `BeginRegistration(ctx, app, cache, user, passwordless bool)`: exclusions of the user's existing credentials, conveyance none, user verification preferred, and for a passkey resident key required, user verification required and the credProps extension; store the SessionData JSON plus the passwordless flag in cooler under `mfa:webauthn:register:<user id>:<challenge>` for 120 s; return `{"options": creation, "challenge": session.Challenge}`. `FinishRegistration(ctx, app, cache, db, user, challenge, attestationJSON, name)`: `Pull` the session (miss or other user → error), `protocol.ParseCredentialCreationResponseBytes`, `CreateCredential`, insert the row (credential_id and public_key base64url, attestation format, transports, sign count, name or "Security Key", credential_data, passwordless per Decisions). `BeginAssertion(ctx, app, db, challengeRow, user)`: allowed credentials from the user's enabled rows, user verification preferred, store `session.Challenge` in the challenge row's code with method webauthn through a guarded UPDATE (uncompleted, unexpired). `FinishAssertion(ctx, app, tx, challengeRow, user, assertionJSON)`: rebuild SessionData from the row (Challenge, RelyingPartyID, UserID handle, AllowedCredentialIDs from the database, UserVerification preferred, Expires the challenge's expires_at), parse with `protocol.ParseCredentialRequestResponseBytes`, `ValidateLogin`, refuse a clone warning, write the updated Credential back (credential_data, sign_count, last_used_at). Library errors are mapped to fixed reasons and never returned to clients or logged with their text.
|
||||
|
||||
controllers/webauthn.go: `WebAuthnRegisterOptions` (jwt.auth; 403 `{"error":"WebAuthn is not available."}` unless webauthn is available; `passwordless` honoured only with two_factor_passwordless_login on), `WebAuthnRegister` (fields attestation required object, challenge required string, name nullable string max 255; failures answer 422 `{"error":"Registration failed: <fixed reason>"}` with reasons such as "invalid or expired challenge" and "verification failed"; success upserts and enables the webauthn method row, runs the ensure-recovery-codes rule (D-19), fires WebAuthnRegisteredEvent and answers `{"message":"Security key registered successfully.","credential":{"id","name","created_at"},"status":...}` plus recovery_codes when a set was created), `WebAuthnRemove` (DELETE webauthn/{id} scoped by user id; 404 `{"error":"Credential not found."}`; deleting the last enabled credential disables the method, fires DisabledEvent and runs the last-method cleanup; WebAuthnRemovedEvent; `{"message":"Security key removed.","status":...}`), `WebAuthnOptions` (unauthenticated, PHP validation; FindChallenge else 401 `{"error":"Invalid or expired challenge."}`, missing user 401 `{"error":"Invalid challenge."}`, failure cap 401; answers `{options, challenge}`), `WebAuthnVerify` (validation `challenge_token` size 64 and `assertion` required object; BeginAttempt; a row without method webauthn or code → 401 `{"error":"Invalid or expired challenge. Request WebAuthn options first."}`; FinishAssertion failure → FailedEvent and 401 `{"error":"WebAuthn verification failed.","two_factor_failed":true}`; success → CompleteChallenge, CompleteLogin, VerifiedEvent with method webauthn, trusted_device_token per D-10). Status lists webauthn_credentials `{id, name (default "Security Key"), last_used_at, created_at}` with wire.Time; `EnabledMethods` keeps webauthn only with an enabled credential (PHP); Admin.Reset and HasTwoFactor include the credentials (delete them; a credential counts). Routes: challenge group `g.Post("/webauthn/options", ...)`, `g.Post("/webauthn/verify", ...)`; management group `g.Post("/webauthn/register/options", ...)`, `g.Post("/webauthn/register", ...)`, `g.Delete("/webauthn/{id}", ...)` followed directly by `g.Where("id", "[0-9]+")`.
|
||||
|
||||
Test helper webauthn_authenticator_test.go: `virtualAuthenticator` holding an ECDSA P-256 key, a random credential id, a sign counter and flags (UP, UV, BE, BS settable), producing the RegistrationResponseJSON (attestation format none, attested credential data with a zero AAGUID and the COSE EC2 key, clientDataJSON type webauthn.create with the challenge and origin) and the AuthenticationResponseJSON (authenticator data with the RP ID hash, flags and counter, clientDataJSON type webauthn.get, ES256 signature over authData plus SHA-256 of clientDataJSON, userHandle when given), encoding CBOR with go-webauthn's own CBOR helper package (or fxamacker/cbor/v2, already in the graph through go-webauthn, if the helper is not exported). Smoke tests: `TestWebAuthnCeremonySmoke` (no database, -short: WebAuthnFor with rp_id localhost and origin http://localhost:3000; registration options carry attestation none and the challenge; FinishRegistration's library path accepts the virtual authenticator's response; an assertion with a counter that went backwards is refused as a clone; an assertion with BE changed is refused). `TestWebAuthnSmoke` (Postgres, rp_id localhost, origins http://localhost:3000): register options, register with the virtual authenticator (name "Laptop key") answers the PHP message with credential and recovery_codes; status lists the credential; login answers available_methods containing webauthn; webauthn/verify before options answers the "Request WebAuthn options first." 401; options then verify answers a token and the stored sign_count advanced; a second register with the same challenge fails (state consumed); DELETE webauthn/{id} of the only credential disables the method and the next login answers a token for a user without other methods. README (same commit): WebAuthn section (shapes per D-08, RP ID and origins, IP hosts refused, Redis needed for multi-instance ceremonies, PHP credentials not importable as-is). Commit: `feat: WebAuthn security keys as a second factor`.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-mfa-plugin vet ./... && go -C ../sm-mfa-plugin test -count=1 -short ./... && go -C ../sm-mfa-plugin test -count=1 -short -run '^TestWebAuthnCeremonySmoke$' -v . 2>&1 | grep -q -- '^--- PASS: TestWebAuthnCeremonySmoke'</automated>
|
||||
<fails_when>Non-zero exit: vet or a short test is red, or no "--- PASS: TestWebAuthnCeremonySmoke" line (the virtual authenticator's responses are refused or the clone and BE checks do not hold).</fails_when>
|
||||
<automated>go -C ../sm-mfa-plugin test -count=1 -run '^(TestWebAuthnSmoke|TestTracerSmoke|TestChallengeSmoke|TestTrustedSettingsSmoke)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 4</automated>
|
||||
<fails_when>The count of top-level "--- PASS: Test" lines is not 4: the WebAuthn path failed, an earlier smoke regressed, or a test skipped because Docker was unreachable (rerun through sudo -u golem, else record NOT RUN).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'github.com/go-webauthn/webauthn v0.18.2' ../sm-mfa-plugin/go.mod` prints 1 (or the version the user approved, recorded in the SUMMARY).
|
||||
- `grep -c 'credential_data' ../sm-mfa-plugin/updates/202610220006_create_golem15_user_webauthn_credentials.go` prints at least 1 and `grep -c 'passwordless' ../sm-mfa-plugin/updates/202610220006_create_golem15_user_webauthn_credentials.go` prints at least 1.
|
||||
- `grep -c 'Pull(' ../sm-mfa-plugin/classes/webauthn.go` prints at least 1 and `grep -c 'PreferNoAttestation' ../sm-mfa-plugin/classes/webauthn.go` prints at least 1.
|
||||
- `grep -A1 '"/webauthn/{id}"' ../sm-mfa-plugin/routes.go | grep -c 'Where("id", "\[0-9\]+")'` prints 1.
|
||||
- `grep -c '"WebAuthn verification failed."' ../sm-mfa-plugin/controllers/webauthn.go` prints at least 1 and `grep -c 'Request WebAuthn options first.' ../sm-mfa-plugin/controllers/webauthn.go` prints 1.
|
||||
- `D=$(go -C ../sm-mfa-plugin list -deps .) && test "$(printf '%s\n' "$D" | grep -c '^gopkg.in/yaml')" -eq 0` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>A user can add a security key and sign in with it as their second factor, with all ceremony state held on the server.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: A user who registered a passkey signs in with it alone when the site allows passwordless login, and social sign-in asks for the second factor when the site turns that on</name>
|
||||
<reversibility rating="costly">D-14: once users register passkeys and sign in without a password, removing the passwordless routes strands them.</reversibility>
|
||||
<files>../sm-mfa-plugin/classes/passwordless.go, ../sm-mfa-plugin/controllers/passwordless.go, ../sm-mfa-plugin/routes.go, ../sm-mfa-plugin/lang/en/lang.yaml, ../sm-mfa-plugin/lang/pl/lang.yaml, ../sm-mfa-plugin/passwordless_smoke_test.go, ../sm-mfa-plugin/README.md, ../sm-sociallogin-plugin/classes/flow.go, ../sm-sociallogin-plugin/classes/codes.go, ../sm-sociallogin-plugin/controllers/complete.go, ../sm-sociallogin-plugin/controllers/register_complete.go, ../sm-sociallogin-plugin/lang/en/lang.yaml, ../sm-sociallogin-plugin/lang/pl/lang.yaml, ../sm-sociallogin-plugin/social_gate_smoke_test.go, ../sm-sociallogin-plugin/README.md</files>
|
||||
<read_first>../sm-mfa-plugin/classes/webauthn.go and controllers/webauthn.go (Task 1), ../sm-mfa-plugin/webauthn_authenticator_test.go, ../fonoteka.go/plugins/golem15/user/classes/throttle.go (RejectIfThrottled :67), ../fonoteka.go/plugins/golem15/user/controllers/login_gates.go, ../sm-sociallogin-plugin/classes/flow.go (signIn :311-324, linkIdentity, Outcome), ../sm-sociallogin-plugin/classes/codes.go, ../sm-sociallogin-plugin/controllers/complete.go, ../sm-sociallogin-plugin/controllers/register_complete.go, ../sm-sociallogin-plugin/controllers/callback.go, ../sm-sociallogin-plugin/classes/messages.go, ../sm-sociallogin-plugin/tracer_smoke_test.go, ../sm-sociallogin-plugin/harness_test.go, ../sm-sociallogin-plugin/README.md, /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/components/TwoFactor.php (:278, :293), 19.6-RESEARCH.md (Q5 sociallogin, Q6 passwordless, Code Example 6, Security Domain, Assumption A7)</read_first>
|
||||
<action>D-14 in sm-mfa-plugin. classes/passwordless.go: `BeginPasswordless(ctx, app, cache)` with `BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired))`, session JSON in cooler under `mfa:webauthn:passwordless:-:<challenge>` for 120 s, returning `{options, challenge}`; `FinishPasswordless(ctx, app, cache, db, challenge, assertionJSON, ip)` per RESEARCH Code Example 6: Pull the session (miss → failure); `ValidatePasskeyLogin` with a handler that loads the credential by the base64url raw id and refuses it unless it exists, is enabled, has passwordless true and its user's handle equals the returned userHandle; refuse a clone warning; write the credential back; then the user (soft-deleted ones included through Unscoped) must be activated and pass `userclasses.RejectIfThrottled` (ban and suspension); any refusal is one generic failure. controllers/passwordless.go: `PasswordlessOptions` and `PasswordlessVerify` (fields challenge required string, assertion required object); both answer 403 `{"error":"Passwordless login is not available."}` while `two_factor_passwordless_login` is off (read per request); a failure answers 401 `{"error":"WebAuthn verification failed."}`; success runs `usercontrollers.FinishLogin` (restores a deactivated account, as a full password-plus-second-factor login would), `IssueToken`, `APIArray`, `FireLoginEvent` with `userclasses.LoginSourcePasskey`, and answers `{token, user}`; no gate, no second challenge (D-14). Routes in the challenge group (2fa-verify bucket): `g.Post("/webauthn/passwordless/options", ...)`, `g.Post("/webauthn/passwordless/verify", ...)`. README (same commit): Passkeys section (setting, passkey registration with passwordless true, the UV and resident-key rules, activation, ban and suspension checks, no second challenge, the security review in plan 04). Smoke test passwordless_smoke_test.go `TestPasswordlessSmoke` (Postgres, rp_id localhost) with subtests: `setting_off` (both routes 403); `passkey_signs_in` (setting on, register with passwordless true, UV and credProps rk true, then options and verify with the userHandle answer a token for that user and fire one login event with Source passkey); `second_factor_key_refused` (a credential registered without passwordless answers 401); `uv_missing_refused` (an assertion without the UV flag answers 401); `banned_refused` (a banned user's passkey answers 401); `state_once` (verify reusing a consumed challenge answers 401). Commit in sm-mfa-plugin: `feat: passwordless passkey login`.
|
||||
|
||||
D-09 in sm-sociallogin-plugin. classes/flow.go `signIn`: before FinishLogin call `usercontrollers.CheckLoginGates(ctx, app, userclasses.LoginAttempt{User: user, Source: userclasses.LoginSourceSocial, Request: r})`; an error returns the error (the callback's existing error path; no token); a result with status 200 (or 0) returns `Outcome{Kind: OutcomeLogin, Challenge: res.Body, Action: action, ReturnTo: returnTo}` without calling FinishLogin or IssueToken; any other status returns `FlowError{Key: "two_factor_unavailable"}`. `linkIdentity` stays ungated. classes/codes.go: `CompletionPayload` gains `Challenge map[string]any` with JSON name challenge and omitempty, and the callback stores the outcome's Challenge in it. controllers/complete.go: when the pulled payload has a Challenge, answer 200 with exactly that body (no token cookie handling); register_complete.go likewise for an outcome with a Challenge. Lang en and pl `oauth.two_factor_unavailable` ("Two-factor verification is temporarily unavailable. Try again later." and Polish). go.mod keeps requiring sm-user-plugin v0.1.5 with the go.work replace until Task 5. README (same commit): a "Two-factor sign-in" section: with a two-factor plugin that enables its social-login setting, oauth-complete and oauth-register-complete answer the two_factor_required body instead of a token, the SPA callback must handle it before the setting is turned on, linking is never gated, trusted devices do not apply (no login body). Smoke test social_gate_smoke_test.go `TestSocialGateSmoke` (Postgres, a fake gate added to the published LoginGates, no dependency on sm-mfa-plugin) with subtests: `no_gate_unchanged` (the tracer's sign-in answers token, user, action, return_to); `challenge_body` (a gate returning Status 200 with a two_factor_required body: oauth-complete answers exactly that body, no token key, and a soft-deleted identity owner stays soft-deleted); `refusal_redirects` (Status 401: the callback redirects with ?error= carrying the two_factor_unavailable message); `gate_error_fails_closed` (no completion code with a token is issued); `link_not_gated` (action=link with a link ticket calls the gate zero times). Commit in sm-sociallogin-plugin: `feat: social sign-in through the login gate`.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-mfa-plugin vet ./... && go -C ../sm-mfa-plugin test -count=1 -short ./... && go -C ../sm-sociallogin-plugin vet ./... && go -C ../sm-sociallogin-plugin test -count=1 -short ./...</automated>
|
||||
<fails_when>Non-zero exit: vet or a short test of either plugin is red.</fails_when>
|
||||
<automated>go -C ../sm-mfa-plugin test -count=1 -run '^(TestPasswordlessSmoke|TestWebAuthnSmoke)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>The count of top-level "--- PASS: Test" lines is not 2: a passkey rule does not hold or a test skipped because Docker was unreachable (rerun through sudo -u golem, else record NOT RUN).</fails_when>
|
||||
<automated>go -C ../sm-sociallogin-plugin test -count=1 -run '^(TestSocialGateSmoke|TestTracerSmoke)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>The count of top-level "--- PASS: Test" lines is not 2: the social gate behaves wrongly, the v0.1.2 tracer regressed, or a test skipped (Docker unreachable: record NOT RUN).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'ValidatePasskeyLogin' ../sm-mfa-plugin/classes/passwordless.go` prints at least 1 and `grep -c 'VerificationRequired' ../sm-mfa-plugin/classes/passwordless.go` prints at least 1.
|
||||
- `grep -c '"/webauthn/passwordless/options"' ../sm-mfa-plugin/routes.go` prints 1 and `grep -c '"/webauthn/passwordless/verify"' ../sm-mfa-plugin/routes.go` prints 1.
|
||||
- `grep -c 'Passwordless login is not available.' ../sm-mfa-plugin/controllers/passwordless.go` prints at least 1.
|
||||
- `grep -c 'CheckLoginGates(' ../sm-sociallogin-plugin/classes/flow.go` prints 1 and `grep -c 'LoginSourceSocial' ../sm-sociallogin-plugin/classes/flow.go` prints 1.
|
||||
- `grep -c 'json:"challenge,omitempty"' ../sm-sociallogin-plugin/classes/codes.go` prints 1.
|
||||
- `grep -c 'two_factor_unavailable' ../sm-sociallogin-plugin/lang/en/lang.yaml` prints 1 and the same count for lang/pl/lang.yaml is 1.
|
||||
- `grep -c 'two_factor_required' ../sm-sociallogin-plugin/README.md` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>A user with a passkey signs in without a password where the site allows it, and sites that want it can require the second factor after a social sign-in too.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: The demo site lets a signed-in user turn on an authenticator app with a QR code, keep their recovery codes, turn it off, and answer the login challenge with a TOTP or recovery code</name>
|
||||
<files>../sm-demo-app/vue-demo-app/app/lib/twoFactor.ts, ../sm-demo-app/vue-demo-app/app/lib/twoFactor.test.ts, ../sm-demo-app/vue-demo-app/app/composables/useDemoAuth.ts, ../sm-demo-app/vue-demo-app/app/composables/useTwoFactor.ts, ../sm-demo-app/vue-demo-app/app/pages/login.vue, ../sm-demo-app/vue-demo-app/app/pages/account/security.vue, ../sm-demo-app/vue-demo-app/app/components/SiteHeader.vue, ../sm-demo-app/vue-demo-app/i18n/locales/en.json, ../sm-demo-app/vue-demo-app/i18n/locales/pl.json</files>
|
||||
<read_first>../sm-demo-app/vue-demo-app/app/composables/useDemoAuth.ts, ../sm-demo-app/vue-demo-app/app/pages/login.vue, ../sm-demo-app/vue-demo-app/app/pages/register.vue, ../sm-demo-app/vue-demo-app/app/pages/subscriptions.vue, ../sm-demo-app/vue-demo-app/app/components/SiteHeader.vue, ../sm-demo-app/vue-demo-app/app/utils/authCookie.ts, ../sm-demo-app/vue-demo-app/app/assets/css/site.css, ../sm-demo-app/vue-demo-app/i18n/locales/en.json, ../sm-demo-app/vue-demo-app/i18n/locales/pl.json, ../sm-demo-app/vue-demo-app/vitest.config.ts, ../sm-demo-app/vue-demo-app/app/lib/forms.ts, ../sm-demo-app/vue-demo-app/app/utils/journalLocale.test.ts (test style), /media/nvme/dev/jz/wavepath.org/vue-wavepath-app/src/types/auth.ts (:157-162), 19.6-02-SUMMARY.md (management response bodies, D-19 recovery_codes), 19.6-PATTERNS.md (VDEMO section)</read_first>
|
||||
<action>Per D-20, in the submodule checkout ../sm-demo-app/vue-demo-app (check `git -C ../sm-demo-app/vue-demo-app status --porcelain` is empty and its branch is master tracking origin first; stop and report otherwise). Never touch ../vue-demo-app.
|
||||
|
||||
app/lib/twoFactor.ts (pure, unit-tested): types `TwoFactorChallenge` ({two_factor_required: true, challenge_token, available_methods, expires_at}), `TwoFactorStatus` (the PHP status keys), `TotpSetup` ({secret, qr_code_svg, qr_code_url}); `isTwoFactorChallenge(value)` (true only for an object with two_factor_required === true and a 64-character string challenge_token); `normalizeRecoveryCode(s)` (uppercase, spaces removed, as the server does); `isTotpCode(s)` (exactly six digits after trimming); `challengeExpired(expiresAt, now)` (true for an unparsable or past expires_at); `svgDataUrl(svg)` (`data:image/svg+xml;base64,` plus base64 of the UTF-8 bytes, so the server's SVG is shown through an img and never injected as markup). app/lib/twoFactor.test.ts covers each with positive and negative cases (a token answer is not a challenge; a 63-character token is not; " abcde-23456 " normalizes to "ABCDE-23456"; "12345" and "1234567" are not TOTP codes; the data URL decodes back to the input).
|
||||
|
||||
useDemoAuth.ts: `login` returns `{ challenge: TwoFactorChallenge }` when the answer is a challenge and `{}` after storing a token as today (the caller decides); new `verifyChallenge(challengeToken, code)` posts `{challenge_token, method: 'totp', code}` to `/_user/api/v1/2fa/verify` and `recoverChallenge(challengeToken, code)` posts to `/_user/api/v1/2fa/recovery`, both storing the returned token. useTwoFactor.ts: `useTwoFactor()` with `status()`, `setupTotp()`, `confirmTotp(secret, code)` (returns the status and the recovery_codes when present) and `disableTotp()`, each `$fetch` with `Authorization: Bearer <token>` from the DEMO_TOKEN_COOKIE cookie. login.vue: after a challenge answer, show a second form in the same panel: a code input (inputmode numeric, autocomplete one-time-code, maxlength 6) with a submit, a link-button switching to a recovery-code input, a "start over" link-button back to the password form; an expired challenge (challengeExpired) or a 401 shows the server's error through authErrors and offers start over; success navigates to `next` as today. New pages/account/security.vue: logged-out notice like subscriptions.vue; when TOTP is off, "Set up an authenticator app" calls setupTotp and shows the QR through `<img :src="svgDataUrl(setup.qr_code_svg)">` with width and height 200 and alt text, the secret as copyable text for manual entry and a confirm form; after confirm, when recovery_codes came back, show them once in a list with a note to store them and a "Saved them" button that clears them from memory; when TOTP is on, the remaining recovery-code count and a "Turn off" button with a confirm(). SiteHeader.vue: a NuxtLink to /account/security beside "My presentations" when logged in. i18n en and pl: keys `auth.twoFactor.*` (title, prompt, code label, verify, useRecovery, useApp, recoveryLabel, startOver, expired, failed) and `security.*` (title, lead, setup, scan, manual, confirm, enabled, disabled, turnOff, turnOffConfirm, recoveryTitle, recoveryNote, saved, remaining, loginFirst) with natural Polish. Styling uses only the existing classes (panel, form, button, link-button, form__error, notice, muted).
|
||||
|
||||
Commit in the vue-demo-app repository (local only until Task 5): `feat: two-factor setup and the login challenge`.</action>
|
||||
<verify>
|
||||
<automated>pnpm --dir ../sm-demo-app/vue-demo-app test && pnpm --dir ../sm-demo-app/vue-demo-app exec vitest run --reporter=verbose app/lib/twoFactor.test.ts 2>&1 | grep -q 'twoFactor.test.ts'</automated>
|
||||
<fails_when>Non-zero exit of the vitest suite, or no line naming twoFactor.test.ts in the verbose run (the new tests are missing or were not collected).</fails_when>
|
||||
<automated>pnpm --dir ../sm-demo-app/vue-demo-app build</automated>
|
||||
<fails_when>Non-zero exit: the Nuxt build fails (type or template errors in the new page, composables or header).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'v-html' ../sm-demo-app/vue-demo-app/app/pages/account/security.vue` prints 0 and `grep -c 'svgDataUrl' ../sm-demo-app/vue-demo-app/app/pages/account/security.vue` prints at least 1.
|
||||
- `grep -c '2fa/verify' ../sm-demo-app/vue-demo-app/app/composables/useDemoAuth.ts` prints 1 and `grep -c '2fa/recovery' ../sm-demo-app/vue-demo-app/app/composables/useDemoAuth.ts` prints 1.
|
||||
- `grep -c 'totp/setup' ../sm-demo-app/vue-demo-app/app/composables/useTwoFactor.ts` prints 1 and `grep -c 'recovery_codes' ../sm-demo-app/vue-demo-app/app/composables/useTwoFactor.ts` prints at least 1.
|
||||
- `grep -c '/account/security' ../sm-demo-app/vue-demo-app/app/components/SiteHeader.vue` prints at least 1.
|
||||
- `grep -c '"twoFactor"' ../sm-demo-app/vue-demo-app/i18n/locales/en.json` prints 1 and the same count for pl.json is 1.
|
||||
- `R=$(git -C ../sm-demo-app/vue-demo-app remote get-url origin) && test "$R" = "git@git.golem15.com:golem15/vue-demo-app.git"` exits 0 and `S=$(git -C ../vue-demo-app status --porcelain) && test -z "$S"` exits 0 (the stale sibling stays untouched).
|
||||
</acceptance_criteria>
|
||||
<done>A visitor on the demo can protect their account with an authenticator app, keep their recovery codes, and pass the extra login step.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 4: Go-ahead for the phase's remote operations in D-17 order (sm-user-plugin push and tag, new sm-mfa-plugin repository and tag, sm-sociallogin-plugin push and tag, vue-demo-app push, then sm-demo-app and sm-bm-app pushes after their commits)</name>
|
||||
<read_first>19.6-CONTEXT.md (D-17), 19.6-01-SUMMARY.md, 19.6-02-SUMMARY.md, CLAUDE.md (Commits and concurrency), .planning/notes/core-plugins-own-repos.md</read_first>
|
||||
<acceptance_criteria>
|
||||
- The SUMMARY draft records the reply verbatim under a "Task 4 reply" heading, with the tag numbers it names.
|
||||
- Before the reply, `R=$(git -C ../sm-mfa-plugin remote) && test -z "$R"` exits 0 and `T=$(git -C ../fonoteka.go/plugins/golem15/user tag --points-at HEAD) && test -z "$T"` exits 0.
|
||||
</acceptance_criteria>
|
||||
<decision>Which remote operations may this phase perform, and with which tags?</decision>
|
||||
<context>Tasks 1-3 and plans 01-02 are committed locally and green. Task 6 mounts sm-mfa-plugin as a git submodule in sm-demo-app and sm-bm-app and bumps sm-user-plugin and sm-sociallogin-plugin pointers, which must reference pushed commits (CLAUDE.md: plugin pushed first, pointer bump in the application as a separate commit). sm-demo-app's TestGoModRequiresTags requires every mounted plugin at a vX.Y.Z tag, so the demo cannot mount mfa without tags; sm-bm-app pins plugins as v0.0.0 with replaces and needs only pushed commits. Proposed tags: sm-user-plugin the next patch after its newest tag on origin (v0.1.6 unless Phase 19.8 tagged one meanwhile; it carries the login gates, events, reset slot and the device table migration, which become one-way for applications that run migrate), sm-sociallogin-plugin v0.1.3, sm-mfa-plugin v0.1.0 (new private repository created with `tea repos create --owner golem15 --name sm-mfa-plugin`). vue-demo-app is pushed without a tag. No framework tag and no changelog post are needed (no framework change). fonoteka.go gets a local commit only, as always. Nothing is deployed in any option.</context>
|
||||
<options>
|
||||
<option id="release">
|
||||
<name>release (optionally naming tags, for example "release user v0.1.6 sociallogin v0.1.3 mfa v0.1.0")</name>
|
||||
<pros>D-17 exactly: tagged, reproducible pins; the demo and bm mount mfa in this phase; the device flow reaches both applications.</pros>
|
||||
<cons>Publishes the device-session migration and the new tables; later changes need migrations.</cons>
|
||||
</option>
|
||||
<option id="push-only">
|
||||
<name>push-only</name>
|
||||
<pros>Repositories exist; sm-bm-app (v0.0.0 replaces) mounts mfa; fonoteka.go and sm-bm-app move to the new user plugin.</pros>
|
||||
<cons>No tags: sm-demo-app cannot mount mfa or bump the user plugin (TestGoModRequiresTags), so its part of D-17 and the D-20 UI pointer bump wait for a later release; recorded as a deviation with a todo.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>hold</name>
|
||||
<pros>Nothing leaves this machine.</pros>
|
||||
<cons>Task 5 and the remote half of Task 6 do not run; only fonoteka.go's local pointer bump can happen; D-17 remains open and the phase pauses after this plan.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "release" (optionally with tags), "push-only" or "hold". release and push-only also authorise pushing sm-demo-app and sm-bm-app after Task 6's commits, each only when its unpushed commits are this phase's (or ones the reply names); nothing is ever force-pushed or deployed. Say whether plan 04 may publish patch tags for post-release fixes under this same reply or must ask again.</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: Under the Task 4 reply, publish in D-17 order: sm-user-plugin, then sm-mfa-plugin and sm-sociallogin-plugin pinned to it, then vue-demo-app</name>
|
||||
<precondition>19.6-03-SUMMARY.md (draft) records the Task 4 reply "release" or "push-only"; on "hold" this task records "remote operations held" and does nothing else.</precondition>
|
||||
<reversibility rating="one-way">Pushed tags and the device-session and 2FA migrations become what applications pin and run; undoing them needs new releases and migrations (gated by Task 4).</reversibility>
|
||||
<files>../sm-mfa-plugin/go.mod, ../sm-mfa-plugin/go.sum, ../sm-mfa-plugin/README.md, ../sm-sociallogin-plugin/go.mod, ../sm-sociallogin-plugin/go.sum</files>
|
||||
<read_first>19.6-03-SUMMARY.md draft (Task 4 reply), 19.6-01-SUMMARY.md and 19.6-02-SUMMARY.md (this phase's commits per repository), CLAUDE.md (Commits and concurrency), .planning/STATE.md decisions for Phases 19.1 and 19.5 (tea repos create precedent), ../sm-sociallogin-plugin/go.mod</read_first>
|
||||
<action>Per D-17 and the recorded reply. Before every push: `git fetch origin`, `git status --porcelain` shows no change to this phase's paths, and `git log --oneline origin/master..master` lists only this phase's commits from the SUMMARYs (or ones the reply names); if anything else appears, or origin moved ahead, stop and report (no rebase without the user). Never force-push. Never deploy.
|
||||
|
||||
On "release": (1) sm-user-plugin (../fonoteka.go/plugins/golem15/user): `go -C ../fonoteka.go vet ./plugins/golem15/user/...` and the short suite green; `git push origin master`; annotated tag with the reply's version (one paragraph: login gates, login and password-changed events, reset-2FA slot, device authorization); push the tag. (2) sm-mfa-plugin: require that sm-user-plugin tag and the framework version sm-sociallogin-plugin requires (v0.3.1; a newer tag on origin only if the code needs one, checked with `git -C ../summercms.go ls-remote --tags origin`), `go mod tidy`, vet and short tests green, README Dependencies names both versions; commit `chore: require sm-user-plugin <tag>`; `tea repos create --owner golem15 --name sm-mfa-plugin` (private unless the reply says otherwise), `git remote add origin git@git.golem15.com:golem15/sm-mfa-plugin.git`, `git push -u origin master`, annotated tag v0.1.0 (or the reply's), push it. (3) sm-sociallogin-plugin: require the same sm-user-plugin tag, `go mod tidy`, vet and short tests green; commit `chore: require sm-user-plugin <tag>`; push master; annotated tag (v0.1.3 or the reply's); push it. (4) vue-demo-app (../sm-demo-app/vue-demo-app): push master (no tag).
|
||||
|
||||
On "push-only": the same pushes without tags; sm-mfa-plugin and sm-sociallogin-plugin keep their current requires (recorded deviation) and the repository is created and pushed without a tag.
|
||||
|
||||
Record in the SUMMARY: each pushed commit range, each tag, the new repository URL.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-mfa-plugin vet ./... && go -C ../sm-mfa-plugin test -count=1 -short ./... && git -C ../sm-mfa-plugin ls-remote --exit-code origin refs/heads/master && git -C ../fonoteka.go/plugins/golem15/user ls-remote --exit-code origin refs/heads/master</automated>
|
||||
<fails_when>Non-zero exit: the plugin no longer builds or passes after the require bump, or ls-remote exits 2 or 128 because a pushed branch does not exist (repository not created or not pushed). On "hold" the command is not run and the SUMMARY says so.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- On release: `git -C ../fonoteka.go/plugins/golem15/user ls-remote --exit-code --tags origin <user tag>` exits 0, `git -C ../sm-mfa-plugin ls-remote --exit-code --tags origin v0.1.0` exits 0, `git -C ../sm-sociallogin-plugin ls-remote --exit-code --tags origin <sociallogin tag>` exits 0, and `grep -c 'git.golem15.com/golem15/sm-user-plugin <user tag>' ../sm-mfa-plugin/go.mod` prints 1.
|
||||
- On release and push-only: `L=$(git -C ../fonoteka.go/plugins/golem15/user log --oneline origin/master..master) && test -z "$L"` exits 0 and `L=$(git -C ../sm-demo-app/vue-demo-app log --oneline origin/master..master) && test -z "$L"` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>The user plugin, the new two-factor plugin, the social-login plugin and the demo frontend are on git.golem15.com in D-17 order, so applications can pin them.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 6: The demo and bm applications mount two-factor sign-in and get the device flow, and fonoteka.go moves to the new user plugin with its guard tests updated</name>
|
||||
<precondition>19.6-03-SUMMARY.md (draft) records Task 5's pushes for "release" or "push-only"; on "hold" only part (3) below runs.</precondition>
|
||||
<files>../sm-demo-app/.gitmodules, ../sm-demo-app/plugins/golem15/mfa, ../sm-demo-app/plugins/golem15/user, ../sm-demo-app/plugins/golem15/sociallogin, ../sm-demo-app/vue-demo-app, ../sm-demo-app/go.mod, ../sm-demo-app/go.sum, ../sm-demo-app/go.work, ../sm-demo-app/summer.yaml, ../sm-demo-app/plugins.gen.go, ../sm-demo-app/boot_test.go, ../sm-demo-app/mfa_test.go, ../sm-demo-app/config/golem15.mfa.yaml, ../sm-demo-app/deploy/env.example, ../sm-demo-app/README.md, ../sm-bm-app/.gitmodules, ../sm-bm-app/plugins/golem15/mfa, ../sm-bm-app/plugins/golem15/user, ../sm-bm-app/plugins/golem15/sociallogin, ../sm-bm-app/go.mod, ../sm-bm-app/go.sum, ../sm-bm-app/go.work, ../sm-bm-app/summer.yaml, ../sm-bm-app/plugins.gen.go, ../sm-bm-app/mfa_test.go, ../sm-bm-app/README.md, ../fonoteka.go/plugins/golem15/user, ../fonoteka.go/plugins/golem15/fonoteka/phase07_coverage_test.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/hidden_marshal_test.go</files>
|
||||
<read_first>19.6-03-SUMMARY.md draft (reply, tags), `git -C ../sm-demo-app show --stat d5a98d9` and `git -C ../sm-bm-app show --stat 117ef8f` (mount precedents), ../sm-demo-app/summer.yaml, ../sm-demo-app/go.work, ../sm-demo-app/go.mod, ../sm-demo-app/go_mod_test.go, ../sm-demo-app/.gitmodules, ../sm-demo-app/boot_test.go, ../sm-demo-app/sociallogin_test.go, ../sm-demo-app/deploy/env.example, ../sm-demo-app/README.md, ../sm-demo-app/DEPLOY.md, ../sm-bm-app/summer.yaml, ../sm-bm-app/go.mod, ../sm-bm-app/go.work, ../sm-bm-app/sociallogin_test.go, ../sm-bm-app/README.md, ../fonoteka.go/plugins/golem15/fonoteka/phase07_coverage_test.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/hidden_marshal_test.go, `git -C ../fonoteka.go show --stat 7fc790d`, ../sm-mfa-plugin/README.md (config keys)</read_first>
|
||||
<action>Per D-17, each application commit separate, plugins already pushed (Task 5). Check `git status --porcelain` of each application before editing; stop and report when another session's edits touch a file below.
|
||||
|
||||
(1) sm-demo-app (release only; on push-only record the deviation and skip to (2)): commit A `chore: bump sm-user-plugin to <tag>` (submodule checkout of the tag, go.mod require, go.sum through `go mod tidy`); commit B `chore: bump sm-sociallogin-plugin to <tag>` likewise; commit C `feat: mount golem15.mfa`, as d5a98d9 did for sociallogin: `git -C ../sm-demo-app submodule add -b master git@git.golem15.com:golem15/sm-mfa-plugin.git plugins/golem15/mfa` (or ssu) at tag v0.1.0, go.work `use ./plugins/golem15/mfa`, summer.yaml entry `- id: golem15.mfa` / `module: git.golem15.com/golem15/sm-mfa-plugin` after golem15.sociallogin and before golem15.demo, go.mod require v0.1.0 and tidy, `summer build` (installed from ../summercms.go with `go install ./cmd/summer`) regenerating plugins.gen.go (keep only that change; report anything else it rewrites), boot_test.go `wantPlugins` gains golem15.mfa in summer.yaml order and the routed prefixes gain `/_user/api/v1/2fa/` and `/_user/api/v1/devices`, config/golem15.mfa.yaml with two_factor_mode optional, two_factor_available_methods [totp, email], the social-login toggle off and a comment that the deployed demo serves SPA and API on one host so the WebAuthn RP defaults to the app.url host while a local 127.0.0.1 run cannot use WebAuthn (Pitfall 7), deploy/env.example with commented SUMMER_GOLEM15__MFA__ lines for the mode and the WebAuthn RP override, README plugin row; new mfa_test.go `TestMFAMounted` booting the committed config without a database (as socialServer does) and asserting that POST /_user/api/v1/2fa/verify, POST /_user/api/v1/2fa/webauthn/passwordless/options, GET /_user/api/v1/2fa/status, POST /_user/api/v1/devices/initiate, GET /_user/api/v1/devices/status/{token} and DELETE /_user/api/v1/devices/{id} resolve; commit D `chore: bump vue-demo-app to the two-factor UI` (pointer only). Push sm-demo-app per the reply after the pre-push check.
|
||||
|
||||
(2) sm-bm-app (release and push-only): commit A `chore: bump sm-user-plugin to <tag or commit>` (pointer, plus go.mod or go.sum only if they change); commit B `chore: bump sm-sociallogin-plugin to <tag or commit>`; commit C `feat: mount golem15.mfa` as 117ef8f did: submodule at plugins/golem15/mfa, go.mod `require git.golem15.com/golem15/sm-mfa-plugin v0.0.0` with `replace git.golem15.com/golem15/sm-mfa-plugin => ./plugins/golem15/mfa`, go.work use, summer.yaml entry after golem15.sociallogin, regenerated plugins.gen.go, README row (API only; the frontend has no 2FA screens, so users cannot enable 2FA there yet and logins are unchanged), mfa_test.go `TestMFAMounted` (boot without a database like TestSocialLoginMounted, the same route assertions). Push sm-bm-app per the reply.
|
||||
|
||||
(3) fonoteka.go (always, local commit only, never pushed): one commit `chore: bump sm-user-plugin to <tag or commit>` staging only `plugins/golem15/user` (already at the pushed commit, or the local one on hold) and the test updates the full suite needs: phase07_coverage_test.go removes the device-routes fragment from `banned` and keeps the 2fa fragment (fonoteka still must not resolve the two-factor routes); hidden_marshal_test.go raises `expectedUserModels` by one with a comment naming DeviceAuthSession (7fc790d precedent); any other fonoteka test that inventories user-plugin tables, routes or permissions (parity/schema_diff_test.go Go-only allowances, console/dev_seed_test.go, dev_stack_smoke_test.go) gets the matching additive entry with a comment, only where the full suite shows it is needed. fonoteka.go does not mount golem15.mfa.</action>
|
||||
<verify>
|
||||
<automated>go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test -count=1 -short ./... && go -C ../fonoteka.go build ./...</automated>
|
||||
<fails_when>Non-zero exit: fonoteka.go no longer builds, or a short test is red at the bumped user-plugin pointer (for example the guard test still bans /devices or the model count is stale).</fails_when>
|
||||
<automated>go -C ../sm-demo-app build ./... && go -C ../sm-demo-app vet . && go -C ../sm-demo-app test -count=1 -short -run '^(TestMFAMounted|TestBootAllPlugins|TestGoModRequiresTags)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>Non-zero exit, or the count of top-level "--- PASS: Test" lines is not 3: the demo does not build, a route does not resolve, a plugin is missing from wantPlugins, or a require is not a tag. On push-only and hold this command is not run and the SUMMARY says so.</fails_when>
|
||||
<automated>go -C ../sm-bm-app build ./... && go -C ../sm-bm-app vet . && go -C ../sm-bm-app test -count=1 -short . && go -C ../sm-bm-app test -count=1 -short -run '^TestMFAMounted$' -v . 2>&1 | grep -q -- '^--- PASS: TestMFAMounted'</automated>
|
||||
<fails_when>Non-zero exit, or no "--- PASS: TestMFAMounted" line: bm does not build or the mfa routes do not resolve. On hold this command is not run and the SUMMARY says so.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c '"/devices"' ../fonoteka.go/plugins/golem15/fonoteka/phase07_coverage_test.go` prints 0 and `grep -c '"2fa"' ../fonoteka.go/plugins/golem15/fonoteka/phase07_coverage_test.go` prints 1.
|
||||
- `grep -c 'golem15.mfa' ../fonoteka.go/summer.yaml` prints 0 and `M=$(go -C ../fonoteka.go list -m all) && test "$(printf '%s\n' "$M" | grep -c 'go-webauthn')" -eq 0` exits 0 (D-01).
|
||||
- On release: `grep -c 'id: golem15.mfa' ../sm-demo-app/summer.yaml` prints 1, `grep -c 'git.golem15.com/golem15/sm-mfa-plugin v0.1.0' ../sm-demo-app/go.mod` prints 1, and `grep -c 'golem15.mfa' ../sm-demo-app/plugins.gen.go` prints at least 1.
|
||||
- On release and push-only: `grep -c 'id: golem15.mfa' ../sm-bm-app/summer.yaml` prints 1 and `L=$(git -C ../sm-bm-app log --oneline origin/master..master) && test -z "$L"` exits 0 after the push.
|
||||
- `L=$(git -C ../fonoteka.go log -1 --format=%s -- plugins/golem15/user) && test "${L#chore: bump sm-user-plugin}" != "$L"` exits 0 (the pointer moved in the bump commit).
|
||||
</acceptance_criteria>
|
||||
<done>Both applications offer two-factor sign-in and device authorization, the demo shows the screens for it, and fonoteka.go builds and passes at the new user plugin without mounting two-factor sign-in.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| browser authenticator to the WebAuthn routes | attestations and assertions arrive from the client and must be bound to server-side ceremony state |
|
||||
| frontend origin to the RP | the RP ID and allowed origins decide which pages may run ceremonies |
|
||||
| anonymous client to the passwordless routes | a passkey alone yields a token |
|
||||
| social provider callback to signIn | a social identity must not bypass an enabled second factor |
|
||||
| demo frontend to the API | server-generated SVG rendered in the browser |
|
||||
| this machine to git.golem15.com | tags, pushes and a new repository |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.6-26 | Spoofing | WebAuthn challenge supplied by the client (Pitfall 6) | high | mitigate | expected challenges held server-side (challenge row code; cooler Pull for registration and passkeys, single use, 120 s); a client challenge is only a lookup key; TestWebAuthnSmoke state reuse |
|
||||
| T-19.6-27 | Elevation of privilege | passkey login with a second-factor-only credential (D-14) | high | mitigate | passwordless column set only for requested, UV-verified, credProps-rk credentials; ValidatePasskeyLogin with UV required; handler checks enabled, passwordless and the user handle; setting off by default; TestPasswordlessSmoke; explicit D-14 review in plan 04 |
|
||||
| T-19.6-28 | Spoofing | cloned authenticator | medium | mitigate | credential written back after every assertion with sign count and flags; a clone warning refuses the login; BE changes refused by the library (Pitfall 8); TestWebAuthnCeremonySmoke |
|
||||
| T-19.6-29 | Spoofing | RP ID or origin misconfiguration (Pitfall 7) | medium | mitigate | explicit rp_id and origins config with app.url and frontend_url defaults; the library checks origin and RP ID hash; README documents IP hosts and split domains |
|
||||
| T-19.6-30 | Elevation of privilege | social sign-in bypassing an enabled second factor (D-09) | high | mitigate | gate in signIn before FinishLogin and before any token; challenge body replaces the token in oauth-complete and oauth-register-complete; errors fail closed; link stays ungated because the caller is signed in; TestSocialGateSmoke |
|
||||
| T-19.6-31 | Elevation of privilege | passkey login of a banned, suspended or inactive account | high | mitigate | activation check and RejectIfThrottled before the token; one generic failure body; TestPasswordlessSmoke banned_refused |
|
||||
| T-19.6-32 | Tampering | remote operations (Task 5 and Task 6 pushes) | medium | mitigate | blocking decision first; fetch and compare the unpushed range to this phase's commits before each push; never force; no deploy; fonoteka.go never pushed |
|
||||
| T-19.6-33 | Tampering | demo rendering the server's QR SVG | low | mitigate | rendered through an img data URL, never v-html (acceptance criterion greps for it) |
|
||||
| T-19.6-34 | Information disclosure | WebAuthn user handle derived from the numeric user id (A4) | low | accept | PHP-compatible handle keeps imported passkeys mappable; it reveals only an internal id to the user's own authenticator |
|
||||
| T-19.6-35 | Denial of service | registration and passkey state in the memory cooler on multi-instance deployments (A11) | low | accept | documented in the README: multi-instance applications need the Redis cooler driver; ceremonies fail closed otherwise |
|
||||
| T-19.6-SC | Tampering | Go module installs | high | mitigate | go-webauthn v0.18.2 installed only after plan 01 Task 5's recorded approval; any other version needs the user's approval; `go list -deps` free of the archived YAML module; fonoteka.go's module graph has no go-webauthn |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: TestWebAuthnCeremonySmoke (short), TestWebAuthnSmoke and the plan 02 smokes.
|
||||
- Task 2: TestPasswordlessSmoke, TestWebAuthnSmoke; TestSocialGateSmoke and sociallogin's TestTracerSmoke.
|
||||
- Task 3: vitest with twoFactor.test.ts and the Nuxt build.
|
||||
- Task 4: the reply recorded. Task 5: ls-remote checks. Task 6: fonoteka.go full short suite and build; demo TestMFAMounted, TestBootAllPlugins, TestGoModRequiresTags; bm suite and TestMFAMounted.
|
||||
- Feedback sampling (19.6-VALIDATION.md): after each commit, vet and the short suite of the touched repository.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-1: WebAuthn registration and login challenges work with the standard shapes; passkey login behind its setting.
|
||||
- D-09: social sign-in goes through the challenge only when the setting is on.
|
||||
- D-17: published in order; demo and bm mount mfa; fonoteka.go green at the new pointer without mfa.
|
||||
- D-20: the demo's TOTP setup, recovery codes, disable and login challenge screens.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-03-SUMMARY.md` when done (a draft at the Task 4 checkpoint, final after Task 6), with the commits in order per repository, the Task 4 reply verbatim, the tags and the repository URL, the final identifiers, which Docker-backed tests ran or were NOT RUN, every deviation (for example push-only skipping the demo), and a g15office comment on `avoroeb4` (or a note that the tool was unavailable).
|
||||
</output>
|
||||
@@ -0,0 +1,349 @@
|
||||
---
|
||||
phase: 19.6-two-factor-auth-and-device-flow-in-sm-user-plugin
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: ["19.6-01", "19.6-02", "19.6-03"]
|
||||
files_modified:
|
||||
- scripts/check-phase19.6.sh
|
||||
- ../fonoteka.go/plugins/golem15/user/login_gate_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/password_changed_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/reset_two_factor_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/device_auth_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/device_auth_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/updates/device_auth_sessions_test.go
|
||||
- ../sm-mfa-plugin/gate_test.go
|
||||
- ../sm-mfa-plugin/challenge_test.go
|
||||
- ../sm-mfa-plugin/totp_test.go
|
||||
- ../sm-mfa-plugin/recovery_test.go
|
||||
- ../sm-mfa-plugin/email_test.go
|
||||
- ../sm-mfa-plugin/trusted_test.go
|
||||
- ../sm-mfa-plugin/settings_test.go
|
||||
- ../sm-mfa-plugin/status_test.go
|
||||
- ../sm-mfa-plugin/admin_test.go
|
||||
- ../sm-mfa-plugin/tokens_at_rest_test.go
|
||||
- ../sm-mfa-plugin/migrations_test.go
|
||||
- ../sm-mfa-plugin/webauthn_test.go
|
||||
- ../sm-mfa-plugin/passwordless_test.go
|
||||
- ../sm-mfa-plugin/routes_test.go
|
||||
- ../sm-mfa-plugin/php_bodies_test.go
|
||||
- ../sm-mfa-plugin/login_paths_test.go
|
||||
- ../sm-mfa-plugin/php_method_map_test.go
|
||||
- ../sm-mfa-plugin/php_port_test.go
|
||||
- ../sm-mfa-plugin/testdata/PHP_METHOD_MAP.md
|
||||
- ../sm-mfa-plugin/testdata/php_methods.txt
|
||||
- ../sm-mfa-plugin/README.md
|
||||
- ../sm-sociallogin-plugin/social_gate_test.go
|
||||
- .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-SECURITY-REVIEW.md
|
||||
- .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-VALIDATION.md
|
||||
- ../sm-mfa-plugin/go.mod
|
||||
- ../sm-mfa-plugin/go.sum
|
||||
- ../sm-sociallogin-plugin/go.mod
|
||||
- ../sm-sociallogin-plugin/go.sum
|
||||
- ../sm-demo-app/plugins/golem15/mfa
|
||||
- ../sm-demo-app/plugins/golem15/user
|
||||
- ../sm-demo-app/plugins/golem15/sociallogin
|
||||
- ../sm-demo-app/go.mod
|
||||
- ../sm-demo-app/go.sum
|
||||
- ../sm-bm-app/plugins/golem15/mfa
|
||||
- ../sm-bm-app/plugins/golem15/user
|
||||
- ../sm-bm-app/plugins/golem15/sociallogin
|
||||
- ../fonoteka.go/plugins/golem15/user
|
||||
- .planning/todos/pending/mfa-post-release-fixes.md
|
||||
autonomous: false
|
||||
requirements: [SC-4, D-02, D-05, D-06, D-07, D-08, D-09, D-10, D-11, D-12, D-12a, D-12b, D-12c, D-13, D-14, D-15, D-16, D-17, D-18, D-19]
|
||||
estimate:
|
||||
tokens: 330000
|
||||
raw_tokens: 330000
|
||||
tasks: 6
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "SC-4: the phase's new code has unit tests delivered in this, the last plan: sm-user-plugin's gate, events, reset slot and device flow; every decision of sm-mfa-plugin in the 19.6-RESEARCH.md decision-to-test map; sociallogin's gate; on Postgres where a database is involved."
|
||||
- "D-02, D-18 (A10): table tests pin the gate chain (nil registry, order, first result wins, errors fail closed and stop the chain) and Login's order (rehash before the gate, restore and reactivate mail after it, no event on a challenge)."
|
||||
- "D-11, D-12a, D-12b, D-12c, D-06: table tests cover initiate validation and cleanup, status for every state with the flat expired body and used never expiring, authorize by short code (case, spaces, missing hyphen, 7 and 9 characters, excluded letters) and by token with PHP's 400, 401 and 422 bodies, concurrent authorizes and polls each yielding one winner, the deleted-user hand-over, list order and is_current, revoke of foreign ids, revoke after one and two refreshes, and token columns holding SHA-256 hex."
|
||||
- "D-05, D-06, D-07, D-10, D-12, D-13, D-15, D-16, D-18, D-19: sm-mfa-plugin tests pin every PHP error and success body verbatim, the challenge lifecycle (TTL, five attempts, one completion under 20 parallel verifies), TOTP window and replay with an injected clock, the QR serializer, recovery format and single use, the email gating, send cap and expiry_minutes, the failure-cap formula and window, trusted devices, every revocation source, the settings overlay and allow-lists, the status shape with the Carbon date form, the admin reset, tokens and secrets at rest, and the six migrations up and down with PHP's column names."
|
||||
- "D-08, D-14: WebAuthn tests pin the standard option and response shapes (attestation none, timeout 60000, user verification preferred for second factors, required for passkeys), server-side state consumed once, BE and clone refusals, and every passkey rule; 19.6-SECURITY-REVIEW.md has an explicit D-14 section mapping each passkey risk to a passing test."
|
||||
- "D-09, invariant: TestEveryLoginPathIsGated drives password and social (toggle on) sign-ins of a TOTP user to a challenge with no token, and proves passkey and device-poll tokens arise only through their own proofs; TestSocialGate covers the sociallogin side, link included."
|
||||
- "PHP test map: every method of the PHP plugin's tests/security/AuthenticationTest.php appears in sm-mfa-plugin/testdata/PHP_METHOD_MAP.md with its Go test or not-applicable with a reason; TestPHPMethodMap fails on a missing, unknown or extra method and on a named Go test that does not exist; test_user_005 maps to TestPHPUser005 (typed mode setting)."
|
||||
- "Security review: 19.6-SECURITY-REVIEW.md maps every T-19.6 threat of plans 01-04 to the passing test that pins its mitigation or to an accepted disposition with its reason; a defect found is fixed in its own commit with a test."
|
||||
- "scripts/check-phase19.6.sh fails closed: a required test that fails, skips or does not run fails its stage; --self-test proves the detectors on planted output; --all runs every stage across summercms.go, sm-user-plugin, sm-mfa-plugin, sm-sociallogin-plugin, sm-demo-app (Go and vue-demo-app vitest), sm-bm-app and fonoteka.go, and prints NOT RUN docker without a reachable daemon unless PHASE196_REQUIRE_DOCKER=1 makes that a failure."
|
||||
- "D-17 after the release: every production fix this plan commits after plan 03's release (a change outside *_test.go, testdata/ and README.md in sm-user-plugin, sm-mfa-plugin or sm-sociallogin-plugin) is contained in every pin that consumes it, through patch tags and separate pointer-bump commits under the Task 5 go-ahead; on hold, each unreleased fix is a recorded deviation with a todo and a g15office task."
|
||||
artifacts:
|
||||
- path: "scripts/check-phase19.6.sh"
|
||||
provides: "fail-closed phase gate across seven repositories"
|
||||
contains: "PHASE196_REQUIRE_DOCKER"
|
||||
- path: "../sm-mfa-plugin/login_paths_test.go"
|
||||
provides: "TestEveryLoginPathIsGated (assumption-delta invariant)"
|
||||
contains: "TestEveryLoginPathIsGated"
|
||||
- path: "../sm-mfa-plugin/testdata/PHP_METHOD_MAP.md"
|
||||
provides: "case-by-case map of the PHP AuthenticationTest methods"
|
||||
contains: "test_user_005_twofactor_type_confusion"
|
||||
- path: ".planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-SECURITY-REVIEW.md"
|
||||
provides: "threat-to-test map T-19.6-01..T-19.6-38 and the D-14 review"
|
||||
contains: "D-14"
|
||||
key_links:
|
||||
- from: "scripts/check-phase19.6.sh"
|
||||
to: "../sm-mfa-plugin/php_method_map_test.go"
|
||||
via: "the mfa stage requires TestPHPMethodMap by name"
|
||||
pattern: "TestPHPMethodMap"
|
||||
- from: ".planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-VALIDATION.md"
|
||||
to: "scripts/check-phase19.6.sh"
|
||||
via: "the per-task map names the gate stage and test for each decision"
|
||||
pattern: "check-phase19.6.sh"
|
||||
- from: "../sm-mfa-plugin"
|
||||
to: "../sm-demo-app/plugins/golem15/mfa"
|
||||
via: "a post-release fix reaches the applications through Task 6's patch tag and pointer bump, checked with merge-base --is-ancestor"
|
||||
pattern: "merge-base --is-ancestor"
|
||||
prohibitions:
|
||||
- statement: "No production behaviour changes in this plan except fixes for defects a new test or the security review exposes, each in its own commit in the owning repository with the README change it needs."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Tests never contain a real secret, never call a real authenticator or mail server and never log codes or tokens; WebAuthn runs through the virtual authenticator, mail through the capturing mailer."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "A skipped Docker test is never counted as passed: the gate treats a skip of a required name as a failure, and the SUMMARY records NOT RUN where no daemon answered."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Test-only commits are pushed only as plan 03's Task 4 reply authorised; tags and pointer bumps happen only in Task 6 under the Task 5 reply (or plan 03's reply when it pre-authorised patch releases); nothing is force-pushed or deployed; fonoteka.go is never pushed."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story invented, see plan 01): `sm-user-plugin` has the PHP user plugin's second factors and device login. ROADMAP SC-4: the phase gets a security review and the new code has unit tests, delivered in the phase's last plan (also the project rule "unit tests are the last plan of every phase").
|
||||
|
||||
This plan's slice (plan 4 of 4, wave 4, "Unit tests"): one fail-closed gate first (the tracer), then unit tests for sm-user-plugin, then sm-mfa-plugin's and sociallogin's decision-to-test map with the login-path invariant, then the PHP test map, the security review with the explicit D-14 section, the whole gate green and VALIDATION.md completed. If a production fix landed after plan 03's release, a gated patch release carries it to the applications (Tasks 5 and 6).
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip). `must_haves` derive from SC-1..SC-4 and D-01..D-20.
|
||||
|
||||
## Decisions taken in this plan
|
||||
|
||||
- Test names (one top-level test per decision area with table subtests): sm-user-plugin `TestLoginGates` (short), `TestLoginGateOrder`, `TestPasswordChangedSources`, `TestResetTwoFactorAction`, `TestDeviceCodes` (short, classes package), `TestDeviceAuthURL` (short), `TestDeviceDisplayName` (short), `TestDeviceInitiate`, `TestDeviceStatus`, `TestDeviceAuthorize`, `TestDeviceHandOverConcurrent`, `TestDeviceListAndRevoke`, `TestDeviceRevokeAfterRefresh`, `TestDeviceTokensAtRest`, `TestDeviceAuthSessionsMigration` (updates package); sm-mfa-plugin `TestTOTP` (short), `TestQRSVG` (short), `TestRecoveryCodeFormat` (short), `TestMaskEmail` (short), `TestEmailExpiryMinutes` (short), `TestWebAuthnShapes` (short), `TestRoutesAndBuckets` (short), `TestPHPMethodMap` (short), `TestPHPUser005` (short), `TestGate`, `TestChallengeLifecycle`, `TestRecovery`, `TestEmailCode`, `TestFailureCap`, `TestTrustedDevice`, `TestRevocation`, `TestSettings`, `TestStatusShape`, `TestAdminReset`, `TestTokensAtRest`, `TestMigrations`, `TestWebAuthn`, `TestPasswordless`, `TestPHPErrorBodies`, `TestEveryLoginPathIsGated`; sm-sociallogin-plugin `TestSocialGate`. The smoke tests of plans 01-03 stay and are required by the gate too.
|
||||
- The gate's user-plugin base for the additive check is the commit plan 01 started from (19.6-01-SUMMARY.md; `35cf166` when nothing moved), overridable with PHASE196_USER_BASE. The migration check allows exactly one added non-test file under updates/ (`202610210002_create_device_auth_sessions.go`) and no modified or removed one.
|
||||
- The PHP map covers the one PHP test file with second-factor content, tests/security/AuthenticationTest.php, method by method (RESEARCH: PHP has almost no tests here); 2FA and device cases map to Go tests, the others to not-applicable with the phase or reason that owns them.
|
||||
- Post-release fixes (D-17): plan 03 released the plugins and pinned the applications. A defect fix in Tasks 2-4 reaches them only through a new release; Task 4 lists such fixes against every pin; Task 5 asks for the go-ahead unless plan 03's Task 4 reply already authorised patch releases for this plan (then Task 5 records that and continues); Task 6 releases in D-17 order. Test-only and README-only commits need no release.
|
||||
- Context budget and hand-offs (estimate 330k across 6 tasks; the four-plan decision fixes the plan count): the first agent runs Tasks 1-2 and writes `19.6-04-SUMMARY.md` as a draft (`Status: draft (hand-off after Task 2)`) with commits per repository, test names, gate stages and NOT RUN notes; a fresh agent runs Tasks 3-4 and ends with the `## Post-release fixes` table; when the table is empty it writes "nothing to propagate" under `Task 5 reply`, records Tasks 5 and 6 as not needed and finalizes the SUMMARY, otherwise it stops at Task 5 (or continues when plan 03's reply pre-authorised); a continuation agent runs Task 6.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `scripts/check-phase19.6.sh` with modes `--go`, `--userplugin`, `--mfa`, `--sociallogin`, `--demo`, `--demo-web`, `--bm`, `--fonoteka`, `--docker`, `--docs`, `--self-test`, `--all`; environment `PHASE196_ROOT`, `USER_PLUGIN`, `MFA_PLUGIN`, `SOCIALLOGIN_PLUGIN`, `DEMO_APP`, `DEMO_WEB`, `BM_APP`, `FONOTEKA`, `PHASE196_USER_BASE`, `PHASE196_REQUIRE_DOCKER`.
|
||||
- Unit tests named in Decisions; PHP map files `../sm-mfa-plugin/testdata/PHP_METHOD_MAP.md` and `testdata/php_methods.txt`; README Testing sections listing them.
|
||||
- `.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-SECURITY-REVIEW.md`; the completed `19.6-VALIDATION.md` (status validated, nyquist_compliant true, wave_0_complete true).
|
||||
- When needed: patch tags of the touched plugins, pointer-bump commits, `.planning/todos/pending/mfa-post-release-fixes.md`.
|
||||
|
||||
<objective>
|
||||
Deliver the phase's unit tests last: a fail-closed gate over every touched repository, decision-by-decision tests for sm-user-plugin, sm-mfa-plugin and the social-login gate, the login-path invariant, the PHP test map, the security review with the explicit passkey section, and the release of any post-release fix.
|
||||
|
||||
Purpose: SC-4 and every decision's test row in RESEARCH's Validation Architecture; the D-14 security review; D-17 for fixes.
|
||||
Output: test commits in sm-user-plugin, sm-mfa-plugin and sm-sociallogin-plugin, the gate script in summercms.go, the security review and validation docs, and (only if needed) a gated patch release.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-CONTEXT.md
|
||||
@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-RESEARCH.md
|
||||
@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-VALIDATION.md
|
||||
@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-01-SUMMARY.md
|
||||
@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-02-SUMMARY.md
|
||||
@.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-03-SUMMARY.md
|
||||
|
||||
Repos and concurrency: tests are added in sm-user-plugin (../fonoteka.go/plugins/golem15/user), sm-mfa-plugin and sm-sociallogin-plugin; the gate script and the planning docs in summercms.go. A defect a test exposes is fixed in the owning repository in its own commit with its README change and listed in the SUMMARY's `## Post-release fixes` table. No co-author tags; stage by explicit path; test and production changes in separate commits. Keep vet and the short suites green at every commit. Docker-backed tests skip under -short; run them through `sudo -u golem` if the socket is denied; when containers are unreachable (Phase 19.8 saw this on this host) the docker stage reports NOT RUN and the SUMMARY says so.
|
||||
|
||||
<interfaces>
|
||||
- scripts/check-phase19.5.sh (whole file: header, `set -euo pipefail`, the go test -json detector, `--self-test`, docker NOT RUN rule, `--all`) and scripts/check-phase19.4.sh: the structure to copy.
|
||||
- 19.6-01/02/03 SUMMARYs: final identifiers, smoke test names, migration IDs, tags, the base commit, the Task 4 reply of plan 03.
|
||||
- sm-user-plugin test helpers: session_test.go (`sessionApp`, `insertUser`, `loginToken`, `bearer`), admin_harness_test.go (`newAdminEnv`, `admin`, `call`, `expect`, `mails`), updates/postgres_test.go (migration harness, `dedicatedDB`), the plan 01 smoke tests.
|
||||
- sm-mfa-plugin test helpers: harness_test.go (`bootMFA`, `freshDB`), webauthn_authenticator_test.go (`virtualAuthenticator`), the plan 02-03 smoke tests; pquerna `totp.GenerateCodeCustom` for codes at injected times.
|
||||
- sm-sociallogin-plugin: harness_test.go, fakeprovider_test.go, social_gate_smoke_test.go, testdata/PHP_METHOD_MAP.md and php_method_map_test.go (the map-test pattern to copy).
|
||||
- PHP: /media/nvme/dev/jz/wavepath.org/plugins/golem15/user/tests/security/AuthenticationTest.php (USER-005 at :132-190); RESEARCH Q4 for every verbatim body.
|
||||
- RESEARCH Validation Architecture (decision-to-test map, Wave 0 gaps), Security Domain (ASVS categories, known threat patterns), Assumptions A1-A13.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: scripts/check-phase19.6.sh runs every repository's suite and the phase's smoke tests by name, and fails when one is missing, skipped or red</name>
|
||||
<files>scripts/check-phase19.6.sh</files>
|
||||
<read_first>scripts/check-phase19.5.sh (whole file), scripts/check-phase19.4.sh, 19.6-01-SUMMARY.md, 19.6-02-SUMMARY.md, 19.6-03-SUMMARY.md (smoke test names, base commit, tags), 19.6-VALIDATION.md</read_first>
|
||||
<action>Create scripts/check-phase19.6.sh from check-phase19.5.sh's structure (header comment listing every stage, `set -euo pipefail`, NO_COLOR, the go test -json detector that fails a stage on a failed or skipped test including subtests, zero matched tests, "no tests to run" or a missing required name, a subtest pass never counting for its parent, `--self-test` with planted output, `--all`). Defaults are the sibling checkouts: PHASE196_ROOT (this repository), USER_PLUGIN (../fonoteka.go/plugins/golem15/user, tested through the fonoteka.go workspace), MFA_PLUGIN (../sm-mfa-plugin), SOCIALLOGIN_PLUGIN (../sm-sociallogin-plugin), DEMO_APP (../sm-demo-app), DEMO_WEB (../sm-demo-app/vue-demo-app), BM_APP (../sm-bm-app), FONOTEKA (../fonoteka.go), PHASE196_USER_BASE (the base from 19.6-01-SUMMARY.md). Stages:
|
||||
- `--go`: go vet ./... and go test -short ./... here (the framework is unchanged by this phase; the stage proves it stays green and that the framework go.mod requires none of the three second-factor libraries).
|
||||
- `--userplugin`: vet and short tests of the user plugin through the fonoteka.go workspace with the Docker-free names (TestLoginGatesSmoke, TestDeviceCodesSmoke now; Task 2 adds its names); the additive check (no line declaring an exported func, method, type or const removed from a non-test .go file since the base); the migration check (the only non-test change under updates/ is the added device-session migration).
|
||||
- `--mfa`: vet and short tests in sm-mfa-plugin with TestQRSVGSmoke and TestWebAuthnCeremonySmoke by name; `go list -deps .` free of the archived YAML v3 module; go.mod pins pquerna/otp v1.5.0, boombuler/barcode v1.1.0 and go-webauthn/webauthn at the version the 19.6-03 SUMMARY records.
|
||||
- `--sociallogin`: vet and short tests in the plugin plus its Phase 19.5 rules (no gorilla/sessions, no goth import outside classes/providers_goth.go).
|
||||
- `--demo`: go build ./..., go vet ., go test -short . with TestBootAllPlugins, TestGoModRequiresTags and TestMFAMounted by name (skipped with a NOT MOUNTED note when the demo's summer.yaml has no golem15.mfa because plan 03 ran push-only or hold).
|
||||
- `--demo-web`: `pnpm --dir "$DEMO_WEB" test` must pass and report app/lib/twoFactor.test.ts.
|
||||
- `--bm`: go build ./..., go vet ., go test -short . with TestSocialLoginMounted and TestMFAMounted by name.
|
||||
- `--fonoteka`: go vet ./... and go test -short ./... of the workspace; summer.yaml does not mount golem15.mfa; `go list -m all` lists no go-webauthn, pquerna or boombuler module (D-01).
|
||||
- `--docker` (Docker host only, "needs a reachable Docker daemon" otherwise): the Postgres smoke tests of plans 01-03 by name without -short (user plugin: TestLoginGateSmoke, TestPasswordChangedEventSmoke, TestResetTwoFactorSlotSmoke, TestDeviceFlowSmoke, TestDeviceRevokeSmoke; mfa: TestTracerSmoke, TestChallengeSmoke, TestTrustedSettingsSmoke, TestWebAuthnSmoke, TestPasswordlessSmoke; sociallogin: TestSocialGateSmoke).
|
||||
- `--docs`: the decision note names the three pins; STACK.md has the three rows; TestDocsTree and `go run ./cmd/summer docs:build --check`; no README of sm-mfa-plugin or sm-user-plugin and no file under modules/ or docs/ names a consuming application (case-insensitive grep for plytarium, płytarium, fonoteka, wavepath, horoskopia, queststream and bm.jakubzych outside _test.go files and .planning).
|
||||
- `--all`: self-test, go, userplugin, mfa, sociallogin, demo, demo-web, bm, fonoteka, docs, then docker when a daemon answers (NOT RUN docker otherwise; a failure with PHASE196_REQUIRE_DOCKER=1).
|
||||
Each later task appends its test names to the stage that runs them. `--self-test` covers the detectors (fail, skip, zero tests, missing name, a subtest pass not counting for its parent, a vitest run without the required file) and the docker NOT RUN rule on a faked PATH without docker. Commit in summercms.go: `test(19.6): fail-closed phase gate`.</action>
|
||||
<verify>
|
||||
<automated>bash -n scripts/check-phase19.6.sh && scripts/check-phase19.6.sh --self-test && scripts/check-phase19.6.sh --go && scripts/check-phase19.6.sh --userplugin && scripts/check-phase19.6.sh --mfa && scripts/check-phase19.6.sh --sociallogin</automated>
|
||||
<fails_when>Non-zero exit: a syntax error, a self-test detector that does not catch its planted failure, or a stage that finds a required smoke test missing, skipped or red, a removed user-plugin export, a modified user-plugin migration, an unexpected library version or the archived YAML module in the mfa dependency graph.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'PHASE196_REQUIRE_DOCKER' scripts/check-phase19.6.sh` prints at least 2.
|
||||
- `grep -cE -- '^\s*--(go|userplugin|mfa|sociallogin|demo|demo-web|bm|fonoteka|docker|docs|self-test|all)\)' scripts/check-phase19.6.sh` prints at least 12.
|
||||
- `grep -c 'TestWebAuthnCeremonySmoke' scripts/check-phase19.6.sh` prints at least 1 and `grep -c '202610210002_create_device_auth_sessions' scripts/check-phase19.6.sh` prints at least 1.
|
||||
- `test -x scripts/check-phase19.6.sh` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>One command shows, for every repository the phase touched, whether its suite and the phase's named tests pass, and a skipped or missing test cannot pass it.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Unit tests for sm-user-plugin's additions (gate chain and Login order, password-changed sources, reset action, the whole device flow and its migration), wired into the gate</name>
|
||||
<files>../fonoteka.go/plugins/golem15/user/login_gate_test.go, ../fonoteka.go/plugins/golem15/user/password_changed_test.go, ../fonoteka.go/plugins/golem15/user/reset_two_factor_test.go, ../fonoteka.go/plugins/golem15/user/device_auth_test.go, ../fonoteka.go/plugins/golem15/user/classes/device_auth_test.go, ../fonoteka.go/plugins/golem15/user/updates/device_auth_sessions_test.go, ../fonoteka.go/plugins/golem15/user/README.md, scripts/check-phase19.6.sh</files>
|
||||
<read_first>19.6-01-SUMMARY.md, ../fonoteka.go/plugins/golem15/user/classes/login_gate.go, ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (Login, Refresh), ../fonoteka.go/plugins/golem15/user/controllers/devices.go, ../fonoteka.go/plugins/golem15/user/classes/device_auth.go, ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go, the plan 01 smoke tests, ../fonoteka.go/plugins/golem15/user/session_test.go, ../fonoteka.go/plugins/golem15/user/admin_harness_test.go, ../fonoteka.go/plugins/golem15/user/updates/postgres_test.go, ../fonoteka.go/plugins/golem15/user/updates/api_tokens_test.go (migration test style), 19.6-RESEARCH.md (Q4 device flow, Q7, Pitfalls 3, 13-17, 21)</read_first>
|
||||
<action>Tests only (fix defects in separate commits). `TestLoginGates` (short): nil registry, empty, order, first result wins, error stops the chain, concurrent Add and Check under `go test -race` clean. `TestLoginGateOrder` (Postgres): the rehash happens for a challenged user (stored hash changes when bcrypt_cost rises, no PasswordChangedEvent); a challenged soft-deleted user stays soft-deleted with no mail; an allowed one is restored with one reactivate mail and one LoginEvent; a refusal result's status and body pass through; refresh never calls a gate (a counting gate stays at 0 across /refresh). `TestPasswordChangedSources` (Postgres): reset, change and admin set each fire once with their source inside a transaction (the listener sees the new hash through ev.Tx), rehash and register fire none, a listener error rolls back each of the three writes. `TestResetTwoFactorAction` (admin harness): not offered without a service; offered and Danger with one; HasTwoFactor false hides it; the permission is required; Run without a transaction errors; Run's error rolls back. `TestDeviceCodes` (classes package, short): token length, alphabet and uniqueness over 10,000 draws, short-code format and distribution (every alphabet character appears), normalization table. `TestDeviceAuthURL` and `TestDeviceDisplayName` (short tables, including the PHP accessor order iPhone before Mac). `TestDeviceInitiate` (device_name over 255 characters 422; the cleanup deletes only stale pending and expired rows and at most 500; short-code collision retry with a pre-inserted code). `TestDeviceStatus` (unknown 404 body; pending; expired pending and expired confirmed give the flat body and set status expired; used and revoked past expires_at keep their status, Pitfall 13). `TestDeviceAuthorize` (short code variants and refusals with PHP bodies; token path; a session bound to another user refused; the same caller re-authorizing answers success; 10 concurrent authorizes by two users bind exactly one; missing, invalid and blacklisted bearer 401 with {"error":"Unauthorized"}). `TestDeviceHandOverConcurrent` (50 concurrent polls give one token; the deleted-user hand-over marks revoked and answers status revoked; LoginEvent Source device fired once). `TestDeviceListAndRevoke` (order, is_current, device_name, foreign id 404, non-numeric id unmatched, idempotent revoke, empty list is []). `TestDeviceRevokeAfterRefresh` (one and two refreshes before revoke; the blacklist entry outlives the refresh window; a refresh failure of the device token leaves the row unchanged). `TestDeviceTokensAtRest` (the token column equals SHA-256 hex and never the plaintext; no plaintext in any column). `TestDeviceAuthSessionsMigration` (updates package, dedicated database: up creates PHP's column names plus jwt_jti, the unique token and short_code, the CHECK refuses an unknown status, the FK cascades on user delete, rollback drops the table, re-up works). Append every name to the userplugin and docker stages of the gate; README Testing lists them. Commits in sm-user-plugin: `test: login gates, password-changed sources and the reset action` and `test: device authorization flow and its migration`; gate update in summercms.go: `test(19.6): gate requires the user-plugin unit tests`.</action>
|
||||
<verify>
|
||||
<automated>scripts/check-phase19.6.sh --userplugin && go -C ../fonoteka.go test -count=1 -short -race -run '^(TestLoginGates|TestDeviceAuthURL|TestDeviceDisplayName)$' -v ./plugins/golem15/user 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>Non-zero exit from the userplugin stage, or the count of top-level "--- PASS: Test" lines is not 3 (a short table test failed, raced or is missing).</fails_when>
|
||||
<automated>scripts/check-phase19.6.sh --docker</automated>
|
||||
<fails_when>Non-zero exit: a required Postgres test failed or skipped; "needs a reachable Docker daemon" means NOT RUN, recorded in the SUMMARY (rerun through sudo -u golem first).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c '^func TestDeviceRevokeAfterRefresh' ../fonoteka.go/plugins/golem15/user/device_auth_test.go` prints 1 and `grep -c '^func TestDeviceHandOverConcurrent' ../fonoteka.go/plugins/golem15/user/device_auth_test.go` prints 1.
|
||||
- `grep -c '^func TestDeviceAuthSessionsMigration' ../fonoteka.go/plugins/golem15/user/updates/device_auth_sessions_test.go` prints 1.
|
||||
- `grep -c 'TestDeviceRevokeAfterRefresh' scripts/check-phase19.6.sh` prints at least 1 and `grep -c 'TestLoginGateOrder' scripts/check-phase19.6.sh` prints at least 1.
|
||||
- `grep -c 'TestDeviceRevokeAfterRefresh' ../fonoteka.go/plugins/golem15/user/README.md` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>Every user-plugin seam and every device-flow state and refusal is pinned by a named test the gate requires.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Unit tests for sm-mfa-plugin and the social-login gate, one per decision in the validation map, plus the invariant that every first-factor login path is gated, wired into the gate</name>
|
||||
<files>../sm-mfa-plugin/gate_test.go, ../sm-mfa-plugin/challenge_test.go, ../sm-mfa-plugin/totp_test.go, ../sm-mfa-plugin/recovery_test.go, ../sm-mfa-plugin/email_test.go, ../sm-mfa-plugin/trusted_test.go, ../sm-mfa-plugin/settings_test.go, ../sm-mfa-plugin/status_test.go, ../sm-mfa-plugin/admin_test.go, ../sm-mfa-plugin/tokens_at_rest_test.go, ../sm-mfa-plugin/migrations_test.go, ../sm-mfa-plugin/webauthn_test.go, ../sm-mfa-plugin/passwordless_test.go, ../sm-mfa-plugin/routes_test.go, ../sm-mfa-plugin/php_bodies_test.go, ../sm-mfa-plugin/login_paths_test.go, ../sm-mfa-plugin/README.md, ../sm-sociallogin-plugin/social_gate_test.go, scripts/check-phase19.6.sh</files>
|
||||
<read_first>19.6-02-SUMMARY.md, 19.6-03-SUMMARY.md, every classes/ and controllers/ file of ../sm-mfa-plugin, ../sm-mfa-plugin/harness_test.go, ../sm-mfa-plugin/webauthn_authenticator_test.go, the plan 02-03 smoke tests, ../sm-sociallogin-plugin/classes/flow.go, ../sm-sociallogin-plugin/social_gate_smoke_test.go, 19.6-RESEARCH.md (Q4 every body and table, Validation Architecture decision-to-test map, Pitfalls 1-12, Assumptions A2, A5-A9)</read_first>
|
||||
<action>Tests only (fix defects in separate commits, listed for Task 4's table). Short tests: `TestTOTP` (injected times at step boundaries: counters at-1, at, at+1 accepted, at+2 and at-2 refused, replay at or below last_counter refused, the URL's issuer, account, algorithm, digits and period), `TestQRSVG` (several lengths and levels round-trip to the matrix; size attributes), `TestRecoveryCodeFormat` (10 codes, regex, alphabet only, no duplicates across 1,000 sets' codes within a set, normalization and the 32-character cap), `TestMaskEmail` (PHP table including one- and two-character local parts and plus addresses), `TestEmailExpiryMinutes` (A9: min of TTL and remaining time, rounded up, never below 1), `TestWebAuthnShapes` (D-08: the marshalled options have publicKey with a base64url challenge, attestation none, timeout 60000, userVerification preferred for second factors and required with residentKey required for passkeys, and the user id is the 8-byte big-endian handle), `TestRoutesAndBuckets` (boot without a database: every challenge and passwordless route carries the 2fa-verify bucket, every management route jwt.auth and throttle:user-api, each {id} route has the digit constraint; the 2fa-verify bucket is 10 per minute keyed by client IP).
|
||||
|
||||
Postgres tests: `TestGate` (sources password and social with the toggle on and off; passkey, two_factor and device never challenge; mode disabled exempts even users with methods and nothing else does, D-13; no enabled method passes; a user with only a webauthn row and no credential is challenged with available_methods [] as PHP; available_methods not filtered by the global list; the cap refusal); `TestChallengeLifecycle` (TTL from the setting; five attempts then refusal; 20 parallel verifies give one token; completed and expired challenges refused; creation keeps rows inside the cap window and deletes older expired ones); `TestRecovery` (single use under 10 concurrent attempts; regenerate replaces in one transaction; count route requires the password; D-19 recovery_codes only when a set was created, for totp/confirm, email/enable and webauthn/register); `TestEmailCode` (A8 gating for send and verify; three sends then refusal; each send replaces the code; template name per locale and the vars in both cases; the code is never in a log line, using a capturing slog handler); `TestFailureCap` (the formula over completed and uncompleted rows, the window boundary, refusal at login and on every verify endpoint, recovery after the window); `TestTrustedDevice` (D-10 issue, skip, expiry, other user, disabled setting, last_used_at and ip update, social never skipped, A7); `TestRevocation` (D-07: change, reset and admin set delete trusted devices; rehash does not; last method disable deletes devices and codes; removing the last WebAuthn credential does the same); `TestSettings` (D-12 overlay per key, NULL falls back to config, immediate effect, Rules and BeforeValidate allow-lists, admin JSON of the settings screen); `TestStatusShape` (PHP keys exactly; webauthn_credentials and trusted_devices item keys; the Carbon date form for trusted devices, A2; +00:00 for credentials); `TestAdminReset` (D-15 through the user plugin's record action end to end in the admin harness or through the published service: methods disabled with secrets cleared, credentials, codes and devices deleted, one disabled event, login then answers a token); `TestTokensAtRest` (D-06: challenge and trusted tokens SHA-256 hex, TOTP secret ciphertext that decrypts to the secret, recovery and email codes bcrypt); `TestMigrations` (D-16: the six migrations up, PHP column names and the added columns, indexes and FKs, rollback in reverse, re-up); `TestWebAuthn` (register and verify, state consumed once, wrong origin refused, BE change refused, clone refused, credential_data written back, delete scoped to the owner); `TestPasswordless` (D-14 table: setting off 403; second-factor credential refused; UV missing refused; disabled credential refused; userHandle of another user refused; banned, suspended and inactive refused with the generic body; soft-deleted owner restored and mailed; login event Source passkey; no challenge row created); `TestPHPErrorBodies` (every PHP status and body from RESEARCH Q4's two tables, byte for byte after trimming a trailing newline, including the 422 first-message form); `TestEveryLoginPathIsGated` (the assumption-delta invariant: for a TOTP user, password login and social sign-in with the toggle on answer two_factor_required and no token, social with the toggle off and every non-first-factor source answer tokens only through their own proof: passwordless only with a passkey assertion, the device poll only after an authorize with a bearer JWT; registering a new account and activate-by-code mint without a gate because the account cannot have 2FA yet). sociallogin `TestSocialGate` (refusal, error, challenge payload through oauth-complete and oauth-register-complete, link never gated, token cookie mode not applied to a challenge). Append every name to the mfa, sociallogin and docker stages; README Testing sections list them. Commits: in sm-mfa-plugin `test: second-factor decisions, PHP bodies and the login-path invariant` (split by area if a commit grows past one logical change); in sm-sociallogin-plugin `test: social sign-in gate`; in summercms.go `test(19.6): gate requires the mfa and social gate tests`.</action>
|
||||
<verify>
|
||||
<automated>scripts/check-phase19.6.sh --mfa && scripts/check-phase19.6.sh --sociallogin && go -C ../sm-mfa-plugin test -count=1 -short -run '^(TestTOTP|TestQRSVG|TestRecoveryCodeFormat|TestMaskEmail|TestEmailExpiryMinutes|TestWebAuthnShapes|TestRoutesAndBuckets)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 7</automated>
|
||||
<fails_when>Non-zero exit from a stage, or the count of top-level "--- PASS: Test" lines is not 7 (a short decision test failed or is missing).</fails_when>
|
||||
<automated>scripts/check-phase19.6.sh --docker</automated>
|
||||
<fails_when>Non-zero exit: a required Postgres test failed or skipped; "needs a reachable Docker daemon" means NOT RUN, recorded in the SUMMARY (rerun through sudo -u golem first).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c '^func TestEveryLoginPathIsGated' ../sm-mfa-plugin/login_paths_test.go` prints 1 and `grep -c '^func TestPHPErrorBodies' ../sm-mfa-plugin/php_bodies_test.go` prints 1.
|
||||
- `grep -c '^func TestPasswordless' ../sm-mfa-plugin/passwordless_test.go` prints 1 and `grep -c '^func TestFailureCap' ../sm-mfa-plugin/challenge_test.go ../sm-mfa-plugin/email_test.go ../sm-mfa-plugin/gate_test.go | awk -F: '{s+=$2} END {print s}'` prints 1.
|
||||
- `grep -c '^func TestSocialGate' ../sm-sociallogin-plugin/social_gate_test.go` prints 1.
|
||||
- `grep -c 'TestEveryLoginPathIsGated' scripts/check-phase19.6.sh` prints at least 1 and `grep -c 'TestSocialGate' scripts/check-phase19.6.sh` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>Every second-factor decision, every PHP body and the rule that no first-factor path skips the challenge are pinned by named tests the gate requires.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: The PHP security test mapped method by method, the security review with the explicit passkey section, the whole gate green across seven repositories, and the validation map completed</name>
|
||||
<files>../sm-mfa-plugin/php_method_map_test.go, ../sm-mfa-plugin/php_port_test.go, ../sm-mfa-plugin/testdata/PHP_METHOD_MAP.md, ../sm-mfa-plugin/testdata/php_methods.txt, ../sm-mfa-plugin/README.md, .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-SECURITY-REVIEW.md, .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-VALIDATION.md, scripts/check-phase19.6.sh</files>
|
||||
<read_first>/media/nvme/dev/jz/wavepath.org/plugins/golem15/user/tests/security/AuthenticationTest.php (whole file), ../sm-sociallogin-plugin/php_method_map_test.go and testdata/PHP_METHOD_MAP.md (pattern), .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md (structure), the threat_model blocks of 19.6-01..04-PLAN.md, 19.6-VALIDATION.md, 19.6-RESEARCH.md (Security Domain, Assumptions), 19.6-01/02/03 SUMMARYs</read_first>
|
||||
<action>PHP map. testdata/php_methods.txt lists every `test_*` method of AuthenticationTest.php (extracted with grep at execution, one per line). testdata/PHP_METHOD_MAP.md has one row per method: PHP method | repository | Go test (top-level or Name/subtest) or not-applicable | reason. test_user_005_twofactor_type_confusion maps to `TestPHPUser005` in php_port_test.go (short): a stored or submitted mode that is not one of the three strings is refused by validation and treated as optional by the effective settings, so enforcement never silently turns off through a type mix-up, and the mode compare is on a typed string. The remaining methods map to the Go test or Phase 19.5 test that covers them, or to not-applicable with the reason (for example PIN login not ported; Phase 07 deferral). `TestPHPMethodMap` (short) fails when a method in php_methods.txt has no row, a row names a method not in the file, or a named Go test does not exist in the named repository's test sources (USER_PLUGIN and SOCIALLOGIN_PLUGIN paths overridable by environment, as sociallogin's map test does).
|
||||
|
||||
Security review `19.6-SECURITY-REVIEW.md` (19.5 structure): scope and method; one row per threat T-19.6-01..T-19.6-38 from the four plans' threat_model blocks with the mitigation as built, the passing test that pins it (name and repository) or the accepted disposition with its reason; an explicit "D-14 passwordless passkey login" section reviewing the setting default, credential eligibility (passwordless column, UV at registration and login, credProps rk), user-handle binding, enabled and ownership checks, activation, ban and suspension, generic failures, single-use state and the multi-instance caveat, origin and RP ID checks, clone and BE refusal, rate limiting, deactivated-account restore, logging and why no second challenge follows, each pinned to a TestPasswordless or TestWebAuthn subtest; a "D-18 hardening" section for A6, A8, A9 and A10; ASVS L1 V2, V3, V4, V5, V6 and V7 notes from RESEARCH's Security Domain; residual risks (device-code phishing accepted per D-12c, blacklist_grace forks, memory cooler single instance, numeric user handle). A defect found here is fixed in its own commit with a test and listed for the table below.
|
||||
|
||||
Gate and validation. Add TestPHPMethodMap and TestPHPUser005 to the mfa stage; run `scripts/check-phase19.6.sh --all` until green (docker NOT RUN allowed only when no daemon answers, recorded). Update 19.6-VALIDATION.md: status validated, nyquist_compliant true, wave_0_complete true, the per-task verification map with one row per task of plans 01-04 (task id, plan, wave, decision, threat ref, secure behaviour, test type, the gate stage and test name, status), Wave 0 items checked, sign-off ticked. In the SUMMARY write `## Post-release fixes`: one row per production commit made by this plan after plan 03's release (repository, commit, files, the released pins that lack it: user tag and the gitlinks in sm-demo-app, sm-bm-app and fonoteka.go; mfa tag and the demo and bm gitlinks; sociallogin tag and the demo and bm gitlinks), or "none". Commits: in sm-mfa-plugin `test: PHP security test map`; in summercms.go `docs(19.6): security review and validation map` (planning docs alone) and the gate update `test(19.6): gate requires the PHP map`. Post a short comment on g15office task `avoroeb4` with the gate result (or record that the tool is unavailable).</action>
|
||||
<verify>
|
||||
<automated>scripts/check-phase19.6.sh --all</automated>
|
||||
<fails_when>Non-zero exit: any stage failed, a required test is missing or skipped, the PHP map is incomplete, or docker failed under PHASE196_REQUIRE_DOCKER=1 (without it, "NOT RUN docker" in the output means no daemon answered and is recorded in the SUMMARY).</fails_when>
|
||||
<automated>go -C ../sm-mfa-plugin test -count=1 -short -run '^(TestPHPMethodMap|TestPHPUser005)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>The count of top-level "--- PASS: Test" lines is not 2: the map test or the USER-005 port failed or does not exist.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'test_user_005_twofactor_type_confusion' ../sm-mfa-plugin/testdata/PHP_METHOD_MAP.md` prints 1 and `grep -c 'TestPHPUser005' ../sm-mfa-plugin/testdata/PHP_METHOD_MAP.md` prints at least 1.
|
||||
- `grep -cE 'T-19\.6-(0[1-9]|[1-3][0-9])' .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-SECURITY-REVIEW.md` prints at least 38 and `grep -c 'D-14' .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-SECURITY-REVIEW.md` prints at least 1.
|
||||
- `grep -c 'nyquist_compliant: true' .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-VALIDATION.md` prints 1 and `grep -c 'check-phase19.6.sh' .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-VALIDATION.md` prints at least 4.
|
||||
- The SUMMARY has a `## Post-release fixes` heading with a table or the word none.
|
||||
</acceptance_criteria>
|
||||
<done>The PHP test file is accounted for method by method, every threat is pinned or consciously accepted, the passkey route has its explicit review, and one command proves the phase across every repository.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 5: Go-ahead to release this plan's post-release fixes to the applications that pin plan 03's release (presented only when Task 4's Post-release fixes table has a row and plan 03's reply did not pre-authorise patch releases)</name>
|
||||
<read_first>19.6-04-SUMMARY.md draft (Post-release fixes table), 19.6-03-SUMMARY.md (Task 4 reply, tags), CLAUDE.md (Commits and concurrency)</read_first>
|
||||
<acceptance_criteria>
|
||||
- The SUMMARY records the reply verbatim under "Task 5 reply", or "nothing to propagate" when the table is empty, or "pre-authorised by plan 03 Task 4" with the quoted reply.
|
||||
- Before the reply, no new tag exists on the plugins: `T=$(git -C ../sm-mfa-plugin tag --points-at HEAD) && test -z "$T" || test "$T" = "v0.1.0"` exits 0 when HEAD is the released commit.
|
||||
</acceptance_criteria>
|
||||
<decision>May the post-release fixes listed in the SUMMARY ship as patch releases now?</decision>
|
||||
<context>Each row of the Post-release fixes table is a production commit in sm-user-plugin, sm-mfa-plugin or sm-sociallogin-plugin made after plan 03 released them; the applications' pins (sm-demo-app tags and gitlinks, sm-bm-app gitlinks, fonoteka.go's user gitlink) do not contain it yet. Releasing means: push the plugin, an annotated patch tag, the dependent plugin's require bump if any, then separate pointer-bump commits in the applications, pushed (fonoteka.go committed locally only). Holding leaves the fixes unreleased with a todo and a g15office task.</context>
|
||||
<options>
|
||||
<option id="release">
|
||||
<name>release (optionally naming the patch tags)</name>
|
||||
<pros>Every application runs the fixed code; the gate's pin check passes.</pros>
|
||||
<cons>More tags and pointer bumps today.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>hold</name>
|
||||
<pros>Nothing leaves this machine.</pros>
|
||||
<cons>Applications keep the unfixed release; recorded as a deviation with .planning/todos/pending/mfa-post-release-fixes.md and a g15office task.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "release" (optionally with tags) or "hold". Never auto-approved: tags and pushes always need the user.</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 6: Under the Task 5 reply, every post-release fix reaches each application pin in D-17 order: patch tags, dependent requires, then separate pointer-bump commits in sm-demo-app, sm-bm-app and fonoteka.go</name>
|
||||
<precondition>19.6-04-SUMMARY.md records "release" (or the pre-authorisation) under Task 5 reply; on "hold" or "nothing to propagate" this task only writes the todo (hold) or records "not needed".</precondition>
|
||||
<files>../sm-mfa-plugin/go.mod, ../sm-mfa-plugin/go.sum, ../sm-sociallogin-plugin/go.mod, ../sm-sociallogin-plugin/go.sum, ../sm-demo-app/plugins/golem15/mfa, ../sm-demo-app/plugins/golem15/user, ../sm-demo-app/plugins/golem15/sociallogin, ../sm-demo-app/go.mod, ../sm-demo-app/go.sum, ../sm-bm-app/plugins/golem15/mfa, ../sm-bm-app/plugins/golem15/user, ../sm-bm-app/plugins/golem15/sociallogin, ../fonoteka.go/plugins/golem15/user, .planning/todos/pending/mfa-post-release-fixes.md</files>
|
||||
<read_first>19.6-04-SUMMARY.md draft (table, reply), 19.6-03-SUMMARY.md (tags and pins), 19.6-03-PLAN.md Tasks 5 and 6 (the same procedures), .planning/G15OFFICE.md</read_first>
|
||||
<action>On release, per row and in D-17 order: sm-user-plugin first (push, annotated patch tag, push tag), then sm-mfa-plugin and sm-sociallogin-plugin (require the new user tag when the fix is there, `go mod tidy`, vet and short tests, commit, push, patch tag), then the applications in separate commits (`chore: bump <plugin> to <tag>`): sm-demo-app (submodule checkout of the tag, go.mod require and go.sum), sm-bm-app (gitlink), fonoteka.go (user gitlink only, local commit, never pushed); push sm-demo-app and sm-bm-app after the same pre-push check as plan 03 Task 5 (fetch, compare the unpushed range to this phase's commits, never force, never deploy). Then for every fix commit F and every pin P listed in its row run `git merge-base --is-ancestor F <pin commit>` in the plugin repository and record the results. On hold: write .planning/todos/pending/mfa-post-release-fixes.md (the table, the pins lacking each fix, the commands to release them) with a g15office task under the Phase 19.6 milestone task `avoroeb4`, and record each fix as a deviation in the SUMMARY.</action>
|
||||
<verify>
|
||||
<automated>scripts/check-phase19.6.sh --demo && scripts/check-phase19.6.sh --bm && scripts/check-phase19.6.sh --fonoteka</automated>
|
||||
<fails_when>Non-zero exit: an application no longer builds or passes at the new pins, a required test is missing, or fonoteka.go gained a second-factor module. On hold or nothing-to-propagate this command still runs and must pass at the existing pins.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- On release: for every row, `git -C <plugin repo> merge-base --is-ancestor <fix commit> <pinned commit>` exits 0 for each pin (results listed in the SUMMARY), and `L=$(git -C ../sm-demo-app log --oneline origin/master..master) && test -z "$L"` exits 0 after the push.
|
||||
- On hold: `test -f .planning/todos/pending/mfa-post-release-fixes.md` exits 0 and the SUMMARY names the g15office task.
|
||||
</acceptance_criteria>
|
||||
<done>No application runs a release that lacks a fix this phase made, or the gap is tracked as a todo with its release commands.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| test results to the phase verdict | a skipped, missing or misnamed test must not read as a pass |
|
||||
| released plugins to application pins | a fix must reach every application that pins the released code |
|
||||
| new login paths to the gate | any future token mint that skips the second factor must be caught |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.6-36 | Tampering | scripts/check-phase19.6.sh verdict | medium | mitigate | the go test -json detector fails on fail, skip, zero matches, "no tests to run" and missing names, a subtest pass never counts for its parent, the vitest detector needs the named file; --self-test proves each on planted output; docker NOT RUN is explicit and fatal with PHASE196_REQUIRE_DOCKER=1 |
|
||||
| T-19.6-37 | Tampering | post-release fixes not reaching applications | medium | mitigate | Task 4's table lists every production fix against every pin; Task 6 releases in D-17 order under the Task 5 reply and checks each pin with merge-base --is-ancestor; hold records a todo and a g15office task |
|
||||
| T-19.6-38 | Elevation of privilege | a token mint path that bypasses the second factor (assumption-delta debt) | high | mitigate | TestEveryLoginPathIsGated drives every first-factor path to a challenge and documents each ungated mint by decision; the security review lists the accepted debt from plan 01's assumption_delta_decision |
|
||||
| T-19.6-SC | Tampering | Go module installs | high | mitigate | this plan installs no module; the mfa stage pins the three approved versions and refuses the archived YAML module; the fonoteka stage refuses second-factor modules in fonoteka.go's graph |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: the gate's syntax, self-test and the go, userplugin, mfa and sociallogin stages.
|
||||
- Task 2: userplugin stage with the new names; short table tests under -race; docker stage (or NOT RUN).
|
||||
- Task 3: mfa and sociallogin stages; seven short decision tests; docker stage (or NOT RUN).
|
||||
- Task 4: `scripts/check-phase19.6.sh --all`; TestPHPMethodMap and TestPHPUser005; the review and the validation map.
|
||||
- Task 5: the reply recorded. Task 6: demo, bm and fonoteka stages at the final pins.
|
||||
- Feedback sampling (19.6-VALIDATION.md): after each commit, vet and the short suite of the touched repository.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-4: every new piece of the phase has unit tests in the last plan, and the phase has its security review with the explicit D-14 section.
|
||||
- The gate passes across all seven repositories (docker NOT RUN only where no daemon answers, recorded).
|
||||
- The PHP security test file is mapped method by method.
|
||||
- No application pins a release that lacks a fix, or the gap is a tracked todo.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-04-SUMMARY.md` when done, with the commits in order per repository, the test names added, each gate stage and its state, the Docker NOT RUN notes, the defects found with their fix commits, the Post-release fixes table, the Task 5 reply verbatim, the merge-base results, and the g15office comment.
|
||||
</output>
|
||||
@@ -102,6 +102,15 @@ Repos: `sm-mfa-plugin` (new), `sm-user-plugin`, `sm-sociallogin-plugin` (it call
|
||||
### Adoption
|
||||
- **D-17:** **sm-demo-app and sm-bm-app** mount mfa in this phase, and both get the device flow through the user plugin pin bump. fonoteka.go does not mount mfa. Its guard test `fonoteka/phase07_coverage_test.go:79` (`TestNoDeferredRoutesResolve`) fails once `/devices` routes resolve, so it is updated in the fonoteka pointer-bump commit. Commit order: sm-user-plugin → sm-mfa-plugin and sociallogin pushed → app pointer bumps as separate commits. Push, tag and repo creation need the user's go-ahead.
|
||||
|
||||
### Decided after research (2026-10-21)
|
||||
- **D-18:** **Security hardening beyond PHP, all four adopted** (RESEARCH A6, A8, A9, A10):
|
||||
- A6: a per-user second-factor failure cap: 10 failed verifications in 15 minutes refuse new challenges and verifies with the generic 401 until the window passes. It is counted from challenge rows in the DB, so it holds across instances. Failed challenge rows are kept for the window.
|
||||
- A8: `email/send` and `verify method=email` work only when the user's email method is enabled; otherwise they get the same 401 `{"error":"Invalid or expired challenge."}`. At most 3 sends per challenge.
|
||||
- A9: the mail's `expiry_minutes` is `min(email code TTL, remaining challenge time)`.
|
||||
- A10: restoring a soft-deleted account moves after the 2FA gate, so the password alone cannot reactivate an account. This refines D-02's gate placement: the password step runs first, then the gate, then the undelete and the reactivate mail.
|
||||
- **D-19:** **The first recovery-code set is returned.** When an enable or confirm call creates a new set, its response carries an additive `recovery_codes` key. Responses that create no set are unchanged from PHP.
|
||||
- **D-20:** **The demo gets a 2FA UI** in `vue-demo-app` (`../vue-demo-app`, served by sm-demo-app): TOTP setup with the QR code and confirm, the recovery codes shown once, disable, and the login challenge step (TOTP or recovery code). sm-bm-app stays API-only. WebAuthn, email codes and trusted devices in the demo UI are optional.
|
||||
|
||||
### Claude's Discretion
|
||||
- Go identifiers for the gate interface, registry and payloads.
|
||||
- The QR library (research, D-04).
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
# Phase 19.6: Two-factor auth and device flow - Pattern Map
|
||||
|
||||
**Mapped:** 2026-10-21
|
||||
**Files analyzed:** 38 new or modified files in 6 repos
|
||||
**Analogs found:** 36 / 38
|
||||
|
||||
Path roots used below (all siblings of `summercms.go`, under `/media/nvme/dev/golem15/summercms.io/summercms/`):
|
||||
- `USER` = `fonoteka.go/plugins/golem15/user` (the sm-user-plugin checkout you edit and push from, v0.1.5 / `35cf166`; sociallogin's `go.work` already replaces the module with this path)
|
||||
- `SOC` = `sm-sociallogin-plugin` (the template for the new plugin)
|
||||
- `MFA` = `sm-mfa-plugin` (new repo, plugin `golem15.mfa`)
|
||||
- `DEMO` = `sm-demo-app`, `BM` = `sm-bm-app`, `VDEMO` = `vue-demo-app` (mounted in DEMO as the `vue-demo-app` submodule)
|
||||
|
||||
All analog paths are git-tracked in their own repos (checked with `git ls-files`).
|
||||
|
||||
## File Classification
|
||||
|
||||
### sm-user-plugin (additive seams + device flow)
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|---|---|---|---|---|
|
||||
| `USER/classes/login_gate.go` (new) | service registry | request-response | `USER/classes/oauth_providers.go` (published seam interface) | role-match |
|
||||
| `USER/classes/events.go` (mod: `LoginEvent`, `PasswordChangedEvent`, `TwoFactorAdmin` iface) | model/event | event-driven | same file, `DeactivatingEvent` :59-67 | exact |
|
||||
| `USER/controllers/registration.go` (mod: `FireLoginEvent`, `FirePasswordChangedEvent`) | utility | event-driven | same file, `FireDeactivatingEvent` :166-171 | exact |
|
||||
| `USER/controllers/api_controller.go` (mod: Login gate :86-115, Refresh JTI hand-over :194-211, ResetPassword :327-343 / ChangePassword :580-597 fire hook) | controller | request-response | itself | exact |
|
||||
| `USER/controllers/users_admin_controller.go` (mod: `reset_two_factor` action, admin password hook :238-247) | controller (cabana) | CRUD | same file `AdminRecordActions` :662-720 | exact |
|
||||
| `USER/controllers/users/config_form.yaml` (mod: `recordActions`) | config | — | itself :17 | exact |
|
||||
| `USER/admin_permissions.go` (mod: `golem15.users.reset_two_factor`) | config | — | itself, `manage_privileged_groups` entry | exact |
|
||||
| `USER/controllers/devices.go` (new: initiate/status/authorize/list/revoke) | controller | request-response + CRUD | `USER/controllers/api_tokens.go` | exact |
|
||||
| `USER/models/device_auth_session.go` (new) | model | CRUD | `USER/models/api_token.go` | exact |
|
||||
| `USER/updates/2026102X0001_create_device_auth_sessions.go` (new) | migration | — | `USER/updates/202610040001_create_api_tokens.go` | exact |
|
||||
| `USER/classes/device_auth.go` (new: token/short-code gen, hash, state UPDATEs) | service | CRUD | `USER/classes/api_tokens.go` (`MintAPIToken`, `HashAPIToken`) | exact |
|
||||
| `USER/routes.go` (mod: 5 device routes) | route | — | itself :17-37 | exact |
|
||||
| `USER/config/config.yaml` (mod: `device_auth.auth_url`, ttl) | config | — | itself | exact |
|
||||
| `USER/README.md` (mod) | docs | — | itself | exact |
|
||||
|
||||
### sm-mfa-plugin (new repo)
|
||||
|
||||
| New File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|---|---|---|---|---|
|
||||
| `MFA/plugin.go` | plugin entry | — | `SOC/plugin.go` | exact |
|
||||
| `MFA/routes.go` | route | — | `SOC/routes.go` | exact |
|
||||
| `MFA/buckets.go` (`2fa-verify`) | middleware config | request-response | `sm-oauthserver-plugin/buckets.go` | exact |
|
||||
| `MFA/admin_settings.go`, `MFA/admin_permissions.go` | config | — | `SOC/admin_settings.go`, `SOC/admin_permissions.go` | exact |
|
||||
| `MFA/config.go`, `MFA/config/config.yaml` | config | — | `SOC/config.go`, `SOC/config/config.yaml` | exact |
|
||||
| `MFA/mail.go` + `MFA/views/mail/two_factor_code{,-en}.htm` | config + template | — | `sm-paymentgateway-plugin/mail.go`; template `USER/views/mail/restore-en.htm` | exact |
|
||||
| `MFA/boot.go` (listeners: `PasswordChangedEvent`, `DeletingEvent`) | event listener | event-driven | `sm-chatbots-plugin/boot.go` :82-98 | exact |
|
||||
| `MFA/classes/gate.go` (implements `userclasses.LoginGate`) | service | request-response | Research Code Example 1 + `SOC/plugin.go` :101 publish | role-match |
|
||||
| `MFA/classes/challenge.go` (create, guarded UPDATEs) | service | CRUD | `USER/classes/api_tokens.go` (hash), Research Code Example 2 | role-match |
|
||||
| `MFA/classes/totp.go`, `qr.go` | utility | transform | Research Code Examples 3, 4 | no analog (new lib) |
|
||||
| `MFA/classes/webauthn.go` | service | request-response | Research Code Examples 5, 6; cooler use from `SOC/classes/codes.go` :171-215 | partial |
|
||||
| `MFA/classes/recovery.go`, `email.go`, `trusted.go`, `events.go` | service | CRUD / event | `USER/classes/api_tokens.go`; `USER/classes/events.go` | role-match |
|
||||
| `MFA/controllers/helpers.go` | utility | — | `SOC/controllers/helpers.go` :1-26 | exact |
|
||||
| `MFA/controllers/challenge.go`, `management.go`, `passwordless.go` | controller | request-response | `USER/controllers/api_tokens.go` (jwt.auth deps), `SOC/controllers/complete.go` (unauth) | exact |
|
||||
| `MFA/models/*.go` (5 tables + `Settings`), `MFA/models/registry.go` | model | CRUD | `USER/models/api_token.go`; `SOC/models/settings.go`; `SOC/models/registry.go` | exact |
|
||||
| `MFA/models/settings/fields.yaml` | config | — | `SOC/models/settings/fields.yaml` | exact |
|
||||
| `MFA/updates/*.go`, `MFA/updates/registry.go` | migration | — | `USER/updates/202610040001_create_api_tokens.go`; `SOC/updates/*` | exact |
|
||||
| `MFA/lang/{en,pl}/lang.yaml` | config | — | `SOC/lang/*/lang.yaml` | exact |
|
||||
| `MFA/go.mod`, `MFA/go.work` | config | — | `SOC/go.mod`, `SOC/go.work` | exact |
|
||||
| `MFA/testmain_test.go`, `MFA/harness_test.go`, `*_test.go` | test | — | `SOC/testmain_test.go`, `SOC/harness_test.go` | exact |
|
||||
| `MFA/README.md` | docs | — | `SOC/README.md` | exact |
|
||||
|
||||
### sociallogin, apps, frontend
|
||||
|
||||
| File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|---|---|---|---|---|
|
||||
| `SOC/classes/flow.go` (mod: gate in `signIn` :311-324) | service | request-response | itself + `USER` Login gate | exact |
|
||||
| `SOC/classes/codes.go` (mod: `CompletionPayload.Challenge`) | model | — | itself :35-40 | exact |
|
||||
| `SOC/controllers/complete.go` (mod: write challenge as 200) | controller | request-response | itself :17-35 | exact |
|
||||
| `SOC/models/settings.go` + fields.yaml (or mfa settings: social toggle lives in **mfa** per D-12) | — | — | — | — |
|
||||
| `DEMO/summer.yaml`, `DEMO/go.work`, `DEMO/.gitmodules`, `DEMO/plugins.gen.go`, `DEMO/config/golem15.mfa.yaml` | config | — | sociallogin entries in the same files | exact |
|
||||
| `DEMO/mfa_test.go` (boot + routes resolve) | test | — | `DEMO/sociallogin_test.go` | exact |
|
||||
| `BM/summer.yaml` etc. | config | — | sociallogin entry in `BM/summer.yaml` :6-7 | exact |
|
||||
| `fonoteka.go/plugins/golem15/fonoteka/phase07_coverage_test.go` (drop `"/devices"`) | test | — | itself :79-112 | exact |
|
||||
| `VDEMO/app/composables/useDemoAuth.ts` (mod: challenge-aware login + verify) | composable | request-response | itself | exact |
|
||||
| `VDEMO/app/pages/login.vue` (mod: challenge step) | component | request-response | itself | exact |
|
||||
| `VDEMO/app/pages/account/security.vue` (new: TOTP setup/confirm, codes, disable) | component | request-response | `VDEMO/app/pages/login.vue` (form) + `subscriptions.vue` (logged-in gate) | role-match |
|
||||
| `VDEMO/app/composables/useTwoFactor.ts` (new) | composable | request-response | `VDEMO/app/composables/useDemoAuth.ts` | role-match |
|
||||
| `VDEMO/app/layouts/default.vue` (mod: "Security" link) | component | — | itself :22-25 | exact |
|
||||
|
||||
---
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `USER/classes/login_gate.go` (service registry, request-response)
|
||||
|
||||
**Analog:** `USER/classes/oauth_providers.go` :13-31: a seam interface another plugin publishes, documented with the explicit-type-argument warning.
|
||||
```go
|
||||
// OAuthProviderSource is what a social-login plugin publishes so the user
|
||||
// plugin can list its providers ... The user plugin looks it up on every
|
||||
// request, so a plugin that boots later is still found.
|
||||
//
|
||||
// Publish it with an explicit type argument,
|
||||
//
|
||||
// app.Publish[classes.OAuthProviderSource](src)
|
||||
//
|
||||
// because a bare app.Publish(src) keys the service on its concrete type and
|
||||
// the lookup misses it (the list then stays empty).
|
||||
type OAuthProviderSource interface { ... }
|
||||
```
|
||||
Lookup side (`USER/controllers/api_controller.go` :236-242):
|
||||
```go
|
||||
func oauthProviderSource(app *backpack.App) (classes.OAuthProviderSource, bool) {
|
||||
if app == nil {
|
||||
return nil, false
|
||||
}
|
||||
src, ok := app.Lookup[classes.OAuthProviderSource]()
|
||||
return src, ok && src != nil
|
||||
}
|
||||
```
|
||||
Registry body: copy Research Code Example 1 (`LoginGates` with `sync.RWMutex`, `Add`, `Check` fail-closed). The user plugin publishes `*classes.LoginGates` in `Register`; mfa calls `.Add` in `Boot`. `TwoFactorAdmin` (D-15) is a second interface in the same style, published by mfa with `app.Publish[userclasses.TwoFactorAdmin](…)`.
|
||||
|
||||
---
|
||||
|
||||
### `USER/classes/events.go` + `USER/controllers/registration.go` (events, event-driven)
|
||||
|
||||
**Analog event type** (`classes/events.go` :59-67), copy for `PasswordChangedEvent{User, Tx, Source}` and `LoginEvent{User, Source}`; add `LoginEventName`/`PasswordChangedEventName` constants like :8-15:
|
||||
```go
|
||||
// DeactivatingEvent fires after a user is soft-deleted ... inside the same
|
||||
// transaction ... A listener error rolls the deactivation back.
|
||||
type DeactivatingEvent struct {
|
||||
User *models.User
|
||||
Tx *gorm.DB
|
||||
}
|
||||
```
|
||||
**Fire helper** (`controllers/registration.go` :166-171), copy as `FirePasswordChangedEvent` / `FireLoginEvent` (exported so mfa and sociallogin can fire the login event after verify/passwordless):
|
||||
```go
|
||||
func FireDeactivatingEvent(ctx context.Context, app *backpack.App, user *models.User, tx *gorm.DB) error {
|
||||
if app == nil || app.Events == nil {
|
||||
return nil
|
||||
}
|
||||
return app.Events.Fire(ctx, &classes.DeactivatingEvent{User: user, Tx: tx})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `USER/controllers/api_controller.go` (controller, request-response)
|
||||
|
||||
**Login** :47-127. Insert the gate after `CheckAndRecordLogin(…, true)` (:94-101) and before `mintFor` (:115). `fields` is already parsed at :53 (`readFields(r)`), so pass it into `LoginAttempt` (Pitfall 2). Per D-18 A10, **move** the soft-delete restore block (:87-93) after the gate:
|
||||
```go
|
||||
if user.DeletedAt.Valid {
|
||||
if err := gdb.WithContext(ctx).Unscoped().Model(user).Update("deleted_at", nil).Error; err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.reactivate"), user.Email, mailVars(displayName(user), "", ""))
|
||||
}
|
||||
if err := classes.CheckAndRecordLogin(ctx, gdb, user, ip, true); err != nil {
|
||||
...
|
||||
}
|
||||
// <- gate goes here (Research Code Example 1), then the restore block, rehash, TouchLastSeen, mintFor
|
||||
token, err := mintFor(app, secret, user, requestURL(r))
|
||||
...
|
||||
writeJSON(w, http.StatusOK, map[string]any{"token": token, "user": payload})
|
||||
```
|
||||
Error idiom everywhere: `writeOpaque500(w); return`. Fire `LoginEvent` just before the final `writeJSON`.
|
||||
|
||||
**Refresh** :179-213. After `bouncer.Refresh` succeeds, add the JTI hand-over (Research Code Example 8) next to the existing best-effort `TouchLastSeen` block, with the same "log, never fail" idiom:
|
||||
```go
|
||||
if sub, _, _, _, err := bouncer.VerifyClaims(next, secret); err == nil {
|
||||
if id, err := strconv.ParseUint(sub, 10, 64); err == nil && id != 0 {
|
||||
if err := classes.TouchLastSeen(r.Context(), gdb, uint(id)); err != nil {
|
||||
slog.Default().Warn("golem15.user: stamp last_seen on refresh", "user_id", id, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
`bouncer.VerifyClaims(tokenString, secret) (sub, iat, exp, jti, err)` at `summercms.go/modules/bouncer/jwt.go:194`; `bouncer.Mint(secret, sub, issuerURL, ttl) (token, jti, err)` at `bouncer/mint.go:31`.
|
||||
|
||||
**Password-write sites** for `FirePasswordChangedEvent`: ResetPassword :335-343 (`Updates(map[string]any{"password": hashed, "tokens_valid_after": now, "email_verified_at": now})`), ChangePassword :589-597. Wrap the `Updates` + fire in `gdb.Transaction` so a listener error rolls back (Pattern 5). Do **not** fire from the rehash at :102-110.
|
||||
|
||||
**Blacklist on revoke** (copy from Logout :137-157):
|
||||
```go
|
||||
if bl, ok := app.Lookup[bouncer.BlacklistStore](); ok && bl != nil {
|
||||
if err := bl.Add(r.Context(), jti, exp, time.Now()); err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Mail send helper** :931-943 (`sendTemplateVars`): mfa copies it verbatim with its own log prefix.
|
||||
|
||||
---
|
||||
|
||||
### `USER/controllers/users_admin_controller.go` + `config_form.yaml` + `admin_permissions.go` (cabana, CRUD)
|
||||
|
||||
**Analog:** `AdminRecordActions` :662-720. Add one more entry built like the `action(...)` closure but with `Permissions: []string{PermissionResetTwoFactor}`, `Danger: true`, `Applies` = lookup `classes.TwoFactorAdmin` (false when absent) then `HasTwoFactor`, `Run` = `cabana.TxFromContext(ctx)` then `svc.Reset(ctx, tx, user)`:
|
||||
```go
|
||||
return pact.AdminRecordAction{
|
||||
Name: name,
|
||||
Label: usersLang + label,
|
||||
Confirm: usersLang + confirm,
|
||||
Permissions: []string{PermissionAccessUsers},
|
||||
Applies: func(ctx context.Context, record any) (bool, error) { ... },
|
||||
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
|
||||
user, err := asUser(in.Record)
|
||||
...
|
||||
tx, ok := cabana.TxFromContext(ctx)
|
||||
if !ok {
|
||||
return pact.AdminRecordActionResult{}, errors.New("users admin: a record action needs its transaction")
|
||||
}
|
||||
...
|
||||
return pact.AdminRecordActionResult{Message: usersLang + success}, nil
|
||||
},
|
||||
}
|
||||
```
|
||||
`config_form.yaml` :17 becomes `recordActions: [activate, unban, unsuspend, reset_two_factor]`. Permission entry copies `golem15.users.manage_privileged_groups` in `admin_permissions.go` (no `Roles`). Lang keys go in `USER/lang/*/lang.yaml`.
|
||||
|
||||
Admin password change :238-247: `changed, err := c.storePassword(ctx, user)` → when `changed`, fire `PasswordChangedEvent` with `requestDB(ctx, c.db)` as `Tx`.
|
||||
|
||||
---
|
||||
|
||||
### `USER/controllers/devices.go` (controller, request-response + CRUD)
|
||||
|
||||
**Analog:** `USER/controllers/api_tokens.go`.
|
||||
|
||||
**Imports** (:3-17):
|
||||
```go
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/wire"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
```
|
||||
**Auth deps** (:113-129), reuse for `authorize`, `GET devices`, `DELETE devices/{id}` (routes carry `"jwt.auth"`):
|
||||
```go
|
||||
func apiTokenDeps(w http.ResponseWriter, r *http.Request, app *backpack.App) (*gorm.DB, *bouncer.Principal, bool) {
|
||||
...
|
||||
db, ok := app.Lookup[*gorm.DB]()
|
||||
...
|
||||
user, ok := bouncer.User(r.Context())
|
||||
if !ok || user == nil || user.ID == 0 {
|
||||
writeUnauthorized(w)
|
||||
return nil, nil, false
|
||||
}
|
||||
return db, user, true
|
||||
}
|
||||
```
|
||||
**List** (:66-85) uses `wire.Slice(data)` for an empty `[]`. **Destroy by id** (:88-111): `strconv.ParseUint(r.PathValue("id"), 10, 64)`, 404 on `gorm.ErrRecordNotFound`, scoped `Where("user_id = ?", user.ID)`. Device `initiate`/`status` are unauthenticated; status uses the guarded `confirmed→used` UPDATE (Research Code Example 7) and mints with `bouncer.Mint` to keep the JTI (`mintFor` discards it).
|
||||
|
||||
### `USER/routes.go` (route)
|
||||
Add inside the existing `/_user/api/v1` group (:17-37). Keep each `g.Where("id", "[0-9]+")` **directly after** its `{id}` route (Pitfall 17), as at :35-36:
|
||||
```go
|
||||
g.Delete("/tokens/{id}", controllers.APITokenDestroy(p.app), "jwt.auth")
|
||||
g.Where("id", "[0-9]+")
|
||||
```
|
||||
|
||||
### `USER/models/device_auth_session.go` (model)
|
||||
**Analog:** `USER/models/api_token.go` :10-50: explicit `gorm:"column:…"` tags, `TokenHash string … json:"-"`, `TableName()`, `Fillable()`, `Hidden()`, `func init() { Register(APIToken{}) }`. Table name `golem15_user_device_auth_sessions`; columns token_hash, short_code, status, user_id, device_name (bounded, Pitfall 14), ip, `jwt_jti`, expires_at, confirmed_at, used_at, last_activity_at.
|
||||
|
||||
### `USER/updates/2026102X0001_create_device_auth_sessions.go` (migration)
|
||||
**Analog:** `USER/updates/202610040001_create_api_tokens.go` :8-36:
|
||||
```go
|
||||
var apiTokenMigrations = []*gormigrate.Migration{
|
||||
{
|
||||
ID: "202610040001_create_user_api_tokens",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`CREATE TABLE user_api_tokens (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
...
|
||||
token_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||
...
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX user_api_tokens_user_id_index ON user_api_tokens (user_id);`).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Exec("DROP TABLE IF EXISTS user_api_tokens").Error
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func init() { Register(apiTokenMigrations...) }
|
||||
```
|
||||
`user_id` is nullable for device sessions (pending rows have no user).
|
||||
|
||||
### `USER/classes/device_auth.go` (service)
|
||||
**Analog:** `USER/classes/api_tokens.go` :75-111: `crypto/rand` bytes, hash with `HashAPIToken` (SHA-256 hex = the D-06 `sha256Hex` scheme), never store plaintext:
|
||||
```go
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
...
|
||||
func HashAPIToken(secret string) string {
|
||||
sum := sha256.Sum256([]byte(secret))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
```
|
||||
Reuse `HashAPIToken` (or an exported alias) for device, challenge and trusted-device tokens instead of a new hash func.
|
||||
|
||||
---
|
||||
|
||||
### `MFA/plugin.go` (plugin entry)
|
||||
|
||||
**Analog:** `SOC/plugin.go`. Copy package doc, the interface assertion block (:26-35, add `pact.HasMailTemplates`, `surf.BucketProvider`), `PluginID` const, `//go:embed config` / `lang`, `Requires() []string{"golem15.user"}` (:72), and the Boot shape that **needs no database** (:91-102; DEMO `boot_test.go` boots without a DB, Pitfall 20):
|
||||
```go
|
||||
func (p *Plugin) Boot(app *backpack.App) error {
|
||||
p.app = app
|
||||
copyLegacyEnv(app)
|
||||
if _, err := LoadConfig(app); err != nil {
|
||||
return err
|
||||
}
|
||||
return app.Publish[userclasses.OAuthProviderSource](&providerSource{app: app, reg: p.reg})
|
||||
}
|
||||
...
|
||||
func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() }
|
||||
func (p *Plugin) Models() []any { return models.All() }
|
||||
func init() { party.Register(&Plugin{}) }
|
||||
```
|
||||
mfa's Boot: look up `*userclasses.LoginGates` and `Add` the gate, `app.Publish[userclasses.TwoFactorAdmin](…)`, register listeners. DB, cache and mailer are looked up per request.
|
||||
|
||||
### `MFA/routes.go` (route)
|
||||
**Analog:** `SOC/routes.go` :12-26:
|
||||
```go
|
||||
func (p *Plugin) Routes(r pact.Router) error {
|
||||
r.Group("/_user/api/v1", surf.Use("throttle:user-api"), func(g pact.Router) {
|
||||
g.Get("/oauth-complete", controllers.Complete(p.app))
|
||||
...
|
||||
})
|
||||
return nil
|
||||
}
|
||||
```
|
||||
mfa: two groups on `/_user/api/v1/2fa`: challenge + passwordless with `surf.Use("throttle:2fa-verify")`; management routes with `"jwt.auth"` per route (as `USER/routes.go` :33-36) and `g.Where("id", "[0-9]+")` after each `{id}` route.
|
||||
|
||||
### `MFA/buckets.go` (`2fa-verify`, 10/min per IP)
|
||||
**Analog:** `sm-oauthserver-plugin/buckets.go` :29-111, the per-IP variant at :96-99:
|
||||
```go
|
||||
var _ surf.BucketProvider = (*Plugin)(nil)
|
||||
|
||||
func (p *Plugin) Buckets() map[string]surf.Bucket {
|
||||
trusted := surf.TrustedProxies(nil)
|
||||
if p.app != nil {
|
||||
trusted = surf.TrustedProxies(p.app.Config)
|
||||
}
|
||||
ip := func(r *http.Request) string { return surf.ClientIP(r, trusted) }
|
||||
...
|
||||
BucketMCPRegister: {
|
||||
Name: BucketMCPRegister, Max: 10, Decay: time.Minute,
|
||||
Key: func(r *http.Request) string { return "oauth-mcp-register-ip:" + ip(r) },
|
||||
},
|
||||
```
|
||||
Bucket names are global; mfa owns `"2fa-verify"`.
|
||||
|
||||
### `MFA/admin_settings.go`, `MFA/admin_permissions.go`, `MFA/models/settings.go`, `MFA/models/settings/fields.yaml`, settings migration
|
||||
**Analog:** `SOC/admin_settings.go` (whole file), `SOC/admin_permissions.go` (whole file), `SOC/models/settings.go`, `SOC/updates/202610200001_create_golem15_sociallogin_settings.go`.
|
||||
```go
|
||||
func (p *Plugin) Settings() []pact.SettingsItem {
|
||||
return []pact.SettingsItem{{
|
||||
Code: SettingsCode,
|
||||
Label: models.LangPrefix + "settings.label",
|
||||
...
|
||||
Permissions: []string{PermissionAccessSettings},
|
||||
Form: "models/settings/fields.yaml",
|
||||
NewModel: func() any { return &models.Settings{} },
|
||||
}}
|
||||
}
|
||||
```
|
||||
Settings model essentials (`SOC/models/settings.go`): singleton `ID` 1, nullable pointer columns where NULL = config default, `TableName`, `Fillable`, `Rules` (`"nullable|max:255"` style), `BeforeValidate` returning `&cabana.ValidationError{Details: map[string]any{"field": []string{"…"}}}` (:84-91; use it for the methods/groups allow-lists), and `LoadSettings(ctx, db)` per request (:129-141):
|
||||
```go
|
||||
func LoadSettings(ctx context.Context, db *gorm.DB) (*Settings, error) {
|
||||
...
|
||||
var rows []Settings
|
||||
if err := db.WithContext(ctx).Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil {
|
||||
return nil, fmt.Errorf("golem15.sociallogin: settings: %w", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return &Settings{ID: 1}, nil
|
||||
}
|
||||
return &rows[0], nil
|
||||
}
|
||||
```
|
||||
Migration seeds row 1: `INSERT INTO … (id, created_at, updated_at) VALUES (1, NOW(), NOW())`, executed through `run(tx, []string{…})` from `SOC/updates/registry.go`. Field types: `dropdown` (mode), `taglist` `mode: array` (methods, groups), `number`, `switch` (Research §Q8). `TOTP secret` column uses `lagoon.Encrypted` with `gorm:"…;type:text" json:"-"` like `SOC/models/settings.go` :39.
|
||||
|
||||
### `MFA/models/*.go` + `MFA/updates/*.go` (5 tables)
|
||||
Model: `USER/models/api_token.go` (see above). Registry files: `SOC/models/registry.go` and `SOC/updates/registry.go` (whole files, `Register`/`All`, `run` helper). Migration SQL: `USER/updates/202610040001_create_api_tokens.go` style with `REFERENCES users(id) ON DELETE CASCADE`. Table names keep PHP's `golem15_user_*` (D-16).
|
||||
|
||||
### `MFA/mail.go` + `MFA/views/mail/two_factor_code{,-en}.htm`
|
||||
**Analog:** `sm-paymentgateway-plugin/mail.go` :1-24:
|
||||
```go
|
||||
//go:embed views/mail
|
||||
var mailFS embed.FS
|
||||
|
||||
func (p *Plugin) MailTemplatesFS() fs.FS { return mailFS }
|
||||
|
||||
func (p *Plugin) MailTemplates() []string {
|
||||
return []string{
|
||||
"golem15.paymentgateway::mail.order.canceled",
|
||||
"golem15.paymentgateway::mail.order.canceled-en",
|
||||
```
|
||||
mfa lists `golem15.mfa::mail.two_factor_code` and `…-en` (postcard requires the owning plugin prefix, Pitfall 18). Template front-matter from `USER/views/mail/restore-en.htm`:
|
||||
```
|
||||
subject = "Reset your password"
|
||||
description = "Password reset"
|
||||
layout = "user"
|
||||
==
|
||||
Hello **{{ .Name }}**.
|
||||
```
|
||||
mfa cannot use the `user` layout alias; use the default layout or ship `views/mail/layouts/…` with its own `MailLayouts()`. Pass both cases of vars (`"name"`/`"Name"`, `"code"`/`"Code"`, `"expiry_minutes"`/`"ExpiryMinutes"`) as `USER mailVars` (:924-929) does. Locale: `userclasses.MailTemplate(base, userclasses.ResolveMailLocale(ctx, user))`.
|
||||
|
||||
### `MFA/boot.go` listeners
|
||||
**Analog:** `sm-chatbots-plugin/boot.go` :82-98:
|
||||
```go
|
||||
app.Events.Listen[*userclasses.DeletingEvent](PluginID, p.onUserDeleting)
|
||||
...
|
||||
func (p *Plugin) onUserDeleting(ctx context.Context, ev *userclasses.DeletingEvent) error {
|
||||
if p == nil || ev == nil || ev.User == nil {
|
||||
return nil
|
||||
}
|
||||
db := ev.Tx
|
||||
if db == nil {
|
||||
db = p.db
|
||||
}
|
||||
...
|
||||
return db.WithContext(ctx).Where("user_id = ?", ev.User.ID).Delete(&models.BotFlag{}).Error
|
||||
}
|
||||
```
|
||||
mfa: `PasswordChangedEvent` → delete trusted devices through `ev.Tx`.
|
||||
|
||||
### `MFA/controllers/*.go` (controller, request-response)
|
||||
- **Helpers:** copy `SOC/controllers/helpers.go` :22-26 (`writeJSON` with `Cache-Control: no-cache, private` + `wire.WriteJSON`).
|
||||
- **Management (jwt.auth):** `USER/controllers/api_tokens.go` `apiTokenDeps` pattern (above). Management errors are PHP's 401 `{"error":"Unauthorized"}`.
|
||||
- **Challenge (unauth):** shape of `SOC/controllers/complete.go` :17-35: read input, call a classes function, log with `slog.Default().Error("golem15.mfa: …", "err", err)` + 500 `{"error":"Server error"}`, map not-found to the PHP status.
|
||||
- **Token mint after verify:** `usercontrollers.FinishLogin` → `usercontrollers.IssueToken` → `usercontrollers.APIArray` exactly as `SOC/classes/flow.go` :311-324 (`signIn`), then `FireLoginEvent`.
|
||||
- **Validation:** 422 `{error, errors}` via a local `writeValidation` copied from `USER/controllers/api_controller.go` :945-950.
|
||||
|
||||
### `MFA/classes/webauthn.go` ceremony state in cooler
|
||||
**Analog:** `SOC/classes/codes.go` :171-215 (`cooler.FromApp(app)`, `cache.Set(ctx, key, value, ttl)`, `cache.Pull(ctx, key)`, reject `len(code) > 128`). Ceremony state needs no AEAD sealing (it holds no secret), so a plain JSON `Set`/`Pull` suffices.
|
||||
|
||||
### `MFA/testmain_test.go`, `MFA/harness_test.go`
|
||||
**Analog:** `SOC/testmain_test.go` (TestMain with `flag.Parse()` + `testing.Short()`, `postgres:16-alpine`, `freshDB(t)` at :81-83) and `SOC/harness_test.go` :34-80 (`bootSocialWith`: freshDB → `lagoon.Use` → `httptest.NewUnstartedServer` → temp config dir with `app.yaml` → `party.Activate`). Rename DB/user to `golem15_mfa`. Blank-import `_ "git.golem15.com/golem15/sm-user-plugin"` as SOC does (:22).
|
||||
|
||||
### `MFA/go.mod`, `MFA/go.work`
|
||||
**Analog:** `SOC/go.work`:
|
||||
```
|
||||
go 1.27.0
|
||||
|
||||
use .
|
||||
|
||||
replace git.golem15.com/golem15/summercms => ../summercms.go
|
||||
|
||||
replace git.golem15.com/golem15/sm-user-plugin => ../fonoteka.go/plugins/golem15/user
|
||||
```
|
||||
`SOC/go.mod` requires `sm-user-plugin v0.1.5` and `summercms v0.3.1`; mfa requires the **new** user-plugin tag carrying the seams, plus pquerna/otp v1.5.0, go-webauthn v0.18.2, boombuler/barcode v1.1.0 (after the decision note).
|
||||
|
||||
---
|
||||
|
||||
### `SOC/classes/flow.go`, `SOC/classes/codes.go`, `SOC/controllers/complete.go` (D-09)
|
||||
`signIn` (:311-324) is where the gate goes, before `IssueToken`, with `Source: "social"`, and only when the mfa social toggle is on (the gate itself reads the setting; sociallogin just calls `gates.Check`). `linkIdentity` (flow.go ~:128-165) stays ungated.
|
||||
```go
|
||||
func signIn(ctx context.Context, app *backpack.App, r *http.Request, db *gorm.DB, user *usermodels.User, action, returnTo string) (Outcome, error) {
|
||||
if err := usercontrollers.FinishLogin(ctx, app, db, user, ClientIP(app, r)); err != nil {
|
||||
return Outcome{}, err
|
||||
}
|
||||
token, err := usercontrollers.IssueToken(app, user, r)
|
||||
...
|
||||
return Outcome{Kind: OutcomeLogin, Token: token, User: payload, Action: action, ReturnTo: returnTo}, nil
|
||||
}
|
||||
```
|
||||
Note: `FinishLogin` restores soft-deleted accounts, so per D-18 A10 the gate must run **before** `FinishLogin`. Add `Challenge map[string]any \`json:"challenge,omitempty"\`` to `CompletionPayload` (codes.go :35-40); in `Complete` (:34), when `p.Challenge != nil`, `writeJSON(w, http.StatusOK, p.Challenge)` instead of `writeSession`.
|
||||
|
||||
### DEMO / BM adoption
|
||||
- `DEMO/summer.yaml`: add `- id: golem15.mfa` / `module: git.golem15.com/golem15/sm-mfa-plugin` after `golem15.sociallogin` (pattern: existing entries).
|
||||
- `DEMO/go.work`: add `./plugins/golem15/mfa` to `use (…)`.
|
||||
- `DEMO/.gitmodules`: copy the `plugins/golem15/user` stanza (`url = git@git.golem15.com:golem15/sm-mfa-plugin.git`, `branch = master`); add via `ssu`.
|
||||
- Regenerate `plugins.gen.go`/`main.go` with `summer build`.
|
||||
- `DEMO/config/golem15.mfa.yaml`: WebAuthn `rp_id`/`origins` override for local use (Pitfall 7; app url is `http://127.0.0.1:8097`, frontend `http://127.0.0.1:3020`).
|
||||
- Test: copy `DEMO/sociallogin_test.go` :16-40 (`testConfig` → `backpack.New` → `party.Activate(app, PluginIDs)` → `surf.BuildRouter` → assert `/_user/api/v1/2fa/...` and `/devices` routes resolve).
|
||||
- `BM/summer.yaml` :4-7 same entry; bm stays API-only.
|
||||
|
||||
### `fonoteka.go/plugins/golem15/fonoteka/phase07_coverage_test.go`
|
||||
Remove `"/devices",` from `banned` (:97-105); keep `"2fa"`. Separate pointer-bump commit in fonoteka.go.
|
||||
|
||||
### `VDEMO` (D-20)
|
||||
**`app/composables/useDemoAuth.ts`** (whole file, 20 lines). Extend `login` to detect `two_factor_required` and return the challenge instead of throwing; add `verify(challengeToken, method, code)` → `POST /_user/api/v1/2fa/verify` and `recovery(challengeToken, code)` → `/2fa/recovery`, both setting `token.value`:
|
||||
```ts
|
||||
export function useDemoAuth() {
|
||||
const token = useCookie<string | null>(DEMO_TOKEN_COOKIE, { maxAge: 60 * 60 * 24, sameSite: 'lax' })
|
||||
const loggedIn = computed(() => !!token.value)
|
||||
|
||||
async function login(email: string, password: string) {
|
||||
const res = await $fetch<{ token?: string; ... }>(
|
||||
'/_user/api/v1/login',
|
||||
{ method: 'POST', body: { email, password } },
|
||||
)
|
||||
const value = res.token ?? res.access_token ?? res.data?.token ?? res.data?.access_token
|
||||
if (!value) throw new Error('No token in the login answer')
|
||||
token.value = value
|
||||
}
|
||||
```
|
||||
**`app/pages/login.vue`**: keep the `submit`/`busy`/`error` idiom and the `next` redirect guard (:15-22); add a second form shown when a challenge is pending (code input with `autocomplete="one-time-code"`, a "use a recovery code" toggle). Error extraction `e?.data?.message || e?.data?.error`.
|
||||
**New `app/pages/account/security.vue`** + **`app/composables/useTwoFactor.ts`**: `$fetch` with `headers: { Authorization: \`Bearer ${token.value}\` }` to `/2fa/status`, `/2fa/totp/setup` (render `qr_code_svg` with `v-html`, the SVG is server-generated), `/2fa/totp/confirm` (show `recovery_codes` from D-19 once), `DELETE /2fa/totp`. Logged-out notice copied from `subscriptions.vue` :21-23. Markup classes: `wrap section section--narrow`, `panel`, `form`, `button`, `form__error`, `notice`.
|
||||
**`app/layouts/default.vue`** :22-25: add a `NuxtLink to="/account/security"` next to "Log out". `/_user` is already in `goPaths` in `nuxt.config.ts`, so no proxy change.
|
||||
|
||||
---
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### JSON responses
|
||||
**Source:** `SOC/controllers/helpers.go` :22-26; user plugin `writeJSON`/`writeOpaque500`/`writeUnauthorized`.
|
||||
**Apply to:** every mfa and device handler. Timestamps through `wire.Time` (`+00:00`), lists through `wire.Slice`.
|
||||
|
||||
### Cross-plugin service seams
|
||||
**Source:** `USER/classes/oauth_providers.go` :13-31 and `SOC/plugin.go` :101. Always `app.Publish[Iface](impl)` with the explicit type and look up per request. **Apply to:** `LoginGates`, `TwoFactorAdmin`.
|
||||
|
||||
### Typed events
|
||||
**Source:** `USER/classes/events.go`, `USER/controllers/registration.go` :129-171, listener `sm-chatbots-plugin/boot.go` :82-98. Events carry `Tx`; listener errors roll the write back. **Apply to:** `LoginEvent`, `PasswordChangedEvent`, all `golem15.user.2fa.*` events (as typed structs with `…EventName` constants).
|
||||
|
||||
### Hashed tokens at rest
|
||||
**Source:** `USER/classes/api_tokens.go` :75-99 (`crypto/rand` 32 bytes, `HashAPIToken` SHA-256 hex, `token_hash VARCHAR(64) NOT NULL UNIQUE`). **Apply to:** challenge, trusted-device and device-session tokens. Slow hash for recovery/email codes: `bouncer.HashPassword(cost, plain)` / `bouncer.CheckPassword(hash, plain)` with the cost read as in `USER/controllers/api_controller.go` :102-105.
|
||||
|
||||
### Guarded single-statement state changes
|
||||
**Source:** Research Code Examples 2 and 7 (no existing analog; the user plugin uses read-modify-write). **Apply to:** challenge attempts/completion, recovery consume, device confirm/used, email send count.
|
||||
|
||||
### Raw-SQL migrations with registry
|
||||
**Source:** `SOC/updates/registry.go`, `USER/updates/202610040001_create_api_tokens.go`. **Apply to:** all six tables. Never GORM AutoMigrate.
|
||||
|
||||
### Docker-gated tests
|
||||
**Source:** `SOC/testmain_test.go` (flag.Parse + testing.Short). **Apply to:** mfa and user-plugin DB tests.
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|---|---|---|---|
|
||||
| `MFA/classes/totp.go`, `MFA/classes/qr.go` | utility | transform | First use of pquerna/otp and boombuler; use Research Code Examples 3 and 4 |
|
||||
| `MFA/classes/webauthn.go` (ceremony logic) | service | request-response | First WebAuthn use; use Research §Q6 and Code Examples 5-6 (only the cooler storage has an analog) |
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `fonoteka.go/plugins/golem15/user`, `sm-sociallogin-plugin`, `sm-oauthserver-plugin`, `sm-paymentgateway-plugin`, `sm-chatbots-plugin`, `sm-demo-app`, `sm-bm-app`, `vue-demo-app`, `fonoteka.go/plugins/golem15/fonoteka`, `summercms.go/modules/bouncer`
|
||||
**Files scanned:** ~45
|
||||
**Pattern extraction date:** 2026-10-21
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
---
|
||||
phase: "19.6"
|
||||
slug: "two-factor-auth-and-device-flow-in-sm-user-plugin"
|
||||
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
|
||||
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
created: "2026-10-21"
|
||||
---
|
||||
|
||||
# Phase 19.6 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | Go `testing` + `httptest`; testcontainers-go Postgres (DB tests skip under `-short`) |
|
||||
| **Config file** | none — Wave 0 adds `sm-mfa-plugin/testmain_test.go` and `harness_test.go` (copied from `sm-sociallogin-plugin`) |
|
||||
| **Quick run command** | `go -C ../sm-mfa-plugin test -short ./...` and `go -C ../fonoteka.go test -short ./plugins/golem15/user/...` |
|
||||
| **Full suite command** | `scripts/check-phase19.6.sh` (go, userplugin, mfa, sociallogin, demo, bm, fonoteka, docker, docs stages) |
|
||||
| **Estimated runtime** | ~180 seconds (full, with Docker) |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** `go vet ./...` + `go test -short ./...` of the touched repo
|
||||
- **After every plan wave:** full suites (Docker) of every touched repo
|
||||
- **Before `/msd-verify-work`:** `scripts/check-phase19.6.sh` must be green
|
||||
- **Max feedback latency:** 60 seconds for the short loop
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
Decision → test map is in `19.6-RESEARCH.md` § Validation Architecture; the planner assigns task IDs.
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||
| 19.6-XX-XX | TBD | TBD | D-XX | T-19.6-XX | per RESEARCH map | unit/integration | per RESEARCH map | ❌ W0 | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `sm-mfa-plugin/testmain_test.go`, `harness_test.go` — boot `golem15.user` + `golem15.mfa`
|
||||
- [ ] WebAuthn fixtures — recorded attestation/assertion JSON or a virtual authenticator
|
||||
- [ ] Clock injection for TOTP tests
|
||||
- [ ] user-plugin harness helper for device-flow tests
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| WebAuthn registration and login in a real browser | D-08 / SC-1 | Browser authenticator ceremony cannot run in `go test` | Register a passkey on the demo instance over HTTPS with a hostname RP ID, then sign in with it |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 60s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
@@ -0,0 +1,460 @@
|
||||
---
|
||||
phase: 19.6.1-backend-administrators-screen
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- modules/pact/capabilities.go
|
||||
- modules/pact/README.md
|
||||
- modules/cabana/action_extensions.go
|
||||
- modules/cabana/backend_screens.go
|
||||
- modules/cabana/backend_users_admin.go
|
||||
- modules/cabana/backend_roles_admin.go
|
||||
- modules/cabana/backend_privileges.go
|
||||
- modules/cabana/backendscreens/controllers/users/config_list.yaml
|
||||
- modules/cabana/backendscreens/controllers/users/config_form.yaml
|
||||
- modules/cabana/backendscreens/controllers/users/config_filter.yaml
|
||||
- modules/cabana/backendscreens/controllers/users/_hint.htm
|
||||
- modules/cabana/backendscreens/models/user/columns.yaml
|
||||
- modules/cabana/backendscreens/models/user/fields.yaml
|
||||
- modules/cabana/backendscreens/controllers/roles/config_list.yaml
|
||||
- modules/cabana/backendscreens/controllers/roles/config_form.yaml
|
||||
- modules/cabana/backendscreens/models/userrole/columns.yaml
|
||||
- modules/cabana/backendscreens/models/userrole/fields.yaml
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/registry.go
|
||||
- modules/cabana/contracts.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/backend_screens_test.go
|
||||
- modules/cabana/backend_screens_smoke_test.go
|
||||
- modules/cabana/README.md
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- docs/backend/admin-controllers.md
|
||||
- docs/backend/users-and-permissions.md
|
||||
autonomous: true
|
||||
requirements: [SC-1, SC-2, SC-3, D-01, D-02, D-03, D-04, D-05, D-06, D-07, D-08, D-09, D-10, D-11, D-12, D-13, D-14, D-15, D-16, D-17, D-18, D-19, D-20, D-22]
|
||||
|
||||
estimate:
|
||||
tokens: 330000
|
||||
raw_tokens: 330000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-01, D-02, D-04: a superuser sees an Administrators tile (order 100) and a Roles tile (order 110) under Settings → System; the permission catalog gains backend.manage_users and backend.manage_roles granted to no role in code, so after an upgrade no developer or publisher admin sees a tile and the controller routes answer them 403."
|
||||
- "D-14, D-15, SC-3: a plugin implementing pact.HasAdminActionExtensions adds record actions to any admin controller by ID, summercms.cabana.users included; they are offered on that controller's preview after the owner's actions, only to admins holding the target's RequiredPermissions and the action's Permissions and for whom Applies is true, and run through the existing row-locked RecordAction transaction; an unknown controller ID, a target without a preview block, a reserved or duplicate name, a nil Run, an empty label or an unknown permission stops boot."
|
||||
- "An application whose plugins register no admin controllers still activates no admin: Activate returns nil routes without reading admin.jwt.secret, so examples/hello and the modules/surf admin-prefix tests pass unchanged."
|
||||
- "D-03: for a non-superuser the Administrators list, search and filters never return a superuser, GET of a superuser id (show, preview partial, record action) answers 404, and the is_superuser field, column and filter are absent from the form and list schemas; a superuser sees all three."
|
||||
- "D-05: a non-superuser create or update body carrying is_superuser (true or false, field hidden or not) answers 403 with backend::lang.user.superuser_forbidden on is_superuser and writes nothing; a superuser grants and removes it."
|
||||
- "D-06: the Administrators form has a Permissions tab with a radio (inherit / allow / deny) permission editor stored in backend_users.permissions and merged last by FindByID."
|
||||
- "D-07: a non-superuser cannot grant more than they hold: catalog permissions they lack are Locked options (a changed locked value answers 403 and the stored value is kept), roles whose stored plus code-declared grants they do not cover are locked in the role picker (assigning one answers 403 with user.role_ceiling, keeping an unchanged above-ceiling role still saves), and on the Roles screen codes they lack are locked."
|
||||
- "D-08: an admin editing their own record changes login, email, first and last name; a change of their own role, superuser flag, permissions or activation answers 403 user.self_privileged (the role is compared after assignment and the save rolls back), deleting yourself answers 403 user.self_delete and deactivating yourself 403 user.self_deactivate; there is no redirect to My Account."
|
||||
- "D-09: the last active superuser cannot be demoted (update), deactivated (record or bulk action) or soft-deleted (form or bulk delete); every reducing path counts the other active superusers inside the write transaction after pg_advisory_xact_lock, which is taken after the row lock, so concurrent demotions of the only two superusers leave one active superuser without a deadlock; admin:* commands never clear the flag and stay unchanged (SC-1)."
|
||||
- "D-10: Activate and Deactivate are record and bulk actions toggling is_activated, Delete is a soft delete through deleted_at, the trashed filter lists deleted admins carrying the Deleted row state, Restore brings them back, and no code path hard-deletes a backend user."
|
||||
- "D-11: password and confirmation are required on create and optional on update (blank keeps the password), at least 4 characters and at most 72 bytes, bcrypt-hashed at admin.password.bcrypt_cost like admin:create; a login or email already held (case-insensitive, deleted admins included) answers 422 on that field, saying when a deleted administrator holds it."
|
||||
- "D-12: deactivate, soft delete and a password set for another admin set tokens_valid_after to now+1s, so the target's earlier JWTs answer 401 on the next request; deactivate and delete also stop personal API tokens (FindByID refuses inactive and deleted users); restore and activate leave tokens_valid_after unchanged; a password set leaves personal API tokens working, exactly as admin:reset-password does, and the docs say so."
|
||||
- "D-13: creating an administrator sends no mail and offers no invite option."
|
||||
- "D-16: the Roles form has name, code (slug preset from name), description and a Permissions tab with a checkbox permission editor; changing the code of a system role (is_system, or a role code-declared grants attach to) answers 422 user.role.code_locked on code; name, description and stored grants stay editable within the D-07 ceiling."
|
||||
- "D-17: deleting a role that any admin holds, soft-deleted admins included, answers 403 whose message names the role and the count; deleting a system role answers 403 user.role.delete_system; a refused row in a bulk delete rolls back the whole selection."
|
||||
- "D-19: a non-superuser holding backend.manage_users gets 403 user.outranked when updating, deleting, or running a record or bulk action on a non-superuser admin whose effective grants (role JSON, code-declared role grants, user overrides) their own grants do not cover."
|
||||
- "D-20: a password sent for your own record on the Administrators form answers 403 user.own_password on password; My Account, which checks the current password, stays the place to change it."
|
||||
- "Pitfall 1: GET of a superuser's record returns data.is_superuser true to a superuser, and saving that form untouched keeps the flag."
|
||||
- "D-18, D-22: this plan is the server side of the three-plan split; cabana and pact READMEs and docs/backend/admin-controllers.md and users-and-permissions.md change in the same commits as the API, and TestDocsTree plus summer docs:build --check pass."
|
||||
- "UI: the Administrators list shows backend::lang.user.list_empty when a search or filter matches nothing (an unfiltered list always holds the viewer) and the Roles list shows user.role.list_empty (seeded system roles keep it non-empty unfiltered); the existing loading skeletons, list.load_failed and form.load_failed alerts (also the D-03 404) and empty-search copy apply unchanged."
|
||||
- "UI: Administrators list columns are login, first name, last name, email, role (dash when empty), last sign-in (dash when never), superuser (superusers only) and an invisible created column; active, deactivated, deleted and deleted+deactivated rows coexist, each state a text badge in RowStateBadges order; 20 rows per page with the existing pagination and horizontal scroll."
|
||||
- "UI: bulk Activate, Deactivate and Restore act on the selection (menu enabled only with a selection); one refused row rolls the whole selection back and the danger toast shows the refusal; every refusal that can come back from a delete or an action is a ForbiddenError with Message set (UI-SPEC C8)."
|
||||
- "UI: the Administrators preview shows at most one status callout by precedence deleted (danger) > deactivated (warning) > own account (warning), then at most one of Activate / Deactivate / Restore, then contributed actions in boot merge order wrapping with the footer, then Edit administrator; Deactivate is not offered on your own record; an action's 403 is a danger toast with the server message, 409 the existing stale toast, 404 the load-failed state."
|
||||
- "UI: the create form starts blank with password and confirmation required (422 field lines and count banner on a miss or mismatch); the Account tab pairs login/email, first/last name and password/confirmation, then a full-width role picker that shows above-ceiling roles locked; the Roles create form starts unchecked with codes the editor lacks shown as locked rows; long values scroll inside the inputs and the header title truncates."
|
||||
- "UI: a non-superuser editor sees no Superuser switch, column or filter, locked permission rows for codes they lack and locked roles above their ceiling; a 403 save shows the forbidden banner with the server message plus field lines and keeps values; a 422 shows field lines, the count banner and activates the first invalid field's tab."
|
||||
- "UI: each Settings tile is listed only to holders of its code or superusers, so an admin holding only backend.manage_roles sees only Roles; a Roles bulk delete containing a role still assigned or a system role refuses the whole selection with a danger toast naming that role."
|
||||
artifacts:
|
||||
- path: "modules/pact/capabilities.go"
|
||||
provides: "AdminActionExtension and HasAdminActionExtensions"
|
||||
contains: "type HasAdminActionExtensions interface"
|
||||
- path: "modules/cabana/action_extensions.go"
|
||||
provides: "boot-time merge of contributed record actions"
|
||||
contains: "func compileActionExtensions("
|
||||
- path: "modules/cabana/backend_screens.go"
|
||||
provides: "internal summercms.cabana pseudo-plugin: controllers, permissions, settings links, AdminFS"
|
||||
contains: "AdministratorsControllerID"
|
||||
- path: "modules/cabana/backend_users_admin.go"
|
||||
provides: "summercms.cabana.users controller with hooks, providers and actions"
|
||||
contains: "FormAfterUpdate"
|
||||
- path: "modules/cabana/backend_roles_admin.go"
|
||||
provides: "summercms.cabana.roles controller"
|
||||
contains: "FormBeforeDelete"
|
||||
- path: "modules/cabana/backend_privileges.go"
|
||||
provides: "actsAsSuperuser, effectiveGrants, grantsCovered, guardOutranked, guardLastSuperuser"
|
||||
contains: "pg_advisory_xact_lock"
|
||||
- path: "modules/cabana/backendscreens/models/user/fields.yaml"
|
||||
provides: "Administrators form (Account and Permissions tabs)"
|
||||
contains: "permissioneditor"
|
||||
- path: "modules/cabana/backend_screens_smoke_test.go"
|
||||
provides: "Postgres smoke tests of the four slices"
|
||||
contains: "TestBackendScreensPostgresSmoke"
|
||||
key_links:
|
||||
- from: "modules/cabana/http.go"
|
||||
to: "modules/cabana/action_extensions.go"
|
||||
via: "Activate merges contributions after compiling controllers and before compileContributions validates permissions"
|
||||
pattern: "compileActionExtensions\\(reg, "
|
||||
- from: "modules/cabana/http.go"
|
||||
to: "modules/cabana/backend_screens.go"
|
||||
via: "Activate appends the pseudo-plugin only after the no-controllers early return"
|
||||
pattern: "withBackendScreens\\("
|
||||
- from: "modules/cabana/backend_users_admin.go"
|
||||
to: "modules/cabana/backend_privileges.go"
|
||||
via: "every reducing write path calls the last-superuser guard inside the write tx"
|
||||
pattern: "guardLastSuperuser\\("
|
||||
- from: "modules/cabana/crud.go"
|
||||
to: "modules/cabana/backend_users_admin.go"
|
||||
via: "projectFullRecord asks the unexported data extender so is_superuser loads its stored value"
|
||||
pattern: "extendRecordData\\("
|
||||
- from: "modules/cabana/http.go"
|
||||
to: "modules/cabana/backend_users_admin.go"
|
||||
via: "formSchema and listSchema drop per-request hidden fields, columns and filters"
|
||||
pattern: "hiddenSchemaFields\\("
|
||||
prohibitions:
|
||||
- statement: "Existing cabana payloads (record, list, schema, settings, navigation, /auth/me, /account) keep their shape for every controller other than the two new ones, and admin:* commands keep their behaviour and output: modules/cabana/commands.go, token_commands.go and account.go are not modified."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No code path hard-deletes a backend user; tokens, linked_by references and attachments keep valid foreign keys."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Neither backend.manage_users nor backend.manage_roles is granted to the developer, publisher or any other role through code-declared pact.Permission roles."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No invite or welcome mail, send-reset-link action, force-change-on-next-login, impersonation, user groups, per-row list action menu or top-level navigation item is added (CONTEXT Deferred Ideas, D-02, D-13, D-15)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Framework code, READMEs, docs and tests name no consuming application; no new dependency is added and YAML stays on goccy/go-yaml."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; MVP mode is off, no story is invented): cabana gets WinterCMS's Administrators screens, so backend admins and their roles are managed in the admin SPA instead of only through `admin:*` console commands, with the privilege rules on the server and a record-action seam other plugins (19.7's link action) use.
|
||||
|
||||
This plan's slice (plan 1 of 3, wave 1, D-22): the whole server side. The tracer wires the new `pact` seam, the internal `summercms.cabana` pseudo-plugin, the Settings tile and the Administrators list and preview end to end, with a contributed record action running on the preview. Task 2 adds the form and every save-path rule, Task 3 the lifecycle actions and the last-superuser lock, Task 4 the Roles screen. Plan 02 adds the SPA parts of D-21 (field `trigger`, per-record permission locks), Plan 03 the unit and Postgres tests.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Spec-less probe fallback skipped for edges: no requirement IDs (phase_req_ids null) and no SPEC.md; visible choice. Edge rows come from 19.6.1-UI-SPEC.md "UI Considerations" instead: the covered rows for the server-rendered screens are lifted into `must_haves.truths` above (lines starting "UI:"), the D-21 rows go to Plan 02, the five backstop rows to Plan 03, and the dismissed row (static note text has no loading state) is not lifted. Prohibitions were recalled by hand (fallback §B) and authored descriptor-less.
|
||||
|
||||
## Decisions taken in this plan (recorded assumptions and Claude's discretion)
|
||||
|
||||
- IDs (CONTEXT discretion, RESEARCH A4): plugin `summercms.cabana`, controllers `summercms.cabana.users` and `summercms.cabana.roles` (SPA routes `/backend/summercms/cabana/...`); `summercms` is not a reserved first segment (registry.go:46). The migration history id `summercms.cabana` is untouched; no migration.
|
||||
- The pseudo-plugin is appended only when some real plugin registers admin controllers (RESEARCH Pattern 1, Pitfall 3). A plugin that only contributes record actions does not switch the admin on; its contributions are compiled whenever the admin is active.
|
||||
- The D-14 seam carries record actions only (CONTEXT discretion, RESEARCH A5). `AdminActionExtension` is a struct so bulk or toolbar fields can be added later without breaking implementers. A record action still carries no input beyond the record (RESEARCH Open Question 5, for 19.7 to design).
|
||||
- 19.6 D-15 ("Reset 2FA" contributed by an MFA plugin to the user plugin's Users screen) can use the seam as is: that screen has a preview block; the contributed name must not collide with a name the owner registers (RESEARCH Pattern 2).
|
||||
- D-09 lock strategy (CONTEXT discretion): one `pg_advisory_xact_lock` on a fixed key taken after the row lock, plus a count in the write transaction (RESEARCH Pattern 4; a `FOR UPDATE` on the superuser set would deadlock).
|
||||
- D-09 reachability: through the screens a single request cannot remove the last active superuser, because only an active superuser sees superusers (D-03) and changes to your own flag, activation or row are refused first (D-08), so the acting superuser always remains. The guard closes the concurrent case (superuser A demotes, deactivates or deletes B while B does the same to A) and any later write path. The smoke tests therefore exercise the self refusals; Plan 03 proves every path with a race held on the advisory lock.
|
||||
- D-07 wildcard rule (RESEARCH A3): a role's wildcard grant counts as covered only when the editor holds the same or a broader wildcard.
|
||||
- D-12 and personal API tokens (RESEARCH Pitfall 7, Open Question 6): a password set for another admin bumps `tokens_valid_after` (JWTs stop) and leaves personal API tokens working, exactly like `admin:reset-password`; deactivate and delete stop both. Documented in docs/backend/users-and-permissions.md. Revoking personal tokens on a password set would widen the phase; the user confirmed this reading on 2026-10-22 and D-12 is amended in CONTEXT.
|
||||
- D-17 refusal shape: the delete answers 403 `ForbiddenError` with a translated message naming the role and the count, not a 422 `ValidationError`, because the SPA delete toast shows only `error.message` (UI-SPEC C8 note). This deviates from CONTEXT's word "validation error"; the visible requirement (the toast names the count) holds without an SPA change.
|
||||
- D-16 system-role code change answers 422 on `code` (UI-SPEC C8), a field error on a save, not a delete refusal.
|
||||
- Roles list has no users-count column (RESEARCH Open Question 7; cabana columns have no count key).
|
||||
- The `is_superuser` projection and the per-request schema drops use unexported in-package interfaces, so no `pact` surface and no payload change for other controllers.
|
||||
- Lifecycle copy, filters and columns follow UI-SPEC C2-C5 exactly; all `backend::lang.user.*`, `user.role.*`, `permissions.*` and `settings.category_system` copy lands in Task 1 (en and pl); the D-21 framework copy lands with Plan 02.
|
||||
|
||||
## Source coverage (whole phase)
|
||||
|
||||
| Source | Item | Plan | Status |
|
||||
|---|---|---|---|
|
||||
| GOAL | Administrators and Roles managed in the SPA with server-side rules and a plugin seam | 01-03 | COVERED |
|
||||
| SC-1 | list, create, edit, deactivate admins; edit roles with the permission editor; payloads and admin:* unchanged | 01 (T1-T4), 03 | COVERED |
|
||||
| SC-2 | only a superuser grants superuser; no self-raise; last active superuser protected | 01 (T2, T3), 03 | COVERED |
|
||||
| SC-3 | record-action seam usable by plugins; unit tests for the rules | 01 (T1), 03 | COVERED |
|
||||
| RESEARCH | Activation caveat, protected keys, save order, Pitfalls 1-10, advisory lock, ceilings, settings links | 01 | COVERED |
|
||||
| CONTEXT | D-01..D-20 | 01 | COVERED |
|
||||
| CONTEXT | D-21 (trigger, record locks, OpenAPI, dist) | 02 | COVERED |
|
||||
| CONTEXT | D-18 (tests last, docs with the code), D-22 (three plans) | 01, 02, 03 | COVERED |
|
||||
| UI-SPEC | C1-C5, C8, copy | 01 | COVERED |
|
||||
| UI-SPEC | C6, C7, framework copy | 02 | COVERED |
|
||||
| UI-SPEC | backstop rows | 03 | COVERED |
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- pact: `type AdminActionExtension struct { Controller string; RecordActions []AdminRecordAction }`, `type HasAdminActionExtensions interface { AdminActionExtensions() []AdminActionExtension }`.
|
||||
- cabana exported: constants `PermissionManageUsers` ("backend.manage_users"), `PermissionManageRoles` ("backend.manage_roles"), `AdministratorsControllerID` ("summercms.cabana.users"), `RolesControllerID` ("summercms.cabana.roles"); methods `BackendUser.Fillable`, `BackendUser.Rules`, `BackendUser.FilterScopes`, `BackendUser.FilterScope`, `BackendUserRole.Fillable`, `BackendUserRole.Rules`.
|
||||
- cabana unexported: `backendScreens` pseudo-plugin and `withBackendScreens`, `compileActionExtensions`, `Registry.permissionOrder`, `CompiledController` contributor map, `usersAdmin`, `rolesAdmin`, `actsAsSuperuser`, `roleGrantSet`, `effectiveGrants`, `grantsCovered`, `guardOutranked`, `guardLastSuperuser`, `superuserLockKey`, `revocationCutoff`, `recordDataExtender` (`extendRecordData`), `schemaFieldHider` (`hiddenSchemaFields`), `BackendUser.priorRoleID` and `priorLoaded`, filter scope names `filterByRole` and `filterTrashed`.
|
||||
- Admin screens: controllers `summercms.cabana.users` (list, form, preview with hint, filters, record and bulk actions activate / deactivate / restore) and `summercms.cabana.roles` (list, form); settings link entries `administrators` and `roles` in category `backend::lang.settings.category_system`.
|
||||
- Embedded YAML and partial under `modules/cabana/backendscreens/`.
|
||||
- Lang groups `backend::lang.user` (with `user.role`), `backend::lang.permissions`, key `backend::lang.settings.category_system` (en, pl).
|
||||
- Smoke tests `TestBackendScreensSmoke` (-short), `TestBackendScreensPostgresSmoke`, `TestAdministratorsFormSmoke`, `TestAdministratorsLifecycleSmoke`, `TestRolesSmoke` (Postgres).
|
||||
|
||||
<objective>
|
||||
Build the server side of the Administrators and Roles screens (D-22 plan 01): the D-14 `pact` action-extension seam, the internal `summercms.cabana` pseudo-plugin with both controllers, every privilege rule of D-03 to D-12, D-16, D-17, D-19 and D-20 enforced in the save transaction, the lang keys and the docs.
|
||||
|
||||
Purpose: SC-1, SC-2, SC-3 (seam); D-01..D-20.
|
||||
Output: four framework commits (each with its README and docs changes and a smoke test), no change to commands.go, token_commands.go or account.go.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-CONTEXT.md
|
||||
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-RESEARCH.md
|
||||
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-PATTERNS.md
|
||||
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-UI-SPEC.md
|
||||
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-VALIDATION.md
|
||||
|
||||
Concurrency and commits: 19.6, 19.7 and 19.8 are in flight and touch cabana. Before each task run `git status --porcelain -- modules/cabana modules/pact modules/phrasebook docs` and `git log --oneline -5 -- modules/cabana/http.go modules/cabana/crud.go`; stop and report when a file this task edits carries someone else's uncommitted change. Re-read the `crud.go` save order (lines ~771-848) and `Activate` (http.go:74-210) before editing them (RESEARCH "Valid until"). Record the plan base as `PLAN_BASE=$(git rev-parse HEAD)` before Task 1. Stage by explicit path, never `git add -A`; leave STATE.md and state.json alone; no co-author tags; READMEs and docs in the same commit as the code they describe; `go vet ./...`, `go test -short ./...` and `go -C examples/hello test ./...` green at every commit. Docker-backed tests skip under -short; on this host run them, through `sudo -u golem` if the Docker socket is denied (CLAUDE.md); a Docker test that could not run is NOT RUN in the SUMMARY, never passed.
|
||||
|
||||
Reference only (read, never modify): `../sm-grzybyfunkcjonalne-app/plugins/golem15/user/` (abbreviated USER/ below) and `../examples/golem15-wintercms-starter/modules/backend/`.
|
||||
|
||||
<interfaces>
|
||||
- pact (modules/pact/capabilities.go): `AdminController{ID, ModelName, ConfigDir}` :179, `HasAdminControllers` :186, `AdminAssets{AdminFS() fs.FS}` :192, `AdminPermissioned` :197, `AdminRecordSource{NewRecord() any}` :203, `AdminBulkAction{Name, Label, Confirm, Permissions, Run, Detached}` :286 with `AdminBulkActionInput{Records []any}` and `AdminBulkActionResult{Message, Affected}`, `HasAdminBulkActions` :325, `AdminRecordAction{Name, Label, Confirm, Permissions, Danger, Applies, Run}` :343, `AdminRecordActionInput{RecordID uint64, Record any}` :360, `AdminRecordActionResult{Message}` :368, `HasAdminRecordActions` :374, `AdminPartialData.PartialData(ctx, name, record)` :385, `Permission{Code, Tab, Label, Roles}` :512, `HasPermissions` :520, `SettingsItem{Code, Label, Description, Category, Icon, Model, Order, Keywords, Permissions, Controller, Form, NewModel}` :570, `HasSettings` :590, `ListExtendQuery` :720, `RowState` constants `RowStateDeleted`, `RowStateDisabled` :726-741, `ListRowStates` :744, `FormRecordTitle` :772, `FormExtendQuery` :777, `FormBeforeCreate/Update`, `FormAfterCreate/Update`, `FormBeforeDelete`, `FormAfterDelete` :782-808, `FormVirtualFields` :831, `FormRules(ctx, op)` :842, `FilterScope{FilterScopes() []string; FilterScope(name, db, value) *gorm.DB}` and `FilterOptions(scope) []Option` (near :908-922).
|
||||
- party (modules/party/registry.go:16-21): `Plugin{ID() string; Requires() []string; Register(*backpack.App) error; Boot(*backpack.App) error}`.
|
||||
- cabana: `Activate(app, plugins) (*Routes, error)` http.go:74 (early return `if len(items) == 0` :79, compileRegistryWithTypes :103, checkDeferredModels :122, compileContributions :125, compileTokenScopes :128, validateMessageKeys :135); `formSchema` http.go:753 (per-request `kept` slice :771-785), `listSchema` http.go:801 (new slices for toolbar and bulk actions), `protect` http.go:1101; `collectControllers` registry.go:22 (controller ID must start with plugin ID), `reservedVendorSegments` registry.go:46, `compilePermissionCatalog` registry.go:~212-246, `compileContributions` registry.go:268 (validates RecordActions permissions :342-346, settings link controllers), `permissionCode` :366, `rolePermissions(role) map[string]bool` :449; `compileExtension` extension.go:60 (preview rule :91-93), `compileRecordActions` extension.go:405, `builtinToolbarActions` extension.go:47, `virtualFieldTypes` and `checkVirtualFields` extension.go:156-175; `CompiledController` contracts.go:97 (Form.recordActions is unexported `[]string`, schema_types.go:159; RecordActions map), `Registry` contracts.go:146, `Allows`, `granted` contracts.go:214-248, `requiredOf`; `BackendUser`, `BackendUserRole` contracts.go:17-58 (BackendUser has a `Role BackendUserRole` association field); `FindByID` merge auth.go:39-67, `applyUserPermissions` auth.go:73, `principalFrom` auth.go:101, `parseGrants` auth.go:118, `adminBcryptCost(app)` auth.go:481; `ValidationError{Details}` crud.go:69, `ForbiddenError{Message, Details}` crud.go:83 (Message is a phrase key or text, no params), `localizeControllerError` crud.go:98; `offeredRecordActions` crud.go:551, `RecordAction` crud.go:586, `projectFullRecord` crud.go:647, `save` crud.go:672 (order: Fill, Validate, formBeforeUpdate :800, applyPermissionValues :809, checkRelationScope, checkRelationLocks :819, assignBelongsTo :822, `tx.Unscoped().Save` :829, formAfterUpdate :848), `newWritableModel` crud.go:907 (model needs lagoon.HasFillable and Rules), `loadRecord` crud.go:937 (FormExtendQuery then FOR UPDATE), `deleteRecord` crud.go:1127 (GORM Delete on the tx root, soft delete), bulk delete loop crud.go:355-373, `protectedFillKey` crud.go:1407 (role_id, permissions, is_superuser, is_system, is_activated, password); `recordActionOf` actions.go:242; `TxFromContext`, `VirtualFieldsFromContext` (only keys present in the body) tx_context.go:27,54; `PermissionOption{Code, Label, Tab, Comment, Locked}`, `PermissionEditorProvider{AdminPermissionOptions, AdminPermissionValues, AdminSetPermissionValues}`, `applyPermissionValues` field_permission.go:25-65, 260; `FieldRelationContract{Field, Kind, NewRelated, ForeignKey, LabelColumn, WritableForeignKey}`, `RelationLock{IDs, Message}`, `RelationLockProvider.AdminRelationLocks(ctx, field)` relation_field.go:22-66; `assetPath` schema.go:41 (`~/plugins/summercms/cabana/x` resolves to `x` in the AdminFS); `compileSettingLink` settings.go:57; `passwordMaxBytes = 72` and `accountMessage(ctx, tr, rule, field, params)` account.go:47, 335-336; admin:create uniqueness query commands.go:96 and defaults :106-115, reset cutoff `time.Now().UTC().Add(time.Second)` commands.go:157.
|
||||
- phrasebook: `(*Translator).Choice(ctx, key, count, params map[string]string) string`, `Get(ctx, key, params)` translator.go:152-178.
|
||||
- bouncer: `Principal{ID, Backend, IsSuperuser, PermissionGrants map[string]bool, Scope *ScopeLimit{Full bool; Scopes}}` context.go:26-35, `User(ctx)` :46, `HashPassword(cost, plain)`.
|
||||
- Test helpers (package cabana_test, modules/cabana/auth_test.go): `adminGorm(t)` :321 (skips under -short), `adminHandler(t, gdb, mutate)` :367, `insertAdmin(t, gdb, login, email, password, activated, deleted)` :403 (developer role, never superuser), `demoPlugin`; env pattern `newActEnv` with `phrasebook.Activate`, `surf.Assemble`, `postJSON`, `adminAPI`, `accessToken`, `adminTestPassword` in phase101_actions_test.go:233-346. Internal fixtures (package cabana): `contributionPlugin`, `metadataRegistry`, `metadataPlugin` used in settings_link_test.go:1-60. External hooks into internals go through modules/cabana/export_test.go.
|
||||
- Analogs: USER/admin.go:11-38 (embed + AdminFS + interface asserts), USER/controllers/users_admin_controller.go (identity :41-51, unscoped queries :85-97, row states :102-129, virtual fields and rules :135-155, FilterOptions :168, guardPrivilegedMember :193-209, FormBeforeUpdate/Create :217-275, storePassword :303-321, AdminFieldRelations :370, AdminRelationLocks :402-422, permission provider :426-477, stored-row re-read :479-497, FormBeforeDelete :524, bulk actions :589-627, record actions :665-743, PartialData :746), USER/controllers/users/{config_list,config_form,config_filter}.yaml, USER/controllers/users/_hint.htm, USER/models/user/{columns,fields}.yaml, USER/controllers/usergroups/*.yaml; Winter `modules/backend/controllers/Users.php`, `UserRoles.php`, `models/user/fields.yaml`, `models/userrole/fields.yaml`, `formwidgets/PermissionEditor.php` (~93-150).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a superuser opens Settings → System → Administrators, browses administrators, opens one's preview, and runs a record action another plugin contributed through the new pact seam</name>
|
||||
<reversibility rating="costly">D-14 publishes a pact capability that 19.7 and the MFA plugin implement, and D-01's permission codes get stored in role and user permission JSON on every install that grants them.</reversibility>
|
||||
<files>modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/action_extensions.go, modules/cabana/backend_screens.go, modules/cabana/backend_users_admin.go, modules/cabana/backendscreens/controllers/users/config_list.yaml, modules/cabana/backendscreens/controllers/users/config_form.yaml, modules/cabana/backendscreens/models/user/columns.yaml, modules/cabana/backendscreens/models/user/fields.yaml, modules/cabana/http.go, modules/cabana/registry.go, modules/cabana/contracts.go, modules/cabana/backend_screens_test.go, modules/cabana/backend_screens_smoke_test.go, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, modules/cabana/README.md, docs/backend/admin-controllers.md</files>
|
||||
<read_first>modules/pact/capabilities.go (:179-205, :325-390, :505-595), modules/pact/README.md (Features line 30, API table :130-140), modules/cabana/http.go (:74-210), modules/cabana/registry.go (:1-80, :205-360, :440-460), modules/cabana/extension.go (:30-130, :395-429), modules/cabana/contracts.go, modules/cabana/crud.go (:546-640), modules/cabana/actions.go (:230-270), modules/cabana/settings.go (:57-65), modules/cabana/navigation.go (:43-152), modules/cabana/schema.go (:30-60), modules/cabana/settings_link_test.go, modules/cabana/export_test.go, modules/cabana/phase101_actions_test.go (:225-346), modules/cabana/auth_test.go (:314-440), modules/surf/admin_prefix_test.go (:91-99, :194), modules/phrasebook/backend/lang/en/lang.yaml and pl/lang.yaml (top-level groups, settings: and permissioneditor: blocks), USER/admin.go, USER/controllers/users/config_list.yaml, USER/controllers/users/config_form.yaml, USER/models/user/columns.yaml, USER/models/user/fields.yaml, docs/backend/admin-controllers.md (:464-500), modules/cabana/README.md, 19.6.1-UI-SPEC.md (Copywriting Contract, C1-C4), 19.6.1-RESEARCH.md (Patterns 1 and 2, Pitfalls 3 and 8, Code Examples "A contribution from another plugin")</read_first>
|
||||
<action>Per D-01, D-02, D-04, D-14, D-15 and SC-3.
|
||||
|
||||
pact (capabilities.go, additive, next to HasAdminRecordActions): `type AdminActionExtension struct { Controller string; RecordActions []AdminRecordAction }` and the plugin-level `type HasAdminActionExtensions interface { AdminActionExtensions() []AdminActionExtension }`. Doc comments: Controller is the ID (vendor.plugin.controller) of any activated plugin's admin controller, cabana's own included; each action keeps its own Permissions, Applies, Confirm and Danger and runs exactly like the owner's record actions; contributions are offered on the target's preview screen after the owner's actions, in plugin activation order; the admin also checks the target controller's RequiredPermissions; record actions only for now, the struct leaves room for more kinds. The comment names no consumer. pact README: extend the admin-extension Features sentence and add two API-table rows.
|
||||
|
||||
cabana/action_extensions.go (new): `compileActionExtensions(reg *Registry, plugins []party.Plugin) error`. For each plugin implementing HasAdminActionExtensions, in slice order, for each extension: the target is `reg.byID[ext.Controller]`, a missing one is an error naming the plugin and the controller ID; the target needs a compiled form with a preview block (error otherwise: record actions are offered on the preview screen); each action passes the checks compileRecordActions applies (identifier name, not create or delete, non-nil Run) and has a non-empty Label; a name already present in the target's RecordActions (the owner's or an earlier contribution) is an error naming both plugins. Merge by building a new recordActions slice for the target form (copy, then append the name; never append into the compiled backing array) and adding the action to `cc.RecordActions`; keep the contributing plugin per name in a new unexported map on CompiledController (contracts.go) for the error text. offeredRecordActions, recordActionOf and RecordAction then serve contributed actions unchanged; do not edit them.
|
||||
|
||||
registry.go: compilePermissionCatalog also appends each new code to a new unexported `permissionOrder []string` on Registry (contracts.go), in activation order, for stable editor display.
|
||||
|
||||
cabana/backend_screens.go (new): exported constants `PermissionManageUsers = "backend.manage_users"`, `PermissionManageRoles = "backend.manage_roles"`, `AdministratorsControllerID = "summercms.cabana.users"`, `RolesControllerID = "summercms.cabana.roles"`; the unexported pseudo-plugin `backendScreens{app *backpack.App; reg *Registry}` implementing party.Plugin (ID "summercms.cabana", Requires nil, no-op Register and Boot), pact.AdminAssets (AdminFS returns fs.Sub of a go:embed of the backendscreens directory, so paths start at controllers/ and models/), pact.HasAdminControllers (the Administrators controller; Roles joins in Task 4), pact.HasPermissions and pact.HasSettings, with compile-time interface assertions. Permissions(): both codes, Tab backend::lang.permissions.tab_system, Label backend::lang.permissions.manage_users and backend::lang.permissions.manage_roles; leave the Roles field of both entries empty (D-04). Settings(): one link entry, Code administrators, Controller AdministratorsControllerID, Permissions [PermissionManageUsers], Label backend::lang.user.menu_label, Description backend::lang.user.menu_description, Category backend::lang.settings.category_system, Icon users, Order 100 (D-02; the roles entry joins in Task 4). The pseudo-plugin declares no navigation entry (D-02). `withBackendScreens(app, plugins) ([]party.Plugin, *backendScreens)` returns a new slice with the pseudo-plugin appended (the caller's slice is never mutated).
|
||||
|
||||
http.go Activate: keep the `len(items) == 0` early return exactly where it is (no controllers: no admin routes, no secret check; examples/hello and surf's admin-prefix test rely on it). After it call withBackendScreens, re-run collectControllers on the extended slice and use that slice for every later step (checkReservedSegments, compileFieldTypes, compileRegistryWithTypes, checkDeferredModels, the new compileActionExtensions placed after checkDeferredModels and before compileContributions, compileContributions, compileTokenScopes). After compileContributions set `screens.reg = reg`. Update Activate's doc comment.
|
||||
|
||||
cabana/backend_users_admin.go (new): `usersAdmin{screens *backendScreens}` (lazy app and registry access, as the analog's app func): ID AdministratorsControllerID, ModelName `Backend\Models\User`, ConfigDir controllers/users, RequiredPermissions [PermissionManageUsers], NewRecord `&BackendUser{}`, FormRecordTitle ("First Last" when either name is set, else the login), AdminFieldRelations with the role contract (Field role, Kind belongsTo, NewRelated BackendUserRole, ForeignKey role_id, LabelColumn name, WritableForeignKey true). contracts.go: BackendUser gains `Fillable()` (login, email, first_name, last_name) and `Rules()` (login required|max:255, email required|email|max:255); BackendUserRole gains `Fillable()` (name, code, description) and `Rules()` (name required|max:255|unique:backend_user_roles, code required|max:255). Methods only; no struct field changes in this task.
|
||||
|
||||
YAML under backendscreens/ (shapes from USER/controllers/users/*.yaml and UI-SPEC C2-C4): controllers/users/config_list.yaml (list models/user/columns.yaml, modelClass `Backend\Models\User`, title backend::lang.user.list_title, recordUrl summercms/cabana/users/preview/:id, noRecordsMessage backend::lang.user.list_empty, recordsPerPage 20, showSetup true, toolbar search prompt backend::lang.list.search_prompt and no buttons yet: create and delete come with their guards in Tasks 2 and 3); controllers/users/config_form.yaml (name backend::lang.user.name, form models/user/fields.yaml, modelClass, defaultRedirect summercms/cabana/users, update redirect summercms/cabana/users/update/:id and redirectClose summercms/cabana/users/preview/:id, a preview block, messages update/saved/edit backend::lang.user.edit/preview backend::lang.user.preview); models/user/columns.yaml in UI-SPEC C2 order (login, first_name, last_name, email searchable and sortable; role relation role select name sortable; last_login datetime sortable; is_superuser switch; created_at datetime invisible sortable); models/user/fields.yaml Account tab (backend::lang.user.account) with login left, email right, first_name left, last_name right, role (type relation, nameFrom name, emptyOption backend::lang.user.role_none, comment backend::lang.user.role_comment, span full) and last_login (datepicker, context preview, span left).
|
||||
|
||||
Lang (en and pl, modules/phrasebook/backend/lang): every key of UI-SPEC "Copywriting Contract" except the "Framework additions (D-21)" table, which Plan 02 adds: new groups `user` (with the nested `role` block) and `permissions`, and `settings.category_system`. Plural texts are CLDR maps (en one/other, pl one/few/many/other), placeholders :name and :count, values verbatim from the UI-SPEC tables.
|
||||
|
||||
Smoke tests (Plan 03 extends both files). modules/cabana/backend_screens_test.go (package cabana, Docker-free): `TestBackendScreensSmoke` compiles, through withBackendScreens and the same compile steps Activate uses (or an unexported helper factored out of Activate for both), an extender plugin that owns one controller with a form and preview (reuse an existing internal fixture where one fits) and contributes record action acme_ping to AdministratorsControllerID with a permission it declares; asserts the Administrators controller's compiled recordActions end with acme_ping and RecordActions holds it; the catalog holds both backend codes and roleGrants for developer and publisher hold neither; settingsList for a superuser principal lists administrators with controller summercms.cabana.users and for a backend principal without grants does not; a contribution to acme.missing.things fails compilation with an error containing that ID; Activate with a plugin that registers no admin controllers returns nil routes and a nil error. modules/cabana/backend_screens_smoke_test.go (package cabana_test, Postgres through adminGorm): `TestBackendScreensPostgresSmoke` assembles like newActEnv (phrasebook.Activate, surf.Assemble) with demoPlugin plus a contributor plugin whose acme_ping Run asserts cabana.TxFromContext and records RecordID; seeds a superuser (insertAdmin, then UPDATE is_superuser = TRUE) and a developer-role admin with unique logins; as the superuser: GET /summercms/cabana/users lists both, GET /summercms/cabana/users/{id} carries acme_ping in meta.actions, POST /summercms/cabana/users/{id}/actions/acme_ping answers 200 and Run saw that id, GET /settings lists administrators; as the developer admin GET /summercms/cabana/users answers 403.
|
||||
|
||||
Existing tests: every test that assembles the admin with a controller-owning plugin now also activates the pseudo-plugin. If an existing test enumerates a superuser's settings entries or the permission catalog, update only that expectation to include the new entries and name each file in the SUMMARY; any other change in an existing test's result is a regression to fix in the code.
|
||||
|
||||
Docs in this commit: cabana README (Features: the built-in Administrators screen and cross-controller record actions; API reference rows for the four constants and the new BackendUser and BackendUserRole methods; the screens activate only when a plugin has admin controllers); docs/backend/admin-controllers.md, after "Record actions", a section "Adding record actions to another controller" with a neutral acme example contributing to summercms.cabana.users, the boot errors, that the target's RequiredPermissions and the action's own Permissions both apply, that a contributed name must not reuse one the owner registers, that a record action carries no input beyond the record, and that contributions are compiled only when the admin is active. Commit: `feat(cabana): Administrators screen and cross-controller record actions`.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/cabana/... ./modules/pact/... ./modules/surf/... && go test -count=1 -short -run '^TestBackendScreensSmoke$' -v ./modules/cabana 2>&1 | grep -q -- '^--- PASS: TestBackendScreensSmoke' && go -C examples/hello test -count=1 ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit from vet, the short suites, hello or the docs checker, or no "--- PASS: TestBackendScreensSmoke" line (the smoke failed or matched no test).</fails_when>
|
||||
<automated>go test -count=1 -run '^TestBackendScreensPostgresSmoke$' -v ./modules/cabana 2>&1 | grep -q -- '^--- PASS: TestBackendScreensPostgresSmoke' && go test -count=1 ./modules/cabana ./modules/surf</automated>
|
||||
<fails_when>No "--- PASS: TestBackendScreensPostgresSmoke" line (failed, or "--- SKIP" because Docker was unreachable: rerun through sudo -u golem, else record NOT RUN), or the full Docker-backed cabana and surf suites exit non-zero.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'type HasAdminActionExtensions interface' modules/pact/capabilities.go` prints 1 and `grep -c 'type AdminActionExtension struct' modules/pact/capabilities.go` prints 1.
|
||||
- `grep -c 'func compileActionExtensions(' modules/cabana/action_extensions.go` prints 1.
|
||||
- `awk '/compileActionExtensions\(reg/{a=NR} /compileContributions\(reg/{c=NR} END{exit !(a && c && a<c)}' modules/cabana/http.go` exits 0 (the merge runs before permission validation).
|
||||
- `awk '/len\(items\) == 0/{e=NR} /withBackendScreens\(/{w=NR} END{exit !(e && w && e<w)}' modules/cabana/http.go` exits 0 (the pseudo-plugin is appended after the no-controllers return).
|
||||
- `grep -v '^\s*//' modules/cabana/backend_screens.go | grep -c 'Roles:'` prints 0.
|
||||
- `grep -c '"backend.manage_users"' modules/cabana/backend_screens.go` prints 1 and `grep -c '"summercms.cabana.users"' modules/cabana/backend_screens.go` prints 1.
|
||||
- `grep -c 'pact.HasNavigation' modules/cabana/backend_screens.go` prints 0.
|
||||
- `grep -c '^user:' modules/phrasebook/backend/lang/en/lang.yaml` prints 1, `grep -c '^user:' modules/phrasebook/backend/lang/pl/lang.yaml` prints 1, `grep -c '^permissions:' modules/phrasebook/backend/lang/en/lang.yaml` prints 1, and `grep -c 'category_system' modules/phrasebook/backend/lang/pl/lang.yaml` prints 1.
|
||||
- `grep -c 'summercms.cabana.users' docs/backend/admin-controllers.md` prints at least 1 and `grep -c 'HasAdminActionExtensions' modules/pact/README.md` prints at least 1.
|
||||
- `git log --format=%h "$PLAN_BASE"..HEAD -- modules/cabana/commands.go modules/cabana/token_commands.go modules/cabana/account.go` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>A superuser finds Administrators under Settings → System, lists administrators, opens a preview and runs a plugin-contributed record action; admins without the code get 403; apps without admin controllers boot as before.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Administrators form: create and edit administrators with passwords, role and per-user permission overrides, under every save-path privilege rule</name>
|
||||
<reversibility rating="reversible">Hooks and unexported helpers inside cabana; no published surface beyond the screen's behaviour.</reversibility>
|
||||
<files>modules/cabana/backend_privileges.go, modules/cabana/backend_users_admin.go, modules/cabana/contracts.go, modules/cabana/crud.go, modules/cabana/http.go, modules/cabana/backendscreens/models/user/fields.yaml, modules/cabana/backendscreens/controllers/users/config_list.yaml, modules/cabana/backendscreens/controllers/users/config_form.yaml, modules/cabana/backendscreens/controllers/users/config_filter.yaml, modules/cabana/backend_screens_smoke_test.go, modules/cabana/README.md, docs/backend/users-and-permissions.md</files>
|
||||
<read_first>modules/cabana/backend_users_admin.go and backend_screens.go (from Task 1), modules/cabana/crud.go (:647-870, :907-960, :1095-1145, :1180-1210, :1354-1420), modules/cabana/http.go (:753-845), modules/cabana/field_permission.go, modules/cabana/relation_field.go (:20-110), modules/cabana/tx_context.go, modules/cabana/auth.go (:33-140, :476-492), modules/cabana/commands.go (:85-170), modules/cabana/account.go (:40-50, :320-345, :470-480), modules/cabana/contracts.go (:200-250), modules/cabana/filter_schema.go (:290-330), modules/cabana/query.go (:480-495), modules/bouncer/context.go, modules/lagoon/validate.go (:355-380), USER/controllers/users_admin_controller.go (:85-155, :168-320, :370-522), USER/controllers/users/config_filter.yaml, USER/models/user/fields.yaml, ../examples/golem15-wintercms-starter/modules/backend/controllers/Users.php, ../examples/golem15-wintercms-starter/modules/backend/formwidgets/PermissionEditor.php (:93-150), docs/backend/users-and-permissions.md (:39-76, :134-160), 19.6.1-RESEARCH.md (Pattern 3 hook table, Pattern 4, Pattern 5, Pitfalls 1, 2, 4, 9), 19.6.1-UI-SPEC.md (C3, C8)</read_first>
|
||||
<action>Per D-03, D-05, D-06, D-07, D-08, D-09 (demotion path), D-11, D-12 (password set), D-19 and D-20.
|
||||
|
||||
backend_privileges.go (new):
|
||||
- `actsAsSuperuser(p *bouncer.Principal) bool` is `p != nil && p.IsSuperuser && (p.Scope == nil || p.Scope.Full)`; every superuser test on these screens uses it (Allows lets a superuser through, and a superuser's scoped personal token must not get superuser-only powers).
|
||||
- `roleGrantSet(reg *Registry, role BackendUserRole) map[string]bool`: parseGrants(role.Permissions) plus reg.rolePermissions(role.Code), the FindByID merge.
|
||||
- `effectiveGrants(ctx, db *gorm.DB, reg *Registry, user *BackendUser) (map[string]bool, error)`: loads the role by user.RoleID into its own BackendUserRole value through `db.Session(&gorm.Session{NewDB: true})`, never onto the record being saved (GORM's Save would rewrite role_id from a loaded association, RESEARCH anti-pattern), then applyUserPermissions(grants, user.Permissions).
|
||||
- `grantsCovered(holder, wanted map[string]bool) bool`: each non-wildcard code in wanted must pass granted(holder, code); a wildcard grant in wanted (ending in ".*" or starting with "*") is covered only when holder holds the same wildcard or a broader prefix wildcard (RESEARCH A3); a wildcard from wanted is never passed to granted or Allows.
|
||||
- `guardOutranked(ctx, reg, editor *bouncer.Principal, target *BackendUser) error` (D-19): nil for actsAsSuperuser editors and for the editor's own record; otherwise the target's effectiveGrants must be grantsCovered by editor.PermissionGrants, else `&ForbiddenError{Message: "backend::lang.user.outranked"}`.
|
||||
- `guardLastSuperuser(ctx, target *BackendUser) error` (D-09): nil unless the target is currently a superuser, activated and not deleted; then, through the write transaction from TxFromContext (none is an error), `SELECT pg_advisory_xact_lock(?)` with the fixed int64 `superuserLockKey` (documented beside it), and count other rows with `is_superuser AND is_activated AND id <> ?` (the default scope adds deleted_at IS NULL); zero answers `&ForbiddenError{Message: "backend::lang.user.last_superuser"}`. The caller always holds its row lock first and nothing takes a row lock after the advisory lock, so there is no wait cycle (RESEARCH Pattern 4).
|
||||
- `revocationCutoff() time.Time` returns `time.Now().UTC().Add(time.Second)` (admin:reset-password's value).
|
||||
|
||||
contracts.go: BackendUser gains unexported fields `priorRoleID *uint` and `priorLoaded bool` (GORM and cabana's model reflection skip unexported fields) and `FilterScopes()` returning filterByRole with `FilterScope(name, db, value)` narrowing to `backend_users.role_id = value` (anything else matches nothing, a nil db passes through), as USER models/user.go:112-129 does for groups.
|
||||
|
||||
crud.go and http.go, unexported in-package hooks (no pact surface; other controllers' payloads unchanged):
|
||||
- `recordDataExtender` with `extendRecordData(ctx context.Context, record any, data map[string]any)`, called by projectFullRecord after the scalar projection when the controller implements it.
|
||||
- `schemaFieldHider` with `hiddenSchemaFields(ctx context.Context) map[string]bool`, applied in formSchema while building `kept` and in listSchema to columns and filters, always into new slices (the cached schema is never mutated).
|
||||
|
||||
backend_users_admin.go:
|
||||
- `extendRecordData`: for actsAsSuperuser principals set data["is_superuser"] to the stored flag (Pitfall 1: the virtual switch must load its value or an untouched save demotes). `hiddenSchemaFields`: is_superuser for every non-superuser (D-03).
|
||||
- ListExtendQuery and FormExtendQuery: for non-superusers add `Where("backend_users.is_superuser = ?", false)` (qualified, query.go:485-490), so a superuser id is not found for them on show, partials, update, delete and record actions, and never listed (D-03). Task 3 makes both unscoped for the trashed view.
|
||||
- FormVirtualFields: password, password_confirmation, is_superuser. FormRules(ctx, op): password `required|min:4|confirmed` on create and `nullable|min:4|confirmed` on update (D-11); login and email uniqueness is checked by hand below, never with lagoon's unique rule (Pitfall 4). FilterOptions(scope): for filterByRole every role by name (USER :168-191 pattern).
|
||||
- AdminRelationLocks(ctx, "role") (D-07): zero lock for superusers; otherwise lock the ids of every role whose roleGrantSet the editor's grants do not cover, Message backend::lang.user.role_ceiling. (Locks, not option filtering, so a target's current above-ceiling role still saves unchanged.)
|
||||
- PermissionEditorProvider (mode radio, D-06, D-07): AdminPermissionOptions lists the catalog in permissionOrder (Label and Tab from pact.Permission; Comment backend::lang.permissions.manage_users_comment and manage_roles_comment for the two backend codes) with `Locked: !actsAsSuperuser(editor) && !granted(editor.PermissionGrants, code)`; AdminPermissionValues decodes BackendUser.Permissions JSON into code to 1 or -1 (other values dropped); AdminSetPermissionValues refuses on the editor's own record any next set that differs from the stored one with `&ForbiddenError{Message: "backend::lang.user.self_privileged", Details: {"permissions": [same key]}}` (D-08) and otherwise stores compact JSON, `{}` when empty.
|
||||
- FormBeforeCreate: virtual is_superuser present and not actsAsSuperuser answers 403 superuser_forbidden with Details is_superuser, whatever its value (D-05); a superuser sets IsSuperuser from it; IsActivated true and ActivatedAt now (admin:create, commands.go:106-115); password: longer than passwordMaxBytes answers 422 on password with accountMessage(ctx, tr, "max.string", "password", max 72), else bouncer.HashPassword(adminBcryptCost(app), plain); then the identity check. No mail is sent (D-13).
|
||||
- FormBeforeUpdate (the model still holds the stored role_id, is_superuser, is_activated and permissions: protected keys are never filled): record priorRoleID and priorLoaded; guardOutranked for a target that is not the editor (D-19); is_superuser present: not actsAsSuperuser answers 403 superuser_forbidden (D-05); own record with a different value answers 403 self_privileged with Details is_superuser (D-08); stored true and submitted false calls guardLastSuperuser before the flag changes, its refusal carrying Details is_superuser (D-09); then set IsSuperuser. A non-empty password: own record answers 403 `backend::lang.user.own_password` with Details password (D-20); over 72 bytes answers 422; else hash it and set TokensValidAfter to revocationCutoff() (D-11, D-12). Then the identity check.
|
||||
- Identity check (create and update): one unscoped, case-insensitive count like admin:create (commands.go:96) excluding the record's own id; a held login answers 422 on login with backend::lang.user.login_taken_deleted when only a deleted admin holds it, else login_taken; the same for email with email_taken_deleted and email_taken.
|
||||
- FormAfterUpdate: on the editor's own record a RoleID different from priorRoleID answers 403 self_privileged with Details role, which rolls the save back (D-08; assignBelongsTo runs after FormBeforeUpdate, Pitfall 2).
|
||||
|
||||
YAML: fields.yaml adds password (left) and password_confirmation (right) as type password, context [create, update], comment backend::lang.user.password_comment on password; and the Permissions tab (backend::lang.user.permissions) with is_superuser (switch, label backend::lang.user.superuser, comment backend::lang.user.superuser_comment, span full) and permissions (permissioneditor, mode radio, label backend::lang.user.permission_overrides, comment backend::lang.user.permission_overrides_comment, span full); no trigger key yet (Plan 02). config_list.yaml: toolbar buttons [create], messages create backend::lang.user.new. config_form.yaml: create redirect summercms/cabana/users/preview/:id, redirectClose summercms/cabana/users, messages create backend::lang.user.new. config_filter.yaml (supported types only): role (scope filterByRole, modelClass `Backend\Models\UserRole`, nameFrom name, label backend::lang.user.role_field), is_superuser (switch, column is_superuser, label backend::lang.user.superuser), activated (switch, column is_activated, label backend::lang.user.activated), last_login (daterange, column last_login, label backend::lang.user.last_login); config_list.yaml gets `filter: config_filter.yaml`.
|
||||
|
||||
Smoke (append to backend_screens_smoke_test.go): `TestAdministratorsFormSmoke`: the superuser creates an admin with password and confirmation (201, the stored hash verifies with bouncer, is_activated true); GET of the superuser's own record returns data.is_superuser true; a developer-role admin whose role JSON grants backend.manage_users gets 404 on GET of the superuser's id, no is_superuser in the form schema fields, and 403 when updating a non-superuser with is_superuser false in the body; the superuser switching off its own flag answers 403 with the self_privileged text; with a second superuser seeded, demoting it through update answers 200 and its record then reads is_superuser false. (A sequential request cannot reach the last-superuser refusal through the screens, see Decisions; Plan 03 proves each path with a held-lock race.)
|
||||
|
||||
Docs in this commit: docs/backend/users-and-permissions.md "Permissions" lists backend.manage_users and backend.manage_roles (granted to no role by default, so only superusers reach the screens until someone grants them); "Managing administrators" gains the Administrators screen (Settings → System, the form, passwords, and the rules D-05, D-07, D-08, D-19, D-20; a password set ends the target's sessions but not their personal API tokens, as admin:reset-password); the My account paragraph (~line 138) now says role, superuser flag, permissions and activation are changed by another administrator on the Administrators screen or with the operator commands. cabana README Features. Commit: `feat(cabana): administrator form with server-side privilege rules`.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/cabana/... ./modules/surf/... && go -C examples/hello test -count=1 ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit from vet, the short suites, hello or the docs checker.</fails_when>
|
||||
<automated>go test -count=1 -run '^(TestBackendScreensPostgresSmoke|TestAdministratorsFormSmoke)$' -v ./modules/cabana 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>Fewer than 2 top-level "--- PASS: Test" lines: a smoke failed, or both printed "--- SKIP" because Docker was unreachable (rerun through sudo -u golem, else record NOT RUN).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'pg_advisory_xact_lock' modules/cabana/backend_privileges.go` prints at least 1 and `grep -c 'func guardLastSuperuser(' modules/cabana/backend_privileges.go` prints 1.
|
||||
- `grep -c 'func actsAsSuperuser(' modules/cabana/backend_privileges.go` prints 1 and `grep -c 'Scope.Full' modules/cabana/backend_privileges.go` prints at least 1.
|
||||
- `grep -v '^\s*//' modules/cabana/backend_users_admin.go | grep -c 'Preload("Role")'` prints 0 and the same count on backend_privileges.go prints 0.
|
||||
- `grep -c 'backend_users.is_superuser = ?' modules/cabana/backend_users_admin.go` prints at least 1.
|
||||
- `grep -c 'extendRecordData(' modules/cabana/crud.go` prints at least 1 and `grep -c 'hiddenSchemaFields(' modules/cabana/http.go` prints at least 2.
|
||||
- `grep -cE 'func \([a-z]+ \*?usersAdmin\) FormAfterUpdate\(' modules/cabana/backend_users_admin.go` prints 1.
|
||||
- `grep -c 'mode: radio' modules/cabana/backendscreens/models/user/fields.yaml` prints 1 and `grep -c 'type: password' modules/cabana/backendscreens/models/user/fields.yaml` prints 2.
|
||||
- `grep -c 'backend.manage_roles' docs/backend/users-and-permissions.md` prints at least 1 and `grep -ci 'personal API tokens' docs/backend/users-and-permissions.md` prints at least 2.
|
||||
</acceptance_criteria>
|
||||
<done>A superuser creates administrators with passwords and edits role, overrides and superuser access; a limited admin is held to D-03, D-05, D-07, D-08, D-19 and D-20 by the server; the last superuser cannot be demoted through the form.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Administrators lifecycle: activate, deactivate, delete and restore administrators, with the last active superuser protected on every path and access cut at once</name>
|
||||
<reversibility rating="reversible">Controller actions and hooks inside cabana; soft delete keeps every row restorable.</reversibility>
|
||||
<files>modules/cabana/backend_users_admin.go, modules/cabana/contracts.go, modules/cabana/backendscreens/controllers/users/config_list.yaml, modules/cabana/backendscreens/controllers/users/config_form.yaml, modules/cabana/backendscreens/controllers/users/config_filter.yaml, modules/cabana/backendscreens/controllers/users/_hint.htm, modules/cabana/backend_screens_smoke_test.go, modules/cabana/README.md, docs/backend/users-and-permissions.md</files>
|
||||
<read_first>modules/cabana/backend_users_admin.go and backend_privileges.go (from Task 2), modules/cabana/crud.go (:340-380, :586-640, :937-960, :1127-1145), modules/cabana/actions.go, modules/cabana/partial_render.go, modules/cabana/token_guard.go (:90-110), modules/bouncer/jwt.go (:140-175), modules/pact/capabilities.go (:276-330, :720-760), USER/controllers/users_admin_controller.go (:85-129, :524-556, :589-770), USER/controllers/users/_hint.htm, USER/controllers/users/config_list.yaml, USER/controllers/users/config_form.yaml, docs/backend/admin-controllers.md (Bulk actions, Record actions), 19.6.1-UI-SPEC.md (C2, C4, C8, Administrators preview copy), 19.6.1-RESEARCH.md (Pattern 3 hook table rows for delete and actions, Pitfall 7)</read_first>
|
||||
<action>Per D-08 (self delete and deactivate), D-09, D-10, D-12, D-15 and D-19.
|
||||
|
||||
backend_users_admin.go:
|
||||
- ListExtendQuery and FormExtendQuery start from `db.Unscoped()` and keep the D-03 filter, so deleted admins are listed, opened and restorable (the row write keeps the load scope, crud.go:827-829).
|
||||
- ListRowStates: pact.RowStateDeleted when DeletedAt.Valid, pact.RowStateDisabled when not activated (no other state).
|
||||
- FormBeforeDelete (called once per row inside bulk delete, so one refusal rolls back the whole selection): the editor's own record answers 403 with Message backend::lang.user.self_delete (D-08); guardOutranked (D-19); guardLastSuperuser (D-09). FormAfterDelete: set tokens_valid_after to revocationCutoff() on the now soft-deleted row through `tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped()` (D-12). The generic delete stays GORM's soft delete on the tx root; never hard-delete a backend user (D-10; tokens, linked_by and attachments keep their foreign keys). Do not copy the analog's hard-deleting FormAfterDelete.
|
||||
- AdminRecordActions and AdminBulkActions: activate, deactivate and restore, with UI-SPEC labels, confirms and success messages (user.activate / activate_confirm / activate_selected_confirm / activated / activated_selected, and the same pattern for deactivate and restore); none is Danger. Applies: activate when not deleted and not activated; deactivate when not deleted, activated and not the editor's own record; restore when deleted. Run, for the record and for each bulk row, through cabana.TxFromContext (the analog's builder closures, USER :589-743): deactivate answers 403 self_deactivate on the editor's own record (bulk path), then guardOutranked and guardLastSuperuser, then sets is_activated false and tokens_valid_after revocationCutoff() (D-12); activate runs guardOutranked, then sets is_activated true and activated_at now, no bump; restore runs guardOutranked, then sets deleted_at NULL, no bump. Bulk rows that do not apply are skipped and not counted in Affected; a refused row returns its ForbiddenError and rolls back the selection. Every refusal from a delete or an action is a ForbiddenError with Message set (UI-SPEC C8: the toast shows only the message).
|
||||
- PartialData(ctx, "hint", record): a view {Kind, Title, Text} by precedence deleted (danger, user.trashed_hint_title and trashed_hint_desc) > deactivated (warning, user.deactivated_hint_title and deactivated_hint_desc) > the editor's own record (warning, user.self_hint_title and self_hint_desc) > nothing (empty Title renders nothing).
|
||||
|
||||
contracts.go: BackendUser FilterScopes adds filterTrashed: value hide narrows to `backend_users.deleted_at IS NULL`, only to `IS NOT NULL`, both or none no narrowing (accept the value shapes the analog's scope parser accepts). The controller's FilterOptions serves its two options (hide, only) labelled user.filter_deleted_hide and user.filter_deleted_only.
|
||||
|
||||
YAML and partial: _hint.htm copied verbatim from USER/controllers/users/_hint.htm; config_form.yaml preview headerPartial hint, recordActions [activate, deactivate, restore], messages deleteConfirm backend::lang.user.delete_confirm and deleted backend::lang.messages.form.deleted; config_list.yaml toolbar buttons [create, delete], showCheckboxes true, bulkActions [activate, deactivate, restore], messages empty backend::lang.user.list_empty, deleteConfirm backend::lang.user.delete_selected_confirm, deleted backend::lang.messages.list.deleted, rowStateDeleted backend::lang.user.state_deleted, rowStateDisabled backend::lang.user.state_deactivated (no rowStateNegative); config_filter.yaml gets the trashed scope (scope filterTrashed, modelClass `Backend\Models\User`, label backend::lang.user.filter_deleted).
|
||||
|
||||
admin:* commands: none clears is_superuser or deactivates today (commands.go:71-168), so D-09 needs no command change; leave commands.go and token_commands.go untouched (SC-1).
|
||||
|
||||
Smoke (append): `TestAdministratorsLifecycleSmoke`: the superuser deactivates another admin by record action and that admin's earlier bearer token answers 401 on GET /auth/me; deletes an admin through DELETE, finds the row with row state deleted in the list, restores it with the record action and it lists without the state; a bulk deactivate whose selection holds another admin and the actor's own row answers 403 with the self_deactivate text and leaves both rows activated (the whole selection rolled back).
|
||||
|
||||
Docs in this commit: docs/backend/users-and-permissions.md, a lifecycle paragraph (activate, deactivate, soft delete, restore, the trashed filter, the last active superuser rule on every path, revocation: deactivate and delete end the target's sessions and personal API tokens, activate and restore bring their personal tokens back, the admin:* commands never clear the superuser flag); cabana README Features. Commit: `feat(cabana): administrator lifecycle and last-superuser protection`.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/cabana/... && go -C examples/hello test -count=1 ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit from vet, the short suite, hello or the docs checker.</fails_when>
|
||||
<automated>go test -count=1 -run '^(TestBackendScreensPostgresSmoke|TestAdministratorsFormSmoke|TestAdministratorsLifecycleSmoke)$' -v ./modules/cabana 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>Fewer than 3 top-level "--- PASS: Test" lines: a smoke failed or skipped (Docker unreachable: rerun through sudo -u golem, else NOT RUN).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'guardLastSuperuser(' modules/cabana/backend_users_admin.go` prints at least 3 (update demotion, delete, deactivate).
|
||||
- `grep -v '^\s*//' modules/cabana/backend_users_admin.go | grep -cE 'Unscoped\(\)\.Delete|Unscoped\(\)\.Where\([^)]*\)\.Delete'` prints 0.
|
||||
- `grep -c 'revocationCutoff()' modules/cabana/backend_users_admin.go` prints at least 3 (password set, deactivate, delete).
|
||||
- `grep -c 'bulkActions: \[activate, deactivate, restore\]' modules/cabana/backendscreens/controllers/users/config_list.yaml` prints 1 and `grep -c 'recordActions: \[activate, deactivate, restore\]' modules/cabana/backendscreens/controllers/users/config_form.yaml` prints 1.
|
||||
- `grep -c 'filterTrashed' modules/cabana/backendscreens/controllers/users/config_filter.yaml` prints 1.
|
||||
- `cmp modules/cabana/backendscreens/controllers/users/_hint.htm ../sm-grzybyfunkcjonalne-app/plugins/golem15/user/controllers/users/_hint.htm` exits 0.
|
||||
- `git log --format=%h "$PLAN_BASE"..HEAD -- modules/cabana/commands.go modules/cabana/token_commands.go` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>A superuser deactivates, deletes, restores and reactivates administrators from the preview and the list; access ends at once; the last active superuser survives every route.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Roles screen: superusers and holders of backend.manage_roles create and edit roles with the permission editor; system roles keep their code and no role in use is deleted</name>
|
||||
<reversibility rating="reversible">A cabana controller and YAML; role rows keep the existing schema.</reversibility>
|
||||
<files>modules/cabana/backend_roles_admin.go, modules/cabana/backend_screens.go, modules/cabana/backendscreens/controllers/roles/config_list.yaml, modules/cabana/backendscreens/controllers/roles/config_form.yaml, modules/cabana/backendscreens/models/userrole/columns.yaml, modules/cabana/backendscreens/models/userrole/fields.yaml, modules/cabana/backend_screens_smoke_test.go, modules/cabana/README.md, docs/backend/users-and-permissions.md</files>
|
||||
<read_first>modules/cabana/backend_screens.go, backend_users_admin.go and backend_privileges.go (from Tasks 1-3), modules/cabana/contracts.go (:17-28), modules/cabana/registry.go (:440-460), modules/lagoon/backend_admin_migrations.go (:20-70), modules/cabana/commands.go (:185-210 roleIDByCode), modules/phrasebook/translator.go (:150-195), USER/controllers/usergroups_admin_controller.go, USER/controllers/usergroups/config_list.yaml, USER/controllers/usergroups/config_form.yaml, USER/controllers/users_admin_controller.go (:479-536), ../examples/golem15-wintercms-starter/modules/backend/controllers/UserRoles.php, ../examples/golem15-wintercms-starter/modules/backend/models/UserRole.php, ../examples/golem15-wintercms-starter/modules/backend/models/userrole/fields.yaml, 19.6.1-UI-SPEC.md (C1, C5, C8 and the D-17 note), 19.6.1-RESEARCH.md (Pattern 6, Pitfall 5)</read_first>
|
||||
<action>Per D-02 (Roles tile), D-07 (ceiling on the Roles screen), D-16 and D-17.
|
||||
|
||||
backend_roles_admin.go (new): `rolesAdmin{screens *backendScreens}`: ID RolesControllerID, ModelName `Backend\Models\UserRole`, ConfigDir controllers/roles, RequiredPermissions [PermissionManageRoles], NewRecord `&BackendUserRole{}`, FormRecordTitle the role name.
|
||||
- PermissionEditorProvider (mode checkbox): options are the catalog in permissionOrder with `Locked: !actsAsSuperuser(editor) && !granted(editor.PermissionGrants, code)`, so a non-superuser can neither add nor remove a permission they lack on any role (D-07); AdminPermissionValues decodes the role's Permissions JSON (value 1 only); AdminSetPermissionValues stores compact JSON, `{}` when empty.
|
||||
- A system role is `IsSystem` or a role whose stored code has code-declared grants (`len(reg.rolePermissions(code)) > 0`).
|
||||
- FormBeforeCreate and FormBeforeUpdate: a code already used by another role (exact match, excluding the record's own id) answers 422 on code with backend::lang.user.role.code_taken (the database has only an index on code and roleIDByCode fails on an ambiguous one). FormBeforeUpdate re-reads the stored code and is_system through a fresh session on the tx (code is fillable and Fill has already overwritten it; USER :479-497 pattern); a system role whose code changed answers 422 on code with backend::lang.user.role.code_locked (D-16). is_system is a protected key and never fillable, so roles created here are never system roles.
|
||||
- FormBeforeDelete: a system role answers 403 with Message backend::lang.user.role.delete_system; otherwise count backend_users holding role_id unscoped (soft-deleted admins still reference the role, the foreign key has no ON DELETE); a count above zero answers 403 with Message set to the translator's Choice of backend::lang.user.role.delete_has_users for that count with name and count params in the request locale (a ForbiddenError carries no params, so the hook translates; phrasebook Translator from the app, towel locale on ctx). Roles have no deleted_at, so the generic delete is a hard delete, which is correct for an unused, non-system role.
|
||||
|
||||
backend_screens.go: AdminControllers adds rolesAdmin; Settings adds the link entry Code roles, Controller RolesControllerID, Permissions [PermissionManageRoles], Label backend::lang.user.role.menu_label, Description backend::lang.user.role.menu_description, Category backend::lang.settings.category_system, Icon shield-check, Order 110 (D-02, UI-SPEC C1).
|
||||
|
||||
YAML (UI-SPEC C5, shapes from USER/controllers/usergroups/): controllers/roles/config_list.yaml (list models/userrole/columns.yaml, modelClass `Backend\Models\UserRole`, title backend::lang.user.role.list_title, recordUrl summercms/cabana/roles/update/:id, showCheckboxes true, noRecordsMessage backend::lang.user.role.list_empty, toolbar buttons [create, delete] with the search prompt, no bulk actions, messages create backend::lang.user.role.new, deleteConfirm backend::lang.user.role.delete_selected_confirm, deleted backend::lang.messages.list.deleted); controllers/roles/config_form.yaml (name backend::lang.user.role.name, form models/userrole/fields.yaml, create redirect summercms/cabana/roles/update/:id and redirectClose summercms/cabana/roles, update redirect to the update screen and redirectClose to the list, no preview block, messages create user.role.new, update user.role.edit, saved, deleteConfirm user.role.delete_confirm, deleted); models/userrole/columns.yaml (name and code searchable and sortable, description searchable, is_system switch sortable, created_at and updated_at invisible sortable); models/userrole/fields.yaml (name left with comment user.role.name_comment; code right with `preset: {type: slug, field: name}` and comment user.role.code_comment; description textarea size tiny span full; permissions on tab backend::lang.user.permissions, permissioneditor mode checkbox, label user.role.permissions_field, comment user.role.permissions_comment, span full).
|
||||
|
||||
Smoke (append): `TestRolesSmoke`: the superuser creates a role (name and code, one permission) and gets 201; assigns it to an admin through the Administrators update; deleting the role answers 403 whose message contains the role name and 1; deleting the seeded developer role answers 403 with the delete_system text; changing developer's code answers 422 on code; GET /settings for the superuser lists administrators before roles.
|
||||
|
||||
Docs in this commit: docs/backend/users-and-permissions.md, a Roles paragraph (Settings → System → Roles, system roles and their code, code-declared grants merged at runtime, the D-07 ceiling, the in-use and system-role delete refusals); cabana README Features. Commit: `feat(cabana): Roles screen`.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./... && go -C examples/hello test -count=1 ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check && scripts/check-admin-openapi.sh --check</automated>
|
||||
<fails_when>Non-zero exit from vet, the whole short suite, hello, the docs checker, or the OpenAPI drift check (this plan must not change any swag-visible type).</fails_when>
|
||||
<automated>go test -count=1 -run '^(TestBackendScreensPostgresSmoke|TestAdministratorsFormSmoke|TestAdministratorsLifecycleSmoke|TestRolesSmoke)$' -v ./modules/cabana 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 4 && go test -count=1 ./modules/cabana ./modules/surf</automated>
|
||||
<fails_when>Fewer than 4 top-level "--- PASS: Test" lines (a smoke failed or skipped; Docker unreachable means NOT RUN after a sudo -u golem retry), or the full Docker-backed cabana and surf suites exit non-zero.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c '"summercms.cabana.roles"' modules/cabana/backend_screens.go` prints 1 and `grep -c 'Code: *"roles"' modules/cabana/backend_screens.go` prints 1.
|
||||
- `grep -c 'mode: checkbox' modules/cabana/backendscreens/models/userrole/fields.yaml` prints 1 and `grep -c 'type: slug' modules/cabana/backendscreens/models/userrole/fields.yaml` prints 1.
|
||||
- `grep -c 'delete_has_users' modules/cabana/backend_roles_admin.go` prints at least 1 and `grep -c 'Choice(' modules/cabana/backend_roles_admin.go` prints at least 1.
|
||||
- `grep -c 'code_locked' modules/cabana/backend_roles_admin.go` prints at least 1.
|
||||
- `grep -c 'preview:' modules/cabana/backendscreens/controllers/roles/config_form.yaml` prints 0.
|
||||
- `git log --format=%h "$PLAN_BASE"..HEAD -- modules/cabana/commands.go modules/cabana/token_commands.go modules/cabana/account.go` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>Roles are listed, created, edited and deleted on their own Settings tile; system role codes stay fixed; a role in use or a system role cannot be deleted; a limited editor cannot widen any role.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| admin SPA / API client → cabana admin API | Request bodies, ids and query filters are untrusted; the principal comes from the JWT or personal token guard |
|
||||
| admin with backend.manage_users → other admins' accounts | A delegated, non-superuser administrator acts on accounts that may hold more privilege |
|
||||
| contributing plugin → another controller's screen (D-14) | Plugin code runs inside the target's transaction behind the target's permission |
|
||||
| concurrent write transactions → last-superuser invariant | Two requests may reduce the superuser set at once |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.6.1-01 | Elevation | generic CRUD fill of is_superuser, role_id, permissions, is_activated, password | high | mitigate | protectedFillKey keeps them unfillable; only usersAdmin hooks, the radio editor provider and the WritableForeignKey role relation write them, each after its check |
|
||||
| T-19.6.1-02 | Elevation | FormBeforeCreate/Update virtual is_superuser | critical | mitigate | presence check on VirtualFieldsFromContext: a non-superuser sending the key answers 403 superuser_forbidden even when the field is hidden (D-05) |
|
||||
| T-19.6.1-03 | Elevation | own record: role, superuser, permissions, activation | high | mitigate | priorRoleID snapshot compared in FormAfterUpdate (rollback), AdminSetPermissionValues self refusal, self checks on is_superuser, deactivate and delete (D-08) |
|
||||
| T-19.6.1-04 | Elevation | permission editors and role picker | high | mitigate | PermissionOption.Locked for codes the editor lacks (stored value kept, change 403) and RelationLockProvider on role ids whose roleGrantSet is not covered (D-07); conservative wildcard rule in grantsCovered |
|
||||
| T-19.6.1-05 | Elevation | password set, deactivate, delete, actions on a higher-privileged admin | critical | mitigate | guardOutranked on update, delete, record and bulk actions for non-superusers (D-19); D-20 refuses an own-password set without the current password |
|
||||
| T-19.6.1-06 | Denial of service | demote, deactivate, delete of the last active superuser | high | mitigate | guardLastSuperuser on every reducing path: pg_advisory_xact_lock after the row lock plus a count of other active superusers in the write tx (D-09) |
|
||||
| T-19.6.1-07 | Information disclosure | list, show, partials, filters for non-superusers | medium | mitigate | ListExtendQuery/FormExtendQuery filter on backend_users.is_superuser (404 by id) and hiddenSchemaFields drops the field, column and filter (D-03) |
|
||||
| T-19.6.1-08 | Elevation | superuser's scoped personal API token | high | mitigate | actsAsSuperuser requires an unscoped or full token scope for every superuser-only power on these screens |
|
||||
| T-19.6.1-09 | Elevation | contributed record actions (D-14) | high | mitigate | protect() checks the target's RequiredPermissions before the action's own Permissions; compileContributions validates contributed permissions at boot; the run path is the existing row-locked RecordAction |
|
||||
| T-19.6.1-10 | Tampering | contributed action names | medium | mitigate | boot error on a name the owner or an earlier contributor registers, on reserved names, nil Run, empty label and unknown controller ID |
|
||||
| T-19.6.1-11 | Spoofing | sessions after deactivate, delete, password set | high | mitigate | tokens_valid_after = now+1s on all three (D-12); FindByID refuses inactive and deleted users for personal tokens |
|
||||
| T-19.6.1-12 | Spoofing | personal API tokens after a password set for another admin | medium | accept | same semantics as admin:reset-password, documented; the operator revokes them with admin:token:revoke; revoking would widen the phase (flagged) |
|
||||
| T-19.6.1-13 | Tampering | unprojected is_superuser switch | high | mitigate | extendRecordData projects the stored flag to superusers so an untouched save never demotes (Pitfall 1) |
|
||||
| T-19.6.1-14 | Elevation | upgrade of existing installs | medium | mitigate | the two codes are declared with no role grants (D-04); only superusers reach the screens until a grant is made |
|
||||
| T-19.6.1-15 | Denial of service | role delete with holders | low | mitigate | unscoped holder count refuses with 403 before the FK would fail (D-17) |
|
||||
| T-19.6.1-16 | Information disclosure | login/email collision messages revealing deleted accounts | low | accept | only holders of backend.manage_users see them, and they can list deleted admins anyway |
|
||||
| T-19.6.1-17 | Denial of service | oversized passwords | low | mitigate | 72-byte cap before bcrypt, min 4, confirmation (D-11) |
|
||||
| T-19.6.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | this plan installs no package; any install would need the package-legitimacy gate and a blocking human checkpoint |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- After each task: `go vet ./... && go test -short ./modules/cabana/... ./modules/pact/... ./modules/surf/...`, `go -C examples/hello test ./...`, the task's smoke tests with Docker, `go test -run '^TestDocsTree$' ./cmd/summer`, `go run ./cmd/summer docs:build --check`.
|
||||
- After Task 4: `go test ./modules/cabana ./modules/surf` with Docker, `go test -short ./...`, `scripts/check-admin-openapi.sh --check` (no swag-visible change in this plan).
|
||||
- SC-1: `git log "$PLAN_BASE"..HEAD -- modules/cabana/commands.go modules/cabana/token_commands.go modules/cabana/account.go` is empty; the existing TestAdmin*, TestAccount* and conformance tests pass unchanged.
|
||||
- Manual (verify-work UAT, VALIDATION Manual-Only): sign in as a superuser and as a limited admin, open Settings → System → Administrators and Roles, create, edit, deactivate.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Both screens work end to end for superusers; every D-03..D-12, D-16, D-17, D-19, D-20 refusal answers with the status and copy of UI-SPEC C8.
|
||||
- A plugin can contribute a record action to summercms.cabana.users or any other controller; the boot errors fire.
|
||||
- Admin-less applications and every existing test keep their behaviour; docs and READMEs describe the new surface.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.6.1-backend-administrators-screen/19.6.1-01-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,293 @@
|
||||
---
|
||||
phase: 19.6.1-backend-administrators-screen
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["19.6.1-01"]
|
||||
files_modified:
|
||||
- modules/cabana/field_permission.go
|
||||
- modules/cabana/relation_field.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/backend_roles_admin.go
|
||||
- modules/cabana/backend_users_admin.go
|
||||
- modules/cabana/field_trigger.go
|
||||
- modules/cabana/form_schema.go
|
||||
- modules/cabana/schema_types.go
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/cabana/backendscreens/models/user/fields.yaml
|
||||
- modules/cabana/record_locks_smoke_test.go
|
||||
- modules/cabana/field_trigger_smoke_test.go
|
||||
- modules/cabana/README.md
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- admin/openapi/admin.json
|
||||
- admin/src/api/schema.d.ts
|
||||
- admin/src/api/types.ts
|
||||
- admin/src/components/form/formContext.ts
|
||||
- admin/src/components/form/control.ts
|
||||
- admin/src/components/form/formState.ts
|
||||
- admin/src/components/form/trigger.ts
|
||||
- admin/src/components/form/FormField.vue
|
||||
- admin/src/components/form/fields/PermissionEditorField.vue
|
||||
- admin/src/views/FormView.vue
|
||||
- admin/src/views/PreviewView.vue
|
||||
- modules/boardwalk/dist
|
||||
- docs/backend/forms.md
|
||||
autonomous: true
|
||||
requirements: [SC-1, SC-2, D-06, D-08, D-16, D-18, D-21, D-22]
|
||||
|
||||
estimate:
|
||||
tokens: 190000
|
||||
raw_tokens: 190000
|
||||
tasks: 2
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-21, D-16: on a role that a code-declared pact.Permission.Roles grant attaches to, the Roles form shows each such grant checked, disabled, with the Lock icon and the note backend::lang.permissioneditor.locked_by_code; the save body never carries those codes, the server keeps them, and the save does not answer 403 even when the role's stored JSON lacks them."
|
||||
- "D-21, D-08: on the editor's own Administrators record every permission override row is record-locked with its stored value (1, -1 or inherit) and the note backend::lang.permissioneditor.locked_self, the save body omits them, and a hand-crafted body that changes one answers 403 on permissions."
|
||||
- "D-21: show and save responses carry meta.permissionLocks keyed by permissioneditor field name with entries {code, value, note} (note translated in the request locale) only when a record lock applies; the form schema never carries it; responses of controllers without a lock provider are unchanged (omitempty)."
|
||||
- "D-21, D-06: fields.yaml accepts trigger with exactly action (disable or hide), field and condition (checked or unchecked); a missing or extra sub-key, another value, a controlling field that is not in the same form, is the field itself or is not a switch or checkbox, and a trigger in a repeater row, settings or relation form stop boot naming the controller, the field and the key; the form schema carries it as trigger."
|
||||
- "D-06: on the Administrators form a superuser opening another superuser sees Permission overrides read-only with the note “Not editable while “Superuser” is on.” between the label and the control, the field is left out of the save body while disabled and the server keeps the stored overrides; switching Superuser off makes it editable at once; a non-superuser, whose schema has no Superuser switch, always gets an editable editor (subject to D-07 locks)."
|
||||
- "D-21: trigger hide removes the field from rendering, from the save body and from the tab computation (a tab whose fields are all hidden disappears and the first remaining tab becomes active), and a server message for a hidden field appears in the FormErrorBanner as an orphan line; PreviewView applies hide only."
|
||||
- "D-21: a user edit that flips a trigger to active announces “<field label>: <note>” in the form's polite live region; enabling announces nothing; values typed before a field was disabled stay in the form and return when it is enabled."
|
||||
- "D-21, D-18: admin/openapi/admin.json and admin/src/api/schema.d.ts are regenerated in the same commits that change cabana.FormField and cabana.RecordMeta (scripts/check-admin-openapi.sh --check passes after each), and modules/boardwalk/dist is rebuilt from the committed lockfile and committed (scripts/check-admin-dist.sh passes)."
|
||||
- "D-21: server enforcement stays authoritative: a client that ignores trigger and record locks gains nothing, because Plan 01's rules and the record-lock save rule decide every write."
|
||||
- "UI: the trigger note sits above a long permission editor so it is visible without scrolling; a superuser target's Permissions tab shows the Superuser card on, then the read-only overrides editor with the note."
|
||||
- "UI: record locks render as: none → the plain editor; some → those rows locked with the record's note and the rest editable; all (your own record) → the whole editor read-only; a row both request-locked and record-locked shows the record lock's value and note; on read-only screens (preview, trigger-disabled editor) record locks show the value only, without icon or note."
|
||||
- "UI: an invalid trigger is a boot error and never reaches the SPA; the trigger and lock notes are static text with no loading state."
|
||||
artifacts:
|
||||
- path: "modules/cabana/field_trigger.go"
|
||||
provides: "FieldTrigger type, trigger compile and boot checks"
|
||||
contains: "type FieldTrigger struct"
|
||||
- path: "modules/cabana/field_permission.go"
|
||||
provides: "PermissionRecordLock, PermissionRecordLockProvider and the record-lock save rule"
|
||||
contains: "type PermissionRecordLockProvider interface"
|
||||
- path: "modules/cabana/relation_field.go"
|
||||
provides: "RecordMeta.PermissionLocks"
|
||||
contains: "permissionLocks,omitempty"
|
||||
- path: "admin/src/components/form/trigger.ts"
|
||||
provides: "trigger evaluation helpers"
|
||||
contains: "export function applyTriggers"
|
||||
- path: "admin/src/components/form/fields/PermissionEditorField.vue"
|
||||
provides: "record-locked rows"
|
||||
contains: "data-locked-by"
|
||||
- path: "modules/boardwalk/dist"
|
||||
provides: "committed admin build with the D-21 SPA support"
|
||||
key_links:
|
||||
- from: "modules/cabana/crud.go"
|
||||
to: "modules/cabana/field_permission.go"
|
||||
via: "projectFullRecord fills meta.permissionLocks from the controller's PermissionRecordLockProvider"
|
||||
pattern: "AdminPermissionRecordLocks\\("
|
||||
- from: "admin/src/views/FormView.vue"
|
||||
to: "admin/src/components/form/trigger.ts"
|
||||
via: "the effective field list (tabs, grid, banner names, payload) comes from applyTriggers"
|
||||
pattern: "applyTriggers\\("
|
||||
- from: "admin/src/components/form/formState.ts"
|
||||
to: "admin/src/components/form/control.ts"
|
||||
via: "editablePayload passes record-locked codes to permissionValues so they are never sent"
|
||||
pattern: "permissionValues\\("
|
||||
- from: "modules/cabana/backendscreens/models/user/fields.yaml"
|
||||
to: "modules/cabana/field_trigger.go"
|
||||
via: "the permissions field declares trigger disable on is_superuser checked"
|
||||
pattern: "field: is_superuser"
|
||||
prohibitions:
|
||||
- statement: "The SPA never becomes the enforcement point: a trigger-disabled field or a record-locked code is never trusted from the client; the server keeps stored values and decides every write."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No new npm dependency, design token, colour, font, radius, shadow or icon outside the existing set is added, and no new .vue file is needed."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Trigger and lock notes render through text interpolation only, never v-html."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Payloads of controllers that use neither trigger nor record locks are unchanged: the new keys are omitempty and existing keys keep their meaning."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not a user story; MVP mode off): cabana gets WinterCMS's Administrators screens, managed in the admin SPA, with the privilege rules on the server.
|
||||
|
||||
This plan's slice (plan 2 of 3, wave 2, D-22 "a short plan for the SPA support in D-21"): the two display features D-21 put in scope, end to end. The tracer carries per-record permission locks from a controller provider through the record response to the permission editor (D-16 code-declared role grants, D-08 your own overrides). Task 2 adds the field `trigger` (D-06: overrides disabled while Superuser is on) and the committed SPA build. Each feature lands with the server type, the swag-generated OpenAPI and `schema.d.ts`, the SPA code and the docs in one commit, so no commit leaves generated files stale.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Skipped for edges (no requirement IDs, no SPEC.md; visible choice). The UI-SPEC "UI Considerations" rows for the trigger and the record locks are lifted into `must_haves.truths` (lines starting "UI:"); the backstop rows for these features are owned by Plan 03, which writes those tests. Prohibitions recalled by hand, descriptor-less.
|
||||
|
||||
## Decisions taken in this plan (recorded assumptions and Claude's discretion)
|
||||
|
||||
- Partition (D-22): Plan 01 changed no swag-visible type; this plan owns every change to `cabana.FormField` and `cabana.RecordMeta`, so the OpenAPI regeneration happens here, in the same commits as the type changes.
|
||||
- Record locks are an exported, optional cabana interface next to `PermissionEditorProvider` (`PermissionRecordLockProvider`), so any plugin's permission editor can use them; UI-SPEC C7's recommended payload shape `meta.permissionLocks: {field: [{code, value, note}]}` is kept as is.
|
||||
- Save rule for a record-locked code: absent from the body keeps the stored value; present with the stored value is accepted; present with another value answers 403 on the field (the existing locked phrase). This works for the old SPA (which sends stored values) and the new one (which sends none), and keeps D-08's "the server refuses" for hand-crafted bodies.
|
||||
- Locks travel to the permission editor through a provided injection key (`FORM_PERMISSION_LOCKS`) rather than prop threading through FormGrid, FormField and FieldRenderer (UI-SPEC C7 leaves the path to the planner); trigger notes travel the same way (`FORM_TRIGGER_NOTES`).
|
||||
- Trigger is evaluated only where the SPA renders controller forms (FormView, PreviewView), so the server refuses it in repeater rows, settings forms and relation forms.
|
||||
- `trigger` supports only the `checked` and `unchecked` conditions (Winter's `value[...]` is out of scope, UI-SPEC C6).
|
||||
- The dist is rebuilt once, in Task 2, after both features; Task 1 leaves `modules/boardwalk/dist` stale for one commit (the release gate applies before tagging, `.planning/notes/2026-10-07-embedded-admin-release-gate.md`).
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- cabana exported: `type PermissionRecordLock struct { Code string; Value int; Note string }` (json code, value, note), `type PermissionRecordLockProvider interface { AdminPermissionRecordLocks(ctx context.Context, field string, record any) ([]PermissionRecordLock, error) }`, `RecordMeta.PermissionLocks map[string][]PermissionRecordLock` (json `permissionLocks,omitempty`), `type FieldTrigger struct { Action, Field, Condition string }` (json action, field, condition), `FormField.Trigger *FieldTrigger` (json `trigger,omitempty`).
|
||||
- cabana unexported: `compileTrigger`, `checkTriggers`; record-lock handling in `applyPermissionValues` and `projectFullRecord`; `AdminPermissionRecordLocks` on `rolesAdmin` and `usersAdmin`.
|
||||
- fields.yaml key `trigger` (`action: disable|hide`, `field`, `condition: checked|unchecked`).
|
||||
- Lang keys `backend::lang.form.trigger_disabled_on`, `form.trigger_disabled_off`, `permissioneditor.locked_by_code`, `permissioneditor.locked_self` (en, pl).
|
||||
- SPA: `admin/src/components/form/trigger.ts` (`triggerState`, `applyTriggers`), injection keys `FORM_PERMISSION_LOCKS` and `FORM_TRIGGER_NOTES` in formContext.ts, `permissionValues(field, value, locked?)`, `editablePayload(fields, values, mode, locks?)`, type aliases `PermissionRecordLock` and `FieldTrigger` in admin/src/api/types.ts; regenerated `admin/openapi/admin.json`, `admin/src/api/schema.d.ts`; rebuilt `modules/boardwalk/dist`.
|
||||
- Smoke tests `TestPermissionRecordLocksSmoke`, `TestFieldTriggerSmoke` (-short).
|
||||
|
||||
<objective>
|
||||
Ship the SPA support D-21 put in scope, server type to rendered control: per-record locked permission values (D-16 code-declared role grants, D-08 your own overrides) and the field `trigger` (D-06), with the OpenAPI and `schema.d.ts` regenerated and the committed admin build refreshed (D-22 plan 02).
|
||||
|
||||
Purpose: D-06, D-08, D-16 display rules via D-21; SC-1 (roles shown with the permission editor as they really are); server stays authoritative (SC-2).
|
||||
Output: two framework commits, each with server, generated OpenAPI, SPA and docs; the second also commits `modules/boardwalk/dist`.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-CONTEXT.md
|
||||
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-UI-SPEC.md
|
||||
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-PATTERNS.md
|
||||
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-01-SUMMARY.md
|
||||
@.planning/notes/2026-10-07-embedded-admin-release-gate.md
|
||||
|
||||
Concurrency and commits: as in Plan 01 (record `PLAN_BASE=$(git rev-parse HEAD)` before Task 1; check `git status --porcelain -- modules/cabana admin modules/boardwalk docs` before each task; stage by explicit path; no co-author tags; docs with the code; `go vet ./...`, `go test -short ./...` and `go -C examples/hello test ./...` green at every commit). `npm --prefix admin ci` installs from the committed lockfile only; no `npm install` of new packages. When staging `modules/boardwalk/dist`, stage the whole directory path (hashed asset names change) and nothing else from other sessions.
|
||||
|
||||
<interfaces>
|
||||
- From Plan 01 (19.6.1-01-SUMMARY.md has the names as built): `rolesAdmin`, `usersAdmin`, `actsAsSuperuser`, `Registry.permissionOrder`, `Registry.rolePermissions(code)`, `PermissionManageUsers`, `AdministratorsControllerID`, `RolesControllerID`; the users fields.yaml Permissions tab with `is_superuser` (virtual switch, hidden for non-superusers by `hiddenSchemaFields`) and `permissions` (permissioneditor, radio).
|
||||
- cabana: `PermissionOption`, `PermissionEditorProvider`, `permissionLockedKey`, `localizePermissionOptions`, `liftPermissionValues`, `applyPermissionValues` (the locked comparison loop ~:303-307), `projectPermissionFields` field_permission.go; `RecordMeta{Title, Labels, Actions, Notices}` relation_field.go:95-102; `projectFullRecord` crud.go:647 and the show and save paths that localize meta with `s.tr` (offeredRecordActions, `localizeNotices`); `translateKey(ctx, tr, key)`; `formFieldKeys` form_schema.go:38-50, `compileDependsOn` call form_schema.go:646, `compileDependsOn` / `checkDependsOn` field_dependent.go:23-60 (the compile and sibling-check analog), `FormField` schema_types.go:260-300 (`DependsOn` json `dependsOn,omitempty`); settings forms compile in settings.go `compileSettingWithTypes` (refuses plugin field types: mirror that refusal), repeater row forms in field_repeater.go, relation forms in relation_form.go; swag descriptions of the form schema and record responses in admin_openapi.go (the `preset` / `dependsOn` sentences reflected in admin.json :5261).
|
||||
- SPA: `FormField`, `PermissionOption`, `RecordMeta` aliases admin/src/api/types.ts:36-51; `permissionValues(field, value)` control.ts:77-90 and `toggleOn` (control.ts, above :60); `editablePayload(fields, values, mode)` formState.ts:53-77 (skips readOnly fields; permissioneditor through permissionValues); FormView.vue: `fields` computed :117 (context filter), `fieldNames` :124, `tabs` :126, `panelFields`, `labels` :80 set from `record.meta.labels` :223, snapshot and save `editablePayload` :208, :226, :398, `announce` :324, provides :314-349, FormErrorBanner :577; PreviewView.vue: `fields` :71, `tabs` :84, meta handling :152, provides :210; formContext.ts injection keys (FORM_VALUES and others, defaults for forms without a provider); FormField.vue (label, FieldRenderer, comment, error markup; describedBy); PermissionEditorField.vue (`disabled(option)` :79, `showsLock(option)` :83, `valueOf`, lock icon and note markup ~:147-166, `t('backend::lang.permissioneditor.locked')`); RelationField.vue locked note markup :326-337 (`Lock :size="14" class="shrink-0 text-muted"`).
|
||||
- Scripts: `scripts/check-admin-openapi.sh [--check]` (swag v1.16.6 → swagger2openapi → openapi-typescript), `scripts/check-admin-dist.sh` (typecheck, vite build into a temp dir, diff with modules/boardwalk/dist), `npm --prefix admin run test|typecheck|build`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a role that a plugin's code-declared grant attaches to shows that grant checked and locked with its note on the Roles form, and your own Administrators record shows every override locked, with saves keeping the locked values</name>
|
||||
<reversibility rating="costly">meta.permissionLocks and PermissionRecordLockProvider become a published payload and interface that plugins and the SPA read; changing them later needs coordinated SPA and plugin changes.</reversibility>
|
||||
<files>modules/cabana/field_permission.go, modules/cabana/relation_field.go, modules/cabana/crud.go, modules/cabana/backend_roles_admin.go, modules/cabana/backend_users_admin.go, modules/cabana/admin_openapi.go, modules/cabana/record_locks_smoke_test.go, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/api/types.ts, admin/src/components/form/formContext.ts, admin/src/components/form/control.ts, admin/src/components/form/formState.ts, admin/src/components/form/fields/PermissionEditorField.vue, admin/src/views/FormView.vue, admin/src/views/PreviewView.vue, modules/cabana/README.md, docs/backend/forms.md</files>
|
||||
<read_first>.planning/phases/19.6.1-backend-administrators-screen/19.6.1-01-SUMMARY.md, modules/cabana/field_permission.go, modules/cabana/relation_field.go (:80-110), modules/cabana/crud.go (:500-560, :640-670), modules/cabana/backend_roles_admin.go, modules/cabana/backend_users_admin.go, modules/cabana/admin_openapi.go (record response and form schema descriptions), scripts/check-admin-openapi.sh, admin/src/api/types.ts, admin/src/components/form/formContext.ts, admin/src/components/form/control.ts, admin/src/components/form/formState.ts, admin/src/components/form/fields/PermissionEditorField.vue, admin/src/views/FormView.vue, admin/src/views/PreviewView.vue, admin/tests/form/PermissionEditorField.test.ts, docs/backend/forms.md ("Permission editor" :305-373), 19.6.1-UI-SPEC.md (C7, Framework additions copy)</read_first>
|
||||
<action>Per D-21 for D-16 and D-08.
|
||||
|
||||
Server, field_permission.go: exported `PermissionRecordLock{Code string json:"code"; Value int json:"value"; Note string json:"note"}` and the optional `PermissionRecordLockProvider` with `AdminPermissionRecordLocks(ctx context.Context, field string, record any) ([]PermissionRecordLock, error)`, documented: implemented by a controller that also implements PermissionEditorProvider; asked with the request principal (bouncer.User) for a stored record, never on create; Value is what the editor shows (1, -1, or 0 for inherit); Note is a phrase key or text. applyPermissionValues on update: ask the provider for the record's locks; for a record-locked code the submitted value, when present in the body, must equal the stored value, else `&ForbiddenError{Details: {field: [permissionLockedKey]}}`; whether present or not, the stored value is carried into next unchanged (like a non-offered code) and the code is skipped in the request-Locked comparison, so a system role whose code grant is not in its stored JSON saves without a 403.
|
||||
|
||||
relation_field.go: `RecordMeta.PermissionLocks map[string][]PermissionRecordLock` json `permissionLocks,omitempty`, with a doc comment (show and save responses; absent when no lock applies; never in the form schema). crud.go: projectFullRecord fills it for each permissioneditor field whose provider returns locks; the Note is translated in the request locale where the show and save responses localize their other meta (with `s.tr`, as for record action labels and notices).
|
||||
|
||||
backend_roles_admin.go: AdminPermissionRecordLocks returns, for the role's stored code, every code in `reg.rolePermissions(code)` in permissionOrder with Value 1 and Note backend::lang.permissioneditor.locked_by_code (D-16). backend_users_admin.go: for the editor's own record (principal ID equals the record's), every offered code with Value from the stored overrides (1, -1, else 0) and Note backend::lang.permissioneditor.locked_self; none for other records (D-08 display; AdminSetPermissionValues keeps refusing a change).
|
||||
|
||||
Lang (en, pl): permissioneditor.locked_by_code and permissioneditor.locked_self, verbatim from UI-SPEC "Framework additions (D-21)".
|
||||
|
||||
admin_openapi.go: extend the record response descriptions with meta.permissionLocks (per permissioneditor field, entries code, value, note; what the editor does with them); run `scripts/check-admin-openapi.sh` to regenerate admin/openapi/admin.json and admin/src/api/schema.d.ts and commit both.
|
||||
|
||||
SPA (UI-SPEC C7; tokens and markup copied from existing components):
|
||||
- types.ts: `export type PermissionRecordLock = Schemas['cabana.PermissionRecordLock']`.
|
||||
- formContext.ts: `FORM_PERMISSION_LOCKS: InjectionKey<Readonly<Ref<Record<string, PermissionRecordLock[]>>>>`; injecting controls default to an empty map.
|
||||
- control.ts: `permissionValues(field, value, locked?: ReadonlySet<string>)` skips record-locked codes (never sent, like non-offered codes).
|
||||
- formState.ts: `editablePayload(fields, values, mode, locks?)` passes each permissioneditor field's locked codes to permissionValues; callers without locks are unchanged.
|
||||
- FormView.vue: keep `record.meta.permissionLocks ?? {}` next to `labels` (reset on every load and after a save), provide it, and pass it to every editablePayload call (dirty snapshot and save). PreviewView.vue: provide it from the record meta.
|
||||
- PermissionEditorField.vue: inject the locks for `field.name`; a record-locked row shows the lock's value (checkbox checked for 1; radio segment for 1, 0, -1), its control is disabled with aria-disabled="true", the existing Lock 14px follows the label and the existing lock-note line (same id and aria-describedby link) shows the lock's note instead of permissioneditor.locked or the option comment; the row gets `data-locked` and `data-locked-by="record"`; a record lock wins over a request lock (value and note); on a read-only screen record locks show the value only, without icon or note (existing showsLock rule); `set()` refuses record-locked codes.
|
||||
|
||||
Smoke (modules/cabana/record_locks_smoke_test.go, package cabana, -short): `TestPermissionRecordLocksSmoke` with an in-test controller implementing both providers (offers a.x and a.y, record-locks a.x at value 1 with a phrase note, stored {a.y: 1}): applyPermissionValues on update with a body without a.x keeps a.x out of next unchanged-stored and stores a.y as submitted; a body sending a.x: 1 when stored lacks it answers ForbiddenError on the field; a body equal to the stored values passes; with a.x also request-Locked and absent from the body there is no 403; projectFullRecord's meta carries permissionLocks for the field and omits the key for a controller without the provider. Keep existing admin tests green (`npm --prefix admin run test`); Plan 03 adds the SPA tests.
|
||||
|
||||
Docs in this commit: docs/backend/forms.md "Permission editor": per-record locks (PermissionRecordLockProvider, meta.permissionLocks, the save rule, how the SPA shows them, example: grants a plugin declares for a role); cabana README API rows for PermissionRecordLock, PermissionRecordLockProvider and RecordMeta.PermissionLocks. Commit: `feat(cabana,admin): per-record locked permission values`.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/cabana/... && go test -count=1 -short -run '^TestPermissionRecordLocksSmoke$' -v ./modules/cabana 2>&1 | grep -q -- '^--- PASS: TestPermissionRecordLocksSmoke' && go -C examples/hello test -count=1 ./... && scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && npm --prefix admin run test && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit from vet, the short suite, hello, the OpenAPI drift check (committed admin.json or schema.d.ts differ from a fresh generation), the SPA typecheck or vitest run, or the docs checker; or no "--- PASS: TestPermissionRecordLocksSmoke" line.</fails_when>
|
||||
<automated>go test -count=1 -run '^(TestRolesSmoke|TestAdministratorsFormSmoke)$' -v ./modules/cabana 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>Fewer than 2 top-level "--- PASS: Test" lines: Plan 01's role and form saves broke under the record-lock rule, or Docker was unreachable ("--- SKIP"; rerun through sudo -u golem, else NOT RUN).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'type PermissionRecordLockProvider interface' modules/cabana/field_permission.go` prints 1 and `grep -c 'permissionLocks,omitempty' modules/cabana/relation_field.go` prints 1.
|
||||
- `grep -c 'AdminPermissionRecordLocks(' modules/cabana/backend_roles_admin.go` prints 1 and the same count on modules/cabana/backend_users_admin.go prints 1.
|
||||
- `grep -c 'locked_by_code' modules/phrasebook/backend/lang/pl/lang.yaml` prints 1 and `grep -c 'locked_self' modules/phrasebook/backend/lang/en/lang.yaml` prints 1.
|
||||
- `grep -c 'permissionLocks' admin/src/api/schema.d.ts` prints at least 1 and `grep -c 'cabana.PermissionRecordLock' admin/openapi/admin.json` prints at least 1.
|
||||
- `grep -c 'data-locked-by' admin/src/components/form/fields/PermissionEditorField.vue` prints at least 1 and `grep -c 'FORM_PERMISSION_LOCKS' admin/src/components/form/formContext.ts` prints 1.
|
||||
- `grep -c 'v-html' admin/src/components/form/fields/PermissionEditorField.vue` prints 0.
|
||||
- `git diff --name-only "$PLAN_BASE"..HEAD -- admin/package.json admin/package-lock.json` prints nothing after the commit (`PLAN_BASE` = `git rev-parse HEAD` recorded before Task 1).
|
||||
</acceptance_criteria>
|
||||
<done>Opening a system role shows its plugin-declared grants checked and locked with the note; your own record's overrides are locked; saving either keeps the locked values and never 403s on them, while a hand-crafted change is refused.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Field trigger: on the Administrators form the permission overrides are read-only with a note while Superuser is on and editable again when it is off; then the committed admin build</name>
|
||||
<reversibility rating="costly">The fields.yaml trigger key and its schema payload become a contract every plugin's YAML may use; reshaping it later touches plugin YAML and the SPA.</reversibility>
|
||||
<files>modules/cabana/field_trigger.go, modules/cabana/form_schema.go, modules/cabana/schema_types.go, modules/cabana/admin_openapi.go, modules/cabana/backendscreens/models/user/fields.yaml, modules/cabana/field_trigger_smoke_test.go, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/api/types.ts, admin/src/components/form/trigger.ts, admin/src/components/form/formContext.ts, admin/src/components/form/FormField.vue, admin/src/views/FormView.vue, admin/src/views/PreviewView.vue, modules/boardwalk/dist, modules/cabana/README.md, docs/backend/forms.md</files>
|
||||
<read_first>modules/cabana/form_schema.go (:20-60, :600-700), modules/cabana/field_dependent.go (:1-80), modules/cabana/schema_types.go (:255-300), modules/cabana/settings.go (:70-140), modules/cabana/field_repeater.go (row form compile), modules/cabana/relation_form.go (form compile entry), modules/cabana/admin_openapi.go (form schema description), modules/cabana/backendscreens/models/user/fields.yaml, admin/src/components/form/control.ts (toggleOn), admin/src/components/form/formState.ts, admin/src/components/form/FormField.vue, admin/src/components/form/formContext.ts, admin/src/views/FormView.vue (:110-260, :314-420, :560-600), admin/src/views/PreviewView.vue (:60-120, :200-290), admin/src/components/form/FormErrorBanner.vue, admin/src/components/form/fields/RelationField.vue (:320-340), admin/tests/form/DependentDropdown.test.ts, docs/backend/forms.md (:111-172), scripts/check-admin-dist.sh, .planning/notes/2026-10-07-embedded-admin-release-gate.md, 19.6.1-UI-SPEC.md (C6, Framework additions copy, Typography and Spacing for the note)</read_first>
|
||||
<action>Per D-21 for D-06.
|
||||
|
||||
Server: form_schema.go adds "trigger" to formFieldKeys and calls compileTrigger next to compileDependsOn. field_trigger.go (new): `FieldTrigger{Action string json:"action"; Field string json:"field"; Condition string json:"condition"}`; compileTrigger reads the `trigger` mapping: exactly the sub-keys action (disable or hide), field (an identifier) and condition (checked or unchecked); a missing or extra sub-key or another value is an error naming the key; it sets `FormField.Trigger`. checkTriggers(fields), run after the form's fields compile like checkDependsOn: the named field must be another field of the same form, of type switch or checkbox (a virtual switch such as is_superuser counts). A trigger inside a repeater row form, a settings form or a relation form is a boot error (the SPA evaluates triggers only in FormView and PreviewView); wire the refusal where those forms compile, as settings.go refuses plugin field types. Errors go through bootErr so they name the plugin, controller, file, field and key. schema_types.go: `Trigger *FieldTrigger` json `trigger,omitempty` on FormField with a doc comment. admin_openapi.go: add a sentence on trigger to the form schema description; regenerate admin/openapi/admin.json and admin/src/api/schema.d.ts with scripts/check-admin-openapi.sh. backendscreens/models/user/fields.yaml: the permissions field gets `trigger: {action: disable, field: is_superuser, condition: checked}` (D-06). Lang (en, pl): form.trigger_disabled_on and form.trigger_disabled_off with :field, verbatim from UI-SPEC.
|
||||
|
||||
SPA (UI-SPEC C6):
|
||||
- trigger.ts (new): `triggerState(field, values, present: ReadonlySet<string>): 'active' | 'inactive'` (checked is active when toggleOn(values[controlling]); unchecked is active when it is off; a controlling field missing from `present`, the names of the schema fields that pass this request's filters and context, counts as off) and `applyTriggers(fields, values, labelOf): { fields, notes }`: a hide-active field is removed; a disable-active field becomes a copy with readOnly true; notes maps each disable-active field name to t(form.trigger_disabled_on or _off, {field: controlling label}). Pure, no Vue imports beyond types.
|
||||
- formContext.ts: `FORM_TRIGGER_NOTES` injection key (default empty map).
|
||||
- FormView.vue: apply applyTriggers to the context-filtered fields with the whole form's values (not the active tab's) and use the result everywhere the form uses fields: tabs, panelFields, fieldNames for FormErrorBanner (a hidden field's error becomes an orphan banner line), and every editablePayload call (a disabled field is readOnly, so it is not sent; a hidden one is absent), so the server keeps stored values. When the active tab disappears, the first remaining tab becomes active. Provide FORM_TRIGGER_NOTES. When a user edit turns a trigger active, call announce with "<field label>: <note>"; nothing on enable. Values stay in `values` while a field is disabled or hidden.
|
||||
- FormField.vue: inject the notes; for a field with a note render, between the label and the control, `<p :id="`${controlId}-trigger`" data-trigger-note class="flex items-center gap-2 text-[13px] text-muted">` holding Info (size 14, class shrink-0, aria-hidden) and the note text by interpolation, and add that id to describedBy.
|
||||
- PreviewView.vue: apply hide triggers against the record values for fields and tabs; disable has no effect there and no note shows.
|
||||
- types.ts: `export type FieldTrigger = Schemas['cabana.FieldTrigger']`.
|
||||
|
||||
Build: `npm --prefix admin run build`, then stage and commit modules/boardwalk/dist in this commit; scripts/check-admin-dist.sh must pass.
|
||||
|
||||
Smoke (modules/cabana/field_trigger_smoke_test.go, package cabana, -short): `TestFieldTriggerSmoke`: a fixture form (fstest.MapFS) with a switch and a field carrying a valid trigger compiles and its localized schema field carries Trigger; one invalid case per rule (unknown action, unknown condition, extra sub-key, missing field, self reference, a text controlling field) fails compilation with an error containing the field name and "trigger"; the compiled Administrators form (through withBackendScreens) carries the trigger on permissions. Keep `npm --prefix admin run test` green; Plan 03 adds the SPA tests.
|
||||
|
||||
Docs in this commit: docs/backend/forms.md, a "Triggers" section under field options (the key, the two actions and conditions, the boot errors, that the server stays authoritative and a disabled field is not sent); cabana README Features and the FieldTrigger API row. Commit: `feat(cabana,admin): field trigger and admin build`.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./... && go test -count=1 -short -run '^(TestFieldTriggerSmoke|TestPermissionRecordLocksSmoke|TestBackendScreensSmoke)$' -v ./modules/cabana 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3 && go -C examples/hello test -count=1 ./... && scripts/check-admin-openapi.sh --check && npm --prefix admin run test && scripts/check-admin-dist.sh && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit from vet, the whole short suite, hello, the OpenAPI drift check, vitest, the dist drift check ("modules/boardwalk/dist is stale"), or the docs checker; or fewer than 3 top-level "--- PASS: Test" lines among the three smokes.</fails_when>
|
||||
<automated>go test -count=1 ./modules/cabana ./modules/surf</automated>
|
||||
<fails_when>Non-zero exit of the Docker-backed cabana and surf suites (Plan 01 smokes included); a run where Docker was unreachable shows SKIP lines and is recorded NOT RUN after a sudo -u golem retry.</fails_when>
|
||||
<human-check>Optional before verify-work: sign in as a superuser, open another superuser's Administrators form, Permissions tab: the overrides editor is read-only with the note; switch Superuser off and it becomes editable; open your own record and a system role and see the locked rows with their notes.</human-check>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c '"trigger": {}' modules/cabana/form_schema.go` prints 1 and `grep -c 'type FieldTrigger struct' modules/cabana/field_trigger.go` prints 1.
|
||||
- `grep -c 'trigger,omitempty' modules/cabana/schema_types.go` prints 1.
|
||||
- `grep -c 'field: is_superuser' modules/cabana/backendscreens/models/user/fields.yaml` prints 1.
|
||||
- `grep -c 'export function applyTriggers' admin/src/components/form/trigger.ts` prints 1 and `grep -c 'applyTriggers(' admin/src/views/FormView.vue` prints at least 1.
|
||||
- `grep -c 'data-trigger-note' admin/src/components/form/FormField.vue` prints 1 and `grep -c 'v-html' admin/src/components/form/FormField.vue` prints 0.
|
||||
- `grep -c 'trigger_disabled_on' modules/phrasebook/backend/lang/pl/lang.yaml` prints 1.
|
||||
- `git status --porcelain -- modules/boardwalk/dist admin/openapi admin/src/api` prints nothing after the commit.
|
||||
</acceptance_criteria>
|
||||
<done>A superuser editing another superuser sees the overrides read-only with the note and editable once Superuser is off; plugins can declare triggers in fields.yaml; the committed admin build carries both D-21 features.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| admin SPA → cabana save API | The SPA's trigger and lock handling is a display aid; the body it sends is untrusted |
|
||||
| controller lock provider → record response | Lock notes and values come from plugin code and reach every viewer of the record |
|
||||
| plugin fields.yaml → boot compiler | A plugin's YAML may declare malformed triggers |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.6.1-18 | Elevation | trigger-disabled permission overrides | medium | mitigate | the disabled field is omitted and the server keeps stored values; superusers bypass overrides anyway; no server rule depends on the trigger |
|
||||
| T-19.6.1-19 | Elevation | record-locked codes in a hand-crafted save body | high | mitigate | applyPermissionValues refuses a present record-locked value that differs from the stored one (403) and carries the stored value otherwise; D-08's AdminSetPermissionValues refusal stays |
|
||||
| T-19.6.1-20 | Information disclosure | meta.permissionLocks | low | accept | sent only on show and save responses behind protect() to admins who may open the record; it repeats codes the permission editor already shows |
|
||||
| T-19.6.1-21 | Tampering | malformed trigger YAML | low | mitigate | compileTrigger and checkTriggers stop boot with errors naming controller, field and key |
|
||||
| T-19.6.1-22 | Tampering | stale committed SPA build or OpenAPI | medium | mitigate | check-admin-openapi.sh --check and check-admin-dist.sh in this plan's verify; dist rebuilt from the committed lockfile |
|
||||
| T-19.6.1-23 | Tampering | note text injected into markup | medium | mitigate | notes render by Vue text interpolation only (no v-html); acceptance greps forbid v-html in the touched components |
|
||||
| T-19.6.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package is added; `npm --prefix admin ci` installs only the committed lockfile; any new package would need the legitimacy gate and a blocking human checkpoint |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: Go short suite and smoke, `scripts/check-admin-openapi.sh --check`, `npm --prefix admin run typecheck` and `test`, docs checker; Plan 01 role and form smokes with Docker.
|
||||
- Task 2: the same plus `scripts/check-admin-dist.sh`, and the full Docker-backed cabana and surf suites.
|
||||
- Manual (verify-work UAT): the human-check in Task 2.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Record locks and triggers work end to end on both screens; the server behaviour of Plan 01 is unchanged for clients that ignore them.
|
||||
- Generated OpenAPI, schema.d.ts and the committed dist match fresh builds.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.6.1-backend-administrators-screen/19.6.1-02-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,316 @@
|
||||
---
|
||||
phase: 19.6.1-backend-administrators-screen
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["19.6.1-01", "19.6.1-02"]
|
||||
files_modified:
|
||||
- modules/cabana/export_test.go
|
||||
- modules/cabana/administrators_harness_test.go
|
||||
- modules/cabana/administrators_postgres_test.go
|
||||
- modules/cabana/administrators_lifecycle_postgres_test.go
|
||||
- modules/cabana/backend_privileges_internal_test.go
|
||||
- modules/cabana/backend_screens_test.go
|
||||
- modules/cabana/action_extensions_test.go
|
||||
- modules/cabana/action_extensions_postgres_test.go
|
||||
- modules/cabana/roles_postgres_test.go
|
||||
- modules/cabana/field_trigger_test.go
|
||||
- modules/cabana/record_locks_test.go
|
||||
- admin/tests/form/trigger.test.ts
|
||||
- admin/tests/form/PermissionEditorField.test.ts
|
||||
- admin/tests/form/formState.test.ts
|
||||
- admin/tests/form/FormView.test.ts
|
||||
- admin/tests/form/PreviewView.test.ts
|
||||
- admin/tests/browser/record-actions-wrap.test.ts
|
||||
- scripts/check-phase19.6.1.sh
|
||||
- .planning/phases/19.6.1-backend-administrators-screen/19.6.1-VALIDATION.md
|
||||
autonomous: true
|
||||
requirements: [SC-1, SC-2, SC-3, D-03, D-04, D-05, D-06, D-07, D-08, D-09, D-10, D-11, D-12, D-14, D-15, D-16, D-17, D-18, D-19, D-20, D-21, D-22]
|
||||
|
||||
estimate:
|
||||
tokens: 280000
|
||||
raw_tokens: 280000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-18, D-05: TestAdministratorsSuperuserFlag proves through the assembled HTTP handler that a non-superuser with backend.manage_users sending is_superuser true or false on create or update gets 403 with the superuser_forbidden text on is_superuser and the row is unchanged, and that a superuser grants and removes the flag."
|
||||
- "D-18: the Postgres tests TestAdministratorsSuperuserHidden (D-03), TestAdministratorsSuperuserProjected (Pitfall 1), TestAdministratorsPermissionCeiling (D-06, D-07), TestAdministratorsRoleCeiling (D-07), TestAdministratorsSelfEdit (D-08), TestAdministratorsPasswordAndIdentity (D-11), TestAdministratorsOutranked (D-19) and TestAdministratorsOwnPassword (D-20) pass, each asserting status, the localized refusal text and that a refused write changed no row."
|
||||
- "D-09: TestAdministratorsLastSuperuser races superusers A and B (the only active superusers) on each reducing path (update demotion, record deactivate, bulk deactivate, form delete, bulk delete): a test transaction holds the advisory lock until pg_locks shows both requests waiting, then releases; exactly one request succeeds, the other answers 403 with the last_superuser text, exactly one of A and B stays an active superuser, and no response is a 500 or names a deadlock."
|
||||
- "D-09: TestAdministratorsLastSuperuserConcurrent runs ten unsynchronized rounds of A demoting B while B demotes A and ends every round with at least one active superuser and no 500."
|
||||
- "D-10, D-12: TestAdministratorsLifecycle covers activate, deactivate and restore as record and bulk actions, soft delete with the deleted row state and the trashed filter, and that no backend_users row disappears; TestAdministratorsRevocation shows the target's earlier JWT answering 401 after deactivate, delete and a password set by another admin, and tokens_valid_after unchanged after restore and activate."
|
||||
- "D-14, SC-3: TestActionExtensionBoot (-short) fails boot for an unknown controller, a target without a preview block, a duplicate of the owner's name, a duplicate between two contributors, a reserved name, a nil Run, an empty label and an unknown permission; TestActionExtensionRun offers a contributed action after the owner's only with the target's and the action's permissions and a true Applies, answers 409 when Applies turns false inside the transaction, and runs Run with TxFromContext and the locked record."
|
||||
- "D-16, D-17, D-07: TestRolesLifecycle (system role code change 422, delete with holders including soft-deleted ones 403 naming the count, system role delete 403, bulk delete rollback) and TestRolesPermissionCeiling (a non-superuser holding backend.manage_roles cannot add or remove a code they lack) pass."
|
||||
- "D-21: TestFieldTriggerBoot and TestPermissionRecordLocks (-short) and TestRolesCodeDeclaredLocks (Postgres) pass; the SPA tests in admin/tests/form (trigger.test.ts and the extended PermissionEditorField, formState, FormView and PreviewView tests) pass under npm --prefix admin run test, and admin/tests/browser/record-actions-wrap.test.ts passes under npm --prefix admin run test:browser."
|
||||
- "Activation, D-04: TestBackendScreensActivation shows that plugins without admin controllers activate no admin (no secret read), that with controllers the two settings links, both permission codes and both controllers exist, and that developer and publisher roles hold neither code; examples/hello and the surf admin-prefix tests stay green."
|
||||
- "D-07: TestRoleCeiling (-short) covers grantsCovered with exact codes, a holder wildcard covering exact codes, a role wildcard covered only by an equal or broader holder wildcard, and an empty role."
|
||||
- "SC-1: the existing TestAdminCreateCommand, TestAdminResetPasswordCommand, account, auth and OpenAPI conformance tests pass unchanged, and scripts/check-phase19.6.1.sh refuses when any commit since the phase base that touches a phase file also touches modules/cabana/commands.go, token_commands.go or account.go."
|
||||
- "D-18: scripts/check-phase19.6.1.sh --all exits 0, refusing a stage on any failed, skipped or zero-match test or a missing required name, and reporting NOT RUN for the Docker stage only when no Docker daemon answers (a failure when PHASE1961_REQUIRE_DOCKER=1)."
|
||||
- { statement: "An SPA browser test renders the preview footer with three owner-style actions and two contributed actions with 40-character labels at 1024px width and asserts the footer wraps without any button overlapping the Edit button (UI-SPEC long-text row for contributed action labels).", verification: backstop }
|
||||
- { statement: "An SPA unit test shows a radio permission editor with trigger {action: disable, field: is_superuser, condition: checked} read-only with the trigger note while the switch is on, editable when off, and its key absent from editablePayload while disabled.", verification: backstop }
|
||||
- { statement: "An SPA unit test shows that with is_superuser missing from the schema the triggered editor is editable and its values are sent.", verification: backstop }
|
||||
- { statement: "An SPA unit test shows that hiding every field of the active tab removes the tab, activates the first remaining tab, and shows a server error for the hidden field as a FormErrorBanner line.", verification: backstop }
|
||||
- { statement: "An SPA unit test shows a checkbox editor with meta.permissionLocks rendering the locked code checked and disabled with the record note, and permissionValues omitting it from the payload.", verification: backstop }
|
||||
artifacts:
|
||||
- path: "modules/cabana/administrators_harness_test.go"
|
||||
provides: "screens test env: seeded admins, roles, logins, calls with CSRF header, cleanup"
|
||||
contains: "func newScreensEnv("
|
||||
- path: "modules/cabana/administrators_lifecycle_postgres_test.go"
|
||||
provides: "D-09 race harness and lifecycle tests"
|
||||
contains: "TestAdministratorsLastSuperuser"
|
||||
- path: "modules/cabana/action_extensions_test.go"
|
||||
provides: "D-14 boot errors"
|
||||
contains: "TestActionExtensionBoot"
|
||||
- path: "admin/tests/form/trigger.test.ts"
|
||||
provides: "trigger evaluation and FormView payload tests"
|
||||
contains: "applyTriggers"
|
||||
- path: "scripts/check-phase19.6.1.sh"
|
||||
provides: "fail-closed phase gate"
|
||||
contains: "REQUIRE_TESTS"
|
||||
key_links:
|
||||
- from: "modules/cabana/administrators_lifecycle_postgres_test.go"
|
||||
to: "modules/cabana/export_test.go"
|
||||
via: "the race holds the same advisory lock key the guard uses"
|
||||
pattern: "SuperuserLockKey"
|
||||
- from: "scripts/check-phase19.6.1.sh"
|
||||
to: "modules/cabana/administrators_postgres_test.go"
|
||||
via: "the docker stage requires every rule test by name"
|
||||
pattern: "TestAdministratorsSelfEdit"
|
||||
- from: "admin/tests/form/trigger.test.ts"
|
||||
to: "admin/src/components/form/trigger.ts"
|
||||
via: "unit tests of triggerState and applyTriggers"
|
||||
pattern: "from '../../src/components/form/trigger'"
|
||||
prohibitions:
|
||||
- statement: "No production code changes in this plan except a fix a failing test proves necessary, which is recorded in the SUMMARY as a deviation with its own commit."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Tests seed and clean up their own uniquely named admins and roles, never depend on another test's rows, and name no consuming application."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "A Docker-backed test that could not run is reported NOT RUN, never counted as passed."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not a user story; MVP mode off): cabana gets WinterCMS's Administrators screens with the privilege rules on the server; SC-3 asks that unit tests cover the privilege rules.
|
||||
|
||||
This plan's slice (plan 3 of 3, wave 3, D-18 and D-22: unit tests are the last plan): the tests behind every rule of D-05 to D-09 (with the concurrent last-superuser case), D-12 revocation, the D-14 seam, the Roles rules, D-19, D-20 and the D-21 SPA support, plus the phase gate script. The tracer builds the shared HTTP test env and the first rule test through it; the other tasks add the rule tests by area.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Skipped for edges (no requirement IDs, no SPEC.md; visible choice). The five UI-SPEC "UI Considerations" backstop rows are authored here as `{ statement, verification: backstop }` truths, because this plan writes those tests. Prohibitions recalled by hand, descriptor-less.
|
||||
|
||||
## Decisions taken in this plan (recorded assumptions and Claude's discretion)
|
||||
|
||||
- The last-superuser refusal is reachable only under concurrency through the screens (Plan 01 Decisions), so each path is proven with a race held on the advisory lock: a test transaction takes the guard's lock, both requests block on it after their row locks (visible as two ungranted advisory rows in pg_locks), the test releases, and the second request sees the first one's commit. `cabana.SuperuserLockKey` is exposed to tests through export_test.go only.
|
||||
- D-09 tests isolate the superuser set: they record every other active superuser, deactivate them for the test and restore them in t.Cleanup (no cabana test uses t.Parallel); every test seeds uniquely named rows and soft-deletes and demotes them in t.Cleanup.
|
||||
- Tests that need the compiled registry or unexported helpers live in package cabana (`-short`, no database); HTTP-level tests live in package cabana_test and use `adminGorm`, which skips under -short.
|
||||
- The footer-wrap backstop needs real layout, so it runs in vitest browser mode (Playwright Chromium, admin/vitest.browser.config.ts), the same setup as admin/tests/browser/richeditor-display.test.ts.
|
||||
- The gate script copies the fail-closed detectors of scripts/check-phase19.5.sh (`detect_go`, `detect_vitest`, `go_json`, `go_named`) and its consuming-application name check.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Test-only exports in modules/cabana/export_test.go: `SuperuserLockKey`, and a compile helper for the screens registry if the internal tests need it from cabana_test.
|
||||
- Test env in modules/cabana/administrators_harness_test.go: `newScreensEnv(t)`, `screensEnv.seedAdmin(t, adminSeed)`, `screensEnv.seedRole(t, roleSeed)`, `screensEnv.login(t, login) string`, `screensEnv.call(t, method, rel, body, token)`, `screensEnv.isolateSuperusers(t, keep ...uint)`, the contributor test plugin with `acme_ping`.
|
||||
- Go tests: TestAdministratorsSuperuserFlag, TestAdministratorsSuperuserHidden, TestAdministratorsSuperuserProjected, TestAdministratorsPermissionCeiling, TestAdministratorsRoleCeiling, TestAdministratorsSelfEdit, TestAdministratorsPasswordAndIdentity, TestAdministratorsOutranked, TestAdministratorsOwnPassword, TestAdministratorsLastSuperuser, TestAdministratorsLastSuperuserConcurrent, TestAdministratorsLifecycle, TestAdministratorsRevocation, TestActionExtensionBoot, TestActionExtensionRun, TestRolesLifecycle, TestRolesPermissionCeiling, TestRolesCodeDeclaredLocks, TestRoleCeiling, TestBackendScreensActivation, TestFieldTriggerBoot, TestPermissionRecordLocks.
|
||||
- SPA tests: admin/tests/form/trigger.test.ts, additions to PermissionEditorField.test.ts, formState.test.ts, FormView.test.ts, PreviewView.test.ts, and admin/tests/browser/record-actions-wrap.test.ts.
|
||||
- `scripts/check-phase19.6.1.sh` with stages go, docker, admin, docs, sc1 and `--all`.
|
||||
- 19.6.1-VALIDATION.md per-task map statuses updated, Wave 0 ticked, `wave_0_complete: true`.
|
||||
|
||||
<objective>
|
||||
Write the unit and Postgres tests for every privilege rule and seam of the phase, the SPA tests for D-21 and the phase gate script, last per the project rule (D-18, D-22 plan 03).
|
||||
|
||||
Purpose: SC-2 and SC-3 (unit tests cover the privilege rules), SC-1 regression proof.
|
||||
Output: test files, the gate script, the filled validation map; test commits separate from any production fix.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-CONTEXT.md
|
||||
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-RESEARCH.md
|
||||
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-VALIDATION.md
|
||||
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-UI-SPEC.md
|
||||
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-01-SUMMARY.md
|
||||
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-02-SUMMARY.md
|
||||
|
||||
Concurrency and commits: as in Plans 01 and 02. Docker-backed tests skip under -short; run them on this host, through `sudo -u golem` if the Docker socket is denied (CLAUDE.md); a test that could not run is NOT RUN in the SUMMARY. A test that fails because the code is wrong gets a production fix in its own `fix(cabana): ...` commit, named in the SUMMARY as a deviation; never weaken an assertion to pass.
|
||||
|
||||
<interfaces>
|
||||
- From Plans 01 and 02 (their SUMMARYs list the names as built): `cabana.PermissionManageUsers`, `PermissionManageRoles`, `AdministratorsControllerID`, `RolesControllerID`; pact `AdminActionExtension`, `HasAdminActionExtensions`; unexported `withBackendScreens`, `compileActionExtensions`, `grantsCovered`, `guardLastSuperuser`, `superuserLockKey`, `compileTrigger`, `checkTriggers`, `applyPermissionValues`; `cabana.PermissionRecordLock`, `PermissionRecordLockProvider`, `RecordMeta.PermissionLocks`, `FieldTrigger`, `FormField.Trigger`; smoke tests in backend_screens_test.go, backend_screens_smoke_test.go, record_locks_smoke_test.go, field_trigger_smoke_test.go; admin routes `/summercms/cabana/users` and `/summercms/cabana/roles` (list, `{id}`, `{id}/actions/{action}`, `bulk/{action}`, `bulk-delete`, `schema/form`, `schema/list`), `/settings`, `/auth/me`, `/auth/login`.
|
||||
- Test helpers (package cabana_test): `adminGorm(t)` auth_test.go:321, `insertAdmin` :403, `demoPlugin`, `postJSON`, `adminAPI`, `accessToken`, `adminTestPassword`, `adminTestSecret`, `adminSQL`; env and call pattern phase101_actions_test.go:225-346 (`X-Requested-With: XMLHttpRequest` on writes, `cabana.Envelope[T]`); internal fixtures (package cabana) `contributionPlugin`, `metadataRegistry` settings_link_test.go; internal service handlers called with `bouncer.WithUser` (settings_link_test.go:150-200); export_test.go pattern (`CompiledListSchema`).
|
||||
- Race tools: `pg_locks` (locktype 'advisory', granted false), `SELECT pg_advisory_xact_lock(?)` in a `gdb.Begin()` transaction, released by Rollback.
|
||||
- SPA tests: admin/tests/form/PermissionEditorField.test.ts, formState.test.ts, FormView.test.ts, PreviewView.test.ts, RecordActions.test.ts, DependentDropdown.test.ts (sibling evaluation), admin/tests/browser/richeditor-display.test.ts and admin/vitest.browser.config.ts, fixtures under admin/tests/fixtures/.
|
||||
- Gate analog: scripts/check-phase19.5.sh (`detect_go` :189, `detect_vitest` :239, `go_json` :261, `go_named` :288, `run_docs` :721 with APP_NAMES).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: the screens test env seeds admins and roles, signs in, and proves the first privilege rule (D-05) end to end through the assembled admin handler</name>
|
||||
<reversibility rating="reversible">Test-only code.</reversibility>
|
||||
<files>modules/cabana/export_test.go, modules/cabana/administrators_harness_test.go, modules/cabana/administrators_postgres_test.go</files>
|
||||
<read_first>.planning/phases/19.6.1-backend-administrators-screen/19.6.1-01-SUMMARY.md, .planning/phases/19.6.1-backend-administrators-screen/19.6.1-02-SUMMARY.md, modules/cabana/backend_screens_smoke_test.go, modules/cabana/auth_test.go (:300-440), modules/cabana/phase101_actions_test.go (:225-346), modules/cabana/export_test.go, modules/cabana/backend_users_admin.go, modules/cabana/backend_privileges.go, 19.6.1-UI-SPEC.md (C8), 19.6.1-RESEARCH.md (Validation Architecture)</read_first>
|
||||
<action>Per D-18 and D-05.
|
||||
|
||||
export_test.go: `const SuperuserLockKey = superuserLockKey` with a comment (the race tests hold the guard's lock); add a compile helper only if a cabana_test test needs the screens registry.
|
||||
|
||||
administrators_harness_test.go (package cabana_test): `newScreensEnv(t)` assembles like newActEnv (compass config with the admin secret and body limits, lagoon.Publish, phrasebook.Activate, surf.Assemble) with demoPlugin plus a contributor plugin (its own permission code acme.screens.ping and a role grant of that code to nothing) that adds record action acme_ping to AdministratorsControllerID and records each Run's RecordID and whether cabana.TxFromContext held a transaction. `seedAdmin(t, adminSeed{Superuser, Activated, Deleted, RoleCode, RolePermissions map, Permissions map, Password})` inserts with a unique login and email from a nanosecond stamp (a seeded role per call when RolePermissions is set, else the developer role), and registers t.Cleanup that sets is_superuser false and soft-deletes the row through SQL. `seedRole(t, roleSeed{Name, Code, Permissions, System})` likewise. `login(t, login) string` posts /auth/login and returns the bearer token. `call(t, method, rel, body, token)` sends JSON with Accept-Language en and X-Requested-With on writes. `isolateSuperusers(t, keep ...uint)` deactivates every other active superuser and restores them in t.Cleanup. Small decoders for the data envelope and the error envelope (code, message, details).
|
||||
|
||||
administrators_postgres_test.go: `TestAdministratorsSuperuserFlag` (D-05): a superuser S and a delegated admin L (role grants backend.manage_users); L creating an admin with is_superuser true answers 403, message and details.is_superuser carry the en superuser_forbidden text, and no row with that login exists; L updating a non-superuser N with is_superuser false answers 403 and N's row is byte-for-byte unchanged (compare updated_at and the columns); S creates an admin with is_superuser true (201, row flag true) and then removes it from another superuser through update (200, flag false).</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/cabana && go test -count=1 -short ./modules/cabana && go test -count=1 -run '^TestAdministratorsSuperuserFlag$' -v ./modules/cabana 2>&1 | grep -q -- '^--- PASS: TestAdministratorsSuperuserFlag'</automated>
|
||||
<fails_when>Non-zero exit from vet or the short suite, or no "--- PASS: TestAdministratorsSuperuserFlag" line (failed, or "--- SKIP" because Docker was unreachable: rerun through sudo -u golem, else NOT RUN).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'SuperuserLockKey' modules/cabana/export_test.go` prints at least 1.
|
||||
- `grep -c 'func newScreensEnv(' modules/cabana/administrators_harness_test.go` prints 1 and `grep -cE 'func \([a-z]+ \*screensEnv\) isolateSuperusers\(' modules/cabana/administrators_harness_test.go` prints 1.
|
||||
- `grep -c 't.Cleanup(' modules/cabana/administrators_harness_test.go` prints at least 2.
|
||||
- `grep -ciE 'fonoteka|plytarium|grzyby|wavepath' modules/cabana/administrators_harness_test.go modules/cabana/administrators_postgres_test.go` prints 0 for each file.
|
||||
</acceptance_criteria>
|
||||
<done>One rule is proven over real HTTP against Postgres, and every later test reuses the same env.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Save-path rule tests: superuser hiding and projection, permission and role ceilings, self-edit, passwords and identity, outranking and own password</name>
|
||||
<reversibility rating="reversible">Test-only code.</reversibility>
|
||||
<files>modules/cabana/administrators_postgres_test.go, modules/cabana/backend_privileges_internal_test.go</files>
|
||||
<read_first>modules/cabana/administrators_harness_test.go, modules/cabana/administrators_postgres_test.go (from Task 1), modules/cabana/backend_users_admin.go, modules/cabana/backend_privileges.go, modules/cabana/contracts.go (granted :214-248), modules/cabana/field_permission.go, modules/cabana/relation_field.go (RelationLock), 19.6.1-UI-SPEC.md (C3, C8), 19.6.1-RESEARCH.md (Pattern 5, Pitfalls 1, 2, 4, 9)</read_first>
|
||||
<behavior>
|
||||
- TestAdministratorsSuperuserHidden (D-03): for delegated admin L the list (also with search on the superuser's login and with filters) never contains superuser S; GET, the hint partial and a record action on S's id answer 404; schema/form has no is_superuser field and schema/list no is_superuser column or filter; for S all three exist.
|
||||
- TestAdministratorsSuperuserProjected (Pitfall 1): S's GET of another superuser returns data.is_superuser true; PUT of exactly the editable payload of that record (as the SPA builds it) answers 200 and the flag stays true.
|
||||
- TestAdministratorsPermissionCeiling (D-06, D-07): L (holding backend.manage_users and acme.screens.ping only) sees every other catalog code Locked in schema/form; setting a locked code to 1 or -1 on N answers 403 on permissions and N's stored JSON is unchanged; a stored locked value N already has is kept when L saves other fields; allowing or denying acme.screens.ping on N saves and FindByID-equivalent GET /auth/me as N reflects it (the override merged last).
|
||||
- TestAdministratorsRoleCeiling (D-07): roles whose grants L does not cover are locked in the role field options; assigning one to N answers 403 with the role_ceiling text; N holding an above-ceiling role saves a name change unchanged in role; assigning a covered role saves.
|
||||
- TestAdministratorsSelfEdit (D-08): L edits own first name, login and email (200); own role change answers 403 self_privileged and the role is unchanged (the after-assignment path); own is_superuser for S answers 403; own permissions change answers 403; own delete answers 403 self_delete; own bulk deactivate answers 403 self_deactivate; the record action deactivate is not offered on your own preview.
|
||||
- TestAdministratorsPasswordAndIdentity (D-11): create without password, without confirmation or with a mismatch answers 422 on password; 3 characters answers 422; 73 bytes answers 422 with the max text; a valid create stores a bcrypt hash that bouncer verifies; update with a blank password keeps the hash; a login or email equal to another admin's, in another case, or held by a soft-deleted admin answers 422 on that field with login_taken, email_taken or the _deleted variants.
|
||||
- TestAdministratorsOutranked (D-19): L acting on M whose role grants a code L lacks gets 403 outranked on update (also a password set), delete, record activate, deactivate and restore, and bulk actions, and M's row is unchanged; acting on N with covered grants passes.
|
||||
- TestAdministratorsOwnPassword (D-20): L or S sending a password for their own record answers 403 own_password on password and the hash is unchanged; another admin's password set by S answers 200.
|
||||
- TestRoleCeiling (package cabana, -short): grantsCovered with exact codes, a holder wildcard covering exact codes, a role wildcard covered by the same or a broader holder wildcard and not by exact codes, a suffix wildcard, an empty role, a nil holder.
|
||||
</behavior>
|
||||
<action>Per D-03, D-05..D-08, D-11, D-19, D-20 and RESEARCH Pitfalls 1, 2, 4, 9. Write the behaviours above as table-driven Go tests where the cases share setup, every refusal asserting the HTTP status, the error code, the en text of the phrase key (read from the translator or compared to the UI-SPEC English value) in message or details as UI-SPEC C8 places it, and the unchanged row (re-read the columns). Seed through the env helpers, one set of rows per test. backend_privileges_internal_test.go holds TestRoleCeiling against grantsCovered directly. These tests describe Plan 01's built behaviour; when one fails, fix the code in a separate fix commit and record it.</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/cabana && go test -count=1 -short -run '^TestRoleCeiling$' -v ./modules/cabana 2>&1 | grep -q -- '^--- PASS: TestRoleCeiling'</automated>
|
||||
<fails_when>Non-zero exit from vet or no "--- PASS: TestRoleCeiling" line.</fails_when>
|
||||
<automated>go test -count=1 -run '^(TestAdministratorsSuperuserFlag|TestAdministratorsSuperuserHidden|TestAdministratorsSuperuserProjected|TestAdministratorsPermissionCeiling|TestAdministratorsRoleCeiling|TestAdministratorsSelfEdit|TestAdministratorsPasswordAndIdentity|TestAdministratorsOutranked|TestAdministratorsOwnPassword)$' -v ./modules/cabana 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 9</automated>
|
||||
<fails_when>Fewer than 9 top-level "--- PASS: Test" lines: a rule test failed, was renamed, or skipped because Docker was unreachable (rerun through sudo -u golem, else NOT RUN).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -cE '^func Test(AdministratorsSuperuserHidden|AdministratorsSuperuserProjected|AdministratorsPermissionCeiling|AdministratorsRoleCeiling|AdministratorsSelfEdit|AdministratorsPasswordAndIdentity|AdministratorsOutranked|AdministratorsOwnPassword)\(' modules/cabana/administrators_postgres_test.go` prints 8.
|
||||
- `grep -c '^func TestRoleCeiling(' modules/cabana/backend_privileges_internal_test.go` prints 1 and `head -1 modules/cabana/backend_privileges_internal_test.go` prints `package cabana`.
|
||||
- `grep -c 'own_password\|Change your own password' modules/cabana/administrators_postgres_test.go` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>Every save-path rule has a test that fails when the rule is removed.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Lifecycle, last-superuser race, revocation, seam and Roles tests</name>
|
||||
<reversibility rating="reversible">Test-only code.</reversibility>
|
||||
<files>modules/cabana/administrators_lifecycle_postgres_test.go, modules/cabana/action_extensions_test.go, modules/cabana/action_extensions_postgres_test.go, modules/cabana/roles_postgres_test.go, modules/cabana/backend_screens_test.go</files>
|
||||
<read_first>modules/cabana/administrators_harness_test.go, modules/cabana/backend_screens_test.go, modules/cabana/action_extensions.go, modules/cabana/backend_users_admin.go, modules/cabana/backend_roles_admin.go, modules/cabana/backend_privileges.go, modules/cabana/crud.go (:340-380, :586-640), modules/cabana/settings_link_test.go, modules/cabana/phase121_schema_boot_test.go, modules/cabana/example_actions_test.go, modules/surf/admin_prefix_test.go (:180-200), 19.6.1-RESEARCH.md (Pattern 2, Pattern 4, Pitfalls 5, 7)</read_first>
|
||||
<behavior>
|
||||
- TestAdministratorsLastSuperuser (D-09), subtests demote_update, deactivate_record, deactivate_bulk, delete_form, delete_bulk: isolateSuperusers keeping fresh A and B; a test tx holds pg_advisory_xact_lock(cabana.SuperuserLockKey); A acts on B and B acts on A concurrently; poll pg_locks until two ungranted advisory rows exist (fail after 10 s); roll the test tx back; exactly one 2xx and one 403 with the last_superuser text; exactly one of A and B is an active, non-deleted superuser afterwards; no 500 and no "deadlock" in any body.
|
||||
- TestAdministratorsLastSuperuserConcurrent (D-09): ten rounds of unsynchronized A demotes B and B demotes A; each round ends with at least one active superuser and no 500.
|
||||
- TestAdministratorsLifecycle (D-10): record and bulk activate, deactivate and restore with Applies (activate offered only when inactive and not deleted, restore only when deleted, deactivate not on yourself); bulk skips rows that do not apply and reports Affected; soft delete keeps the row (count of backend_users unchanged) with row state deleted; the trashed filter options hide and only narrow the list; the preview hint follows deleted > deactivated > own account.
|
||||
- TestAdministratorsRevocation (D-12): the target's JWT issued before the action answers 401 on /auth/me after deactivate (record and bulk), after delete, and after a password set by S; the target's personal API token stops after deactivate and delete; tokens_valid_after is unchanged after restore and activate.
|
||||
- TestActionExtensionBoot (package cabana, -short): compile errors for an unknown controller, a target form without preview, a duplicate of the owner's record action, a duplicate between two contributors (message names both plugins), create or delete as a name, a nil Run, an empty label, a permission no plugin registers; a valid contribution lands after the owner's names in declared order across two contributors.
|
||||
- TestActionExtensionRun (Postgres): acme_ping is offered on an admin's preview only to principals holding both backend.manage_users and acme.screens.ping; a principal with only acme.screens.ping gets 403 from the route; with Applies flipped to false between show and run the POST answers 409; Run saw the RecordID and a transaction.
|
||||
- TestRolesLifecycle (D-16, D-17): create with a slugged code; duplicate code answers 422 code_taken; changing developer's code answers 422 code_locked; a role a plugin's code-declared grant attaches to counts as system; delete with one active and one soft-deleted holder answers 403 whose message names the role and 2; delete of developer answers 403 delete_system; bulk delete of an unused role plus a held one answers 403 and both rows remain.
|
||||
- TestRolesPermissionCeiling (D-07): a principal holding backend.manage_roles and acme.screens.ping sees other codes Locked on the Roles form, adding or removing a locked code answers 403 and the role JSON is unchanged, toggling acme.screens.ping saves.
|
||||
- TestBackendScreensActivation (package cabana, -short): no admin controllers → Activate returns nil routes and nil error without admin.jwt.secret set; with a controller plugin → the registry holds both controllers, both settings links (administrators before roles), both permission codes with no role grants, and no navigation entry from summercms.cabana.
|
||||
</behavior>
|
||||
<action>Per D-04, D-07, D-09, D-10, D-12, D-14, D-15, D-16, D-17 and SC-3. Write the behaviours above. The race helper lives in administrators_lifecycle_postgres_test.go and takes a func(actor, target) performing one path, so the five subtests share it; wait on pg_locks with `SELECT count(*) FROM pg_locks WHERE locktype = 'advisory' AND NOT granted` every 20 ms. Personal API tokens for the revocation test are minted the way token_postgres_smoke_test.go does. Boot tests reuse the internal fixtures and assert error substrings (controller ID, action name, plugin IDs). When a test fails because the code is wrong, fix the code in a separate fix commit and record it.</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/cabana && go test -count=1 -short -run '^(TestActionExtensionBoot|TestBackendScreensActivation|TestBackendScreensSmoke)$' -v ./modules/cabana 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>Non-zero exit from vet, or fewer than 3 top-level "--- PASS: Test" lines among the Docker-free seam and activation tests.</fails_when>
|
||||
<automated>go test -count=1 -run '^(TestAdministratorsLastSuperuser|TestAdministratorsLastSuperuserConcurrent|TestAdministratorsLifecycle|TestAdministratorsRevocation|TestActionExtensionRun|TestRolesLifecycle|TestRolesPermissionCeiling)$' -v ./modules/cabana 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 7</automated>
|
||||
<fails_when>Fewer than 7 top-level "--- PASS: Test" lines: a test failed (including the race timing out waiting for two lock waiters), was renamed, or skipped because Docker was unreachable (rerun through sudo -u golem, else NOT RUN).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'SuperuserLockKey' modules/cabana/administrators_lifecycle_postgres_test.go` prints at least 1 and `grep -c "locktype = 'advisory'" modules/cabana/administrators_lifecycle_postgres_test.go` prints at least 1.
|
||||
- `grep -cE 't\.Run\("(demote_update|deactivate_record|deactivate_bulk|delete_form|delete_bulk)"' modules/cabana/administrators_lifecycle_postgres_test.go` prints 5.
|
||||
- `grep -c '^func TestActionExtensionBoot(' modules/cabana/action_extensions_test.go` prints 1 and `head -1 modules/cabana/action_extensions_test.go` prints `package cabana`.
|
||||
- `grep -cE '^func Test(RolesLifecycle|RolesPermissionCeiling)\(' modules/cabana/roles_postgres_test.go` prints 2.
|
||||
- `grep -c 't.Parallel()' modules/cabana/administrators_lifecycle_postgres_test.go` prints 0.
|
||||
</acceptance_criteria>
|
||||
<done>The concurrent last-superuser case, every lifecycle path, revocation, the seam and the Roles rules are proven.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 4: D-21 tests (Go and SPA, including the backstops), the phase gate script and the validation map</name>
|
||||
<reversibility rating="reversible">Tests, a check script and planning metadata.</reversibility>
|
||||
<files>modules/cabana/field_trigger_test.go, modules/cabana/record_locks_test.go, modules/cabana/roles_postgres_test.go, admin/tests/form/trigger.test.ts, admin/tests/form/PermissionEditorField.test.ts, admin/tests/form/formState.test.ts, admin/tests/form/FormView.test.ts, admin/tests/form/PreviewView.test.ts, admin/tests/browser/record-actions-wrap.test.ts, scripts/check-phase19.6.1.sh, .planning/phases/19.6.1-backend-administrators-screen/19.6.1-VALIDATION.md</files>
|
||||
<read_first>.planning/phases/19.6.1-backend-administrators-screen/19.6.1-02-SUMMARY.md, modules/cabana/field_trigger.go, modules/cabana/field_trigger_smoke_test.go, modules/cabana/record_locks_smoke_test.go, modules/cabana/field_permission.go, admin/src/components/form/trigger.ts, admin/src/components/form/fields/PermissionEditorField.vue, admin/src/components/form/formState.ts, admin/src/components/form/control.ts, admin/src/views/FormView.vue, admin/src/views/PreviewView.vue, admin/src/components/form/RecordActions.vue, admin/tests/form/PermissionEditorField.test.ts, admin/tests/form/formState.test.ts, admin/tests/form/FormView.test.ts, admin/tests/form/PreviewView.test.ts, admin/tests/form/RecordActions.test.ts, admin/tests/browser/richeditor-display.test.ts, admin/vitest.browser.config.ts, scripts/check-phase19.5.sh, .planning/phases/19.6.1-backend-administrators-screen/19.6.1-VALIDATION.md, 19.6.1-UI-SPEC.md (C6, C7, UI Considerations backstop rows)</read_first>
|
||||
<behavior>
|
||||
- TestFieldTriggerBoot (package cabana, -short): every refusal rule of compileTrigger and checkTriggers (missing or extra sub-key, unknown action or condition, unknown, self or non-toggle controlling field, trigger in a repeater row, a settings form and a relation form), each error naming field and key; valid disable and hide triggers reach the localized schema.
|
||||
- TestPermissionRecordLocks (package cabana, -short): the save rule (absent keeps stored, equal passes, different answers 403 on the field, request-locked plus record-locked absent passes) for radio and checkbox modes; meta omits permissionLocks without a provider and on create.
|
||||
- TestRolesCodeDeclaredLocks (Postgres): with a test plugin declaring acme.screens.report for the developer role, GET of developer carries meta.permissionLocks.permissions with acme.screens.report value 1 and the en locked_by_code note; saving developer's description with the SPA payload (which omits the code) answers 200; the delegated admin's own record carries every offered code record-locked with the en locked_self note.
|
||||
- trigger.test.ts: triggerState for checked and unchecked against true, 1, "1", "true", false and missing values; a controlling field absent from present counts as off; applyTriggers disable makes the field readOnly with the note, hide removes it.
|
||||
- FormView.test.ts additions: disable backstop (switch on → editor read-only with the data-trigger-note line, key absent from the save body; switch off → editable and sent); absent-controller backstop (schema without is_superuser → editable and sent); hide backstop (all fields of the active tab hidden → tab removed, first tab active, a 422 detail for the hidden field shown in FormErrorBanner); announce text on disable.
|
||||
- PermissionEditorField.test.ts and formState.test.ts additions: record-lock backstop (checkbox editor with permissionLocks shows the code checked and disabled with data-locked-by="record" and the record note; permissionValues and editablePayload omit it); a radio lock shows the lock value; record lock wins over request lock; read-only screen shows no icon or note.
|
||||
- PreviewView.test.ts addition: hide trigger removes the field on the preview; disable shows no note.
|
||||
- record-actions-wrap.test.ts (browser): the footer backstop at 1024px with three owner actions and two contributed actions labelled with 40 characters: the buttons wrap onto more than one row and no button's bounding box intersects the Edit button's.
|
||||
</behavior>
|
||||
<action>Per D-21, D-18 and UI-SPEC C6, C7 and the backstop rows. Write the behaviours above (Go tests table-driven; SPA tests with @vue/test-utils and fixtures shaped like admin/tests/fixtures/*.form-schema.json; the browser test mounts RecordActions inside the preview footer markup at a 1024px viewport).
|
||||
|
||||
scripts/check-phase19.6.1.sh (executable, `set -euo pipefail`, header comment listing stages like check-phase19.5.sh): copy detect_go, detect_vitest, go_json and go_named from scripts/check-phase19.5.sh. Stages: `go` (go vet ./..., go test -short ./..., go -C examples/hello test ./..., and go_named with -short for TestBackendScreensSmoke, TestBackendScreensActivation, TestActionExtensionBoot, TestRoleCeiling, TestFieldTriggerBoot, TestFieldTriggerSmoke, TestPermissionRecordLocks, TestPermissionRecordLocksSmoke); `docker` (go_named without -short for every Postgres test of Plans 01 and 03 by name, plus go test ./modules/surf; NOT RUN when `docker info` fails, a failure when PHASE1961_REQUIRE_DOCKER=1); `admin` (npm --prefix admin run typecheck, the vitest run through detect_vitest requiring tests/form/trigger.test.ts, PermissionEditorField.test.ts, formState.test.ts, FormView.test.ts and PreviewView.test.ts, npm --prefix admin run test:browser requiring record-actions-wrap.test.ts, scripts/check-admin-openapi.sh --check, scripts/check-admin-dist.sh); `docs` (go_named TestDocsTree in ./cmd/summer, go run ./cmd/summer docs:build --check, and the consuming-application name check of check-phase19.5.sh over modules/ and docs/); `sc1` (PHASE1961_BASE, default the parent of the first commit that added modules/cabana/backend_screens.go: refuse when a commit in BASE..HEAD touches modules/cabana/commands.go, token_commands.go or account.go and also a phase file: action_extensions.go, backend_screens.go, backend_users_admin.go, backend_roles_admin.go, backend_privileges.go, field_trigger.go); `--all` runs go, admin, docs, sc1, then docker. Each stage prints one pass line; any refusal exits non-zero.
|
||||
|
||||
19.6.1-VALIDATION.md: the planner already filled the per-task verification map; update its File Exists and Status columns from the runs, correct any test or file name that changed during execution, tick the Wave 0 list with the file names as built, and set `wave_0_complete: true`; leave `status` and `nyquist_compliant` for validate-phase. Commit tests (`test(cabana): ...`, `test(admin): ...`), the script (`chore(scripts): check-phase19.6.1 gate`) and the planning doc (`docs(19.6.1): validation map`) separately.</action>
|
||||
<verify>
|
||||
<automated>go test -count=1 -short -run '^(TestFieldTriggerBoot|TestPermissionRecordLocks)$' -v ./modules/cabana 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 2 && npm --prefix admin run test && npm --prefix admin run test:browser</automated>
|
||||
<fails_when>Fewer than 2 top-level "--- PASS: Test" lines, or a non-zero exit from the vitest run or the browser run (a failed, missing or skipped SPA test).</fails_when>
|
||||
<automated>bash scripts/check-phase19.6.1.sh --all</automated>
|
||||
<fails_when>Non-zero exit or a line starting "refuse:"; a "NOT RUN docker" line is acceptable only when no Docker daemon answers and must be recorded in the SUMMARY.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `test -x scripts/check-phase19.6.1.sh` exits 0 and `grep -c 'REQUIRE_TESTS' scripts/check-phase19.6.1.sh` prints at least 1.
|
||||
- `grep -c 'TestAdministratorsLastSuperuserConcurrent' scripts/check-phase19.6.1.sh` prints at least 1 and `grep -c 'PHASE1961_REQUIRE_DOCKER' scripts/check-phase19.6.1.sh` prints at least 1.
|
||||
- `grep -c "from '../../src/components/form/trigger'" admin/tests/form/trigger.test.ts` prints 1.
|
||||
- `grep -c 'data-locked-by' admin/tests/form/PermissionEditorField.test.ts` prints at least 1 and `grep -c '1024' admin/tests/browser/record-actions-wrap.test.ts` prints at least 1.
|
||||
- `grep -c '^wave_0_complete: true' .planning/phases/19.6.1-backend-administrators-screen/19.6.1-VALIDATION.md` prints 1.
|
||||
</acceptance_criteria>
|
||||
<done>D-21 and every backstop have tests, and one script proves the whole phase fail-closed.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| test suite → privilege rules | A rule without a test can regress silently |
|
||||
| shared Postgres container → tests | Rows from one test can change another test's outcome |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.6.1-24 | Repudiation | privilege regressions (D-05..D-09, D-19, D-20) going unnoticed | high | mitigate | one named test per rule asserting status, text and unchanged rows; the gate requires every name and refuses skipped or zero-match runs |
|
||||
| T-19.6.1-25 | Denial of service | a deadlock in the last-superuser guard hidden by a flaky race | high | mitigate | the held-lock race waits for two pg_locks waiters before releasing, so the interleaving is forced on every path; the unsynchronized rounds assert no 500 or deadlock |
|
||||
| T-19.6.1-26 | Tampering | cross-test interference through shared superuser rows | medium | mitigate | isolateSuperusers plus per-test unique rows and cleanup; no t.Parallel in the package |
|
||||
| T-19.6.1-27 | Information disclosure | tests or fixtures naming consuming applications | low | mitigate | the gate's docs stage name check and the acceptance greps |
|
||||
| T-19.6.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package is added; vitest browser mode uses the Playwright already in the lockfile; any new package would need the legitimacy gate and a blocking human checkpoint |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `go test -short ./modules/cabana` and the named Docker-free tests after every task; the named Postgres tests per task with Docker.
|
||||
- `npm --prefix admin run test` and `test:browser` after Task 4.
|
||||
- `bash scripts/check-phase19.6.1.sh --all` as the phase gate (also the input for verify-work).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Every decision with a server rule (D-03..D-12, D-14, D-16, D-17, D-19, D-20, D-21) has at least one named test that fails without the rule.
|
||||
- The concurrent last-superuser case is proven on all five reducing paths.
|
||||
- The gate script passes on this host with Docker, and reports NOT RUN honestly without it.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.6.1-backend-administrators-screen/19.6.1-03-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,188 @@
|
||||
# Phase 19.6.1: Backend Administrators screen - Context
|
||||
|
||||
**Gathered:** 2026-10-21
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
cabana ships its own **Administrators** and **Roles** admin screens, in the style of WinterCMS `Backend\Controllers\Users` and `UserRoles`. Backend admins and roles can then be managed in the admin SPA, and not only through the `admin:*` console commands.
|
||||
|
||||
The screens:
|
||||
- are built on cabana's existing generic CRUD (list, form and preview renderer), the `permissioneditor` field and record actions;
|
||||
- enforce the privilege rules on the server;
|
||||
- expose a generic seam through which plugins add record actions to a controller they don't own. 19.7's frontend-link action is the first consumer (19.7 D-34).
|
||||
|
||||
The phase is kept short (1-2 plans). Existing cabana payloads and the `admin:*` commands stay unchanged. Only `summercms.go` is touched.
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Permissions and placement
|
||||
- **D-01:** The screens are guarded by **two new permission codes**:
|
||||
- `backend.manage_users` for Administrators (Winter's name is kept);
|
||||
- `backend.manage_roles` for Roles.
|
||||
|
||||
Roles change what every holder can do, so they get their own code. These are the first permissions cabana itself registers, through an internal pseudo-plugin (see Code Insights). — **Reversibility:** costly — the permission codes end up stored in `backend_user_roles.permissions` and `backend_users.permissions` JSON on every install that grants them.
|
||||
- **D-02:** **Placement: Settings → System section**, as in Winter, through `pact.HasSettings` entries that link into the controllers. No top-level navigation item.
|
||||
- **D-03:** **Superusers are hidden from non-superusers**, as in Winter:
|
||||
- `ListExtendQuery` and `FormExtendQuery` filter `is_superuser` rows out;
|
||||
- the `is_superuser` field and filter are removed for non-superusers;
|
||||
- opening a superuser record by ID answers 404 or 403.
|
||||
- **D-04:** **No default role grant.** Neither code is added to the `developer` or `publisher` system roles through code-declared `Roles`. Only superusers reach the screens until someone grants the codes, so existing installs don't widen silently on upgrade.
|
||||
|
||||
### Privilege rules (server-side, all enforced in the save transaction)
|
||||
- **D-05:** **Only a superuser can create or edit a superuser, or set `is_superuser`** (roadmap SC-2). A non-superuser sending `is_superuser` is answered 403, even if the field is hidden.
|
||||
- **D-06:** **The per-user permission override is editable**: a permissions tab on the Administrators form with `permissioneditor` mode **radio** (inherit / allow / deny), as in Winter. It is disabled when `is_superuser` is on. The column and its merge (`applyUserPermissions`, merged last) already exist.
|
||||
- **D-07:** **A non-superuser can't grant more than they hold**, and this covers both permissions and role assignment.
|
||||
- **Permission editors**, on both the per-user tab and the Roles screen: an option the editor doesn't hold is `PermissionOption.Locked`, and its stored value is kept on save (Winter's `getSaveValueSecure`). Changing a locked value is answered 403, the existing behaviour.
|
||||
- **Role picker**: it offers only roles whose merged grants (stored JSON plus code-declared grants) are a subset of the editor's own permissions. Assigning any other role is refused on the server.
|
||||
- **Roles screen**: a non-superuser can't add a permission they lack to any role.
|
||||
- **D-08:** **Editing your own record is allowed, but privileged fields are read-only.**
|
||||
- The admin can edit their profile fields on the Administrators screen.
|
||||
- The server refuses, for the editor's own record, any change to `role_id`, `is_superuser`, `permissions` or `is_activated`, and refuses deleting yourself.
|
||||
- This covers "an admin cannot raise their own role or permissions" (SC-2) in one place. There is no redirect to My Account.
|
||||
- **D-09:** **The last active superuser is protected on every write path.** It can't be deactivated, demoted (`is_superuser` off), soft-deleted, or turned inactive by any route. The check covers:
|
||||
- form update and delete;
|
||||
- bulk actions, including bulk deactivate and bulk delete;
|
||||
- record actions;
|
||||
- any `admin:*` command path that could clear the flag.
|
||||
|
||||
It runs inside the save transaction under a lock (for example a row lock on the active superuser set, or an advisory lock), so two concurrent demotions can't both pass. Today's `admin:*` commands don't demote, so their behaviour and output stay unchanged (SC-1).
|
||||
|
||||
### Lifecycle and passwords
|
||||
- **D-10:** **Deactivate + soft delete + restore.**
|
||||
- Activate and Deactivate are record and bulk actions that toggle `is_activated`.
|
||||
- Delete is a soft delete through the existing `deleted_at`.
|
||||
- A trashed filter lists deleted admins, and a Restore action brings them back.
|
||||
- No hard delete, so FK references (tokens, `linked_by`, attachments) stay valid.
|
||||
- **D-11:** **Passwords are typed in the form.** Password and confirmation fields are required on create and optional on update, and blank means unchanged. The value is bcrypt-hashed like `admin:create`. There is no "send reset link" action and no force-change-on-next-login in this phase.
|
||||
- **D-12:** **Changes cut access immediately.** Deactivate, soft delete, and setting a password for another admin all bump `tokens_valid_after`, as `admin:reset-password` does. Existing JWTs then stop on the next request. Deactivate and soft delete also stop personal API tokens, because `FindByID` already refuses inactive or deleted users. A password set for another admin leaves their personal API tokens working, the same as `admin:reset-password` (confirmed 2026-10-22 at plan check). Restore and activate don't bump it.
|
||||
- **D-13:** **No invite or welcome mail** on create in this phase. Winter's `send_invite` is deferred.
|
||||
|
||||
### Record-action seam
|
||||
- **D-14:** **A generic cross-controller action extension.** It is a new optional `pact` capability through which a plugin contributes `AdminRecordAction`s to any admin controller by controller ID, including cabana's Administrators controller and other plugins' controllers.
|
||||
- It may also cover toolbar and bulk actions if the shape allows it at no extra cost (planner's call).
|
||||
- Contributions merge at boot. A duplicate action name on a controller, or an unknown controller ID, is a boot error.
|
||||
- Each action keeps its own `Permissions`, `Applies` and `Confirm`. The existing row-locked transactional run path (`crud.go` `RecordAction`) runs it.
|
||||
- The seam is additive and names neither MCP nor the user plugin. It is the seam 19.7 D-04/D-34 consumes. The researcher checks whether 19.6 D-15 ("Reset 2FA" contributed by sm-mfa-plugin to the user plugin's Users screen) can use it as is.
|
||||
|
||||
— **Reversibility:** costly — this is a published `pact` capability that plugins (19.7's link action, sm-mfa-plugin) implement.
|
||||
- **D-15:** **Contributed actions appear on the preview screen only**, through the existing `RecordActions.vue` on `PreviewView`. The Administrators controller therefore gets a preview block. There are no per-row actions on the list and no list payload changes.
|
||||
|
||||
### Roles screen
|
||||
- **D-16:** **Fields and system roles follow Winter.**
|
||||
- Fields: name, code (slugged from name), description, and a permissions tab using `permissioneditor` mode **checkbox**.
|
||||
- System roles (`is_system`, or roles that code-declared `pact.Permission.Roles` grants attach to) have their code locked, and their code-declared grants are shown as locked.
|
||||
- Name, description and any extra stored grants stay editable, within the D-07 ceiling.
|
||||
- **D-17:** **A role that still has admins can't be deleted.** The delete returns a validation error that names how many admins hold the role. System roles can't be deleted.
|
||||
|
||||
### Quality gates
|
||||
- **D-18:** Unit tests are the last plan (project rule). They cover every privilege rule in D-05 to D-09, including the concurrent last-superuser case, plus D-12 revocation and the D-14 seam (boot errors, permission filtering, run path). Docs: the cabana README and the affected `docs/` pages are updated in the same commits as the API, config and permission changes. If `admin/` changes, `check-admin-openapi.sh` and the committed `modules/boardwalk/dist` gate (`check-admin-dist.sh`) apply.
|
||||
|
||||
### Post-research decisions (2026-10-21, plan-phase)
|
||||
- **D-19:** **No outranking.** A non-superuser with the manage-administrators permission is refused (403) when updating, deleting or running record/bulk actions on a non-superuser admin whose effective role + permissions grant more than the caller holds (closes the password-set account-takeover path).
|
||||
- **D-20:** **Own password is not set on this screen.** Sending a password for your own record on the Administrators form is refused; the error points to My Account, which checks the current password.
|
||||
- **D-21:** **SPA support is in scope.** D-06 (permission override disabled while `is_superuser` is on) gets a real field `trigger` in fields.yaml and the admin SPA, and D-16 (code-declared role grants) gets per-record locked permission values rendered as locked in the permission editor. Server-side enforcement stays authoritative. The `admin/` change regenerates `admin/openapi/admin.json` + `schema.d.ts` (`scripts/check-admin-openapi.sh`) and rebuilds the committed `modules/boardwalk/dist` (`scripts/check-admin-dist.sh`).
|
||||
- **D-22:** **Three plans:** 01 the pact action-extension seam, the internal cabana pseudo-plugin with Administrators + Roles controllers, privilege guards, lang keys and docs (server side); 02 a short plan for the SPA support in D-21; 03 unit + Postgres tests (last, per project rule).
|
||||
|
||||
### Claude's Discretion
|
||||
- How the internal pseudo-plugin is shaped and named (id, for example `summercms.cabana` / `backend.*` controller IDs), as long as it reuses the existing `summercms.cabana` migration id and doesn't collide with the reserved first path segments.
|
||||
- Exact Go identifiers for the D-14 capability and its types.
|
||||
- List columns and filters (Winter's set: login, name, email, role, last login, activated; filters: role, superuser for superusers only, trashed), and form tab layout.
|
||||
- The lock strategy for D-09.
|
||||
- Whether toolbar and bulk actions join the D-14 seam in this phase.
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### Phase scope and consumers
|
||||
- `.planning/ROADMAP.md` § "Phase 19.6.1: Backend Administrators screen": goal and success criteria
|
||||
- `.planning/phases/19.7-generic-mcp-module/19.7-CONTEXT.md`: D-03 to D-07 and D-34 (the link record action lands on this screen through the D-14 seam)
|
||||
- `.planning/notes/2026-10-17-generic-mcp-module.md`: design note behind 19.7's use of this screen
|
||||
- `.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-CONTEXT.md`: D-15 ("Reset 2FA" action contributed to another plugin's screen, a candidate user of the D-14 seam)
|
||||
- `.planning/notes/2026-10-07-embedded-admin-release-gate.md`: rebuild and commit the dist if `admin/` changes
|
||||
|
||||
### cabana internals to extend
|
||||
- `modules/cabana/README.md`: module docs to update
|
||||
- `modules/cabana/contracts.go`: `BackendUser`, `BackendUserRole`, `Allows`, `requiredOf`
|
||||
- `modules/cabana/auth.go`: `FindByID` permission merge (`principalFrom`, `applyUserPermissions`)
|
||||
- `modules/cabana/registry.go`: `compilePermissionCatalog`, `rolePermissions`, reserved path segments, controller ID rules
|
||||
- `modules/cabana/field_permission.go`: `permissioneditor`, `PermissionEditorProvider`, `PermissionOption.Locked`
|
||||
- `modules/cabana/extension.go`: `compileExtension` (actions only come from the owning controller today)
|
||||
- `modules/cabana/crud.go`: form lifecycle hooks, `offeredRecordActions`, `RecordAction`, row locks
|
||||
- `modules/cabana/actions.go`: action run paths and diary logging
|
||||
- `modules/cabana/http.go`: `Activate`, route mounting, account endpoints
|
||||
- `modules/cabana/account.go`: My Account allow-list (stays unchanged)
|
||||
- `modules/cabana/commands.go`, `modules/cabana/token_commands.go`: `admin:*` commands (unchanged behaviour)
|
||||
- `modules/lagoon/backend_admin_migrations.go`: `backend_users` and `backend_user_roles` schema and seeded system roles
|
||||
- `pact/capabilities.go`: `HasPermissions`, `HasAdminControllers`, `HasSettings`, `AdminRecordAction`, `HasAdminRecordActions`, form hooks
|
||||
|
||||
### Reference implementations
|
||||
- `../sm-grzybyfunkcjonalne-app/plugins/golem15/user/controllers/users_admin_controller.go`: Users screen with radio permission editor, bulk and record actions
|
||||
- `../sm-grzybyfunkcjonalne-app/plugins/golem15/user/classes/privileged.go`: privileged-group guard in FormBeforeUpdate/Delete, re-reading the row in the tx
|
||||
- `../examples/golem15-wintercms-starter/modules/backend/controllers/Users.php`: superuser filtering, per-user PermissionEditor, restore
|
||||
- `../examples/golem15-wintercms-starter/modules/backend/controllers/UserRoles.php`
|
||||
- `../examples/golem15-wintercms-starter/modules/backend/models/user/fields.yaml`, `../examples/golem15-wintercms-starter/modules/backend/models/userrole/fields.yaml`
|
||||
- `../examples/golem15-wintercms-starter/modules/backend/models/UserRole.php`: system roles
|
||||
- `../examples/golem15-wintercms-starter/modules/backend/formwidgets/PermissionEditor.php` (lines ~93-150): `getSaveValueSecure` ceiling
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- Generic SPA renderer (`admin/src/views/ListView.vue`, `FormView.vue`, `PreviewView.vue`, `components/form/FieldRenderer.vue`): a YAML-declared controller renders without new Vue code.
|
||||
- `admin/src/components/form/fields/PermissionEditorField.vue` already handles `Locked` options.
|
||||
- `admin/src/components/form/RecordActions.vue` renders record actions on the preview screen.
|
||||
- `permissioneditor` field (radio and checkbox modes) with per-request options from `PermissionEditorProvider`, which can depend on who is asking. That is the D-07 hook.
|
||||
- Form lifecycle hooks (`FormBeforeCreate/Update/Delete`, `FormRules`, `ListExtendQuery`, `FormExtendQuery`) run inside the save transaction with the row locked `FOR UPDATE`. `cabana.TxFromContext` is available.
|
||||
|
||||
### Established Patterns
|
||||
- Before-update hooks see the already-filled model. To compare with stored values (self-edit, demotion), re-read the row in the tx, as sm-user-plugin's `guardCredentials` does.
|
||||
- Permission merge order: role JSON, then code-declared role grants, then user overrides (1 grants; -1 or 0 removes). Superusers bypass `Allows` except under scoped API tokens.
|
||||
- Soft delete via `gorm.DeletedAt`. `tokens_valid_after` revocation, as used by `admin:reset-password`.
|
||||
- Action runs are diary-logged with `admin_id` (bulk and record).
|
||||
|
||||
### Integration Points
|
||||
- cabana registers no controllers or permissions today, and `Activate` returns nil when no plugin has controllers. An internal pseudo-plugin added to the `plugins` slice can supply the controllers, permissions, settings entries and an embedded AdminFS. With it, the admin routes always mount. The researcher confirms nothing depends on "no controllers means no admin routes".
|
||||
- The D-14 seam merges into `compileExtension` (`extension.go`), next to the owning controller's actions.
|
||||
- Admin OpenAPI: swag annotations in cabana regenerate `admin/openapi/admin.json` and `admin/src/api/schema.d.ts` (`scripts/check-admin-openapi.sh`).
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- Mirror WinterCMS Settings → System → Administrators / Roles in look and behaviour, except where the decisions above deliberately tighten it: the role-assignment ceiling (D-07), last-superuser protection (D-09), and self-edit refusal (D-08).
|
||||
- Winter has no last-superuser rule; D-09 is new.
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- Invite or welcome mail on admin create (Winter's `send_invite`): a later todo.
|
||||
- A "send password reset link" record action and force-change-on-next-login for backend admins.
|
||||
- Per-row action menus on list views.
|
||||
- Backend user groups (Winter's groups tab). No table exists.
|
||||
- Impersonate admin (Winter's `backend.impersonate_users`).
|
||||
|
||||
### Reviewed Todos (not folded)
|
||||
- **Audit-log admin action runs with the admin id** (`.planning/todos/pending/admin-action-audit-log.md`): kept deferred to keep the phase short. Record and bulk runs already diary-log `admin_id`.
|
||||
- **Verified link between a frontend user and a backend user** (`.planning/todos/pending/2026-10-17-frontend-backend-account-link.md`): already folded into 19.7 (D-01). This phase only provides its seam.
|
||||
- The other keyword matches (scaffolding, plugin ports, i18n, benchmarks) were unrelated to this phase.
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 19.6.1-backend-administrators-screen*
|
||||
*Context gathered: 2026-10-21*
|
||||
@@ -0,0 +1,56 @@
|
||||
# Phase 19.6.1: Backend Administrators screen - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-10-21
|
||||
**Phase:** 19.6.1-backend-administrators-screen
|
||||
**Areas discussed:** Permissions and placement, Privilege edge rules, Lifecycle and passwords, Record-action seam and roles
|
||||
|
||||
---
|
||||
|
||||
## Permissions and placement
|
||||
|
||||
| Question | Options | Selected |
|
||||
|----------|---------|----------|
|
||||
| Permission codes | Two codes (`backend.manage_users` + `backend.manage_roles`) / One code as in Winter / Superuser only | Two codes |
|
||||
| Placement | Settings → System section / Top-level navigation item | Settings → System |
|
||||
| Superusers for non-superusers | Hidden as in Winter / Visible read-only | Hidden |
|
||||
| Default role grant | None / Grant to `developer` system role | None |
|
||||
|
||||
## Privilege edge rules
|
||||
|
||||
| Question | Options | Selected |
|
||||
|----------|---------|----------|
|
||||
| Per-user permission override on the form | Yes, radio editor / No, roles only | Yes, radio |
|
||||
| Reach of the "can't grant more" ceiling | Permissions and role assignment / Permission editor only (Winter) | Permissions and role assignment |
|
||||
| Self-edit | Allowed with privileged fields read-only / Redirect to My Account | Allowed, read-only privileged fields |
|
||||
| Last-superuser enforcement | Every write path, locked / Admin screen only | Every write path |
|
||||
|
||||
## Lifecycle and passwords
|
||||
|
||||
| Question | Options | Selected |
|
||||
|----------|---------|----------|
|
||||
| Removal | Deactivate + soft delete + restore / Deactivate only / Deactivate + hard delete | Deactivate + soft delete + restore |
|
||||
| Password set/reset | Typed in form / Form + reset-link action / Form + force change | Typed in form |
|
||||
| Revocation on deactivate/delete/password set | Bump `tokens_valid_after` / Principal check only | Bump |
|
||||
| Invite mail on create | None this phase / Optional `send_invite` | None |
|
||||
|
||||
## Record-action seam and roles
|
||||
|
||||
| Question | Options | Selected |
|
||||
|----------|---------|----------|
|
||||
| Seam shape | Generic cross-controller extension / Administrators-only hook | Generic |
|
||||
| Where actions appear | Preview only / Preview + per-row list menu | Preview only |
|
||||
| Fold admin-action audit-log todo | No, keep deferred / Fold in | Keep deferred |
|
||||
| Roles: system roles and delete in use | Winter rules + block delete in use / Winter rules + unassign on delete | Block delete in use |
|
||||
|
||||
The user chose the recommended option in every case and added no free-text notes.
|
||||
|
||||
## Claude's Discretion
|
||||
|
||||
- Shape and name of the internal pseudo-plugin, Go identifiers of the seam, list columns and filters, the D-09 lock strategy, and whether toolbar and bulk actions join the seam.
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
- Invite mail, reset-link action, force-change-on-next-login, per-row list actions, backend groups, impersonation, and the admin-action audit log.
|
||||
@@ -0,0 +1,371 @@
|
||||
# Phase 19.6.1: Backend Administrators screen - Pattern Map
|
||||
|
||||
**Mapped:** 2026-10-21
|
||||
**Files analyzed:** 24 (new + modified, grouped by plan per D-22)
|
||||
**Analogs found:** 22 / 24
|
||||
|
||||
Primary template for both screens: sm-user-plugin's Users screen. Paths below are relative to the meta repo (`/media/nvme/dev/golem15/summercms.io/summercms/`). The plugin is the git submodule at `sm-grzybyfunkcjonalne-app/plugins/golem15/user`, abbreviated **`USER/`** below. Every file named was checked with `git ls-files` inside that submodule (tracked). Framework paths are relative to `summercms.go/`.
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|---|---|---|---|---|
|
||||
| **Plan 01: server** | | | | |
|
||||
| `modules/pact/capabilities.go` (+ `AdminActionExtension`, `HasAdminActionExtensions`) | contract | boot-time registration | `pact.HasAdminRecordActions` / `HasAdminControllers` (same file, lines 183-190, 343-376) | exact |
|
||||
| `modules/cabana/action_extensions.go` (new, `compileActionExtensions`) | compiler | boot-time merge | `modules/cabana/extension.go` `compileExtension` (lines 60-110) | exact |
|
||||
| `modules/cabana/http.go` (`Activate`: conditional pseudo-plugin, merge call, `screens.reg = reg`; `formSchema`/list schema per-request field drops) | wiring | request-response | itself, lines 74-130 and 753-790 | exact (in-place) |
|
||||
| `modules/cabana/backend_screens.go` (new pseudo-plugin) | plugin/provider | boot | `USER/admin.go` (lines 1-38) + `modules/cabana/example_controller_test.go` `BlogPlugin` (lines 115-135) | role-match |
|
||||
| `modules/cabana/backend_users_admin.go` (new, `summercms.cabana.users`) | controller | CRUD + actions | `USER/controllers/users_admin_controller.go` | exact |
|
||||
| `modules/cabana/backend_roles_admin.go` (new, `summercms.cabana.roles`) | controller | CRUD | `USER/controllers/users_admin_controller.go` (guardCredentials re-read, FormBeforeDelete, permission provider) | role-match |
|
||||
| `modules/cabana/backend_privileges.go` (new: `actsAsSuperuser`, `guardLastSuperuser`, grant-subset / outranks helpers) | utility | transform + tx lock | `USER/.../users_admin_controller.go` `guardPrivilegedMember` (193-209); `cabana.Allows`/`granted` in `modules/cabana/contracts.go:214-248` | role-match |
|
||||
| `modules/cabana/contracts.go` (`BackendUser`/`BackendUserRole` gain `Fillable`, `Rules`, `FilterScopes`, `FilterScope`, unexported snapshot field) | model | CRUD | `Post.Fillable` in `modules/cabana/example_controller_test.go:25-28` | role-match |
|
||||
| `modules/cabana/crud.go` (unexported projection hook in `projectFullRecord`, record permission locks in meta) | service | request-response | `projectFullRecord` (crud.go:647-670), `FormRecordTitle` hook use at 660-667 | exact (in-place) |
|
||||
| `modules/cabana/field_permission.go` (skip record-locked codes in the locked comparison; record-lock provider) | service | request-response | itself, lines 55-65 (provider interface), 290-312 (locked check) | exact (in-place) |
|
||||
| `modules/cabana/relation_field.go` `RecordMeta` (+ `PermissionLocks`) | DTO | response | `RecordMeta` (relation_field.go:95-102) `Labels`/`Notices` | exact |
|
||||
| `modules/cabana/form_schema.go` + new `field_trigger.go` (`trigger` key, compile + check) | compiler | boot | `modules/cabana/field_dependent.go` `compileDependsOn` / `checkDependsOn` (lines 23-60) | exact |
|
||||
| `modules/cabana/schema_types.go` `FormField` (+ `Trigger`) | DTO | schema payload | `FormField.DependsOn` (schema_types.go:278-281) | exact |
|
||||
| `modules/cabana/backendscreens/controllers/users/{config_list,config_form,config_filter}.yaml`, `_hint.htm` | config | — | `USER/controllers/users/{config_list,config_form,config_filter}.yaml`, `_hint.htm` | exact |
|
||||
| `modules/cabana/backendscreens/models/user/{columns,fields}.yaml` | config | — | `USER/models/user/{columns,fields}.yaml` | exact |
|
||||
| `modules/cabana/backendscreens/controllers/roles/*.yaml`, `models/userrole/*.yaml` | config | — | `USER/controllers/usergroups/*`, `USER/models/usergroup/*` (no preview block) | exact |
|
||||
| `modules/phrasebook/backend/lang/{en,pl}/lang.yaml` (+ `user:`, `user.role:`, `permissions:`, `form.trigger_*`, `permissioneditor.locked_*`, `settings.category_system`) | i18n | — | existing `permissioneditor:` block (en lang.yaml:258-264) | exact |
|
||||
| `modules/cabana/admin_openapi.go` (swag for `trigger`, `meta.permissionLocks`) | docs/annotation | — | existing `dependsOn` description (reflected in `admin/src/api/schema.d.ts:2203, 5778`) | exact |
|
||||
| `modules/cabana/README.md`, `docs/backend/users-and-permissions.md` (line ~138 paragraph) | docs | — | same files | in-place |
|
||||
| **Plan 02: SPA (D-21)** | | | | |
|
||||
| `admin/src/components/form/trigger.ts` (new) | utility | transform | `admin/src/components/form/control.ts` `permissionValues` (lines 77-90) | role-match |
|
||||
| `admin/src/components/form/formState.ts` `editablePayload` | utility | transform | itself, lines 47-77 | exact (in-place) |
|
||||
| `admin/src/components/form/FormField.vue` (trigger note), `fields/PermissionEditorField.vue` (record locks), `FormGrid.vue`/`FieldRenderer.vue` (prop threading), `views/FormView.vue`, `views/PreviewView.vue` | component | render | `labels` prop path FormGrid.vue:20,64 → FormField.vue:18,65 → FieldRenderer.vue:22; lock markup PermissionEditorField.vue:79-84,147-166; note markup RelationField.vue:326-337 | exact |
|
||||
| **Plan 03: tests** | | | | |
|
||||
| `modules/cabana/backend_*_test.go` (Postgres) | test | — | `modules/cabana/auth_test.go` `adminGorm` (314-360); `modules/cabana/example_actions_test.go`; `USER/admin_privileged_test.go`, `USER/admin_users_test.go` | exact |
|
||||
| `admin/tests/form/{trigger,PermissionEditorField,formState,FormView,PreviewView}.test.ts` | test | — | existing `admin/tests/form/PermissionEditorField.test.ts`, `formState.test.ts`, `DependentDropdown.test.ts` | exact |
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `modules/cabana/backend_users_admin.go` (controller, CRUD + actions)
|
||||
|
||||
**Analog:** `USER/controllers/users_admin_controller.go`. Note: this file lives in package `cabana`, so drop the `cabana.` prefix on `TxFromContext`, `VirtualFieldsFromContext`, `ForbiddenError`, `RelationLock`, `PermissionOption`, `FieldRelationContract`, `Allows`.
|
||||
|
||||
**Controller identity + lazy app accessor** (lines 37-60):
|
||||
```go
|
||||
type usersAdminController struct {
|
||||
app func() *backpack.App
|
||||
}
|
||||
func (usersAdminController) ID() string { return "golem15.user.users" } // -> "summercms.cabana.users"
|
||||
func (usersAdminController) ModelName() string { return `Golem15\User\Models\User` }
|
||||
func (usersAdminController) ConfigDir() string { return "controllers/users" }
|
||||
func (usersAdminController) RequiredPermissions() []string { return []string{PermissionAccessUsers} } // -> backend.manage_users
|
||||
func (usersAdminController) NewRecord() any { return &models.User{} } // -> &BackendUser{}
|
||||
```
|
||||
|
||||
**Unscoped list/form query** (lines 85-97) — extend with D-03 `Where("backend_users.is_superuser = ?", false)` when `!actsAsSuperuser(principal)`:
|
||||
```go
|
||||
func (usersAdminController) ListExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
|
||||
if db == nil { return db }
|
||||
return db.Unscoped()
|
||||
}
|
||||
```
|
||||
|
||||
**Row states** (lines 102-129): copy the loop, keep only `pact.RowStateDeleted` (`DeletedAt.Valid`) and `pact.RowStateDisabled` (`!IsActivated`); no throttle query.
|
||||
|
||||
**Virtual fields + rules** (lines 135-155):
|
||||
```go
|
||||
func (usersAdminController) FormVirtualFields() []string {
|
||||
return []string{"password", "password_confirmation", "send_invite"} // -> password, password_confirmation, is_superuser
|
||||
}
|
||||
func (c usersAdminController) FormRules(_ context.Context, op string) map[string]string {
|
||||
password := "nullable|" + length + "|confirmed"
|
||||
if op == "create" { password = "required|" + length + "|confirmed" }
|
||||
...
|
||||
}
|
||||
```
|
||||
Use `min:4` (Winter) and add the 72-byte check (`passwordMaxBytes`, `modules/cabana/account.go:46-47, 335-336`). Do NOT use lagoon `unique` for login/email (Research Pitfall 4).
|
||||
|
||||
**Password storing + revocation** (lines 303-321, 238-247):
|
||||
```go
|
||||
values, _ := cabana.VirtualFieldsFromContext(ctx)
|
||||
plain, _ := values["password"].(string)
|
||||
if plain == "" { return false, nil }
|
||||
hashed, err := bouncer.HashPassword(c.configInt(...), plain) // -> bouncer.HashPassword(adminBcryptCost(app), plain)
|
||||
...
|
||||
if changed { now := time.Now(); user.TokensValidAfter = &now }
|
||||
```
|
||||
Use the `admin:reset-password` cutoff verbatim (`modules/cabana/commands.go:157`): `cutoff := time.Now().UTC().Add(time.Second)`. D-20: own record + non-empty password -> `&ForbiddenError{Message: "backend::lang.user.own_password", Details: map[string]any{"password": []string{...}}}`.
|
||||
|
||||
**Create defaults** — copy `admin:create` (`modules/cabana/commands.go:106-115`): `IsActivated: true, ActivatedAt: &now`. Uniqueness query to mirror (commands.go:96), unscoped, excluding own id:
|
||||
```go
|
||||
tx.Model(&BackendUser{}).Where("login IN (?, ?) OR lower(email) IN (?, ?)", login, email, strings.ToLower(login), email).Count(&existing)
|
||||
```
|
||||
|
||||
**Re-read stored row in the tx** (lines 479-497) — template for the prior-state snapshot / role-code check:
|
||||
```go
|
||||
err := db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped().
|
||||
Model(&models.User{}).Where("id = ?", user.ID).Pluck("email", &stored).Error
|
||||
```
|
||||
|
||||
**Privilege guard returning 403 with field details** (lines 193-209, 499-522):
|
||||
```go
|
||||
principal, _ := bouncer.User(ctx)
|
||||
if cabana.Allows(principal, []string{classes.PermissionManagePrivilegedGroups}) { return nil }
|
||||
...
|
||||
return &cabana.ForbiddenError{Message: message, Details: details}
|
||||
```
|
||||
Every delete/action refusal must set `Message` (UI-SPEC C8).
|
||||
|
||||
**Writable protected FK relation** (lines 370-380) for `role`:
|
||||
```go
|
||||
{Field: "organisation", Kind: "belongsTo", NewRelated: func() any { return &models.Organisation{} },
|
||||
ForeignKey: "organisation_id", LabelColumn: "name", WritableForeignKey: true},
|
||||
// -> Field "role", NewRelated &BackendUserRole{}, ForeignKey "role_id"
|
||||
```
|
||||
|
||||
**Role ceiling via relation locks** (lines 402-422):
|
||||
```go
|
||||
func (c usersAdminController) AdminRelationLocks(ctx context.Context, field string) (cabana.RelationLock, error) {
|
||||
if field != groupsField { return cabana.RelationLock{}, nil } // -> "role"
|
||||
principal, _ := bouncer.User(ctx)
|
||||
if cabana.Allows(principal, ...) { return cabana.RelationLock{}, nil } // -> actsAsSuperuser
|
||||
...
|
||||
return cabana.RelationLock{IDs: ids, Message: usersLang + "privileged_group_forbidden"}, nil // -> backend::lang.user.role_ceiling
|
||||
}
|
||||
```
|
||||
|
||||
**Permission editor provider** (lines 426-477): copy the three methods; options come from the registry catalog (ordered) with `Locked: !actsAsSuperuser(p) && !granted(p.PermissionGrants, code)`; `AdminSetPermissionValues` refuses own record (D-08) and writes `BackendUser.Permissions` JSON.
|
||||
|
||||
**Delete hooks** (lines 524-536): `FormBeforeDelete` is called once per row in bulk delete (rollback of whole selection). Analog's `FormAfterDelete` (538-556) hard-deletes — **do not copy that part**; ours only bumps `tokens_valid_after` via `tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped()` (D-10, D-12).
|
||||
|
||||
**Bulk actions** (lines 589-627) and **record actions** (lines 665-743): copy the `bulk(...)` / `action(...)` builder closures verbatim, including:
|
||||
```go
|
||||
tx, ok := cabana.TxFromContext(ctx)
|
||||
if !ok { return pact.AdminRecordActionResult{}, errors.New("users admin: a record action needs its transaction") }
|
||||
```
|
||||
Names: `activate`, `deactivate`, `restore` (both record and bulk). Deactivate: self -> 403 `user.self_deactivate`, `guardLastSuperuser`, outranks (D-19), bump cutoff.
|
||||
|
||||
**Preview hint** (lines 746-770 + `USER/controllers/users/_hint.htm`): copy `usersHintView{Kind, Title, Text}` + `PartialData(ctx, "hint", record)`; precedence deleted > deactivated > own account.
|
||||
|
||||
---
|
||||
|
||||
### `modules/cabana/backend_roles_admin.go` (controller, CRUD)
|
||||
|
||||
**Analog:** same file. Reuse: identity block, `FormRules` (`name` required+unique, `code` uniqueness by hand -> `user.role.code_taken`), re-read pattern (lines 479-497) to detect a `code` change on a system role (`code` is fillable, already written before `FormBeforeUpdate`), `FormBeforeDelete` shape (524-536) returning `ForbiddenError{Message: "backend::lang.user.role.delete_has_users"}` after an **unscoped** `Count` of `backend_users` by `role_id`. Permission provider: checkbox mode; record locks = `reg.rolePermissions(role.Code)` (registry.go:449-460). No preview block, no record actions (YAML from `USER/controllers/usergroups/`).
|
||||
|
||||
---
|
||||
|
||||
### `modules/cabana/backend_screens.go` (pseudo-plugin)
|
||||
|
||||
**Analog:** `USER/admin.go` lines 11-38:
|
||||
```go
|
||||
//go:embed controllers/users/config_list.yaml controllers/users/config_form.yaml controllers/users/config_filter.yaml controllers/users/_hint.htm models/user/fields.yaml models/user/columns.yaml
|
||||
var adminFS embed.FS
|
||||
|
||||
func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
|
||||
func (p *Plugin) AdminControllers() []pact.AdminController {
|
||||
return controllers.AdminControllers(func() *backpack.App { if p == nil { return nil }; return p.app })
|
||||
}
|
||||
var (
|
||||
_ pact.AdminAssets = (*Plugin)(nil)
|
||||
_ pact.HasAdminControllers = (*Plugin)(nil)
|
||||
_ pact.HasPermissions = (*Plugin)(nil)
|
||||
)
|
||||
```
|
||||
Embed root: `backendscreens/` (use `fs.Sub` so paths start at `controllers/...`). Add `party.Plugin` methods (`ID() "summercms.cabana"`, `Requires() nil`, no-op `Register`/`Boot`, `modules/party/registry.go:16-21`), `pact.HasSettings`.
|
||||
|
||||
**Settings link entries** — analog `pact.SettingsItem` with `Controller` (capabilities.go:570-590; link entry declares no Model/Form/NewModel). Settings item fixture shape: `modules/cabana/example_controller_test.go:121-133`:
|
||||
```go
|
||||
return []pact.SettingsItem{{Code: "blog", Label: "...", Description: "...", Category: "...", Icon: "...", Permissions: []string{...}, ...}}
|
||||
// -> {Code: "administrators", Controller: "summercms.cabana.users", Permissions: []string{"backend.manage_users"},
|
||||
// Label: "backend::lang.user.menu_label", Description: "backend::lang.user.menu_description",
|
||||
// Category: "backend::lang.settings.category_system", Icon: "users", Order: 100}
|
||||
// {Code: "roles", Controller: "summercms.cabana.roles", ..., Icon: "shield-check", Order: 110}
|
||||
```
|
||||
Permissions: `pact.Permission{Code, Tab, Label, Roles}` with `Roles` empty (D-04).
|
||||
|
||||
---
|
||||
|
||||
### `modules/cabana/http.go` `Activate` (wiring)
|
||||
|
||||
**Analog:** itself, lines 74-130. Insertion points:
|
||||
```go
|
||||
items, err := collectControllers(plugins)
|
||||
...
|
||||
if len(items) == 0 { return nil, nil } // line 79: keep; append pseudo-plugin AFTER this check, then re-collect
|
||||
...
|
||||
reg, err := compileRegistryWithTypes(items, fieldTypes) // line 103
|
||||
// NEW: compileActionExtensions(reg, plugins) -- must run before compileContributions (validates action perms)
|
||||
if err := compileContributions(reg, plugins); err != nil { ... } // line 125
|
||||
```
|
||||
Per-request schema filter analog (http.go:767-785): the widget-drop loop builds a fresh `kept` slice — add the D-03 drop of `is_superuser` there through an unexported interface; mirror for the list schema columns and filters.
|
||||
|
||||
---
|
||||
|
||||
### `modules/cabana/action_extensions.go` (D-14 merge)
|
||||
|
||||
**Analog:** `modules/cabana/extension.go` `compileExtension` lines 60-110:
|
||||
```go
|
||||
recordActions, err := compileRecordActions(cc.Controller)
|
||||
if err != nil { return fmt.Errorf("cabana: admin controller %s/%s: %w", pluginID, id, err) }
|
||||
cc.RecordActions = recordActions
|
||||
...
|
||||
if len(cc.Form.recordActions) > 0 && cc.Form.preview == nil {
|
||||
return bootErr(pluginID, id, formFile, fmt.Errorf("recordActions needs a preview block (record actions are offered on the preview screen)"))
|
||||
}
|
||||
for _, name := range cc.Form.recordActions {
|
||||
action, ok := recordActions[name]
|
||||
...
|
||||
if strings.TrimSpace(action.Label) == "" { return bootErr(..., fmt.Errorf("recordActions: action %s needs a label", name)) }
|
||||
}
|
||||
```
|
||||
Merge = append name to `cc.Form.recordActions` and action to `cc.RecordActions`; errors for unknown controller ID, no form/preview, duplicate name, non-identifier, missing `Run`/`Label` (reuse validation of `compileRecordActions`, extension.go:401-427). Consumers then work unchanged: `offeredRecordActions` (crud.go:551-575) and `recordActionOf` (actions.go:242-247) iterate `cc.Form.recordActions`.
|
||||
|
||||
### `modules/pact/capabilities.go`
|
||||
|
||||
**Analog** (lines 186-190, 372-376):
|
||||
```go
|
||||
// HasAdminControllers is implemented by plugins that register admin controllers.
|
||||
type HasAdminControllers interface { AdminControllers() []AdminController }
|
||||
// HasAdminRecordActions is implemented by an admin controller that registers ...
|
||||
type HasAdminRecordActions interface { AdminRecordActions() []AdminRecordAction }
|
||||
```
|
||||
New: `type AdminActionExtension struct { Controller string; RecordActions []AdminRecordAction }` + `type HasAdminActionExtensions interface { AdminActionExtensions() []AdminActionExtension }` (plugin-level). Reuse `AdminRecordAction` (343-354) unchanged.
|
||||
|
||||
---
|
||||
|
||||
### `modules/cabana/field_trigger.go` + `form_schema.go` + `schema_types.go` (D-21 trigger)
|
||||
|
||||
**Analog:** `modules/cabana/field_dependent.go` lines 23-60:
|
||||
```go
|
||||
func compileDependsOn(typ string, values map[string]ast.Node, field *FormField) error {
|
||||
node, ok := values["dependsOn"]
|
||||
if !ok { return nil }
|
||||
...
|
||||
name, err := nodeString(node)
|
||||
if err != nil || !identifier(name) { return fmt.Errorf("dependsOn %q is not a field name", nodeText(node)) }
|
||||
field.DependsOn = name
|
||||
return nil
|
||||
}
|
||||
// checkDependsOn(fields []FormField, owner string) error -- byName map, sibling must exist and be scalar
|
||||
```
|
||||
Register `"trigger": {}` in `formFieldKeys` (form_schema.go:38-50); call site next to `compileDependsOn` (form_schema.go:646). Payload field next to `DependsOn string \`json:"dependsOn,omitempty"\`` (schema_types.go:278-281). Refuse `trigger` inside repeater forms; controlling field must be `switch`/`checkbox`.
|
||||
|
||||
### `modules/cabana/crud.go` / `field_permission.go` / `relation_field.go` (projection + record locks)
|
||||
|
||||
**Projection hook analog** — `FormRecordTitle` use inside `projectFullRecord` (crud.go:660-667):
|
||||
```go
|
||||
if hook, ok := cc.Controller.(pact.FormRecordTitle); ok && hook != nil {
|
||||
title, err := hook.FormRecordTitle(ctx, tx, model)
|
||||
if err != nil { return RecordResult{}, lifecycleFailure(cc, err) }
|
||||
meta.Title = title
|
||||
}
|
||||
```
|
||||
Use an **unexported** interface (only cabana's controller can implement) to add `data["is_superuser"]` and `meta.PermissionLocks`. Protected key list to leave untouched: crud.go:1407-1417.
|
||||
|
||||
**Locked comparison to amend** (field_permission.go:303-307) — skip record-locked codes:
|
||||
```go
|
||||
for _, option := range options {
|
||||
if option.Locked && stored[option.Code] != submitted[option.Code] {
|
||||
return &ForbiddenError{Details: map[string]any{value.field: []string{permissionLockedKey}}}
|
||||
}
|
||||
}
|
||||
```
|
||||
**Meta DTO** (relation_field.go:95-102): add `PermissionLocks map[string][]PermissionLock \`json:"permissionLocks,omitempty"\`` beside `Labels`/`Notices`.
|
||||
|
||||
---
|
||||
|
||||
### Admin YAML (`modules/cabana/backendscreens/...`)
|
||||
|
||||
**Analog:** `USER/controllers/users/config_list.yaml`, `config_form.yaml`, `config_filter.yaml` (read in full). Copy structure; substitutions per UI-SPEC C2/C3/C5:
|
||||
```yaml
|
||||
list: ~/plugins/golem15/user/models/user/columns.yaml # -> path inside backendscreens FS (check how ~/ resolves for the pseudo-plugin)
|
||||
recordUrl: golem15/user/users/preview/:id # -> summercms/cabana/users/preview/:id
|
||||
toolbar: { buttons: [create, delete], search: { prompt: backend::lang.list.search_prompt } }
|
||||
bulkActions: [activate, deactivate, restore]
|
||||
messages: { rowStateDeleted: ..., rowStateDisabled: ... } # no rowStateNegative
|
||||
```
|
||||
```yaml
|
||||
preview:
|
||||
headerPartial: hint
|
||||
recordActions: [activate, deactivate, restore]
|
||||
create: { redirect: .../preview/:id, redirectClose: ... }
|
||||
update: { redirect: .../update/:id, redirectClose: .../preview/:id }
|
||||
```
|
||||
Filters: copy `scope` (groups → role, `modelClass`, `nameFrom`, `scope`), `switch` (activated), `daterange` (created_date → last_login). Trashed = `scope` with two `FilterOptions`.
|
||||
|
||||
`_hint.htm` copy verbatim:
|
||||
```html
|
||||
{{- if .Data.Title -}}
|
||||
<div class="summer-callout summer-callout--{{ .Data.Kind }}" role="status">
|
||||
<p class="summer-callout__title">{{ trans .Data.Title }}</p>
|
||||
<p class="summer-callout__text">{{ trans .Data.Text }}</p>
|
||||
</div>
|
||||
{{- end -}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### SPA (Plan 02)
|
||||
|
||||
**`formState.ts` `editablePayload`** (lines 53-77) — add a trigger-active skip before the permissioneditor branch:
|
||||
```ts
|
||||
for (const field of fields) {
|
||||
if (field.readOnly || !isRegisteredField(field)) { continue }
|
||||
...
|
||||
if (field.type === 'permissioneditor') {
|
||||
out[field.name] = permissionValues(field, value) // record-locked codes must be skipped here too
|
||||
continue
|
||||
}
|
||||
```
|
||||
**`control.ts` `permissionValues`** (77-90): pass record locks, skip their codes.
|
||||
**`PermissionEditorField.vue`** (lines 15-28, 79-84, 147-166, 184-207): extend `isDisabled` (line 79 `readOnly.value || !!option.locked`) and `showsLock` (83-84) with record locks; note line replaces `t('backend::lang.permissioneditor.locked')` with the record note; add `data-locked-by="record"`.
|
||||
**Prop threading:** copy `labels` path — `FormGrid.vue:20` (`labels?: RecordMeta['labels']`) → `:labels="labels?.[field.name]"` (64) → `FormField.vue:18,65` → `FieldRenderer.vue:22`.
|
||||
**Trigger note markup:** copy RelationField locked note (RelationField.vue:326-337, `Lock :size="14" class="shrink-0 text-muted"`), swap to `Info`, `flex items-center gap-2 text-[13px] text-muted`.
|
||||
**Gates:** `scripts/check-admin-openapi.sh`, rebuild + commit `modules/boardwalk/dist`, `scripts/check-admin-dist.sh`.
|
||||
|
||||
---
|
||||
|
||||
### Tests (Plan 03)
|
||||
|
||||
**Postgres helper:** `modules/cabana/auth_test.go:320-360`:
|
||||
```go
|
||||
func adminGorm(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
if testing.Short() { t.Skip("requires testcontainers postgres") }
|
||||
adminOnce.Do(func() { ... postgres.Run(ctx, "postgres:16-alpine", postgres.WithDatabase("cabana"), ...) })
|
||||
```
|
||||
**Privilege test analog:** `USER/admin_privileged_test.go`, `USER/admin_users_test.go`, harness `USER/admin_harness_test.go`. **Record action run-path analog:** `modules/cabana/example_actions_test.go`, `phase121_record_test.go`. **Boot error analog:** `modules/cabana/phase121_schema_boot_test.go`. Also re-run `cd examples/hello && go test ./...` and `go test ./modules/surf` (Activate no-controllers invariant).
|
||||
**SPA:** `admin/tests/form/PermissionEditorField.test.ts`, `formState.test.ts`, `FormView.test.ts`, `PreviewView.test.ts`, `RecordActions.test.ts`, `DependentDropdown.test.ts` (sibling-field evaluation analog for trigger).
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Write transaction access
|
||||
**Source:** `USER/controllers/users_admin_controller.go:543-546, 600-603`
|
||||
**Apply to:** every hook/action that writes or counts (incl. `guardLastSuperuser`)
|
||||
```go
|
||||
tx, ok := cabana.TxFromContext(ctx)
|
||||
if !ok { return errors.New("...: a delete needs its transaction") }
|
||||
db := tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped()
|
||||
```
|
||||
|
||||
### Refusals
|
||||
**Source:** `ForbiddenError` use at users_admin_controller.go:208 and field_permission.go:305
|
||||
**Apply to:** D-05/07/08/09/17/19/20. 403 with `Message` (toast/banner) + `Details` keyed by field (field line). 422 `ValidationError` only for login/email/code field errors.
|
||||
|
||||
### Principal + permission checks
|
||||
**Source:** `bouncer.User(ctx)` + `Allows(principal, codes)` (users_admin_controller.go:194-197; contracts.go:214-248). `actsAsSuperuser` = `p != nil && p.IsSuperuser && (p.Scope == nil || p.Scope.Full)` (bouncer/context.go:16-35). Never pass a wildcard role grant to `granted`.
|
||||
|
||||
### Revocation cutoff
|
||||
**Source:** `modules/cabana/commands.go:157-162` — `time.Now().UTC().Add(time.Second)` into `tokens_valid_after`. Apply to deactivate, delete, password set for another admin; not to restore/activate.
|
||||
|
||||
### Lang keys
|
||||
**Source:** `modules/phrasebook/backend/lang/en/lang.yaml` top-level groups (`permissioneditor:` at 258). Add en + pl in the same commit; `validateMessageKeys` (messages.go:296-315) fails boot on missing `messages.*` keys. CLDR plural maps, not pipe strings.
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File / mechanism | Role | Data Flow | Reason |
|
||||
|---|---|---|---|
|
||||
| `guardLastSuperuser` with `pg_advisory_xact_lock` (in `backend_privileges.go`) | utility | tx lock | No advisory-lock usage in the repo; use RESEARCH.md "The last-superuser guard (skeleton)" |
|
||||
| Cross-controller merge semantics (unknown target / duplicate across plugins) | compiler | boot | Only owner-controller actions exist; error wording follows `bootErr` in extension.go |
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `summercms.go/modules/{cabana,pact,party,phrasebook}`, `summercms.go/admin/src/components/form`, `summercms.go/admin/tests/form`, `sm-grzybyfunkcjonalne-app/plugins/golem15/user` (tracked submodule).
|
||||
**Files scanned:** ~30
|
||||
**Pattern extraction date:** 2026-10-21
|
||||
@@ -0,0 +1,588 @@
|
||||
# Phase 19.6.1: Backend Administrators screen - Research
|
||||
|
||||
**Researched:** 2026-10-21
|
||||
**Domain:** cabana (admin framework) — generic CRUD reuse, server-side privilege rules, a new `pact` capability
|
||||
**Confidence:** HIGH for in-repo mechanics (every hook, ordering and value below was read from source this session); MEDIUM for the recommended shapes where CONTEXT leaves discretion; LOW items are listed in the Assumptions Log
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
|
||||
#### Permissions and placement
|
||||
- **D-01:** The screens are guarded by **two new permission codes**:
|
||||
- `backend.manage_users` for Administrators (Winter's name is kept);
|
||||
- `backend.manage_roles` for Roles.
|
||||
|
||||
Roles change what every holder can do, so they get their own code. These are the first permissions cabana itself registers, through an internal pseudo-plugin (see Code Insights). — **Reversibility:** costly — the permission codes end up stored in `backend_user_roles.permissions` and `backend_users.permissions` JSON on every install that grants them.
|
||||
- **D-02:** **Placement: Settings → System section**, as in Winter, through `pact.HasSettings` entries that link into the controllers. No top-level navigation item.
|
||||
- **D-03:** **Superusers are hidden from non-superusers**, as in Winter:
|
||||
- `ListExtendQuery` and `FormExtendQuery` filter `is_superuser` rows out;
|
||||
- the `is_superuser` field and filter are removed for non-superusers;
|
||||
- opening a superuser record by ID answers 404 or 403.
|
||||
- **D-04:** **No default role grant.** Neither code is added to the `developer` or `publisher` system roles through code-declared `Roles`. Only superusers reach the screens until someone grants the codes, so existing installs don't widen silently on upgrade.
|
||||
|
||||
#### Privilege rules (server-side, all enforced in the save transaction)
|
||||
- **D-05:** **Only a superuser can create or edit a superuser, or set `is_superuser`** (roadmap SC-2). A non-superuser sending `is_superuser` is answered 403, even if the field is hidden.
|
||||
- **D-06:** **The per-user permission override is editable**: a permissions tab on the Administrators form with `permissioneditor` mode **radio** (inherit / allow / deny), as in Winter. It is disabled when `is_superuser` is on. The column and its merge (`applyUserPermissions`, merged last) already exist.
|
||||
- **D-07:** **A non-superuser can't grant more than they hold**, and this covers both permissions and role assignment.
|
||||
- **Permission editors**, on both the per-user tab and the Roles screen: an option the editor doesn't hold is `PermissionOption.Locked`, and its stored value is kept on save (Winter's `getSaveValueSecure`). Changing a locked value is answered 403, the existing behaviour.
|
||||
- **Role picker**: it offers only roles whose merged grants (stored JSON plus code-declared grants) are a subset of the editor's own permissions. Assigning any other role is refused on the server.
|
||||
- **Roles screen**: a non-superuser can't add a permission they lack to any role.
|
||||
- **D-08:** **Editing your own record is allowed, but privileged fields are read-only.**
|
||||
- The admin can edit their profile fields on the Administrators screen.
|
||||
- The server refuses, for the editor's own record, any change to `role_id`, `is_superuser`, `permissions` or `is_activated`, and refuses deleting yourself.
|
||||
- This covers "an admin cannot raise their own role or permissions" (SC-2) in one place. There is no redirect to My Account.
|
||||
- **D-09:** **The last active superuser is protected on every write path.** It can't be deactivated, demoted (`is_superuser` off), soft-deleted, or turned inactive by any route. The check covers:
|
||||
- form update and delete;
|
||||
- bulk actions, including bulk deactivate and bulk delete;
|
||||
- record actions;
|
||||
- any `admin:*` command path that could clear the flag.
|
||||
|
||||
It runs inside the save transaction under a lock (for example a row lock on the active superuser set, or an advisory lock), so two concurrent demotions can't both pass. Today's `admin:*` commands don't demote, so their behaviour and output stay unchanged (SC-1).
|
||||
|
||||
#### Lifecycle and passwords
|
||||
- **D-10:** **Deactivate + soft delete + restore.**
|
||||
- Activate and Deactivate are record and bulk actions that toggle `is_activated`.
|
||||
- Delete is a soft delete through the existing `deleted_at`.
|
||||
- A trashed filter lists deleted admins, and a Restore action brings them back.
|
||||
- No hard delete, so FK references (tokens, `linked_by`, attachments) stay valid.
|
||||
- **D-11:** **Passwords are typed in the form.** Password and confirmation fields are required on create and optional on update, and blank means unchanged. The value is bcrypt-hashed like `admin:create`. There is no "send reset link" action and no force-change-on-next-login in this phase.
|
||||
- **D-12:** **Changes cut access immediately.** Deactivate, soft delete, and setting a password for another admin all bump `tokens_valid_after`, as `admin:reset-password` does. Existing JWTs and personal tokens then stop on the next request; `FindByID` already refuses inactive or deleted users. Restore and activate don't bump it.
|
||||
- **D-13:** **No invite or welcome mail** on create in this phase. Winter's `send_invite` is deferred.
|
||||
|
||||
#### Record-action seam
|
||||
- **D-14:** **A generic cross-controller action extension.** It is a new optional `pact` capability through which a plugin contributes `AdminRecordAction`s to any admin controller by controller ID, including cabana's Administrators controller and other plugins' controllers.
|
||||
- It may also cover toolbar and bulk actions if the shape allows it at no extra cost (planner's call).
|
||||
- Contributions merge at boot. A duplicate action name on a controller, or an unknown controller ID, is a boot error.
|
||||
- Each action keeps its own `Permissions`, `Applies` and `Confirm`. The existing row-locked transactional run path (`crud.go` `RecordAction`) runs it.
|
||||
- The seam is additive and names neither MCP nor the user plugin. It is the seam 19.7 D-04/D-34 consumes. The researcher checks whether 19.6 D-15 ("Reset 2FA" contributed by sm-mfa-plugin to the user plugin's Users screen) can use it as is.
|
||||
|
||||
— **Reversibility:** costly — this is a published `pact` capability that plugins (19.7's link action, sm-mfa-plugin) implement.
|
||||
- **D-15:** **Contributed actions appear on the preview screen only**, through the existing `RecordActions.vue` on `PreviewView`. The Administrators controller therefore gets a preview block. There are no per-row actions on the list and no list payload changes.
|
||||
|
||||
#### Roles screen
|
||||
- **D-16:** **Fields and system roles follow Winter.**
|
||||
- Fields: name, code (slugged from name), description, and a permissions tab using `permissioneditor` mode **checkbox**.
|
||||
- System roles (`is_system`, or roles that code-declared `pact.Permission.Roles` grants attach to) have their code locked, and their code-declared grants are shown as locked.
|
||||
- Name, description and any extra stored grants stay editable, within the D-07 ceiling.
|
||||
- **D-17:** **A role that still has admins can't be deleted.** The delete returns a validation error that names how many admins hold the role. System roles can't be deleted.
|
||||
|
||||
#### Quality gates
|
||||
- **D-18:** Unit tests are the last plan (project rule). They cover every privilege rule in D-05 to D-09, including the concurrent last-superuser case, plus D-12 revocation and the D-14 seam (boot errors, permission filtering, run path). Docs: the cabana README and the affected `docs/` pages are updated in the same commits as the API, config and permission changes. If `admin/` changes, `check-admin-openapi.sh` and the committed `modules/boardwalk/dist` gate (`check-admin-dist.sh`) apply.
|
||||
|
||||
### Claude's Discretion
|
||||
- How the internal pseudo-plugin is shaped and named (id, for example `summercms.cabana` / `backend.*` controller IDs), as long as it reuses the existing `summercms.cabana` migration id and doesn't collide with the reserved first path segments.
|
||||
- Exact Go identifiers for the D-14 capability and its types.
|
||||
- List columns and filters (Winter's set: login, name, email, role, last login, activated; filters: role, superuser for superusers only, trashed), and form tab layout.
|
||||
- The lock strategy for D-09.
|
||||
- Whether toolbar and bulk actions join the D-14 seam in this phase.
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
- Invite or welcome mail on admin create (Winter's `send_invite`): a later todo.
|
||||
- A "send password reset link" record action and force-change-on-next-login for backend admins.
|
||||
- Per-row action menus on list views.
|
||||
- Backend user groups (Winter's groups tab). No table exists.
|
||||
- Impersonate admin (Winter's `backend.impersonate_users`).
|
||||
|
||||
#### Reviewed Todos (not folded)
|
||||
- **Audit-log admin action runs with the admin id** (`.planning/todos/pending/admin-action-audit-log.md`): kept deferred to keep the phase short. Record and bulk runs already diary-log `admin_id`.
|
||||
- **Verified link between a frontend user and a backend user** (`.planning/todos/pending/2026-10-17-frontend-backend-account-link.md`): already folded into 19.7 (D-01). This phase only provides its seam.
|
||||
- The other keyword matches (scaffolding, plugin ports, i18n, benchmarks) were unrelated to this phase.
|
||||
</user_constraints>
|
||||
|
||||
## Project Constraints (from CLAUDE.md)
|
||||
|
||||
- Keep `go vet ./...` and `go test ./...` green at every commit; `go test -short ./...` is the Docker-free loop. Docker-backed tests skip under `-short` (a `TestMain` must call `flag.Parse()` and check `testing.Short()`). If the Docker socket is denied, run through `sudo -u golem`.
|
||||
- Standard library first; a new dependency needs a research doc or decision note. **This phase needs none** (see Standard Stack).
|
||||
- YAML through `goccy/go-yaml`, never `gopkg.in/yaml.v3`.
|
||||
- A change to a module's exported API, config keys, CLI commands or dependencies updates that module's `README.md` and the affected `docs/` pages **in the same commit**. Every identifier in a README or docs page must exist (the docs checker enforces it: `summer docs:build --check`, `go test ./cmd/summer -run TestDocsTree`).
|
||||
- Framework code never names a consuming application (neutral names only in READMEs, docs, examples, tests).
|
||||
- Core plugins (user, blog, pages, payment) must not get breaking changes; the new `pact` capability is additive.
|
||||
- If `admin/` changes: rebuild, commit `modules/boardwalk/dist`, pass `scripts/check-admin-dist.sh`; swag changes need `scripts/check-admin-openapi.sh`. The recommended design below needs **no `admin/` change** (see Open Questions 3 and 4 for the two places that would).
|
||||
- Lean planning: few large plans; present the plan count with one-line scopes and wait for confirmation. **Unit tests are the last plan.**
|
||||
- No co-author tags; one logical change per commit; planning docs and code in separate commits; stage files by path (other sessions edit `STATE.md`/`state.json`; check for in-flight edits before touching cabana). No push/tag/deploy without go-ahead. State via `msd_run query state.*`. Post a g15office comment when the phase advances.
|
||||
|
||||
## Summary
|
||||
|
||||
Everything the phase needs already exists as a generic cabana seam, with three gaps that must be designed before planning. **What exists:** YAML-driven list/form/preview, `FormVirtualFields` (password, confirmation, and a virtual `is_superuser` switch), `PermissionEditorProvider` with per-request `Locked` options and the stored-value-kept merge (exactly Winter's `getSaveValueSecure`), `RelationLockProvider` (per-request locked related ids, enforced with 403 on save — the right tool for the D-07 role ceiling), `FormExtendQuery`/`ListExtendQuery` with the principal on `ctx` (D-03), row-locked record and bulk action run paths, `ListRowStates`, `FilterScope`/`FilterOptions`, settings link entries (`pact.SettingsItem.Controller`), and `tokens_valid_after` revocation. sm-user-plugin's Users screen is a complete working template for the controller (virtual password, `guardCredentials` re-read in the tx, bulk/record actions, row states).
|
||||
|
||||
**The three gaps.** (1) `protectedFillKey` makes `role_id`, `permissions`, `is_superuser`, `is_activated` and `password` unfillable *and unprojected* by the generic CRUD — good for safety, but a virtual `is_superuser` switch would load as "off" on the edit form, and the SPA sends every field, so an untouched save would demote a superuser. cabana must project that value (an unexported in-package hook is enough, because the controller lives in cabana). (2) The save order is `FormBeforeUpdate` → `applyPermissionValues` → `checkRelationLocks` → `assignBelongsTo` → row write → `FormAfterUpdate`, so the new `role_id` is not visible in `FormBeforeUpdate`; the D-08 "no change to your own role" check must compare a snapshot taken in `FormBeforeUpdate` with the model in `FormAfterUpdate`. (3) Contributed record actions (D-14) are not offered or runnable today: `offeredRecordActions` and `recordActionOf` only see names in the owning controller's `config_form.yaml` `recordActions`. The seam must append contributed names to the compiled list at boot.
|
||||
|
||||
**Activation caveat:** `Activate` returns `nil` when no plugin registers controllers, and several things depend on it — `examples/hello` boots without `admin.jwt.secret`, and `modules/surf/admin_prefix_test.go` asserts "no admin controllers" → not mounted. Add the internal pseudo-plugin **only when at least one real plugin has admin controllers**; then nothing else changes.
|
||||
|
||||
**Primary recommendation:** Two plans. Plan 01: the `pact.HasAdminActionExtensions` seam, the internal `summercms.cabana` pseudo-plugin with `summercms.cabana.users` and `summercms.cabana.roles` controllers over `*cabana.BackendUser`/`*cabana.BackendUserRole`, the privilege guards (one shared `guardLastSuperuser` under `pg_advisory_xact_lock`), lang keys, and README/docs. Plan 02 (last): the unit and Postgres tests for D-05..D-09, D-12, D-14 and the concurrent case.
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| Administrators/Roles list, form, preview rendering | Browser (generic Vue SPA from `admin/`) | API (cabana schema endpoints) | The SPA renders any YAML-declared controller; no new Vue code is needed |
|
||||
| Privilege rules D-05..D-09 | API / Backend (cabana controller hooks inside the save tx) | Database (advisory lock, counts in the tx) | Must hold for any client, including personal API tokens and hand-crafted bodies |
|
||||
| Superuser hiding (D-03) | API (`ListExtendQuery`/`FormExtendQuery`, per-request schema filter) | — | The hidden field is display-only; the query scope is the control |
|
||||
| Role ceiling and permission ceiling (D-07) | API (`RelationLockProvider`, `PermissionEditorProvider` `Locked`) | Browser (locked rows are display aids) | Framework already refuses locked changes with 403 |
|
||||
| Last-superuser invariant (D-09) | Database (lock + count inside the write tx) | API (one guard called from every write path) | Only a serialized in-tx check survives concurrency |
|
||||
| Session revocation (D-12) | Database (`tokens_valid_after`) | API (bouncer JWT check, `FindByID` refusal) | Existing mechanism; JWT guard compares `iat` with the cutoff per request |
|
||||
| Cross-controller record actions (D-14) | API (boot-time merge in cabana, `pact` capability) | Browser (`RecordActions.vue` on preview, unchanged) | Contributions become ordinary record actions after the merge |
|
||||
| Permission catalog and settings entries | API (cabana registry, pseudo-plugin) | — | Same path as any plugin's `HasPermissions`/`HasSettings` |
|
||||
|
||||
## Standard Stack
|
||||
|
||||
No new dependency. Everything is framework code plus the existing set.
|
||||
|
||||
### Core
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| Go stdlib | go1.27.2 (local toolchain) | everything | Project rule: stdlib first [VERIFIED: `go version` this session] |
|
||||
| gorm.io/gorm | v1.31.2 | models, tx, `clause.Locking` | Already the data layer [VERIFIED: summercms.go/go.mod:50] |
|
||||
| PostgreSQL `pg_advisory_xact_lock(bigint)` | server feature (tests use `postgres:16-alpine`) | D-09 serialization | Built into Postgres; released at tx end [CITED: postgresql.org/docs/current/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS] |
|
||||
| bouncer.HashPassword / NeedsRehash | in-repo | bcrypt like `admin:create` | Same cost key `admin.password.bcrypt_cost` [VERIFIED: modules/cabana/commands.go:102, auth.go:481-490] |
|
||||
|
||||
### Supporting (test only, already in go.mod)
|
||||
| Library | Purpose | When to Use |
|
||||
|---------|---------|-------------|
|
||||
| testcontainers-go postgres module | Postgres for the privilege tests | Through the existing `adminGorm(t)` helper (skips under `-short`) [VERIFIED: modules/cabana/auth_test.go:314-360] |
|
||||
|
||||
### Alternatives Considered
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| `pg_advisory_xact_lock` | `SELECT ... FROM backend_users WHERE is_superuser ... FOR UPDATE` | Deadlock-prone: every write path already holds its target row (`loadRecord`/`lockScoped` take `FOR UPDATE` first), so two demotions of different superusers would each hold one row and wait for the other; Postgres aborts one with a 500 instead of a clean refusal |
|
||||
| `*cabana.BackendUser` as the record type | unexported wrapper model | A wrapper hides the type from contributors (19.7's link action could only use `RecordID`); `BackendUser` gains a few additive methods instead |
|
||||
| `RelationLockProvider` for the role ceiling | `RelationExtendOptionsQuery` filtering | Filtering removes the target admin's *current* role from options, so `checkRelationScope` 422s every save of an admin who holds a role above the editor's ceiling, even a name change; locks keep the value, show it locked, and refuse only a change |
|
||||
|
||||
**Installation:** none.
|
||||
|
||||
## Package Legitimacy Audit
|
||||
|
||||
No external packages are installed by this phase. **Packages removed:** none. **Packages flagged:** none.
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### System Architecture Diagram
|
||||
|
||||
```
|
||||
Boot (cabana.Activate)
|
||||
plugins ──collectControllers──> [none?] ──yes──> return nil (unchanged: no admin, no secret)
|
||||
│ some
|
||||
▼
|
||||
plugins + backendScreens{ID "summercms.cabana"} (internal pseudo-plugin: controllers users/roles,
|
||||
│ permissions backend.manage_users/manage_roles,
|
||||
│ 2 settings link entries, embedded AdminFS)
|
||||
▼
|
||||
compileRegistryWithTypes ──> compileActionExtensions (NEW: pact.HasAdminActionExtensions;
|
||||
│ append contributed names to cc.Form.recordActions + cc.RecordActions;
|
||||
│ unknown controller / no preview / duplicate name => boot error)
|
||||
▼
|
||||
compileContributions (permission catalog incl. backend.*, validates contributed action perms)
|
||||
▼
|
||||
backendScreens.reg = reg (controllers read the catalog + role grants per request)
|
||||
|
||||
Request: PUT /backend/api/v1/summercms/cabana/users/{id}
|
||||
guard (JWT or g15_ token) ─> protect(): RequiredPermissions backend.manage_users
|
||||
▼
|
||||
CRUDService.save tx:
|
||||
loadRecord (FormExtendQuery: unscoped; non-superuser => is_superuser = false; FOR UPDATE)
|
||||
Fill (protected keys never filled) ─> Validate (FormRules: password rules)
|
||||
FormBeforeUpdate ── snapshot prior role_id; virtual is_superuser (D-05, D-08, D-09 guard);
|
||||
│ password hash + tokens_valid_after (D-11, D-12); login/email uniqueness
|
||||
applyPermissionValues ─> AdminSetPermissionValues (D-07 locked kept; D-08 self refused)
|
||||
checkRelationLocks (role ceiling, D-07) ─> assignBelongsTo (role_id)
|
||||
row write ─> FormAfterUpdate (D-08: self role_id changed vs snapshot => 403, rollback)
|
||||
▼
|
||||
projectFullRecord (+ unexported hook adds is_superuser to data)
|
||||
|
||||
Record/bulk action: POST .../users/{id}/actions/deactivate or .../bulk/deactivate
|
||||
tx: row lock ─> Applies ─> Run: self? 403 ; guardLastSuperuser (advisory lock + count others) ;
|
||||
update is_activated=false, tokens_valid_after=now+1s
|
||||
```
|
||||
|
||||
### Recommended Project Structure
|
||||
```
|
||||
modules/cabana/
|
||||
├── backend_screens.go # pseudo-plugin (party.Plugin + HasAdminControllers, AdminAssets,
|
||||
│ # HasPermissions, HasSettings), embed of backendscreens/
|
||||
├── backend_users_admin.go # summercms.cabana.users controller: hooks, actions, providers
|
||||
├── backend_roles_admin.go # summercms.cabana.roles controller
|
||||
├── backend_privileges.go # actsAsSuperuser, guardLastSuperuser, grant subset/ceiling helpers
|
||||
├── action_extensions.go # compileActionExtensions (D-14 merge)
|
||||
├── backendscreens/ # embedded AdminFS (go:embed)
|
||||
│ ├── controllers/users/{config_list,config_form,config_filter}.yaml
|
||||
│ ├── controllers/roles/{config_list,config_form}.yaml
|
||||
│ └── models/{user,userrole}/{columns,fields}.yaml
|
||||
modules/pact/capabilities.go # + AdminActionExtension, HasAdminActionExtensions
|
||||
modules/phrasebook/backend/lang/{en,pl}/lang.yaml # + user.*, user.role.*, permissions/settings keys
|
||||
```
|
||||
|
||||
### Pattern 1: Conditional internal pseudo-plugin in `Activate`
|
||||
**What:** append the pseudo-plugin only when real controllers exist, and give it the compiled registry after compilation.
|
||||
**Why:** `Activate` has `if len(items) == 0 { return nil, nil }` [VERIFIED: modules/cabana/http.go:79-81]; `examples/hello` has no admin controllers and no JWT secret, and `modules/surf/admin_prefix_test.go:91-99,194` asserts "no admin controllers" → not mounted [VERIFIED: read this session].
|
||||
**Rules it must satisfy:** controller IDs must start with the plugin ID (`id != p.ID() && !strings.HasPrefix(id, p.ID()+".")` is an error [VERIFIED: registry.go:33-36]); the route derives the ID from three segments `vendor.plugin.controller` [VERIFIED: http.go:1102]; the reserved first segments are `map[string]bool{"api": true, "assets": true, "login": true, "settings": true, "account": true}` [VERIFIED: registry.go:46] — `summercms` is free. A plugin with controllers needs `pact.AdminAssets` (`"cabana: plugin %s has admin controllers but no AdminFS"` [VERIFIED: registry.go:80-83]). `party.Plugin` is `ID() string; Requires() []string; Register(app *backpack.App) error; Boot(app *backpack.App) error` [VERIFIED: modules/party/registry.go:16-21].
|
||||
**Recommended IDs:** plugin `summercms.cabana`, controllers `summercms.cabana.users` and `summercms.cabana.roles` (SPA route `/backend/summercms/cabana/users`). The migration history id `summercms.cabana` stays in lagoon untouched (`migrator(gdb, "summercms.cabana", BackendAdminMigrations)` [VERIFIED: modules/lagoon/migrations.go:75]); no migration is needed.
|
||||
|
||||
```go
|
||||
// Sketch (identifiers are the planner's call). Source: modules/cabana/http.go:74-130 shape.
|
||||
items, err := collectControllers(plugins)
|
||||
if err != nil { return nil, err }
|
||||
if len(items) == 0 { return nil, nil } // unchanged: no admin, no secret check
|
||||
screens := &backendScreens{app: app} // party.Plugin, ID "summercms.cabana"
|
||||
plugins = append(append([]party.Plugin(nil), plugins...), screens)
|
||||
if items, err = collectControllers(plugins); err != nil { return nil, err }
|
||||
// ... compileFieldTypes, compileRegistryWithTypes(items, fieldTypes) ...
|
||||
// if err := compileActionExtensions(reg, plugins); err != nil { return nil, err } // before compileContributions
|
||||
// ... compileContributions(reg, plugins), compileTokenScopes ...
|
||||
screens.reg = reg // controllers read the catalog per request
|
||||
```
|
||||
|
||||
### Pattern 2: The D-14 seam as a boot-time merge into existing record actions
|
||||
**What:** a plugin-level capability (like `HasAdminControllers`, not a controller capability) returning contributions keyed by controller ID. The merge appends each contributed name to `cc.Form.recordActions` and the action to `cc.RecordActions`; after that `offeredRecordActions`, `recordActionOf`, `RecordAction` (row lock, Applies re-check, `TxFromContext`), the diary log line and `validatePermissions` all work unchanged.
|
||||
**Why the merge is required:** both lookups iterate only declared names — `for _, name := range cc.Form.recordActions` [VERIFIED: crud.go:557] and `for _, entry := range cc.Form.recordActions { declared = declared || entry == name }` [VERIFIED: actions.go:242-247]. The compiled list is `recordActions []string` [VERIFIED: schema_types.go:159], unexported, so it is not in any payload.
|
||||
**Boot checks to reuse:** an actions-only preview rule already exists: `recordActions needs a preview block (record actions are offered on the preview screen)` [VERIFIED: extension.go:91-93]; names must be identifiers, not `builtinToolbarActions = map[string]bool{"create": true, "delete": true}` [VERIFIED: extension.go:47], unique, with `Run` and a non-empty `Label` [VERIFIED: extension.go:94-102, 401-427]. Add: unknown controller ID → error; target without a form or preview block → error; duplicate against the owner's names and other contributions → error. `compileContributions` then validates each action's `Permissions` against the catalog because it iterates `controller.RecordActions` [VERIFIED: registry.go:342-346] — so the merge must run **before** `compileContributions`.
|
||||
**Shape (additive, discretion on names):**
|
||||
|
||||
```go
|
||||
// modules/pact/capabilities.go — uses the existing AdminRecordAction type verbatim:
|
||||
// type AdminRecordAction struct { Name, Label, Confirm string; Permissions []string; Danger bool;
|
||||
// Applies func(ctx context.Context, record any) (bool, error); Run func(ctx context.Context,
|
||||
// in AdminRecordActionInput) (AdminRecordActionResult, error) } [VERIFIED: capabilities.go:343-354]
|
||||
|
||||
// AdminActionExtension adds actions to an admin controller the plugin may not own.
|
||||
type AdminActionExtension struct {
|
||||
Controller string // controller ID, vendor.plugin.controller
|
||||
RecordActions []AdminRecordAction // offered on the controller's preview screen after its own
|
||||
}
|
||||
|
||||
// HasAdminActionExtensions is implemented by plugins (not controllers).
|
||||
type HasAdminActionExtensions interface {
|
||||
AdminActionExtensions() []AdminActionExtension
|
||||
}
|
||||
```
|
||||
A struct (not a bare slice) lets `BulkActions` be added later as a field without breaking implementers. **Recommendation: record actions only in this phase.** Bulk contributions would also need entries in the list schema's `BulkActions` view (label/confirm built from YAML today), which is not free.
|
||||
**Protection is inherited:** `protect()` checks the target controller's `RequiredPermissions` before any action, then `allowAction` checks the action's own `Permissions` [VERIFIED: http.go:1101-1110, actions.go:256-262]. A contributed action on the Administrators screen therefore needs `backend.manage_users` **and** its own code.
|
||||
|
||||
**19.6 D-15 can use it as is**, with two conditions: the target `golem15.user.users` already has a preview block (`preview:` + `recordActions: [activate, unban, unsuspend]` [VERIFIED: fonoteka.go/plugins/golem15/user/controllers/users/config_form.yaml:14-17]), and the contributed name must not collide with a name the user plugin itself registers. 19.6-01-PLAN already plans an in-plugin `reset_two_factor` slot (the user plugin registers the action, mfa publishes `classes.TwoFactorAdmin`) [VERIFIED: 19.6-01-PLAN.md:49,270]; if that ships, mfa must not also contribute `reset_two_factor`. Nothing in 19.6 has to change.
|
||||
|
||||
**19.7 caveat:** `AdminRecordActionInput` carries only `RecordID uint64` and `Record any` [VERIFIED: capabilities.go:360-363] — no payload. "Remove link" fits; "set a link for editor onboarding" (19.7 D-07) needs a chosen frontend user, which a record action cannot carry. See Open Question 5.
|
||||
|
||||
### Pattern 3: Administrators controller over `*cabana.BackendUser`
|
||||
**Model requirements:** the generic CRUD needs `pact.AdminRecordSource`, a model implementing `lagoon.HasFillable` and `Rules() map[string]string` (`newWritableModel` [VERIFIED: crud.go:907-927]); `scope` filters need `pact.FilterScope` **on the model** (`filterProvider` asserts `src.NewRecord().(pact.FilterScope)` [VERIFIED: filter_schema.go:311-322]); `FilterOptions` may be on the controller [VERIFIED: filter_schema.go:301-309]. Recommend adding `Fillable()`, `Rules()`, `FilterScopes()`, `FilterScope()` to `BackendUser` (additive; `BackendUser` is used by sm-journal-plugin as a relation target and importer type, none of which consults these methods) and the same `Fillable`/`Rules` to `BackendUserRole`.
|
||||
|
||||
**Protected keys** (never filled, never projected, never bound): `"id", "created_at", "updated_at", "deleted_at", "owner_id", "user_id", "collection_id", "organisation_id", "organization_id", "scope_id", "role_id", "permissions", "is_superuser", "is_system", "is_activated", "password"` [VERIFIED: crud.go:1407-1417]. Consequences:
|
||||
- `role` is a `type: relation` belongsTo with `FieldRelationContract{Field: "role", Kind: "belongsTo", NewRelated: ..., ForeignKey: "role_id", LabelColumn: "name", WritableForeignKey: true}` — `WritableForeignKey` is the documented opt-in for a protected foreign key [VERIFIED: relation_field.go:41-43, 247], the same as sm-user-plugin's `organisation` [VERIFIED: sm-grzybyfunkcjonalne-app/plugins/golem15/user/controllers/users_admin_controller.go:348-357].
|
||||
- `password`, `password_confirmation` and `is_superuser` are listed in `FormVirtualFields`; allowed virtual types are `"password", "text", "textarea", "number", "checkbox", "switch", "dropdown"` [VERIFIED: extension.go:158-161]. A `type: password` field must be listed (`checkVirtualFields` [VERIFIED: extension.go:170-175]). Virtual values reach hooks through `cabana.VirtualFieldsFromContext`, only for keys present in the body ("a missing key means 'not submitted'") [VERIFIED: tx_context.go:46-70, crud.go:1269-1288] — so D-05's "sending `is_superuser` → 403" is detectable even when the field is hidden.
|
||||
- `permissions` uses `type: permissioneditor` (`mode: radio` for users, `mode: checkbox` for roles); it is lifted separately and never filled [VERIFIED: field_permission.go:177-211].
|
||||
- `is_activated` only changes through record/bulk actions (D-10).
|
||||
|
||||
**Save order (the timing every guard depends on)** [VERIFIED: crud.go:771-848]: `lagoon.Fill` → `Validate` (merged `FormRules`) → `formBeforeUpdate` (line 800) → `applyPermissionValues` (809) → `checkRelationScope` → `checkRelationLocks` (819) → `assignBelongsTo` (822) → `tx.Unscoped().Save(target)` (829) → `formAfterUpdate` (848). In `FormBeforeUpdate` the model still holds the **stored** `RoleID`, `IsSuperuser`, `IsActivated` and `Permissions` (protected keys are never filled); the new `role_id` appears only after `assignBelongsTo`.
|
||||
|
||||
**Hook plan (the guards):**
|
||||
|
||||
| Where | Check | Answer |
|
||||
|-------|-------|--------|
|
||||
| `ListExtendQuery`, `FormExtendQuery` | `db.Unscoped()` (trashed admins listable and restorable, as sm-user-plugin does [VERIFIED: users_admin_controller.go:84-97]); non-superuser → `Where("backend_users.is_superuser = ?", false)` (qualified: list search uses correlated subqueries next to unqualified scope predicates [VERIFIED: query.go:485-490]) | hidden superuser by id = 404 through `loadRecord`'s not-found [VERIFIED: crud.go:946-953] |
|
||||
| `FormBeforeCreate` | virtual `is_superuser` present and not `actsAsSuperuser` → 403; set `IsSuperuser`; set `IsActivated = true`, `ActivatedAt = now` (as `admin:create` does [VERIFIED: commands.go:106-115]); hash password; login/email checks | `*cabana.ForbiddenError` (403) / `*cabana.ValidationError` (422) |
|
||||
| `FormBeforeUpdate` | snapshot prior `RoleID`; `is_superuser` present: non-superuser → 403, self and changed → 403, demotion of an active superuser → `guardLastSuperuser`; password: hash, bump `TokensValidAfter` (self: see OQ 2); login/email uniqueness | 403 / 422 |
|
||||
| `AdminSetPermissionValues` (users) | self and `next` ≠ stored → 403; store JSON | 403 |
|
||||
| `AdminRelationLocks(ctx, "role")` | non-superuser: lock every role whose merged grants ⊄ editor's | framework 403 on change [VERIFIED: relation_field.go:55-67] |
|
||||
| `FormAfterUpdate` | self and `RoleID` ≠ snapshot → 403 (rolls the tx back) | 403 |
|
||||
| `FormBeforeDelete` | self → 403; active superuser → `guardLastSuperuser` | 403 |
|
||||
| `FormAfterDelete` | bump `tokens_valid_after` on the (now trashed) row through `tx.Unscoped()` | — |
|
||||
| record + bulk `activate` / `deactivate` / `restore` | deactivate: self → 403, active superuser → `guardLastSuperuser`, bump; activate: set `activated_at`; restore: `deleted_at = NULL` | 403 |
|
||||
|
||||
`deleteRecord` calls `FormBeforeDelete` once per row inside the bulk delete transaction [VERIFIED: crud.go:366-373, 1127-1143], so one refused row rolls back the whole selection, and the per-row "count the others" check sees earlier rows' writes in the same tx.
|
||||
|
||||
**The `is_superuser` projection gap.** Protected keys are skipped in `projectRecord` [VERIFIED: crud.go:1354-1378] and virtual fields are "never bound, filled or projected" [VERIFIED: contracts.go:133-135]. The SPA sends every non-readOnly field on save (`editablePayload` [VERIFIED: admin/src/components/form/formState.ts:53-77]). An unprojected switch therefore shows "off" for a superuser and is sent as `false` when the form is saved → silent demotion. Fix: an **unexported** in-package interface that `projectFullRecord` consults (only cabana's own controllers can implement an unexported method), adding `is_superuser` to `data`. The same mechanism (another unexported interface consulted in `formSchema`/`listSchema`) removes the `is_superuser` field and filter for non-superusers (D-03); today `formSchema` only drops widget fields per request [VERIFIED: http.go:767-785]. No `pact` surface, no payload change for other controllers.
|
||||
|
||||
**Prior-state snapshot.** Use an unexported field on `BackendUser` (for example `adminPrior *uint` plus a set flag). GORM parses only exported fields (`if fieldStruct := modelType.Field(i); ast.IsExported(fieldStruct.Name)` [VERIFIED: gorm.io/gorm@v1.31.2/schema/schema.go:220-221]) and cabana's `flattenModelFields` skips `f.PkgPath != ""` [VERIFIED: model_fields.go:54-56]. The same pointer is passed to `formBeforeUpdate` and `formAfterUpdate` [VERIFIED: crud.go:800, 848].
|
||||
|
||||
### Pattern 4: `guardLastSuperuser` under an advisory transaction lock
|
||||
**What:** one function every reducing path calls before it writes: take `pg_advisory_xact_lock(<constant bigint>)`, then count `is_superuser AND is_activated AND deleted_at IS NULL AND id <> target` through the write tx (`cabana.TxFromContext`); refuse when the count is 0 and the target is currently an active superuser.
|
||||
**Why it is correct:** every path takes its target row lock first (`loadRecord`/`lockScoped`, `FOR UPDATE` [VERIFIED: crud.go:946, 1058-1060]) and then the advisory lock; nothing takes a row lock after the advisory lock (the count is a plain `SELECT`), so there is no wait cycle. Under READ COMMITTED, the second transaction's count runs after the first commits (it waited on the advisory lock), so it sees the first demotion. Re-entrant within one tx, so a bulk action calling it per row is fine.
|
||||
**Scope:** only paths that reduce the set (demote, deactivate, delete). Create/restore/activate raise it. `admin:create`/`admin:reset-password` never clear the flag [VERIFIED: commands.go:71-168], so they stay unchanged (SC-1); 19.8 already reshaped those commands' output (commit `9d06a78`), so do not touch `commands.go`.
|
||||
|
||||
### Pattern 5: Ceilings (D-07)
|
||||
- **Effective grants of a role** = `parseGrants(role.Permissions)` ∪ `reg.rolePermissions(role.Code)` — the same merge as `FindByID` [VERIFIED: auth.go:39-67, registry.go:449-460].
|
||||
- **Editor's grants** = `principal.PermissionGrants` (already merged per request by `FindByID`).
|
||||
- **Subset test:** for each role code `c` without a wildcard, `granted(editor, c)` [VERIFIED: contracts.go:214-248]. **Do not** pass a wildcard role grant (`acme.*`) to `granted`/`Allows` — a required code ending in `*` means "any matching grant", not "all" [VERIFIED: contracts.go:216-226]. Treat a wildcard role grant as covered only when the editor holds the same or a broader wildcard (conservative; see A3).
|
||||
- **Options:** `AdminPermissionOptions` returns the catalog (`reg.permissions`, a map — add an ordered slice in `compilePermissionCatalog` for stable display) with `Locked: !actsAsSuperuser(p) && !granted(p.PermissionGrants, code)`. The framework then keeps stored non-offered codes, refuses a changed locked value with `ForbiddenError` on the field (`permissionLockedKey = "backend::lang.permissioneditor.locked"` [VERIFIED: field_permission.go:24, 303-307]).
|
||||
- `actsAsSuperuser(p)` = `p != nil && p.IsSuperuser && (p.Scope == nil || p.Scope.Full)` — a superuser's scoped personal token never grants superuser-only powers. `Scope` is `*ScopeLimit{Full bool; Scopes [][]string}` [VERIFIED: modules/bouncer/context.go:16-35].
|
||||
|
||||
### Pattern 6: Roles controller (D-16, D-17)
|
||||
- Fields: `name`, `code` (`preset: {type: slug, field: name}` — `preset` is a supported field key [VERIFIED: form_schema.go:47]), `description` (`textarea`), `permissions` (`permissioneditor`, `mode: checkbox`). Roles have no `deleted_at` (`BackendUserRole` has no `DeletedAt` [VERIFIED: contracts.go:17-26]) → delete is a hard delete.
|
||||
- `code` is fillable (not protected), so `Fill` has already written it before `FormBeforeUpdate`: re-read the stored row through a fresh session on the tx (sm-user-plugin's `guardCredentials` pattern [VERIFIED: users_admin_controller.go:458-470]) and refuse a code change on a system role (`is_system` or `reg.roleGrants[code]` non-empty).
|
||||
- `FormBeforeDelete`: system role → refuse; count admins with `role_id` **unscoped** (soft-deleted admins still reference the row through `role_id INTEGER REFERENCES backend_user_roles(id)` with no `ON DELETE` [VERIFIED: backend_admin_migrations.go:50]) → `ValidationError` naming the count.
|
||||
- Rules: `name` required + `unique:backend_user_roles` (DB has `name TEXT NOT NULL UNIQUE` [VERIFIED: backend_admin_migrations.go:29]); `code` unique too (DB has only an index, `code TEXT,` [VERIFIED: :30]; `roleIDByCode` fails on an ambiguous code [VERIFIED: commands.go:192-207]).
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **Hand-written routes for the screens.** Every route exists generically; new routes would need swag annotations, OpenAPI regeneration and SPA work.
|
||||
- **Using `RelationExtendOptionsQuery` for the role ceiling** (see Alternatives).
|
||||
- **Checking `role_id` in `FormBeforeUpdate`.** It still holds the stored value.
|
||||
- **`Preload("Role")` in `FormExtendQuery`.** GORM saves a non-zero belongs-to association and resets the foreign key from it (`setupReferences` → `ref.ForeignKey.Set(..., pv)`; a zero association is skipped) [VERIFIED: gorm@v1.31.2/callbacks/associations.go:24-29, 92-93], so a preloaded old `Role` would overwrite the new `role_id` on `Save`.
|
||||
- **Trusting lagoon's `unique` rule for login/email.** It adds `deleted_at IS NULL` and is case-sensitive [VERIFIED: modules/lagoon/validate.go:359-381]; the DB has `login TEXT NOT NULL UNIQUE`, `email TEXT NOT NULL UNIQUE` [VERIFIED: backend_admin_migrations.go:41-42] plus `lower(email)` unique, all including soft-deleted rows.
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Permission ceiling on save | own diffing of submitted permissions | `PermissionEditorProvider` + `Locked` | `applyPermissionValues` already keeps unoffered codes, rejects unknown codes and 403s locked changes |
|
||||
| Role ceiling | own check on `role_id` | `RelationLockProvider` | Enforced before any row write, shows locked options |
|
||||
| Password hashing / rehash | custom bcrypt | `bouncer.HashPassword(adminBcryptCost(app), pw)` | Same cost and 72-byte limit as login and `admin:create` |
|
||||
| Session revocation | blacklist entries | `tokens_valid_after` | JWT guard compares `iat` per request [VERIFIED: bouncer/jwt.go:160-173] |
|
||||
| Record/bulk action plumbing | new endpoints | `HasAdminRecordActions` / `HasAdminBulkActions` | Row locks, Applies re-check, CSRF, diary log already done |
|
||||
| Settings placement | navigation item | `pact.SettingsItem{Controller: ...}` link entries | Listed only when the principal can open the controller [VERIFIED: navigation.go:87-93] |
|
||||
|
||||
**Key insight:** the only new mechanisms are the D-14 merge, two unexported projection/schema hooks, and the last-superuser lock. Everything else is configuration of existing seams.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Silent superuser demotion from an unprojected switch
|
||||
**What goes wrong:** a superuser edits another superuser's name; the form sends `is_superuser: false`; the target is demoted.
|
||||
**Why:** protected and virtual fields are not projected; the SPA sends every field.
|
||||
**How to avoid:** project `is_superuser` through the unexported hook; test that GET returns it and that saving an untouched form keeps it.
|
||||
|
||||
### Pitfall 2: Self role change slips through `FormBeforeUpdate`
|
||||
**Why:** `assignBelongsTo` runs after the hook. **Avoid:** snapshot + `FormAfterUpdate` check, with a test that sends a new `role` for the editor's own record and asserts 403 and an unchanged row.
|
||||
|
||||
### Pitfall 3: "No controllers means no admin" broken
|
||||
**What goes wrong:** `examples/hello` and every admin-less app fail boot with `cabana: admin.jwt.secret is empty`; `surf` prefix tests fail. **Avoid:** conditional pseudo-plugin; run `cd examples/hello && go test ./...` and `go test ./modules/surf`.
|
||||
|
||||
### Pitfall 4: Soft-deleted rows still own their login and email
|
||||
**What goes wrong:** creating `editor@example.com` after deleting an admin with that email is a 500 (unique violation). **Avoid:** an unscoped, case-insensitive check mirroring `admin:create` (`login IN (?, ?) OR lower(email) IN (?, ?)`, excluding the record's own id [VERIFIED: commands.go:96]); answer 422 on the field and mention that a deleted administrator holds the value (restore it).
|
||||
|
||||
### Pitfall 5: Role delete hits the FK
|
||||
**Why:** soft-deleted admins still reference the role. **Avoid:** unscoped count in `FormBeforeDelete`.
|
||||
|
||||
### Pitfall 6: Deadlock instead of a refusal under concurrency
|
||||
**Avoid:** advisory lock, not `FOR UPDATE` on the superuser set (Pattern 4).
|
||||
|
||||
### Pitfall 7: Personal API tokens survive a password reset and revive on restore
|
||||
**What:** `tokens_valid_after` cuts JWTs only; personal tokens are cut by `FindByID` refusing inactive/deleted users (`token_guard.go:100-104`) and are explicitly untouched by `admin:reset-password` ("Personal API tokens are not touched" [VERIFIED: commands.go:47-50]). So D-12's "personal tokens then stop" holds for deactivate and delete, not for a password set; and restore/activate (which don't bump) bring the admin's personal tokens back. See Open Question 6.
|
||||
|
||||
### Pitfall 8: Existing tests and consumers see two new settings entries for superusers
|
||||
**What:** a superuser's `/settings` gains "Administrators" and "Roles". Non-superuser lists are unchanged (D-04: no default role grant), so e.g. the fonoteka developer-settings assertion (`reflect.DeepEqual(settings, []string{"fonoteka"})`) still holds [VERIFIED: fonoteka.go/plugins/golem15/fonoteka/admin_metadata_test.go:156-158]. Grep sibling repos for superuser-settings assertions before the framework tag.
|
||||
|
||||
### Pitfall 9: bcrypt input limit
|
||||
`HashPassword` refuses input over 72 bytes; My Account checks `len(password) > passwordMaxBytes` (72) itself [VERIFIED: account.go:46-47, 335-336]. Add the same byte check for the admin form's password, with `min:4` as in Winter (`'password' => 'required:create|min:4|confirmed'` [VERIFIED: examples/golem15-wintercms-starter/modules/backend/models/User.php:30]).
|
||||
|
||||
### Pitfall 10: Missing lang keys
|
||||
`backend::lang.user.*` does not exist yet: the backend namespace has only `auth, nav, page, list, form, relation, settings, extension, datepicker, money, taglist, secret, fileupload, messages, permissioneditor, api_tokens, myaccount, richeditor, repeater, reorder` [VERIFIED: modules/phrasebook/backend/lang/en/lang.yaml top-level keys]. `validateMessageKeys` fails boot on a missing `messages.*` phrase key [VERIFIED: messages.go:296-315]. Port the strings from Winter's `modules/backend/lang/en/lang.php` and `pl/lang.php` (`'user' => [ 'name' => 'Administrator', 'menu_label' => 'Administrators', ...` [VERIFIED: examples/golem15-wintercms-starter/modules/backend/lang/en/lang.php:133-137]).
|
||||
|
||||
## Code Examples
|
||||
|
||||
### The last-superuser guard (skeleton)
|
||||
```go
|
||||
// Source: pattern from this research; TxFromContext from modules/cabana/tx_context.go:27-33.
|
||||
// The lock key is any fixed bigint constant (planner picks it).
|
||||
func guardLastSuperuser(ctx context.Context, target *BackendUser) error {
|
||||
if !target.IsSuperuser || !target.IsActivated || target.DeletedAt.Valid {
|
||||
return nil // the write does not shrink the active superuser set
|
||||
}
|
||||
tx, ok := TxFromContext(ctx)
|
||||
if !ok {
|
||||
return errors.New("cabana: last-superuser check needs the write transaction")
|
||||
}
|
||||
db := tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx)
|
||||
if err := db.Exec("SELECT pg_advisory_xact_lock(?)", superuserLockKey).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var others int64
|
||||
if err := db.Model(&BackendUser{}).
|
||||
Where("is_superuser AND is_activated AND id <> ?", target.ID).
|
||||
Count(&others).Error; err != nil { // default scope adds deleted_at IS NULL
|
||||
return err
|
||||
}
|
||||
if others == 0 {
|
||||
return &ForbiddenError{Message: "backend::lang.user.last_superuser"} // new phrase key
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### Revocation like `admin:reset-password`
|
||||
```go
|
||||
// Source: modules/cabana/commands.go:157-162 (verbatim values):
|
||||
// cutoff := time.Now().UTC().Add(time.Second)
|
||||
// ... Updates(map[string]any{"password": hash, "tokens_valid_after": cutoff})
|
||||
cutoff := time.Now().UTC().Add(time.Second)
|
||||
user.TokensValidAfter = &cutoff // in FormBeforeUpdate; Save writes it
|
||||
```
|
||||
|
||||
### A contribution from another plugin (what 19.7 writes)
|
||||
```go
|
||||
// Neutral example; Permissions must be a code some activated plugin registers.
|
||||
func (p *Plugin) AdminActionExtensions() []pact.AdminActionExtension {
|
||||
return []pact.AdminActionExtension{{
|
||||
Controller: "summercms.cabana.users",
|
||||
RecordActions: []pact.AdminRecordAction{{
|
||||
Name: "unlink_profile", Label: "acme.profiles::lang.unlink", Danger: true,
|
||||
Permissions: []string{"acme.profiles.manage_links"},
|
||||
Applies: func(ctx context.Context, record any) (bool, error) { /* pure read */ return true, nil },
|
||||
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
|
||||
tx, ok := cabana.TxFromContext(ctx) // the action's row-locked transaction
|
||||
_ = tx; _ = ok; _ = in.RecordID // backend_users.id
|
||||
return pact.AdminRecordActionResult{Message: "acme.profiles::lang.unlinked"}, nil
|
||||
},
|
||||
}},
|
||||
}}
|
||||
}
|
||||
```
|
||||
|
||||
### Settings link entries (D-02)
|
||||
```go
|
||||
// pact.SettingsItem fields: Code, Label, Description, Category, Icon, Model, Order,
|
||||
// Keywords, Permissions, Controller, Form, NewModel [VERIFIED: modules/pact/capabilities.go:570-587].
|
||||
// A link entry declares Controller and no Model/Form/NewModel [VERIFIED: settings.go:57-65].
|
||||
pact.SettingsItem{Code: "administrators", Controller: "summercms.cabana.users",
|
||||
Permissions: []string{"backend.manage_users"}, Label: "backend::lang.user.menu_label",
|
||||
Category: "<new system category phrase key>"}
|
||||
```
|
||||
`Code` must be an identifier and unique across all settings [VERIFIED: registry.go:287-292] — check that no installed plugin already uses `administrators`/`roles` (none found in the sibling repos grepped this session).
|
||||
|
||||
### Filters available to the users list
|
||||
Supported filter types are only `switch` (boolean `column`), `daterange` (time `column`) and `scope` (`scope`, `modelClass`, `nameFrom` required; `FilterScope` on the model and `FilterOptions` to serve choices); Winter's `conditions` key is refused [VERIFIED: filter_schema.go:112-114, 170-190, 218-248]. So: `is_superuser` → `switch`; `role` → `scope`; trashed → `scope` with two options (only deleted / without deleted); last login → `daterange`.
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| Admins only through `admin:create` / `admin:reset-password` | SPA screens + same commands | this phase | docs `users-and-permissions.md` "Managing administrators" and the My Account paragraph ("role, superuser flag, permissions and activation stay with the operator commands below" [VERIFIED: docs/backend/users-and-permissions.md:138]) must change |
|
||||
| Winter: no last-superuser rule; self-edit redirects to My Account | D-08/D-09 server rules | this phase | Deliberate tightening (CONTEXT specifics) |
|
||||
| Winter: system role permissions entirely disabled and code-defined; developer inherits orphaned permissions [VERIFIED: modules/backend/models/UserRole.php:38-63, 91-99] | Stored extras editable on top of code grants (D-16); cabana merges stored + code grants and has no orphan rule [VERIFIED: auth.go:54-66] | — | No behaviour change to `FindByID` in this phase |
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if Wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | Adding `Fillable`/`Rules`/`FilterScopes`/`FilterScope` methods and an unexported field to the exported `cabana.BackendUser` is non-breaking for consumers (none found using unkeyed literals or `==` on it) | Pattern 3 | A consumer that compares `BackendUser` values or embeds it in a model with its own `Rules` could change behaviour; mitigated by grepping sibling repos before tagging |
|
||||
| A2 | `pg_advisory_xact_lock` ordering after row locks is deadlock-free for every path listed | Pattern 4 | A future path that takes a row lock after the advisory lock could deadlock; the concurrency test catches regressions |
|
||||
| A3 | Conservative wildcard rule for the role ceiling (a role's `x.*` grant is covered only by an equal or broader editor wildcard) is acceptable | Pattern 5 | Could lock roles a non-superuser should be able to assign; superusers unaffected |
|
||||
| A4 | Recommended IDs `summercms.cabana.users` / `summercms.cabana.roles` (URLs `/backend/summercms/cabana/...`) are acceptable instead of Winter's `backend/users` | Pattern 1 | Purely cosmetic; IDs become stored in nothing except settings codes and links |
|
||||
| A5 | Record actions only (no bulk/toolbar) in the D-14 seam this phase | Pattern 2 | 19.7 may want a bulk "unlink"; the struct shape keeps it additive |
|
||||
| A6 | The SPA renders the new controllers with no `admin/` change | Summary | If a gap shows up in UAT (for example the users-count column), it means an admin/ change and a dist rebuild |
|
||||
|
||||
## Open Questions (RESOLVED)
|
||||
|
||||
1. **[RESOLVED: D-19 (no outranking).]** **Can a non-superuser manage an admin who outranks them?** (security, not in CONTEXT)
|
||||
- What we know: D-07 stops granting more than you hold, but a non-superuser with `backend.manage_users` can still set the password of, deactivate, or delete a non-superuser admin whose role grants more than their own. Setting the password is an account takeover, which escalates privilege. sm-user-plugin guards its privileged groups the same way (`guardPrivilegedMember` [VERIFIED: users_admin_controller.go:192-209]).
|
||||
- Recommendation: add an "outranks" refusal. When the target's effective grants are not a subset of the editor's, a non-superuser gets a 403 on update, delete and actions, using the Pattern 5 helper. This needs user confirmation.
|
||||
2. **[RESOLVED: D-20 (own password refused, points to My Account).]** **Changing your own password on the Administrators screen.** D-08 doesn't list `password`. My Account requires the current password and mints a new session; this screen would do neither. Recommendation: refuse a password change on your own record here and point to My Account. This needs confirmation.
|
||||
3. **[RESOLVED: D-21 chose (b), real `trigger` support in plan 02.]** **D-06 "disabled when `is_superuser` is on".** cabana's fields.yaml has no `trigger` key: the accepted keys are listed in `formFieldKeys` and include no `trigger` [VERIFIED: form_schema.go:38-50]. Options:
|
||||
- (a) server-side only: superusers bypass `Allows`, so their per-user permissions are inert, and a comment on the field says so;
|
||||
- (b) add `trigger` support, which changes `admin/` (dist rebuild, possibly the OpenAPI schema).
|
||||
- Recommendation: (a), recorded as a deviation.
|
||||
4. **[RESOLVED: D-21 chose (b), per-record locks in plan 02.]** **D-16 "code-declared grants shown as locked" per role.** `PermissionOption.Locked` is per request, not per record: `AdminPermissionOptions(ctx, field)` gets no record, and the form schema is fetched without an id [VERIFIED: field_permission.go:61-65, http.go:753-759]. Options:
|
||||
- (a) show the merged values. A save that drops a code-declared grant is refused with a 403 using the locked phrase, or the code-declared grants are stripped from the stored JSON, since they are merged at runtime anyway;
|
||||
- (b) an optional record-level locks provider whose codes travel in record meta, which changes `admin/` and the OpenAPI schema.
|
||||
- Recommendation: (a). This needs confirmation, because it softens "shown as locked".
|
||||
5. **[RESOLVED: deferred to 19.7; noted in the seam docs.]** **19.7 "set a link" needs input.** Record actions carry no payload [VERIFIED: capabilities.go:360-363].
|
||||
- Option: `AdminRecordActionInput` could later gain an optional `Payload json.RawMessage`, mirroring `AdminActionInput.Payload` [VERIFIED: capabilities.go:245-251]. That is additive, but it needs SPA input UI.
|
||||
- Recommendation: keep it out of 19.6.1, design it in 19.7, and note it in the seam docs.
|
||||
6. **[RESOLVED: D-12 as amended 2026-10-22: a password set keeps personal tokens; deactivate and delete stop them (plan 01 Decisions).]** **Personal API tokens on password set and on restore** (Pitfall 7).
|
||||
- Options: revoke the target's personal tokens on deactivate and delete, or accept today's semantics and document them.
|
||||
- Recommendation: document them, matching `admin:reset-password`. Revoking tokens would widen the phase.
|
||||
7. **[RESOLVED: omitted in this phase (plan 01).]** **Users count on the Roles list.** Winter shows `users_count`. cabana columns have no count/`valueFrom` key: `columnDocument` holds `Label`, `Searchable`, `Sortable`, `Type`, `Relation`, `Select`, `Invisible`, `Currency`, `CurrencyFrom` [VERIFIED: list_schema.go:62-73]. Recommendation: omit it in this phase.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Go toolchain | build, tests | ✓ | go1.27.2 | — |
|
||||
| Docker daemon | Postgres testcontainers (privilege, concurrency tests) | ✓ | Server 29.9.1 | `sudo -u golem` if the socket is denied (CLAUDE.md) |
|
||||
| Node / npm | only if `admin/` changes | ✓ | v22.23.3 / 12.2.1 | not needed by the recommended design |
|
||||
|
||||
**Missing dependencies with no fallback:** none.
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | Go `testing`; testcontainers-go Postgres (`postgres:16-alpine`) through `adminGorm(t)`, which skips under `-short` [VERIFIED: auth_test.go:321-325] |
|
||||
| Config file | none (package helpers `adminGorm`, `adminHandler`, `insertAdmin` in `modules/cabana/auth_test.go`, package `cabana_test`) |
|
||||
| Quick run command | `go test -short ./modules/cabana -run 'TestActionExtension\|TestBackendScreens\|TestRoleCeiling'` |
|
||||
| Full suite command | `go vet ./... && go test ./... && (cd examples/hello && go test ./...) && go test ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check` |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
(No requirement IDs are mapped; rows key on decisions.)
|
||||
|
||||
| Decision | Behavior | Type | Automated Command | File Exists? |
|
||||
|----------|----------|------|-------------------|-------------|
|
||||
| Activation | no real controllers → `Activate` nil, hello boots without secret; with controllers → pseudo-plugin controllers, permissions, 2 settings links | unit | `go test -short ./modules/cabana -run TestBackendScreensActivation`; `go test ./modules/surf -run TestAdminPrefix`; `cd examples/hello && go test ./...` | ❌ Wave 0 (surf/hello exist) |
|
||||
| D-14 | boot errors: unknown controller, no preview, duplicate vs owner, duplicate between contributors, reserved name, nil Run, empty label, unknown permission | unit | `go test -short ./modules/cabana -run TestActionExtensionBoot` | ❌ |
|
||||
| D-14 | offered only with permission + Applies, after owner's actions; run path row-locks, re-checks Applies (409), runs in tx, requires target controller permission | Postgres | `go test ./modules/cabana -run TestActionExtensionRun` | ❌ |
|
||||
| D-03 | non-superuser: list excludes superusers, GET superuser id 404, schema lacks `is_superuser` field and filter; superuser sees them | Postgres + unit | `go test ./modules/cabana -run TestAdministratorsSuperuserHidden` | ❌ |
|
||||
| D-05 | non-superuser sending `is_superuser` (true or false) on create/update → 403, row unchanged; superuser can grant | Postgres | `go test ./modules/cabana -run TestAdministratorsSuperuserFlag` | ❌ |
|
||||
| Pitfall 1 | GET projects `is_superuser`; saving an untouched superuser form keeps the flag | Postgres | `-run TestAdministratorsSuperuserProjected` | ❌ |
|
||||
| D-06/D-07 | per-user radio: locked codes for non-held perms, changed locked → 403, stored kept; Roles checkbox same | Postgres | `-run TestAdministratorsPermissionCeiling\|TestRolesPermissionCeiling` | ❌ |
|
||||
| D-07 | role options locked above ceiling; assigning locked role → 403; unchanged locked current role saves fine | Postgres | `-run TestAdministratorsRoleCeiling` | ❌ |
|
||||
| D-07 | subset helper incl. wildcard cases | unit (package cabana) | `go test -short ./modules/cabana -run TestRoleCeiling` | ❌ |
|
||||
| D-08 | self: change role (FormAfterUpdate path) / is_superuser / permissions / deactivate / delete → 403; profile fields save | Postgres | `-run TestAdministratorsSelfEdit` | ❌ |
|
||||
| D-09 | last active superuser: demote, deactivate (record + bulk), delete (form + bulk) → refused, rollback; with two superusers bulk-deleting both → refused | Postgres | `-run TestAdministratorsLastSuperuser` | ❌ |
|
||||
| D-09 | concurrent: two goroutines demote two different superusers; a third tx holds the advisory lock until `pg_locks` shows 2 waiters, then releases; exactly one succeeds, ≥1 active superuser remains, no deadlock error | Postgres | `-run TestAdministratorsLastSuperuserConcurrent` | ❌ |
|
||||
| D-10 | activate/deactivate/restore record + bulk; soft delete; trashed filter; row state `deleted` | Postgres | `-run TestAdministratorsLifecycle` | ❌ |
|
||||
| D-11 | create needs password + confirmation; update blank = unchanged; bcrypt verifies; >72 bytes 422; duplicate login/email incl. deleted and case variants 422 | Postgres | `-run TestAdministratorsPasswordAndIdentity` | ❌ |
|
||||
| D-12 | after deactivate / delete / password set for another admin, the target's old JWT → 401 next request; restore/activate don't bump | Postgres (HTTP) | `-run TestAdministratorsRevocation` | ❌ |
|
||||
| D-16/D-17 | system role code change refused; delete with holders (incl. soft-deleted) → 422 naming count; system role delete refused | Postgres | `-run TestRolesLifecycle` | ❌ |
|
||||
| SC-1 | `admin:*` commands and `/auth/me`, `/account` payloads unchanged | existing | `go test ./modules/cabana -run 'TestAdmin\|TestAccount\|TestMe'` | ✅ |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** `go vet ./modules/cabana ./modules/pact && go test -short ./modules/cabana ./modules/pact ./modules/surf`
|
||||
- **Per wave merge:** `go test ./modules/cabana ./modules/surf` (Docker) + `cd examples/hello && go test ./...`
|
||||
- **Phase gate:** full suite green; optionally `scripts/check-phase19.6.1.sh` wrapping the above, as earlier phases do (`scripts/check-phase19.5.sh`).
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `modules/cabana/backend_screens_test.go`: activation and schema (`-short`)
|
||||
- [ ] `modules/cabana/action_extensions_test.go`: D-14 boot and run
|
||||
- [ ] `modules/cabana/backend_privileges_internal_test.go`: subset/ceiling helpers (`package cabana`)
|
||||
- [ ] `modules/cabana/administrators_postgres_test.go`: D-03..D-12 HTTP-level, through `adminHandler` and `insertAdmin`. Each test seeds its own uniquely named admins and cleans them up, because the container is shared by the package.
|
||||
- [ ] `modules/cabana/roles_postgres_test.go`: D-16/D-17
|
||||
- [ ] `insertAdmin` has no superuser flag; add a helper that sets `is_superuser`.
|
||||
|
||||
## Security Domain
|
||||
|
||||
### Applicable ASVS Categories
|
||||
|
||||
| ASVS Category | Applies | Standard Control |
|
||||
|---------------|---------|-----------------|
|
||||
| V2 Authentication | yes (password set) | bcrypt via `bouncer.HashPassword`, min 4 / max 72 bytes, confirmation |
|
||||
| V3 Session Management | yes | `tokens_valid_after` cutoff, `FindByID` refusal of inactive/deleted |
|
||||
| V4 Access Control | yes (core of the phase) | `RequiredPermissions`, action `Permissions`, D-05..D-09 hooks, `RelationLockProvider`, `Locked` options, query scopes |
|
||||
| V5 Input Validation | yes | lagoon rules + `FormRules`; protected fill keys; JSON-int permission values |
|
||||
| V6 Cryptography | no new use | — |
|
||||
|
||||
### Known Threat Patterns for this stack
|
||||
|
||||
| Pattern | STRIDE | Standard Mitigation |
|
||||
|---------|--------|---------------------|
|
||||
| Mass assignment of `is_superuser`/`role_id`/`permissions` | Elevation | `protectedFillKey`; only the controller sets them, after the checks |
|
||||
| Hidden-field bypass (a non-superuser posts `is_superuser`) | Elevation | Presence check on the virtual value → 403 (D-05) |
|
||||
| Self-escalation through your own role or permissions | Elevation | D-08 checks (snapshot in `FormAfterUpdate`; `AdminSetPermissionValues`) |
|
||||
| Granting above your own permissions | Elevation | D-07 locks |
|
||||
| Account takeover of a higher-privileged admin by password reset | Elevation | Open Question 1 "outranks" refusal |
|
||||
| Removing the last superuser (lockout) | Denial of service | `guardLastSuperuser` under an advisory lock |
|
||||
| Superuser enumeration by id | Information disclosure | `FormExtendQuery` scope → 404 |
|
||||
| A scoped personal token used for superuser-only operations | Elevation | `actsAsSuperuser` requires an unscoped or full scope |
|
||||
| A contributed action skipping the screen's permission | Elevation | `protect()` checks the controller's permissions before the action's own |
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence, read this session)
|
||||
- `modules/cabana/{contracts,crud,extension,registry,http,actions,auth,commands,field_permission,relation_field,filter_schema,form_schema,settings,navigation,tx_context,model_fields,account,token_guard,messages,query,list_schema,schema_types}.go`
|
||||
- `modules/pact/capabilities.go`, `modules/party/registry.go`, `modules/bouncer/context.go`, `modules/lagoon/{backend_admin_migrations,validate,fill}.go`, `modules/surf/{router,admin_prefix_test}.go`
|
||||
- `gorm.io/gorm@v1.31.2` `schema/schema.go`, `callbacks/associations.go` (module cache)
|
||||
- `admin/src/components/form/{formState.ts,fields/PermissionEditorField.vue,fields/SwitchField.vue}`, `admin/src/views/PreviewView.vue`
|
||||
- sm-user-plugin Users screen: `fonoteka.go/plugins/golem15/user/controllers/` and `sm-grzybyfunkcjonalne-app/plugins/golem15/user/{controllers,classes/privileged.go}`
|
||||
- WinterCMS: `examples/golem15-wintercms-starter/modules/backend/{controllers/Users.php,controllers/UserRoles.php,models/UserRole.php,models/User.php,formwidgets/PermissionEditor.php,models/user/*.yaml,models/userrole/*.yaml,controllers/users/*.yaml,lang/en/lang.php}`
|
||||
- Planning: 19.6.1 CONTEXT, 19.7 CONTEXT/RESEARCH, 19.6 CONTEXT and 19.6-01-PLAN, 19.8-04-PLAN, `docs/backend/users-and-permissions.md`, `docs/backend/admin-controllers.md`
|
||||
|
||||
### Secondary (MEDIUM)
|
||||
- PostgreSQL advisory lock functions: postgresql.org/docs/current/functions-admin.html (cited from documentation knowledge; not fetched this session)
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH. No new dependencies; versions were read from go.mod and the local toolchain.
|
||||
- Architecture: HIGH for the mechanics, including save order, protected keys, lookups and activation. MEDIUM for the recommended shapes (the seam names, the unexported hooks and the snapshot field), which are discretion items.
|
||||
- Pitfalls: HIGH. Each pitfall traces to a cited line.
|
||||
|
||||
**Research date:** 2026-10-21
|
||||
**Valid until:** 2026-11-04. cabana is under active change (19.6, 19.7 and 19.8 are in flight), so re-check `crud.go` save order and `Activate` before executing.
|
||||
@@ -0,0 +1,550 @@
|
||||
---
|
||||
phase: "19.6.1"
|
||||
slug: "backend-administrators-screen"
|
||||
status: approved
|
||||
reviewed_at: "2026-10-21"
|
||||
shadcn_initialized: false
|
||||
preset: none
|
||||
created: "2026-10-21"
|
||||
---
|
||||
|
||||
# Phase 19.6.1 — UI Design Contract
|
||||
|
||||
> Visual and interaction contract for frontend phases. Generated by msd-ui-researcher, verified by msd-ui-checker.
|
||||
|
||||
This phase adds **no new screen design**. The Administrators and Roles screens are YAML-declared cabana controllers (`columns.yaml`, `fields.yaml`, `config_*.yaml`) rendered by the existing generic views in `admin/` (`ListView.vue`, `FormView.vue`, `PreviewView.vue`, `SettingsIndexView.vue`). The contract therefore fixes four things:
|
||||
|
||||
1. **What the YAML declares**: columns, filters, form tabs and field order, preview, record and bulk actions, settings entries. These are layout decisions made with existing components.
|
||||
2. **Two narrow SPA additions (D-21)**:
|
||||
- a field `trigger` (`action: disable|hide`, `field`, `condition: checked|unchecked`) evaluated in `FormView`/`PreviewView`;
|
||||
- per-record locked permission values rendered by `PermissionEditorField.vue`.
|
||||
3. **All copy** (en + pl) for both screens, the settings entries, the permission catalog, record/bulk actions, the preview status hint and every privilege refusal (D-05, D-07, D-08, D-09, D-17, D-19, D-20).
|
||||
4. **Where each refusal surfaces** in the existing UI (banner, field line, toast), so the server picks an error shape the SPA already displays.
|
||||
|
||||
The SPA has a complete design system: Direction C v2 (`.planning/phases/10-admin-vue-spa/design/README.md`), implemented as Tailwind 4 `@theme` tokens in `admin/src/styles/main.css` on Reka UI primitives. **This contract adds no tokens, colours, fonts, radii, shadows or icons outside the existing set.** Every class string below is copied from an existing component (`FormField.vue`, `PermissionEditorField.vue`, `RelationField.vue`, `RecordActions.vue`, `FormErrorBanner.vue`, `RowStateBadges.vue`, `main.css` callout kit).
|
||||
|
||||
Sources: 19.6.1-CONTEXT.md D-01..D-22 (locked); 19.6.1-RESEARCH.md (Patterns 1-6, Pitfalls, Open Questions 1-4 now resolved by D-19..D-21); approved house conventions from the 12.1, 12.2 and 16.3 UI-SPECs; WinterCMS `modules/backend/models/{user,userrole}/*.yaml` and `lang/{en,pl}/lang.php` (`user.*` block) for structure and wording; the codebase scan listed per section.
|
||||
|
||||
---
|
||||
|
||||
## Design System
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Tool | none (no shadcn; Vue 3 SPA with its own Direction C system. The shadcn gate does not apply to a Vue codebase with an established system, the same ruling as Phases 10.1, 12.1, 12.2, 16.3) |
|
||||
| Preset | not applicable |
|
||||
| Component library | Reka UI 2.9.10 (headless primitives) plus the local components in `admin/src/components/` |
|
||||
| Icon library | `@lucide/vue` 1.17.0 (named imports only). Settings tiles use the registry names `users` and `shield-check` through `admin/src/app/icons.ts`; the trigger note uses `Info` 14px; locks use the existing `Lock` 14px |
|
||||
| Font | DM Sans 400/600/700 (self-hosted `@fontsource/dm-sans`); no DM Mono in this phase |
|
||||
|
||||
---
|
||||
|
||||
## Component Inventory
|
||||
|
||||
Enumerated by `node --input-type=module -e "import * as r from 'reka-ui'; console.log(Object.keys(r).length)"` (run in `admin/`) — 478 exports — reka-ui@2.9.10 — 2026-10-21.
|
||||
Local components enumerated by `find admin/src/components -name '*.vue' | wc -l` — 59 components — summercms-admin@unversioned (private workspace package with no `version` field in `admin/package.json`; pinned by repo commit 053c643) — 2026-10-21.
|
||||
|
||||
Icon presence verified by `node --input-type=module -e "import * as l from '@lucide/vue'; for (const n of ['Lock','Info','CircleAlert','Users','ShieldCheck']) console.log(n, n in l)"` (run in `admin/`) against @lucide/vue@1.17.0 on 2026-10-21: all exported. `users` and `shield-check` are already mapped in `admin/src/app/icons.ts`.
|
||||
|
||||
This table is a **non-exhaustive** list of known-good components for this phase, not a closed allowlist. Checking for another Reka export or local component is the expected path.
|
||||
|
||||
| Component | Import path | Notes |
|
||||
|-----------|-------------|-------|
|
||||
| ListView | `admin/src/views/ListView.vue` | Both lists. Toolbar create/delete, search, filters, bulk actions menu, row-state badges. No change. |
|
||||
| FormView | `admin/src/views/FormView.vue` | Both forms. Gains trigger evaluation (Contract C6) and threads record permission locks (C7). |
|
||||
| PreviewView | `admin/src/views/PreviewView.vue` | Administrators preview with the `hint` header partial and `RecordActions`. Gains `hide` trigger evaluation only (C6). |
|
||||
| FormField | `admin/src/components/form/FormField.vue` | Gains the trigger note line (C6). Label, comment, error and notice markup unchanged. |
|
||||
| PermissionEditorField | `admin/src/components/form/fields/PermissionEditorField.vue` | Radio mode (per-user overrides) and checkbox mode (role grants). Gains record locks (C7). |
|
||||
| SwitchField | `admin/src/components/form/fields/SwitchField.vue` | The `is_superuser` toggle card. No change. |
|
||||
| RelationField | `admin/src/components/form/fields/RelationField.vue` | The `role` belongsTo dropdown. Its existing locked-option handling (`Lock` icon, `form.locked_note`) renders the D-07 role ceiling. No change. |
|
||||
| PasswordField | `admin/src/components/form/fields/PasswordField.vue` | `password` and `password_confirmation`. No change. |
|
||||
| RecordActions | `admin/src/components/form/RecordActions.vue` | Preview footer actions, owner's and contributed (D-14, D-15). No change. |
|
||||
| FormErrorBanner | `admin/src/components/form/FormErrorBanner.vue` | 403 forbidden banner and 422 banner (C8). No change. |
|
||||
| RowStateBadges | `admin/src/components/list/RowStateBadges.vue` | `deleted` and `disabled` states on the Administrators list. No change. |
|
||||
| PartialHost + `.summer-callout` kit | `admin/src/components/partial/PartialHost.vue`, `main.css` | Preview status hint (`_hint.htm`, html/template, server-rendered). No change. |
|
||||
| ConfirmDialog + `useConfirm` | `admin/src/components/ui/ConfirmDialog.vue`, `confirm.ts` | Record, bulk and delete confirmations. No change. |
|
||||
| `showToast` | `admin/src/state/useToasts.ts` | Action results and delete refusals. No change. |
|
||||
| `editablePayload`, `permissionValues` | `admin/src/components/form/formState.ts`, `control.ts` | Payload rules change for triggered fields (C6) and record-locked codes (C7). |
|
||||
|
||||
New SPA code is limited to: trigger evaluation helpers (suggested `admin/src/components/form/trigger.ts`), the note line in `FormField.vue`, the record-lock prop/inject path into `PermissionEditorField.vue`, and the regenerated `admin/src/api/schema.d.ts`. No new `.vue` file is required.
|
||||
|
||||
---
|
||||
|
||||
## Spacing Scale
|
||||
|
||||
Declared values for new layout in this phase (multiples of 4):
|
||||
|
||||
| Token | Value | Tailwind | Usage |
|
||||
|-------|-------|----------|-------|
|
||||
| xs | 4px | `gap-1` | not used by new markup |
|
||||
| sm | 8px | `gap-2` | Trigger note: icon-to-text gap (copied from the `RelationField` locked note `gap-2`) |
|
||||
| md | 16px | `px-4` | Permission editor row padding (existing, unchanged) |
|
||||
| lg | 24px | `gap-x-6` | Form grid column gap (existing `FormGrid`, unchanged) |
|
||||
| xl | 32px | `px-8` | Sticky footer padding (existing, unchanged) |
|
||||
| 2xl | 48px | — | not used |
|
||||
| 3xl | 64px | — | not used |
|
||||
|
||||
Exceptions (inherited from Direction C and already approved as locked in 16.3 on 2026-10-11; re-used verbatim, none introduced): label-to-control gap 6px (`gap-1.5` in `FormField`); form row gap 22px (`gap-y-[22px]` in `FormGrid`); card padding 28px (`p-7`); page stack 20px (`gap-5`); footer button gap 10px (`gap-2.5`); toggle card / callout vertical padding 14px (`py-3.5`); callout padding 14x18 (`.summer-callout`, `px-[18px] py-3.5` banner); permission row 12px vertical padding (`py-3`) and 56px minimum row height.
|
||||
|
||||
The trigger note line sits inside `FormField`'s existing `flex-col gap-1.5` stack, so it adds no new spacing value.
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
New markup uses exactly three styles; everything else is rendered by existing components unchanged.
|
||||
|
||||
| Role | Size | Weight | Line Height | Used by |
|
||||
|------|------|--------|-------------|---------|
|
||||
| Body | 14px | 400 | 1.5 | Field values, callout text, toasts, banner text (all existing) |
|
||||
| Label | 14px | 600 | 1.5 | Field labels, permission row labels, callout titles, button text (all existing) |
|
||||
| Meta | 13px | 400 | 1.5 | **New:** the trigger note line and the record-lock note line. Existing: field comments, error lines, the locked note |
|
||||
| Page heading (inherited) | 24px | 700 | 1.2 (`tracking-[-0.02em]`) | Record title in `FormView`/`PreviewView` header, unchanged |
|
||||
|
||||
Weights: new content uses 400 and 600 only. 700 appears only in the inherited page heading (locked system-level exception, as in 16.3). No 12px text is added (the existing 12px row-state badge is reused unchanged).
|
||||
|
||||
---
|
||||
|
||||
## Color
|
||||
|
||||
All colours are existing CSS variables (light / dark from `main.css`). Hard-coded hex values are forbidden in new markup.
|
||||
|
||||
| Role | Value (light / dark) | Usage |
|
||||
|------|----------------------|-------|
|
||||
| Dominant (60%) | `bg` #f4f6f9 / #111726, `surface` #ffffff / #182033 | Page, form card, list table, permission editor rows |
|
||||
| Secondary (30%) | `subtle` #f3f5f8 / #1f283d, `border` #e6e9ef / #29334b, `border-strong` #d2d8e2 / #3a4661, `muted` #566175 / #a9b3c6 | Permission section headers, segmented control track, read-only boxes, the trigger note and lock note text, `Lock`/`Info` icons, the "Deleted" row badge outline |
|
||||
| Accent (10%) | `primary` #22304d / #fcd34d; `sel` #fdf3cf / #3a3622; `ring` | See reserved list |
|
||||
| Destructive | `danger` #c62828 / #f58a8a, `danger-soft` #fdf0f0 / #3a1d24 | Form footer Delete (danger outline), delete confirm buttons, forbidden and 422 banners, field error lines, the "deleted" preview callout, danger toasts |
|
||||
| Semantic OK | `ok-bg` / `ok-text` | Existing "Allow" segment checked state and the "Yes" switch pill on preview; unchanged |
|
||||
|
||||
Accent reserved for (exhaustive for this phase):
|
||||
1. `primary` fill: the single primary button per screen that already exists: footer **Save** on forms, **Edit administrator** on the preview, **New administrator** / **New role** list toolbar create. No new primary button is added.
|
||||
2. `primary` track: the `is_superuser` switch when on (existing `SwitchField`), and checked role-grant checkboxes in the permission editor (existing).
|
||||
3. `sel` tint: the warning variant of the preview status callout (deactivated account; your own account), via `.summer-callout--warning`.
|
||||
4. `ring` (3px): focus-visible on every focusable element (base style), never removed.
|
||||
|
||||
Accent is never used for: record action buttons (outline), the trigger note, lock icons, row-state badges, settings tiles, or links.
|
||||
|
||||
---
|
||||
|
||||
## Copywriting Contract
|
||||
|
||||
All copy lives in `modules/phrasebook/backend/lang/{en,pl}/lang.yaml` (`backend::lang.*`). New groups: `user:` (with a nested `role:` block, mirroring Winter's `backend::lang.user.*` and `backend::lang.user.role.*` names so ported plugins and docs keep the same keys) and `permissions:`. Additions to existing groups: `form.trigger_*`, `permissioneditor.locked_*`, `settings.category_system`. Placeholders use `:name` / `:count` / `:field`. Plural texts are CLDR maps (`one`/`other` in en; `one`/`few`/`many`/`other` in pl), never pipe strings. Polish is the design language; English values follow the same meaning.
|
||||
|
||||
Validation messages that already exist (`required`, `min.string`, `max.string`, `confirmed`, `unique`) are reused for password and name rules; no new copy for them.
|
||||
|
||||
### Primary CTAs and key states
|
||||
|
||||
| Element | Key | EN | PL |
|
||||
|---------|-----|----|----|
|
||||
| Controller record name (`config_form.yaml` `name`) | `user.name` / `user.role.name` | Administrator / Role | Administrator / Rola |
|
||||
| Administrators list title | `user.list_title` | Administrators | Administratorzy |
|
||||
| Primary CTA, Administrators (toolbar create; create-form title) | `user.new` | New administrator | Nowy administrator |
|
||||
| Preview primary (Edit) | `user.edit` | Edit administrator | Edytuj administratora |
|
||||
| Preview subtitle | `user.preview` | Administrator details | Szczegóły administratora |
|
||||
| Roles list title | `user.role.list_title` | Roles | Role |
|
||||
| Primary CTA, Roles (toolbar create; create-form title) | `user.role.new` | New role | Nowa rola |
|
||||
| Role edit title | `user.role.edit` | Edit role | Edytuj rolę |
|
||||
| Save, Save and close, Cancel, Delete | existing `form.*` | unchanged | unchanged |
|
||||
| Empty state, Administrators (`noRecordsMessage`) | `user.list_empty` | No administrators in this view. Clear the search and filters, or create an administrator. | Brak administratorów w tym widoku. Wyczyść wyszukiwanie i filtry lub utwórz administratora. |
|
||||
| Empty state, Roles (`noRecordsMessage`) | `user.role.list_empty` | No roles in this view. Clear the search, or create a role. | Brak ról w tym widoku. Wyczyść wyszukiwanie lub utwórz rolę. |
|
||||
| Empty search (both lists) | existing `messages.list.empty_search` / `empty_search_hint` | unchanged | unchanged |
|
||||
| Error state, list or form load failed | existing `list.load_failed`, `form.load_failed` | unchanged (also the D-03 404 for a hidden superuser) | unchanged |
|
||||
| Error state, save failed (non-422/403) | existing `form.error_generic` | unchanged | unchanged |
|
||||
| Saved toast | existing `messages.form.saved` | unchanged | unchanged |
|
||||
|
||||
### Settings entries and permission catalog (D-01, D-02)
|
||||
|
||||
| Element | Key | EN | PL |
|
||||
|---------|-----|----|----|
|
||||
| Settings category | `settings.category_system` | System | System |
|
||||
| Administrators tile label | `user.menu_label` | Administrators | Administratorzy |
|
||||
| Administrators tile description | `user.menu_description` | Manage administrator accounts, their roles and permissions. | Zarządzaj kontami administratorów, ich rolami i uprawnieniami. |
|
||||
| Roles tile label | `user.role.menu_label` | Roles | Role |
|
||||
| Roles tile description | `user.role.menu_description` | Define roles and the permissions they grant. | Określ role i uprawnienia, które nadają. |
|
||||
| Permission tab (catalog section) | `permissions.tab_system` | System | System |
|
||||
| `backend.manage_users` label | `permissions.manage_users` | Manage administrators | Zarządzanie administratorami |
|
||||
| `backend.manage_users` comment | `permissions.manage_users_comment` | Create, edit, deactivate and delete administrator accounts. | Tworzenie, edycja, dezaktywacja i usuwanie kont administratorów. |
|
||||
| `backend.manage_roles` label | `permissions.manage_roles` | Manage roles | Zarządzanie rolami |
|
||||
| `backend.manage_roles` comment | `permissions.manage_roles_comment` | Create and edit roles and the permissions they grant. | Tworzenie i edycja ról oraz uprawnień, które nadają. |
|
||||
|
||||
### Administrators: list
|
||||
|
||||
| Element | Key | EN | PL |
|
||||
|---------|-----|----|----|
|
||||
| Column / filter: login | `user.login` | Login | Login |
|
||||
| Column: first name | `user.first_name` | First name | Imię |
|
||||
| Column: last name | `user.last_name` | Last name | Nazwisko |
|
||||
| Column: email | `user.email` | Email | E-mail |
|
||||
| Column / filter: role | `user.role_field` | Role | Rola |
|
||||
| Column / filter: last sign-in | `user.last_login` | Last sign-in | Ostatnie logowanie |
|
||||
| Column / filter: superuser (superusers only) | `user.superuser` | Superuser | Superużytkownik |
|
||||
| Column (invisible): created | `user.created_at` | Created | Utworzono |
|
||||
| Filter: active | `user.activated` | Active | Aktywny |
|
||||
| Filter: deleted (scope) | `user.filter_deleted` | Deleted | Usunięci |
|
||||
| Filter option | `user.filter_deleted_hide` | Hide deleted | Ukryj usuniętych |
|
||||
| Filter option | `user.filter_deleted_only` | Only deleted | Tylko usunięci |
|
||||
| Row state `deleted` | `user.state_deleted` | Deleted | Usunięty |
|
||||
| Row state `disabled` | `user.state_deactivated` | Deactivated | Dezaktywowany |
|
||||
| Delete selected confirm (danger) | `user.delete_selected_confirm` | one: "Delete :count administrator? They are signed out at once. Deleted accounts can be restored." other: "Delete :count administrators? They are signed out at once. Deleted accounts can be restored." | one: "Usunąć :count administratora? Zostanie od razu wylogowany. Usunięte konta można przywrócić." few/many: "Usunąć :count administratorów? Zostaną od razu wylogowani. Usunięte konta można przywrócić." other: "Usunąć :count administratora? Zostaną od razu wylogowani. Usunięte konta można przywrócić." |
|
||||
| Deleted toast | existing `messages.list.deleted` | unchanged | unchanged |
|
||||
|
||||
### Administrators: form
|
||||
|
||||
| Element | Key | EN | PL |
|
||||
|---------|-----|----|----|
|
||||
| Tab 1 | `user.account` | Account | Konto |
|
||||
| Tab 2 | `user.permissions` | Permissions | Uprawnienia |
|
||||
| Password label | `user.password` | Password | Hasło |
|
||||
| Password comment | `user.password_comment` | At least 4 characters. When editing, leave blank to keep the current password. | Co najmniej 4 znaki. Przy edycji zostaw puste, aby zachować obecne hasło. |
|
||||
| Password confirmation label | `user.password_confirmation` | Confirm password | Potwierdź hasło |
|
||||
| Role comment | `user.role_comment` | The role sets this administrator's permissions. Single permissions can be adjusted on the Permissions tab. | Rola określa uprawnienia administratora. Pojedyncze uprawnienia można zmienić w zakładce Uprawnienia. |
|
||||
| Role empty option | `user.role_none` | No role | Brak roli |
|
||||
| Superuser comment | `user.superuser_comment` | Grants every permission in the admin, including managing other administrators. Permission overrides below are not used while this is on. | Daje wszystkie uprawnienia w panelu, w tym zarządzanie innymi administratorami. Gdy jest włączone, nadpisania uprawnień poniżej nie są używane. |
|
||||
| Permission overrides label | `user.permission_overrides` | Permission overrides | Nadpisania uprawnień |
|
||||
| Permission overrides comment | `user.permission_overrides_comment` | Inherit follows the role. Allow and Deny apply to this administrator only. | Dziedzicz oznacza ustawienie z roli. Zezwól i Odmów dotyczą tylko tego administratora. |
|
||||
| Delete confirm (form footer, danger) | `user.delete_confirm` | Delete this administrator? They are signed out at once. The account can be restored from the list. | Usunąć tego administratora? Zostanie od razu wylogowany. Konto można przywrócić z listy. |
|
||||
|
||||
### Administrators: preview, record actions and bulk actions (D-10, D-15)
|
||||
|
||||
| Element | Key | EN | PL |
|
||||
|---------|-----|----|----|
|
||||
| Preview-only field: last sign-in | `user.last_login` | Last sign-in | Ostatnie logowanie |
|
||||
| Record + bulk action: Activate | `user.activate` | Activate | Aktywuj |
|
||||
| Activate confirm (record) | `user.activate_confirm` | Activate this administrator? They can sign in again, and their existing API tokens work again. | Aktywować tego administratora? Będzie mógł się zalogować, a jego dotychczasowe tokeny API znów zadziałają. |
|
||||
| Activate confirm (bulk) | `user.activate_selected_confirm` | Activate the selected administrators? They can sign in again, and their existing API tokens work again. | Aktywować zaznaczonych administratorów? Będą mogli się zalogować, a ich dotychczasowe tokeny API znów zadziałają. |
|
||||
| Activate success (record / bulk) | `user.activated` / `user.activated_selected` | Administrator activated / Selected administrators activated | Aktywowano administratora / Aktywowano zaznaczonych administratorów |
|
||||
| Record + bulk action: Deactivate | `user.deactivate` | Deactivate | Dezaktywuj |
|
||||
| Deactivate confirm (record) | `user.deactivate_confirm` | Deactivate this administrator? They are signed out at once and cannot sign in until activated again. | Dezaktywować tego administratora? Zostanie od razu wylogowany i nie zaloguje się, dopóki konto nie zostanie ponownie aktywowane. |
|
||||
| Deactivate confirm (bulk) | `user.deactivate_selected_confirm` | Deactivate the selected administrators? They are signed out at once and cannot sign in until activated again. | Dezaktywować zaznaczonych administratorów? Zostaną od razu wylogowani i nie zalogują się, dopóki ich konta nie zostaną ponownie aktywowane. |
|
||||
| Deactivate success (record / bulk) | `user.deactivated` / `user.deactivated_selected` | Administrator deactivated / Selected administrators deactivated | Dezaktywowano administratora / Dezaktywowano zaznaczonych administratorów |
|
||||
| Record + bulk action: Restore | `user.restore` | Restore | Przywróć |
|
||||
| Restore confirm (record) | `user.restore_confirm` | Restore this administrator? If the account is active, they can sign in again and their existing API tokens work again. | Przywrócić tego administratora? Jeśli konto jest aktywne, będzie mógł się zalogować, a jego dotychczasowe tokeny API znów zadziałają. |
|
||||
| Restore confirm (bulk) | `user.restore_selected_confirm` | Restore the selected administrators? Active accounts can sign in again, and their existing API tokens work again. | Przywrócić zaznaczonych administratorów? Aktywne konta będą mogły się zalogować, a ich dotychczasowe tokeny API znów zadziałają. |
|
||||
| Restore success (record / bulk) | `user.restored` / `user.restored_selected` | Administrator restored / Selected administrators restored | Przywrócono administratora / Przywrócono zaznaczonych administratorów |
|
||||
| Hint: deleted (danger) title / text | `user.trashed_hint_title` / `user.trashed_hint_desc` | This account has been deleted / It cannot be used to sign in. Use Restore to bring it back. | To konto zostało usunięte / Nie można się na nie zalogować. Użyj „Przywróć”, aby je odzyskać. |
|
||||
| Hint: deactivated (warning) title / text | `user.deactivated_hint_title` / `user.deactivated_hint_desc` | This account is deactivated / It cannot be used to sign in. Use Activate to allow sign-in again. | To konto jest dezaktywowane / Nie można się na nie zalogować. Użyj „Aktywuj”, aby ponownie zezwolić na logowanie. |
|
||||
| Hint: own account (warning) title / text | `user.self_hint_title` / `user.self_hint_desc` | This is your account / You can change your name, login and email here. Your role, permissions, superuser access and activation can only be changed by another administrator. Change your password in My account. | To Twoje konto / Tutaj zmienisz imię, nazwisko, login i e-mail. Rolę, uprawnienia, dostęp superużytkownika i aktywację może zmienić tylko inny administrator. Hasło zmień w sekcji Moje konto. |
|
||||
|
||||
Contributed record actions (D-14) bring their own label, confirm and success copy from the contributing plugin's phrasebook; this phase adds none.
|
||||
|
||||
### Roles: list and form (D-16, D-17)
|
||||
|
||||
| Element | Key | EN | PL |
|
||||
|---------|-----|----|----|
|
||||
| Name label / comment | `user.role.name_field` / `user.role.name_comment` | Name / Shown in the role picker on the administrator form. | Nazwa / Wyświetlana na liście ról w formularzu administratora. |
|
||||
| Code label / comment | `user.role.code_field` / `user.role.code_comment` | Code / A unique identifier used by plugins and the API. The code of a system role cannot be changed. | Kod / Unikalny identyfikator używany przez wtyczki i API. Kodu roli systemowej nie można zmienić. |
|
||||
| Description label | `user.role.description_field` | Description | Opis |
|
||||
| Column: system role | `user.role.is_system` | System role | Rola systemowa |
|
||||
| Permissions tab | `user.permissions` | Permissions | Uprawnienia |
|
||||
| Granted permissions label / comment | `user.role.permissions_field` / `user.role.permissions_comment` | Granted permissions / Every administrator with this role gets the checked permissions. | Nadane uprawnienia / Każdy administrator z tą rolą otrzymuje zaznaczone uprawnienia. |
|
||||
| Delete confirm (form footer, danger) | `user.role.delete_confirm` | Delete this role? This cannot be undone. A role that is still assigned to administrators cannot be deleted. | Usunąć tę rolę? Tej operacji nie można cofnąć. Roli przypisanej do administratorów nie można usunąć. |
|
||||
| Delete selected confirm (danger) | `user.role.delete_selected_confirm` | one: "Delete :count role? This cannot be undone." other: "Delete :count roles? This cannot be undone." | one: "Usunąć :count rolę? Tej operacji nie można cofnąć." few: "Usunąć :count role? Tej operacji nie można cofnąć." many: "Usunąć :count ról? Tej operacji nie można cofnąć." other: "Usunąć :count roli? Tej operacji nie można cofnąć." |
|
||||
|
||||
### Framework additions (D-21)
|
||||
|
||||
| Element | Key | EN | PL |
|
||||
|---------|-----|----|----|
|
||||
| Trigger note, `condition: checked` | `form.trigger_disabled_on` | Not editable while “:field” is on. | Nie można edytować, gdy „:field” jest włączone. |
|
||||
| Trigger note, `condition: unchecked` | `form.trigger_disabled_off` | Not editable while “:field” is off. | Nie można edytować, gdy „:field” jest wyłączone. |
|
||||
| Record lock note: grant declared in code (Roles) | `permissioneditor.locked_by_code` | Granted to this role by a plugin. It cannot be removed here. | Nadane tej roli przez wtyczkę. Nie można go tu odebrać. |
|
||||
| Record lock note: your own record (Administrators) | `permissioneditor.locked_self` | You cannot change your own permissions. | Nie możesz zmieniać własnych uprawnień. |
|
||||
|
||||
### Privilege refusals (server copy)
|
||||
|
||||
All of these are phrase keys the server puts in a `ForbiddenError` (`Message` and/or `Details`) or a `ValidationError` detail. Contract C8 maps where each one renders.
|
||||
|
||||
| Decision | Key | EN | PL |
|
||||
|----------|-----|----|----|
|
||||
| D-05 | `user.superuser_forbidden` | Only a superuser can grant, remove or change superuser access. | Tylko superużytkownik może nadawać, odbierać lub zmieniać dostęp superużytkownika. |
|
||||
| D-07 role ceiling (`RelationLock.Message`) | `user.role_ceiling` | You can only assign roles whose permissions you have yourself. | Możesz przypisywać tylko role, których uprawnienia sam posiadasz. |
|
||||
| D-07 permission ceiling | existing `permissioneditor.locked` | You cannot change this permission. | Nie możesz zmienić tego uprawnienia. |
|
||||
| D-08 own privileged fields | `user.self_privileged` | You cannot change your own role, permissions, superuser access or activation. Ask another administrator. | Nie możesz zmieniać własnej roli, uprawnień, dostępu superużytkownika ani aktywacji. Poproś innego administratora. |
|
||||
| D-08 delete yourself | `user.self_delete` | You cannot delete your own account. | Nie możesz usunąć własnego konta. |
|
||||
| D-08 deactivate yourself | `user.self_deactivate` | You cannot deactivate your own account. | Nie możesz dezaktywować własnego konta. |
|
||||
| D-09 | `user.last_superuser` | This is the last active superuser, so it cannot be deactivated, deleted or lose superuser access. Make another administrator a superuser first. | To ostatni aktywny superużytkownik, więc nie można go dezaktywować, usunąć ani odebrać mu dostępu superużytkownika. Najpierw nadaj ten dostęp innemu administratorowi. |
|
||||
| D-19 | `user.outranked` | This administrator has permissions you do not have, so you cannot change, deactivate or delete their account. | Ten administrator ma uprawnienia, których nie masz, więc nie możesz zmieniać, dezaktywować ani usuwać jego konta. |
|
||||
| D-20 | `user.own_password` | Change your own password in My account, where your current password is checked. | Własne hasło zmień w sekcji Moje konto, która sprawdza obecne hasło. |
|
||||
| D-17 role has admins | `user.role.delete_has_users` | one: "The role “:name” is assigned to :count administrator. Give them another role first. Deleted accounts count too." other: "The role “:name” is assigned to :count administrators. Give them another role first. Deleted accounts count too." | one: "Rola „:name” jest przypisana do :count administratora. Najpierw przydziel mu inną rolę. Liczą się też usunięte konta." few/many: "Rola „:name” jest przypisana do :count administratorów. Najpierw przydziel im inną rolę. Liczą się też usunięte konta." other: "Rola „:name” jest przypisana do :count administratora. Najpierw przydziel inną rolę. Liczą się też usunięte konta." |
|
||||
| D-17 system role | `user.role.delete_system` | System roles cannot be deleted. | Ról systemowych nie można usunąć. |
|
||||
| D-16 system role code | `user.role.code_locked` | The code of a system role cannot be changed. | Kodu roli systemowej nie można zmienić. |
|
||||
| Role code taken | `user.role.code_taken` | This code is already used by another role. | Ten kod jest już używany przez inną rolę. |
|
||||
| Login taken | `user.login_taken` | This login is already used by another administrator. | Ten login jest już używany przez innego administratora. |
|
||||
| Login held by a deleted admin | `user.login_taken_deleted` | This login belongs to a deleted administrator. Restore that account instead of creating a new one. | Ten login należy do usuniętego administratora. Przywróć to konto zamiast tworzyć nowe. |
|
||||
| Email taken | `user.email_taken` | This email is already used by another administrator. | Ten adres e-mail jest już używany przez innego administratora. |
|
||||
| Email held by a deleted admin | `user.email_taken_deleted` | This email belongs to a deleted administrator. Restore that account instead of creating a new one. | Ten adres e-mail należy do usuniętego administratora. Przywróć to konto zamiast tworzyć nowe. |
|
||||
|
||||
### Destructive actions
|
||||
|
||||
| Action | Confirmation approach | Copy |
|
||||
|--------|----------------------|------|
|
||||
| Delete an administrator (form footer) | Existing FormView `confirm.ask({ danger: true })` with `messages.deleteConfirm` | `user.delete_confirm`; confirm button existing `form.delete` |
|
||||
| Delete selected administrators (list toolbar) | Existing ListView danger confirm with the plural `messages.deleteConfirm` | `user.delete_selected_confirm` |
|
||||
| Deactivate (record and bulk) | Existing confirm, not `danger` (reversible, `AdminRecordAction.Danger` false) | `user.deactivate_confirm`, `user.deactivate_selected_confirm`; the confirm button label is the action label "Deactivate" |
|
||||
| Delete a role (form footer) | Existing danger confirm | `user.role.delete_confirm` |
|
||||
| Delete selected roles | Existing danger confirm | `user.role.delete_selected_confirm` |
|
||||
| Demote a superuser (switch off and Save) | No dialog: the save itself is the confirmation, as for every form field. The server refuses with D-09 when it would remove the last active superuser | — |
|
||||
|
||||
---
|
||||
|
||||
## Component Contracts
|
||||
|
||||
### C1. Settings placement (D-02)
|
||||
|
||||
- Two `pact.SettingsItem` link entries in category `backend::lang.settings.category_system`, rendered by the existing `SettingsIndexView` tiles (no SPA change):
|
||||
- `administrators`: label `user.menu_label`, description `user.menu_description`, icon `users`, `Controller: summercms.cabana.users`, `Permissions: [backend.manage_users]`, order 100;
|
||||
- `roles`: label `user.role.menu_label`, description `user.role.menu_description`, icon `shield-check`, `Controller: summercms.cabana.roles`, `Permissions: [backend.manage_roles]`, order 110.
|
||||
- Administrators comes before Roles in the group. A tile appears only when the principal can open its controller (existing filter), so an admin holding only `backend.manage_roles` sees only Roles.
|
||||
- No top-level navigation item and no section flyout entry.
|
||||
|
||||
### C2. Administrators list
|
||||
|
||||
`config_list.yaml` (shape copied from sm-user-plugin's Users list):
|
||||
- `title: user.list_title`, `recordUrl` → the record's **preview** (D-15), `recordsPerPage: 20`, `showCheckboxes: true`, `showSetup: true`, `noRecordsMessage: user.list_empty`.
|
||||
- Toolbar: `buttons: [create, delete]` (create label `user.new`; delete is the soft delete), search prompt `list.search_prompt`.
|
||||
- `bulkActions: [activate, deactivate, restore]`.
|
||||
- Messages: `create: user.new`, `empty: user.list_empty`, `deleteConfirm: user.delete_selected_confirm`, `deleted: messages.list.deleted`, `rowStateDeleted: user.state_deleted`, `rowStateDisabled: user.state_deactivated`. No `rowStateNegative`.
|
||||
- Row states (`ListRowStates`): `deleted` when `deleted_at` is set; `disabled` when not activated. A deleted and deactivated row shows both badges, in `RowStateBadges`' fixed order.
|
||||
|
||||
Columns, in this order:
|
||||
|
||||
| Column | Type | Visible | Searchable / sortable | Notes |
|
||||
|--------|------|---------|----------------------|-------|
|
||||
| `login` | text | yes | searchable, sortable | |
|
||||
| `first_name` | text | yes | searchable, sortable | |
|
||||
| `last_name` | text | yes | searchable, sortable | |
|
||||
| `email` | text | yes | searchable, sortable | |
|
||||
| `role` | relation `role`, select `name` | yes | sortable | Empty cell shows the existing `list.empty_value` dash |
|
||||
| `last_login` | datetime | yes | sortable | Never signed in shows the dash |
|
||||
| `is_superuser` | switch | yes, **superusers only** | — | Removed from the schema for non-superusers by the same per-request hook that removes the field and filter (D-03) |
|
||||
| `created_at` | datetime | invisible | sortable | Available through list setup |
|
||||
|
||||
Filters (supported types only: `switch`, `daterange`, `scope`):
|
||||
|
||||
| Scope | Type | Label | Notes |
|
||||
|-------|------|-------|-------|
|
||||
| `role` | scope (`modelClass` role, `nameFrom: name`) | `user.role_field` | |
|
||||
| `is_superuser` | switch, column `is_superuser` | `user.superuser` | Superusers only (D-03) |
|
||||
| `activated` | switch, column `is_activated` | `user.activated` | |
|
||||
| `last_login` | daterange, column `last_login` | `user.last_login` | |
|
||||
| `trashed` | scope with two options | `user.filter_deleted` | Options `user.filter_deleted_hide` and `user.filter_deleted_only`. With no option chosen the list shows every administrator, deleted ones marked with the "Deleted" badge (the list query is unscoped, as in sm-user-plugin) |
|
||||
|
||||
### C3. Administrators form
|
||||
|
||||
`config_form.yaml`: `name: user.name` (en "Administrator", pl "Administrator"), `preview: { headerPartial: hint }`, `recordActions: [activate, deactivate, restore]`, redirects copied from sm-user-plugin (create → the new record's preview; update `redirect` → stays on update; `redirectClose` → preview), messages `create: user.new`, `update: messages.form.update`, `saved: messages.form.saved`, `deleteConfirm: user.delete_confirm`, `deleted: messages.form.deleted`, `edit: user.edit`, `preview: user.preview`.
|
||||
|
||||
Record title (header h1, breadcrumb, `:name` in messages): "First Last" when either name is set, otherwise the login.
|
||||
|
||||
Fields, by tab (two-column `FormGrid`; `left`/`right` pair on one row):
|
||||
|
||||
**Tab `user.account` (the default tab)**
|
||||
|
||||
| Field | Type | Span | Context | Required | Notes |
|
||||
|-------|------|------|---------|----------|-------|
|
||||
| `login` | text | left | create, update, preview | yes | |
|
||||
| `email` | text | right | create, update, preview | yes | |
|
||||
| `first_name` | text | left | all | no | |
|
||||
| `last_name` | text | right | all | no | |
|
||||
| `password` | password (virtual) | left | create, update | create only | Comment `user.password_comment`. Blank on update means unchanged (existing `editablePayload` rule) |
|
||||
| `password_confirmation` | password (virtual) | right | create, update | create only | |
|
||||
| `role` | relation belongsTo, `nameFrom: name`, `emptyOption: user.role_none` | full | all | no | Comment `user.role_comment`. Roles above the editor's ceiling render locked through `RelationLockProvider` (D-07), using `RelationField`'s existing lock icon and `form.locked_note` |
|
||||
| `last_login` | datepicker (datetime) | left | **preview only** | — | Read-only display on the preview screen; never in a save body |
|
||||
|
||||
**Tab `user.permissions`**
|
||||
|
||||
| Field | Type | Span | Context | Notes |
|
||||
|-------|------|------|---------|-------|
|
||||
| `is_superuser` | switch (virtual, projected) | full | create, update, preview | Label `user.superuser`, comment `user.superuser_comment`. Present only for superusers (D-03). Renders as the existing toggle card |
|
||||
| `permissions` | permissioneditor, `mode: radio` | full | create, update, preview | Label `user.permission_overrides`, comment `user.permission_overrides_comment`. `trigger: { action: disable, field: is_superuser, condition: checked }` (C6, D-06). Options locked above the editor's grants (D-07, existing `Locked`); every option record-locked on the editor's own record (C7, D-08) |
|
||||
|
||||
- The tab order is fixed: Account, then Permissions. The Permissions tab is always present (a non-superuser still sees the overrides editor without the switch).
|
||||
- The `FormView` footer is unchanged: Delete (danger outline, update only), Cancel (ghost), Save and close (outline), Save (primary).
|
||||
- No client-side validation is added; 422 errors render under fields and in the existing banner, and the first invalid field's tab is activated (existing).
|
||||
|
||||
### C4. Administrators preview (D-15)
|
||||
|
||||
- Rendered by `PreviewView` unchanged. Header: back button, record title, subtitle `user.preview`, and the two tabs.
|
||||
- **Status hint** (`headerPartial: hint`, server-rendered with the `.summer-callout` kit exactly as sm-user-plugin's `_hint.htm`): one callout or nothing, by precedence:
|
||||
1. deleted → `summer-callout--danger`, `user.trashed_hint_title` / `user.trashed_hint_desc`;
|
||||
2. deactivated → `summer-callout--warning`, `user.deactivated_hint_title` / `user.deactivated_hint_desc`;
|
||||
3. the record is the signed-in administrator's own → `summer-callout--warning`, `user.self_hint_title` / `user.self_hint_desc`;
|
||||
4. otherwise no callout.
|
||||
The callout has `role="status"`; the hint is refetched after every record action (existing `hintKey`).
|
||||
- Fields shown: Account tab `login`, `email`, `first_name`, `last_name`, `role` (relation text box), `last_login` (read-only datepicker); Permissions tab `is_superuser` (Yes/No pill, superusers only) and `permissions` (read-only editor, no lock icons on a read-only screen, existing rule). Password fields never show (existing preview rule).
|
||||
- **Footer actions, left to right inside the right-aligned group**: the owner's record actions in declared order (`activate`, `deactivate`, `restore`, each only while it applies), then contributed actions (D-14) in boot merge order, then the primary **Edit administrator** button. All record actions are outline buttons (existing `RecordActions`). Applies rules:
|
||||
- `activate`: not deleted and not activated;
|
||||
- `deactivate`: not deleted, activated, and **not the editor's own record** (the action is not offered to yourself; the server still refuses it with `user.self_deactivate`);
|
||||
- `restore`: deleted.
|
||||
So a record shows at most one of Activate / Deactivate / Restore. A record action that targets the last active superuser is still offered and refused with `user.last_superuser` (the check needs the lock and is not a pure read).
|
||||
- Every action asks for confirmation (existing). Success: toast with the action's success phrase, record and hint reload. 403: danger toast with the server message (C8). 409: existing stale toast and reload.
|
||||
- A contributed action with `Danger: true` uses the danger confirm button; its footer button stays outline (existing rule).
|
||||
|
||||
### C5. Roles list and form (D-16, D-17)
|
||||
|
||||
List (`config_list.yaml`): `title: user.role.list_title`, `recordUrl` → the record's update form (Roles has **no preview block**), toolbar `[create, delete]`, `showCheckboxes: true`, no bulk actions, `noRecordsMessage: user.role.list_empty`, messages `create: user.role.new`, `deleteConfirm: user.role.delete_selected_confirm`, `deleted: messages.list.deleted`.
|
||||
|
||||
| Column | Type | Visible | Searchable / sortable |
|
||||
|--------|------|---------|----------------------|
|
||||
| `name` | text | yes | searchable, sortable |
|
||||
| `code` | text | yes | searchable, sortable |
|
||||
| `description` | text | yes | searchable |
|
||||
| `is_system` | switch | yes | sortable |
|
||||
| `created_at` | datetime | invisible | sortable |
|
||||
| `updated_at` | datetime | invisible | sortable |
|
||||
|
||||
No users-count column in this phase (cabana columns have no count/`valueFrom`; D-17's refusal names the count instead).
|
||||
|
||||
Form (`config_form.yaml`): `name: user.role.name` (en "Role", pl "Rola"), messages `create: user.role.new`, `update: user.role.edit`, `saved: messages.form.saved`, `deleteConfirm: user.role.delete_confirm`, `deleted: messages.form.deleted`. Create redirects to the new role's update form; Save and close returns to the list.
|
||||
|
||||
| Field | Type | Span | Tab | Notes |
|
||||
|-------|------|------|-----|-------|
|
||||
| `name` | text | left | default (`form.tab_default`) | Required. Comment `user.role.name_comment` |
|
||||
| `code` | text, `preset: { type: slug, field: name }` | right | default | Required. Comment `user.role.code_comment`. Follows the name on create until edited (existing preset rule); never follows on update. A change on a system role is refused with `user.role.code_locked` on the `code` field |
|
||||
| `description` | textarea, `size: tiny` | full | default | |
|
||||
| `permissions` | permissioneditor, `mode: checkbox` | full | `user.permissions` | Label `user.role.permissions_field`, comment `user.role.permissions_comment`. Code-declared grants render record-locked (C7, note `permissioneditor.locked_by_code`); permissions the editor lacks render request-locked (existing, note `permissioneditor.locked`) |
|
||||
|
||||
### C6. Field `trigger` (D-21, used by D-06)
|
||||
|
||||
**YAML and schema.** `trigger` becomes a valid key in `formFieldKeys`:
|
||||
|
||||
```yaml
|
||||
trigger:
|
||||
action: disable # disable | hide
|
||||
field: is_superuser # another field of the same form (same fields.yaml)
|
||||
condition: checked # checked | unchecked
|
||||
```
|
||||
|
||||
- Boot errors (fail fast, like every unknown key): a missing sub-key; an action other than `disable`/`hide`; a condition other than `checked`/`unchecked` (Winter's `value[...]` is not supported in this phase); a `field` that names no field of the same form, names itself, or names a field whose type is not `switch` or `checkbox`; `trigger` on a field nested in a repeater form.
|
||||
- The form schema payload carries it as `trigger: { action, field, condition }` on `cabana.FormField` (swag annotation → `admin/openapi/admin.json` → `schema.d.ts`, `scripts/check-admin-openapi.sh`).
|
||||
|
||||
**Evaluation (SPA).** One pure helper (suggested `admin/src/components/form/trigger.ts`) takes the field, the **whole form's** values (FormView `values`, not the active tab's scope, because the controlling field may sit on another tab) and the set of field names present in the filtered schema, and returns `'active' | 'inactive'`:
|
||||
- `checked`: active when the controlling value is on by `toggleOn` (`true`, `1`, `"1"`, `"true"`);
|
||||
- `unchecked`: active when it is off;
|
||||
- a controlling field absent from the schema for this request (for example `is_superuser` removed for a non-superuser, D-03) or excluded by `context` counts as **off**.
|
||||
It re-evaluates on every value change, immediately (no animation, no debounce).
|
||||
|
||||
**`disable` while active (FormView):**
|
||||
- The field renders through its own control **as read-only** (the control receives the field with `readOnly: true`, exactly as `PreviewField` does), keeping its current values visible. For `permissioneditor` this disables every segment / checkbox and, by the existing rule, hides the per-row lock icons and lock notes.
|
||||
- A note line renders in `FormField` **between the label and the control** (so it is visible above a long permission editor): `<p id="{controlId}-trigger" data-trigger-note class="flex items-center gap-2 text-[13px] text-muted">` with `Info` 14px (`shrink-0`, `aria-hidden`) and `form.trigger_disabled_on` (condition `checked`) or `form.trigger_disabled_off` (condition `unchecked`), `:field` = the controlling field's label. Its id joins the control's `aria-describedby`.
|
||||
- **Payload:** the field is left out of the save body (`editablePayload` skips it). The server then keeps the stored value: an absent `permissioneditor` key is not lifted (`liftPermissionValues` skips absent keys) and an absent scalar is not filled. Values typed before the field was disabled stay in the form and return when it is enabled again.
|
||||
- **Announcement:** when a user edit flips a trigger, the form's existing polite live region (`announce`) says "<field label>: <note text>" on disable, and nothing on enable.
|
||||
|
||||
**`hide` while active (FormView):**
|
||||
- The field is not rendered, is left out of the save body, and is left out of the tab computation: a tab whose every field is hidden disappears, and if it was the active tab the first remaining tab becomes active.
|
||||
- Its name is also left out of `FormErrorBanner`'s `fieldNames`, so a server message for a hidden field is shown in the banner as an orphan line rather than lost.
|
||||
|
||||
**PreviewView:** `hide` is evaluated the same way against the record's values; `disable` has no effect (every preview field is already read-only) and no note is shown.
|
||||
|
||||
**Dirty state:** because the payload changes when a trigger flips, toggling the controlling field marks the form dirty exactly once per real change (existing snapshot comparison); no extra rule.
|
||||
|
||||
**D-06 outcome on the Administrators form:** a superuser opens another superuser's record → Permissions tab shows the Superuser card on, then "Permission overrides" with the note "Not editable while “Superuser” is on." and a read-only editor. Turning the switch off enables the editor at once. A non-superuser never sees the switch and always gets an editable editor (subject to D-07 locks).
|
||||
|
||||
### C7. Per-record locked permission values (D-21, used by D-16 and D-08)
|
||||
|
||||
**Data.** The record response carries, per `permissioneditor` field, the codes the server fixes for **this record**, with the value to show and the note to show. Recommended shape (planner may rename; the rendering rules below are fixed): `meta.permissionLocks: { [fieldName]: Array<{ code: string, value: number, note: string }> }`, where `note` is already translated in the request locale. It is absent when no record lock applies, and it is never part of the form schema (the schema is fetched without a record id). `FormView` (and `PreviewView`) pass it to the field through the same path as `meta.labels` (prop through `FormGrid`/`FormField`/`FieldRenderer`, or a provided context; planner's call).
|
||||
|
||||
**Server use in this phase:**
|
||||
- Roles (D-16): every code-declared grant of the role (`pact.Permission.Roles` for its code), `value: 1`, note `permissioneditor.locked_by_code`.
|
||||
- Administrators (D-08): on the editor's own record, every offered code, `value` = the stored override (1, -1, or 0 for inherit), note `permissioneditor.locked_self`.
|
||||
|
||||
**Rendering in `PermissionEditorField`** (editable screens):
|
||||
- A record-locked row shows `value` (checkbox checked for 1; the radio segment for 1 / 0 / -1), its control is disabled with `aria-disabled="true"`, the existing `Lock` 14px icon follows the label, and the note line (`text-[13px] text-muted`, existing lock-note markup and id, linked by `aria-describedby`) shows the record lock's `note` instead of `permissioneditor.locked` or the option comment. The row gets `data-locked` and `data-locked-by="record"`.
|
||||
- When a row is both request-locked (the editor lacks the code) and record-locked, the record lock's value and note win.
|
||||
- On a read-only screen (preview, or a trigger-disabled editor) record locks render like every lock there: value shown, no icon, no note (existing `showsLock` rule).
|
||||
|
||||
**Payload.** Record-locked codes are **never sent**, exactly like non-offered codes (`permissionValues` skips them). The server keeps whatever it stores for them and skips them in the locked-value comparison, so a role whose code grant is not in its stored JSON saves without a 403. The displayed value comes from the lock, not from the stored JSON.
|
||||
|
||||
### C8. Where each refusal surfaces
|
||||
|
||||
The SPA shows a 403 save refusal as the `FormErrorBanner` forbidden banner (`error.message`, else `form.forbidden`) plus field lines for `error.details`; a 422 as field lines plus the error-count banner; and a delete or action refusal as a danger toast with `error.message`. A `ValidationError` has no message of its own (the envelope says "Validation failed"), so **every refusal that can come back from a delete or an action must be a `ForbiddenError` with `Message` set**, or its text is lost in the toast.
|
||||
|
||||
| Rule | Path | Status / error | Renders as | Copy |
|
||||
|------|------|----------------|-----------|------|
|
||||
| D-03 hidden superuser opened by id | GET record | 404 | Existing `form.load_failed` alert on the form/preview | existing |
|
||||
| D-05 `is_superuser` sent by a non-superuser | create / update | 403, `Message` + `Details.is_superuser` | Forbidden banner | `user.superuser_forbidden` |
|
||||
| D-07 locked permission changed | create / update | 403 (existing `applyPermissionValues`) | Forbidden banner + line under the editor | `permissioneditor.locked` |
|
||||
| D-07 locked role assigned | create / update | 403 (existing `checkRelationLocks`) | Forbidden banner + line under Role | `user.role_ceiling` (`RelationLock.Message`) |
|
||||
| D-08 own role / superuser / permissions changed | update | 403, `Message` + `Details` on the changed field | Forbidden banner + field line | `user.self_privileged` |
|
||||
| D-08 delete yourself | form delete, list bulk delete | 403, `Message` | Danger toast | `user.self_delete` |
|
||||
| D-08 deactivate yourself | bulk action (record action is not offered) | 403, `Message` | Danger toast | `user.self_deactivate` |
|
||||
| D-09 last active superuser | update (demote) | 403, `Message` + `Details.is_superuser` | Forbidden banner + line under the switch | `user.last_superuser` |
|
||||
| D-09 last active superuser | delete, bulk delete, deactivate (record / bulk) | 403, `Message` | Danger toast | `user.last_superuser` |
|
||||
| D-19 target outranks the editor | update | 403, `Message` | Forbidden banner | `user.outranked` |
|
||||
| D-19 target outranks the editor | delete, bulk delete, record / bulk actions | 403, `Message` | Danger toast | `user.outranked` |
|
||||
| D-20 own password on this screen | update | 403, `Message` + `Details.password` | Forbidden banner + line under Password | `user.own_password` |
|
||||
| Login / email taken (active or deleted) | create / update | 422, `Details.login` / `Details.email` | Field line + count banner | `user.login_taken[_deleted]`, `user.email_taken[_deleted]` |
|
||||
| Password rules (required on create, min 4, max 72 bytes, confirmed) | create / update | 422, `Details.password` | Field line | existing validation messages |
|
||||
| D-16 system role code changed | update | 422, `Details.code` | Field line | `user.role.code_locked` |
|
||||
| Role code taken | create / update | 422, `Details.code` | Field line | `user.role.code_taken` |
|
||||
| D-17 role still has admins | form delete, bulk delete | 403, `Message` (see note) | Danger toast | `user.role.delete_has_users` with `:name`, `:count` |
|
||||
| D-17 system role | form delete, bulk delete | 403, `Message` | Danger toast | `user.role.delete_system` |
|
||||
|
||||
Note on D-17: CONTEXT says "validation error". The visible requirement is that the toast names the count. With the current SPA that needs `ForbiddenError{Message}`; a `ValidationError` would show "Validation failed". The planner either uses the 403 shape or changes the SPA delete toast to read `error.details`. The 403 shape is recommended because it needs no SPA change. One refused row in a bulk delete rolls back the whole selection (existing), and the toast names the first refused role.
|
||||
|
||||
---
|
||||
|
||||
## UI Considerations
|
||||
|
||||
Applicable state considerations resolved: 9 elements (probe-checked 2026-10-21; 7 gap rows added after the probe) (Administrators list, Administrators form, Administrators preview with record actions, Roles list, Roles form, settings tiles, trigger mechanism, record permission locks, refusal surfaces). Copy for empty and error rows lives in the Copywriting Contract.
|
||||
|
||||
| Category | Element(s) | Status | Resolution / Reason |
|
||||
|----------|------------|--------|---------------------|
|
||||
| empty | Administrators list | ✅ covered | With no rows (search or filters) the list shows `user.list_empty`; an empty search shows the existing `messages.list.empty_search` + hint. Without search or filters the list always holds at least the viewer's own account, so the empty copy only shows for filtered views |
|
||||
| empty | Roles list | ✅ covered | `user.role.list_empty`; the seeded system roles mean an unfiltered list is never empty |
|
||||
| loading | Both lists, both forms, preview | ✅ covered | Existing skeletons and disabled footers in `ListView`/`FormView`/`PreviewView`; no new loading UI |
|
||||
| error | Both lists, both forms, preview | ✅ covered | Existing `list.load_failed` / `form.load_failed` alerts; a hidden superuser opened by id (D-03) lands in the same alert |
|
||||
| populated | Administrators list | ✅ covered | Columns login, first name, last name, email, role, last sign-in, superuser (superusers only); deleted and deactivated rows carry text badges |
|
||||
| partial | Administrators list (mixed states) | ✅ covered | Active, deactivated, deleted and deleted+deactivated rows coexist; each state is a text badge in fixed order (WCAG 1.4.1) |
|
||||
| zero-one-many | Bulk actions | ✅ covered | Bulk menu is enabled only with a selection (existing); one refused row rolls back the whole selection and the toast shows the refusal |
|
||||
| overflow | Both lists | ✅ covered | Existing pagination (20 per page) and horizontal table scroll; long emails wrap per existing `CellValue` rules |
|
||||
| long-text | Role name / record title in confirms and toasts | ✅ covered | `ConfirmDialog` description wraps with `[overflow-wrap:anywhere]` (added in 16.3); the record title truncates in the header h1 (existing `truncate`) |
|
||||
| populated | Administrators form, Account tab | ✅ covered | Login/email, first/last name, password/confirmation pairs, full-width role picker with locked roles shown locked |
|
||||
| partial | Administrators form, superuser target | ✅ covered | Permissions tab shows the Superuser card on and the overrides editor read-only with the trigger note (C6) |
|
||||
| partial | Administrators form, own record | ✅ covered | Every override row is record-locked with `permissioneditor.locked_self` (C7); a change of role or the Superuser switch is refused with `user.self_privileged` in the forbidden banner; a password change is refused with `user.own_password` under Password |
|
||||
| partial | Administrators form, non-superuser editor | ✅ covered | No Superuser switch, column or filter; permissions the editor lacks are locked rows; roles above the editor's ceiling are locked options |
|
||||
| empty | Permission editor with no catalog | ✅ covered | Existing `permissioneditor.empty` box; cannot happen in practice once the two `backend.*` codes are registered |
|
||||
| overflow | Permission editor (many plugin permissions) | ✅ covered | Sections per catalog tab, page scroll; the trigger note sits above the editor so it stays visible without scrolling to the comment |
|
||||
| error | Save refused (403) | ✅ covered | Forbidden banner with the server message, field lines for named fields, values and dirty state kept (existing); mapping in C8 |
|
||||
| error | Save invalid (422) | ✅ covered | Field lines, count banner, first invalid field's tab activated and focused (existing) |
|
||||
| populated | Administrators preview | ✅ covered | Status callout by precedence deleted > deactivated > own account; footer with at most one of Activate / Deactivate / Restore, then contributed actions, then Edit |
|
||||
| error | Record action refused (403) / stale (409) / gone (404) | ✅ covered | Danger toast with server message; 409 existing stale toast and reload; 404 existing load-failed state |
|
||||
| zero-one-many | Contributed record actions | ✅ covered | Zero: only owner actions; one or many: outline buttons after the owner's, in boot merge order, wrapping with the footer's `flex-wrap` |
|
||||
| long-text | Contributed action labels | 🧪 backstop | An SPA test renders the preview footer with three owner-style actions plus two contributed actions with 40-character labels at 1024px and asserts the footer wraps without overlapping the Edit button |
|
||||
| populated | Roles form, system role | ✅ covered | Code-declared grants checked and locked with `permissioneditor.locked_by_code`; extra grants editable within the D-07 ceiling; a code change refused under Code |
|
||||
| populated | Settings tiles | ✅ covered | Two tiles under "System", each shown only to holders of its permission (or superusers) |
|
||||
| populated | Trigger `disable` | 🧪 backstop | An SPA unit test: a radio permission editor with `trigger: {action: disable, field: is_superuser, condition: checked}` is read-only with the note when the switch is on, editable when off, and its key is absent from `editablePayload` while disabled |
|
||||
| partial | Trigger, controlling field absent | 🧪 backstop | An SPA unit test: with `is_superuser` missing from the schema the editor is editable and sent |
|
||||
| partial | Trigger `hide` on a whole tab | 🧪 backstop | An SPA unit test: hiding every field of the active tab removes the tab, activates the first tab, and a server error on the hidden field appears in the banner |
|
||||
| populated | Record locks | 🧪 backstop | An SPA unit test: a checkbox editor with `permissionLocks` shows the locked code checked and disabled with the record note, and `permissionValues` omits it from the payload |
|
||||
| empty | Administrators create form | ✅ covered | Blank form; password and confirmation are required on create (D-11); missing or mismatched fields come back as 422 field lines with the count banner (existing) |
|
||||
| long-text | Administrators and Roles form inputs | ✅ covered | Existing full-width text inputs; long login, email, name or description values scroll inside the input; the header h1 record title truncates (existing `truncate`) |
|
||||
| empty | Roles create form | ✅ covered | No record locks on a new role; the checkbox editor starts all unchecked and editable within the D-07 ceiling (codes the editor lacks are locked rows) |
|
||||
| error | Roles form refusals | ✅ covered | A system role's code change is refused under Code (D-16); deleting a role that still has admins returns 403 with `user.role.delete_has_users` in a danger toast (D-17, C8) |
|
||||
| zero-one-many | Roles bulk delete | ✅ covered | One selected role that still has admins (or is a system role) refuses the whole selection; the transaction rolls back and the danger toast names that role |
|
||||
| error | Invalid `trigger` YAML | ✅ covered | Unknown `action`, `condition` or a `field` not in the form is a boot error naming controller, field and key; it never reaches the SPA |
|
||||
| zero-one-many | Record permission locks | ✅ covered | No locks: plain editor. Some locks: those rows are locked with the record's note, others editable. All locked (editing your own record, D-08): the whole editor is read-only |
|
||||
| loading / empty | Trigger note, lock note | ➖ dismissed | Static text derived synchronously from schema and record; no async state |
|
||||
|
||||
---
|
||||
|
||||
## Registry Safety
|
||||
|
||||
| Registry | Blocks Used | Safety Gate |
|
||||
|----------|-------------|-------------|
|
||||
| shadcn official | none (shadcn not used) | not applicable |
|
||||
| third-party registries | none | not applicable |
|
||||
| npm dependencies | none added (Reka UI, `@lucide/vue`, Tailwind already installed at the versions above) | not applicable |
|
||||
|
||||
---
|
||||
|
||||
## Checker Sign-Off
|
||||
|
||||
- [ ] Dimension 1 Copywriting: PASS
|
||||
- [ ] Dimension 2 Visuals: PASS
|
||||
- [ ] Dimension 3 Color: PASS
|
||||
- [ ] Dimension 4 Typography: PASS
|
||||
- [ ] Dimension 5 Spacing: PASS
|
||||
- [ ] Dimension 6 Registry Safety: PASS
|
||||
- [ ] Dimension 7 Inventory Provenance: PASS
|
||||
|
||||
**Approval:** pending
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
phase: "19.6.1"
|
||||
slug: "backend-administrators-screen"
|
||||
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
|
||||
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
created: "2026-10-21"
|
||||
---
|
||||
|
||||
# Phase 19.6.1 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | Go `testing`; testcontainers-go Postgres via `adminGorm(t)` (skips under `-short`) |
|
||||
| **Config file** | none — package helpers `adminGorm`, `adminHandler`, `insertAdmin` in `modules/cabana/auth_test.go` |
|
||||
| **Quick run command** | `go vet ./modules/cabana ./modules/pact && go test -short ./modules/cabana ./modules/pact ./modules/surf` |
|
||||
| **Full suite command** | `go vet ./... && go test ./... && (cd examples/hello && go test ./...) && go test ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check` |
|
||||
| **Estimated runtime** | ~180 seconds (Docker-backed) |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** Run the quick run command
|
||||
- **After every plan wave:** `go test ./modules/cabana ./modules/surf` (Docker) + `cd examples/hello && go test ./...`
|
||||
- **Before `/msd-verify-work`:** Full suite must be green
|
||||
- **Max feedback latency:** 180 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
Filled by the planner per task; the decision → test map lives in `19.6.1-RESEARCH.md` § Validation Architecture.
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||
| 19.6.1-01-01 | 01 | 1 | D-01, D-02, D-04, D-14, D-15 | T-19.6.1-09, -10, -14 | seam merged before permission validation; contributed actions behind target + own permissions; no default role grant | smoke (-short + Postgres) | `go test -short -run '^TestBackendScreensSmoke$' ./modules/cabana`; `go test -run '^TestBackendScreensPostgresSmoke$' ./modules/cabana`; `go -C examples/hello test ./...` | ❌ W0 | ⬜ pending |
|
||||
| 19.6.1-01-02 | 01 | 1 | D-03, D-05..D-08, D-11, D-12, D-19, D-20 | T-19.6.1-01..-05, -07, -08, -11, -13, -17 | save-path rules in the save tx; is_superuser projected; hidden for non-superusers | smoke (Postgres) | `go test -run '^TestAdministratorsFormSmoke$' ./modules/cabana` | ❌ W0 | ⬜ pending |
|
||||
| 19.6.1-01-03 | 01 | 1 | D-08, D-09, D-10, D-12, D-19 | T-19.6.1-05, -06, -11, -12 | last-superuser guard under advisory lock; soft delete only; revocation | smoke (Postgres) | `go test -run '^TestAdministratorsLifecycleSmoke$' ./modules/cabana` | ❌ W0 | ⬜ pending |
|
||||
| 19.6.1-01-04 | 01 | 1 | D-02, D-07, D-16, D-17 | T-19.6.1-04, -15 | roles ceiling; system role code locked; in-use delete refused | smoke (Postgres) | `go test -run '^TestRolesSmoke$' ./modules/cabana`; `scripts/check-admin-openapi.sh --check` | ❌ W0 | ⬜ pending |
|
||||
| 19.6.1-02-01 | 02 | 2 | D-21 (D-16, D-08) | T-19.6.1-19, -20, -22, -23 | record-locked codes kept server-side; changed value 403 | smoke (-short) + vitest | `go test -short -run '^TestPermissionRecordLocksSmoke$' ./modules/cabana`; `scripts/check-admin-openapi.sh --check`; `npm --prefix admin run test` | ❌ W0 | ⬜ pending |
|
||||
| 19.6.1-02-02 | 02 | 2 | D-21 (D-06) | T-19.6.1-18, -21, -22, -23 | trigger boot errors; disabled field not sent; dist matches build | smoke (-short) + vitest + dist | `go test -short -run '^TestFieldTriggerSmoke$' ./modules/cabana`; `npm --prefix admin run test`; `scripts/check-admin-dist.sh` | ❌ W0 | ⬜ pending |
|
||||
| 19.6.1-03-01 | 03 | 3 | D-05, D-18 | T-19.6.1-24, -26 | harness + first rule over HTTP | Postgres | `go test -run '^TestAdministratorsSuperuserFlag$' ./modules/cabana` | ❌ W0 | ⬜ pending |
|
||||
| 19.6.1-03-02 | 03 | 3 | D-03, D-06, D-07, D-08, D-11, D-19, D-20 | T-19.6.1-24 | each save-path rule has a failing-without-rule test | unit (-short) + Postgres | `go test -short -run '^TestRoleCeiling$' ./modules/cabana`; `go test -run '^TestAdministrators(SuperuserHidden\|SuperuserProjected\|PermissionCeiling\|RoleCeiling\|SelfEdit\|PasswordAndIdentity\|Outranked\|OwnPassword)$' ./modules/cabana` | ❌ W0 | ⬜ pending |
|
||||
| 19.6.1-03-03 | 03 | 3 | D-04, D-09, D-10, D-12, D-14, D-16, D-17 | T-19.6.1-24, -25, -26 | held-lock race on 5 paths; revocation; seam boot and run; roles | unit (-short) + Postgres | `go test -short -run '^(TestActionExtensionBoot\|TestBackendScreensActivation)$' ./modules/cabana`; `go test -run '^(TestAdministratorsLastSuperuser\|TestAdministratorsLastSuperuserConcurrent\|TestAdministratorsLifecycle\|TestAdministratorsRevocation\|TestActionExtensionRun\|TestRolesLifecycle\|TestRolesPermissionCeiling)$' ./modules/cabana` | ❌ W0 | ⬜ pending |
|
||||
| 19.6.1-03-04 | 03 | 3 | D-21, D-18 | T-19.6.1-24, -27 | D-21 Go + SPA tests incl. backstops; fail-closed gate | unit + vitest + browser + gate | `go test -short -run '^(TestFieldTriggerBoot\|TestPermissionRecordLocks)$' ./modules/cabana`; `npm --prefix admin run test`; `npm --prefix admin run test:browser`; `bash scripts/check-phase19.6.1.sh --all` | ❌ W0 | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `modules/cabana/backend_screens_test.go` — activation and schema (`-short`; smoke in plan 01, `TestBackendScreensActivation` in plan 03)
|
||||
- [ ] `modules/cabana/backend_screens_smoke_test.go` — plan 01 Postgres smokes
|
||||
- [ ] `modules/cabana/record_locks_smoke_test.go`, `modules/cabana/field_trigger_smoke_test.go` — plan 02 smokes (`-short`)
|
||||
- [ ] `modules/cabana/action_extensions_test.go` (boot, `-short`) and `modules/cabana/action_extensions_postgres_test.go` (run) — D-14
|
||||
- [ ] `modules/cabana/backend_privileges_internal_test.go` — subset/ceiling helpers (`package cabana`)
|
||||
- [ ] `modules/cabana/administrators_harness_test.go` — superuser-capable seeding, roles, login, `isolateSuperusers`
|
||||
- [ ] `modules/cabana/administrators_postgres_test.go` — D-03, D-05..D-08, D-11, D-19, D-20 at HTTP level
|
||||
- [ ] `modules/cabana/administrators_lifecycle_postgres_test.go` — D-09 held-lock race, D-10, D-12
|
||||
- [ ] `modules/cabana/roles_postgres_test.go` — D-07 (roles), D-16, D-17, D-21 code-declared locks
|
||||
- [ ] `modules/cabana/field_trigger_test.go`, `modules/cabana/record_locks_test.go` — D-21 (`-short`)
|
||||
- [ ] `admin/tests/form/trigger.test.ts` + additions to PermissionEditorField, formState, FormView, PreviewView tests; `admin/tests/browser/record-actions-wrap.test.ts`
|
||||
- [ ] `scripts/check-phase19.6.1.sh` — phase gate
|
||||
- [ ] `modules/cabana/export_test.go` — `SuperuserLockKey`
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Administrators/Roles screens render and save in the admin SPA | SC-1 | visual SPA check | log in as superuser and as a limited admin, open Settings → Administrators / Roles, create, edit, deactivate |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 180s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
@@ -0,0 +1 @@
|
||||
No external API integration: the phase adds cabana's own Administrators and Roles admin screens, a framework-internal pact capability and SPA support for its own admin JSON API; no third-party API, SDK or service is integrated.
|
||||
@@ -108,6 +108,23 @@ Out of scope: MCP resources (the capability is designed for them, but they ship
|
||||
|
||||
fonoteka.go and sm-bm-app must still build and pass at the bumped pins. Push, tag and repo work need the user's go-ahead.
|
||||
|
||||
### Decisions after research (2026-10-21, plan-phase)
|
||||
- **D-32:** **Hybrid protocol.** pier also answers MCP 2026-07-28 clients, which have no sessions, through a stateless path next to D-25's stateful one (go-sdk v1.8.0 serves 2026-07-28 only statelessly). Both paths share identity, dispatch and per-principal filtering. Push for stateless clients follows the 2026-07-28 mechanism (`subscriptions/listen`). Amends D-25.
|
||||
- **D-33:** **pier is compiled in only with a build tag** (for example `summercms_mcp` in `summer.yaml` `build.tags`, the `summercms_s3` pattern). Without the tag the binary carries no go-sdk. With the tag, the config flag still keeps pier off by default (D-30). Plugin-facing `pact` types stay SDK-free, so plugins compile either way. Amends D-30 and SC-1.
|
||||
- **D-34:** **Admins set and remove links on the Administrators screen from Phase 19.6.1**, through its record-action seam. It is no longer a cabana "backend Users" screen 19.7 has to invent. Amends D-04 part 2 and D-07.
|
||||
- **D-35:** Defaults taken from research:
|
||||
- frontend tool filtering uses group grants, matching what `user.permission` enforces (not `MergedPermissions`);
|
||||
- sessions bind to `(client_id, user_id)`, so they survive token refresh;
|
||||
- the `ai` scope plays no part in the read/write ceiling;
|
||||
- consent is served as JSON endpoints in sm-oauthserver-plugin, with a `/connect` page in vue-demo-app;
|
||||
- the permission code is `golem15.users.manage_backend_links`.
|
||||
- **D-36:** **Plan split, 5 plans** (user-confirmed):
|
||||
1. framework seams;
|
||||
2. the pier module and framework release;
|
||||
3. sm-user-plugin;
|
||||
4. oauthserver, Journal and demo;
|
||||
5. unit tests, `check-phase19.7.sh` and the security review.
|
||||
|
||||
### Claude's Discretion
|
||||
- Exact Go identifiers: the pact capability and tool/prompt struct names, the link service, the context marker, the table name and the permission code.
|
||||
- Config key spellings (enable flag, endpoint path with default `/mcp`, the personal-token flag, the session TTL).
|
||||
|
||||
688
.planning/phases/19.7-generic-mcp-module/19.7-RESEARCH.md
Normal file
688
.planning/phases/19.7-generic-mcp-module/19.7-RESEARCH.md
Normal file
@@ -0,0 +1,688 @@
|
||||
# Phase 19.7: Generic MCP module - Research
|
||||
|
||||
**Researched:** 2026-10-21
|
||||
**Domain:** MCP streamable HTTP server inside a Go binary (official go-sdk), in-process route dispatch through surf, OAuth resource-server verification, frontend/backend identity link
|
||||
**Confidence:** HIGH for in-repo seams and SDK behaviour (source read and probed this session); MEDIUM for MCP-ecosystem direction (spec 2026-07-28); LOW for client-compatibility claims (marked [ASSUMED])
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
|
||||
#### Prerequisites and sequencing
|
||||
- **D-01:** **Both prerequisite todos are folded into 19.7**, as early plans before the MCP module itself: `2026-10-17-plugin-registered-frontend-permissions` and `2026-10-17-frontend-backend-account-link`. One sm-user-plugin release, one pin bump per app, and one security review cover the whole identity chain.
|
||||
- **D-02:** **19.7 runs after 19.4 is complete.** OAuth for MCP comes from `sm-oauthserver-plugin`'s MCP `wristband.Server` (`/oauth/mcp/*`, DCR, 19.4 D-01/D-02/D-06). 19.7 adds no second OAuth adapter. It adds the `backend` scope (D-21) and resolves the backend principal next to the frontend one.
|
||||
- **D-03:** **The account link lives in sm-user-plugin.** That covers:
|
||||
- the table, for example `golem15_user_backend_links` (name is the planner's call);
|
||||
- the proof round-trip;
|
||||
- a new backend permission (for example `golem15.user.manage_backend_links`);
|
||||
- the admin set/remove action;
|
||||
- an `app.Lookup` service the MCP module calls, for example `LinkedBackendUser(ctx, frontendUserID)` (name is the planner's call).
|
||||
|
||||
Only sm-user-plugin can verify a frontend sign-in (password, sociallogin, the mfa gate), and sm-oauthserver-plugin already `Requires()` it. The framework never imports the user plugin. — **Reversibility:** costly — the table and service become part of a core plugin's contract consumed by apps and pier.
|
||||
- **D-04:** **cabana gains an additive, generic seam** so that a plugin can contribute:
|
||||
- a section or action to **My Profile** (`cabana/account.go` and the admin SPA account page), which sm-user-plugin uses for "Link frontend account";
|
||||
- a record action on the **backend Users** screen (set/remove the link).
|
||||
|
||||
The seam is generic and names neither the user plugin nor MCP. Existing cabana payloads are unchanged. The researcher checks whether the 19.6 D-15 "Reset 2FA" action needs the same seam, and reuses it if so.
|
||||
- **D-05:** **The link is proven by a site sign-in round-trip.** "Link frontend account" creates a one-shot, signed, short-lived link request bound to the admin. The admin is sent to the site login with it. The frontend user signs in by any method the site supports (password, social login, 2FA), and the request is confirmed. Only then is the link saved. Linking by matching email, or by picking a user from a list without proof, is never allowed. — **Reversibility:** one-way — a link created without proof lets an admin act as that frontend user through MCP; unproven links cannot be told apart afterwards.
|
||||
- **D-06:** **Cardinality 1:1**, unique on both sides: a frontend user has at most one backend link, and a backend user has at most one frontend link.
|
||||
- **D-07:** **Admin management behind the new backend permission.** A holder can remove any link and can set one for editor onboarding. A link set by an admin records `linked_by` and fires an event. The self-service My Profile path always needs the D-05 proof.
|
||||
- **D-08:** **The link is resolved on every request, not stored in the OAuth grant.** Unlinking, deleting either user, or revoking the grant takes effect on the next MCP call with no token surgery. One indexed query per request, which may be cached briefly; the planner decides and states the staleness bound.
|
||||
|
||||
#### Frontend permission codes (todo)
|
||||
- **D-09:** A new optional `pact` capability is the frontend counterpart of `pact.HasPermissions`. A plugin returns codes with label, tab and comment. sm-user-plugin offers them in the user and group permission editors next to the rows seeded in `golem15_user_frontend_permissions`. Additive only: existing seeded rows, the table, the codes and the payloads stay as they are (core-plugin rule).
|
||||
|
||||
#### Tool declaration and dispatch
|
||||
- **D-10:** **A tool binds to a route by method + path** (for example `POST /_journal/api/v1/posts`), resolved at boot in surf's route table. **Boot fails** if the route does not exist. The call goes **in-process through that route's full middleware chain** (throttles, scope, permission), so it runs the same code path as HTTP. A tool carries no handler func.
|
||||
- **D-11:** **Arguments map through one input struct with tags.** `path:"id"` fills `{id}`, `query:"q"` goes to the query string, and the remaining `json` fields form the body. The input JSON schema is reflected from the same struct, with `jsonschema` tags supplying descriptions.
|
||||
- **D-12:** **Names are auto-prefixed with the plugin short code.** A plugin declares `create_post`, and pier exposes `journal_create_post`. A duplicate within one plugin is a boot error. Prompts follow the same rule. — **Reversibility:** costly — clients and saved agent configs reference tool names once shipped.
|
||||
- **D-13:** **Results pass the route's JSON through.**
|
||||
- A 2xx body is returned as text content plus `structuredContent`.
|
||||
- An optional Output struct produces an `outputSchema`.
|
||||
- A 4xx/5xx becomes `isError: true` carrying the route's own error body, so the agent sees the real validation errors.
|
||||
- **D-14:** **Annotations are derived from the HTTP method and can be overridden.** GET gives `readOnlyHint`, DELETE gives `destructiveHint`, and PUT gives `idempotentHint`. A tool may override any hint.
|
||||
- **D-15:** **Only token-guarded routes can be targets.** At boot the route must either carry the guard's token-accepting middleware (`user.api_token` for `user`, the cabana backend token guard for `backend`), or its tool must explicitly declare a guard. The second case covers routes that authenticate inside the handler. A cookie/CSRF-only or public route can't become a tool by accident.
|
||||
- **D-16:** **Permissions = route middleware ∪ tool declaration.** pier derives the requirements from route middleware where present (`user.scope:*`, `user.permission:<code>`, cabana scope/permission). A tool may also declare `Permissions`, which `tools/list` checks too. Journal is the case that needs this: `POST /_journal/api/v1/posts` has only `throttle:journal-api` and checks `cabana.Allows` inside `requireBackendPrincipal`. The declaration only filters the list, and the route still enforces on every call.
|
||||
- **D-17:** **Identity reaches the handler as a pre-resolved principal on the context.** pier sets the resolved principal (`bouncer.WithUser` / the user plugin's equivalent) behind a framework-owned marker that only an in-process pier dispatch can set; it must not be settable from an HTTP request. The following honour that marker before parsing the Authorization header, additively:
|
||||
- bouncer;
|
||||
- sm-user-plugin's `user.api_token` middleware;
|
||||
- Journal's `requireBackendPrincipal`.
|
||||
|
||||
No credential is minted per call. Security review covers the marker. — **Reversibility:** costly — three auth paths in two core plugins learn the marker.
|
||||
- **D-18:** **The first tools ship in this phase.** Journal declares backend post tools (list/show/create/update, plus whatever research finds useful), and sm-user-plugin declares a few frontend tools (for example own profile). sm-demo-app enables pier and mounts both, which proves both guards end to end.
|
||||
|
||||
#### Session credentials and filtering
|
||||
- **D-19:** **Personal tokens can open an MCP session behind a config flag**, off by default (spelling is the planner's call, for example `pier.allow_personal_tokens`).
|
||||
- A `user_api_tokens` bearer yields frontend tools only.
|
||||
- A cabana `g15_…` token yields backend tools only.
|
||||
- Each is capped by that token's own scope, exactly as over HTTP.
|
||||
|
||||
OAuth is always available.
|
||||
- **D-20:** **OAuth `read`/`write` are a ceiling.** A grant with only `read` offers only tools with `readOnlyHint`, and `write` unlocks the rest. Permissions always filter on top: a scope never grants anything.
|
||||
- **D-21:** **Backend tools need a separate `backend` OAuth scope.** A client must request `backend`. The consent screen shows a line such as "also act as admin <name>" only when the user has a link. A user can therefore connect a client to frontend tools only, and the scope is a visible, revocable ceiling. This adds the scope to sm-oauthserver-plugin's MCP Server (additive to 19.4's `read`/`write`/`ai`/`offline_access`). — **Reversibility:** costly — published scope name in the MCP metadata and in existing grants.
|
||||
- **D-22:** **The backend principal keeps the linked admin's real cabana permissions**, superuser included, capped by the grant's read/write scope (D-20). The security review covers superuser reach through MCP.
|
||||
- **D-23:** **A call runs as the tool's declared guard.** Each tool has exactly one guard: a `backend` tool runs as the linked admin, and a `user` tool runs as the frontend user. `tools/list` is the union of what each identity may call. Audit and actor attribution name the real actor.
|
||||
|
||||
#### Transport, SDK and features
|
||||
- **D-24:** **The SDK is the official `github.com/modelcontextprotocol/go-sdk`.** A decision note under `.planning/notes/` names it and its pinned version before code lands, and `.planning/research/STACK.md` is updated (CLAUDE.md dependency rule). Research also:
|
||||
- checks the transitive dependencies, including `google/jsonschema-go` for schema reflection;
|
||||
- confirms that the streamable HTTP handler supports the session-store and auth hooks needed for D-25.
|
||||
- **D-25:** **Stateful streamable HTTP with Redis-backed sessions** (`Mcp-Session-Id`, SSE streams), shared across instances on 19.3's Redis.
|
||||
- A session never replaces the bearer: **every request is authorized fresh**.
|
||||
- A session is **bound to the principal(s) that opened it**. A different token presenting the same session ID is refused.
|
||||
- DELETE on the endpoint ends a session.
|
||||
|
||||
— **Reversibility:** costly — session storage and the multi-instance fan-out shape the module's internals.
|
||||
- **D-26:** **`list_changed` is pushed.** These events fan out over Redis pub/sub to the instance holding the session, which sends `notifications/tools/list_changed` (and `prompts/list_changed`):
|
||||
- frontend user or group permission changes;
|
||||
- backend permission changes;
|
||||
- link set or removed;
|
||||
- grant or token revoked.
|
||||
|
||||
Every request still re-checks (D-08), so the push only shortens how long a client shows a stale list.
|
||||
- **D-27:** **Sessions use a sliding idle TTL** (configurable, for example 30 min) in Redis. Revoking the grant or token, deleting the user or removing the link ends or downgrades the session.
|
||||
- **D-28:** **The capability is designed for tools, prompts and resources; 19.7 ships tools and prompts.** A prompt is a template with arguments, filtered by the same guard and permissions and prefixed like tools (D-12). Resources (URI template → GET route) are deferred, but the `pact` capability's shape leaves room for them without a breaking change. — **Reversibility:** costly — the pact capability is a published framework API.
|
||||
- **D-29:** **Module name `pier`**, where outside boats (agents) dock to reach the beach. It gets a README in the standard structure with a `**Name:**` mnemonic and a row in the root `README.md` table.
|
||||
- **D-30:** **Off by default.** With pier disabled nothing is mounted and nothing changes, the same pattern as `http.api_docs.enabled` mounting `/api-docs` (`modules/surf/apidocs.go`).
|
||||
|
||||
#### Quality gates (from the roadmap)
|
||||
- **D-31:** The phase gets a **security review**: link proof, the D-17 context marker, session binding, scope ceilings and superuser reach. **Unit tests are the last plan.** Commit order follows the existing pattern:
|
||||
1. framework release (pier, the cabana seam, the bouncer marker);
|
||||
2. sm-user-plugin;
|
||||
3. sm-oauthserver-plugin (`backend` scope, consent line);
|
||||
4. sm-journal-plugin (tools, marker);
|
||||
5. app pointer bumps as separate commits.
|
||||
|
||||
fonoteka.go and sm-bm-app must still build and pass at the bumped pins. Push, tag and repo work need the user's go-ahead.
|
||||
|
||||
### Claude's Discretion
|
||||
- Exact Go identifiers: the pact capability and tool/prompt struct names, the link service, the context marker, the table name and the permission code.
|
||||
- Config key spellings (enable flag, endpoint path with default `/mcp`, the personal-token flag, the session TTL).
|
||||
- The link-request token format and TTL, within "one-shot, signed, short-lived, bound to the admin".
|
||||
- The cache bound for the per-request link lookup.
|
||||
- Which Journal and user tools ship beyond the minimum that proves both guards.
|
||||
- How the cabana seam is shaped (contribution interface vs extending `AdminController`), as long as it is generic and additive.
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
- **MCP resources** (URI template → GET route, guard-filtered) and `resources/subscribe`. The capability is shaped for them (D-28), so a follow-up phase adds them.
|
||||
- **Generated admin CRUD tools from `fields.yaml`.** Ruled out in the design note: agents use plugin management routes.
|
||||
|
||||
#### Reviewed Todos (not folded)
|
||||
The other `todo.match-phase` hits matched keywords only and are unrelated:
|
||||
- `journal-author-profile-fields`
|
||||
- `user-plugin-commands-open-database` (already folded into 19.5)
|
||||
- `translate-messages-as-nuxt-i18n-json-editor`
|
||||
- `reload-open-apps-when-a-new-version-is-deployed`
|
||||
- `uploads-keep-client-extension-stored-xss`
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
No requirement IDs are mapped to this phase (ROADMAP: "Requirements: TBD"). The planner maps the four roadmap success criteria and D-01..D-31 instead:
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|------------------|
|
||||
| SC-1 | Module enabled in config serves an MCP endpoint; disabled (default) mounts nothing | Pattern 1 (config-gated mount like `mountAPIDocs`), Pitfall 1 (surf buffers responses, so the mount needs a streaming route kind), Open Question 2 (build tag vs always linked) |
|
||||
| SC-2 | Plugin declares tools through an optional `pact` capability (name, description, input struct → JSON schema, guard, route); none declared → none exposed | Pattern 2 (pact capability shape, SDK-free), Pattern 4 (schema reflection with jsonschema-go `ForType`), D-12 prefixing |
|
||||
| SC-3 | `tools/list` filtered per principal; a call runs through the route's own middleware | Pattern 3 (surf route handler lookup + in-process dispatch), Pattern 5 (guard providers and requirement parsing), Pitfall 3 (frontend permission semantics differ between `user.permission` and `MergedPermissions`) |
|
||||
| SC-4 | One OAuth sign-in as frontend user; verified link → backend tools as that admin; removing link/grant removes them; security review; SDK decision note; unit tests last | Pattern 6 (token verification: `oas_at_` verifier does not exist yet), Pattern 7 (link proof via fresh frontend JWT), Pattern 8 (sessions in Redis with rehydration), Security Domain |
|
||||
</phase_requirements>
|
||||
|
||||
## Project Constraints (from CLAUDE.md)
|
||||
|
||||
- **Standard library first.** A new dependency needs a research doc or a phase/decision note naming it (`.planning/research/STACK.md` has the decided set). The go-sdk and jsonschema-go need the decision note **before** code lands (D-24).
|
||||
- **YAML via `goccy/go-yaml` only**, never `gopkg.in/yaml.v3`.
|
||||
- **Core plugins (user, blog/journal, pages, payment) — no breaking changes.** Every change in sm-user-plugin, sm-journal-plugin and sm-oauthserver-plugin must be additive (existing payloads, routes, tables, codes unchanged).
|
||||
- **Framework code never names a consuming application** (READMEs, docs, examples use neutral names such as `blog`/`acme`). pier must not name sm-user-plugin, Journal or MCP clients in code or docs; guard providers come in through `app.Lookup`.
|
||||
- **One Go module**; a new module gets a beach name (`pier`, D-29), a README in the standard structure (H1, one-sentence summary, import line, `**Name:**`, Overview, Features, Usage, API reference, Configuration, Dependencies, Testing) and a row in root `README.md` reusing the summary sentence.
|
||||
- **Docs rule:** a change to a module's exported API, config keys, CLI commands or dependencies updates that module's `README.md` and the affected `docs/` pages in the same commit. Every identifier in a README/docs page must exist (docs checker `summer docs:build --check`); config keys are reviewed by hand. Expect new `docs/services/mcp.md` (or similar) and updates to `docs/services/oauth-server.md`, `docs/services/authentication.md`, `docs/plugins/registration.md`, `docs/backend/users-and-permissions.md`.
|
||||
- **Tests:** `go vet ./...` and `go test ./...` green at every commit; Docker tests skip under `-short`; a `TestMain` starting containers calls `flag.Parse()` then checks `testing.Short()`.
|
||||
- **Admin SPA:** a change in `admin/` (the My Profile seam) requires rebuilding and committing `modules/boardwalk/dist` and passing `scripts/check-admin-dist.sh`; swag annotation changes need `scripts/check-admin-openapi.sh` (cabana `admin_openapi.go` → `admin/openapi/admin.json` → `admin/src/api/schema.d.ts`).
|
||||
- **MSD rules:** lean planning (few large plans; present plan count + one-line scopes and wait for confirmation); unit tests are the last plan; STATE.md only through `msd_run query state.*`; g15office sync.
|
||||
- **Commits:** no co-author tags; one logical change per commit; planning docs and code in separate commits; stage files by explicit path; several sessions work concurrently (19.6 and 19.8 are in flight — do not touch their uncommitted files). No push/tag/deploy without go-ahead; every pushed `vX.Y.Z` framework tag gets a changelog post the same day.
|
||||
- **Shared plugin repos:** commit and push the plugin first, then the pointer bump in the app as a separate commit (`.planning/notes/core-plugins-own-repos.md`).
|
||||
- **User memory:** never add co-author tags; core plugins must not break.
|
||||
|
||||
## Summary
|
||||
|
||||
The phase is buildable with the official Go SDK, but three facts discovered this session reshape the plan relative to what CONTEXT assumes:
|
||||
|
||||
1. **The go-sdk (v1.8.0, 2026-09-04) holds stateful sessions only in process memory** — `StreamableHTTPHandler.sessions map[string]*sessionInfo` with no pluggable store, and an unknown `Mcp-Session-Id` answers `404 session not found` [VERIFIED: go-sdk@v1.8.0/mcp/streamable.go:47-55, 558-566 — `sessions map[string]*sessionInfo // keyed by session ID`, `http.Error(w, "session not found", http.StatusNotFound)`]. D-25's Redis-shared sessions are still achievable: pier writes a thin session router over the SDK's **exported** `StreamableServerTransport{SessionID}` and `Server.Connect(ctx, t, &ServerSessionOptions{State})`, persisting the JSON-serialisable `ServerSessionState` in Redis and **rehydrating** a session on whichever instance receives the request. A probe built this session proved it: a session initialised on instance A served its next `tools/call` on instance B after rehydration [VERIFIED: local probe, output "rehydrated sess-A on B … call2: map[id:9 served_by:B]"]. Per-session `*mcp.Server` instances give per-principal tool lists and targeted `notifications/tools/list_changed` (the SDK debounces and sends it to that server's sessions when tools are added/removed) [VERIFIED: local probe "list_changed received; tools now: 1"].
|
||||
2. **The MCP spec moved to a sessionless protocol.** Spec 2026-07-28 removed `initialize` and the `Mcp-Session-Id` session [CITED: blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/]. The SDK serves 2026-07-28 **only** in `Stateless` mode; a stateful server rejects such requests with `CodeUnsupportedProtocolVersion` listing the legacy versions, and SDK clients fall back to `initialize` at 2025-11-25 (probe: "stateful negotiated: 2025-11-25 session: true"). D-25 therefore pins pier to protocol ≤ 2025-11-25. This works today, but it is a forward-compatibility risk the user should confirm (Open Question 1).
|
||||
3. **Several pieces CONTEXT treats as existing do not exist yet:** (a) nothing verifies `oas_at_` MCP access tokens at a resource — the oauthserver README says "Verifying MCP access tokens at a resource arrives with the MCP module, not here"; (b) the MCP consent page is "the application's" (wristband redirects to `{issuer}/connect?request=`) and neither sm-oauthserver-plugin nor vue-demo-app ships one; (c) there is **no backend Users admin screen** in cabana (admins are CLI-managed), so D-04's "record action on the backend Users screen" has no host; (d) wristband's MCP authorize allow-list of scopes is a hard-coded map, so adding `backend` needs a framework change, not only a plugin option; (e) the user plugin has **no** `user.api_token`-guarded route for "own profile" (`/fetch` is JWT-only), so D-18's frontend tool needs a new additive route; (f) surf's per-route recovery **buffers the whole response and makes `Flush` a no-op**, so an SSE endpoint cannot be mounted through the normal route wrapper.
|
||||
|
||||
**Primary recommendation:** Build pier as a framework module that (a) gets a compiled per-route handler lookup from surf (additive `Router` method plus `SetPathValue`-based dispatch, mirroring `overlap.go`), (b) mounts `/mcp` through a new non-buffering streaming route kind, (c) authenticates every HTTP request itself through guard/verifier providers published by plugins (`oas_at_` verifier in sm-oauthserver-plugin, user token resolver in sm-user-plugin, cabana built in), (d) runs a Redis-backed rehydrating session router over the SDK transport with one `*mcp.Server` per session, and (e) passes identity to routes through a context-only dispatch marker honoured in `bouncer.Registry.Middleware` and `cabana.ResolveBackend` — which covers `user.api_token` and Journal's `requireBackendPrincipal` with no change to their code (Journal needs only a one-line change in `optionalBackendPrincipal`).
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| MCP transport (POST/GET/DELETE `/mcp`, SSE, sessions) | API / Backend (pier) | Database/Storage (Redis session registry) | Protocol lives in the binary; Redis only stores session state and fans out events |
|
||||
| Bearer verification per request | API / Backend (pier, via published verifiers) | — | `oas_at_` verifier in sm-oauthserver-plugin, user personal tokens in sm-user-plugin, `g15_` in cabana; pier never parses another plugin's tables |
|
||||
| Tool declaration (name, schema, guard, route) | API / Backend (plugins via `pact`) | — | Compiled Go declarations, SDK-free contract |
|
||||
| Tool execution | API / Backend (surf in-process dispatch) | — | Runs the route's own middleware chain (D-10) |
|
||||
| Per-principal filtering | API / Backend (guard providers) | Database (permission reads) | The same code that enforces the route answers "may call" |
|
||||
| OAuth authorize/consent/token | API / Backend (wristband + sm-oauthserver-plugin) | Browser (consent page) | Consent UI is the application's SPA page or a plugin-rendered page |
|
||||
| Link proof round-trip | API / Backend (sm-user-plugin) | Browser (site sign-in page, admin SPA My Profile) | Only the user plugin can verify a frontend sign-in (D-03) |
|
||||
| My Profile contribution seam | Frontend admin SPA (boardwalk/admin) | API (cabana account endpoints) | Generic seam, rendered by the SPA from JSON |
|
||||
| Session store and event fan-out | Database / Storage (Redis via cooler) | API | Shared state across instances (D-25/D-26) |
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| `github.com/modelcontextprotocol/go-sdk` (packages `mcp`, `auth`, `oauthex`) | v1.8.0 (2026-09-04) [VERIFIED: proxy.golang.org `@latest` → `{"Version":"v1.8.0","Time":"2026-09-04T08:08:52Z"}`] | MCP server, streamable HTTP transport, PRM types, test client | Locked by D-24; official SDK; README: "official Go software development kit (SDK) for the Model Context Protocol"; supports specs 2026-07-28, 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05 [VERIFIED: go-sdk@v1.8.0/README.md] |
|
||||
| `github.com/google/jsonschema-go` (package `jsonschema`) | v0.4.3 (2026-04-17) [VERIFIED: proxy.golang.org info] | Reflect tool input/output schemas from Go structs; validate arguments | Already the SDK's own schema library; `ForType(reflect.Type, *ForOptions)` works on a type known only at runtime |
|
||||
| `github.com/redis/go-redis/v9` | v9.23.0 (already in framework go.mod) [VERIFIED: summercms.go/go.mod:33] | Session registry, sliding TTL, pub/sub fan-out | Already decided in 19.3 (`cooler`) |
|
||||
| Go stdlib `net/http`, `crypto/rand` (`rand.Text`), `net/http/httptest` (ResponseRecorder for in-process dispatch) | Go 1.27.2 installed [VERIFIED: `go version`] | Dispatch, session IDs | Stdlib first |
|
||||
|
||||
Go version requirement: go-sdk `go 1.25.0`, jsonschema-go `go 1.23.0` [VERIFIED: go.mod of each]; the framework is on `go 1.27.0`, so no toolchain change.
|
||||
|
||||
### Supporting
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| `github.com/alicebob/miniredis/v2` | v2.40.0 (already in go.mod) | Redis pub/sub and TTL in Docker-free tests | Session store and fan-out unit tests |
|
||||
| `github.com/testcontainers/testcontainers-go` + `modules/postgres` | v0.44.0 (already) | Postgres-backed link/permission/token tests | Docker suites (skipped under `-short`) |
|
||||
| go-sdk `mcp.Client` + `StreamableClientTransport` | v1.8.0 | End-to-end tests: connect, `ListTools`, `CallTool`, `ToolListChangedHandler` | Every pier integration test; its `HTTPClient` field carries the bearer |
|
||||
|
||||
### Transitive modules linked by importing `mcp` + `auth` + `jsonschema`
|
||||
[VERIFIED: `go list -deps` on a probe module this session]
|
||||
`github.com/google/jsonschema-go v0.4.3`, `github.com/segmentio/asm v1.1.3`, `github.com/segmentio/encoding v0.5.4`, `github.com/yosida95/uritemplate/v3 v3.0.2`, `golang.org/x/oauth2 v0.35.0`, `golang.org/x/sync v0.20.0`, `golang.org/x/sys v0.41.0`, `golang.org/x/time v0.15.0`. The framework already requires newer `x/sync v0.23.0` and `x/sys v0.48.0`, so MVS keeps those; **new to the graph:** go-sdk, jsonschema-go, segmentio/asm, segmentio/encoding, uritemplate, x/oauth2, x/time. `golang-jwt/jwt/v5` and `x/tools` appear in go-sdk's go.mod but are **not linked** by these packages.
|
||||
|
||||
**Binary size:** a stripped probe using `mcp`+`auth`+`jsonschema` is 8,835,335 bytes vs 5,832,967 for a bare `net/http` program — about **+2.9 MiB** [VERIFIED: local build probe]. The real increment in an app that already links x/sys etc. will be somewhat smaller. This matters for Open Question 2.
|
||||
|
||||
**License:** go-sdk LICENSE states a transition from MIT to Apache-2.0 ("All new code … licensed under Apache-2.0 … Contributions … not yet granted explicit permission to relicense remain licensed under the MIT License") [VERIFIED: go-sdk@v1.8.0/LICENSE]; jsonschema-go is MIT [VERIFIED: jsonschema-go@v0.4.3/LICENSE]. Both permissive.
|
||||
|
||||
### Alternatives Considered
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| SDK `StreamableHTTPHandler` as-is | Own session router over `StreamableServerTransport` | The handler cannot share sessions across instances (in-memory map, 404 on miss). The own router (~250 lines) is required for D-25 rehydration; the transport, JSON-RPC, SSE and protocol negotiation stay the SDK's |
|
||||
| One shared `*mcp.Server` + receiving middleware filter | One `*mcp.Server` per session | The SDK has no exported "notify this session of list_changed"; per-session servers get it for free via `AddTool`/`RemoveTools` (probe verified). Middleware filtering is still added for the per-request re-check (D-08) |
|
||||
| Stateful only (D-25) | Hybrid: stateless SDK handler for 2026-07-28 requests, stateful router for ≤2025-11-25 | Forward-compatible with the current spec; needs user confirmation because D-25 is locked (Open Question 1) |
|
||||
| `mark3labs/mcp-go` | — | Not considered: D-24 locks the official SDK |
|
||||
|
||||
**Installation (framework go.mod only; plugins must not require the SDK):**
|
||||
```bash
|
||||
go get github.com/modelcontextprotocol/go-sdk@v1.8.0 github.com/google/jsonschema-go@v0.4.3
|
||||
```
|
||||
|
||||
**Version verification:** `go list -m -versions github.com/modelcontextprotocol/go-sdk` lists `… v1.7.0 v1.8.0-pre.1 v1.8.0-pre.2 v1.8.0`; no v1.9 pre-release exists yet [VERIFIED: go proxy, 2026-10-21]. The SDK notes the `allowsessionsinstateless` compatibility knob "will be removed in the 1.9.0 version" — irrelevant to a stateful server but a sign 1.9 is coming; re-check `@latest` before `go get` if executing after ~2026-11-20.
|
||||
|
||||
## Package Legitimacy Audit
|
||||
|
||||
The `package-legitimacy check` seam supports only npm/pypi/crates ("Usage: msd-tools package-legitimacy check --ecosystem <npm|pypi|crates>"), so Go modules cannot get an `OK` verdict from it. Per the provenance rule the package names are tagged `[ASSUMED]` and need a `checkpoint:human-verify` before `go get`, as 19.6 did for its Go modules.
|
||||
|
||||
| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition |
|
||||
|---------|----------|-----|-----------|-------------|---------|-------------|
|
||||
| `github.com/modelcontextprotocol/go-sdk` [ASSUMED] | Go proxy (sum.golang.org verified by `go mod download`) | v1.8.0 published 2026-09-04; v1.x line since 2025 | n/a (Go) | github.com/modelcontextprotocol/go-sdk (proxy Origin URL, hash 3f3b699b) | seam: not supported | Flagged — planner adds `checkpoint:human-verify` before `go get` |
|
||||
| `github.com/google/jsonschema-go` [ASSUMED] | Go proxy | v0.4.3 published 2026-04-17 | n/a | github.com/google/jsonschema-go (proxy Origin URL, hash 8c4ab4f0) | seam: not supported | Flagged — same checkpoint |
|
||||
| `segmentio/asm`, `segmentio/encoding`, `yosida95/uritemplate/v3`, `x/oauth2`, `x/time` (transitive) [ASSUMED] | Go proxy | — | n/a | via go-sdk go.mod | seam: not supported | Listed in the decision note; human check covers them; run `govulncheck` (not installed here: `go run golang.org/x/vuln/cmd/govulncheck@latest ./...`) |
|
||||
|
||||
**Packages removed due to [SLOP] verdict:** none
|
||||
**Packages flagged as suspicious [SUS]:** none by the seam (unsupported ecosystem); all Go additions are gated by one human-verify checkpoint.
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### System Architecture Diagram
|
||||
|
||||
```
|
||||
MCP client (editor's agent)
|
||||
│ POST/GET/DELETE /mcp Authorization: Bearer <oas_at_… | usr_… | g15_…> Mcp-Session-Id
|
||||
▼
|
||||
surf mux ──(streaming route kind: no buffering, throttle:mcp, Origin check)──► pier HTTP layer
|
||||
│
|
||||
┌──────────────────────────────────────────────────────────────────────────────┤
|
||||
│ 1. verify bearer (EVERY request) │
|
||||
│ oas_at_ ─► oauthserver verifier (token row, client kind=mcp, not revoked) │
|
||||
│ usr_… ─► user plugin resolver (only if allow_personal_tokens) │
|
||||
│ g15_… ─► cabana token resolver (only if allow_personal_tokens) │
|
||||
│ fail ──► 401 + WWW-Authenticate resource_metadata, scope │
|
||||
│ 2. build Identity: frontend principal (+creds), backend principal if │
|
||||
│ scope∋backend AND user plugin LinkedBackendUser(uid) (D-08, per request) │
|
||||
│ 3. session router │
|
||||
│ no id ─► new id (crypto/rand.Text) ─► per-session *mcp.Server built from │
|
||||
│ Identity's allowed tools/prompts ─► Redis SET pier:s:<id>{bind, │
|
||||
│ state, ttl} │
|
||||
│ id ─► Redis GET ─► binding == Identity.binding ? else 403 │
|
||||
│ local transport? else REHYDRATE (Connect with stored State) │
|
||||
│ EXPIRE (sliding TTL) │
|
||||
│ DELETE ─► Redis DEL + PUBLISH ended ─► local Close │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
│ JSON-RPC (SDK transport/session)
|
||||
▼
|
||||
receiving middleware: tools/list & prompts/list re-filtered for THIS request's Identity,
|
||||
tools/call refused if not allowed (scope ceiling D-20, guard, permissions)
|
||||
│ tools/call
|
||||
▼
|
||||
tool handler: validate args (JSON schema) ─► split path/query/body ─► build *http.Request
|
||||
(no Authorization/Cookie, RemoteAddr/XFF/Accept-Language copied, SetPathValue, Pattern)
|
||||
ctx = bouncer dispatch marker{guard names, principal, credential}
|
||||
│
|
||||
▼ surf compiled route handler (constraints, body limit, named middleware,
|
||||
throttle, user.api_token/user.scope/user.permission, recovery) — D-10
|
||||
▼
|
||||
ResponseRecorder ─► 2xx: text + structuredContent ; 4xx/5xx: isError + route body (D-13)
|
||||
|
||||
Event sources (user plugin perms/link/token revoke, oauthserver grant revoke, cabana token revoke)
|
||||
─► festival event ─► pier listener ─► Redis PUBLISH pier:events
|
||||
─► every instance: sessions bound to that subject recompute tool set (Add/RemoveTools
|
||||
→ SDK sends notifications/tools/list_changed) or Close on revocation (D-26/D-27)
|
||||
```
|
||||
|
||||
### Recommended Project Structure
|
||||
|
||||
```
|
||||
modules/pier/ # framework module; the only package importing go-sdk
|
||||
├── README.md # standard structure, **Name:** mnemonic
|
||||
├── pier.go # Config, Mount(r surf-adapter, app, plugins), boot validation
|
||||
├── catalog.go # compile pact declarations → tools/prompts, prefixing (D-12), schemas
|
||||
├── dispatch.go # args → *http.Request, ResponseRecorder → CallToolResult (D-11, D-13)
|
||||
├── identity.go # bearer classification, verifiers, Identity, binding key
|
||||
├── guards.go # guard provider registry ("backend" built in, others via app.Lookup)
|
||||
├── session.go # rehydrating session router over StreamableServerTransport
|
||||
├── store_redis.go / store_memory.go # session registry + pub/sub (memory for single instance/tests)
|
||||
├── prm.go # RFC 9728 protected resource metadata + WWW-Authenticate
|
||||
└── events.go # festival listeners → fan-out
|
||||
modules/pact/capabilities.go # + HasMCP (tools, prompts, room for resources), HasFrontendPermissions
|
||||
modules/bouncer/dispatch.go # + context-only dispatch marker, honoured in Registry.Middleware
|
||||
modules/surf/ # + RouteHandler lookup after compile, streaming route kind, pier mount (tagged or not)
|
||||
modules/cabana/ # + ResolveBackend honours marker, account contribution seam, BackendPrincipal(ctx, app, id), token resolver without method filter
|
||||
modules/wristband/authorize.go # + MCP allowed scopes from Options.ScopesSupported (default unchanged)
|
||||
modules/cooler/ # + expose/publish the Redis client when pier selects redis
|
||||
admin/src/views/AccountView.vue # + generic account sections/actions component; rebuild boardwalk/dist
|
||||
```
|
||||
|
||||
### Pattern 1: Config-gated mount, after all routes exist
|
||||
**What:** pier mounts like `mountAPIDocs` — nothing when disabled. Unlike api-docs it must validate tool → route bindings, so it runs **after** every plugin route and after `cabana.Activate` in `BuildRouter` (routes 624-654), before the final `r.wrap` validation loop.
|
||||
**Existing code:** `if err := mountAPIDocs(r, app, plugins)` at `modules/surf/router.go:635`, `admin, err := cabana.Activate(app, plugins)` at :638 [VERIFIED: modules/surf/router.go:635-638]. `route:list` also calls `BuildRouter`, so a bad binding fails `route:list` too (consistent with "boot fails", D-10).
|
||||
**Import direction:** surf imports cabana, cooler and bouncer already. If surf imports pier, pier must not import surf: pier defines a small interface (`Routes() []RouteInfo`, `Handler(method, pattern) (http.Handler, bool)`), surf passes an adapter.
|
||||
|
||||
### Pattern 2: SDK-free pact capability (D-28 shape)
|
||||
**What:** plugins import `pact` only; pact must not import the SDK or jsonschema-go (otherwise every app with the user plugin links the SDK). Use one method returning a struct so `Resources` can be added later as a field (additive):
|
||||
```go
|
||||
// pact (sketch; identifiers are the planner's call)
|
||||
type MCP struct {
|
||||
Tools []MCPTool
|
||||
Prompts []MCPPrompt
|
||||
// Resources []MCPResource — added in a later phase, non-breaking
|
||||
}
|
||||
type HasMCP interface{ MCP() MCP }
|
||||
|
||||
type MCPTool struct {
|
||||
Name, Title, Description string
|
||||
Guard string // "user" | "backend" — names a published guard provider
|
||||
Method, Path string // the plugin's OWN route, e.g. POST /_journal/api/v1/posts
|
||||
Input any // zero value of the input struct (reflected with jsonschema.ForType)
|
||||
Output any // optional; nil → no outputSchema
|
||||
Permissions []string // D-16: list filter on top of route-derived requirements
|
||||
ReadOnly, Destructive, Idempotent, OpenWorld *bool // D-14 overrides; nil → derived from Method
|
||||
}
|
||||
type HasFrontendPermissions interface{ FrontendPermissions() []FrontendPermission } // D-09: Code, Label, Tab, Comment
|
||||
```
|
||||
Existing analogs: `pact.Permission{Code, Tab, Label, Roles}` and `HasPermissions` [VERIFIED: modules/pact/capabilities.go:512-522 — `type Permission struct { Code string; Tab string; Label string; Roles []string }`, `type HasPermissions interface { Permissions() []Permission }`].
|
||||
|
||||
### Pattern 3: In-process dispatch through the compiled route handler
|
||||
**What:** surf already compiles one wrapped handler per route (`handlers[i] = h` inside `compile()`, `modules/surf/router.go:354-364`) but keeps them local. Add an additive accessor that records them (keyed by `method + " " + path`) and expose it after compile. Dispatch calls that handler **directly**, not the mux, after setting path values exactly as `overlap.go` does (`r.SetPathValue(s.param, vals[i])` … `r.Pattern = m.pattern` [VERIFIED: modules/surf/overlap.go:415-420]).
|
||||
**Why not the mux:** dispatching a concrete URL through ServeMux can match a *different*, more specific route (a `{id}` value `export` would hit `/posts/export`). Calling the bound route's handler makes the binding exact; `constrain` inside that handler still enforces `Where` constraints.
|
||||
**What the wrapped handler contains** (`wrap`, :408-491): constraints → org slot → named middleware (right to left) → body limit → locale → `recoverJSON`/`recoverBare` → CORS profile → `recordPattern`. Calling it runs throttles, `user.api_token`/`user.scope`/`user.permission`, and the handler — D-10's "same code path as HTTP". The outer access log is not on that path, so pier logs tool calls itself (D-23).
|
||||
**Request construction rules:** method/path from the binding; values for single-segment params must not contain `/`; query from `query:` fields; body = the agent's JSON object minus path/query keys (forward raw, see Pitfall 6); copy `RemoteAddr`, `X-Forwarded-For`/`X-Real-IP`, `Accept-Language`; set `Content-Type`/`Accept: application/json`; **drop `Authorization` and `Cookie`** (no token passthrough); attach the dispatch marker to the context; a per-call timeout.
|
||||
|
||||
### Pattern 4: Schema reflection and validation
|
||||
`jsonschema.ForType(reflect.TypeOf(tool.Input), nil)` builds the input schema. Properties come from the JSON name; fields without `omitempty`/`omitzero` become `required`; the `jsonschema` tag is the description; structs get `additionalProperties: false` [VERIFIED: jsonschema-go@v0.4.3/jsonschema/infer.go:56-82, 329-343]. The SDK's low-level `Server.AddTool(t, h)` panics unless the input schema has `"type": "object"` and leaves validation to the caller ("Unmarshaling the arguments and validating them against the input schema are the caller's responsibility") [VERIFIED: go-sdk@v1.8.0/mcp/server.go:295-345]. pier therefore: reflects once at boot (fail boot on error), resolves the schema once, validates every call's arguments, then splits by the `path`/`query` struct tags.
|
||||
|
||||
### Pattern 5: Guard providers keep the framework neutral
|
||||
pier must not hard-code `user.api_token`/`user.scope:`/`user.permission:` (sm-user-plugin names). Recommended shape: a framework interface each guard owner publishes via `app.Publish`:
|
||||
```go
|
||||
// framework, SDK-free package (pact or a small pier contract package) — names are the planner's call
|
||||
type MCPGuard interface {
|
||||
Name() string // "user", "backend"
|
||||
TokenGuarded(routeMiddleware []string) bool // D-15 check at boot
|
||||
Requirements(routeMiddleware []string) (any, error) // parses its own names (user.scope:read, user.permission:x)
|
||||
Allows(ctx context.Context, id Identity, req any, toolPerms []string) bool // the SAME logic the route enforces
|
||||
DispatchGuardNames() []string // bouncer registry names to honour, e.g. ["api_token"]
|
||||
}
|
||||
```
|
||||
cabana's "backend" guard is built into pier (framework may use cabana). The user plugin publishes "user". Existing wiring: the user plugin registers bouncer guards `"jwt"` and `"api_token"` and exposes them as middleware `jwt.auth`/`user.api_token`; factories `user.scope`/`user.permission` [VERIFIED: fonoteka.go/plugins/golem15/user/plugin.go:100,108,244,247,254-255 — `reg.Register(p.ID(), "jwt", …)`, `reg.Register(p.ID(), "api_token", …)`, `out["jwt.auth"] = mw`, `out["user.api_token"] = mw`, `"user.scope": usermiddleware.Scope`, `"user.permission": usermiddleware.Permission`]. cabana registers bouncer guard `"backend"` [VERIFIED: modules/cabana/http.go:164 — `guards.Register("summercms.cabana", "backend", guard)`].
|
||||
|
||||
### Pattern 6: The D-17 marker — where it must be honoured
|
||||
- **bouncer** `Registry.Middleware` calls `authenticate(ng.g, r)` and, for a guard with `UnauthorizedWriter`, writes 401 on failure [VERIFIED: modules/bouncer/registry.go:73-104]. Add before line 83: if the context carries a dispatch marker that lists this guard name, use its principal/credential and skip header parsing. This single change covers `user.api_token` (the bouncer `api_token` guard) and `jwt.auth` — **no sm-user-plugin code change is needed for the marker itself** (D-17's "user.api_token honours the marker" is satisfied in bouncer). Decide whether `jwt.auth` may honour it; D-15 says JWT-session routes are not tool targets, so honour only `api_token` for the user guard.
|
||||
- **cabana** `ResolveBackend` returns `ErrUnauthenticated` immediately when there is no bearer (`if app == nil || r == nil || authorizationBearer(r) == ""` [VERIFIED: modules/cabana/token_guard.go:199-200]). Put the marker check before that line. Journal's `requireBackendPrincipal` calls `cabana.ResolveBackend(app, r, journalCover)` [VERIFIED: sm-journal-plugin/controllers/api/auth.go:85], so it honours the marker with **no Journal change**.
|
||||
- **Journal** `optionalBackendPrincipal` short-circuits on `if r == nil || !hasBearer(r)` [VERIFIED: sm-journal-plugin/controllers/api/auth.go:70], so the list/show tools (public GET routes) would run anonymous (published posts only). Additive one-line fix: drop the `hasBearer` short-circuit (ResolveBackend already returns ErrUnauthenticated for a missing bearer, so anonymous behaviour is identical) or check the marker explicitly.
|
||||
- **Credential for `user.scope`:** `Scope` reads `bouncer.Credential` as `*models.APIToken` and calls `token.HasScope(scope)` [VERIFIED: fonoteka.go/plugins/golem15/user/middleware/api_token.go:90-101]. An OAuth session has no `user_api_tokens` row, so the user guard provider must build an **in-memory** `*models.APIToken{UserID, Scopes: grant scopes}` (never persisted) for the marker; user token scopes are `AllowedAPITokenScopes = []string{"read", "write", "ai"}` [VERIFIED: fonoteka.go/plugins/golem15/user/classes/api_tokens.go:23], the same vocabulary as MCP OAuth scopes. Handlers that read `credential.ID` must tolerate ID 0 (Pitfall 9).
|
||||
- **Unforgeability:** context values cannot be set from HTTP; the marker type lives in bouncer with an unexported key. Any compiled plugin could still call the setter, so restrict by convention plus a check-script grep that only `modules/pier` calls it.
|
||||
|
||||
### Pattern 7: Link proof through a fresh frontend session (D-05)
|
||||
The 19.6 login gate (`Login` consults gates after `CheckAndRecordLogin`, before `mintFor`; 19.6 D-02) and sociallogin's `oauth-complete` both end in a frontend JWT. The proof therefore does not need its own login flow:
|
||||
1. Admin (interactive cabana session; `interactiveOnly` refuses `g15_` tokens) clicks "Link frontend account" → sm-user-plugin creates a link request row (random 32-byte code, stored hashed, `backend_user_id`, `expires_at` ~10 min, `used_at`) and returns a site URL with the code.
|
||||
2. The site page shows "Link this account to admin <name>?" (public `GET` showing only the admin display name and expiry), asks the user to sign in **fresh** (normal login page, so password, social login and 2FA gates all apply).
|
||||
3. `POST …/backend-link/confirm` with `jwt.auth` + the code: row-locked, one-shot, not expired, **JWT `iat` after the request's `created_at`** (forces a fresh sign-in rather than a stale browser session), uniqueness on both sides (D-06) → save link, fire event.
|
||||
19.6 adds `golem15.user.frontend_url` (19.6-01-PLAN artifacts) — reuse it for the site URL. The site page is app-specific (vue-demo-app has only `contact, faq, index, login, register, roadmap, subscriptions, p/, presentations/, quizzes/` pages [VERIFIED: ls sm-demo-app/vue-demo-app/app/pages]); add it there.
|
||||
|
||||
### Pattern 8: Redis-backed rehydrating sessions (D-25..D-27)
|
||||
- New session: `crypto/rand.Text()` id; connect a per-session `*mcp.Server`; after `initialize`, store `{binding, ServerSessionState{InitializeParams, InitializedParams: new(…), LogLevel: "info"}, created}` under `<prefix>pier:s:<id>` with `PX ttl`. `ServerSessionState` is JSON-tagged for this purpose [VERIFIED: go-sdk@v1.8.0/mcp/session.go:18-36].
|
||||
- Existing id, local miss: load state, `server.Connect(ctx, &mcp.StreamableServerTransport{SessionID: id}, &mcp.ServerSessionOptions{State: st})`, then `t.ServeHTTP` (probe-verified). `ServerSessionOptions.onClose` is unexported, so track closure with a goroutine on `ss.Wait()`.
|
||||
- Binding: refuse with 403 when the request's binding key differs. **Binding key must survive access-token refresh** (1 h access TTL, rotation): bind to `(credential family, frontend user id)` — e.g. `oauth:<client_id>:<user_id>`, `pat:user:<token_id>`, `pat:backend:<token_id>` — not the token hash (Open Question 5).
|
||||
- Every request: `PEXPIRE` (sliding TTL). Missing key → 404 (spec: client re-initialises).
|
||||
- DELETE: `DEL` + `PUBLISH pier:events {ended:id}` → every instance closes its local copy.
|
||||
- Fan-out: `PUBLISH` on events; each instance iterates its local sessions bound to the subject and either recomputes the allowed tool set (diff → `AddTool`/`RemoveTools` → SDK sends `notifications/tools/list_changed`, debounced 10 ms [VERIFIED: go-sdk@v1.8.0/mcp/server.go:740-770]) or closes the session on revocation.
|
||||
- Memory driver for single instance and tests (same interface), mirroring cooler's memory/redis split. cooler opens its Redis client only when `cache.driver` or `http.rate_limit.store` selects redis and keeps it unexported (`Handle.client`) [VERIFIED: modules/cooler/cooler.go:109-116, 184-245]; add an additive selector/accessor for pier.
|
||||
|
||||
### Pattern 9: Protected resource metadata (required by the MCP auth spec)
|
||||
"MCP servers **MUST** implement OAuth 2.0 Protected Resource Metadata (RFC9728)"; serve it at `/.well-known/oauth-protected-resource/<mcp path>` and/or root, and include `resource_metadata` (and `scope`) in `WWW-Authenticate` on 401 [CITED: modelcontextprotocol.io/specification/2025-11-25/basic/authorization]. Nothing in the framework or oauthserver serves PRM today (grep found only fonoteka-mcp's Node server). The SDK provides `auth.ProtectedResourceMetadataHandler(*oauthex.ProtectedResourceMetadata)`; mount it through surf at both paths. `resource` must equal `golem15.oauthserver.mcp.resource`, because wristband refuses a `resource` parameter that differs from `Options.Resource` and clients MUST send `resource` ("MCP clients MUST send this parameter regardless") (Pitfall 8).
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **Forwarding the MCP bearer to the route.** Token passthrough is forbidden ("The MCP server MUST NOT pass through the token it received") and would also make Journal try to parse an `oas_at_` token as a backend JWT. Identity goes through the marker only.
|
||||
- **Dispatching through the mux by concrete URL** (route confusion, see Pattern 3).
|
||||
- **Mounting `/mcp` as a normal surf route** (buffered, see Pitfall 1).
|
||||
- **pier parsing user-plugin middleware names or reading user-plugin tables** (framework must stay neutral; use guard providers).
|
||||
- **Putting the SDK in `pact`** or any package plugins import.
|
||||
- **Imperative or dynamic tool descriptions.** fonoteka-mcp keeps descriptions "STATIC factual `verb_noun` one-liners with NO imperative second-person text (auto-injected tool text is a prompt-injection surface)" and sets title plus explicit read/destructive/open-world hints because "both catalogs reject a submission that is missing any of those annotations" [VERIFIED: /media/nvme/dev/golem15/fonoteka/fonoteka-mcp/src/server.ts:5-17].
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| JSON-RPC framing, SSE event streams, protocol negotiation, `initialize` handshake, 2026-07-28 rejection | Own MCP transport | go-sdk `StreamableServerTransport` + `Server.Connect` | Spec edge cases (batching, priming events, header validation, version negotiation) are already handled; pier only routes sessions |
|
||||
| JSON schema from Go structs + validation | Reflection code | `jsonschema.ForType` + `Schema.Resolve`/`Validate` | Same library the SDK uses; consistent `required`/`additionalProperties` |
|
||||
| PRM document and CORS for it | Handwritten JSON | `auth.ProtectedResourceMetadataHandler` + `oauthex.ProtectedResourceMetadata` | RFC 9728 field names |
|
||||
| Test MCP client | Raw HTTP in tests | `mcp.NewClient` + `StreamableClientTransport{HTTPClient}` | Exercises negotiation fallback, sessions, list_changed |
|
||||
| Session IDs | Custom generator | `crypto/rand.Text()` (SDK recommendation) | Unguessable IDs |
|
||||
| Rate limiting `/mcp` | New limiter | surf `throttle:<bucket>` (Redis-backed via cooler when configured) | Existing shared limiter |
|
||||
| Permission checks for tools/list | Re-implementing permission logic in pier | The guard provider's own check (same function the route middleware uses) | List and call must agree (Pitfall 3) |
|
||||
| One-time link codes | Signed JWT with replay cache | DB row with hashed random code, row lock, `used_at` | Same pattern as the user plugin's codes (`newCode()` 32 random bytes hex) [VERIFIED: fonoteka.go/plugins/golem15/user/classes/codes.go:46-52] |
|
||||
|
||||
**Key insight:** the SDK should own the protocol and pier should own identity and dispatch. The only part of the SDK pier bypasses is `StreamableHTTPHandler`'s private session map, and only because D-25 needs it shared.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: surf buffers every route response — SSE cannot stream
|
||||
**What goes wrong:** the GET standalone stream and SSE POST responses never reach the client; the GET never returns.
|
||||
**Why:** `wrap` always ends with `recoverJSON` or `recoverBare` (:479/:481), both of which write into a `bufferedResponse` that commits only after the handler returns, and `func (*bufferedResponse) Flush() {}` is a deliberate no-op ("Route output becomes visible only after the handler returns successfully") [VERIFIED: modules/surf/router.go:479-481, 851-853].
|
||||
**How to avoid:** add an additive streaming route kind (recovery that does not buffer — after headers are sent a panic can only abort; keep throttle, CORS profile, `recordPattern`). The outer layers (access log `statusRecorder` has Flush/Unwrap; `pathScopedCORS`, error-page fallback and request counter pass the writer through) are fine. No route streams today (no `text/event-stream`/`http.Flusher` use in framework or plugins).
|
||||
**Warning signs:** client connects, `initialize` works with `JSONResponse` but tool calls hang with SSE.
|
||||
|
||||
### Pitfall 2: Protocol 2026-07-28 vs stateful sessions
|
||||
**What goes wrong:** a client that speaks only 2026-07-28 cannot use a stateful server.
|
||||
**Why:** "the streamable HTTP transport accepts requests at protocol version `2026-07-28` **only** when `Stateless = true`" [VERIFIED: go-sdk@v1.8.0/docs/protocol.md:352-353]. Stateful servers answer `CodeUnsupportedProtocolVersion` with legacy versions; SDK clients fall back (probe negotiated 2025-11-25).
|
||||
**How to avoid:** keep D-25 (works with dual-version clients) and ask the user about the hybrid in Open Question 1. Test with the SDK client (which tries `server/discover` first).
|
||||
|
||||
### Pitfall 3: Frontend permission semantics differ between the route and `MergedPermissions`
|
||||
**What goes wrong:** `tools/list` shows a tool the call then refuses, or hides one the call would allow.
|
||||
**Why:** `user.permission:<code>` checks `cabana.Allows(principal, []string{code})` [VERIFIED: fonoteka.go/plugins/golem15/user/middleware/api_token.go:106-110], and the principal's grants come from `UserPermissionGrants` — **group permissions only** (`PrincipalByID` → `grants, err := UserPermissionGrants(ctx, gdb, row.ID)` [VERIFIED: classes/user_lookup.go:35,54]; it reads `user_groups.permissions` joined through `users_groups` only, `user_lookup.go:65-99`). `MergedPermissions` (groups plus the user's tri-state override) exists but **no route uses it** (grep: only `classes/permissions.go` references it). The design note says tools/list uses `MergedPermissions`.
|
||||
**How to avoid:** the guard provider answers with the **same** check the route enforces (group grants). Whether routes should move to `MergedPermissions` is a core-plugin behaviour change — Open Question 4. (Backend differs: cabana's `BackendUsers.FindByID` already overlays the admin's own permissions [VERIFIED: modules/cabana/auth.go:39-68].)
|
||||
|
||||
### Pitfall 4: No resource-side verifier for `oas_at_` tokens; OIDC tokens share the table
|
||||
**What goes wrong:** pier cannot authenticate OAuth sessions, or accepts OIDC userinfo tokens as MCP tokens.
|
||||
**Why:** MCP access tokens are `MCPAccessTokenPrefix = "oas_at_"` rows in `golem15_oauthserver_access_tokens` [VERIFIED: sm-oauthserver-plugin/classes/mcp_store.go:19]; the same table holds OIDC access tokens ("OIDC tokens are only usable at the userinfo endpoint", models/access_token.go). No verifier exists ("Verifying MCP access tokens at a resource arrives with the MCP module, not here" — sm-oauthserver-plugin/README.md:32).
|
||||
**How to avoid:** sm-oauthserver-plugin publishes a verifier implementing a framework interface: prefix `oas_at_`, hash lookup, not revoked, not expired, client exists with kind `mcp` and not revoked, user active; returns user id, scopes, client id, token id, expiry. Throttle `last_used_at` writes (cabana's `stampThrottle` pattern).
|
||||
|
||||
### Pitfall 5: `backend` scope is rejected by wristband's hard-coded allow-list
|
||||
**What goes wrong:** a client requesting `backend` gets `invalid_scope` even after the plugin adds it to `ScopesSupported`.
|
||||
**Why:** `var authorizeAllowedScopes = map[string]bool{ "read": true, "write": true, "ai": true, "offline_access": true, }` [VERIFIED: modules/wristband/authorize.go:22-24]; `DefaultOptions().ScopesSupported` is `[]string{"read", "write", "ai", "offline_access"}` [VERIFIED: modules/wristband/server.go:99].
|
||||
**How to avoid:** framework change in wristband: the MCP flavour's allowed set = `Options.ScopesSupported` when set (default stays the four, PHP parity). Then sm-oauthserver-plugin appends `backend` to `mcpOpts.ScopesSupported` in `NewServers`.
|
||||
|
||||
### Pitfall 6: Partial updates and zero values
|
||||
**What goes wrong:** an update tool sends `"published": false` or `""` for fields the agent never mentioned.
|
||||
**Why:** Journal's update uses key presence (`if _, has := body["published"]; has {` …) [VERIFIED: sm-journal-plugin/controllers/api/posts_helpers.go:579-580]; re-marshalling a Go struct emits zero values.
|
||||
**How to avoid:** use the struct only for the schema; forward the agent's **raw** JSON object minus path/query keys as the body. Mark optional fields with `omitempty`/pointers so the schema does not require them.
|
||||
|
||||
### Pitfall 7: Method-filtered `g15_` token scopes at a POST endpoint
|
||||
**What goes wrong:** with `allow_personal_tokens`, a read-only cabana token cannot even open a session.
|
||||
**Why:** the composite guard filters scopes by the *request* method (`methodFilterScopes(g.reg.tokenScopes, stored, r.Method)` [VERIFIED: modules/cabana/token_guard.go:109]); every MCP request is POST/GET/DELETE to `/mcp`, not the tool's route method.
|
||||
**How to avoid:** additive cabana API that resolves a `g15_` token without method filtering and computes the `ScopeLimit` per dispatched route method (`ScopeLimit` semantics: "Only a token guard sets Scope, already filtered to scopes whose methods include the request method" [VERIFIED: modules/bouncer/context.go:10-19]).
|
||||
|
||||
### Pitfall 8: Resource indicator mismatch breaks sign-in
|
||||
**What goes wrong:** authorize answers `invalid_target`.
|
||||
**Why:** wristband refuses a `resource` that differs from `Options.Resource`, which comes from `golem15.oauthserver.mcp.resource` [VERIFIED: sm-oauthserver-plugin/servers.go:123-124]; MCP clients always send `resource` = the canonical MCP URL.
|
||||
**How to avoid:** one value: pier's resource URL (default `app.url` + path, no trailing slash) must equal `mcp.resource`; pier's PRM `resource` uses the same; a boot warning when they differ.
|
||||
|
||||
### Pitfall 9: In-memory credentials and handlers that read `credential.ID`
|
||||
**Warning:** a synthetic `*models.APIToken` (OAuth session) has `ID == 0` and `CollectionIDs` empty; route handlers that look up the token row by ID or use collection scoping (fonoteka-style) would misbehave. Audit token-guarded tool targets; Journal and the new user tool routes don't use the credential.
|
||||
|
||||
### Pitfall 10: Long-lived GET streams outlive revocation
|
||||
**What goes wrong:** a revoked grant keeps receiving notifications on an already-open SSE stream (authorization happens at stream open).
|
||||
**How to avoid:** on revocation events close the session on every instance; additionally re-verify the stream's bearer periodically (e.g. every 60 s) and close when invalid. Shutdown: `serve` uses the signal context as `BaseContext`, so request contexts cancel on SIGTERM; streams end.
|
||||
|
||||
### Pitfall 11: Name collisions and validity
|
||||
Short codes collide across vendors (`acme.blog` and `golem15.blog` → `blog_`); prefix collisions must fail boot (D-12 covers within one plugin only). The SDK only *logs* an invalid name (`AddTool: invalid tool name`); names over 128 chars or with invalid runes must fail boot in pier [VERIFIED: go-sdk@v1.8.0/mcp/tool.go:163-186].
|
||||
|
||||
### Pitfall 12: `structuredContent` must be an object
|
||||
A route returning a JSON array or scalar cannot be passed as `structuredContent`. Wrap non-objects (e.g. `{"data": …}`) or omit `structuredContent`, and only declare an `outputSchema` when the 2xx body always matches it (the low-level API leaves output validation to the caller).
|
||||
|
||||
### Pitfall 13: Concurrent work on sm-user-plugin
|
||||
19.6 plans 01-04 modify `controllers/users_admin_controller.go` (`AdminRecordActions` at :665), `controllers/users/config_form.yaml` (`recordActions: [activate, unban, unsuspend]` at :17), `admin_permissions.go`, `plugin.go`, `routes.go`, `controllers/api_controller.go`, `lang/*`, `README.md` and `admin_registration_test.go` in `../fonoteka.go/plugins/golem15/user` (19.6-01-PLAN files_modified). 19.6 is not executed (no SUMMARY files; the user plugin is at `35cf166`, tag v0.1.5). 19.7's user-plugin plan must start after 19.6's user-plugin release, rebase on it, and append to the same lists.
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Rehydrating a session on another instance (probe-verified)
|
||||
```go
|
||||
// Source: local probe against go-sdk v1.8.0 (exported API only)
|
||||
st := &mcp.ServerSessionState{
|
||||
InitializeParams: storedInitParams, // from ss.InitializeParams() on the first instance
|
||||
InitializedParams: new(mcp.InitializedParams),
|
||||
LogLevel: "info",
|
||||
}
|
||||
t := &mcp.StreamableServerTransport{SessionID: id}
|
||||
if _, err := perSessionServer.Connect(context.Background(), t, &mcp.ServerSessionOptions{State: st}); err != nil {
|
||||
http.Error(w, "failed connection", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
t.ServeHTTP(w, req) // serves POST/GET for this session on this instance
|
||||
```
|
||||
|
||||
### Targeted list_changed by per-session server (probe-verified)
|
||||
```go
|
||||
// Source: local probe; the SDK debounces and notifies only this server's sessions
|
||||
srv.RemoveTools("journal_create_post") // after the link was removed
|
||||
// client side receives notifications/tools/list_changed via ClientOptions.ToolListChangedHandler
|
||||
```
|
||||
|
||||
### Per-request identity into the SDK
|
||||
```go
|
||||
// Source: go-sdk@v1.8.0/auth/auth.go:96-125 and mcp/streamable.go:1463,1585-1586
|
||||
// RequireBearerToken is the only setter of the TokenInfo context key; the transport copies it
|
||||
// into every JSON-RPC request's Extra.TokenInfo, so tool handlers see the CURRENT request's identity.
|
||||
verify := func(ctx context.Context, token string, r *http.Request) (*auth.TokenInfo, error) {
|
||||
id, err := identities.Resolve(ctx, token) // oas_at_ / personal tokens; per request (D-25)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", auth.ErrInvalidToken, err)
|
||||
}
|
||||
return &auth.TokenInfo{Scopes: id.Scopes, Expiration: id.ExpiresAt, UserID: id.BindingKey,
|
||||
Extra: map[string]any{identityKey: id}}, nil
|
||||
}
|
||||
h := auth.RequireBearerToken(verify, &auth.RequireBearerTokenOptions{
|
||||
ResourceMetadataURL: prmURL, Scopes: nil, // pier enforces read/write per tool, not per request
|
||||
})(sessionRouter)
|
||||
```
|
||||
Note: `RequireBearerTokenOptions.AllowMissingExpiration` exists for tokens without expiry (personal tokens with no `expires_at`) [VERIFIED: go-sdk@v1.8.0/auth/auth.go:48-64].
|
||||
|
||||
### Annotations from method (D-14)
|
||||
```go
|
||||
// SDK type: ToolAnnotations{DestructiveHint *bool; IdempotentHint bool; OpenWorldHint *bool; ReadOnlyHint bool; Title string}
|
||||
// Source: go-sdk@v1.8.0/mcp/protocol.go:1964-1991
|
||||
ann := &mcp.ToolAnnotations{Title: tool.Title, ReadOnlyHint: method == http.MethodGet,
|
||||
IdempotentHint: method == http.MethodPut || method == http.MethodGet}
|
||||
destructive := method == http.MethodDelete
|
||||
openWorld := false
|
||||
ann.DestructiveHint, ann.OpenWorldHint = &destructive, &openWorld // always explicit (catalog requirement)
|
||||
```
|
||||
|
||||
### Journal backend tools (D-18 candidates, existing routes)
|
||||
Existing routes [VERIFIED: sm-journal-plugin/routes.go:16-34]:
|
||||
```
|
||||
GET /_journal/api/v1/posts (public group, throttle:journal-public-api; editor sees drafts)
|
||||
GET /_journal/api/v1/posts/{slug} (Where slug `[a-z0-9][a-z0-9\-/]*`; numeric slug falls back to id)
|
||||
GET /_journal/api/v1/categories | /tags
|
||||
POST /_journal/api/v1/posts (throttle:journal-api; requireBackendPrincipal + access_posts)
|
||||
PUT /_journal/api/v1/posts/{id} (Where id `[0-9]+`)
|
||||
DELETE /_journal/api/v1/posts/{id}
|
||||
POST /_journal/api/v1/media/upload | /posts/{id}/featured-images (multipart — not tool-friendly)
|
||||
```
|
||||
Permissions [VERIFIED: sm-journal-plugin/controllers/api/auth.go:26-30]: `permAccessPosts = "golem15.journal.access_posts"`, `permAccessOtherPosts = "golem15.journal.access_other_posts"`, `permAccessPublish = "golem15.journal.access_publish"`, `journalCover = "golem15.journal.*"`. Recommended tools: `journal_list_posts`, `journal_show_post`, `journal_list_categories`, `journal_list_tags` (read), `journal_create_post`, `journal_update_post` (write), `journal_delete_post` (destructive; optional). All declare `Guard: "backend"` and `Permissions: [access_posts]` (D-15/D-16 explicit-guard case). Multipart upload routes are out (JSON args only). The `journal-api` bucket keys by backend principal id when `bouncer.User` holds a backend principal [VERIFIED: sm-journal-plugin/plugin.go:137-146], so marker dispatch is throttled per admin.
|
||||
|
||||
### Frontend tool (D-18) needs a new route
|
||||
`GET /_user/api/v1/fetch` authenticates the JWT inside the handler (`authenticate` → `bouncer.VerifyClaims`) [VERIFIED: fonoteka.go/plugins/golem15/user/controllers/api_controller.go:163-176, 795-825] and is not token-guarded, so it cannot be a target. Add an additive route such as `GET /_user/api/v1/token/me` with `user.api_token`, `user.scope:read` returning the same `{"user": …}` payload via `apiArray`. Other ready frontend targets exist in shared plugins: presentations' manage group (`user.api_token` + `user.scope:read|write`) and quizzes' import (`user.scope:write`, `user.permission:<code>`).
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| `initialize` handshake + `Mcp-Session-Id` sessions | Stateless: per-request `_meta` (protocol version, client capabilities), `server/discover`, no session header | Spec 2026-07-28 (RC locked 2026-05-21, final 2026-07-28) [CITED: blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/] | D-25 pins pier to ≤ 2025-11-25 (Open Question 1) |
|
||||
| GET SSE stream + `resources/subscribe` for change notifications | `subscriptions/listen` long-lived request (toolsListChanged, promptsListChanged…) and cacheable list results (`ttlMs`, `cacheScope`) | 2026-07-28 [VERIFIED: go-sdk@v1.8.0/docs/protocol.md:176-190] | Only relevant if the hybrid is adopted |
|
||||
| SSE resumability (`Last-Event-ID`, EventStore) | Removed in 2026-07-28; kept for ≤2025-11-25 | 2026-07-28 | pier can skip EventStore; a dropped stream loses in-flight responses (client retries) |
|
||||
| DCR as the main client registration | Client ID Metadata Documents preferred; DCR "for backwards compatibility" | Spec 2025-11-25 [CITED: modelcontextprotocol.io/specification/2025-11-25/basic/authorization] | wristband supports DCR only; acceptable, CIMD is a later improvement |
|
||||
|
||||
**Deprecated/outdated:**
|
||||
- `StreamableHTTPOptions.CrossOriginProtection` is deprecated; wrap the handler with `http.NewCrossOriginProtection()` instead [VERIFIED: go-sdk@v1.8.0/mcp/streamable.go:184-195].
|
||||
- Roots, Sampling and Logging are deprecated in 2026-07-28 (still work for a year); pier uses none.
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if Wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | The go-sdk and jsonschema-go module names are legitimate (Go ecosystem not checkable by the legitimacy seam) | Standard Stack, Package Legitimacy Audit | Supply-chain risk; mitigated by the human-verify checkpoint and the proxy Origin URLs |
|
||||
| A2 | Mainstream MCP clients (Claude, ChatGPT, IDE agents) still fall back to 2025-11-25 `initialize` when a server rejects 2026-07-28 | Pitfall 2, Open Question 1 | A client speaking only 2026-07-28 cannot connect to a stateful pier |
|
||||
| A3 | Binding the session to `(client_id, user_id)` (not to the token hash) matches the intent of D-25's "a different token … is refused" | Pattern 8 | If the user means the literal token, sessions break every hour on refresh |
|
||||
| A4 | The admin set/remove path for links can live on the sm-user-plugin (frontend) Users screen and/or a CLI command, because cabana has no backend Users screen | Open Question 3 | If the user wants a new cabana backend-users screen, scope grows a lot |
|
||||
| A5 | Group-only frontend permission semantics should stay as is for routes (pier mirrors them) | Pitfall 3, Open Question 4 | If the user expects tri-state user overrides, list filtering differs from the design note |
|
||||
| A6 | `ai` scope plays no role in the read/write ceiling (D-20) | Open Question 6 | Tools might need `ai` gating |
|
||||
| A7 | A memory session store is acceptable for single-instance apps (sm-demo-app has no Redis: compose.yaml runs only Postgres) | Pattern 8, Environment | If Redis is mandatory, the demo deployment needs Redis on rome |
|
||||
| A8 | Granting `backend` only when a link exists at consent time (and dropping it otherwise) is acceptable UX | Security Domain | A later link would need re-consent (step-up) |
|
||||
| A9 | Per-call binary growth of ~2.9 MiB is acceptable if pier is always linked | Open Question 2 | Every app grows even with pier disabled |
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Stateful-only (D-25) vs hybrid with the 2026-07-28 stateless protocol.**
|
||||
- What we know: the current spec removed sessions; the SDK serves 2026-07-28 only statelessly; SDK clients fall back to 2025-11-25 against a stateful server (probe).
|
||||
- What's unclear: whether the user wants pier to also answer 2026-07-28 clients (stateless dispatch per request, `subscriptions/listen` for push), which is additive to D-25's stateful path.
|
||||
- Recommendation: ship D-25 as locked; design the identity/dispatch/filter layers so a stateless handler can reuse them; raise it at plan confirmation. Do not silently adopt the hybrid.
|
||||
2. **Always link pier, or behind a build tag?**
|
||||
- What we know: linking the SDK costs ~2.9 MiB stripped; the framework already has the opt-in build-tag pattern (`summercms_s3`: `s3_on.go //go:build summercms_s3`, `s3_off.go //go:build !summercms_s3`, `summer.yaml build.tags`) [VERIFIED: modules/lagoon/attach/s3_on.go:1, s3_off.go:1, bucket.go:27-28].
|
||||
- What's unclear: SC-1 says "enables the module in config"; a tag adds a build step.
|
||||
- Recommendation: ask the user; if always linked, keep all plugin-facing types SDK-free anyway (they must be, for the tag option to remain open).
|
||||
3. **D-04 "backend Users screen" does not exist.** cabana mounts no backend-users controller; admins come from `admin:create`/`admin:reset-password` commands [VERIFIED: modules/cabana/http.go:247-330, commands.go:21,45]. 19.6's Reset 2FA uses the user plugin's **own** Users screen (`AdminRecordActions` + a published `TwoFactorAdmin` slot), not a generic cabana seam (19.6-01-PLAN :49, :270), so there is nothing to reuse beyond that pattern. Recommendation: My Profile gets the generic cabana seam (D-04 part 1); admin set/remove goes on the sm-user-plugin **frontend** Users screen as a record action "Remove backend link" (Danger, `Applies` only when linked, permission) plus a way to set it (a form field with `DropdownOptions` of backend admins, or an `AdminAction` widget), and/or a `user:link-backend` console command. Needs user confirmation.
|
||||
4. **Frontend permission semantics** (Pitfall 3): mirror the route (group grants) — recommended — or change `user.permission` to `MergedPermissions` (behaviour change in a core plugin).
|
||||
5. **Session binding granularity** (A3): `(client_id, user_id)` vs token hash.
|
||||
6. **`ai` scope**: ignore for tools (recommended) or require it for some tool class.
|
||||
7. **MCP consent surface.** wristband sends the browser to `{issuer}/connect?request=` [VERIFIED: modules/wristband/authorize.go:174 — `spa := s.opts.Issuer + "/connect?request=" + rfc3986Escape(requestID)`]. Recommendation: sm-oauthserver-plugin adds JSON consent endpoints for the MCP server (show/decide, `jwt.auth`, mirroring fonoteka's `ConsentShow`/`ConsentStore` contract) that include the linked admin's display name when `backend` is requested; vue-demo-app adds `/connect` and the link-proof page. The demo's nginx does not route `/oauth` or `/.well-known` to Go yet (sm-demo-app/DEPLOY.md:34), so the deploy plan must add `/mcp`, `/.well-known/oauth-protected-resource*`, `/.well-known/oauth-authorization-server` and `/oauth/mcp/*`.
|
||||
8. **Permission code spelling.** Existing backend codes are `golem15.users.*` (`golem15.users.access_users`, `…access_groups`, `…access_settings`, `…impersonate_user`, `…manage_privileged_groups` [VERIFIED: fonoteka.go/plugins/golem15/user/admin_permissions.go:17-41]); 19.6 adds `golem15.users.reset_two_factor`. Recommend `golem15.users.manage_backend_links` (CONTEXT's example `golem15.user.…` would break the pattern).
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Go toolchain | everything | ✓ | go1.27.2 | — |
|
||||
| Docker | testcontainers Postgres suites | ✓ | 29.9.1 | `-short` skips; `sudo -u golem` if socket denied (CLAUDE.md) |
|
||||
| redis-server | manual multi-instance smoke | ✓ | 7.2.5 | miniredis in unit tests |
|
||||
| psql | manual checks | ✓ | 18.6 | — |
|
||||
| Node / npm | admin SPA rebuild, vue-demo-app pages | ✓ | v22.23.3 / 12.2.1 | — |
|
||||
| govulncheck | dependency audit in the decision note | ✗ | — | `go run golang.org/x/vuln/cmd/govulncheck@latest ./...` |
|
||||
| Redis on the demo host (rome) | D-25 multi-instance | unknown (sm-demo-app compose has Postgres only; env.example has no `SUMMER_REDIS__URL`) | — | memory session store for the single-instance demo (A7) |
|
||||
|
||||
**Missing dependencies with no fallback:** none.
|
||||
**Missing dependencies with fallback:** govulncheck (go run); Redis on rome (memory store).
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | Go `testing` (go1.27.2) + testcontainers-go v0.44.0 (Postgres) + miniredis v2.40.0 + go-sdk v1.8.0 client; vitest 3.2.7 for the admin SPA seam |
|
||||
| Config file | none (Go); `admin/vitest.config.*` exists for the SPA |
|
||||
| Quick run command | `go test -short ./modules/pier ./modules/surf ./modules/bouncer ./modules/cabana ./modules/wristband ./modules/pact` |
|
||||
| Full suite command | `go vet ./... && go test ./...` (framework) + `go test ./...` in each touched plugin (through the app workspace for sm-user-plugin) + `npm --prefix admin run test` + `scripts/check-phase19.7.sh` |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
| Req | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|-----|----------|-----------|-------------------|-------------|
|
||||
| SC-1/D-30 | disabled → no `/mcp`, no PRM routes in `route:list`; enabled → mounted | unit | `go test -short ./modules/surf -run TestPierMount` | ❌ Wave 0 |
|
||||
| SC-1 | SSE stream flushes through the streaming route kind | unit (httptest, real flush) | `go test -short ./modules/surf -run TestStreamingRoute` | ❌ |
|
||||
| SC-2/D-10..D-12 | bad binding/foreign route/duplicate name/invalid name fail boot; prefixing | unit | `go test -short ./modules/pier -run TestCatalog` | ❌ |
|
||||
| D-11/D-13/D-14 | path/query/body split, raw body forwarding, isError with route body, annotations | unit (fake routes) | `go test -short ./modules/pier -run TestDispatch` | ❌ |
|
||||
| D-15/D-16 | public/cookie-only route refused unless guard declared; requirement union | unit | `go test -short ./modules/pier -run TestGuardRules` | ❌ |
|
||||
| D-17 | marker honoured by bouncer `api_token`/`backend` and `ResolveBackend`; header cannot forge it; frontend marker refused by backend guard | unit | `go test -short ./modules/bouncer ./modules/cabana -run TestDispatchMarker` | ❌ |
|
||||
| SC-3/D-20/D-23 | per-principal tools/list via SDK client; read-only grant hides write tools; call refused when not listed | integration (miniredis, fake guards) | `go test -short ./modules/pier -run TestToolsList` | ❌ |
|
||||
| D-25 | binding mismatch 403; DELETE ends session; rehydration on second router instance; sliding TTL expiry → 404 | integration (two routers, one miniredis) | `go test -short ./modules/pier -run TestSessions` | ❌ |
|
||||
| D-26/D-27 | event → PUBLISH → list_changed received by SDK client on the other instance; revocation closes session | integration | `go test -short ./modules/pier -run TestFanOut` | ❌ |
|
||||
| D-09 | plugin codes appear in user and group permission editors next to seeded rows; seeded rows win on duplicate code | Postgres | `go test ./... -run TestFrontendPermissionCatalog` (user plugin) | ❌ |
|
||||
| D-03..D-08 | link request one-shot/expiry/fresh-iat/1:1 uniqueness/admin set records linked_by/event; per-request lookup | Postgres | `go test ./... -run TestBackendLink` (user plugin) | ❌ |
|
||||
| D-04 | account seam: contributions listed, action runs, `interactiveOnly`, existing `/account*` and `/auth/me` payloads unchanged | unit + vitest | `go test -short ./modules/cabana -run TestAccountContributions`; `npm --prefix admin run test` | ❌ |
|
||||
| D-21 | `backend` accepted at authorize, unchanged default; consent drops `backend` without a link; consent payload has the admin line only with a link | unit + Postgres | `go test -short ./modules/wristband -run TestMCPScopes`; plugin `go test ./... -run TestMCPConsent` | ❌ |
|
||||
| SC-4 | `oas_at_` verifier: OIDC token refused, revoked/expired/foreign-kind refused | Postgres | plugin `go test ./... -run TestMCPTokenVerifier` | ❌ |
|
||||
| SC-4/D-18 | end-to-end in sm-demo-app: OAuth token → user tool works; with link + backend scope → Journal create_post as the admin; unlink → tool gone and call refused | e2e (testcontainers + SDK client) | `cd ../sm-demo-app && go test ./... -run TestMCPEndToEnd` | ❌ |
|
||||
| D-31 | fonoteka.go and sm-bm-app build and pass at bumped pins | build/test | `scripts/check-phase19.7.sh` stage `consumers` | ❌ |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** the quick run command for the touched module(s).
|
||||
- **Per wave merge:** framework `go vet ./... && go test ./...` plus the touched plugin suites.
|
||||
- **Phase gate:** `scripts/check-phase19.7.sh` (fail-closed, stages like check-phase19.4.sh: go, plugins, userplugin additive check, consumers, docker, admin dist/openapi) green before `/msd-verify-work`.
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `scripts/check-phase19.7.sh` — phase gate (pattern: `scripts/check-phase19.4.sh`)
|
||||
- [ ] pier test helpers: fake guard provider, fake verifier, a two-instance harness on one miniredis, an SDK client with a bearer-injecting `http.Client`
|
||||
- [ ] user-plugin Postgres fixtures for links and frontend permission rows (extend `admin_harness_test.go`)
|
||||
- [ ] additive-API checks for sm-user-plugin, sm-journal-plugin and sm-oauthserver-plugin (no exported identifier removed since the last pre-phase commit, as check-phase19.4.sh does)
|
||||
- Frameworks: none to install (go-sdk is added by the decision-note plan after the human check).
|
||||
|
||||
## Security Domain
|
||||
|
||||
### Applicable ASVS Categories
|
||||
|
||||
| ASVS Category | Applies | Standard Control |
|
||||
|---------------|---------|-----------------|
|
||||
| V2 Authentication | yes | Every request re-verifies the bearer (D-25); `oas_at_` verifier checks prefix, hash, expiry, revocation, client kind `mcp`, user active; personal tokens only behind the flag (D-19) |
|
||||
| V3 Session Management | yes | `crypto/rand.Text` session IDs; binding key check (403); sliding TTL; DELETE ends everywhere; revocation closes sessions; periodic re-verify on long GET streams |
|
||||
| V4 Access Control | yes | Scope ceiling (read → readOnly tools only, `backend` scope required for backend guard), guard provider checks equal to route enforcement, route middleware still enforces every call, marker only from pier |
|
||||
| V5 Input Validation | yes | JSON schema validation of arguments (`additionalProperties: false`), path value checks (no `/` in single-segment params), route `Where` constraints, body limits |
|
||||
| V6 Cryptography | yes | No hand-rolled crypto; link codes and tokens stored as SHA-256 of 32 random bytes; constant-time compare where hashes are compared |
|
||||
| V7 Error/Logging | yes | Audit log per tool call (tool, guard, frontend user id, backend admin id, client id, session id prefix, status, duration); never log tokens, arguments or bodies |
|
||||
| V13 API | yes | Origin validation / DNS-rebinding protection (`http.NewCrossOriginProtection` or an allow-list; the SDK's localhost protection applies only to loopback listeners), PRM and `WWW-Authenticate`, `throttle:` bucket on `/mcp` |
|
||||
|
||||
### Known Threat Patterns for this stack
|
||||
|
||||
| Pattern | STRIDE | Standard Mitigation |
|
||||
|---------|--------|---------------------|
|
||||
| Forged dispatch marker (an HTTP request claiming a pre-resolved principal) | Spoofing | Context-only marker with unexported key in bouncer; nothing reads headers for it; test that a request with any header set is unaffected; grep gate that only pier calls the setter |
|
||||
| Frontend marker accepted by a backend guard | Elevation | Marker lists the exact bouncer guard names to honour; `ResolveBackend` honours only a backend-kind marker |
|
||||
| Token passthrough / confused deputy | Spoofing/Elevation | Inner request carries no `Authorization`/`Cookie`; spec "MUST NOT pass through the token" |
|
||||
| Audience confusion (OIDC userinfo token or another resource's token used at `/mcp`) | Spoofing | Verifier accepts only `oas_at_` tokens of `kind = mcp` clients; single resource per issuer, `resource` equal to `mcp.resource`; consider persisting `resource` on MCP token rows (additive column) |
|
||||
| Session hijack (stolen `Mcp-Session-Id` with another valid token) | Spoofing | Binding key check; IDs unguessable; spec "Binding session IDs to user information" (SDK `TokenInfo.UserID` semantics) |
|
||||
| Link without proof / replayed link code / CSRF on confirm | Elevation | One-shot hashed code bound to the admin, short TTL, row lock, `jwt.auth` confirm with fresh `iat`, POST only, page names the admin; never by email (D-05) |
|
||||
| Admin sets a link to any frontend user (D-07) | Elevation | Separate permission without a default role, `linked_by` recorded, event fired; documented as a privileged action in the security review |
|
||||
| Consent phishing: DCR client asks for `backend` | Elevation | Consent shows the client name, redirect host and "also act as admin <name>"; grant `backend` only if a link exists at consent; `backend` revocable per grant |
|
||||
| Superuser reach through an agent (D-22) | Elevation | Read/write ceiling; audit log; security review weighs an optional config ceiling that refuses superuser backend principals over MCP |
|
||||
| Personal token at the MCP endpoint (spec says "MUST only accept tokens … valid for use with their own resources") | Spoofing | Off by default (D-19); tokens are this app's own; each capped by its own scope |
|
||||
| Prompt injection via tool metadata | Tampering | Static, factual descriptions; no data-derived text in descriptions or prompt templates |
|
||||
| DoS: many sessions/streams | DoS | `throttle:` on `/mcp`, session cap per binding key, idle TTL, SDK `MaxRequestBodyBytes` (4 MiB default), per-call timeout, cap on captured route response size |
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- go-sdk v1.8.0 source in the module cache (proxy-verified): `mcp/streamable.go`, `mcp/streamable_server.go`, `mcp/server.go`, `mcp/session.go`, `mcp/shared.go`, `mcp/tool.go`, `mcp/protocol.go`, `auth/auth.go`, `docs/protocol.md`, `README.md`, `LICENSE`, `go.mod`, `examples/server/distributed/main.go`
|
||||
- jsonschema-go v0.4.3 source: `jsonschema/infer.go`, `LICENSE`, `go.mod`
|
||||
- Local probes (scratchpad): linked-module list, +2.9 MiB size, stateful negotiation 2025-11-25, cross-instance rehydration, per-session list_changed
|
||||
- Framework: `modules/surf/router.go`, `routetable.go`, `apidocs.go`, `serve.go`, `params.go`, `overlap.go`, `limiter.go`; `modules/bouncer/context.go`, `registry.go`, `guard.go`; `modules/cabana/contracts.go`, `token_guard.go`, `auth.go`, `http.go`, `account.go`; `modules/wristband/server.go`, `authorize.go`, `stores.go`; `modules/cooler/cooler.go`; `modules/pact/capabilities.go`; `modules/party/registry.go`; `internal/build/*`; `modules/lagoon/attach/s3_*.go`
|
||||
- sm-user-plugin (fonoteka.go checkout, commit 35cf166 = v0.1.5): `plugin.go`, `routes.go`, `middleware/api_token.go`, `classes/permissions.go`, `classes/user_lookup.go`, `classes/api_tokens.go`, `classes/codes.go`, `models/*`, `controllers/*admin_controller.go`, `admin_permissions.go`, `config/config.yaml`
|
||||
- sm-journal-plugin: `routes.go`, `plugin.go`, `controllers/api/auth.go`, `posts.go`, `posts_helpers.go`
|
||||
- sm-oauthserver-plugin: `servers.go`, `routes.go`, `middleware.go`, `classes/mcp_store.go`, `models/access_token.go`, `README.md`
|
||||
- 19.6 plans (files_modified, D-15 slot), 19.4 CONTEXT, design note and both folded todos
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- MCP authorization spec 2025-11-25 — https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization (PRM MUST, resource MUST, no passthrough)
|
||||
- MCP 2026-07-28 release candidate post — https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/ (sessions and initialize removed)
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- Web search summaries on stateless MCP adoption ([appwrite.io](https://appwrite.io/blog/post/mcp-goes-stateless-in-the-2026-07-28-specification), [nango.dev](https://nango.dev/blog/stateless-mcp-how-it-changes-the-way-agents-call-tools), [betterstack](https://betterstack.com/community/guides/ai/mcp-stateless/)) — client behaviour claims not verified (A2)
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH — versions from the Go proxy, source read, dependency graph and size measured locally; legitimacy unverifiable by the seam (Go), gated by a human check.
|
||||
- Architecture: HIGH for in-repo seams (all read this session) and for the SDK session/rehydration/notification mechanics (probe-verified); MEDIUM for the overall hybrid-protocol question.
|
||||
- Pitfalls: HIGH — each tied to a read line range or a probe.
|
||||
|
||||
**Research date:** 2026-10-21
|
||||
**Valid until:** 2026-11-20 (go-sdk moves fast; re-check `@latest` before `go get`)
|
||||
93
.planning/phases/19.7-generic-mcp-module/19.7-VALIDATION.md
Normal file
93
.planning/phases/19.7-generic-mcp-module/19.7-VALIDATION.md
Normal file
@@ -0,0 +1,93 @@
|
||||
---
|
||||
phase: "19.7"
|
||||
slug: "generic-mcp-module"
|
||||
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
|
||||
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
created: "2026-10-21"
|
||||
---
|
||||
|
||||
# Phase 19.7 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution. Source: `19.7-RESEARCH.md` § Validation Architecture.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | Go `testing` + testcontainers-go (Postgres) + miniredis + go-sdk client; vitest for the admin SPA seam |
|
||||
| **Config file** | none (Go); `admin/vitest.config.*` for the SPA |
|
||||
| **Quick run command** | `go test -short ./modules/pier ./modules/surf ./modules/bouncer ./modules/cabana ./modules/wristband ./modules/pact` |
|
||||
| **Full suite command** | `go vet ./... && go test ./...` (framework) + `go test ./...` in each touched plugin + `npm --prefix admin run test` + `scripts/check-phase19.7.sh` |
|
||||
| **Estimated runtime** | ~120 seconds (quick ~20 s) |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** quick run command for the touched module(s)
|
||||
- **After every plan wave:** framework `go vet ./... && go test ./...` plus the touched plugin suites
|
||||
- **Before `/msd-verify-work`:** `scripts/check-phase19.7.sh` green
|
||||
- **Max feedback latency:** 120 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
Filled in by the planner per task; the behaviour → test map is:
|
||||
|
||||
| Ref | Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|-----|----------|-----------|-------------------|-------------|--------|
|
||||
| SC-1/D-30 | disabled → nothing mounted; enabled → `/mcp` mounted | unit | `go test -short ./modules/surf -run TestPierMount` | ❌ W0 | ⬜ pending |
|
||||
| SC-1 | SSE stream flushes through the streaming route kind | unit | `go test -short ./modules/surf -run TestStreamingRoute` | ❌ W0 | ⬜ pending |
|
||||
| SC-2/D-10..D-12 | binding/duplicate/name boot errors; prefixing | unit | `go test -short ./modules/pier -run TestCatalog` | ❌ W0 | ⬜ pending |
|
||||
| D-11/D-13/D-14 | arg split, isError with route body, annotations | unit | `go test -short ./modules/pier -run TestDispatch` | ❌ W0 | ⬜ pending |
|
||||
| D-15/D-16 | non-token routes refused; requirement union | unit | `go test -short ./modules/pier -run TestGuardRules` | ❌ W0 | ⬜ pending |
|
||||
| D-17 | marker honoured in-process only; not forgeable from HTTP; frontend marker refused by backend guard | unit | `go test -short ./modules/bouncer ./modules/cabana -run TestDispatchMarker` | ❌ W0 | ⬜ pending |
|
||||
| SC-3/D-20/D-23 | per-principal tools/list; read grant hides write tools | integration | `go test -short ./modules/pier -run TestToolsList` | ❌ W0 | ⬜ pending |
|
||||
| D-25 | session binding, DELETE, cross-instance rehydration, TTL | integration | `go test -short ./modules/pier -run TestSessions` | ❌ W0 | ⬜ pending |
|
||||
| D-26/D-27 | pub/sub list_changed; revocation ends session | integration | `go test -short ./modules/pier -run TestFanOut` | ❌ W0 | ⬜ pending |
|
||||
| D-09 | plugin frontend permission codes in editors | Postgres | user plugin `go test ./... -run TestFrontendPermissionCatalog` | ❌ W0 | ⬜ pending |
|
||||
| D-03..D-08 | link proof, one-shot, 1:1, admin set, per-request lookup | Postgres | user plugin `go test ./... -run TestBackendLink` | ❌ W0 | ⬜ pending |
|
||||
| D-04 | account contribution seam; existing payloads unchanged | unit + vitest | `go test -short ./modules/cabana -run TestAccountContributions`; `npm --prefix admin run test` | ❌ W0 | ⬜ pending |
|
||||
| D-21 | `backend` scope and consent line | unit + Postgres | `go test -short ./modules/wristband -run TestMCPScopes`; plugin `-run TestMCPConsent` | ❌ W0 | ⬜ pending |
|
||||
| SC-4 | `oas_at_` verifier refuses OIDC/revoked/expired/foreign tokens | Postgres | oauthserver plugin `go test ./... -run TestMCPTokenVerifier` | ❌ W0 | ⬜ pending |
|
||||
| SC-4/D-18 | end-to-end in sm-demo-app incl. unlink removes tools | e2e | `cd ../sm-demo-app && go test ./... -run TestMCPEndToEnd` | ❌ W0 | ⬜ pending |
|
||||
| D-31 | consumers build and pass at bumped pins | build | `scripts/check-phase19.7.sh` stage `consumers` | ❌ W0 | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `scripts/check-phase19.7.sh` — phase gate (pattern: `scripts/check-phase19.4.sh`)
|
||||
- [ ] pier test helpers: fake guard provider, fake verifier, two-instance harness on one miniredis, SDK client with a bearer-injecting `http.Client`
|
||||
- [ ] user-plugin Postgres fixtures for links and frontend permission rows
|
||||
- [ ] additive-API checks for sm-user-plugin, sm-journal-plugin, sm-oauthserver-plugin
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Real MCP client (Claude) connects once via OAuth and sees both tool sets | SC-4 | external client and browser consent | Add the demo's `/mcp` URL to a client, sign in, link an admin, request `backend`, call a Journal tool |
|
||||
| go-sdk legitimacy check before `go get` | D-24 | supply-chain human check | Confirm module path, version and maintainers in the decision note |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 120s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
362
.planning/phases/19.8-console-commands-overhaul/19.8-01-PLAN.md
Normal file
362
.planning/phases/19.8-console-commands-overhaul/19.8-01-PLAN.md
Normal file
@@ -0,0 +1,362 @@
|
||||
---
|
||||
phase: 19.8-console-commands-overhaul
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- .planning/notes/2026-10-21-console-charm-stack.md
|
||||
- .planning/research/STACK.md
|
||||
- .gitignore
|
||||
- go.mod
|
||||
- go.sum
|
||||
- README.md
|
||||
- modules/bonfire/styles.go
|
||||
- modules/bonfire/README.md
|
||||
- modules/diary/diary.go
|
||||
- modules/diary/config.go
|
||||
- modules/diary/component.go
|
||||
- modules/diary/console.go
|
||||
- modules/diary/daily.go
|
||||
- modules/diary/channels.go
|
||||
- modules/diary/tracer_smoke_test.go
|
||||
- modules/diary/config_smoke_test.go
|
||||
- modules/diary/README.md
|
||||
- internal/build/build.go
|
||||
- internal/build/build_test.go
|
||||
- internal/build/new.go
|
||||
- internal/build/stubs/new.tmpl
|
||||
- internal/build/testdata/new/main.go.golden
|
||||
- internal/build/testdata/new/config__log.yaml.golden
|
||||
- internal/build/testdata/new/CLAUDE.md.golden
|
||||
- internal/build/testdata/new-no-plugin/CLAUDE.md.golden
|
||||
- examples/hello/main.go
|
||||
- examples/hello/go.sum
|
||||
- examples/hello/plugins/greeter/go.sum
|
||||
- docs/services/logging.md
|
||||
- docs/setup/configuration.md
|
||||
- docs/services/configuration.md
|
||||
- modules/sunscreen/sunscreen.go
|
||||
- modules/sunscreen/README.md
|
||||
- modules/conga/client.go
|
||||
- modules/conga/worker.go
|
||||
- modules/conga/scheduler.go
|
||||
- modules/conga/conga.go
|
||||
- modules/conga/commands.go
|
||||
- modules/conga/logger_smoke_test.go
|
||||
- modules/conga/README.md
|
||||
- modules/lagoon/connection.go
|
||||
- modules/lagoon/purge.go
|
||||
- modules/lagoon/transaction.go
|
||||
- modules/lagoon/fill.go
|
||||
- modules/lagoon/attach/thumb.go
|
||||
- modules/lagoon/attach/bucket.go
|
||||
- modules/lagoon/logger_smoke_test.go
|
||||
- modules/lagoon/README.md
|
||||
- modules/cabana/log.go
|
||||
- modules/cabana/account.go
|
||||
- modules/cabana/account_tokens.go
|
||||
- modules/cabana/account_preferences.go
|
||||
- modules/cabana/field_file.go
|
||||
- modules/cabana/query.go
|
||||
- modules/cabana/actions.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/auth.go
|
||||
- modules/cabana/api_tokens.go
|
||||
- modules/cabana/token_guard.go
|
||||
- modules/cabana/singleton.go
|
||||
- modules/cabana/reorder.go
|
||||
- modules/cabana/relation_field.go
|
||||
- modules/cabana/partial_render.go
|
||||
- modules/cabana/field_type.go
|
||||
- modules/cabana/field_dependent.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/contracts.go
|
||||
- modules/cooler/cooler.go
|
||||
- modules/cooler/memory.go
|
||||
- modules/cooler/rediscache.go
|
||||
- modules/cooler/ratelimit.go
|
||||
- modules/cooler/sitecache.go
|
||||
- modules/postcard/mailer.go
|
||||
- modules/postcard/drivers.go
|
||||
- modules/beachcomber/beachcomber.go
|
||||
- modules/lighthouse/lighthouse.go
|
||||
- modules/lighthouse/centrifugo/client.go
|
||||
- modules/flare/flare.go
|
||||
- modules/phrasebook/translator.go
|
||||
- modules/wristband/oidc_token.go
|
||||
- modules/bouncer/turnstile.go
|
||||
autonomous: true
|
||||
requirements: [SC-1, SC-2, D-01, D-02, D-04, D-05, D-08, D-09, D-10, D-12]
|
||||
user_setup: []
|
||||
|
||||
estimate:
|
||||
tokens: 210000
|
||||
raw_tokens: 210000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-02: .planning/notes/2026-10-21-console-charm-stack.md names charm.land/lipgloss/v2 v2.0.6, charm.land/bubbletea/v2 v2.1.0, charm.land/bubbles/v2 v2.2.1, charm.land/huh/v2 v2.0.3, charm.land/fang/v2 v2.0.1 and github.com/charmbracelet/colorprofile v0.4.3 (or the patch versions actually installed, recorded in the note), the v2-over-v1 reason, fang used narrowly, the own diary console handler, the stdlib daily writer, the measured binary delta of about +2.1 MB, and that only modules/bonfire and modules/diary import them; its commit is an ancestor of the first commit that makes go.mod require charm.land/lipgloss/v2."
|
||||
- "D-08: diary reads log.default, log.level, log.channels (driver console, stderr, daily, stack or null, with level, format, path, days and channels) and log.components; SUMMER_LOG__* overrides that arrive as strings parse ('30' days, 'console,daily' for a stack list) and overlay the framework defaults key by key; an unknown driver, format or level fails boot with an error naming the key."
|
||||
- "D-09: diary.For(name) returns a component logger that is safe in package var blocks: before diary is set up its records go to slog.Default's handler with a component attribute, after Setup they pass the component floor (log.components[name], else log.level) and reach every channel; River logs through diary.For(\"river\"), GORM through diary.For(\"db\") with parameterized SQL, and every framework module except surf's request log uses a diary component logger instead of the process default logger."
|
||||
- "D-10: with no config/log.yaml an application logs to a stack of console and daily; the daily file is storage/logs/summer-YYYY-MM-DD.log under the application root, JSON, level warn, kept 14 days; summer new writes the same shape as config/log.yaml; an unwritable log directory warns once on the console and disables that channel without failing boot."
|
||||
- "D-04 (logging half), D-05: the console channel renders styled lines with a fixed-width component prefix in bonfire's palette when its writer is a terminal, and logfmt (json when format: json) otherwise; SetVerbosity(1, false) lets INFO through for every component, SetVerbosity(2, false) DEBUG, and quiet raises only the console channel to WARN."
|
||||
- "D-12: every channel leaf is wrapped by sunscreen.Wrap, so a password attribute reads [REDACTED] on the console and in the daily file; sunscreen.InstallDefault keeps its signature and behaviour (sunscreen.Install never existed, so no shim is added and the README says so)."
|
||||
- "D-01 (palette): modules/bonfire/styles.go ports SSU's status styles (green success, cyan current, yellow warning, red and bold red error, 240 muted, bold header) to lipgloss v2, and the diary console handler takes its colors from it."
|
||||
- "The generated main runs diary.Bootstrap before compass loads and diary.Setup right after, examples/hello/main.go is regenerated, and go vet ./... plus go test -short ./... stay green at every commit."
|
||||
artifacts:
|
||||
- path: ".planning/notes/2026-10-21-console-charm-stack.md"
|
||||
provides: "D-02 dependency decision note"
|
||||
contains: "charm.land/lipgloss/v2"
|
||||
- path: "modules/diary/diary.go"
|
||||
provides: "For, Setup, Bootstrap, SetVerbosity, Verbosity, Reset, Close"
|
||||
contains: "func For(component string) *slog.Logger"
|
||||
- path: "modules/diary/daily.go"
|
||||
provides: "daily rotating writer with retention"
|
||||
contains: "0o640"
|
||||
- path: "modules/bonfire/styles.go"
|
||||
provides: "shared console palette and color mode"
|
||||
contains: "type Palette struct"
|
||||
- path: "internal/build/stubs/new.tmpl"
|
||||
provides: "summer new writes config/log.yaml"
|
||||
contains: "new/config/log.yaml"
|
||||
key_links:
|
||||
- from: "internal/build/build.go"
|
||||
to: "modules/diary/diary.go"
|
||||
via: "generated main calls diary.Bootstrap then diary.Setup"
|
||||
pattern: "diary.Setup\\(cfg"
|
||||
- from: "modules/diary/channels.go"
|
||||
to: "modules/sunscreen/sunscreen.go"
|
||||
via: "every channel leaf is wrapped"
|
||||
pattern: "sunscreen.Wrap\\("
|
||||
- from: "modules/conga/client.go"
|
||||
to: "modules/diary/diary.go"
|
||||
via: "River's logger is the river component"
|
||||
pattern: "diary.For\\(\"river\"\\)"
|
||||
- from: "modules/lagoon/connection.go"
|
||||
to: "modules/diary/diary.go"
|
||||
via: "GORM logs through the db component"
|
||||
pattern: "NewSlogLogger\\(diary.For\\(\"db\"\\)"
|
||||
prohibitions:
|
||||
- statement: "No package outside modules/bonfire and modules/diary imports a charm.land or github.com/charmbracelet module."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing in this repository imports gopkg.in/yaml.v3."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Framework code, READMEs and docs name no consuming application (neutral names such as acme or blog)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed or tagged in this plan."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; MVP mode takes a story from `/msd mvp-phase 19.8`, which was not run, so no story is invented here): console commands in `summer` and in application binaries reach the bar set by Laravel's Artisan. The command logic stays; logging, the shared console layer in `bonfire`, and every command's help and output change. A user sees what a command did, can ask for more or less of it, and learns from `--help` how to use it without opening the docs.
|
||||
|
||||
This plan's slice (plan 1 of 4, wave 1): the `diary` logging module. The charm-stack decision note lands first (project rule, D-02). Then one record from a named component logger flows from `config/log.yaml` through `compass` into a `sunscreen`-wrapped stack of console and daily file, wired into the generated `main` (the tracer). Then the full Laravel-shaped channel config with `summer new`'s `config/log.yaml` and the logging docs. Last, River, GORM and every framework module log through named components, so plan 2's verbosity flags govern third-party and framework lines alike.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Spec-less probe fallback skipped: phase has no requirement IDs mapped (phase_req_ids null) and no SPEC.md. This is a visible choice. `must_haves` derive from the ROADMAP success criteria SC-1..SC-5 and CONTEXT D-01..D-30; `requirements` lists those IDs for traceability.
|
||||
|
||||
## Decisions taken in this plan (recorded assumptions and Claude's discretion)
|
||||
|
||||
- Console handler (CONTEXT discretion): diary's own lipgloss-styled `slog.Handler`, not `charm.land/log/v2`, which fails `testing/slogtest` (RESEARCH probe). The decision note records it.
|
||||
- Install order: this plan installs only `charm.land/lipgloss/v2` and `github.com/charmbracelet/colorprofile` (the palette and the console handler import them; `go mod tidy` drops unused requirements). Plan 2 installs bubbletea, bubbles, huh and fang with the code that imports them. The note names all six.
|
||||
- diary imports bonfire for the palette (RESEARCH Architectural Responsibility Map: one palette for list, help, widgets and log lines). bonfire never imports diary; plan 2 connects them through a `bonfire.OnGlobals` hook.
|
||||
- Framework defaults equal the approved D-08 example: `default: stack`, `level: info`, `stack` = `[console, daily]`, `console` format `auto`, `daily` at `storage/logs/summer.log`, 14 days, `json`, level `warn`, `components: {river: warn}`. The example's `surf: info` line is written as `http: info` in `summer new`'s file with a comment that `http: warn` silences the request log (D-11 and D-23 name the component `http`; plan 3 moves the request log onto it).
|
||||
- Verbosity mapping (RESEARCH A4): `-v` lowers every component floor to at most INFO, `-vv` to DEBUG, `-vvv` adds source locations on the console and GORM SQL traces at INFO (parameterized). Quiet raises only the console channel to WARN; files keep their own levels. Plan 2 wires the flags; this plan provides `SetVerbosity`.
|
||||
- Daily files open lazily on the first record (no empty files), in the process's local time zone (RESEARCH A7), mode 0o640 in directories created 0o750. Relative paths resolve against `Options.Root` (default: the working directory the generated main loads `config` from) and may not escape it; absolute paths are the operator's choice and accepted as given.
|
||||
- Env overrides use compass's double-underscore separator (`SUMMER_LOG__CHANNELS__DAILY__DAYS=30`); D-08's `SUMMER_LOG_*` is shorthand for these. diary parses `log.*` from `Config.Lookup` by hand rather than `LoadSection` (RESEARCH A3), so env strings are accepted where YAML would give numbers or lists.
|
||||
- Component names are plugin or module codes without dots (koanf splits keys on `.`). Framework components: the package name (`cabana`, `lagoon`, `attach`, `cooler`, `postcard`, `beachcomber`, `lighthouse`, `centrifugo`, `flare`, `phrasebook`, `wristband`, `bouncer`, `conga`), plus `river` and `db`. Where a module already prefers an app-published `*slog.Logger` (conga, cooler, postcard, beachcomber), that override stays first.
|
||||
- `sunscreen.Install` does not exist (RESEARCH docs table); the roadmap's wording refers to `sunscreen.InstallDefault`, which keeps working unchanged as the compatibility entry point.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Planning: `.planning/notes/2026-10-21-console-charm-stack.md`; Supporting Libraries rows in `.planning/research/STACK.md` for the six charm modules.
|
||||
- New module `modules/diary` (`git.golem15.com/golem15/summercms/modules/diary`):
|
||||
- Functions: `For(component string) *slog.Logger`, `Bootstrap(w io.Writer, opts Options)`, `Setup(cfg *compass.Config, opts Options) error`, `SetVerbosity(verbosity int, quiet bool)`, `Verbosity() int`, `Reset()`, `Close() error`, `ParseConfig(cfg *compass.Config) (Config, error)`, `DefaultConfig() Config`, `NewConsoleHandler(w io.Writer, opts ConsoleOptions) slog.Handler`.
|
||||
- Types: `Options{Verbosity int; Quiet bool; Color bonfire.ColorMode; Root string; Stderr io.Writer; Environ []string; Now func() time.Time}`, `Config{Default string; Level slog.Level; Channels map[string]ChannelConfig; Components map[string]slog.Level}`, `ChannelConfig{Driver, Format, Path string; Level slog.Level; HasLevel bool; Days int; Channels []string}`, `ConsoleOptions{Level slog.Leveler; Profile colorprofile.Profile; AddSource bool}`.
|
||||
- Constants: `ComponentKey = "component"`, `DriverConsole`, `DriverStderr`, `DriverDaily`, `DriverStack`, `DriverNull`, `FormatAuto`, `FormatJSON`, `FormatLogfmt`, config keys `DefaultKey = "log.default"`, `LevelKey = "log.level"`, `ChannelsKey = "log.channels"`, `ComponentsKey = "log.components"`.
|
||||
- Smoke tests: `TestDiaryTracerSmoke`, `TestConfigEnvSmoke`, `TestDailyDegradesSmoke`.
|
||||
- bonfire: `ColorMode` with `ColorAuto`, `ColorAlways`, `ColorNever`; `Palette{Success, Current, Warning, Error, ErrorBold, Muted, Header lipgloss.Style}`; `DefaultPalette() Palette`; `DetectProfile(w io.Writer, mode ColorMode, environ []string) colorprofile.Profile`.
|
||||
- Config keys (published): `log.default`, `log.level`, `log.channels.<name>.driver|level|format|path|days|channels`, `log.components.<name>`.
|
||||
- Generated main: `diary.Bootstrap(os.Stderr, diary.Options{})` as the first statement of `run`, `diary.Setup(cfg, diary.Options{})` after `compass.Load`; no `sunscreen` import. `summer new` writes `config/log.yaml`.
|
||||
- conga: River logger `diary.For("river")`, conga's own lines `diary.For("conga")`; smoke `TestRiverLoggerUsesDiarySmoke`. lagoon: GORM logger `logger.NewSlogLogger(diary.For("db"), ...)`; smoke `TestGormLoggerUsesDiarySmoke`.
|
||||
- `.gitignore` entries `/storage/` and `/examples/hello/storage/`; root README row for diary.
|
||||
|
||||
<objective>
|
||||
Record the charm-stack decision, then give every application one logging pipeline: named component loggers, Laravel-shaped channels configured in `config/log.yaml`, redaction on every channel, daily files on by default, and River, GORM and framework modules routed through it.
|
||||
|
||||
Purpose: D-02 (note first), D-08, D-09, D-10, D-12, D-05 and the logging half of D-04, the palette half of D-01; SC-1 and the diary side of SC-2.
|
||||
Output: one planning-doc commit (note + STACK.md), the diary tracer commit with READMEs and the generated main, the channel-config commit with docs and `summer new`, and one or two commits routing River, GORM and framework modules.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-CONTEXT.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-RESEARCH.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-PATTERNS.md
|
||||
@modules/sunscreen/README.md
|
||||
|
||||
Repos and concurrency: this plan writes only in summercms.go. Several sessions work here (Phase 19.5 is executing; its in-flight files are `.planning/STATE.md`, `.planning/state.json`, `scripts/check-phase19.5.sh` and 19.5 phase docs). Before each task run `git status --porcelain` on the paths the task touches; if a file this plan edits is modified by someone else, stop and report (for cabana files in Task 3: skip only those files, record them in the SUMMARY, continue with the rest). Stage files by explicit path, never `git add -A`. No co-author tags. Planning docs and code in separate commits; a README and docs change in the same commit as the API it describes. Keep `go vet ./...` and `go test -short ./...` green at every commit (and `go -C examples/hello test ./...`, a separate workspace module). Nothing is pushed or tagged.
|
||||
|
||||
<interfaces>
|
||||
Existing seams (extend, do not fork):
|
||||
|
||||
- modules/sunscreen/sunscreen.go: `Wrap(next slog.Handler) slog.Handler` (idempotent: an already-wrapped handler is returned unchanged, lines 82-87), `InstallDefault(w io.Writer)` (line 94), `Scrub(s string) string`, `RedactedKeys()`; handler implements Enabled, Handle, WithAttrs, WithGroup (lines 102-125) — the shape to copy for diary's handlers.
|
||||
- modules/compass/config.go: `Options{Dir, Env string; Environ []string}`, `Load(dir)`, `Open(opts)`, `(*Config).Lookup(path) (any, bool)`, `String`, `Int`, `Bool`, `Has`, `Environment()`; compass imports no framework module. Env layering: modules/compass/env.go:31-44 (`SUMMER_` prefix, `__` separates path segments, values arrive as strings).
|
||||
- internal/build/build.go:94-151 `generateMain(m Manifest)`: strings.Builder template; keep every `commands = append(commands, pkg.Ctor(app...` line verbatim (cmd/summer/docs_test.go:418 regex `generatedConstructor` parses them). `formatSource` at the end.
|
||||
- internal/build/build_test.go:148-170 `TestGenerateMainInstallsRedactingLogger` (asserts the sunscreen install line; replaced in Task 1). Goldens: `go test ./internal/build -run Golden -update` (flag at internal/build/new_golden_test.go:13), files under internal/build/testdata/new/ (`main.go.golden`, `config__<name>.yaml.golden`, `CLAUDE.md.golden`) and testdata/new-no-plugin/.
|
||||
- internal/build/new.go:388-391 the planned file list (`{"config/app.yaml", "new/config/app.yaml", 0o644}` and siblings); internal/build/stubs/new.tmpl `{{define "new/config/http.yaml"}}` (line 23) is the template shape; CLAUDE.md template line 126 lists the config files.
|
||||
- examples/hello: committed generated `main.go`, regenerated by `go -C examples/hello run ../../cmd/summer build` (hello_test.go:282 does the same); its own go.mod plus plugin modules under plugins/ with their own go.sum; the repo go.work uses all of them.
|
||||
- modules/conga/client.go:102-123 `baseConfig(s, log)` sets `river.Config.Logger`; `loggerFromApp(app)` returns the app-published `*slog.Logger` else the process default. modules/lagoon/connection.go:57 `gormFromSQL` opens GORM with `&gorm.Config{}` (GORM's own stdout logger, RESEARCH Pitfall 6). gorm v1.31.2 has `logger.NewSlogLogger(*slog.Logger, logger.Config)` (logger/slog.go:24-32).
|
||||
- SSU palette to port (external repo, read-only): /media/nvme/dev/pxpx/ssu/internal/cli/tui/styles.go (Color "2" green, "6" cyan, "3" yellow, "1" red and bold red, "240" muted, bold header; drop `StyleForStatus` and the git coupling).
|
||||
- Decision-note structure to copy: .planning/notes/2026-10-19-oidc-on-wristband-golang-jwt.md (Decision, Date, Status, Context, Decision bullets, rejected alternatives, Related dependencies, Consequences).
|
||||
- RESEARCH Pattern 1 (proxy-backed named loggers), Pattern 2 (channels as sunscreen-wrapped leaves under slog.NewMultiHandler), Pattern 3 (daily writer), Pitfalls 4-8.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: the decision note lands first, then one diary.For("journal") record flows from config/log.yaml through compass into a sunscreen-wrapped stack of console and daily file, wired into the generated main</name>
|
||||
<reversibility rating="costly">D-02: the charm modules enter every application binary; D-08: log.* keys become published config that every application's config/log.yaml uses.</reversibility>
|
||||
<files>.planning/notes/2026-10-21-console-charm-stack.md, .planning/research/STACK.md, go.mod, go.sum, .gitignore, README.md, modules/bonfire/styles.go, modules/bonfire/README.md, modules/diary/diary.go, modules/diary/config.go, modules/diary/component.go, modules/diary/console.go, modules/diary/daily.go, modules/diary/channels.go, modules/diary/tracer_smoke_test.go, modules/diary/README.md, internal/build/build.go, internal/build/build_test.go, internal/build/testdata/new/main.go.golden, examples/hello/main.go, examples/hello/go.sum, examples/hello/plugins/greeter/go.sum</files>
|
||||
<read_first>.planning/notes/2026-10-19-oidc-on-wristband-golang-jwt.md (structure), .planning/research/STACK.md (Supporting Libraries table, lines 28-49), 19.8-RESEARCH.md (Summary, Standard Stack, Alternatives Considered, Package Legitimacy Audit, Patterns 1-3, Pitfalls 4-8, Probe evidence), 19.8-PATTERNS.md (modules/diary section), modules/sunscreen/sunscreen.go, modules/sunscreen/README.md (README structure to copy), modules/compass/config.go, modules/compass/env.go, modules/bonfire/output.go (current color policy: NO_COLOR, TERM=dumb, FORCE_COLOR), modules/bonfire/README.md, /media/nvme/dev/pxpx/ssu/internal/cli/tui/styles.go, internal/build/build.go, internal/build/build_test.go, examples/hello/main.go, examples/hello/hello_test.go, README.md (module table rows near lines 97 and 117), .gitignore</read_first>
|
||||
<action>Planning doc first (D-02). Run `go list -m -versions` (or `go list -m <path>@latest`) for the six modules and keep RESEARCH's versions unless a newer patch of the same minor exists, in which case use and record that patch. Write `.planning/notes/2026-10-21-console-charm-stack.md` in the structure of the 2026-10-19 note: title "Decision: the console runs on charm's v2 stack, logging stays on stdlib slog"; Date 2026-10-21; Status Accepted; Context Phase 19.8 (D-01, D-02, D-05, D-08 and the CONTEXT discretion item on the console handler), 19.8-RESEARCH.md Standard Stack, Alternatives Considered, Package Legitimacy Audit and probes. Decision bullets: the modules `charm.land/lipgloss/v2` v2.0.6, `charm.land/bubbletea/v2` v2.1.0, `charm.land/bubbles/v2` v2.2.1, `charm.land/huh/v2` v2.0.3, `charm.land/fang/v2` v2.0.1 and `github.com/charmbracelet/colorprofile` v0.4.3 on the charm.land vanity paths; why v2 and not SSU's v1 pins (fang v1 already depends on a lipgloss v2 beta, so v1 widgets plus fang would compile two lipgloss majors; v1 is maintenance-only), and that SSU's widgets are ported with the v2 renames (View returns tea.View, KeyPressMsg, progress WithColors, SetWidth); only modules/bonfire (all five) and modules/diary (lipgloss, colorprofile) import them, plugins reach them through bonfire.Output and diary.For; fang is used narrowly (Execute with WithoutVersion because its -v would collide with verbosity, WithoutManpage because it adds a hidden man command, WithoutCompletions, and WithErrorHandler set to bonfire's renderer), help and list layouts are bonfire's own (D-14); colorprofile.Writer applies --ansi, --no-ansi and NO_COLOR because lipgloss v2 Render always emits ANSI; the diary console handler is our own lipgloss handler and charm's slog logger module is rejected because it fails testing/slogtest (groups treated as prefixes, empty attributes printed, empty-key groups not inlined) and duplicates diary's level and prefix model, which settles the CONTEXT discretion item; daily files use a stdlib writer of about 120 lines and lumberjack is rejected (it rotates by size, has no name-YYYY-MM-DD files, and would need its own note); fan-out is stdlib slog.NewMultiHandler and GORM uses gorm's own logger.NewSlogLogger; cost about +2.1 MB on a stripped binary (2,527,495 to 4,649,223 bytes in the probe); module go directives are at most 1.26 and fit go 1.27.0; MVS keeps cobra v1.10.2, pflag v1.0.9 and x/text v0.42.0; gopkg.in/yaml.v3 enters the module graph only as testify's indirect test dependency of fang and huh and is never imported (goccy rule); package legitimacy was checked by hand against proxy.golang.org and the charm.land go-import meta tags (origins github.com/charmbracelet/*) because the MSD seam rejects Go, verdict approved, fang noted as young (two v2 tags); install order: plan 19.8-01 installs lipgloss and colorprofile, plan 19.8-02 the other four. A rejected-alternatives paragraph (v1 pins, charm's slog logger, lumberjack, hand-written fan-out) and Consequences (every binary grows; removing the stack later means rewriting the widgets, D-01 and D-02 are rated costly; plugins never import charm modules directly). In .planning/research/STACK.md add one Supporting Libraries row per module (purpose; "Decided in .planning/notes/2026-10-21-console-charm-stack.md (Phase 19.8 D-02); imported only by modules/bonfire and modules/diary"). Commit the note and STACK.md together, alone: `docs(19.8): decide the charm v2 console stack`.
|
||||
|
||||
Then the tracer. `go get charm.land/lipgloss/v2@<version> github.com/charmbracelet/colorprofile@<version>`; after the code below imports them run `go mod tidy`, then `go -C examples/hello build ./...` and `go mod tidy` in every examples/hello module whose build asks for go.sum entries.
|
||||
|
||||
modules/bonfire/styles.go (new): `type ColorMode int` with `ColorAuto`, `ColorAlways`, `ColorNever`; `type Palette struct` with lipgloss.Style fields `Success`, `Current`, `Warning`, `Error`, `ErrorBold`, `Muted`, `Header`; `DefaultPalette()` ported from SSU styles.go (Color "2", "6", "3", "1", "1" plus Bold, "240", Bold); `DetectProfile(w io.Writer, mode ColorMode, environ []string) colorprofile.Profile`: ColorNever gives colorprofile.NoTTY (no escape codes at all), ColorAlways gives the detected profile raised to at least ANSI256, ColorAuto treats a non-empty FORCE_COLOR as ColorAlways and otherwise uses colorprofile.Detect(w, environ) (which honours NO_COLOR and TERM=dumb). output.go is not changed in this plan (plan 2 moves the console onto the palette). bonfire README: API reference rows for the four names, Dependencies gains lipgloss and colorprofile.
|
||||
|
||||
modules/diary (new package, doc comment naming D-08 and D-09). diary.go: `Options` (fields listed under Artifacts; Stderr defaults to os.Stderr, Root to ".", Now to time.Now, Environ to os.Environ()); package state in an `atomic.Pointer` to a state struct holding the root handler, the floors, the console channel's `*slog.LevelVar`, verbosity, quiet and a generation counter; `Bootstrap(w, opts)` installs a state with one console channel on w and no config (used before config loads and by the summer tool) and calls slog.SetDefault with a logger over an unnamed component handler; `Setup(cfg, opts)` parses the config, builds the channels, swaps the state, calls slog.SetDefault, and returns parse errors unchanged; `For(component)` returns `slog.New` over a component handler; `SetVerbosity(verbosity, quiet)` recomputes floors and sets the console LevelVar to WARN when quiet (otherwise back to its configured level); `Verbosity()`; `Reset()` (tests) drops the state and restores the slog default captured at the first Bootstrap or Setup; `Close()` closes open daily files. component.go: the proxy handler of RESEARCH Pattern 1: name, pre-bound attrs and groups; with no state it delegates to the process default handler (and to a plain stderr TextHandler if that default is itself a diary component handler, so it can never recurse), adding the component attribute; with a state, Enabled is `level >= floor(name)` and the root's Enabled, Handle adds `slog.String(ComponentKey, name)` when name is non-empty and replays attrs and groups onto the live root (cached per state generation); WithAttrs with a string attribute keyed `component` rebinds the name. In this task floor(name) is log.level lowered by verbosity (Task 2 adds log.components). console.go: `NewConsoleHandler(w, opts)` writing one line per record in one Write under a mutex shared by its WithAttrs and WithGroup clones: time (15:04:05, Muted), level padded to 5 (DEBUG Muted, INFO Current, WARN Warning, ERROR ErrorBold), the component attribute as a fixed-width prefix (10 columns, Header), the message, then key=value attributes (keys Muted) with groups as dotted key prefixes, empty attributes skipped and empty-key groups inlined (slogtest semantics); control characters other than tab are stripped from the message and attribute values (terminal escape injection); output goes through colorprofile.Writer with opts.Profile; AddSource prints file:line. daily.go: an unexported daily writer for `path` (dir, base name and extension split; `storage/logs/summer.log` gives files `summer-2026-10-21.log`): Write locks, picks the file by Now's local date, opens lazily with os.O_APPEND|os.O_CREATE|os.O_WRONLY and mode 0o640 after os.MkdirAll(dir, 0o750), reopens on a date change, and on each open deletes only files matching base-YYYY-MM-DD+ext whose date is more than `days` days old (days 0 keeps all); an open failure reports once through a warn callback and disables the writer (later writes succeed silently). channels.go: builds the channel named by log.default: console (format auto picks the console handler when the writer is a terminal per bonfire.DetectProfile and a TextHandler otherwise; json and logfmt force slog.JSONHandler or slog.TextHandler), daily (JSONHandler by default, TextHandler for logfmt, on the daily writer), stack (slog.NewMultiHandler over the named children); every leaf is `sunscreen.Wrap(...)` and gated by its own `*slog.LevelVar` (the channel level when set, else no extra floor). config.go: `Config`, `ChannelConfig`, constants and `DefaultConfig()` (the framework defaults listed in this plan's decisions), `ParseConfig(cfg)` reading log.default, log.level and log.channels (driver, level, format, path, days, channels) via Lookup in this task (Task 2 completes validation, stderr, null and components).
|
||||
|
||||
Generated main (internal/build/build.go generateMain): import diary, drop the sunscreen import; first statement of run `diary.Bootstrap(os.Stderr, diary.Options{})`; after `compass.Load("config")` and its error check, `if err := diary.Setup(cfg, diary.Options{}); err != nil { return err }` before `backpack.New(cfg)`; every other line unchanged. Replace `TestGenerateMainInstallsRedactingLogger` with `TestGenerateMainInstallsDiary`: Bootstrap appears once and is the first statement of run before `compass.Load(`; Setup appears once between `compass.Load(` and `backpack.New(`; the sunscreen import is absent. Update the goldens with `go test ./internal/build -run Golden -update` and regenerate examples/hello/main.go with `go -C examples/hello run ../../cmd/summer build`. Add `/storage/` and `/examples/hello/storage/` to the root .gitignore (hello's tests run the binary in its directory, which now writes daily files).
|
||||
|
||||
Smoke test modules/diary/tracer_smoke_test.go `TestDiaryTracerSmoke` (runs under -short): write `<tmp>/config/log.yaml` with a stack of console and daily, daily level info and path storage/logs/summer.log; `compass.Open(compass.Options{Dir: <tmp>/config})`; `diary.Setup(cfg, diary.Options{Root: tmp, Stderr: &buf, Color: bonfire.ColorNever, Now: fixed 2026-10-21 12:00})`; `t.Cleanup(diary.Reset)`; `diary.For("journal").Info("post saved", "password", "hunter2", "id", 7)`; assert buf holds `component=journal`, `post saved` and `[REDACTED]` and not the secret; `<tmp>/storage/logs/summer-2026-10-21.log` exists with mode 0o640 and its single JSON line decodes with component journal and password `[REDACTED]`. Also assert a record from `diary.For("x")` created before Setup (package-level var in the test file) still reaches buf after Setup.
|
||||
|
||||
READMEs in the same commit: modules/diary/README.md in the sunscreen structure (H1 `diary`, summary sentence "Application logging modelled on Laravel's logging config: named channels (console, stderr, daily files, stacks), per-component levels and redaction on every channel.", import line, `**Name:** A beach diary records each day; the log keeps one file per day.`, Overview, Role in the framework, Features, Usage with diary.For, API reference for every exported name above, Configuration with the default stack, Dependencies, Testing naming TestDiaryTracerSmoke); a root README.md table row with the same summary sentence and mnemonic, placed alphabetically. Neutral wording; no application named. Commit: `feat(diary): component loggers through a redacting console and daily stack`.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/diary/... ./modules/bonfire/... ./modules/sunscreen/... ./internal/build/... && go test -count=1 -short -run '^TestDiaryTracerSmoke$' -v ./modules/diary 2>&1 | grep -q -- '^--- PASS: TestDiaryTracerSmoke' && go -C examples/hello vet ./... && go -C examples/hello test -count=1 ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit: vet or a short suite is red (generated main, goldens or hello no longer match), no "--- PASS: TestDiaryTracerSmoke" line (the record did not reach both channels redacted, or the test did not run), examples/hello fails to build or test against the regenerated main, or the docs checker reports an identifier in the diary or bonfire README that does not exist.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `N=$(git log --format=%H --diff-filter=A -- .planning/notes/2026-10-21-console-charm-stack.md) && A=$(git log --format=%H -S 'charm.land/lipgloss/v2' -- go.mod) && L=$(printf '%s\n' "$A" | tail -1) && test -n "$N" && test -n "$L" && git merge-base --is-ancestor "$N" "$L"` exits 0 (note committed before go.mod requires lipgloss).
|
||||
- `for s in charm.land/lipgloss/v2 charm.land/bubbletea/v2 charm.land/bubbles/v2 charm.land/huh/v2 charm.land/fang/v2 github.com/charmbracelet/colorprofile WithoutVersion slogtest lumberjack '2.1 MB'; do grep -qF -- "$s" .planning/notes/2026-10-21-console-charm-stack.md || exit 1; done` exits 0.
|
||||
- `grep -c '2026-10-21-console-charm-stack.md' .planning/research/STACK.md` prints at least 1.
|
||||
- `go list -f '{{.ImportPath}} {{join .Imports " "}}' ./... | grep -E ' (charm\.land/|github\.com/charmbracelet/)' | grep -vcE '^git\.golem15\.com/golem15/summercms/modules/(bonfire|diary) '` prints 0.
|
||||
- `grep -c 'sunscreen.Wrap(' modules/diary/channels.go` prints at least 1 and `grep -c '0o640' modules/diary/daily.go` prints at least 1.
|
||||
- `grep -c 'diary.Bootstrap(os.Stderr' internal/build/testdata/new/main.go.golden` prints 1, `grep -c 'diary.Setup(cfg' examples/hello/main.go` prints 1, and `grep -c 'modules/sunscreen"' examples/hello/main.go` prints 0.
|
||||
- `grep -c '\[diary\](modules/diary/README.md)' README.md` prints 1 and `grep -c '^\*\*Name:\*\*' modules/diary/README.md` prints 1.
|
||||
- `git check-ignore -q examples/hello/storage/logs/summer-2026-10-21.log` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>The dependency decision is on record before any charm code, and a record from a named component logger reaches a styled-or-logfmt console line and a JSON daily file, both redacted, in every generated application.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Operators configure logging the Laravel way: stderr and null drivers, per-channel level and format, log.components, env overrides as strings, a confined path and a daily channel that degrades; summer new writes config/log.yaml and the docs describe it</name>
|
||||
<reversibility rating="costly">D-08: the log.* key shape and its defaults are published in summer new's config/log.yaml and the docs; changing them later breaks every application's file.</reversibility>
|
||||
<files>modules/diary/config.go, modules/diary/channels.go, modules/diary/component.go, modules/diary/daily.go, modules/diary/config_smoke_test.go, modules/diary/README.md, internal/build/new.go, internal/build/stubs/new.tmpl, internal/build/testdata/new/config__log.yaml.golden, internal/build/testdata/new/CLAUDE.md.golden, internal/build/testdata/new-no-plugin/CLAUDE.md.golden, docs/services/logging.md, docs/setup/configuration.md, docs/services/configuration.md</files>
|
||||
<read_first>modules/diary/config.go, modules/diary/channels.go, modules/diary/component.go, modules/diary/daily.go (from Task 1), modules/conga/client.go:34-60 (settingsFromApp: defaults first, tolerant string-or-int parsing), modules/cooler/cooler.go:297-307 (errors that name the key), modules/compass/env.go, internal/build/new.go (planNewApp list near line 388), internal/build/stubs/new.tmpl (config templates and the CLAUDE.md template line 126), internal/build/testdata/new/config__http.yaml.golden, docs/services/logging.md, docs/setup/configuration.md, docs/services/configuration.md, 19.8-CONTEXT.md (D-08 example YAML, D-10), 19.8-RESEARCH.md (Pitfalls 4 and 8, Security Domain V5 and V12)</read_first>
|
||||
<action>Per D-08, D-09 and D-10. config.go `ParseConfig(cfg)`: start from `DefaultConfig()` and overlay what the config holds, channel by channel and key by key, so a single env override such as `SUMMER_LOG__CHANNELS__DAILY__DAYS=30` with no log.yaml keeps every other default. Rules: log.level and every level value accept debug, info, warn, warning, error (case-insensitive); log.default must name a channel; `log.channels.<name>.driver` must be one of console, stderr, daily, stack, null (a channel that is not a default and has no driver is an error naming `log.channels.<name>.driver`); format must be auto, json or logfmt, auto only for console (stderr defaults to logfmt, daily to json); days accepts an int or a decimal string, at least 0; channels accepts a YAML list or a comma-separated string, trimmed; `log.components.<name>` values are levels. Every error names the full key, for example `diary: log.channels.daily.days must be a whole number of at least 0`. Stacks may nest; unknown child names and cycles are errors naming the stack.
|
||||
|
||||
channels.go: add `stderr` (TextHandler or JSONHandler on Options.Stderr, never styled) and `null` (slog.DiscardHandler); each still wrapped by sunscreen.Wrap. Path handling for daily: a relative path is cleaned and joined to Options.Root, and one that escapes Root (a `..` prefix after cleaning) is a config error naming `log.channels.<name>.path`; an absolute path is used as given. daily.go: when opening fails (unwritable or read-only directory), the warn callback logs one WARN record from component `diary` on the console channel naming the path and the error, and the channel is disabled; Setup never fails because of a log file. component.go: floor(name) is `Components[name]` when set, else `Level`; verbosity 1 lowers every floor to at most INFO, 2 and more to DEBUG; records from loggers without a component use `Level`.
|
||||
|
||||
summer new: add `{"config/log.yaml", "new/config/log.yaml", 0o644}` to the planned list in internal/build/new.go after storage.yaml, and a `{{define "new/config/log.yaml"}}` template in new.tmpl holding the D-08 example with short comments: console is styled on a terminal and json or logfmt otherwise; the daily file rotates per day under storage/logs and is kept 14 days; components lists `river: warn` and `http: info` with the comment that `http: warn` silences the request log; env overrides use `SUMMER_LOG__` with double underscores. Add `log.yaml` to the config-file list in the CLAUDE.md template. Update goldens with `go test ./internal/build -run Golden -update` (a new `config__log.yaml.golden`; CLAUDE.md goldens).
|
||||
|
||||
Docs in the same commit. docs/services/logging.md: keep its front matter; rewrite "The redacting default logger" into "Channels" (table: console, stderr, daily, stack, null and what each writes), "config/log.yaml" (the D-08 example, defaults when the file is absent, the Laravel config/logging.php comparison), "Component loggers" (diary.For, name rule: plugin or module code without dots, levels per component), "Daily files" (naming, retention, 0640 mode, path relative to the application root, an unwritable directory disables the channel with one warning), "Redaction" (sunscreen.Wrap on every channel; sunscreen.InstallDefault remains for code that installs its own default). Keep "Request log" and "Panics" unchanged (plan 3 updates the request log). docs/setup/configuration.md and docs/services/configuration.md: the log keys and an example `SUMMER_LOG__CHANNELS__DAILY__DAYS=30` (double underscore; string values accepted). diary README: Configuration section with every key and its default, Features gains stderr, null and components; Testing names the new smoke tests.
|
||||
|
||||
Smoke tests (config_smoke_test.go, -short): `TestConfigEnvSmoke` builds a compass config from a temp dir with no log.yaml and an Environ of `SUMMER_LOG__CHANNELS__DAILY__DAYS=30`, `SUMMER_LOG__CHANNELS__STACK__CHANNELS=console,daily`, `SUMMER_LOG__COMPONENTS__RIVER=error`, and asserts the parsed Config (days 30, stack children console and daily, river ERROR, every other default intact) plus one error case per rule naming its key. `TestDailyDegradesSmoke` points daily at a directory made read-only (skip when running as root) and asserts Setup returns nil, one warning line naming the path reaches the console buffer, and a later record still reaches the console. Commit: `feat(diary): channels, components and config/log.yaml`.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/diary/... ./internal/build/... && go test -count=1 -short -run '^(TestConfigEnvSmoke|TestDailyDegradesSmoke|TestDiaryTracerSmoke)$' -v ./modules/diary 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3 && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit, or the count of top-level "--- PASS: Test" lines is not 3 (an env-string or error case failed, the degrade test failed or skipped, or a name did not run), or the new-app goldens or the docs checker disagree with the code.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'define "new/config/log.yaml"' internal/build/stubs/new.tmpl` prints 1 and `test -f internal/build/testdata/new/config__log.yaml.golden` exits 0.
|
||||
- `grep -cE '^ *driver: (stack|console|daily)' internal/build/testdata/new/config__log.yaml.golden` prints at least 3 and `grep -c 'http: info' internal/build/testdata/new/config__log.yaml.golden` prints 1.
|
||||
- `grep -cE 'DriverStderr|DriverNull' modules/diary/config.go` prints at least 2.
|
||||
- `grep -c 'SUMMER_LOG__' docs/setup/configuration.md` prints at least 1 and `grep -c 'diary.For' docs/services/logging.md` prints at least 1.
|
||||
- `grep -c 'log.channels' modules/diary/README.md` prints at least 1 and `grep -c 'log.components' modules/diary/README.md` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>An operator can shape logging entirely from config/log.yaml or SUMMER_LOG__ variables, bad keys fail with the key named, an unwritable log directory never stops boot, and new applications ship the file.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: River, GORM and every framework module log through named diary components, so log.components and the coming verbosity flags govern third-party and framework lines alike</name>
|
||||
<files>modules/conga/client.go, modules/conga/worker.go, modules/conga/scheduler.go, modules/conga/conga.go, modules/conga/commands.go, modules/conga/logger_smoke_test.go, modules/conga/README.md, modules/lagoon/connection.go, modules/lagoon/purge.go, modules/lagoon/transaction.go, modules/lagoon/fill.go, modules/lagoon/attach/thumb.go, modules/lagoon/attach/bucket.go, modules/lagoon/logger_smoke_test.go, modules/lagoon/README.md, modules/cabana/log.go, modules/cabana/account.go, modules/cabana/account_tokens.go, modules/cabana/account_preferences.go, modules/cabana/field_file.go, modules/cabana/query.go, modules/cabana/actions.go, modules/cabana/http.go, modules/cabana/auth.go, modules/cabana/api_tokens.go, modules/cabana/token_guard.go, modules/cabana/singleton.go, modules/cabana/reorder.go, modules/cabana/relation_field.go, modules/cabana/partial_render.go, modules/cabana/field_type.go, modules/cabana/field_dependent.go, modules/cabana/crud.go, modules/cabana/contracts.go, modules/cooler/cooler.go, modules/cooler/memory.go, modules/cooler/rediscache.go, modules/cooler/ratelimit.go, modules/cooler/sitecache.go, modules/postcard/mailer.go, modules/postcard/drivers.go, modules/beachcomber/beachcomber.go, modules/lighthouse/lighthouse.go, modules/lighthouse/centrifugo/client.go, modules/flare/flare.go, modules/phrasebook/translator.go, modules/wristband/oidc_token.go, modules/bouncer/turnstile.go, modules/sunscreen/sunscreen.go, modules/sunscreen/README.md, modules/diary/README.md, docs/services/logging.md</files>
|
||||
<read_first>modules/conga/client.go (baseConfig :102-110, loggerFromApp :116-123), modules/lagoon/connection.go (gormFromSQL :56-75), modules/cooler/cooler.go (loggerFromApp :384-390, warner :400-440), modules/postcard/mailer.go (:150-161), modules/beachcomber/beachcomber.go (:130-182), modules/cabana/auth.go (:390-396), modules/bouncer/turnstile.go (:225-240, the turnstile verification log lines) and modules/bouncer/turnstile_test.go (TestTurnstileNeverLogsSecret :348, captures through slog.SetDefault), modules/sunscreen/sunscreen.go (InstallDefault doc :89-99), modules/sunscreen/README.md, docs/services/logging.md ("Logging from a plugin"), modules/diary/diary.go (For and the no-state fallback), 19.8-RESEARCH.md (Pitfalls 5 and 6, Don't Hand-Roll row "GORM to slog"), 19.8-PATTERNS.md (Shared Patterns: Logger lookup)</read_first>
|
||||
<action>Per D-09 and D-12. Before editing, run `git status --porcelain -- modules/cabana modules/lagoon modules/conga modules/cooler modules/postcard modules/bouncer`; files another session has modified are skipped and listed in the SUMMARY.
|
||||
|
||||
River and GORM first (commit `feat(conga,lagoon): River and GORM log through diary components`). conga/client.go: split the lookup into `riverLogger(app)` (the app-published `*slog.Logger` when present, else `diary.For("river")`) used by `baseConfig`, and conga's own lines through the published logger else `diary.For("conga")`; the framework default `components: {river: warn}` keeps River quiet at INFO. lagoon/connection.go gormFromSQL: `&gorm.Config{Logger: gormLogger()}` where unexported `gormLogger()` returns `logger.NewSlogLogger(diary.For("db"), logger.Config{SlowThreshold: 200 * time.Millisecond, LogLevel: logger.Warn, IgnoreRecordNotFoundError: true, ParameterizedQueries: true})`, with LogLevel `logger.Info` when `diary.Verbosity() >= 3` (SQL traces only at -vvv; parameterized so values never reach the log). README lagoon: a Logging paragraph (GORM lines leave stdout and arrive as component db, record-not-found is no longer logged, -vvv shows parameterized SQL); README conga: River logs as component river (warn by default).
|
||||
|
||||
Then framework modules (commit `refactor: framework modules log through diary components`). Every non-test call that logs through the process default logger in the files listed above moves to a package-level component logger: add an unexported `var moduleLog = diary.For("<package>")` in one file per package (modules/cabana/log.go for cabana; modules/bouncer/turnstile.go for bouncer, whose turnstile middleware holds its only two log calls; in the existing main file of the other packages), component name = package name (cabana, lagoon, attach, cooler, postcard, beachcomber, lighthouse, centrifugo, flare, phrasebook, wristband, bouncer, conga). Where a module prefers an app-published `*slog.Logger` (conga, cooler, postcard, beachcomber `Service.Logger` and `loggerFromApp`), keep that override first and fall back to the component logger. cooler's warner keeps its setLogger seam and starts from `moduleLog`. Messages and attributes do not change (cabana's `admin.auth` audit record keeps its message and gains only the component attribute). modules/surf/accesslog.go is not touched (plan 3 moves the request log to component http, D-23). Tests that install a capture handler with slog.SetDefault keep working because a component logger without diary setup delegates to the process default handler; if a test compares the exact attribute set, add the component attribute to its expectation.
|
||||
|
||||
sunscreen (same refactor commit): the InstallDefault doc comment and README Overview say that generated applications now install diary, which wraps every channel with sunscreen.Wrap, and that InstallDefault stays as the compatibility entry point for programs that install their own default (D-12; note that no sunscreen.Install function exists). diary README Role section lists River (river), GORM (db) and the framework components. docs/services/logging.md "Logging from a plugin": plugins call `diary.For("<plugin code>")` instead of the process default logger; the logger is safe to create in a package var.
|
||||
|
||||
Smoke tests (-short): modules/conga/logger_smoke_test.go `TestRiverLoggerUsesDiarySmoke`: `diary.Bootstrap(&buf, diary.Options{Color: bonfire.ColorNever})`, `t.Cleanup(diary.Reset)`, `riverLogger(nil).Warn("probe")` and buf contains `component=river`; with an app that publishes a capture logger the capture receives it instead. modules/lagoon/logger_smoke_test.go `TestGormLoggerUsesDiarySmoke`: same setup, `gormLogger().Warn(ctx, "slow %s", "query")` and buf contains `component=db`; `diary.SetVerbosity(3, false)` then a fresh gormLogger reports logger.Info as its level (assert via the returned logger.Interface's LogMode comparison or by logging a Trace and seeing a line).</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./... && go test -count=1 -short -run '^TestRiverLoggerUsesDiarySmoke$' -v ./modules/conga 2>&1 | grep -q -- '^--- PASS: TestRiverLoggerUsesDiarySmoke' && go test -count=1 -short -run '^TestGormLoggerUsesDiarySmoke$' -v ./modules/lagoon 2>&1 | grep -q -- '^--- PASS: TestGormLoggerUsesDiarySmoke' && go -C examples/hello test -count=1 ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit: a module suite is red after the logger move (for example a capture test now misses records), no "--- PASS" line for either smoke test (River or GORM records do not carry their component, or the test did not run), hello fails, or the docs checker rejects an identifier.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
<!-- planner-discipline-allow: slog.Default() -->
|
||||
- `grep -rn 'slog.Default()' modules --include=*.go | grep -v '_test.go' | grep -v '^modules/surf/accesslog.go' | grep -v '^modules/diary/' | wc -l` prints 0 (files skipped because another session had them modified are listed in the SUMMARY and excluded from this count by name).
|
||||
- `grep -rnE 'slog\.(Info|Warn|Error|Debug)(Context)?\(' modules --include=*.go | grep -v '_test.go' | grep -v '^modules/diary/' | wc -l` prints 0.
|
||||
- `grep -c 'diary.For("river")' modules/conga/client.go` prints at least 1 and `grep -c 'NewSlogLogger(diary.For("db")' modules/lagoon/connection.go` prints 1.
|
||||
- `grep -c 'ParameterizedQueries: true' modules/lagoon/connection.go` prints 1.
|
||||
- `grep -c 'InstallDefault' modules/sunscreen/README.md` prints at least 1 and `grep -c 'diary' modules/sunscreen/README.md` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>River, GORM and every framework module write through named diary components, so their lines carry a component, obey log.components and verbosity, and are redacted on every channel.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| application and plugin code to log channels | attribute values may carry user data, tokens or SQL parameters |
|
||||
| operator config and environment to diary | log.yaml and SUMMER_LOG__ variables choose paths, levels and drivers |
|
||||
| log files on the host | daily files are read by other host users and by backups |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.8-01 | Information disclosure | diary channels (channels.go) | high | mitigate | every leaf wrapped by sunscreen.Wrap, including stderr, daily and null; TestDiaryTracerSmoke asserts [REDACTED] on the console and in the daily file; plan 4 asserts it per driver |
|
||||
| T-19.8-02 | Information disclosure | GORM logger (lagoon/connection.go) | medium | mitigate | ParameterizedQueries true; SQL traces only at -vvv; record-not-found not logged; values never interpolated into log lines |
|
||||
| T-19.8-03 | Tampering | console handler (console.go) | medium | mitigate | control characters other than tab stripped from messages and attribute values before writing to a terminal, so logged user data cannot inject escape sequences |
|
||||
| T-19.8-04 | Tampering | daily path (log.channels.*.path) | low | mitigate | relative paths cleaned and confined to the application root, an escaping path is a config error naming the key; absolute paths are an explicit operator choice |
|
||||
| T-19.8-05 | Information disclosure | daily files (daily.go) | medium | mitigate | files 0o640 and directories 0o750; retention deletes only base-YYYY-MM-DD+ext files |
|
||||
| T-19.8-06 | Denial of service | daily writer | medium | mitigate | lazy open, retention days (14 by default), an unwritable directory warns once and disables the channel, boot never fails on a log file (TestDailyDegradesSmoke) |
|
||||
| T-19.8-SC | Tampering | Go module installs (go.mod) | high | mitigate | versions pinned from the RESEARCH legitimacy audit (proxy.golang.org and charm.land go-import origins), decision note committed first, go.sum verified by the toolchain, acceptance criterion proves only bonfire and diary import charm modules |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: the note precedes go.mod's lipgloss requirement; TestDiaryTracerSmoke; the generated-main test, goldens and examples/hello; docs checker.
|
||||
- Task 2: TestConfigEnvSmoke, TestDailyDegradesSmoke; summer new goldens; docs checker.
|
||||
- Task 3: the whole short suite; TestRiverLoggerUsesDiarySmoke and TestGormLoggerUsesDiarySmoke; no framework module logs through the process default logger except surf's request log.
|
||||
- Feedback sampling: after each commit run `go test -short ./modules/diary/... ./modules/bonfire/... ./internal/build/...`; after the plan, `go vet ./... && go test -short ./... && go -C examples/hello test ./...`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- D-02: decision note on record before any charm import.
|
||||
- D-08, D-10: Laravel-shaped config with framework defaults, env strings, daily files on by default, summer new's config/log.yaml.
|
||||
- D-09, D-12: component loggers everywhere, redaction on every channel, InstallDefault unchanged.
|
||||
- D-04 and D-05 (logging): styled on a terminal, logfmt or json otherwise; SetVerbosity ready for plan 2.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.8-console-commands-overhaul/19.8-01-SUMMARY.md` when done, with the commits in order (planning doc first), the charm versions actually installed, the final exported diary and bonfire names (plan 2 builds on them), any cabana or other files skipped because another session had them modified, and any deviation from this plan's names.
|
||||
</output>
|
||||
@@ -0,0 +1,261 @@
|
||||
---
|
||||
phase: 19.8-console-commands-overhaul
|
||||
plan: 01
|
||||
subsystem: logging
|
||||
tags: [slog, diary, lipgloss, colorprofile, logging, gorm, river, sunscreen]
|
||||
|
||||
requires:
|
||||
- phase: 08
|
||||
provides: sunscreen redaction handler (sunscreen.Wrap)
|
||||
provides:
|
||||
- "modules/diary: For, Bootstrap, Setup, SetVerbosity, Verbosity, Reset, Close, ParseConfig, DefaultConfig, NewConsoleHandler"
|
||||
- "Laravel-shaped config/log.yaml (log.default, log.level, log.channels, log.components) with console, stderr, daily, stack and null drivers"
|
||||
- "bonfire palette and color detection: ColorMode, Palette, DefaultPalette, DetectProfile"
|
||||
- "Generated main runs diary.Bootstrap first and diary.Setup after compass.Load; summer new writes config/log.yaml"
|
||||
- "River (river), GORM (db) and every framework module log through diary component loggers"
|
||||
affects: [19.8-02, 19.8-03, 19.8-04, console, logging, surf request log]
|
||||
|
||||
actuals:
|
||||
tokens: 53887
|
||||
tasks: 3
|
||||
commits: 6
|
||||
plan_head_before: bde7d9fcb7a2a8dcb0ba9eaa7bcbfd2e22975679
|
||||
plan_head_after: 44291adccaf711cf1a32fd7fdb203a7a375081b9
|
||||
|
||||
tech-stack:
|
||||
added:
|
||||
- "charm.land/lipgloss/v2 v2.0.6"
|
||||
- "github.com/charmbracelet/colorprofile v0.4.3"
|
||||
patterns:
|
||||
- "Proxy component handler: diary.For returns a logger whose handler looks up the live channel tree at log time, so package-level loggers created before setup route correctly"
|
||||
- "Every channel leaf wrapped by sunscreen.Wrap and gated by its own slog.LevelVar; stacks via slog.NewMultiHandler"
|
||||
- "Per-package unexported moduleLog = diary.For(<package>); app-published *slog.Logger still wins where a module already preferred it"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- .planning/notes/2026-10-21-console-charm-stack.md
|
||||
- modules/bonfire/styles.go
|
||||
- modules/diary/diary.go
|
||||
- modules/diary/config.go
|
||||
- modules/diary/component.go
|
||||
- modules/diary/console.go
|
||||
- modules/diary/daily.go
|
||||
- modules/diary/channels.go
|
||||
- modules/diary/README.md
|
||||
- modules/diary/tracer_smoke_test.go
|
||||
- modules/diary/config_smoke_test.go
|
||||
- modules/diary/example_test.go
|
||||
- modules/cabana/log.go
|
||||
- modules/conga/logger_smoke_test.go
|
||||
- modules/lagoon/logger_smoke_test.go
|
||||
- internal/build/testdata/new/config__log.yaml.golden
|
||||
modified:
|
||||
- internal/build/build.go
|
||||
- internal/build/new.go
|
||||
- internal/build/stubs/new.tmpl
|
||||
- examples/hello/main.go
|
||||
- modules/conga/client.go
|
||||
- modules/lagoon/connection.go
|
||||
- modules/sunscreen/sunscreen.go
|
||||
- docs/services/logging.md
|
||||
- docs/setup/configuration.md
|
||||
- docs/services/configuration.md
|
||||
- README.md
|
||||
- go.mod
|
||||
|
||||
key-decisions:
|
||||
- "Charm v2 stack on charm.land paths recorded before any charm import; this plan installs only lipgloss v2.0.6 and colorprofile v0.4.3, plan 2 installs bubbletea v2.1.0, bubbles v2.2.1, huh v2.0.3 and fang v2.0.1 (newest tags on 2026-10-21, equal to the research pins)"
|
||||
- "diary's console handler is its own lipgloss handler; charm's slog logger rejected (fails testing/slogtest); daily files use a stdlib writer, lumberjack rejected"
|
||||
- "format auto picks the styled console handler when bonfire.DetectProfile reports more than NoTTY, so --no-ansi (ColorNever) gives logfmt lines, not unstyled console lines"
|
||||
- "Daily retention keeps the files of today and the days-1 days before (Laravel's maxFiles count), not days+1 files"
|
||||
- "Framework defaults also define stderr (logfmt) and null channels so log.default can name them without defining them"
|
||||
- "wristband and bouncer now import diary (plan: every framework module); their READMEs no longer call them dependency-free leaves. This amends the leaf consequence of .planning/notes/2026-10-19-oidc-on-wristband-golang-jwt.md and needs the user's confirmation"
|
||||
|
||||
patterns-established:
|
||||
- "Component names are plugin or module codes without dots; framework modules use their package name"
|
||||
- "Tests that set diary up call t.Cleanup(diary.Reset), which restores slog.Default and the log package's output"
|
||||
|
||||
requirements-completed: [SC-1, SC-2, D-01, D-02, D-04, D-05, D-08, D-09, D-10, D-12]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Charm v2 decision note committed before go.mod requires lipgloss, with STACK.md rows"
|
||||
requirement: D-02
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "git merge-base --is-ancestor e6039b4 e903cc3 (acceptance criterion 1 of Task 1)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "A component logger record flows from config/log.yaml through compass into a redacting console and daily stack"
|
||||
requirement: SC-1
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/diary/tracer_smoke_test.go#TestDiaryTracerSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "SUMMER_LOG__ overrides parse as strings and overlay defaults; invalid values fail naming the key"
|
||||
requirement: D-08
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/diary/config_smoke_test.go#TestConfigEnvSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Unwritable daily directory warns once on the console and never fails Setup"
|
||||
requirement: D-10
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/diary/config_smoke_test.go#TestDailyDegradesSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Generated main installs diary (Bootstrap first, Setup after compass.Load); summer new writes config/log.yaml"
|
||||
requirement: D-10
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "internal/build/build_test.go#TestGenerateMainInstallsDiary"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "internal/build/new_golden_test.go#TestNewGolden"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "River logs as component river and GORM as component db with parameterized SQL traces only at verbosity 3"
|
||||
requirement: D-09
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/conga/logger_smoke_test.go#TestRiverLoggerUsesDiarySmoke"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "modules/lagoon/logger_smoke_test.go#TestGormLoggerUsesDiarySmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D7
|
||||
description: "Styled console lines on a real terminal look right in bonfire's palette (time, level colors, component prefix)"
|
||||
requirement: D-04
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "Color rendering on a real terminal cannot be asserted by the short suite; a throwaway probe showed the expected ANSI sequences, plan 4 adds goldens"
|
||||
|
||||
duration: 30min
|
||||
completed: 2026-10-21
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 19.8 Plan 01: diary logging module Summary
|
||||
|
||||
**New `diary` module: Laravel-shaped `config/log.yaml` channels (console, stderr, daily, stack, null) with per-component levels, sunscreen on every channel, daily JSON files on by default, and River, GORM and every framework module logging through named component loggers.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 30 min
|
||||
- **Started:** 2026-10-21T09:28:13Z
|
||||
- **Completed:** 2026-10-21T09:58:29Z
|
||||
- **Tasks:** 3
|
||||
- **Files modified:** 86 (code, docs and goldens; 2,414 insertions, 172 deletions)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Decision note `.planning/notes/2026-10-21-console-charm-stack.md` and STACK.md rows committed first (D-02).
|
||||
- `modules/diary`: proxy component loggers safe in package vars, a lipgloss console handler in bonfire's palette with control-character stripping, a stdlib daily writer (0640 files, 0750 dirs, lazy open, retention, degrade-once), channels built as sunscreen-wrapped leaves under `slog.NewMultiHandler`, runtime `SetVerbosity` with quiet affecting only console channels.
|
||||
- `bonfire`: `ColorMode` (`ColorAuto`, `ColorAlways`, `ColorNever`), `Palette`, `DefaultPalette`, `DetectProfile`.
|
||||
- Generated main: `diary.Bootstrap(os.Stderr, diary.Options{})` as the first statement of `run`, `diary.Setup(cfg, diary.Options{})` after `compass.Load`; no sunscreen import. `examples/hello/main.go` regenerated. `summer new` writes `config/log.yaml`.
|
||||
- River through `diary.For("river")`, GORM through `logger.NewSlogLogger(diary.For("db"), ...)`, and every framework module through a package `moduleLog`; only surf's request log still uses the process default (plan 3).
|
||||
- Docs: `docs/services/logging.md` rewritten (Channels, config/log.yaml, Component loggers, Daily files, Redaction, Logging from a plugin), log keys in both configuration pages, READMEs for diary, bonfire, sunscreen, conga, lagoon and every module that now imports diary.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Planning doc (D-02)**: `e6039b4` docs(19.8): decide the charm v2 console stack
|
||||
2. **Task 1: tracer**: `e903cc3` feat(diary): component loggers through a redacting console and daily stack
|
||||
3. **Task 2: channels and config**: `5b09b32` feat(diary): channels, components and config/log.yaml
|
||||
4. **Task 3a: River and GORM**: `e6b20e2` feat(conga,lagoon): River and GORM log through diary components
|
||||
5. **Task 3b: framework modules**: `44291ad` refactor: framework modules log through diary components
|
||||
|
||||
`commits: 6` is measured from the ledger (`bde7d9f..44291ad`); one of the six, `71b9931` docs(19.5), belongs to another session that committed in between. This plan made five commits.
|
||||
|
||||
## Charm versions installed
|
||||
|
||||
- `charm.land/lipgloss/v2` v2.0.6 and `github.com/charmbracelet/colorprofile` v0.4.3 (direct requirements in go.mod).
|
||||
- Not installed yet (plan 2): `charm.land/bubbletea/v2` v2.1.0, `charm.land/bubbles/v2` v2.2.1, `charm.land/huh/v2` v2.0.3, `charm.land/fang/v2` v2.0.1. `go list -m -versions` on 2026-10-21 showed these as the newest tags.
|
||||
|
||||
## Exported names for plan 2
|
||||
|
||||
- **diary:** `For`, `Bootstrap(w io.Writer, opts Options)`, `Setup(cfg *compass.Config, opts Options) error`, `SetVerbosity(v int, quiet bool)`, `Verbosity() int`, `Reset()`, `Close() error`, `ParseConfig`, `DefaultConfig`, `NewConsoleHandler(w io.Writer, opts ConsoleOptions) slog.Handler`; types `Options{Verbosity, Quiet, Color, Root, Stderr, Environ, Now}`, `Config{Default, Level, Channels, Components}`, `ChannelConfig{Driver, Format, Path, Level, HasLevel, Days, Channels}`, `ConsoleOptions{Level, Profile, AddSource}`; constants `ComponentKey`, `DriverConsole`, `DriverStderr`, `DriverDaily`, `DriverStack`, `DriverNull`, `FormatAuto`, `FormatJSON`, `FormatLogfmt`, `DefaultKey`, `LevelKey`, `ChannelsKey`, `ComponentsKey`. All names match the plan.
|
||||
- **bonfire:** `ColorMode`, `ColorAuto`, `ColorAlways`, `ColorNever`, `Palette{Success, Current, Warning, Error, ErrorBold, Muted, Header}`, `DefaultPalette()`, `DetectProfile(w io.Writer, mode ColorMode, environ []string) colorprofile.Profile`. `output.go` unchanged.
|
||||
- Verbosity semantics for plan 2's flags: 1 lowers every component floor to at most info, 2 or more to debug, 3 or more adds source locations on styled console lines and GORM SQL traces (`gormLogger` reads `diary.Verbosity()` when the connection opens); quiet raises only console channels to warn. Before Bootstrap or Setup, `SetVerbosity` only changes what `Verbosity` returns; `Options.Verbosity`/`Options.Quiet` set a new setup's values.
|
||||
|
||||
## Files skipped because of other sessions
|
||||
|
||||
None. `git status` showed no other session's edits under `modules/` before Task 3 (the only foreign edits were `.planning/STATE.md`, `.planning/state.json`, `.msd/`, a 19.4.1 `.gitkeep`, and a 19.5 CONTEXT edit that was committed by its session meanwhile); none were staged.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See `key-decisions` in the front matter. The two worth a second look:
|
||||
|
||||
- **wristband and bouncer imports.** The plan's "every framework module" rule (D-09, acceptance criteria) makes `wristband` and `bouncer` import `diary`, which pulls `bonfire` and `compass` into modules documented as dependency-free leaves. The 2026-10-19 decision note says "wristband stays a protocol leaf: no ORM, no framework module". I followed the plan and amended both READMEs; reverting is two lines per module if the user prefers to keep them leaves (their log calls would then stay on the process default logger).
|
||||
- **Daily retention count.** `days: 14` keeps 14 files (today and 13 before), as Laravel's `days` does; the plan's wording ("more than days days old") would keep 15. The docs and README say which.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Supplied-root pin guard always failed**
|
||||
- **Found during:** start of execution
|
||||
- **Issue:** the orchestrator's pin script substituted the pinned path into its own unbound-placeholder sentinel (`''|'<pinned path>') PIN_STAGE=pin-unbound`), so it reported `pin-unbound` FATAL for the correct root.
|
||||
- **Fix:** ran a scratchpad copy with only that sentinel restored to the literal `{PINNED_ROOT}`; every real check (actual toplevel against the pinned toplevel) unchanged and passing before each write and commit. The orchestrator's script was not modified.
|
||||
|
||||
**2. [Rule 3 - Blocking] TestNewGolden pins a fixed file list**
|
||||
- **Found during:** Task 2
|
||||
- **Fix:** added `config/log.yaml` to the list in `internal/build/new_golden_test.go` (not in the plan's file list) so `config__log.yaml.golden` is created and checked.
|
||||
|
||||
**3. [Rule 3 - Blocking] Go fences in docs need a src= reference**
|
||||
- **Found during:** Task 2 (docs checker)
|
||||
- **Fix:** added `modules/diary/example_test.go` (`ExampleFor`, runnable with output; package var `blogLog`) and referenced it from `docs/services/logging.md` with `src=`; shortened the page's front-matter description below 160 characters.
|
||||
|
||||
**4. [Rule 2 - CLAUDE.md docs rule] READMEs of every module that now imports diary**
|
||||
- **Found during:** Task 3
|
||||
- **Fix:** Dependencies sections of cabana, cooler, postcard, beachcomber, lighthouse, flare, phrasebook, wristband and bouncer list `diary`; postcard's `NewLogDriver` row and doc comment, and the `Logger()` doc comments of beachcomber, lighthouse and flare, describe the new fallback. Not in the plan's file list.
|
||||
|
||||
**5. [Rule 1 - Bug] gofmt import order in console.go**
|
||||
- **Found during:** Task 2; the Task 1 commit had an unsorted import block in `modules/diary/console.go`, fixed by `gofmt -w` in the Task 2 commit.
|
||||
|
||||
**6. Example modules' go.mod/go.sum left unchanged**
|
||||
- **Plan said:** run `go mod tidy` in the examples/hello modules whose build asks for go.sum entries (files list names hello and greeter go.sum).
|
||||
- **Found:** inside the repository's go.work every example build and test passes without changes; `go mod tidy` there only pulled pre-existing drift (aws-sdk and others, and dropped a toolchain line). Reverted; recorded in `deferred-items.md`.
|
||||
|
||||
**7. Tracer test logs two records to the daily file**
|
||||
- The plan's tracer asserts "a single JSON line"; the same test also logs from the pre-setup package-var logger (as the plan asks), so the daily file holds two lines and the test checks the first one.
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 7 (3 blocking fixes, 1 docs rule, 1 formatting fix, 2 recorded differences). **Impact:** none on the plan's API names or behaviour; extra README updates keep the documentation rule.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- `TestOIDCSubjectIsRandomSmoke` (wristband) failed once: the random subject contained `ada`, which the test rejects as the fixture user's name. Pre-existing flake, passed on rerun; logged in `deferred-items.md`.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None beyond the plan's threat model: daily files under the application root (T-19.8-04, T-19.8-05, T-19.8-06), redaction on every leaf including stderr and null (T-19.8-01), parameterized GORM traces (T-19.8-02), control-character stripping on the console (T-19.8-03).
|
||||
|
||||
## Verification
|
||||
|
||||
- `go vet ./...`: clean.
|
||||
- `go test -count=1 -short ./...`: green (after the one wristband flake rerun).
|
||||
- `go -C examples/hello vet ./...` and `go -C examples/hello test -count=1 ./...`: green.
|
||||
- `TestDiaryTracerSmoke`, `TestConfigEnvSmoke`, `TestDailyDegradesSmoke`, `TestRiverLoggerUsesDiarySmoke`, `TestGormLoggerUsesDiarySmoke`: `--- PASS` by name.
|
||||
- `TestDocsTree` and `summer docs:build --check`: no problems.
|
||||
- Acceptance criteria of all three tasks re-run: pass (no `slog.Default()` or package-level `slog.Info/Warn/Error/Debug` calls left in non-test framework code outside diary and surf's access log; only bonfire and diary import charm modules; nothing imports `gopkg.in/yaml.v3`; no consuming application named).
|
||||
- Nothing pushed or tagged.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
Ready for 19.8-02: global flags call `diary.SetVerbosity`, the bonfire widgets move onto `Palette`/`DetectProfile`, and plan 2 installs the remaining four charm modules.
|
||||
|
||||
## Self-Check: PASSED
|
||||
347
.planning/phases/19.8-console-commands-overhaul/19.8-02-PLAN.md
Normal file
347
.planning/phases/19.8-console-commands-overhaul/19.8-02-PLAN.md
Normal file
@@ -0,0 +1,347 @@
|
||||
---
|
||||
phase: 19.8-console-commands-overhaul
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["19.8-01"]
|
||||
files_modified:
|
||||
- go.mod
|
||||
- go.sum
|
||||
- modules/bonfire/command.go
|
||||
- modules/bonfire/root.go
|
||||
- modules/bonfire/globals.go
|
||||
- modules/bonfire/version.go
|
||||
- modules/bonfire/execute.go
|
||||
- modules/bonfire/list.go
|
||||
- modules/bonfire/help.go
|
||||
- modules/bonfire/docs.go
|
||||
- modules/bonfire/errors.go
|
||||
- modules/bonfire/styles.go
|
||||
- modules/bonfire/output.go
|
||||
- modules/bonfire/widgets.go
|
||||
- modules/bonfire/prompts.go
|
||||
- modules/bonfire/live.go
|
||||
- modules/bonfire/selector.go
|
||||
- modules/bonfire/helpers.go
|
||||
- modules/bonfire/list_smoke_test.go
|
||||
- modules/bonfire/help_smoke_test.go
|
||||
- modules/bonfire/widgets_smoke_test.go
|
||||
- modules/bonfire/README.md
|
||||
- modules/diary/diary.go
|
||||
- modules/diary/console.go
|
||||
- modules/diary/README.md
|
||||
- internal/build/build.go
|
||||
- internal/build/build_test.go
|
||||
- internal/build/testdata/new/main.go.golden
|
||||
- examples/hello/main.go
|
||||
- examples/hello/hello_test.go
|
||||
- examples/hello/go.sum
|
||||
- examples/hello/plugins/greeter/go.sum
|
||||
- cmd/summer/main.go
|
||||
- cmd/summer/docs.go
|
||||
- internal/docsite/check_commands.go
|
||||
- docs/console/introduction.md
|
||||
- docs/console/writing-commands.md
|
||||
- docs/services/logging.md
|
||||
- .planning/todos/pending/bonfire-duplicate-command-names.md
|
||||
- .planning/todos/done/bonfire-duplicate-command-names.md
|
||||
autonomous: true
|
||||
requirements: [SC-2, SC-3, D-01, D-02, D-03, D-04, D-13, D-14, D-15, D-16, D-17, D-18, D-27, D-28, D-30]
|
||||
user_setup: []
|
||||
|
||||
estimate:
|
||||
tokens: 230000
|
||||
raw_tokens: 230000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-13: running an application binary or summer with no arguments (or with only -h/--help) prints the Artisan list: a 'SummerCMS <name> <version> (framework <version>)' header from debug.ReadBuildInfo, Usage, the global options, then 'Available commands:' with top-level commands first and namespaces (the part before the first colon) sorted under their own header lines, descriptions aligned; exit code 0."
|
||||
- "SC-2, D-04: every command accepts -v/-vv/-vvv (--verbose), -q/--quiet, --ansi/--no-ansi, -n/--no-interaction and --env; bonfire.PeekGlobals reads them before config loads so --env picks the compass environment and diary starts at the right verbosity, and the root's OnGlobals hook re-applies them to diary after cobra parses; a bonfire.Call root never runs the hook."
|
||||
- "D-14, D-15: '<binary> help <command>', 'summer help <command>' and '<command> --help' print Description, Usage, Arguments, Options with [default: ...], Global options, Help (Long) and Examples; bonfire.Command gains Examples []Example and Hidden bool at the end of the struct, so existing plugins compile unchanged; Hidden commands run but are left out of list unless list --all."
|
||||
- "D-16, D-17: 'list --format=json' prints every command (hidden ones flagged) with arguments, options, defaults and examples as a bonfire.Listing; bonfire.CheckDocs(commands) returns one DocProblem per command lacking a description, long help, examples, or an argument or option description."
|
||||
- "D-18: errors render as a styled block on stderr: ERROR and the cause, the explanation and a Try: line from bonfire.WithHint (with {bin} replaced by bin/<name> or summer), 'Run with -v for details.' at default verbosity and the wrapped chain at -v; cobra's 'Did you mean' becomes the Try line; bonfire.Execute returns 2 for usage errors (unknown command or flag, wrong argument count), 1 for failures, and prints nothing more for errors wrapped by bonfire.Reported; message text passes through sunscreen.Scrub."
|
||||
- "D-01, D-02, D-30: on a terminal, Spinner, Progress, bonfire.Process, Table and Choice render through ported SSU widgets on bubbletea/bubbles/lipgloss v2 (spinner, multi-row process view, gradient progress bar, single arrow-key selector with a confirmation screen; no multi-select), Ask/Confirm/Secret use huh; fang.Execute runs with WithoutVersion, WithoutManpage, WithoutCompletions and WithErrorHandler; the Output interface is unchanged."
|
||||
- "D-03, D-27, D-28: off a terminal, under -q, --no-ansi or -n every widget prints plain lines without animation and today's non-TTY bytes are unchanged; -q suppresses Info, Success, Warning, spinners and progress but keeps Println/Printf/Write, tables, --json data and Error; -q implies -n; with -n prompts return their defaults and Secret without piped stdin fails with a hint; bonfire.ConfirmProceed returns true without asking whenever the Output is not interactive."
|
||||
- "Folded todo: bonfire.NewRoot fails with ErrDuplicateCommand naming the command when two commands share a name (or a command is named list or help), and with ErrReservedFlag when a command flag reuses a global name or shorthand; the bonfire README and docs/console/writing-commands.md say so and the todo moves to .planning/todos/done/."
|
||||
artifacts:
|
||||
- path: "modules/bonfire/globals.go"
|
||||
provides: "Globals, PeekGlobals, Globals.Args"
|
||||
contains: "func PeekGlobals(args []string) Globals"
|
||||
- path: "modules/bonfire/execute.go"
|
||||
provides: "Execute, Fail, exit codes, narrow fang"
|
||||
contains: "fang.WithoutVersion()"
|
||||
- path: "modules/bonfire/list.go"
|
||||
provides: "Artisan list, list --all, list --format=json, Listing"
|
||||
contains: "Available commands:"
|
||||
- path: "modules/bonfire/help.go"
|
||||
provides: "Artisan per-command help"
|
||||
contains: "Examples:"
|
||||
- path: "modules/bonfire/live.go"
|
||||
provides: "ported spinner, process and progress models (bubbletea v2)"
|
||||
contains: "tea.WithInput(nil)"
|
||||
key_links:
|
||||
- from: "internal/build/build.go"
|
||||
to: "modules/bonfire/execute.go"
|
||||
via: "generated main returns bonfire.Execute's exit code"
|
||||
pattern: "bonfire.Execute\\("
|
||||
- from: "internal/build/build.go"
|
||||
to: "modules/diary/diary.go"
|
||||
via: "PeekGlobals feeds diary before compass, OnGlobals re-applies after parsing"
|
||||
pattern: "bonfire.OnGlobals\\(diary.ApplyGlobals\\)"
|
||||
- from: "modules/diary/console.go"
|
||||
to: "modules/bonfire/helpers.go"
|
||||
via: "log lines print above a running live view"
|
||||
pattern: "bonfire.PrintAbove\\("
|
||||
prohibitions:
|
||||
- statement: "No method is added to bonfire.Output, Input, Progress or Command's existing fields; new behaviour arrives through added struct fields, optional interfaces and helper functions only."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No prompt blocks a run whose stdin or stdout is not a terminal, or that has -n or -q."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Framework code, READMEs and docs name no consuming application."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed or tagged in this plan."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented, see plan 01): console commands reach the Artisan bar; a user sees what a command did, can ask for more or less of it, and learns from `--help` how to use it.
|
||||
|
||||
This plan's slice (plan 2 of 4, wave 2): the shared console layer in `bonfire`. The tracer makes a bare application binary print the Artisan list, carries the global flags into `diary` before plugins boot, and returns exit codes through `bonfire.Execute` in the generated `main` and in `summer`. Then per-command help, `help <command>`, Examples and Hidden, the JSON listing, the documentation coverage helper and the styled error block with hints. Last, SSU's widgets ported to charm v2 behind the unchanged `Output` interface, with the degradation rules for non-TTY, `-q`, `--no-ansi` and `-n`.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Spec-less probe fallback skipped: no requirement IDs (phase_req_ids null) and no SPEC.md; visible choice. `must_haves` cite SC-2, SC-3 and D-01..D-30.
|
||||
|
||||
## Decisions taken in this plan (recorded assumptions and Claude's discretion)
|
||||
|
||||
- Global flag semantics (CONTEXT discretion, following Artisan and Symfony): `-q` implies `-n` (Symfony sets non-interactive when quiet); `--ansi` and `--no-ansi` are last-wins; `--env X` and `--env=X` both work and reach `compass.Options.Env`, which already validates the value; `-v` counts up to 3.
|
||||
- Usage name: `summer` for the tool, `bin/<name>` for application binaries (the D-13 preview). Example commands are stored without the binary (`admin:token:create jane --json`) and rendered with the usage name, so one Example serves both `summer` delegates and the application binary.
|
||||
- Per-command help puts the command's own options under "Options:" (the D-14 preview) and the global flags in a separate compact "Global options:" block, then "Help:" with Long, then "Examples:". The preview's Options block stays exactly as approved; the extra blocks teach the global flags and show the long help SC-3 requires.
|
||||
- Defaults render as `[default: "x"]`, unquoted when the default parses as an integer (`[default: 90]`, D-14 preview); bare flags show no default; repeatable flags add "(multiple values allowed)".
|
||||
- fang (D-02) is used through `fang.Execute` with `WithoutVersion`, `WithoutManpage`, `WithoutCompletions` and `WithErrorHandler`; `-h`/`--help` without a command is routed to the list before fang runs (RESEARCH Pattern 5, option b); every subcommand gets its own help func, which cobra prefers over the root's.
|
||||
- Choice uses the ported single selector with SSU's confirmation screen (D-01, D-30); Ask, Confirm and Secret use huh (D-02). Multi-select, the filter and the detail pane are not ported (D-30).
|
||||
- `ConfirmProceed` encodes D-28 for plan 3: it asks only on an interactive terminal; off a terminal, under `-n`/`-q` or in `bonfire.Call` it returns true without asking, so deploys over SSH never block.
|
||||
- `-n` with Secret: when stdin is piped the line is read (admin:create in scripts keeps working); when stdin is a terminal the prompt fails with a hint to pipe the value, because Secret has no default.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- bonfire (`git.golem15.com/golem15/summercms/modules/bonfire`):
|
||||
- Types: `Globals{Verbosity int; Quiet bool; Color ColorMode; NoInteraction bool; Env string}`, `RootOption`, `Version{App, Framework string; Local bool}`, `Example{Command, Description string}`, `Listing{Application ApplicationInfo; GlobalOptions []OptionInfo; Commands []CommandInfo}`, `ApplicationInfo`, `CommandInfo`, `ArgumentInfo`, `OptionInfo`, `ExampleInfo`, `DocProblem{Command string; Missing []string}`, `HintError{Err error; Explanation, Try string}`, `UsageError{Err error}`, `ItemProgress` (optional interface: Progress plus `Current(item string)` and `Fail(item string, err error)`).
|
||||
- Command fields (appended): `Examples []Example`, `Hidden bool`.
|
||||
- Functions: `PeekGlobals(args []string) Globals`, `(Globals).Args() []string`, `WithVersion(v Version) RootOption`, `OnGlobals(fn func(Globals)) RootOption`, `BuildVersion() Version`, `(Version).Header(name string) string`, `Execute(ctx context.Context, root *cobra.Command, args []string) int`, `Fail(w io.Writer, name string, err error, g Globals) int`, `RenderError(w io.Writer, name string, err error, g Globals)`, `WithHint(err error, explanation, try string) error`, `Reported(err error) error`, `Usage(err error) error`, `Describe(name string, commands []Command, v Version) Listing`, `CheckDocs(commands []Command) []DocProblem`, `GlobalsOf(out Output) Globals`, `VerbosityOf(out Output) int`, `IsQuiet(out Output) bool`, `Interactive(out Output) bool`, `ConfirmProceed(out Output, question string) (bool, error)`, `Process(ctx context.Context, out Output, operation string, items []string, fn func(ctx context.Context, item string) error) error`, `Current(p Progress, item string)`, `Failed(p Progress, item string, err error)`, `PrintAbove(line string) bool`.
|
||||
- Variables and constants: `ExitOK = 0`, `ExitFailure = 1`, `ExitUsage = 2`, `ErrDuplicateCommand`, `ErrReservedFlag`, `ErrReported`, `ErrAborted`.
|
||||
- CLI: `list` (`--all`, `--format=txt|json`), `help [command]`; persistent flags `-v|--verbose` (count), `-q|--quiet`, `--ansi`, `--no-ansi`, `-n|--no-interaction`, `--env`.
|
||||
- Smoke tests: `TestListTracerSmoke`, `TestHelpSmoke`, `TestListJSONSmoke`, `TestErrorRenderSmoke`, `TestWidgetMatrixSmoke`.
|
||||
- diary: `ApplyGlobals(g bonfire.Globals)`, `FromGlobals(g bonfire.Globals) Options`; the console handler prints through `bonfire.PrintAbove` while a live view runs.
|
||||
- Generated main: `func main() { os.Exit(run(os.Args[1:], os.Stdout)) }`, `func run(args []string, out io.Writer) int` with `bonfire.PeekGlobals`, `compass.Open(compass.Options{Dir: "config", Env: g.Env})`, `bonfire.Fail` before the root exists, `bonfire.NewRoot(..., bonfire.WithVersion(bonfire.BuildVersion()), bonfire.OnGlobals(diary.ApplyGlobals))`, `bonfire.Execute`.
|
||||
- examples/hello: `TestBuiltBinaryListsCommands`; updated `TestHelpListsServe`, `TestMigrateWithoutDSNFailsLoudly`, `TestBuiltBinaryUnknownCommandExitsNonZero` (exit 2).
|
||||
- docs checker: `commandWord` knows the bool globals and `--env`; `docsCommands` lists `list` and `help` for the tool and applications.
|
||||
- Dependencies installed: `charm.land/fang/v2`, `charm.land/bubbletea/v2`, `charm.land/bubbles/v2`, `charm.land/huh/v2` at the decision note's versions.
|
||||
|
||||
<objective>
|
||||
Make bonfire Artisan-grade: global flags on every command that reach diary before boot, the grouped list and per-command help with examples, a JSON listing and a coverage helper, styled errors with hints and exit codes, duplicate-name rejection, and SSU's widgets on charm v2 that degrade cleanly, all behind the unchanged Output interface.
|
||||
|
||||
Purpose: SC-2, SC-3 (framework side), D-01, D-02 (rest of the stack), D-03, D-04 (flags), D-13..D-18, D-27, D-28 (helper), D-30, folded todo bonfire-duplicate-command-names.
|
||||
Output: three code commits with README and docs, one planning-doc commit closing the todo.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-CONTEXT.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-RESEARCH.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-PATTERNS.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-01-SUMMARY.md
|
||||
@modules/bonfire/README.md
|
||||
|
||||
Repos and concurrency: summercms.go only. Check `git status --porcelain` on each task's paths first and stop if another session has one of them modified. Stage by explicit path; no co-author tags; planning docs and code in separate commits; README and docs in the same commit as the API they describe; `go vet ./...`, `go test -short ./...` and `go -C examples/hello test ./...` green at every commit. Nothing is pushed or tagged.
|
||||
|
||||
<interfaces>
|
||||
Existing seams:
|
||||
|
||||
- modules/bonfire/command.go: `Command{Name, Description, Long string; Passthrough bool; Flags []Flag; Args []Arg; Run}` (lines 15-32), `Flag{Name, Shorthand, Description, Default string; Bare, Repeatable bool}`, `Arg{Name, Description string; Required bool}`, `Input`, `validCommandName` (bare names build, dev, new, serve, migrate), `commandUsage`, `positionalArgs`.
|
||||
- modules/bonfire/root.go: `NewRoot(name, commands, out)`, `NewRootIO(name, commands, in, out, errW)` (root with SilenceUsage, SilenceErrors, DisableDefaultCmd), `wrap(c, out)` (flag registration with StringP/StringSliceP, `NoOptDefVal = "true"` for Bare).
|
||||
- modules/bonfire/call.go: `Call(ctx, commands, name, args, out)` builds a fresh NewRootIO per call with empty stdin (scheduler path), `Catalog`, `findCommand`.
|
||||
- modules/bonfire/output.go: `Output` interface (Println, Printf, Info, Success, Error, Warning, Table, Spinner, Progress, Ask, Confirm, Choice, Secret, io.Writer), `console` struct with `tty`, `interactive`, `color`, injectable `lookup`; `newConsole(in, out, errW, lookup, tty, interactive *bool)`; widgets.go non-TTY fallbacks (`[...] message`, decile percentage lines, tab-separated table); prompts.go line-based prompts and `Secret` reading a plain line when stdin is not a terminal.
|
||||
- Existing tests that must stay green unchanged in intent: modules/bonfire/output_test.go (non-TTY output, NO_COLOR/TERM=dumb/FORCE_COLOR presence of escape codes, `TestHelpUsesSharedAdapter`), prompts_test.go, call_test.go, modules/cabana/commands_test.go:241-262 (WR-13 piped password), cmd/summer/main_test.go (help output contains each flag name and default).
|
||||
- modules/diary (plan 01): `Options`, `Bootstrap`, `Setup`, `SetVerbosity`, `NewConsoleHandler`; modules/bonfire/styles.go: `ColorMode`, `Palette`, `DefaultPalette`, `DetectProfile`.
|
||||
- internal/build/build.go generateMain (plan 01 shape) and `TestGenerateMainInstallsDiary`; examples/hello/hello_test.go (run(...) error today; TestHelpListsServe, TestMigrateWithoutDSNFailsLoudly, TestBuiltBinaryUnknownCommandExitsNonZero, runSummerBuild); internal/build/build_test.go `TestBuiltHelloFailsOnInvalidRequires` (combined output must still name both plugin IDs).
|
||||
- internal/docsite/check_commands.go:99-125 `commandWord` (assumes only --help/-h on the root), cmd/summer/docs.go:144-165 `docsCommands`, cmd/summer/docs_test.go:418 `generatedConstructor` regex over `commands = append(commands, pkg.Ctor(app` lines.
|
||||
- fang v2 source (module cache after go get): fang.go `Execute`, `WithoutVersion`, `WithoutManpage`, `WithoutCompletions`, `WithErrorHandler`, `ErrorHandler` signature; it wraps stderr in colorprofile.NewWriter before calling the handler (RESEARCH Pattern 5).
|
||||
- SSU widgets to port (external repo, read-only): /media/nvme/dev/pxpx/ssu/internal/cli/tui/spinner.go, process.go, progress.go, selector.go, selector_keys.go, confirm.go, styles.go; RESEARCH "Porting SSU's spinner to bubbletea v2" for the API renames, progress WithColors cyan #00afaf to green #00af00 and SetWidth(40).
|
||||
- cobra v1.10.2: a command's own helpFunc wins over its parent's; `SuggestionsFor`; `SetHelpCommand`; `SetFlagErrorFunc`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a bare application binary prints the Artisan list with the version header and global options, -vv and -q on any command reach diary before plugins boot, and the process exits 0, 1 or 2 through bonfire.Execute</name>
|
||||
<reversibility rating="reversible">Rendering and flag plumbing sit behind NewRoot's unchanged signature (options are variadic); the generated main is rewritten by every summer build.</reversibility>
|
||||
<files>go.mod, go.sum, modules/bonfire/globals.go, modules/bonfire/root.go, modules/bonfire/version.go, modules/bonfire/execute.go, modules/bonfire/list.go, modules/bonfire/list_smoke_test.go, modules/bonfire/README.md, modules/diary/diary.go, modules/diary/README.md, internal/build/build.go, internal/build/build_test.go, internal/build/testdata/new/main.go.golden, examples/hello/main.go, examples/hello/hello_test.go, examples/hello/go.sum, examples/hello/plugins/greeter/go.sum, cmd/summer/main.go, cmd/summer/docs.go, internal/docsite/check_commands.go, docs/console/introduction.md, docs/console/writing-commands.md, .planning/todos/pending/bonfire-duplicate-command-names.md, .planning/todos/done/bonfire-duplicate-command-names.md</files>
|
||||
<read_first>modules/bonfire/root.go, modules/bonfire/command.go, modules/bonfire/call.go, modules/bonfire/output.go, modules/bonfire/README.md, modules/diary/diary.go, internal/build/build.go, internal/build/build_test.go, examples/hello/hello_test.go, cmd/summer/main.go, cmd/summer/docs.go, internal/docsite/check_commands.go, docs/console/introduction.md ("Getting help"), docs/console/writing-commands.md (the duplicate-names paragraph), .planning/todos/pending/bonfire-duplicate-command-names.md, .planning/G15OFFICE.md, 19.8-RESEARCH.md (Pattern 4, Pattern 5, Pitfalls 1-3 and 15, Code Examples "Generated main after this phase", Probe evidence), 19.8-CONTEXT.md (D-13 preview)</read_first>
|
||||
<action>Install `charm.land/fang/v2` at the decision note's version (`go get`, then `go mod tidy` once execute.go imports it, and tidy the examples/hello modules that need go.sum entries).
|
||||
|
||||
globals.go: `Globals` (fields under Artifacts). `PeekGlobals(args)`: a tolerant scan that never errors and stops at `--`: `-v`, `-vv`, `-vvv` and each `--verbose` add to Verbosity (capped at 3); `-q`/`--quiet`; `-n`/`--no-interaction`; `--ansi` and `--no-ansi` set Color to ColorAlways or ColorNever, last wins; `--env X` and `--env=X`; Quiet also sets NoInteraction. `(Globals).Args()` re-serialises for forwarding (`-v`/`-vv`/`-vvv`, `-q`, `-n`, `--ansi` or `--no-ansi`, `--env=<value>`), empty for the zero value.
|
||||
|
||||
root.go: `type RootOption func(*rootConfig)`, `WithVersion(v Version)`, `OnGlobals(fn func(Globals))`; `NewRoot(name, commands, out, opts ...RootOption)` and `NewRootIO(name, commands, in, out, errW, opts ...RootOption)`. Register persistent flags on the root: `--verbose`/`-v` as a count, `--quiet`/`-q`, `--ansi`, `--no-ansi`, `--no-interaction`/`-n`, `--env` (string), with the D-13 descriptions. In the command loop: a second command with the same name, or a command named `list` or `help`, returns `fmt.Errorf("%w: %q", ErrDuplicateCommand, name)`; in wrap, a flag whose Name is verbose, quiet, ansi, no-ansi, no-interaction, env or help, or whose Shorthand is v, q, n or h, returns an error wrapping ErrReservedFlag that names the command and the flag (checked before pflag registration so it never panics). Root PersistentPreRunE: read the parsed flags into Globals (Quiet implies NoInteraction), store them on the console Output (new unexported fields; Task 3 acts on them) and call every OnGlobals hook. `bonfire.Call` passes no options, so the scheduler never changes process-wide logging (RESEARCH Pitfall 3). Root RunE renders the list (bare binary). Add the `list` command (Description "List commands"; flags `--all` Bare and `--format` default `txt`; in this task only txt, Task 2 adds json and Hidden).
|
||||
|
||||
version.go: `Version`, `BuildVersion()` from debug.ReadBuildInfo: App is Main.Version with "(devel)" shown as "devel"; Framework is the version of the dep `git.golem15.com/golem15/summercms` ("(devel)" as "devel", Local true when the dep has a Replace); when the main module is the framework itself (the summer tool) Framework equals App. `(Version).Header(name)`: `SummerCMS acme v1.4.0 (framework v0.3.2)`, `(framework v0.3.2, local)` when Local, and `SummerCMS summer v0.3.2` when App and Framework are the same build.
|
||||
|
||||
list.go (txt): Version header line, blank line, "Usage:" and ` <usage name> <command> [options] [arguments]` (usage name `summer` for the tool, else `bin/<name>`), "Options:" with exactly the D-13 global option lines, "Available commands:" with top-level names (no colon, including list and help) first, then each namespace (the text before the first colon) sorted with a one-space-indented header line and its commands sorted, every name padded to the longest plus two spaces before the description. Styling: section titles in Palette.Header, command names in Palette.Success, through a colorprofile.Writer built with DetectProfile(out, globals.Color, os.Environ()).
|
||||
|
||||
execute.go: constants ExitOK, ExitFailure, ExitUsage; `Execute(ctx, root, args)`: SetArgs(args); when the args before `--` hold no command word (only globals and -h/--help), render the list to root's out and return ExitOK; otherwise `fang.Execute(ctx, root, fang.WithoutVersion(), fang.WithoutManpage(), fang.WithoutCompletions(), fang.WithErrorHandler(h))` where h calls RenderError (in this task a plain block: a blank line, ` ERROR <message>`, a blank line); map the returned error: nil to ExitOK, a UsageError (root.SetFlagErrorFunc wraps flag errors with Usage; the positional-args validator wraps count errors; messages starting with "unknown command" count too) to ExitUsage, anything else to ExitFailure. `Fail(w, name, err, g)` renders and returns ExitFailure for errors before the root exists. Usage, UsageError and RenderError are declared here or in errors.go (Task 2 completes the renderer).
|
||||
|
||||
diary: `FromGlobals(g bonfire.Globals) Options` (Verbosity, Quiet, Color) and `ApplyGlobals(g bonfire.Globals)` (SetVerbosity plus the console color mode). README rows for both.
|
||||
|
||||
Generated main: `func main() { os.Exit(run(os.Args[1:], os.Stdout)) }`; `func run(args []string, out io.Writer) int`: `g := bonfire.PeekGlobals(args)`, `diary.Bootstrap(os.Stderr, diary.FromGlobals(g))`, `cfg, err := compass.Open(compass.Options{Dir: "config", Env: g.Env})` with `return bonfire.Fail(os.Stderr, "<binary>", err, g)` on error (same for diary.Setup, party.Activate, app.Publish and NewRoot errors), `diary.Setup(cfg, diary.FromGlobals(g))`, unchanged command assembly lines, `root, err := bonfire.NewRoot("<binary>", commands, out, bonfire.WithVersion(bonfire.BuildVersion()), bonfire.OnGlobals(diary.ApplyGlobals))`, `return bonfire.Execute(context.Background(), root, args)`. Update `TestGenerateMainInstallsDiary` for the new order (PeekGlobals first, then Bootstrap, then compass.Open, then Setup, then backpack.New) and the goldens (`-update`); regenerate examples/hello/main.go. cmd/summer/main.go: same shape (PeekGlobals, diary.Bootstrap, NewRoot with WithVersion and OnGlobals, `os.Exit(bonfire.Execute(...))`).
|
||||
|
||||
examples/hello/hello_test.go: `TestHelpListsServe` expects run to return 0 and the list to name serve, migrate, migrate:status and migrate:rollback plus "Available commands:"; `TestMigrateWithoutDSNFailsLoudly` asserts run returns 1 and that the built binary's stderr for `migrate` with an empty DSN contains `database.dsn`; `TestBuiltBinaryUnknownCommandExitsNonZero` asserts exit code 2; new `TestBuiltBinaryListsCommands`: `./bin/hello` exits 0 and stdout holds "SummerCMS hello", "Available commands:", a line equal to " greeter" and "greeter:hello"; `./bin/hello -vv list` exits 0.
|
||||
|
||||
Docs checker: commandWord treats `-v`, `-vv`, `-vvv`, `--verbose`, `-q`, `--quiet`, `-n`, `--no-interaction`, `--ansi`, `--no-ansi` as value-less and `--env` as taking a value (`--env X` or `--env=X`); update its doc comment. docsCommands adds "list" and "help" to both the tool and the application lists. Keep the generated-main append lines that docs_test parses.
|
||||
|
||||
Docs and README in the same commit: docs/console/introduction.md "Getting help" shows the bare binary's list, the global options table and the exit codes 0, 1 and 2; writing-commands.md replaces "names are not checked for duplicates" with the rejection rule (ErrDuplicateCommand at boot; global flag names and shorthands are reserved, ErrReservedFlag); bonfire README Features and API reference rows for every new name in this task. Commit: `feat(bonfire): global flags, Artisan list and exit codes`.
|
||||
|
||||
Smoke test list_smoke_test.go `TestListTracerSmoke` (-short): NewRootIO("acme") with commands migrate, admin:create, admin:token:create, make:model and an OnGlobals hook that records Globals; Execute with no args returns 0 and the output holds the header, "Usage:", " bin/acme <command> [options] [arguments]", the global option lines and, in order, migrate, then " admin" with its two commands, then " make"; `-vvv migrate` records Verbosity 3; `migrate -q` records Quiet and NoInteraction; `migrat` returns 2; a command whose Run fails returns 1; a duplicate name and a flag with Shorthand "v" each make NewRootIO return an error that names the command. PeekGlobals agrees with the hook for the same args.
|
||||
|
||||
Close the folded todo (planning commit, alone): `git mv .planning/todos/pending/bonfire-duplicate-command-names.md .planning/todos/done/`, append one line naming the commit; move g15office task u4rtpcl1 to Live with a one-line comment (if the g15office tool is not available in this session, record that in the SUMMARY). Commit: `docs(19.8): close the duplicate command names todo`.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/bonfire/... ./modules/diary/... ./internal/build/... ./internal/docsite/... ./cmd/summer/... && go test -count=1 -short -run '^TestListTracerSmoke$' -v ./modules/bonfire 2>&1 | grep -q -- '^--- PASS: TestListTracerSmoke' && go -C examples/hello test -count=1 -run '^(TestBuiltBinaryListsCommands|TestHelpListsServe|TestBuiltBinaryUnknownCommandExitsNonZero)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3 && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit; no "--- PASS: TestListTracerSmoke" line; the count of top-level "--- PASS: Test" lines from examples/hello is not 3 (the built binary does not list, the help test fails, or an unknown command does not exit 2); or the docs checker rejects a command line or identifier.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'func PeekGlobals(args \[\]string) Globals' modules/bonfire/globals.go` prints 1.
|
||||
- `grep -c 'fang.WithoutVersion()' modules/bonfire/execute.go` prints 1 and `grep -c 'fang.WithoutManpage()' modules/bonfire/execute.go` prints 1.
|
||||
- `grep -c 'bonfire.OnGlobals(diary.ApplyGlobals)' internal/build/testdata/new/main.go.golden` prints 1 and `grep -c 'os.Exit(run(os.Args\[1:\], os.Stdout))' examples/hello/main.go` prints 1.
|
||||
- `grep -c 'compass.Options{Dir: "config", Env: g.Env}' internal/build/testdata/new/main.go.golden` prints 1.
|
||||
- `grep -c 'ErrDuplicateCommand' modules/bonfire/README.md` prints at least 1 and `grep -c 'ErrDuplicateCommand' docs/console/writing-commands.md` prints at least 1.
|
||||
- `test -f .planning/todos/done/bonfire-duplicate-command-names.md && test ! -f .planning/todos/pending/bonfire-duplicate-command-names.md` exits 0.
|
||||
- `grep -cE '"--env"|--no-interaction' internal/docsite/check_commands.go` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>A user who runs an application binary or summer with no arguments sees the grouped Artisan list, every command takes the global flags with diary following them from the first log line, and scripts get exit code 2 for usage mistakes.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: help <command> and --help print the Artisan layout with defaults, long help and examples; Hidden, list --all and list --format=json; CheckDocs; errors render as a styled block with a Try: hint and the -v chain</name>
|
||||
<reversibility rating="costly">D-15 and D-16: Command.Examples, Command.Hidden and the JSON listing's field names become API that plugins and docs checks rely on.</reversibility>
|
||||
<files>modules/bonfire/command.go, modules/bonfire/root.go, modules/bonfire/help.go, modules/bonfire/list.go, modules/bonfire/docs.go, modules/bonfire/errors.go, modules/bonfire/execute.go, modules/bonfire/help_smoke_test.go, modules/bonfire/README.md, docs/console/writing-commands.md, docs/console/introduction.md</files>
|
||||
<read_first>modules/bonfire/command.go, modules/bonfire/root.go, modules/bonfire/list.go, modules/bonfire/execute.go (Task 1), modules/bonfire/output_test.go (TestHelpUsesSharedAdapter), cmd/summer/main_test.go (help expectations :20-58), modules/sunscreen/sunscreen.go (Scrub), docs/console/writing-commands.md, docs/console/introduction.md, 19.8-CONTEXT.md (D-14 preview, D-16, D-17, D-18 preview), 19.8-RESEARCH.md (Pattern 5 JSON schema and CheckDocs, Pitfall 13, Code Examples "Error with hint")</read_first>
|
||||
<action>command.go (D-15): append `Examples []Example` and `Hidden bool` to Command (after Run, with doc comments) and add `type Example struct { Command string; Description string }`: Command is the command line without the binary, Description a short sentence shown as a `#` comment above it. wrap maps Hidden to cobra's Hidden and sets `cmd.SetHelpFunc` on every subcommand to the Artisan help renderer (RESEARCH: cobra prefers a command's own help func); root help func renders the list. Replace cobra's help command with `help [command]` via root.SetHelpCommand: no argument prints the list, a name prints that command's help, an unknown name is a Usage error whose Try comes from root.SuggestionsFor.
|
||||
|
||||
help.go (D-14): sections in order, each title in Palette.Header: "Description:" with the Description; "Usage:" with `<commandUsage> [options]` (append `[--] <args...>` only for Passthrough commands); "Arguments:" (name padded, description) when there are Args; "Options:" one line per flag: `-x, --name=NAME` for value flags (` --name=NAME` without shorthand, NAME the upper-cased flag name with dashes as underscores), `--name` for Bare flags, description, then `[default: "value"]` when Default is non-empty (unquoted when it parses as an integer), and "(multiple values allowed)" for Repeatable; "Global options:" the compact global flag lines; "Help:" with Long when set, indented two spaces and wrapped as written; "Examples:" each example as ` # <Description>` then ` <usage name> <Command>`, a blank line between examples. Output through the same colorprofile writer as the list.
|
||||
|
||||
list.go: Hidden commands are skipped unless `--all`; `--format=json` writes `Describe(rootName, commands, version)` as indented JSON; any other format is a Usage error. `Describe` builds `Listing` with JSON keys application{name, version, framework}, global_options, commands[{name, description, long, usage, hidden, passthrough, arguments[{name, description, required}], options[{name, shorthand, description, default, bare, repeatable}], examples[{command, description}]}] covering every command including hidden ones, list and help.
|
||||
|
||||
docs.go (D-17): `DocProblem` with `String()` ("<command>: missing description, examples"), `CheckDocs(commands)` checking Description, Long, at least one Example, every Arg.Description and every Flag.Description; results sorted by command name. It only reports; callers decide to fail (framework, plan 3) or log (plugins).
|
||||
|
||||
errors.go (D-18): `HintError` (Error returns Err.Error(), Unwrap returns Err), `WithHint(err, explanation, try)` (nil stays nil), `ErrReported` and `Reported(err)` (an error matching both ErrReported and err), `UsageError` and `Usage(err)`. `RenderError(w, name, err, g)`: nothing for ErrReported; otherwise, through a colorprofile writer with DetectProfile(w, g.Color, os.Environ()), a blank line, ` ` + an ` ERROR ` badge (Palette.ErrorBold, reversed) + two spaces + the error message (every line indented, text passed through sunscreen.Scrub); when the chain holds a HintError, a blank line and its explanation, then " Try:" and ` <try>` with `{bin}` replaced by the usage name; when the error is cobra's unknown command with suggestions, the message becomes `Command "<name>" is not defined.` and Try lists `<usage name> <suggestion>`; for other usage errors Try is `<usage name> help <command>`; at Verbosity 0 a final " Run with -v for details." line, at Verbosity 1 or more a " Caused by:" list of each wrapped error's message (errors.Unwrap walk, errors.Join members expanded), scrubbed. execute.go uses RenderError from fang's handler and in Fail. Existing callers that only check `err != nil` are unaffected.
|
||||
|
||||
README (same commit): Features (Artisan help and list, Examples and Hidden, JSON listing, CheckDocs, error block with hints and exit codes), API reference rows for every name added in this task. docs/console/writing-commands.md: sections "Examples and hidden commands" (the Example shape, rendering with bin/<name> or summer), "Errors and hints" (WithHint with the {bin} placeholder, Reported for commands that already printed their problems, exit codes), "Documentation coverage" (CheckDocs; the framework test fails, plugin repos log). docs/console/introduction.md "Getting help": `summer help migrate`, `bin/acme migrate --help`, `bin/acme list --all`, `bin/acme list --format=json`. Commit: `feat(bonfire): Artisan help, examples, JSON listing and error hints`.
|
||||
|
||||
Smoke tests help_smoke_test.go (-short): `TestHelpSmoke`: a command with one required Arg, a value flag with Default "*", a value flag with Default "90", a Bare flag, a Repeatable flag, Long and two Examples renders the sections in order with `[default: "*"]`, `[default: 90]`, "(multiple values allowed)" and `bin/acme` prefixes; `help <name>` output equals `<name> --help` output. `TestListJSONSmoke`: `list --format=json` decodes into Listing, includes a Hidden command with hidden true while plain `list` omits it and `list --all` shows it. `TestErrorRenderSmoke`: WithHint renders the explanation and the Try line with {bin} replaced; Verbosity 0 shows "Run with -v for details." and Verbosity 1 shows "Caused by:"; Reported renders nothing and Execute returns 1; an unknown command returns 2 with a Try naming the suggestion; a "Bearer abc..." string in an error is scrubbed. Existing TestHelpUsesSharedAdapter and cmd/summer help tests stay green.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/bonfire/... ./cmd/summer/... ./internal/build/... && go test -count=1 -short -run '^(TestHelpSmoke|TestListJSONSmoke|TestErrorRenderSmoke|TestHelpUsesSharedAdapter)$' -v ./modules/bonfire 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 4 && go -C examples/hello test -count=1 ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit, or the count of top-level "--- PASS: Test" lines is not 4 (a help section, the JSON listing, the error block or the old help test failed or did not run), or examples/hello or the docs checker fail.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -cE '^\s+Examples +\[\]Example' modules/bonfire/command.go` prints 1 and `grep -cE '^\s+Hidden +bool' modules/bonfire/command.go` prints 1.
|
||||
- `S=$(awk '/^type Command struct/,/^}/' modules/bonfire/command.go) && R=$(printf '%s\n' "$S" | grep -nE '^\s+Run\s' | cut -d: -f1) && E=$(printf '%s\n' "$S" | grep -nE '^\s+Examples\s' | cut -d: -f1) && H=$(printf '%s\n' "$S" | grep -nE '^\s+Hidden\s' | cut -d: -f1) && test "$R" -lt "$E" && test "$R" -lt "$H"` exits 0 (Examples and Hidden are appended after Run, additive).
|
||||
- `grep -c 'func CheckDocs(commands \[\]Command) \[\]DocProblem' modules/bonfire/docs.go` prints 1 and `grep -c 'func WithHint(err error, explanation, try string) error' modules/bonfire/errors.go` prints 1.
|
||||
- `grep -c 'sunscreen.Scrub' modules/bonfire/errors.go` prints at least 1.
|
||||
- `grep -c '"global_options"' modules/bonfire/list.go` prints 1.
|
||||
- `grep -cE 'WithHint|CheckDocs|Reported' docs/console/writing-commands.md` prints at least 3.
|
||||
</acceptance_criteria>
|
||||
<done>Any command explains itself in the Artisan layout with its defaults and examples, scripts and agents can read the whole catalog as JSON, and failures end in a readable block that says what to type next.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: On a terminal, spinners, the multi-row process view, the gradient progress bar, tables and the arrow-key selector run on charm v2 (ported from SSU); off a terminal, under -q, --no-ansi or -n every widget prints plain lines and prompts never block</name>
|
||||
<reversibility rating="costly">D-01 and D-02: every command's terminal output and the widget behaviour depend on the ported models and the charm runtime.</reversibility>
|
||||
<files>go.mod, go.sum, modules/bonfire/styles.go, modules/bonfire/output.go, modules/bonfire/widgets.go, modules/bonfire/prompts.go, modules/bonfire/live.go, modules/bonfire/selector.go, modules/bonfire/helpers.go, modules/bonfire/widgets_smoke_test.go, modules/bonfire/README.md, modules/diary/console.go, modules/diary/README.md, docs/console/writing-commands.md, docs/services/logging.md, examples/hello/go.sum, examples/hello/plugins/greeter/go.sum</files>
|
||||
<read_first>modules/bonfire/output.go, modules/bonfire/widgets.go, modules/bonfire/prompts.go, modules/bonfire/styles.go, modules/bonfire/output_test.go, modules/bonfire/prompts_test.go, modules/cabana/commands_test.go (:241-262 WR-13), modules/diary/console.go, /media/nvme/dev/pxpx/ssu/internal/cli/tui/spinner.go, /media/nvme/dev/pxpx/ssu/internal/cli/tui/process.go, /media/nvme/dev/pxpx/ssu/internal/cli/tui/progress.go, /media/nvme/dev/pxpx/ssu/internal/cli/tui/selector.go, /media/nvme/dev/pxpx/ssu/internal/cli/tui/selector_keys.go, /media/nvme/dev/pxpx/ssu/internal/cli/tui/confirm.go, 19.8-RESEARCH.md (Anti-Patterns, Don't Hand-Roll, Porting SSU's spinner, Pitfalls 7, 10 and 12, Testing techniques), 19.8-CONTEXT.md (D-01, D-03, D-27, D-28, D-30)</read_first>
|
||||
<action>Install `charm.land/bubbletea/v2`, `charm.land/bubbles/v2` and `charm.land/huh/v2` at the decision note's versions; tidy the root and examples/hello modules.
|
||||
|
||||
Console policy (output.go): the console keeps its fields and gains quiet, noInteraction, verbosity and color mode from Task 1's PersistentPreRunE. live = tty and not quiet and color mode not ColorNever and not noInteraction (D-03). interactive = stdin and stdout are terminals and not noInteraction (quiet implies noInteraction). Styling moves from the raw ANSI constants to DefaultPalette through a colorprofile.Writer whose profile comes from DetectProfile (NO_COLOR, TERM=dumb and FORCE_COLOR keep their meaning; the symbols ✓ ✗ ⚠ ℹ stay). Info, Success and Warning print nothing when quiet; Error always prints to stderr; Println, Printf, Write and Table always print (data, D-27). Off a terminal the bytes are today's (output_test.go and prompts_test.go must pass unchanged).
|
||||
|
||||
live.go: port SSU's SpinnerModel, ProcessModel and ProgressModel to bubbletea v2 as unexported models (View returns tea.View via tea.NewView, key messages are tea.KeyPressMsg, progress.New(progress.WithColors(lipgloss.Color("#00afaf"), lipgloss.Color("#00af00")), progress.WithoutPercentage()) with SetWidth(40), spinner.MiniDot with Palette.Current) without the git and engine coupling. A helper runs a model with `tea.NewProgram(m, tea.WithInput(nil), tea.WithOutput(out), tea.WithoutSignalHandler(), tea.WithContext(ctx))`, keeps a package-level pointer to the running program under a mutex for PrintAbove, and clears it before Quit returns (never Println after quit). Spinner: live runs the spinner model while fn runs in a goroutine and ends with "✓ message" or "✗ message"; not live keeps `[...] message`; quiet runs fn silently. Progress: live shows the bar with done/total and the current item; the value passed to fn implements Advance plus the optional ItemProgress methods; not live keeps the decile percentage lines; quiet prints nothing. Table: on a terminal lipgloss/v2/table with lipgloss.NormalBorder() and Palette.Header for the header row; off a terminal the existing tab-separated output.
|
||||
|
||||
selector.go: a single-select port of SSU's SelectorModel and its key map (up/down and k/j move, enter selects, esc or ctrl+c cancels) plus SSU's confirmation screen (selected item, enter to confirm, esc to go back); no multi-select, filter or detail pane (D-30).
|
||||
|
||||
prompts.go: interactive Ask uses huh.NewInput (default as placeholder and returned on empty input), Confirm huh.NewConfirm, Secret huh.NewInput().EchoMode(huh.EchoModePassword), Choice the selector; forms run with WithInput(c.in) and WithOutput(c.out); huh.ErrUserAborted and a cancelled selector return ErrAborted. Not interactive and not noInteraction: today's line-based prompts unchanged (piped stdin, empty stdin takes defaults, WR-13). noInteraction: Ask returns def, Confirm returns def, Choice returns choices[def], Secret reads one line when stdin is not a terminal and otherwise returns `WithHint(errors.New("bonfire: a secret is needed and -n turned prompts off"), "This command reads the value from a prompt or from stdin.", "printf '%s\n' \"$VALUE\" | {bin} <command> -n")`.
|
||||
|
||||
helpers.go: GlobalsOf, VerbosityOf, IsQuiet and Interactive read an unexported optional interface the console implements (zero values for other Output implementations); ConfirmProceed returns true without asking when Interactive is false and otherwise asks Confirm(question, false) (D-28); Process runs items one at a time with the process model on a live console (per-row pending, running, done or failed with the error) and one line per item otherwise ("✓ item" or "✗ item: error"; quiet prints failures only through Error), returning errors.Join of the failures; Current and Failed call the ItemProgress methods when the Progress has them; PrintAbove prints a line above the running program and reports whether it did.
|
||||
|
||||
diary console.go: when the console channel renders styled lines on a terminal it first offers the formatted line to bonfire.PrintAbove and writes directly only when that returns false, so log lines from other goroutines never tear a live view.
|
||||
|
||||
README and docs in the same commit: bonfire README Features (charm-backed widgets; a degradation table with rows terminal, not a terminal, -q, --no-ansi, -n and columns animation, decoration, data, prompts), API reference rows for the helpers, ItemProgress and ErrAborted, Dependencies list the charm modules; docs/console/writing-commands.md Output section (the same table, -q keeps data, ConfirmProceed for destructive commands, Process and Current for bulk loops); docs/services/logging.md one paragraph on log lines printing above live views. Commit: `feat(bonfire): SSU widgets on charm v2 behind the Output interface`.
|
||||
|
||||
Smoke test widgets_smoke_test.go `TestWidgetMatrixSmoke` (-short, no terminal): through newConsole with injected tty and interactive flags: quiet suppresses Info, Success and Warning but keeps Println, Table and Error; noInteraction makes Ask, Confirm and Choice return defaults without reading stdin and Secret with a terminal-like stdin returns an error whose chain holds a HintError; ConfirmProceed on a non-interactive console returns true without reading; the spinner, progress, process and selector models produce non-empty frames from Update and View called directly (window size 80x24), and the selector returns the item after enter on the confirmation screen.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/bonfire/... ./modules/diary/... ./modules/cabana/... && go test -count=1 -race -short ./modules/bonfire/... ./modules/diary/... && go test -count=1 -short -run '^(TestWidgetMatrixSmoke|TestNonTTYSpinner|TestNonTTYProgress|TestNonTTYTable|TestColorPolicy|TestSecretReadsPlainStdinLine|TestClosedStdinDoesNotHang)$' -v ./modules/bonfire 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 7 && go -C examples/hello test -count=1 ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit (including a data race reported by -race), or the count of top-level "--- PASS: Test" lines is not 7 (the matrix smoke or one of the unchanged non-TTY, color or piped-secret tests failed or did not run), or examples/hello, the cabana piped-password test or the docs checker fail.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'tea.WithInput(nil)' modules/bonfire/live.go` prints at least 1 and `grep -c 'tea.WithoutSignalHandler()' modules/bonfire/live.go` prints at least 1.
|
||||
- `go list -deps ./modules/bonfire | grep -cE '^charm.land/(bubbletea|bubbles|huh|fang|lipgloss)/v2$'` prints 5.
|
||||
- `grep -c 'huh.EchoModePassword' modules/bonfire/prompts.go` prints 1 and `grep -cE 'engine\.|git\.Submodule' modules/bonfire/live.go modules/bonfire/selector.go | awk -F: '{s+=$2} END {print s}'` prints 0.
|
||||
- `grep -c 'bonfire.PrintAbove(' modules/diary/console.go` prints at least 1.
|
||||
- `C=$(git log --oneline cdddaa3..HEAD -- modules/bonfire/output_test.go modules/bonfire/prompts_test.go) && test -z "$C"` exits 0 (the existing non-TTY and prompt tests were not edited in this phase).
|
||||
- `grep -c 'ConfirmProceed' docs/console/writing-commands.md` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>On a terminal commands look like SSU, off a terminal or with -q, --no-ansi or -n they print the same plain lines as before and never wait for input, and plugins needed no change to get any of it.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| argv to bonfire and the generated main | global flags are read before config loads and before plugins boot |
|
||||
| commands to the terminal | error text, hints and data printed to stdout and stderr, possibly captured by CI logs |
|
||||
| operator stdin to prompts | passwords and confirmations, piped in scripts or typed on a terminal |
|
||||
| plugins to the command root | plugin commands are registered next to framework commands |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.8-07 | Information disclosure | RenderError (errors.go) | medium | mitigate | every rendered message and chain entry passes through sunscreen.Scrub; the wrapped chain prints only at -v and only to stderr; TestErrorRenderSmoke checks a Bearer token is scrubbed |
|
||||
| T-19.8-08 | Denial of service | prompts and ConfirmProceed | high | mitigate | huh and the selector run only when stdin and stdout are terminals and -n/-q are off; Call roots have empty stdin; ConfirmProceed returns true off a terminal (D-28); TestWidgetMatrixSmoke and the unchanged piped-stdin tests |
|
||||
| T-19.8-09 | Information disclosure | -q data path | medium | mitigate | tokens and keys print only through Println/Printf/Write or --json, never through diary; -q suppresses decoration only (D-27), so a script's data is neither lost nor duplicated into logs |
|
||||
| T-19.8-10 | Elevation of privilege | NewRoot command registration | medium | mitigate | duplicate names (and list or help) make NewRoot fail at boot naming the command, so a plugin cannot shadow a framework command such as migrate; reserved global flags are rejected instead of panicking |
|
||||
| T-19.8-11 | Denial of service | bubbletea programs (live.go) | medium | mitigate | tea.WithInput(nil) keeps stdin out of raw mode, tea.WithoutSignalHandler leaves Ctrl+C to the command's signal.NotifyContext, tea.WithContext stops the view with the command |
|
||||
| T-19.8-12 | Tampering | --env flag | low | mitigate | the value goes only to compass.Options.Env, whose sanitizer rejects path separators and dot-dot |
|
||||
| T-19.8-SC | Tampering | Go module installs (fang, bubbletea, bubbles, huh) | high | mitigate | versions pinned by the plan 01 decision note and the RESEARCH legitimacy audit; go.sum verified; only modules/bonfire imports them (acceptance criterion counts the module set) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: TestListTracerSmoke; examples/hello lists, helps and exits 2 on an unknown command; docs checker accepts global flags, list and help.
|
||||
- Task 2: TestHelpSmoke, TestListJSONSmoke, TestErrorRenderSmoke; existing help tests unchanged.
|
||||
- Task 3: TestWidgetMatrixSmoke plus the unchanged non-TTY, color and piped-secret tests; -race on bonfire and diary.
|
||||
- Feedback sampling: after each commit `go test -short ./modules/bonfire/... ./modules/diary/... ./internal/build/... ./internal/docsite/... ./cmd/summer/...`; after the plan, `go vet ./... && go test -short ./... && go -C examples/hello test ./...`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- D-13, D-14, D-15, D-16: list, help, Examples, Hidden and the JSON listing.
|
||||
- D-17: CheckDocs helper ready for plan 3's failing framework test and warn-only plugin tests.
|
||||
- D-18: styled error block with hints and exit codes 1 and 2.
|
||||
- D-01, D-02, D-03, D-27, D-28, D-30: SSU widgets on charm v2 with clean degradation; Output unchanged.
|
||||
- SC-2: global flags on every command drive diary.
|
||||
- Folded todo closed.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.8-console-commands-overhaul/19.8-02-SUMMARY.md` when done, with the commits in order, the charm versions installed, the final exported names (plan 3 uses WithHint, Reported, ConfirmProceed, Process, GlobalsOf, Examples), the g15office result for u4rtpcl1, and any deviation from this plan's names.
|
||||
</output>
|
||||
@@ -0,0 +1,346 @@
|
||||
---
|
||||
phase: 19.8-console-commands-overhaul
|
||||
plan: 02
|
||||
subsystem: console
|
||||
tags: [bonfire, cobra, fang, bubbletea, bubbles, huh, lipgloss, colorprofile, diary, cli]
|
||||
|
||||
requires:
|
||||
- phase: 19.8-01
|
||||
provides: "diary (Bootstrap, Setup, SetVerbosity, console handler) and bonfire ColorMode, Palette, DefaultPalette, DetectProfile"
|
||||
provides:
|
||||
- "Global options -v/-vv/-vvv, -q, --ansi/--no-ansi, -n, --env on every command; PeekGlobals before boot, OnGlobals after parsing"
|
||||
- "Artisan list (bare binary, --help, list, list --all, list --format=json) with the BuildVersion header"
|
||||
- "Artisan per-command help (help <command>, <command> --help) with defaults, long help and Examples"
|
||||
- "Command.Examples, Command.Hidden, Listing, Describe, CheckDocs"
|
||||
- "Error block with WithHint, Reported, Usage; Execute and Fail return exit codes 0, 1, 2"
|
||||
- "Duplicate command names and reserved global flags rejected by NewRoot"
|
||||
- "SSU spinner, progress, process view and selector on charm v2; huh prompts; plain-line degradation"
|
||||
- "Helpers GlobalsOf, VerbosityOf, IsQuiet, Interactive, ConfirmProceed, Process, Current, Failed, PrintAbove"
|
||||
- "Generated main: PeekGlobals, diary.FromGlobals, compass.Open with --env, bonfire.Execute exit code"
|
||||
affects: [19.8-03, 19.8-04, console, plugins, generated main]
|
||||
|
||||
actuals:
|
||||
tokens: 34921
|
||||
tasks: 3
|
||||
commits: 5
|
||||
plan_head_before: b7c07591cb3906d5b83403d18837f17af9189ffa
|
||||
plan_head_after: 3f388629e14ea2e46dc5c97e9cfb01a1f8bad58f
|
||||
|
||||
tech-stack:
|
||||
added:
|
||||
- "charm.land/fang/v2 v2.0.1"
|
||||
- "charm.land/bubbletea/v2 v2.1.0"
|
||||
- "charm.land/bubbles/v2 v2.2.1"
|
||||
- "charm.land/huh/v2 v2.0.3"
|
||||
- "github.com/spf13/pflag v1.0.9 (was indirect, now imported directly)"
|
||||
- "github.com/charmbracelet/x/ansi v0.11.9 (direct, test only: stripping frames)"
|
||||
patterns:
|
||||
- "Root state (rootState) shared by list, help, the help command and per-subcommand help funcs; cobra prefers a command's own help func, so fang's root help func never reaches them"
|
||||
- "Live views run as bubbletea programs with tea.WithInput(nil), tea.WithoutSignalHandler(), tea.WithContext; one at a time; PrintAbove sends lines to the running program with Program.Send (a no-op after it ends), so callers never block on a finished program"
|
||||
- "Optional interface (consoleState) behind GlobalsOf/Interactive instead of new Output methods"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- modules/bonfire/globals.go
|
||||
- modules/bonfire/version.go
|
||||
- modules/bonfire/execute.go
|
||||
- modules/bonfire/list.go
|
||||
- modules/bonfire/help.go
|
||||
- modules/bonfire/docs.go
|
||||
- modules/bonfire/errors.go
|
||||
- modules/bonfire/live.go
|
||||
- modules/bonfire/selector.go
|
||||
- modules/bonfire/helpers.go
|
||||
- modules/bonfire/list_smoke_test.go
|
||||
- modules/bonfire/help_smoke_test.go
|
||||
- modules/bonfire/widgets_smoke_test.go
|
||||
modified:
|
||||
- modules/bonfire/root.go
|
||||
- modules/bonfire/command.go
|
||||
- modules/bonfire/output.go
|
||||
- modules/bonfire/widgets.go
|
||||
- modules/bonfire/prompts.go
|
||||
- modules/bonfire/example_test.go
|
||||
- modules/bonfire/README.md
|
||||
- modules/diary/diary.go
|
||||
- modules/diary/console.go
|
||||
- modules/diary/README.md
|
||||
- internal/build/build.go
|
||||
- internal/build/build_test.go
|
||||
- internal/build/testdata/new/main.go.golden
|
||||
- examples/hello/main.go
|
||||
- examples/hello/hello_test.go
|
||||
- cmd/summer/main.go
|
||||
- cmd/summer/docs.go
|
||||
- cmd/summer/docs_test.go
|
||||
- internal/docsite/check_commands.go
|
||||
- internal/docsite/checks_test.go
|
||||
- docs/console/introduction.md
|
||||
- docs/console/writing-commands.md
|
||||
- docs/services/logging.md
|
||||
- go.mod
|
||||
- go.sum
|
||||
|
||||
key-decisions:
|
||||
- "NewRoot sends the error block to stderr when its output is the process stdout (root.SetErr); Output.Error keeps writing where it did, so today's stdout bytes do not move"
|
||||
- "Execute resets the root's persistent flags before each run and pre-routes a call without a command word (only globals, -h, --help) to the list by dropping -h/--help; fang runs with WithoutVersion, WithoutManpage, WithoutCompletions and WithErrorHandler"
|
||||
- "Passthrough commands (summer's delegates) parse no flags, so the root reads their globals with PeekGlobals over the raw args, as the generated main did before boot"
|
||||
- "UsageError carries an unexported command name set by the flag error func and the argument-count check, so the error block's Try line is '<bin> help <command>' (or '<bin> list' at the root)"
|
||||
- "The builtin list and help commands carry Long and Examples, so plan 3's framework CheckDocs test can include them"
|
||||
- "--no-ansi on an interactive terminal uses the plain line prompts instead of huh and the selector (D-03: no styled widget under --no-ansi); -n and -q return defaults"
|
||||
- "While a live view runs, Println, Printf (complete lines), status lines and tables print above it; a widget started inside a running view prints plain lines (one live view at a time)"
|
||||
- "diary.ApplyGlobals rebuilds the channels only when the color mode differs from the one they were built with; otherwise it is SetVerbosity"
|
||||
|
||||
patterns-established:
|
||||
- "Command examples are stored without the binary and rendered after bin/<name> or summer"
|
||||
- "Commands that already printed their problems return bonfire.Reported(err); usage checks wrap with bonfire.Usage"
|
||||
- "Destructive steps ask through bonfire.ConfirmProceed, which never asks off an interactive terminal"
|
||||
|
||||
requirements-completed: [SC-2, SC-3, D-01, D-02, D-03, D-04, D-13, D-14, D-15, D-16, D-17, D-18, D-27, D-28, D-30]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "A bare binary, --help and list print the Artisan list with the version header, usage, global options and namespace blocks"
|
||||
requirement: D-13
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/bonfire/list_smoke_test.go#TestListTracerSmoke"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "examples/hello/hello_test.go#TestBuiltBinaryListsCommands"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Global options reach the OnGlobals hook (diary) and agree with PeekGlobals; Call runs no hook"
|
||||
requirement: SC-2
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/bonfire/list_smoke_test.go#TestListTracerSmoke"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "internal/build/build_test.go#TestGenerateMainInstallsDiary"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Exit codes 0, 1 and 2 from Execute in the generated main; migrate without a DSN prints database.dsn on stderr and exits 1"
|
||||
requirement: D-18
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "examples/hello/hello_test.go#TestBuiltBinaryUnknownCommandExitsNonZero"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "examples/hello/hello_test.go#TestMigrateWithoutDSNFailsLoudly"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Per-command help in the Artisan layout with defaults, repeatable marker, long help and examples; help <command> equals <command> --help"
|
||||
requirement: D-14
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/bonfire/help_smoke_test.go#TestHelpSmoke"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "modules/bonfire/example_test.go#ExampleExample"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Hidden commands, list --all and list --format=json (Listing); CheckDocs reports documentation gaps"
|
||||
requirement: D-16
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/bonfire/help_smoke_test.go#TestListJSONSmoke"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "modules/bonfire/example_test.go#ExampleCheckDocs"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Error block: hint and Try with {bin}, -v chain, Reported silent, unknown command suggestion, scrubbed Bearer token"
|
||||
requirement: D-18
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/bonfire/help_smoke_test.go#TestErrorRenderSmoke"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "modules/bonfire/example_test.go#ExampleWithHint"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D7
|
||||
description: "Duplicate command names and reserved global flags rejected at boot; todo closed"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/bonfire/list_smoke_test.go#TestListTracerSmoke"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D8
|
||||
description: "Degradation matrix: -q keeps data and errors, -n returns defaults and Secret fails with a hint on a terminal stdin, ConfirmProceed never asks off a terminal, non-TTY bytes unchanged; ported models render frames"
|
||||
requirement: D-03
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/bonfire/widgets_smoke_test.go#TestWidgetMatrixSmoke"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "modules/bonfire/output_test.go#TestNonTTYSpinner, TestNonTTYProgress, TestNonTTYTable, TestColorPolicy; prompts_test.go#TestSecretReadsPlainStdinLine, TestClosedStdinDoesNotHang (unchanged files)"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "go test -race -short ./modules/bonfire/... ./modules/diary/..."
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D9
|
||||
description: "Live spinner, progress bar, process view, huh prompts and the arrow-key selector look and behave like SSU on a real terminal; diary log lines print above a running view"
|
||||
requirement: D-01
|
||||
verification:
|
||||
- kind: manual_procedural
|
||||
ref: "throwaway program run under script(1) on a pty: spinner, progress, Process with diary logs, table; exit 0, logs above the view"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "Animation, colors and keyboard interaction on a real terminal cannot be asserted by the short suite; the pty run only showed no hang and correct ordering. Plan 4 adds goldens; a person should try Choice/Ask/Secret on a terminal."
|
||||
|
||||
duration: 28min
|
||||
completed: 2026-10-21
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 19.8 Plan 02: bonfire console layer Summary
|
||||
|
||||
**bonfire reaches the Artisan bar: global -v/-q/--ansi/-n/--env flags that reach diary before plugins boot, the grouped list and per-command help with examples, a JSON catalog and CheckDocs, a styled error block with Try hints and exit codes 0/1/2, duplicate-name rejection, and SSU's spinner, progress, process view and selector on charm v2 that fall back to today's plain lines, with the Output interface unchanged.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 28 min
|
||||
- **Started:** 2026-10-21T10:02:41Z
|
||||
- **Completed:** 2026-10-21T10:31:06Z
|
||||
- **Tasks:** 3
|
||||
- **Files modified:** 38 (3,505 insertions, 353 deletions)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Tracer: `./bin/hello` prints `SummerCMS hello <version> (framework devel)`, usage, the D-13 global option lines and the commands grouped by namespace; `-vv list` works; an unknown command exits 2; a missing DSN exits 1 with `database.dsn` on stderr.
|
||||
- Generated main and `summer`: `bonfire.PeekGlobals` first, `diary.Bootstrap(os.Stderr, diary.FromGlobals(g))`, `compass.Open(compass.Options{Dir: "config", Env: g.Env})`, `bonfire.Fail` for errors before the root exists, `bonfire.NewRoot(..., bonfire.WithVersion(bonfire.BuildVersion()), bonfire.OnGlobals(diary.ApplyGlobals))`, `os.Exit(...)` of `bonfire.Execute`.
|
||||
- Help: Description, Usage, Arguments, Options (`[default: "*"]`, `[default: 90]`, "(multiple values allowed)"), Global options, Help, Examples; `help` command replaces cobra's.
|
||||
- JSON catalog (`list --format=json`, `bonfire.Describe`), `Hidden` with `list --all`, `bonfire.CheckDocs`.
|
||||
- Error block: ` ERROR ` badge, scrubbed message, explanation and Try lines from `WithHint` (`{bin}` replaced), cobra suggestions as Try, "Run with -v for details." or the `Caused by:` chain at `-v`; `Reported` prints nothing.
|
||||
- Widgets: SSU's spinner, gradient progress bar and process view ported to bubbletea v2; lipgloss boxed tables; huh Ask/Confirm/Secret and a single-select selector with SSU's confirmation screen; `-q`, `-n`, `--no-ansi` and non-TTY degrade to plain lines; `ConfirmProceed`, `Process`, `Current`, `Failed`, `PrintAbove`; diary's styled log lines print above a running view.
|
||||
- Docs: `docs/console/introduction.md` (list, global options, exit codes, help commands), `docs/console/writing-commands.md` (duplicates, Examples and hidden commands, Errors and hints, Documentation coverage, the degradation table, ConfirmProceed and Process), `docs/services/logging.md` (log lines above live views); bonfire and diary READMEs.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1: tracer (global flags, list, exit codes)** - `8d4d7ce` feat(bonfire): global flags, Artisan list and exit codes
|
||||
2. **Folded todo** - `80fae5c` docs(19.8): close the duplicate command names todo
|
||||
3. **Task 2: help, examples, JSON listing, error hints** - `fb32886` feat(bonfire): Artisan help, examples, JSON listing and error hints
|
||||
4. **Task 3: widgets on charm v2** - `3f38862` feat(bonfire): SSU widgets on charm v2 behind the Output interface
|
||||
|
||||
`commits: 5` is measured from the ledger (`b7c0759..3f38862`); one of them, `cb38c49` docs(notes), belongs to another session that committed in between. This plan made four commits before this SUMMARY.
|
||||
|
||||
The tracer feedback gate re-ran Task 1's automated verify after the commit (all green, no human check), so expansion continued.
|
||||
|
||||
## Charm versions installed
|
||||
|
||||
`charm.land/fang/v2` v2.0.1 (Task 1), `charm.land/bubbletea/v2` v2.1.0, `charm.land/bubbles/v2` v2.2.1 and `charm.land/huh/v2` v2.0.3 (Task 3), the versions of `.planning/notes/2026-10-21-console-charm-stack.md`. `go mod tidy` also raised `charmbracelet/ultraviolet`, `x/ansi` (v0.11.9) and `mattn/go-runewidth` (v0.0.30) as the new modules require, and pulled fang's man-page helpers (`muesli/mango*`, `muesli/roff`) as indirect requirements. Nothing imports `gopkg.in/yaml.v3`.
|
||||
|
||||
## Exported names for plan 3
|
||||
|
||||
- **Commands and help:** `Command.Examples []Example`, `Command.Hidden bool`, `Example{Command, Description}`; `CheckDocs(commands []Command) []DocProblem`, `DocProblem{Command, Missing}` with `String()`; `Describe(name, commands, v) Listing`, `Listing`, `ApplicationInfo`, `CommandInfo`, `ArgumentInfo`, `OptionInfo`, `ExampleInfo`.
|
||||
- **Errors:** `WithHint(err, explanation, try) error`, `HintError{Err, Explanation, Try}`, `Reported(err) error`, `ErrReported`, `Usage(err) error`, `UsageError{Err}`, `RenderError(w, name, err, g)`, `Fail(w, name, err, g) int`, `Execute(ctx, root, args) int`, `ExitOK`, `ExitFailure`, `ExitUsage`, `ErrDuplicateCommand`, `ErrReservedFlag`.
|
||||
- **Globals:** `Globals{Verbosity, Quiet, Color, NoInteraction, Env}`, `PeekGlobals(args)`, `(Globals).Args()`, `RootOption`, `WithVersion`, `OnGlobals`, `Version{App, Framework, Local}`, `BuildVersion()`, `(Version).Header(name)`; diary `FromGlobals`, `ApplyGlobals`.
|
||||
- **Output helpers:** `ConfirmProceed(out, question) (bool, error)`, `Process(ctx, out, operation, items, fn) error`, `GlobalsOf`, `VerbosityOf`, `IsQuiet`, `Interactive`, `Current`, `Failed`, `ItemProgress`, `PrintAbove`, `ErrAborted`.
|
||||
|
||||
All names match the plan. `summer dev`'s child binary can get the verbosity forwarded with `bonfire.GlobalsOf(out).Args()`.
|
||||
|
||||
## g15office
|
||||
|
||||
Task u4rtpcl1 (the duplicate command names todo) was **not** moved to Live: no g15office tool is available in this executor session. The todo is closed locally (`80fae5c`); the orchestrator or the user should move u4rtpcl1 to Live with a comment such as "Done in Phase 19.8 plan 02 (8d4d7ce): bonfire.NewRoot rejects duplicate command names".
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See `key-decisions` in the front matter. The two worth a second look:
|
||||
|
||||
- **Error stream.** The error block goes to stderr in real binaries (`NewRoot` sets the cobra error stream to `os.Stderr` when its output is `os.Stdout`, as the old `main` printed errors there). `Output.Error` still writes where it did before (stdout for `NewRoot`), so no command's output moves between streams.
|
||||
- **`--no-ansi` prompts.** On an interactive terminal, `--no-ansi` keeps prompts working but uses the plain line prompts instead of huh and the selector. `-n` and `-q` are the switches that turn prompts into defaults.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 2 - Correctness] Error block on stderr**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** `NewRoot(name, commands, out)` passed `out` as the error stream, so fang's error handler would have printed errors on stdout in every binary, against D-18 ("on stderr") and the plan's own `TestMigrateWithoutDSNFailsLoudly` (stderr must name `database.dsn`).
|
||||
- **Fix:** `NewRoot` calls `root.SetErr(os.Stderr)` when `out` is the process stdout; the console's `Output.Error` stream is unchanged.
|
||||
- **Files modified:** modules/bonfire/root.go
|
||||
- **Commit:** 8d4d7ce
|
||||
|
||||
**2. [Rule 1 - Bug] Re-executed roots kept the previous run's globals**
|
||||
- **Found during:** Task 1 (smoke test)
|
||||
- **Issue:** pflag count flags add to their current value, so a root executed twice kept `-vvv`.
|
||||
- **Fix:** `Execute` resets the root's persistent flags to their defaults before each run. Command-local flags still persist between executions of one root (pflag behaviour, unchanged); the Task 2 smoke tests build a fresh root per run.
|
||||
- **Files modified:** modules/bonfire/globals.go, modules/bonfire/execute.go, modules/bonfire/help_smoke_test.go
|
||||
- **Commits:** 8d4d7ce, fb32886
|
||||
|
||||
**3. [Rule 3 - Blocking] Go fences in docs need runnable `src=` examples**
|
||||
- **Found during:** Task 2 (docs checker)
|
||||
- **Fix:** added `ExampleExample`, `ExampleWithHint` and `ExampleCheckDocs` to `modules/bonfire/example_test.go` (with `// Output:`) and referenced them from `docs/console/writing-commands.md`; `summer docs:sync` filled the copies.
|
||||
- **Commit:** fb32886
|
||||
|
||||
**4. [Rule 2 - Docs checker] Commands of the docs checker**
|
||||
- **Found during:** Task 1
|
||||
- **Fix:** `TestCommandWord` cases for the global flags (`bin/acme -v serve`, `--env staging`, `--env=dev`, `summer -q --no-ansi -n list`) and `TestDocsCommandNames` wants for `list` and `help`. Files not in the plan's list: `internal/docsite/checks_test.go`, `cmd/summer/docs_test.go`.
|
||||
- **Commit:** 8d4d7ce
|
||||
|
||||
**5. [Rule 2 - Correctness] Output printed during a live view**
|
||||
- **Found during:** Task 3 (pty run)
|
||||
- **Issue:** the plan routes only diary lines through `PrintAbove`; a command that prints with `Println`, a status line or a table inside `Spinner` (the README's own example does) would tear the view.
|
||||
- **Fix:** while a live view runs, complete lines for stdout print above it; a widget started inside a running view prints plain lines (one live view at a time).
|
||||
- **Files modified:** modules/bonfire/output.go, modules/bonfire/live.go
|
||||
- **Commit:** 3f38862
|
||||
|
||||
**6. [Interpretation - D-03] `--no-ansi` on a terminal uses the line prompts**
|
||||
- The plan defines interactive as "stdin and stdout are terminals and not noInteraction", which would run huh under `--no-ansi`. D-03 lists `--no-ansi` among the cases with no styled widgets, so huh and the selector need `Color != ColorNever` too; the line prompts (unchanged code path) serve `--no-ansi`.
|
||||
- **Commit:** 3f38862
|
||||
|
||||
### Recorded differences
|
||||
|
||||
**7. Acceptance check `go list -deps ./modules/bonfire | grep -cE '^charm.land/(bubbletea|bubbles|huh|fang|lipgloss)/v2$'` prints 4, not 5.** The check counts packages; `charm.land/bubbles/v2` has only a doc package at its root, and bonfire imports `bubbles/v2/spinner`, `bubbles/v2/progress` and `bubbles/v2/key` (as the plan asks). Counted per module (`go list -deps -f '{{.Module.Path}}' ./modules/bonfire | sort -u`), the result is 5. No blank import was added to satisfy the literal grep.
|
||||
|
||||
**8. examples/hello go.mod/go.sum unchanged.** As in plan 01, the example modules build and test inside the repository's go.work without changes (deferred-items.md, "examples/hello go.mod is stale outside the workspace").
|
||||
|
||||
**9. `TestMigrateWithoutDSNFailsLoudly` builds the binary** (`runSummerBuild`) to check stderr, so it takes about half a second more.
|
||||
|
||||
**10. Builtin `list` and `help` carry `Long` and `Examples`** (not specified), so plan 3's framework `CheckDocs` test can cover them without exceptions.
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 6 auto-fixed or interpreted (2 correctness, 1 bug, 1 blocking, 1 docs checker, 1 D-03 interpretation), 4 recorded differences. **Impact:** no change to the plan's exported names; the Output, Input and Progress interfaces are byte-identical to the plan's base commit.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None beyond the deviations. `go test -count=1 -short ./...` was green after every task (no wristband flake this time).
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None beyond the plan's threat model. The only new terminal input path is huh and the selector, which run only when stdin and stdout are terminals, `-n`/`-q` are off and `--no-ansi` is not set (T-19.8-08); live views never read stdin (T-19.8-11); error text is scrubbed and the chain shows only at `-v` (T-19.8-07).
|
||||
|
||||
## Verification
|
||||
|
||||
- `go vet ./...`: clean.
|
||||
- `go test -count=1 -short ./...`: green after each task.
|
||||
- `go test -count=1 -race -short ./modules/bonfire/... ./modules/diary/...`: green.
|
||||
- `go -C examples/hello test -count=1 ./...`: green.
|
||||
- By name: `TestListTracerSmoke`, `TestHelpSmoke`, `TestListJSONSmoke`, `TestErrorRenderSmoke`, `TestHelpUsesSharedAdapter`, `TestWidgetMatrixSmoke`, `TestNonTTYSpinner`, `TestNonTTYProgress`, `TestNonTTYTable`, `TestColorPolicy`, `TestSecretReadsPlainStdinLine`, `TestClosedStdinDoesNotHang`, `TestBuiltBinaryListsCommands`, `TestHelpListsServe`, `TestBuiltBinaryUnknownCommandExitsNonZero`: `--- PASS`.
|
||||
- `TestDocsTree` and `summer docs:build --check`: no problems.
|
||||
- Acceptance criteria of all three tasks: pass, except the package-count check recorded above (module count 5).
|
||||
- `modules/bonfire/output_test.go` and `prompts_test.go`: no commits since `cdddaa3`.
|
||||
- No consuming application named in changed files. Nothing pushed or tagged.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
Ready for 19.8-03: commands can use `Examples`, `Hidden`, `WithHint`, `Reported`, `Usage`, `ConfirmProceed`, `Process` and `GlobalsOf`; the framework coverage test can call `bonfire.CheckDocs`. Before the sweep's apps boot with the new main, Pitfall 15 still applies: build each app and run `bin/<app> list` to catch duplicate command names. A person should try the live widgets and prompts on a real terminal (coverage D9).
|
||||
|
||||
## Self-Check: PASSED
|
||||
710
.planning/phases/19.8-console-commands-overhaul/19.8-03-PLAN.md
Normal file
710
.planning/phases/19.8-console-commands-overhaul/19.8-03-PLAN.md
Normal file
@@ -0,0 +1,710 @@
|
||||
---
|
||||
phase: 19.8-console-commands-overhaul
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["19.8-01", "19.8-02"]
|
||||
files_modified:
|
||||
- modules/surf/serve.go
|
||||
- modules/surf/summary.go
|
||||
- modules/surf/accesslog.go
|
||||
- modules/surf/routelist_command.go
|
||||
- modules/surf/serve_summary_smoke_test.go
|
||||
- modules/surf/serve_lifecycle_test.go
|
||||
- modules/surf/README.md
|
||||
- modules/lagoon/migrations.go
|
||||
- modules/lagoon/commands.go
|
||||
- modules/lagoon/keygen.go
|
||||
- modules/lagoon/connection.go
|
||||
- modules/lagoon/README.md
|
||||
- modules/conga/worker.go
|
||||
- modules/conga/commands.go
|
||||
- modules/conga/README.md
|
||||
- modules/cabana/commands.go
|
||||
- modules/cabana/token_commands.go
|
||||
- modules/cabana/README.md
|
||||
- modules/lighthouse/centrifugo/commands.go
|
||||
- modules/lighthouse/README.md
|
||||
- modules/flare/commands.go
|
||||
- modules/flare/README.md
|
||||
- modules/bonfire/errors.go
|
||||
- modules/bonfire/README.md
|
||||
- modules/diary/diary.go
|
||||
- modules/diary/console.go
|
||||
- modules/diary/README.md
|
||||
- internal/dev/watch.go
|
||||
- internal/dev/watch_test.go
|
||||
- internal/build/manifest.go
|
||||
- internal/build/artifact.go
|
||||
- internal/build/scaffold.go
|
||||
- cmd/summer/main.go
|
||||
- cmd/summer/runtime.go
|
||||
- cmd/summer/parity.go
|
||||
- cmd/summer/docs.go
|
||||
- cmd/summer/commands_documented_test.go
|
||||
- examples/hello/plugins/greeter/plugin.go
|
||||
- examples/hello/hello_test.go
|
||||
- docs/examples/blog/console/publish.go
|
||||
- docs/console/introduction.md
|
||||
- docs/console/setup-and-maintenance.md
|
||||
- docs/console/scaffolding.md
|
||||
- docs/console/utilities.md
|
||||
- docs/console/writing-commands.md
|
||||
- docs/services/logging.md
|
||||
- docs/setup/upgrading.md
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app/i18n/locales/en.json
|
||||
- ../sm-summercmsio-app/vue-summercmsio-app
|
||||
- ../sm-demo-app/plugins/demo/roadmap/curated.go
|
||||
- ../sm-demo-app/plugins/demo/content/ROADMAP.md
|
||||
- ../sm-paymentgateway-plugin/console/pg_cancel_created_payments.go
|
||||
- ../sm-paymentgateway-plugin/console/pg_cancel_sent_payments.go
|
||||
- ../sm-paymentgateway-plugin/console/pg_cancel_placed_orders.go
|
||||
- ../sm-paymentgateway-plugin/console/pg_finish_orders.go
|
||||
- ../sm-paymentgateway-plugin/console/pg_update_currencies.go
|
||||
- ../sm-paymentgateway-plugin/console/import_winter.go
|
||||
- ../sm-paymentgateway-plugin/admin_settings.go
|
||||
- ../sm-paymentgateway-plugin/api/actions.go
|
||||
- ../sm-paymentgateway-plugin/api/errors.go
|
||||
- ../sm-paymentgateway-plugin/boot.go
|
||||
- ../sm-paymentgateway-plugin/controllers/orders.go
|
||||
- ../sm-paymentgateway-plugin/currency/fixer.go
|
||||
- ../sm-paymentgateway-plugin/events/mailers.go
|
||||
- ../sm-paymentgateway-plugin/orders/bridge.go
|
||||
- ../sm-paymentgateway-plugin/orders/service.go
|
||||
- ../sm-paymentgateway-plugin/payments/service.go
|
||||
- ../sm-paymentgateway-plugin/commands_documented_test.go
|
||||
- ../sm-paymentgateway-plugin/README.md
|
||||
- ../sm-paymentgateway-plugin/go.mod
|
||||
- ../sm-paymentgateway-plugin/go.sum
|
||||
- ../sm-pgstripe-plugin/console/import_winter.go
|
||||
- ../sm-pgstripe-plugin/operator.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/gateway.go
|
||||
- ../sm-pgstripe-plugin/subscriptions/plugin.go
|
||||
- ../sm-pgstripe-plugin/commands_documented_test.go
|
||||
- ../sm-pgstripe-plugin/README.md
|
||||
- ../sm-pgstripe-plugin/go.mod
|
||||
- ../sm-pgstripe-plugin/go.sum
|
||||
- ../sm-oauthserver-plugin/console/prune.go
|
||||
- ../sm-oauthserver-plugin/console/rotate_keys.go
|
||||
- ../sm-oauthserver-plugin/console/import_winter.go
|
||||
- ../sm-oauthserver-plugin/classes/branding.go
|
||||
- ../sm-oauthserver-plugin/classes/claims.go
|
||||
- ../sm-oauthserver-plugin/classes/host.go
|
||||
- ../sm-oauthserver-plugin/commands_documented_test.go
|
||||
- ../sm-oauthserver-plugin/README.md
|
||||
- ../sm-oauthserver-plugin/go.mod
|
||||
- ../sm-oauthserver-plugin/go.sum
|
||||
- ../sm-journal-plugin/console/export_posts.go
|
||||
- ../sm-journal-plugin/console/import_posts.go
|
||||
- ../sm-journal-plugin/plugin.go
|
||||
- ../sm-journal-plugin/search.go
|
||||
- ../sm-journal-plugin/controllers/api/authors.go
|
||||
- ../sm-journal-plugin/controllers/api/posts_search.go
|
||||
- ../sm-journal-plugin/commands_documented_test.go
|
||||
- ../sm-journal-plugin/README.md
|
||||
- ../sm-journal-plugin/go.mod
|
||||
- ../sm-journal-plugin/go.sum
|
||||
- ../sm-sitemap-plugin/console/import.go
|
||||
- ../sm-sitemap-plugin/console/import_winter.go
|
||||
- ../sm-sitemap-plugin/classes/manifest.go
|
||||
- ../sm-sitemap-plugin/routes.go
|
||||
- ../sm-sitemap-plugin/commands_documented_test.go
|
||||
- ../sm-sitemap-plugin/README.md
|
||||
- ../sm-sitemap-plugin/go.mod
|
||||
- ../sm-sitemap-plugin/go.sum
|
||||
- ../sm-faq-plugin/console/import_winter.go
|
||||
- ../sm-faq-plugin/controllers/api/faqs.go
|
||||
- ../sm-faq-plugin/commands_documented_test.go
|
||||
- ../sm-faq-plugin/README.md
|
||||
- ../sm-faq-plugin/go.mod
|
||||
- ../sm-faq-plugin/go.sum
|
||||
- ../sm-menu-plugin/console/import_winter.go
|
||||
- ../sm-menu-plugin/classes/tree.go
|
||||
- ../sm-menu-plugin/controllers/api/menus.go
|
||||
- ../sm-menu-plugin/commands_documented_test.go
|
||||
- ../sm-menu-plugin/README.md
|
||||
- ../sm-menu-plugin/go.mod
|
||||
- ../sm-menu-plugin/go.sum
|
||||
- ../sm-chat-plugin/console/rewrite_context_types.go
|
||||
- ../sm-chat-plugin/api/errors.go
|
||||
- ../sm-chat-plugin/classes/service.go
|
||||
- ../sm-chat-plugin/jobs/maintain_thread_summary.go
|
||||
- ../sm-chat-plugin/commands_documented_test.go
|
||||
- ../sm-chat-plugin/README.md
|
||||
- ../sm-chat-plugin/go.mod
|
||||
- ../sm-chat-plugin/go.sum
|
||||
- ../sm-chatchannels-plugin/console/rewrite_context_types.go
|
||||
- ../sm-chatchannels-plugin/api/errors.go
|
||||
- ../sm-chatchannels-plugin/boot.go
|
||||
- ../sm-chatchannels-plugin/classes/channel_service.go
|
||||
- ../sm-chatchannels-plugin/classes/search_indexer.go
|
||||
- ../sm-chatchannels-plugin/commands_documented_test.go
|
||||
- ../sm-chatchannels-plugin/README.md
|
||||
- ../sm-chatchannels-plugin/go.mod
|
||||
- ../sm-chatchannels-plugin/go.sum
|
||||
- ../sm-chatvideo-plugin/console/reconcile.go
|
||||
- ../sm-chatvideo-plugin/api/errors.go
|
||||
- ../sm-chatvideo-plugin/classes/notifier.go
|
||||
- ../sm-chatvideo-plugin/classes/user_deletion.go
|
||||
- ../sm-chatvideo-plugin/commands_documented_test.go
|
||||
- ../sm-chatvideo-plugin/README.md
|
||||
- ../sm-chatvideo-plugin/go.mod
|
||||
- ../sm-chatvideo-plugin/go.sum
|
||||
- ../sm-forum-plugin/console/rewrite_context_types.go
|
||||
- ../sm-forum-plugin/api/errors.go
|
||||
- ../sm-forum-plugin/boot.go
|
||||
- ../sm-forum-plugin/classes/forum_service.go
|
||||
- ../sm-forum-plugin/commands_documented_test.go
|
||||
- ../sm-forum-plugin/README.md
|
||||
- ../sm-forum-plugin/go.mod
|
||||
- ../sm-forum-plugin/go.sum
|
||||
- ../sm-herald-plugin/console/vapid_generate.go
|
||||
- ../sm-herald-plugin/api/errors.go
|
||||
- ../sm-herald-plugin/boot.go
|
||||
- ../sm-herald-plugin/classes/sender.go
|
||||
- ../sm-herald-plugin/commands_documented_test.go
|
||||
- ../sm-herald-plugin/README.md
|
||||
- ../sm-herald-plugin/go.mod
|
||||
- ../sm-herald-plugin/go.sum
|
||||
- ../fonoteka.go/plugins/golem15/golem/console/import_settings.go
|
||||
- ../fonoteka.go/plugins/golem15/golem/commands_documented_test.go
|
||||
- ../fonoteka.go/plugins/golem15/golem/README.md
|
||||
- ../fonoteka.go/plugins/golem15/golem/go.mod
|
||||
- ../fonoteka.go/plugins/golem15/golem/go.sum
|
||||
- ../fonoteka.go/plugins/golem15/feedback/console/import_settings.go
|
||||
- ../fonoteka.go/plugins/golem15/feedback/controllers/api/feedback_api_controller.go
|
||||
- ../fonoteka.go/plugins/golem15/feedback/jobs.go
|
||||
- ../fonoteka.go/plugins/golem15/feedback/commands_documented_test.go
|
||||
- ../fonoteka.go/plugins/golem15/feedback/README.md
|
||||
- ../fonoteka.go/plugins/golem15/feedback/go.mod
|
||||
- ../fonoteka.go/plugins/golem15/feedback/go.sum
|
||||
- ../fonoteka.go/plugins/golem15/user/console/api_tokens.go
|
||||
- ../fonoteka.go/plugins/golem15/user/console/backfill_handles.go
|
||||
- ../fonoteka.go/plugins/golem15/user/console/require_password_change.go
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/admin_actions.go
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/authorizer.go
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/avatar.go
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/session_user.go
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/registration.go
|
||||
- ../fonoteka.go/plugins/golem15/user/plugin.go
|
||||
- ../fonoteka.go/plugins/golem15/user/commands_documented_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/README.md
|
||||
- ../fonoteka.go/plugins/golem15/user/go.mod
|
||||
- ../fonoteka.go/plugins/golem15/user/go.sum
|
||||
- ../sm-demo-app/plugins/golem15/user
|
||||
- ../sm-demo-app/plugins/golem15/journal
|
||||
- ../sm-demo-app/plugins/golem15/sitemap
|
||||
- ../sm-demo-app/plugins/golem15/menu
|
||||
- ../sm-demo-app/plugins/golem15/faq
|
||||
- ../sm-demo-app/plugins/golem15/paymentgateway
|
||||
- ../sm-demo-app/plugins/golem15/pgstripe
|
||||
- ../sm-demo-app/plugins/golem15/oauthserver
|
||||
- ../sm-demo-app/plugins/golem15/golem
|
||||
- ../sm-demo-app/plugins/golem15/feedback
|
||||
- ../sm-demo-app/main.go
|
||||
- ../sm-bm-app/plugins/golem15/user
|
||||
- ../sm-bm-app/main.go
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/user
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/sitemap
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/menu
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/faq
|
||||
- ../sm-grzybyfunkcjonalne-app/main.go
|
||||
- ../sm-summercmsio-app/plugins/golem15/user
|
||||
- ../sm-summercmsio-app/plugins/golem15/journal
|
||||
- ../sm-summercmsio-app/main.go
|
||||
- ../fonoteka.go/plugins/golem15/user
|
||||
- ../fonoteka.go/plugins/golem15/golem
|
||||
- ../fonoteka.go/plugins/golem15/feedback
|
||||
- ../fonoteka.go/main.go
|
||||
- .planning/todos/pending/user-plugin-console-sweep.md
|
||||
- .planning/G15OFFICE.md
|
||||
autonomous: false
|
||||
requirements: [SC-3, SC-4, SC-5, D-04, D-05, D-06, D-07, D-11, D-17, D-18, D-19, D-20, D-21, D-22, D-23, D-24, D-25, D-26, D-27, D-28, D-29]
|
||||
user_setup: []
|
||||
|
||||
estimate:
|
||||
tokens: 320000
|
||||
raw_tokens: 320000
|
||||
tasks: 8
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-04, D-05, D-06: at default verbosity serve prints a compact aligned startup block on a terminal (app and framework version, environment, URL and admin URL, database host and migration state 'up to date' or 'N pending', plugin count and names, queue, scheduler, mail, cache, realtime and search drivers) followed by INFO logs restyled by diary; -v adds one line per plugin; -q shows only warnings and errors; off a terminal the same facts arrive as an INFO record 'startup summary' and the record 'listening on <addr>' keeps its exact text for the applications' smoke scripts."
|
||||
- "D-07, D-29: on Ctrl+C or SIGTERM, serve, queue:work and summer dev print a short report (draining, requests drained or aborted, worker job counts done, failed and handed back to the queue, uptime, drain errors) and exit 0; a drain error exits 1."
|
||||
- "D-11, D-23: every request produces one INFO record 'http request' from component http with method, the matched route pattern (never the raw path, query or headers), status, duration_ms and request_id (X-Request-Id, at most 128 bytes); it is on by default, components.http: warn or http.access_log: false silences it."
|
||||
- "D-25: summer dev starts bin/<app> serve with the global flags it was given and restarts it on rebuild."
|
||||
- "D-17, D-21, D-22: every command in summercms.go (modules, cmd/summer, internal, examples/hello's greeter and docs/examples/blog) has a description, long help and examples, and cmd/summer's TestCommandsDocumented fails when bonfire.CheckDocs reports any problem; each command's Artisan checklist row in this plan is implemented as recorded (added --force, --pretend and --json flags, progress, result lines), and declined rows say why."
|
||||
- "D-18, D-19: known failures end in a hint block: running an application binary from a directory without config/ below an application root suggests 'cd <rel>; bin/<app> <args>'; an empty DSN, an unreachable database, pending migrations, a missing app.key or uploads bucket each name the command that fixes them; summer make:* outside an application suggests 'cd <app>; summer make:...' (folded todo, messages only); flare and docs commands that already printed their problems return bonfire.Reported."
|
||||
- "D-27, D-28: key:generate, admin:token:create and herald:vapid-generate still print their data under -q; migrate, migrate:rollback, queue:clear and deferred:purge ask for confirmation only in production on an interactive terminal, --force skips it, and runs off a terminal (deploys over SSH, the scheduler) proceed as today; existing --force meanings in the importers and pg:update-currencies are unchanged."
|
||||
- "D-20, D-24, D-26: no framework tag or push happens before the recorded go-ahead; after it the framework is tagged and pushed with a same-day changelog post, then each shared plugin (the D-20 list plus sm-golem-plugin and sm-feedback-plugin) gets exactly one commit (description, long help, examples, named loggers, output polish, a warn-only TestCommandsDocumented, require of the new framework tag) pushed before its applications get separate pointer-bump commits; no command name or flag is removed or renamed. When Phase 19.5 still holds the user plugin, sm-user-plugin is deferred to .planning/todos/pending/user-plugin-console-sweep.md with a g15office task, and the SUMMARY and verification report D-20 as partial."
|
||||
- "SC-5: module READMEs, docs/console pages, docs/services/logging.md, docs/setup/upgrading.md, the summercms.io landing copy and the roadmap deck in sm-demo-app describe the new console."
|
||||
artifacts:
|
||||
- path: "modules/surf/summary.go"
|
||||
provides: "startup summary and shutdown report builders, request counters"
|
||||
contains: "listening on "
|
||||
- path: "modules/lagoon/migrations.go"
|
||||
provides: "PendingMigrations"
|
||||
contains: "func PendingMigrations("
|
||||
- path: "modules/conga/worker.go"
|
||||
provides: "Worker.Stats for the shutdown report"
|
||||
contains: "func (w *Worker) Stats() WorkerStats"
|
||||
- path: "cmd/summer/commands_documented_test.go"
|
||||
provides: "framework documentation coverage gate (fails)"
|
||||
contains: "CheckDocs"
|
||||
- path: "internal/dev/watch.go"
|
||||
provides: "summer dev runs serve and reports on shutdown"
|
||||
contains: "\"serve\""
|
||||
key_links:
|
||||
- from: "modules/surf/accesslog.go"
|
||||
to: "modules/diary/diary.go"
|
||||
via: "request log is the http component"
|
||||
pattern: "diary.For\\(\"http\"\\)"
|
||||
- from: "modules/surf/serve.go"
|
||||
to: "modules/lagoon/migrations.go"
|
||||
via: "startup summary reports pending migrations"
|
||||
pattern: "PendingMigrations\\("
|
||||
- from: "cmd/summer/commands_documented_test.go"
|
||||
to: "modules/bonfire/docs.go"
|
||||
via: "framework commands must pass CheckDocs"
|
||||
pattern: "bonfire.CheckDocs\\("
|
||||
prohibitions:
|
||||
- statement: "No command name or existing flag in summercms.go or any shared plugin is removed, renamed or given a different meaning; new flags are additive."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed, tagged, posted to the changelog or deployed before the Task 6 reply allows it, and nothing is ever force-pushed or deployed."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The startup summary, hints and logs never print a DSN password, an app key, a token or a secret."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Framework code, READMEs and docs name no consuming application."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented, see plan 01): console commands reach the Artisan bar; a user sees what a command did, can ask for more or less of it, and learns from `--help` how to use it.
|
||||
|
||||
This plan's slice (plan 3 of 4, wave 3): the sweep. The tracer takes `serve` all the way: help and examples, the startup summary as a block and as log records, the `http` request log by route pattern, the shutdown report and exit 0. Then the other long-running commands (`queue:work`, `schedule:run`, `summer dev` running `serve`), the application binary's one-shot commands with the Artisan checklist and the D-19 hints, the `summer` tool's commands with the failing framework coverage gate, and the landing site and roadmap deck. A human checkpoint (D-26) stands before anything leaves the machine. After the go-ahead: tag and push the framework with the changelog post, sweep and push the shared plugins one commit each, and bump the applications' pointers in separate commits.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Spec-less probe fallback skipped: no requirement IDs (phase_req_ids null) and no SPEC.md; visible choice. `must_haves` cite SC-3..SC-5 and D-04..D-29.
|
||||
|
||||
## Decisions taken in this plan (recorded assumptions and Claude's discretion)
|
||||
|
||||
- Confirmation rule (D-21 with D-28, following Artisan's ConfirmableTrait): migrate, migrate:rollback, queue:clear and deferred:purge ask only when the environment is `production` and the Output is interactive (`bonfire.ConfirmProceed`); they gain `--force` to skip the question. Everywhere else they behave as today.
|
||||
- `--pretend` (Artisan's name) is added where it can be read-only without touching the command's write path: migrate (lists pending migration IDs per plugin), migrate:rollback (names the migration that would roll back), deferred:purge (counts expired bindings past the cut-off with one read query). The importers already have `--dry-run`; no alias is added (RESEARCH Pitfall 11). Bulk writers whose writes run through a service (paymentgateway's cancel and finish commands, the rewrite-context-types commands, oauthserver:prune, chatvideo:reconcile-sessions, user:backfill-handles) are recorded as declined: a faithful dry run needs a dry mode inside the service, which is command logic and outside this phase (CONTEXT: command logic does not change).
|
||||
- On a terminal the startup summary is a block on stdout and its log records are tagged with `diary.HideOnTTY(ctx)` so the styled console does not print the same facts twice; daily files and non-terminal consoles keep the records (D-05). Under `-q` the block is not printed.
|
||||
- The database line prints host, port and database name parsed with pgconn.ParseConfig, never the user, password or full DSN; a DSN that does not parse shows "configured".
|
||||
- In-flight jobs at stop are reported as "handed back to the queue" after the executor confirms in River's source that StopAndCancel leaves cancelled jobs retryable (RESEARCH A2); if River discards them, the wording becomes "cancelled".
|
||||
- The framework release version is chosen at the Task 6 checkpoint; the recommendation is the next minor above the latest tag at that time (a new module, new dependencies, new config keys and a new generated main). `docs/setup/upgrading.md` gets a section for that version.
|
||||
- The user plugin's checkout (`../fonoteka.go/plugins/golem15/user`) is shared with Phase 19.5, which is executing. It is swept last in Task 8, only when 19.5-04-SUMMARY.md exists and the checkout is clean and in sync with origin; otherwise it is deferred: Task 8 files `.planning/todos/pending/user-plugin-console-sweep.md` with a g15office task (subtask of the Phase 19.8 milestone task), the SUMMARY and the verification report mark D-20 as partial and name both, and sm-bm-app, which mounts only the user plugin, is left untouched and listed in the todo (no breaking change either way).
|
||||
- Plugin descriptions are normalised to imperative sentence case without a trailing period and without planning IDs ("(D-15)", "(D-21)"); names and flags stay.
|
||||
|
||||
## Execution segments (fresh-context resume at the Task 6 checkpoint)
|
||||
|
||||
The phase keeps 4 plans (user-locked), so this plan's size is handled inside the plan: it runs in two executor contexts, split at the release checkpoint that already stops execution. The plugin sweep therefore starts on fresh context instead of last in a degraded one.
|
||||
|
||||
- Segment A: Tasks 1-5 and the Task 6 checkpoint (summercms.go, the landing repository, sm-demo-app; about 190k of the 320k estimate). Before returning the Task 6 checkpoint, the executor writes the `## Handoff` section (below) into the draft `19.8-03-SUMMARY.md` on disk. The draft is not committed here, so it adds nothing to the origin/master..master range shown at the checkpoint; the plan's final SUMMARY commit carries it.
|
||||
- Segment B: Tasks 7 and 8 (about 130k) in a fresh executor context. Under `/msd-execute-phase` this is the continuation agent spawned after the reply (never a resume of the segment A agent); in inline execution, `/clear` and resume the plan at Task 7. Segment B loads only: the handoff, this plan's Decisions, the shared-plugin rows of the Artisan checklist, Tasks 7 and 8, CONTEXT D-20 to D-28, CLAUDE.md, and RESEARCH "Apps that need pointer bumps" with Pitfalls 15 and 16. It does not reread the plan 01 and 02 SUMMARYs or the framework diffs; the handoff names the API it needs. Its first step checks the handoff against the repositories (Task 7 action).
|
||||
- Handoff contents (the draft SUMMARY's `## Handoff`): (1) segment A's commits per repository, hash and subject, and the summercms.go origin/master..master list shown at the checkpoint; (2) the framework API the plugin sweep uses, with the names as built (bonfire.Example and its fields, Command.Examples, Command.Hidden, the CheckDocs signature, Reported, WithHint, IsQuiet, the progress and Process helpers, diary.For); (3) the recommended tag and the upgrading section heading; (4) for each plugin repository and application: path, branch, HEAD, uncommitted paths, origin/master..master count; (5) Docker tests run or NOT RUN; (6) whether 19.5-04-SUMMARY.md exists and the user plugin checkout's state; (7) deviations from this plan so far. After the reply the continuation adds the "Task 6 reply" verbatim; Task 7 appends its results before Task 8 starts, so Task 8 can also resume from the handoff alone.
|
||||
|
||||
## Artisan checklist (D-21), per command
|
||||
|
||||
Columns: Force = confirmation with `--force`; Pretend = `--pretend` or an existing dry run; Json = `--json`; Prog = progress bar, process view or table; Result = result line and exit code. "add" means this plan implements it; "–" means it does not fit, with the reason in Notes. Every command also gets Long and Examples (D-17).
|
||||
|
||||
Framework, application binary (Task 1 to Task 3):
|
||||
|
||||
| Command | Force | Pretend | Json | Prog | Result | Notes |
|
||||
|---|---|---|---|---|---|---|
|
||||
| serve | – | – | – | startup summary, shutdown report | exit 0 graceful, 1 drain error | long-running (Task 1) |
|
||||
| queue:work | – | – | – | summary, report | exit 0 graceful, 1 drain error | long-running (Task 2) |
|
||||
| schedule:run | – | – | – | summary, report | `--once`: "Ran N due entries (M failed)", exit code unchanged from today | long-running (Task 2) |
|
||||
| migrate | add (production + TTY) | add | – | one line per plugin with applied count | "Applied N migrations" or "Nothing to migrate" | deploys over SSH never prompt |
|
||||
| migrate:rollback | add (production + TTY) | add | – | – | "Rolled back <id> of <plugin>" | |
|
||||
| migrate:status | – read-only | – | add | table kept | – | |
|
||||
| deferred:purge | add (production + TTY) | add (count only) | – | – | existing purge line | |
|
||||
| key:generate | – | – | add | – | key on stdout, bare key under -q (D-27) | non-TTY output unchanged |
|
||||
| queue:clear | add (production + TTY) | – | – | – | "Cleared N jobs from <queue>" | description loses its trailing period |
|
||||
| route:list | – | – | add | table kept | – | |
|
||||
| admin:create | – | – | – | – | existing success line | prompt rules from plan 02 (piped password kept) |
|
||||
| admin:reset-password | – | – | – | – | existing | |
|
||||
| admin:token:create | – | – | add | – | token on stdout (data, kept under -q) | |
|
||||
| admin:token:list | – | – | add | table kept | – | |
|
||||
| admin:token:revoke | – single explicit id | – | – | – | existing | |
|
||||
| admin:token:scopes | – | – | add | table kept | – | |
|
||||
| websockets:health | – | – | add | – | exit 1 when unhealthy | |
|
||||
| websockets:generate-vapid-keys | – | – | – | – | Reported replaces the print-then-sentinel error | flags unchanged |
|
||||
| websockets:test-push | – | – | – | – | Reported | Confirm default stays true under -n |
|
||||
| greeter:hello (examples/hello) | – | – | – | – | – | Long, Examples |
|
||||
| blog:publish (docs/examples/blog) | – | – | – | – | existing | Long, Examples |
|
||||
|
||||
Framework, summer tool (Task 2 and Task 4):
|
||||
|
||||
| Command | Force | Pretend | Json | Prog | Result | Notes |
|
||||
|---|---|---|---|---|---|---|
|
||||
| build | – | – | – | spinner around go build on a terminal | "Built bin/<name> in <d>" | |
|
||||
| make:plugin, make:model, make:migration, make:command, make:job, make:admin-controller | – | – | – | – | "Created <path>" per file | D-19 hints (folded todo, messages only) |
|
||||
| plugin:add | – | – | – | – | result line | hint when not in an application |
|
||||
| new | – | – | – | – | existing next steps; writes config/log.yaml (plan 01) | |
|
||||
| dev | – | – | – | header, rebuild lines, shutdown report | exit 0 on Ctrl+C | runs serve (D-25) |
|
||||
| parity:proxy, parity:record, parity:replay, parity:broadcasts, parity:upstream | – | – | – | – | existing | Long, Examples; defaults shown by help |
|
||||
| docs:build, docs:sync | – | `--check` exists | – | – | Reported after the problem list | |
|
||||
| docs:serve | – | – | – | – | exit 0 on Ctrl+C | |
|
||||
| 15 delegates (migrate … admin:token:scopes) | – the app binary decides | – | – | – | the app binary's exit code | Examples per delegate row |
|
||||
|
||||
Shared plugins (Tasks 7 and 8; existing flags unchanged):
|
||||
|
||||
| Command | Force | Pretend | Json | Prog | Result | Notes |
|
||||
|---|---|---|---|---|---|---|
|
||||
| pg:cancel-created-payments, pg:cancel-sent-payments, pg:cancel-placed-orders, pg:finish-orders | – scheduled state transitions | – declined (service dry mode needed) | – | – | "Cancelled N payments" / "Cancelled N orders" / "Finished N orders" | scheduled daily, run through Call |
|
||||
| pg:update-currencies | – (`--force` keeps "force update") | – | – | – | "Updated N currencies" | every 10 minutes |
|
||||
| paymentgateway:import-winter, pgstripe:import-winter, oauthserver:import-winter, sitemap:import-winter, faq:import-winter, menu:import-winter | – (`--force` keeps "replace") | already `--dry-run` | – | – | existing summary lines, final result line | |
|
||||
| oauthserver:prune | – | – declined | – | – | "Pruned N codes, M tokens, K keys" | scheduled daily |
|
||||
| oauthserver:rotate-keys | – | – | – | – | "Promoted key <kid>" | |
|
||||
| journal:export-posts, journal:import-posts | – (`--force` keeps "update existing") | already `--dry-run` | – | add progress over files | final counts line | |
|
||||
| sitemap:import | – | – | – | – | "Imported N URLs" | |
|
||||
| chat:, chatchannels:, forum:rewrite-context-types | – idempotent | – declined | – | – | "Rewrote N rows" | descriptions lose "(idempotent)." wording into Long |
|
||||
| chatvideo:reconcile-sessions | – | – declined | – | – | "Ended N sessions" | |
|
||||
| herald:vapid-generate | – | – | add | – | env lines on stdout (data, kept under -q) | |
|
||||
| golem:import-settings, feedback:import-settings | – (`--force` keeps its meaning) | – | – | – | result line | |
|
||||
| user:token:create | – | – | add | – | token on stdout (data) | |
|
||||
| user:token:list | – | – | add | table kept | – | |
|
||||
| user:token:revoke, user:require-password-change | – | – | – | – | result line | planning IDs removed from descriptions |
|
||||
| user:backfill-handles | – | – declined | – | add progress | "Assigned N handles" | |
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- surf: unexported summary and report builders and a request counter in `modules/surf/summary.go`; request log on component `http` with `request_id`; `http.access_log` defaults to on; smoke `TestServeSummarySmoke`, `TestShutdownReportSmoke`; Docker test `TestServeLifecycle`; `route:list --json`.
|
||||
- lagoon: `MigrationRef{Plugin, ID string}`, `PendingMigrations(gdb *gorm.DB, plugins []party.Plugin) ([]MigrationRef, error)`; flags `migrate --pretend --force`, `migrate:rollback --pretend --force`, `migrate:status --json`, `deferred:purge --pretend --force`, `key:generate --json`; hinted errors for an empty DSN and an unreachable database.
|
||||
- conga: `WorkerStats{Done, Failed, InFlight int64}`, `(*Worker).Stats() WorkerStats`; `queue:clear --force`; queue:work and schedule:run summary and report.
|
||||
- cabana: `admin:token:create --json`, `admin:token:list --json`, `admin:token:scopes --json`. lighthouse: `websockets:health --json`. flare: Reported errors.
|
||||
- diary: `HideOnTTY(ctx context.Context) context.Context`.
|
||||
- bonfire: wrong-directory hint for application binaries inside RenderError and Fail.
|
||||
- internal/dev: `Watch(ctx context.Context, appDir string, out bonfire.Output, args []string) error` runs `bin/<app> serve <args>` and prints a report; `startBin` passes `serve`.
|
||||
- cmd/summer: `TestCommandsDocumented` (fails on any CheckDocs problem); Examples for every tool command and delegate.
|
||||
- Each shared plugin repo: `commands_documented_test.go` with a warn-only `TestCommandsDocumented`; a package-level `diary.For("<code>")` logger; go.mod requires the released framework tag.
|
||||
- Landing copy (`features.console`, `winter.concepts.commands`) and roadmap deck text in sm-demo-app.
|
||||
|
||||
<objective>
|
||||
Sweep every command to the Artisan bar (help, examples, checklist, result lines, hints), give the long-running commands a startup summary and a shutdown report, move the request log onto the http component, describe it all in the docs, landing site and roadmap deck, then release in D-26 order after a human go-ahead.
|
||||
|
||||
Purpose: SC-3 (coverage gate), SC-4, SC-5 and D-04..D-07, D-11, D-17..D-29.
|
||||
Output: framework commits per task with READMEs and docs, local landing and deck commits, the recorded checkpoint reply, the tag and changelog post, one commit per shared plugin, pointer-bump and regenerate commits per application.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-CONTEXT.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-RESEARCH.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-PATTERNS.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-01-SUMMARY.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-02-SUMMARY.md
|
||||
|
||||
Repos and concurrency: Tasks 1-4 write in summercms.go only; Task 5 in ../sm-summercmsio-app (and its vue-summercmsio-app submodule) and ../sm-demo-app; Tasks 7-8 in the shared plugin repos and the applications. Before each task run `git status --porcelain` in every repository the task touches and `git log --oneline origin/master..master` (after `git fetch origin`) in each plugin and application; stop and report when a path this plan edits is modified by someone else or a repository holds commits this plan did not make (except summercms.go master, whose unpushed commits are listed at the checkpoint). Superprojects (sm-demo-app, sm-bm-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app, fonoteka.go) often carry other sessions' pointer changes: stage only the submodule paths and files this plan names. Stage by explicit path; no co-author tags; planning docs and code in separate commits; READMEs and docs in the same commit as the change they describe; `go vet ./...`, `go test -short ./...` and `go -C examples/hello test ./...` green at every framework commit. Docker-backed tests skip under -short; on a Docker host run them, through `sudo -u golem` if the socket is denied (CLAUDE.md); a Docker test that could not run is NOT RUN in the SUMMARY, never passed.
|
||||
|
||||
<interfaces>
|
||||
- plan 02 API: `bonfire.Example`, `Command.Examples`, `Command.Hidden`, `WithHint`, `Reported`, `Usage`, `ConfirmProceed`, `Process`, `Current`, `GlobalsOf`, `IsQuiet`, `Interactive`, `Globals.Args`, `CheckDocs`, `RenderError`, `Fail`; plan 01 API: `diary.For`, `diary.Bootstrap`, `diary.Verbosity`, `diary.NewConsoleHandler`.
|
||||
- modules/surf/serve.go:28-122 ServeCommand (NotifyContext, OpenFromApp, Publish, publishUploads, installCooler, BuildRouter, compile, net.Listen, StartServeWorker, banner, http.Server, 10 s shutdown, stopWorker), serveURL :178, countAdmins :212, serveBanner :243 (`"listening on " + addr`), printBanner :273; modules/surf/serve_test.go TestServeBanner.
|
||||
- modules/surf/accesslog.go: accessLogKey "http.access_log", accessLogMessage "http request", accessEntry{pattern, panicked}, accessLogEnabled :38, the doc comment :42-60 (why paths are never logged), attrs :74-79, accessLogger :101-108; modules/surf/errorpage.go errorPageMaxRequestID = 128 and its request-id trimming.
|
||||
- modules/lagoon/migrations.go: Migrate :64, RollbackLast :121, lastMigrationPlugin :156, StatusRow :169, Status :176; modules/lagoon/commands.go migrate :26, rollback :39, status :60, deferred:purge :82 (PurgeDeferred, PurgeResult{Bindings, Files, Children, Skipped}); modules/lagoon/keygen.go :24-37; modules/lagoon/connection.go:22-24 empty-DSN error, :30-32 ping error.
|
||||
- modules/conga/worker.go: Worker.Queues :44, StartWorker :63, StartServeWorker :163, Stop :172 (StopAndCancel after the deadline), Manager.runAttempt :243; modules/conga/commands.go queue:work :31, schedule:run :45, queue:clear :64, work :84, runScheduler :100.
|
||||
- modules/cabana/token_commands.go:21-60 and :117 (`out.Println(plain)` for the token); modules/cabana/commands.go:21-45; modules/lighthouse/centrifugo/commands.go:31; modules/flare/commands.go:20-21 (errGenerateFailed, errTestPushFailed), :50, :212.
|
||||
- internal/dev/watch.go: Options{Build, Start, Debounce} :21, Watch :29, rebuild output via fmt.Fprintf :79-88, startBin :201 (`exec.Command(binPath)`); internal/dev/watch_test.go fakes Start.
|
||||
- cmd/summer/main.go: toolCommands :28, make* :65-211, resolveMakeArgs :220, printArtifact :245, devCommand :255, addPluginCommand :269, newCommand :296; cmd/summer/runtime.go delegateCommands table :26-44 and delegateCommand :55; cmd/summer/docs.go docs:build/sync/serve and reportDocsProblems :168 (`errors.New(cmd + " failed")`); cmd/summer/parity.go flags; internal/build/manifest.go:215 (`build: no summer.yaml found from`).
|
||||
- examples/hello/plugins/greeter/plugin.go:80 greeter:hello; docs/examples/blog/console/publish.go:18 blog:publish.
|
||||
- Sibling repos: plugin constructors in each ../sm-*-plugin/console/*.go (analog ../sm-sitemap-plugin/console/import.go:27-45); the user, golem and feedback plugins under ../fonoteka.go/plugins/golem15/; application submodules per RESEARCH "Apps that need pointer bumps"; ../sm-summercmsio-app/scripts/changelog-post.py and DEPLOY.md ("Posting to the changelog"); ../sm-demo-app/scripts/sync-roadmap.sh and README "Roadmap deck"; ../sm-summercmsio-app/vue-summercmsio-app/i18n/locales/en.json (`features.console`, `winter.concepts.commands`).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: serve shows what it booted, logs each request by route pattern under the http component, reports what it did on Ctrl+C and exits 0, with help, examples and docs</name>
|
||||
<reversibility rating="costly">D-23 flips the request log to on by default and its record shape (component http, request_id) becomes what operators grep and ship to log stores.</reversibility>
|
||||
<files>modules/surf/serve.go, modules/surf/summary.go, modules/surf/accesslog.go, modules/surf/serve_summary_smoke_test.go, modules/surf/serve_lifecycle_test.go, modules/surf/README.md, modules/lagoon/migrations.go, modules/lagoon/README.md, modules/conga/worker.go, modules/conga/README.md, modules/diary/diary.go, modules/diary/console.go, modules/diary/README.md, docs/console/setup-and-maintenance.md, docs/services/logging.md</files>
|
||||
<read_first>modules/surf/serve.go, modules/surf/accesslog.go, modules/surf/accesslog_test.go (TestAccessLogSmoke), modules/surf/serve_test.go, modules/surf/errorpage.go (request id cap), modules/lagoon/migrations.go, modules/conga/worker.go, modules/conga/postgres_test.go (testcontainers Postgres setup to copy), modules/cooler/cooler.go (CacheDriverKey), modules/postcard/mailer.go (mail.driver default), modules/beachcomber/beachcomber.go (search.driver), modules/lighthouse/lighthouse.go (realtime.driver), modules/conga/conga.go (queue.work_in_serve, queue.queues), modules/diary/console.go, docs/console/setup-and-maintenance.md ("The HTTP server"), docs/services/logging.md ("Request log"), ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh (:292-306, 2>&1 and the "listening on" grep), 19.8-RESEARCH.md (Pattern 6, Pitfall 9), 19.8-CONTEXT.md (D-04..D-07, D-23, D-29)</read_first>
|
||||
<action>Per D-04..D-07, D-11, D-23 and D-29.
|
||||
|
||||
lagoon/migrations.go: `type MigrationRef struct { Plugin, ID string }` and `PendingMigrations(gdb, plugins)` returning each plugin's declared migration IDs that its history table does not hold, in plugin and declaration order (reuse Status's table reading; a missing history table means all pending). README API row.
|
||||
|
||||
conga/worker.go: `type WorkerStats struct { Done, Failed, InFlight int64 }`; atomic counters updated in Manager.runAttempt (InFlight up on entry and down on exit; Done on a nil error, Failed otherwise); `(*Worker).Stats()` returns them. Confirm in River's source how StopAndCancel leaves cancelled jobs (RESEARCH A2) and word the report accordingly. README API row.
|
||||
|
||||
diary: `HideOnTTY(ctx)` returns a context carrying a marker; a console channel that renders styled lines on a terminal skips records whose context carries it; every other channel and a non-terminal console write them. README row.
|
||||
|
||||
surf/summary.go (new, unexported): a request counter wrapping the compiled handler (atomic in-flight and served counts, independent of the access-log setting); `summaryFacts` collected once after binding: app name (app.name) and `bonfire.BuildVersion()`, environment (`app.Config.Environment()`), URL (serveURL) and admin URL with admin presence (existing countAdmins), database host, port and name from pgconn.ParseConfig of database.dsn (never user or password; "configured" when it does not parse), pending migrations from lagoon.PendingMigrations ("up to date" or "N pending — run bin/<app> migrate"), plugins (count and IDs), queue (worker queues or "off"), scheduler (in serve or off, as conga runs it), mail (mail.driver), cache (cooler.CacheDriverKey), realtime (realtime.driver), search (search.driver), each defaulting the way its module does; a block renderer (8 to 12 aligned label/value lines in the bonfire palette; with verbosity 1 or more one extra line per plugin with its ID, required plugins and pending migration count) and a records emitter (one INFO record "startup summary" from `diary.For("serve")` with one attribute per fact, and one INFO record whose message is exactly `listening on <addr>`); a report builder (draining, in-flight requests drained or aborted at the deadline, served total, worker Done/Failed/InFlight, uptime, drain errors) rendered as lines and as one INFO record "shutdown report".
|
||||
|
||||
surf/serve.go: wrap the handler with the counter; after the listener and worker start, on a non-quiet terminal (`!bonfire.IsQuiet(out)` and the console is a terminal) print the block and emit the records with `diary.HideOnTTY(ctx)`; otherwise emit the records only (the old ℹ banner lines and the separate "job worker started" Info line are folded into the summary). On ctx.Done (signal): print "Shutting down: draining N requests" (decoration, suppressed under -q), Shutdown with the 10 s deadline, stop the worker, print and log the report, and return nil when there were no drain errors (D-29), else the joined drain errors (exit 1). A server error unrelated to a signal keeps today's behaviour. Add Long (what serve boots, how it shuts down, exit codes) and Examples (`serve`, `serve --addr 127.0.0.1:8096`, `serve -v` for per-plugin details, `serve -q` for warnings and errors only).
|
||||
|
||||
surf/accesslog.go (D-23): logger is the app-published `*slog.Logger` when present, else `diary.For("http")`; attributes method, pattern, status, duration_ms, request_id (X-Request-Id trimmed and capped at 128 bytes, omitted when empty), client_gone as today; the URL path, query, headers and remote address stay out (keep the doc comment's reasoning and add D-23); accessLogEnabled returns true unless `http.access_log` is set to false. Update TestAccessLogSmoke expectations for request_id and the default.
|
||||
|
||||
Docs and READMEs in the same commit: docs/console/setup-and-maintenance.md "The HTTP server" (the summary block, what -v and -q change, the shutdown report, exit codes, the records off a terminal); docs/services/logging.md "Request log" (on by default at INFO under component http, fields, why the route pattern and never the path, silencing with `components: {http: warn}` or `http.access_log: false`); surf README (Features, Configuration default for http.access_log). Commit: `feat(surf): serve startup summary, shutdown report and http request log`.
|
||||
|
||||
Tests. serve_summary_smoke_test.go (-short): `TestServeSummarySmoke` builds the facts from a compass config with a DSN containing a password and checks the block lines (environment, URL, database host and name, "3 pending", plugin count, drivers) and the record attributes, that the password and user never appear, and that one record's message equals `listening on 127.0.0.1:8096`; `TestShutdownReportSmoke` renders the report for fixed counters and a drain error and checks each line. serve_lifecycle_test.go (Docker, skips under -short through a TestMain that calls flag.Parse and testing.Short, or the package's existing TestMain): `TestServeLifecycle` starts Postgres with testcontainers, runs ServeCommand's Run with `diary.Bootstrap` on a buffer, a file:// uploads bucket in t.TempDir and an addr on 127.0.0.1:0-free port, waits for the listener, sends one request with X-Request-Id, cancels the context, and asserts Run returned nil, the buffer holds `listening on 127.0.0.1:<port>`, "startup summary", one "http request" record with component http, pattern and request_id and without the request path's literal value, and "shutdown report" with served 1.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/surf/... ./modules/lagoon/... ./modules/conga/... ./modules/diary/... && go test -count=1 -short -run '^(TestServeSummarySmoke|TestShutdownReportSmoke|TestAccessLogSmoke|TestServeBanner)$' -v ./modules/surf 2>&1 | grep -c -- '^--- PASS: Test' | grep -qE '^[34]$' && go -C examples/hello test -count=1 ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit, or fewer than 3 top-level "--- PASS: Test" lines (the summary, report or access-log smoke failed or did not run; TestServeBanner may be removed if the banner builder is folded into the summary), or hello or the docs checker fail.</fails_when>
|
||||
<automated>go test -count=1 -run '^TestServeLifecycle$' -v ./modules/surf 2>&1 | grep -q -- '^--- PASS: TestServeLifecycle'</automated>
|
||||
<fails_when>No "--- PASS: TestServeLifecycle" line: the end-to-end serve run failed, or it printed "--- SKIP" because Docker was unreachable (rerun through sudo -u golem; otherwise record NOT RUN in the SUMMARY).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'diary.For("http")' modules/surf/accesslog.go` prints 1 and `grep -c '"request_id"' modules/surf/accesslog.go` prints at least 1.
|
||||
- `grep -cE 'r\.URL\.Path|req\.URL\.Path|RequestURI' modules/surf/accesslog.go` prints 0.
|
||||
- `grep -c 'func PendingMigrations(' modules/lagoon/migrations.go` prints 1 and `grep -c 'func (w \*Worker) Stats() WorkerStats' modules/conga/worker.go` prints 1.
|
||||
- `grep -c '"listening on "' modules/surf/summary.go` prints at least 1.
|
||||
- `grep -c 'Examples:' modules/surf/serve.go` prints at least 1 and `grep -c 'Long:' modules/surf/serve.go` prints at least 1.
|
||||
- `grep -c 'components' docs/services/logging.md` prints at least 1 and `grep -c 'request_id' docs/services/logging.md` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>An operator starting serve sees what booted and whether migrations are pending, sees each request by route, and on Ctrl+C gets a short report and exit code 0.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: queue:work and schedule:run report what booted and what they did; summer dev runs bin/<app> serve with the global flags and reports on Ctrl+C; docs:serve stops cleanly</name>
|
||||
<files>modules/conga/commands.go, modules/conga/README.md, internal/dev/watch.go, internal/dev/watch_test.go, cmd/summer/main.go, cmd/summer/docs.go, docs/console/setup-and-maintenance.md, docs/console/scaffolding.md, docs/console/utilities.md</files>
|
||||
<read_first>modules/conga/commands.go, modules/conga/worker.go (Stats from Task 1), modules/conga/scheduler.go, modules/surf/summary.go (Task 1 block and report helpers to mirror), internal/dev/watch.go, internal/dev/watch_test.go, cmd/summer/main.go (devCommand :255), cmd/summer/docs.go (docs:serve), docs/console/setup-and-maintenance.md ("Queues and the scheduler"), docs/console/scaffolding.md ("Building and watching"), docs/console/utilities.md, 19.8-RESEARCH.md (Pitfalls 13 and 14)</read_first>
|
||||
<action>Per D-07, D-22, D-25 and D-29.
|
||||
|
||||
conga/commands.go queue:work: after the worker starts, a summary (environment, queues with their concurrency, database host, scheduler included or not) as a block on a non-quiet terminal and as an INFO record "startup summary" from `diary.For("queue")` (HideOnTTY when the block was printed); on signal, the report from `Worker.Stats` (done, failed, handed back), uptime and stop errors as lines and an INFO record "shutdown report"; return nil on a clean stop (D-29). schedule:run: summary (entry count, the next due entry); report (entries run, failures, uptime); `--once` ends with "Ran N due entries (M failed)" and keeps today's error and exit behaviour. Long and Examples for queue:work (`queue:work`, `queue:work --queue mail --queue default`), schedule:run (`schedule:run`, `schedule:run --once` for cron) and queue:clear is left to Task 3.
|
||||
|
||||
internal/dev/watch.go (D-25): `Watch(ctx, appDir, out bonfire.Output, args []string) error`; startBin runs `exec.Command(binPath, append([]string{"serve"}, args...)...)` and Options.Start gains the args; the header ("Watching <dir>; running bin/<binary> serve"), rebuild lines ("Rebuilt in <d>", "Build failed: <err>" through out.Error, "Restarted bin/<binary>") go through Output instead of raw Fprintf; on ctx.Done stop the child with SIGTERM (it prints its own report), then print the dev report (rebuilds, failed builds, restarts, uptime) and return nil. cmd/summer/main.go devCommand: wrap ctx with signal.NotifyContext(os.Interrupt, syscall.SIGTERM) and pass `bonfire.GlobalsOf(out).Args()`; Long (what is watched, that it runs serve, flags forwarded) and Examples (`dev`, `dev -v`). watch_test.go: the fake Start asserts it receives "serve" followed by the forwarded args, and a cancelled context returns nil.
|
||||
|
||||
cmd/summer/docs.go docs:serve: Ctrl+C returns nil; Long and Examples.
|
||||
|
||||
Docs in the same commit: setup-and-maintenance.md "Queues and the scheduler" (summary and report, exit codes); scaffolding.md "Building and watching" (dev runs serve, forwards -v/-q, reports on Ctrl+C); utilities.md docs:serve line; conga README. Commit: `feat(conga,dev): worker and scheduler reports, summer dev runs serve`.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/conga/... ./internal/dev/... ./cmd/summer/... && go test -count=1 -short -v ./internal/dev 2>&1 | grep -q -- '^--- PASS' && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit (a conga, dev or summer test is red, for example the fake Start did not receive "serve"), no "--- PASS" line from internal/dev (no test ran), or the docs checker rejects the new pages.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'append(\[\]string{"serve"}, args...)' internal/dev/watch.go` prints 1.
|
||||
- `grep -cE 'fmt\.Fprintf\(out' internal/dev/watch.go` prints 0.
|
||||
- `grep -c 'GlobalsOf(out).Args()' cmd/summer/main.go` prints 1.
|
||||
- `grep -c 'shutdown report' modules/conga/commands.go` prints at least 1.
|
||||
- `grep -c 'serve' docs/console/scaffolding.md` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>Every long-running command tells the operator what it started and what it did, summer dev actually serves the application, and Ctrl+C ends each of them with exit code 0.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Application-binary commands meet their checklist rows (help, examples, --force, --pretend, --json, result lines) and known failures end in hints, including the wrong-directory hint</name>
|
||||
<files>modules/lagoon/commands.go, modules/lagoon/keygen.go, modules/lagoon/connection.go, modules/lagoon/README.md, modules/conga/commands.go, modules/surf/routelist_command.go, modules/surf/serve.go, modules/surf/README.md, modules/cabana/commands.go, modules/cabana/token_commands.go, modules/cabana/README.md, modules/lighthouse/centrifugo/commands.go, modules/lighthouse/README.md, modules/flare/commands.go, modules/flare/README.md, modules/bonfire/errors.go, modules/bonfire/README.md, examples/hello/plugins/greeter/plugin.go, examples/hello/hello_test.go, docs/examples/blog/console/publish.go, cmd/summer/commands_documented_test.go, docs/console/setup-and-maintenance.md, docs/console/writing-commands.md</files>
|
||||
<read_first>modules/lagoon/commands.go, modules/lagoon/keygen.go, modules/lagoon/connection.go, modules/conga/commands.go (queue:clear :64), modules/surf/routelist_command.go, modules/surf/serve.go (publishUploads error, app.key use), modules/cabana/commands.go, modules/cabana/token_commands.go, modules/cabana/commands_test.go (:241-262), modules/lighthouse/centrifugo/commands.go, modules/flare/commands.go, modules/bonfire/errors.go, cmd/summer/docs.go (docsCommands constructors to reuse in the test), examples/hello/plugins/greeter/plugin.go, docs/examples/blog/console/publish.go, docs/console/setup-and-maintenance.md, this plan's checklist table (application binary rows), 19.8-RESEARCH.md (Command Inventory, Code Examples "Error with hint", Pitfalls 10, 11 and 13)</read_first>
|
||||
<action>Implement the application-binary rows of this plan's checklist exactly; every command gets Long and Examples; names and existing flags stay.
|
||||
|
||||
lagoon: migrate gains `--pretend` (print each pending MigrationRef as "<plugin>: <id>" and "N migrations would run", apply nothing) and `--force` (Bare, "Skip the production confirmation"); before applying, when the environment is production and ConfirmProceed asks and gets no, return a Reported error "Migration cancelled."; result "Applied N migrations" (N from PendingMigrations before Migrate) or "Nothing to migrate". migrate:rollback: `--pretend` names the last applied migration of the plugin (from Status) and `--force` with the same confirmation rule; result "Rolled back <id> of <plugin>". migrate:status `--json` prints the rows as a JSON array of {plugin, id, applied_at}. deferred:purge `--pretend` reports the number of bindings older than the cut-off (one COUNT query with the same filter, nothing deleted) and `--force` with the confirmation rule. key:generate `--json` prints {"key": ...}; under -q (bonfire.IsQuiet) the key prints through Println; otherwise unchanged. connection.go: the empty-DSN error becomes WithHint(err, "The application reads database.dsn from config/database.yaml or SUMMER_DATABASE__DSN.", "cp .env.example .env") and the ping failure WithHint(err, "PostgreSQL is not reachable at the configured host.", "docker compose up -d --wait"). Missing uploads bucket and empty app.key errors in serve get hints naming config/storage.yaml and `{bin} key:generate`.
|
||||
|
||||
conga queue:clear: description "Clear pending jobs from a queue" (no trailing period), `--force` with the confirmation rule, result "Cleared N jobs from <queue>". surf route:list `--json` (array of {method, pattern, name, middleware} as the table shows). cabana: admin:token:create `--json` ({id, name, token, scopes, expires_at}; the plain token still prints through Println otherwise), admin:token:list `--json`, admin:token:scopes `--json`. lighthouse websockets:health `--json` ({healthy, ...} without the API key) and exit 1 when unhealthy (as today). flare: replace the print-then-sentinel errors (errGenerateFailed, errTestPushFailed) with `bonfire.Reported(...)` so the renderer adds no second message.
|
||||
|
||||
bonfire/errors.go (D-19, message only): for application binaries (root name other than summer), when a command fails or Fail is called, and the working directory has no `config` directory, and a parent directory holds `summer.yaml`, add a hint "This command runs from the application root." with Try `cd <relative path to that parent>; bin/<name> <original args>`; summer is unaffected (its commands walk up already). Database detection from the wrong directory is not changed.
|
||||
|
||||
Examples: greeter:hello (examples/hello) and blog:publish (docs/examples/blog) get Long and Examples; examples/hello/hello_test.go gains `TestGreeterDocumented` asserting bonfire.CheckDocs returns nothing for the greeter plugin's commands.
|
||||
|
||||
cmd/summer/commands_documented_test.go (D-17): `TestCommandsDocumented` builds the application command set the way docsCommands does (lagoon, key:generate, conga, surf serve and route:list, cabana, centrifugo, flare) plus blog.Publish, runs bonfire.CheckDocs and fails listing every problem. (Task 4 adds the tool commands.)
|
||||
|
||||
Docs and READMEs in the same commit(s): docs/console/setup-and-maintenance.md (new flags with examples, the production confirmation rule and --force, -q keeps keys and tokens), writing-commands.md (the wrong-directory hint), each touched module README's CLI commands section. Commits by module are fine (`feat(lagoon): checklist flags and hints for migration commands`, `feat(cabana,surf,conga): --json and result lines`, `feat(bonfire): wrong-directory hint`), each green.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./... && go test -count=1 -short -run '^TestCommandsDocumented$' -v ./cmd/summer 2>&1 | grep -q -- '^--- PASS: TestCommandsDocumented' && go -C examples/hello test -count=1 -run '^TestGreeterDocumented$' -v . 2>&1 | grep -q -- '^--- PASS: TestGreeterDocumented' && go -C examples/hello test -count=1 ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit; no "--- PASS: TestCommandsDocumented" (an application command lacks a description, long help, examples or a flag description, or the test did not run); no "--- PASS: TestGreeterDocumented"; or the WR-13 piped-password test, hello or the docs checker fail.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c '"pretend"' modules/lagoon/commands.go` prints at least 3 and `grep -c '"force"' modules/lagoon/commands.go` prints at least 3.
|
||||
- `grep -c 'ConfirmProceed' modules/lagoon/commands.go` prints at least 1 and `grep -c 'ConfirmProceed' modules/conga/commands.go` prints at least 1.
|
||||
- `grep -c '"json"' modules/cabana/token_commands.go` prints at least 3 and `grep -c '"json"' modules/surf/routelist_command.go` prints 1.
|
||||
- `grep -cE 'errGenerateFailed|errTestPushFailed' modules/flare/commands.go` prints 0 and `grep -c 'bonfire.Reported(' modules/flare/commands.go` prints at least 1.
|
||||
- `grep -c 'WithHint(' modules/lagoon/connection.go` prints 2.
|
||||
- `grep -c 'Clear all queued jobs, by deleting all pending jobs\.' modules/conga/commands.go` prints 0.
|
||||
- `grep -c 'bonfire.CheckDocs(' cmd/summer/commands_documented_test.go` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>Every application-binary command explains itself with examples, scripts get JSON where they read output, destructive commands ask only where a human is watching in production, and the common failures say exactly what to run next.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: summer tool commands meet their checklist rows and pass the failing coverage gate; make:* errors carry hints; the upgrading notes and console docs are complete</name>
|
||||
<files>cmd/summer/main.go, cmd/summer/runtime.go, cmd/summer/parity.go, cmd/summer/docs.go, cmd/summer/commands_documented_test.go, internal/build/manifest.go, internal/build/artifact.go, internal/build/scaffold.go, docs/console/introduction.md, docs/console/scaffolding.md, docs/console/utilities.md, docs/console/writing-commands.md, docs/setup/upgrading.md, modules/bonfire/README.md, modules/diary/README.md</files>
|
||||
<read_first>cmd/summer/main.go, cmd/summer/runtime.go, cmd/summer/parity.go, cmd/summer/docs.go, cmd/summer/main_test.go, cmd/summer/runtime_test.go, internal/build/manifest.go (:205-220), internal/build/artifact.go, internal/build/scaffold.go, .planning/todos/pending/scaffold-generated-header-and-command-deps.md, docs/console/introduction.md ("Commands that summer delegates"), docs/console/scaffolding.md, docs/console/utilities.md, docs/setup/upgrading.md ("Upgrading to 0.2" structure), this plan's checklist table (summer tool rows)</read_first>
|
||||
<action>Implement the summer tool rows of this plan's checklist; names and flags stay.
|
||||
|
||||
build: on a live console wrap the go build in out.Spinner; end with "Built bin/<name> in <duration>". make:plugin, make:model, make:migration, make:command, make:job, make:admin-controller: one "Created <path>" line per written file (printArtifact), Long (what is generated, where, how the registry picks it up, and for make:command the current limitation that the generated function takes no arguments, from the folded todo), Examples (`make:model acme.blog Post`, `make:command acme.blog publish` and so on). Hints (folded todo, partial, messages only; the scaffolder is unchanged): internal/build/manifest.go's "no summer.yaml found from <dir>" becomes WithHint(err, "summer make: commands run inside an application (a directory with summer.yaml).", "cd <app>; summer <command> <args>"); invalid plugin IDs and identifiers, an artifact that already exists and a plugin that is not in summer.yaml each get an explanation and a Try with a valid form. plugin:add and new: result lines and Examples. parity:* commands: Long (what each does in the parity workflow) and Examples; flag descriptions keep their "(true/false)" wording. docs:build and docs:sync: reportDocsProblems returns `bonfire.Reported(...)` after printing the problem list; Long and Examples. Delegates (runtime.go): the table gains an examples column, each delegate gets two Examples rendered with summer (for example `migrate`, `migrate --pretend`; `admin:token:create jane --scope journal.posts --json`), and Long adds that global flags pass through to the application binary.
|
||||
|
||||
cmd/summer/commands_documented_test.go: extend `TestCommandsDocumented` to `toolCommands()` as well; it fails on any problem.
|
||||
|
||||
docs/setup/upgrading.md: a section "Upgrading to 0.4" (Task 7 renames it if the user picks another version) with Boot refusals and Behaviour changes in the existing structure: run `summer build` to regenerate main.go; daily log files under storage/logs (directory must be writable, or set log.default to console); GORM lines move to component db and record-not-found is no longer logged; the request log is on by default (route pattern, silence with components.http: warn); duplicate command names and reserved global flag names stop the binary at boot; usage errors exit 2; -q keeps data output; summer dev runs serve; destructive commands confirm in production on a terminal (--force skips). docs/console/introduction.md "Commands that summer delegates" (examples and global flags), scaffolding.md (make:* results and hints), utilities.md (parity and docs examples), writing-commands.md (a complete example command with Long and Examples). Commit: `feat(summer): help, examples and hints for every tool command`.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./cmd/summer/... ./internal/build/... && go test -count=1 -short -run '^TestCommandsDocumented$' -v ./cmd/summer 2>&1 | grep -q -- '^--- PASS: TestCommandsDocumented' && go test -count=1 -short ./... && go -C examples/hello test -count=1 ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit, or no "--- PASS: TestCommandsDocumented" (a tool or delegate command lacks a description, long help or examples), or the build, summer, hello or docs checks fail.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'toolCommands()' cmd/summer/commands_documented_test.go` prints at least 1.
|
||||
- `grep -c 'WithHint(' internal/build/manifest.go` prints at least 1.
|
||||
- `grep -c 'bonfire.Reported(' cmd/summer/docs.go` prints at least 1 and `grep -c 'errors.New(cmd + " failed")' cmd/summer/docs.go` prints 0.
|
||||
- `grep -c '^## Upgrading to 0.4' docs/setup/upgrading.md` prints 1 and the section matches each of `storage/logs`, `summer build`, `components`, `exit` (checked with grep -c on the file, each at least 1).
|
||||
- `go run ./cmd/summer list --format=json | python3 -c 'import json,sys; d=json.load(sys.stdin); bad=[c["name"] for c in d["commands"] if not c["examples"] and c["name"] not in ("help","list")]; sys.exit(1 if bad else 0)'` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>summer's own commands, including every delegate, explain themselves with examples, make:* failures say where to run them, and the upgrading page lists every behaviour change of this phase.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: The summercms.io landing copy and the roadmap deck in sm-demo-app describe the new console (local commits, nothing pushed or deployed)</name>
|
||||
<files>../sm-summercmsio-app/vue-summercmsio-app/i18n/locales/en.json, ../sm-summercmsio-app/vue-summercmsio-app, ../sm-demo-app/plugins/demo/roadmap/curated.go, ../sm-demo-app/plugins/demo/content/ROADMAP.md</files>
|
||||
<read_first>../sm-summercmsio-app/vue-summercmsio-app/i18n/locales/en.json (features.console :117-120, winter.concepts.commands :139), ../sm-summercmsio-app/vue-summercmsio-app/app/components/FeaturesSection.vue, ../sm-summercmsio-app/vue-summercmsio-app/app/data/terminal.json, ../sm-summercmsio-app/terminal_check_test.go, ../sm-summercmsio-app/CLAUDE.md, ../sm-demo-app/README.md ("Roadmap deck"), ../sm-demo-app/plugins/demo/roadmap/curated.go (console feature lines near :104 and :145), ../sm-demo-app/plugins/demo/roadmap/roadmap_test.go, ../sm-demo-app/scripts/sync-roadmap.sh</read_first>
|
||||
<action>Per SC-5. Landing (vue-summercmsio-app): `features.console.body` names the new console in one sentence of the same length class (Artisan-style list and help with examples, -v and -q on every command, daily log files); `winter.concepts.commands` keeps "Artisan commands" on the Winter side and shows "bonfire.Command + summer list" (or similar short text) on the Summer side. terminal.json stays unchanged (its commands still work). Run `npm --prefix ../sm-summercmsio-app/vue-summercmsio-app run test`. Commit in vue-summercmsio-app (`feat: describe the Artisan-style console`), then in sm-summercmsio-app stage only the `vue-summercmsio-app` submodule path and commit the pointer (`chore: bump vue-summercmsio-app for console copy`).
|
||||
|
||||
Roadmap deck (sm-demo-app): in plugins/demo/roadmap/curated.go update the console feature text (EN and PL) to name the Artisan-style list and help, global flags, the diary logging with daily files and the startup summary; refresh the snapshot with `scripts/sync-roadmap.sh` (from framework master as of now) and run `go -C ../sm-demo-app test ./plugins/demo/roadmap/...` (TestRoadmapNoClientNames, TestRoadmapPolishTitles must pass). Commit in sm-demo-app staging only those two paths (`feat(demo): roadmap deck describes the new console`). Nothing is pushed or deployed here; Task 7 or 8 pushes under the Task 6 reply.</action>
|
||||
<verify>
|
||||
<automated>npm --prefix ../sm-summercmsio-app/vue-summercmsio-app run test && go -C ../sm-summercmsio-app test -count=1 -run '^(TestLoadTerminal|TestTerminalScript|TestTerminalEnv)$' . && go -C ../sm-demo-app test -count=1 ./plugins/demo/roadmap/... && ../sm-demo-app/scripts/sync-roadmap.sh --check</automated>
|
||||
<fails_when>Non-zero exit: a landing node test fails, the terminal data no longer loads, a roadmap test fails (for example a client name slipped into the deck text), or sync-roadmap --check reports the snapshot stale.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'Scaffolding, scheduler, workers and your own commands.' ../sm-summercmsio-app/vue-summercmsio-app/i18n/locales/en.json` prints 0.
|
||||
- `L=$(git -C ../sm-summercmsio-app/vue-summercmsio-app log --oneline -1 -- i18n/locales/en.json) && test -n "$L"` exits 0 and `S=$(git -C ../sm-summercmsio-app status --porcelain -- vue-summercmsio-app) && test -z "$S"` exits 0 (pointer committed).
|
||||
- `S=$(git -C ../sm-demo-app status --porcelain -- plugins/demo/roadmap plugins/demo/content/ROADMAP.md) && test -z "$S"` exits 0.
|
||||
- `B=$(git -C ../sm-summercmsio-app/vue-summercmsio-app branch -r --contains HEAD) && test -z "$B"` exits 0 (not pushed yet).
|
||||
</acceptance_criteria>
|
||||
<done>The landing copy and the roadmap deck describe the new console, committed locally and ready to publish with the release.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 6: Go-ahead for the release in D-26 order: framework tag, push and changelog post, then the shared plugins' commits and pushes, then the applications' pointer bumps</name>
|
||||
<read_first>19.8-CONTEXT.md (D-20, D-24, D-26), this plan's "Execution segments" section (handoff contents), this plan's SUMMARY draft (Tasks 1-5), CLAUDE.md (Commits and concurrency, the changelog rule), ../sm-summercmsio-app/DEPLOY.md ("Posting to the changelog"), .planning/phases/19.5-social-login-in-sm-user-plugin/ (whether 19.5-04-SUMMARY.md exists)</read_first>
|
||||
<acceptance_criteria>
|
||||
- Before the checkpoint is returned, the draft SUMMARY holds the handoff (items 1-7 of "Execution segments"): `grep -c '^## Handoff' .planning/phases/19.8-console-commands-overhaul/19.8-03-SUMMARY.md` prints 1.
|
||||
- The SUMMARY records the reply verbatim under a "Task 6 reply" heading, and the chosen tag when the reply is a release.
|
||||
- Before the reply, `T=$(git tag --points-at HEAD) && test -z "$T"` exits 0 in summercms.go and no plugin repository has a commit from this plan (`git -C ../sm-journal-plugin log --oneline origin/master..master` lists none of this phase's subjects).
|
||||
</acceptance_criteria>
|
||||
<decision>May the phase publish now, and with which framework tag?</decision>
|
||||
<context>Tasks 1-5 are committed locally and green (the SUMMARY draft lists the commits, the Docker results and anything NOT RUN). The shared plugins cannot be swept until a framework tag exists, because each plugin's single commit (D-20) requires the tag that carries bonfire.Example and diary. Facts to weigh: summercms.go master is ahead of origin/master by this phase's commits plus other sessions' unpushed commits (at planning time 20, mostly Phase 19.5); pushing master publishes all of them, and the tag needs a same-day changelog post (scripts/changelog-post.py in ../sm-summercmsio-app; the token file must exist). The latest tag at planning time was v0.3.1; a new module, new dependencies, new config keys and a new generated main suggest the next minor (v0.4.0), unless another phase tagged in between. After the tag: 15 plugin repositories get one commit each and are pushed (sm-user-plugin only if Phase 19.5 has finished with it; otherwise it is deferred to a tracked todo and g15office task and D-20 is reported partial); then sm-demo-app, sm-bm-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app and fonoteka.go get pointer-bump commits and a regenerated main.go; the landing and deck commits from Task 5 go out with their repositories. Nothing is deployed in any option. This checkpoint ends segment A: the handoff is written to the SUMMARY draft before it returns, and after the reply Tasks 7 and 8 run in a fresh executor context from that handoff (see Execution segments).</context>
|
||||
<options>
|
||||
<option id="release">
|
||||
<name>release <tag> (for example "release v0.4.0")</name>
|
||||
<pros>D-26 exactly: tag, push, changelog, then each plugin pinned to the tag and pushed, then the applications' commits pushed; the deck and landing copy are published with their repositories (deploys stay manual).</pros>
|
||||
<cons>Publishes the other sessions' unpushed framework commits with the tag; the changelog notes are written now.</cons>
|
||||
</option>
|
||||
<option id="release-keep-apps-local">
|
||||
<name>release-keep-apps-local <tag></name>
|
||||
<pros>Framework and plugins are published as in "release"; the applications' pointer-bump and regenerate commits are made but stay local for you to push with your next deploy of each application.</pros>
|
||||
<cons>Applications keep building against the old plugin pointers on other machines until you push them.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>hold</name>
|
||||
<pros>Nothing leaves this machine.</pros>
|
||||
<cons>The plugin sweep (D-20, D-24) cannot start, so SC-3 and SC-4 stay unproven for the plugins; the phase pauses after Task 5. The local Task 5 commits (the sm-demo-app deck, whose snapshot was synced from unpushed framework master, and the landing copy) describe a console that is not released; the SUMMARY says so and they stay unpushed until a release.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "release vX.Y.Z", "release-keep-apps-local vX.Y.Z" or "hold". A release reply also authorises pushing each plugin repository after its single commit and, for "release", pushing each application, landing and deck repository whose unpushed commits are this phase's; nothing is ever force-pushed or deployed. The continuation runs Tasks 7 and 8 in a fresh context from the SUMMARY draft's handoff.</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 7: Under the Task 6 reply, tag and push the framework with the changelog post, then sweep and push paymentgateway, pgstripe, oauthserver, journal, sitemap, faq and menu, one commit each pinned to the tag</name>
|
||||
<precondition>19.8-03-SUMMARY.md (draft) holds the `## Handoff` section from segment A and records the Task 6 reply "release vX.Y.Z" or "release-keep-apps-local vX.Y.Z"; on "hold" this task records "release held", notes that the local Task 5 deck and landing commits describe an unreleased console, and does nothing else.</precondition>
|
||||
<files>docs/setup/upgrading.md, ../sm-paymentgateway-plugin/console/pg_cancel_created_payments.go, ../sm-paymentgateway-plugin/console/pg_cancel_sent_payments.go, ../sm-paymentgateway-plugin/console/pg_cancel_placed_orders.go, ../sm-paymentgateway-plugin/console/pg_finish_orders.go, ../sm-paymentgateway-plugin/console/pg_update_currencies.go, ../sm-paymentgateway-plugin/console/import_winter.go, ../sm-paymentgateway-plugin/commands_documented_test.go, ../sm-paymentgateway-plugin/README.md, ../sm-paymentgateway-plugin/go.mod, ../sm-paymentgateway-plugin/go.sum, ../sm-pgstripe-plugin/console/import_winter.go, ../sm-pgstripe-plugin/commands_documented_test.go, ../sm-pgstripe-plugin/README.md, ../sm-pgstripe-plugin/go.mod, ../sm-oauthserver-plugin/console/prune.go, ../sm-oauthserver-plugin/console/rotate_keys.go, ../sm-oauthserver-plugin/console/import_winter.go, ../sm-oauthserver-plugin/commands_documented_test.go, ../sm-oauthserver-plugin/README.md, ../sm-oauthserver-plugin/go.mod, ../sm-journal-plugin/console/export_posts.go, ../sm-journal-plugin/console/import_posts.go, ../sm-journal-plugin/commands_documented_test.go, ../sm-journal-plugin/README.md, ../sm-journal-plugin/go.mod, ../sm-sitemap-plugin/console/import.go, ../sm-sitemap-plugin/console/import_winter.go, ../sm-sitemap-plugin/commands_documented_test.go, ../sm-sitemap-plugin/README.md, ../sm-sitemap-plugin/go.mod, ../sm-faq-plugin/console/import_winter.go, ../sm-faq-plugin/commands_documented_test.go, ../sm-faq-plugin/README.md, ../sm-faq-plugin/go.mod, ../sm-menu-plugin/console/import_winter.go, ../sm-menu-plugin/commands_documented_test.go, ../sm-menu-plugin/README.md, ../sm-menu-plugin/go.mod (plus each repository's go.sum and the non-console files listed in this plan's frontmatter that log through the process default logger)</files>
|
||||
<read_first>19.8-03-SUMMARY.md draft (`## Handoff` and the Task 6 reply; segment B starts here in a fresh executor context, see Execution segments), CLAUDE.md (Commits and concurrency, changelog rule), ../sm-summercmsio-app/DEPLOY.md ("Posting to the changelog"), ../sm-summercmsio-app/scripts/changelog-post.py (usage header), ../sm-sitemap-plugin/console/import.go (analog), ../sm-paymentgateway-plugin/schedule.go, each repository's console/*.go, go.mod, go.work and README.md, this plan's checklist table (shared plugin rows)</read_first>
|
||||
<action>Per D-20, D-21, D-22, D-24 and D-26, under the recorded reply. Segment B start (fresh context): work from the handoff, not from memory of Tasks 1-5; first check that every hash it lists resolves (`git -C <repo> cat-file -e <hash>`) and that each repository's HEAD, uncommitted paths and origin/master..master count match what it recorded; a mismatch stops the task and is reported. Before every push: `git fetch origin`, `git status` shows no uncommitted change to this phase's paths, and `git log --oneline origin/master..master` lists only commits the user approved (summercms.go: the commits named at the checkpoint; a plugin: its one sweep commit); anything else stops the task. Never force-push; never deploy.
|
||||
|
||||
(1) Framework: if the reply's tag is not v0.4.0, rename the upgrading section heading to that version and commit (`docs: upgrading notes for vX.Y.Z`). `go vet ./...` and `go test -short ./...` green; annotated tag vX.Y.Z on master HEAD with a one-paragraph message (Artisan-style console, diary logging with daily files, startup summaries and reports, request log by route pattern, plus what else the commits since the previous tag contain); `git push origin master`, then `git push origin vX.Y.Z`. From ../sm-summercmsio-app run `scripts/changelog-post.py source vX.Y.Z`, write user-facing notes (## Highlights, ## Added, ## Changed, ## Fixed; no H1, no planning IDs, no quick codes; Changed lists the upgrading items) to a file in the session scratchpad, then `scripts/changelog-post.py publish vX.Y.Z <notes> --excerpt "<one line>"`. If the token file is missing or refused, ask the user; never print, copy or mint a token.
|
||||
|
||||
(2) For each repository in the order sm-paymentgateway-plugin, sm-pgstripe-plugin, sm-oauthserver-plugin, sm-journal-plugin, sm-sitemap-plugin, sm-faq-plugin, sm-menu-plugin, one commit containing all of: `go get git.golem15.com/golem15/summercms@vX.Y.Z` and `go mod tidy` (existing replace and go.work setups kept); each command per its checklist row: description in imperative sentence case without a trailing period, Long (what it does, when to run it, scheduling and flag interplay, for importers the meaning of their existing --force and --dry-run), two or three Examples, the result line, the added flags (none of the existing flags renamed or re-meant); a package-level `diary.For("<plugin code>")` logger (paymentgateway, pgstripe, oauthserver, journal, sitemap, faq, menu) replacing every non-test use of the process default logger in the repository's files (frontmatter list); `commands_documented_test.go` with `TestCommandsDocumented` that builds the plugin's commands with an empty app, calls bonfire.CheckDocs and logs each problem with t.Logf without failing (D-17 warn-only); README CLI commands section with each command, its flags and an example, and Dependencies naming SummerCMS vX.Y.Z. Checks before committing: `go vet ./...`, `go test -short ./...`, `GOWORK=off go build ./...` (proves the tag carries the API) and the warn-only test runs. Commit `feat: console help, examples and named loggers (SummerCMS vX.Y.Z)`, then `git push origin master`.
|
||||
|
||||
Record in the SUMMARY's handoff, before Task 8 starts (so Task 8 can resume from the handoff alone if segment B is interrupted): the tag and its pushed range, the changelog post URL, each plugin's commit hash and push result, each plugin's CheckDocs warnings (expected none).</action>
|
||||
<verify>
|
||||
<automated>git ls-remote --exit-code --tags origin "$(git describe --tags --abbrev=0)" && for r in sm-paymentgateway-plugin sm-pgstripe-plugin sm-oauthserver-plugin sm-journal-plugin sm-sitemap-plugin sm-faq-plugin sm-menu-plugin; do go -C ../$r vet ./... && go -C ../$r test -count=1 -short ./... && go -C ../$r test -count=1 -short -run '^TestCommandsDocumented$' -v . 2>&1 | grep -q -- '^--- PASS: TestCommandsDocumented' && test -z "$(git -C ../$r log --oneline origin/master..master)" || exit 1; done</automated>
|
||||
<fails_when>Non-zero exit: the framework tag is not on origin, a plugin fails vet or its short suite at the tag, its warn-only TestCommandsDocumented is missing or did not run, or the plugin still has unpushed commits. On "hold" the command is not run and the SUMMARY says so.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- On a release reply: `git ls-remote --exit-code --tags origin vX.Y.Z` exits 0 in summercms.go and the SUMMARY holds the changelog post URL.
|
||||
- For each of the seven repositories: `grep -c 'git.golem15.com/golem15/summercms vX.Y.Z' go.mod` prints 1, `git log --oneline -1` is the sweep commit, and `git log --oneline origin/master..master` is empty.
|
||||
- `for r in sm-paymentgateway-plugin sm-pgstripe-plugin sm-oauthserver-plugin sm-journal-plugin sm-sitemap-plugin sm-faq-plugin sm-menu-plugin; do C=$(git -C ../$r ls-files '*.go' | grep -v _test.go | sed "s|^|../$r/|" | xargs grep -lE 'slog\.Default\(\)|slog\.(Info|Warn|Error|Debug)(Context)?\(' 2>/dev/null); test -z "$C" || exit 1; done` exits 0.
|
||||
- `for r in sm-paymentgateway-plugin sm-journal-plugin sm-sitemap-plugin; do B=$(git -C ../$r show HEAD~1:README.md 2>/dev/null | grep -oE '^\| `[a-z]+:[a-z:-]+`' | sort -u) && A=$(grep -oE '^\| `[a-z]+:[a-z:-]+`' ../$r/README.md | sort -u) && test -z "$(comm -23 <(printf '%s\n' "$B") <(printf '%s\n' "$A"))" || exit 1; done` exits 0 (no command name disappeared from a README table; adapt the pattern if a README lists commands differently and record it).
|
||||
</acceptance_criteria>
|
||||
<done>The framework release is public with its changelog post, and seven shared plugins explain their commands, log through named components and are pinned to it, each in one pushed commit.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 8: Under the Task 6 reply, sweep and push chat, chatchannels, chatvideo, forum, herald, golem, feedback and (when Phase 19.5 has released it) user, then bump the applications' pointers and regenerate their main.go in separate commits</name>
|
||||
<precondition>Task 7 recorded the framework tag and seven pushed plugin commits; on "hold" this task records "release held" and does nothing else. For sm-user-plugin additionally: .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-04-SUMMARY.md exists and `git -C ../fonoteka.go/plugins/golem15/user status --porcelain` is empty and in sync with origin/master; otherwise the user plugin is deferred as step (1) describes (todo, g15office task, D-20 reported partial).</precondition>
|
||||
<files>../sm-chat-plugin/console/rewrite_context_types.go, ../sm-chatchannels-plugin/console/rewrite_context_types.go, ../sm-chatvideo-plugin/console/reconcile.go, ../sm-forum-plugin/console/rewrite_context_types.go, ../sm-herald-plugin/console/vapid_generate.go, ../fonoteka.go/plugins/golem15/golem/console/import_settings.go, ../fonoteka.go/plugins/golem15/feedback/console/import_settings.go, ../fonoteka.go/plugins/golem15/user/console/api_tokens.go, ../fonoteka.go/plugins/golem15/user/console/backfill_handles.go, ../fonoteka.go/plugins/golem15/user/console/require_password_change.go, each repository's commands_documented_test.go, README.md, go.mod and go.sum, the non-console files listed in this plan's frontmatter that log through the process default logger, and the application paths ../sm-demo-app/plugins/golem15/*, ../sm-demo-app/main.go, ../sm-bm-app/plugins/golem15/user, ../sm-bm-app/main.go, ../sm-grzybyfunkcjonalne-app/plugins/golem15/*, ../sm-grzybyfunkcjonalne-app/main.go, ../sm-summercmsio-app/plugins/golem15/*, ../sm-summercmsio-app/main.go, ../fonoteka.go/plugins/golem15/user, ../fonoteka.go/plugins/golem15/golem, ../fonoteka.go/plugins/golem15/feedback, ../fonoteka.go/main.go, and in summercms.go .planning/G15OFFICE.md (step 3) and, only when sm-user-plugin is deferred, .planning/todos/pending/user-plugin-console-sweep.md</files>
|
||||
<read_first>19.8-03-SUMMARY.md draft (`## Handoff` with the Task 6 reply and the Task 7 results), each repository's console files, plugin.go, go.mod and README.md, ../fonoteka.go/plugins/golem15/user/console_test.go, 19.8-RESEARCH.md ("Apps that need pointer bumps", Pitfalls 15 and 16), this plan's checklist table (shared plugin rows), CLAUDE.md (Sibling repositories), .planning/G15OFFICE.md</read_first>
|
||||
<action>Per D-20, D-21, D-22, D-24 and D-26; same push rules as Task 7.
|
||||
|
||||
(1) Plugins, one commit each, in the order sm-chat-plugin, sm-chatchannels-plugin, sm-forum-plugin, sm-chatvideo-plugin, sm-herald-plugin, sm-golem-plugin (../fonoteka.go/plugins/golem15/golem), sm-feedback-plugin (../fonoteka.go/plugins/golem15/feedback), then sm-user-plugin (../fonoteka.go/plugins/golem15/user) only when the precondition holds: the same content as Task 7 step (2) (require the tag, checklist row per command, description normalised, Long, Examples, result line, `--json` for herald:vapid-generate, user:token:create and user:token:list with the data on stdout kept under -q, progress for user:backfill-handles, planning IDs removed from user descriptions, a package-level `diary.For("<plugin code>")` logger across the repository, the warn-only TestCommandsDocumented, README CLI section and Dependencies), the same checks (vet, short tests, `GOWORK=off go build ./...`, user plugin through `go -C ../fonoteka.go test -short ./plugins/golem15/user/...` as in Phase 19.5), then push. The rewrite-context-types descriptions move "(idempotent)" into Long. When the sm-user-plugin precondition fails, do not touch that checkout; instead, in summercms.go, write `.planning/todos/pending/user-plugin-console-sweep.md` (frontmatter title "Sweep sm-user-plugin console commands to the Phase 19.8 bar", date, priority medium, area "sm-user-plugin console", `g15office: <id>`; body: the observed reason for the deferral (19.5-04-SUMMARY.md missing, or the checkout dirty or out of sync), the user rows of this plan's Artisan checklist, the Task 7 step (2) content to apply, the framework tag to require, and the follow-up pointer bumps and regenerate commits in sm-demo-app, sm-bm-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app and fonoteka.go), create its g15office task (type Task, subtask of the Phase 19.8 milestone task, description ending `Local: .planning/todos/pending/user-plugin-console-sweep.md`) and write the ID into the frontmatter (if g15office is unavailable, `g15office: pending` and the SUMMARY says so), and commit the todo alone (`docs(19.8): defer the user plugin console sweep`). Record "D-20: partial (sm-user-plugin deferred, see .planning/todos/pending/user-plugin-console-sweep.md)" in the SUMMARY.
|
||||
|
||||
(2) Applications, after every plugin they mount is pushed (mapping: user to sm-demo-app, sm-bm-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app and fonoteka.go; journal to sm-demo-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app; sitemap, menu, faq to sm-demo-app and sm-grzybyfunkcjonalne-app; paymentgateway, pgstripe, oauthserver to sm-demo-app; golem and feedback to sm-demo-app and fonoteka.go; chat, chatchannels, chatvideo, forum, herald to none; with the user plugin deferred, applications skip the user pointer, and sm-bm-app, whose only bumped plugin would be user, gets no commit in this task). In each application: fetch and check out each bumped submodule at its pushed commit (fonoteka.go's user, golem and feedback checkouts are the canonical ones and already there); `go build ./...`; commit only those submodule paths (`chore: bump <plugins> for the console overhaul`); then `go run ../summercms.go/cmd/summer build` (or the installed summer) to regenerate main.go and build bin/<app>, run `./bin/<app> list` (exit 0, no duplicate-name error, RESEARCH Pitfall 15) and `./bin/<app> help migrate` (exit 0), and commit only main.go (`build: regenerate main for SummerCMS vX.Y.Z`). Never stage other sessions' pointer changes (check `git diff --submodule` first). On "release" push each application and the Task 5 landing (vue-summercmsio-app, then sm-summercmsio-app) and deck (sm-demo-app) commits whose unpushed commits are all this phase's; on "release-keep-apps-local" leave them local and list them in the SUMMARY.
|
||||
|
||||
(3) Post a short comment on the Phase 19.8 milestone task in g15office (find it by title; it is not yet in .planning/G15OFFICE.md, so add its ID there in a planning-doc commit when found) naming the release, the plugins and the applications; if the tool is unavailable, record that in the SUMMARY.</action>
|
||||
<verify>
|
||||
<automated>for r in sm-chat-plugin sm-chatchannels-plugin sm-forum-plugin sm-chatvideo-plugin sm-herald-plugin fonoteka.go/plugins/golem15/golem fonoteka.go/plugins/golem15/feedback; do go -C ../$r vet ./... && go -C ../$r test -count=1 -short ./... && test -z "$(git -C ../$r log --oneline origin/master..master)" || exit 1; done && for a in sm-demo-app sm-bm-app sm-grzybyfunkcjonalne-app sm-summercmsio-app fonoteka.go; do go -C ../$a build ./... || exit 1; done</automated>
|
||||
<fails_when>Non-zero exit: a plugin fails vet or tests at the tag or still has unpushed commits, or an application no longer builds with the bumped pointers. On "hold" the command is not run and the SUMMARY says so.</fails_when>
|
||||
<automated>for a in sm-demo-app sm-bm-app sm-grzybyfunkcjonalne-app sm-summercmsio-app fonoteka.go; do if [ "$a" = sm-bm-app ] && test -f .planning/todos/pending/user-plugin-console-sweep.md; then continue; fi; B=$(awk '/^binary:/{print $2}' ../$a/summer.yaml) && (cd ../$a && ./bin/$B list >/dev/null && ./bin/$B help migrate >/dev/null) || exit 1; done</automated>
|
||||
<fails_when>Non-zero exit: an application binary fails to boot its command root (for example a duplicate command name across plugins) or its help for migrate fails.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- For each swept plugin: `grep -c 'git.golem15.com/golem15/summercms vX.Y.Z' go.mod` prints 1 and `git log --oneline origin/master..master` is empty. When the sm-user-plugin precondition failed: the SUMMARY lists it as deferred with "D-20: partial", `test -f .planning/todos/pending/user-plugin-console-sweep.md` exits 0, and `grep -c '^g15office:' .planning/todos/pending/user-plugin-console-sweep.md` prints 1.
|
||||
- `grep -c '(D-15)\|(D-21)' ../fonoteka.go/plugins/golem15/user/console/backfill_handles.go ../fonoteka.go/plugins/golem15/user/console/require_password_change.go | awk -F: '{s+=$2} END {print s}'` prints 0 when the user plugin was swept.
|
||||
- In each application (except sm-bm-app when the user plugin was deferred): `git log --oneline -2` shows a pointer-bump commit and a regenerate commit as separate commits, and `grep -c 'bonfire.Execute(' main.go` prints 1.
|
||||
- The SUMMARY lists every pushed repository with its pushed range, or the local commits on "release-keep-apps-local".
|
||||
</acceptance_criteria>
|
||||
<done>Every shared plugin with commands is swept and pushed in one commit each, and every application runs the new console with the swept plugins, pinned through separate pointer-bump and regenerate commits.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| HTTP clients to the request log | paths carry share and invitation tokens; headers carry request ids of any length |
|
||||
| config and environment to the startup summary and hints | the DSN and keys are in config; summaries and hints are printed to terminals and journald |
|
||||
| operators over non-interactive SSH to destructive commands | deploys run migrate without a terminal |
|
||||
| this machine to git.golem15.com and summercms.io | tags, pushes and the changelog post publish work, including other sessions' commits |
|
||||
| framework to shared core plugins and applications | plugin APIs and command names are relied on by every application |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.8-13 | Information disclosure | surf request log (accesslog.go) | high | mitigate | records carry the route pattern only, never the path, query, headers or remote address (D-23); request_id capped at 128 bytes; acceptance greps for URL path access; TestServeLifecycle asserts the request path value is absent |
|
||||
| T-19.8-14 | Information disclosure | startup summary (summary.go) | high | mitigate | database shown as host, port and name from pgconn.ParseConfig, never user, password or DSN; driver names only for mail, cache, realtime and search; TestServeSummarySmoke asserts the password and user never appear |
|
||||
| T-19.8-15 | Denial of service | production confirmation (lagoon, conga) | high | mitigate | ConfirmProceed asks only on an interactive terminal; non-TTY deploys and scheduler Call runs proceed as today (D-28); --force skips; existing --force meanings untouched |
|
||||
| T-19.8-16 | Tampering | shared plugin contracts | medium | mitigate | one additive commit per plugin, names and flags unchanged (README command-table check), GOWORK=off build at the tag, short suites green; the plan 04 gate adds an exported-symbol additive check |
|
||||
| T-19.8-17 | Repudiation | release publication | medium | mitigate | blocking checkpoint (Task 6) lists origin..master before any push; pushes only after the recorded reply; never force-push or deploy |
|
||||
| T-19.8-18 | Information disclosure | changelog token | medium | mitigate | scripts/changelog-post.py reads the token file itself; the token is never printed, copied or minted; a missing token stops and asks the user |
|
||||
| T-19.8-19 | Information disclosure | hints (bonfire errors.go) | low | accept | Try lines show local relative paths and command names an operator already knows; messages still pass through sunscreen.Scrub |
|
||||
| T-19.8-20 | Denial of service | worker shutdown (conga) | low | mitigate | StopAndCancel after the deadline as today; in-flight jobs reported as handed back only after confirming River's retry behaviour |
|
||||
| T-19.8-SC | Tampering | module requires in plugins | medium | mitigate | plugins require only the framework tag produced in Task 7; no new third-party module is added by this plan |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: serve smokes and TestServeLifecycle (Docker); request log by pattern; docs checker.
|
||||
- Task 2: conga, dev and summer suites; dev runs serve.
|
||||
- Task 3: whole short suite, TestCommandsDocumented over application commands, TestGreeterDocumented.
|
||||
- Task 4: TestCommandsDocumented over all framework commands; every listed command has examples.
|
||||
- Task 5: landing node tests, terminal data, roadmap tests and snapshot check.
|
||||
- Task 6: reply recorded; nothing published before it.
|
||||
- Tasks 7-8: tag on origin, changelog post, each plugin's suite at the tag and pushed, each application builds and boots list and help. When sm-user-plugin was deferred, D-20 is reported partial, naming .planning/todos/pending/user-plugin-console-sweep.md and its g15office ID, never as passed.
|
||||
- Segments: the `## Handoff` section exists in the SUMMARY draft before the Task 6 checkpoint returns, and Tasks 7-8 ran from it in a fresh context.
|
||||
- On "hold": the SUMMARY notes that the local deck and landing commits describe an unreleased console.
|
||||
- Feedback sampling: after each framework commit `go test -short ./...` and `go -C examples/hello test ./...`; after Task 4, `scripts`-free full run `go vet ./... && go test ./...` on a Docker host.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- D-04..D-07, D-29: summaries and reports for serve, queue:work, schedule:run and dev, exit 0 on graceful stop.
|
||||
- D-11, D-23: request log by route pattern, on by default.
|
||||
- D-17, D-21, D-22: every framework command documented and gated; the checklist table implemented; result lines.
|
||||
- D-18, D-19: hints for the known failures; Reported for self-reporting commands.
|
||||
- D-20, D-24, D-26: release in order after the go-ahead; one commit per plugin; separate application commits.
|
||||
- D-25: summer dev serves.
|
||||
- SC-5: docs, READMEs, landing and deck updated.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.8-console-commands-overhaul/19.8-03-SUMMARY.md` when done, with the commits in order per repository, the Task 6 reply verbatim, the tag, the changelog post URL, the pushed ranges (or local commits), Docker tests run or NOT RUN, each plugin's CheckDocs warnings, whether sm-user-plugin was swept or deferred (when deferred: the todo path, its g15office ID and "D-20: partial"), the `## Handoff` section (kept in the final SUMMARY), on "hold" the note that the local deck and landing commits describe an unreleased console, the g15office result, and any checklist row that changed during execution with its reason.
|
||||
</output>
|
||||
@@ -0,0 +1,353 @@
|
||||
---
|
||||
phase: 19.8-console-commands-overhaul
|
||||
plan: 03
|
||||
subsystem: console
|
||||
tags: [bonfire, diary, surf, conga, lagoon, cli, release, plugins]
|
||||
requires:
|
||||
- phase: 19.8-01
|
||||
provides: diary module (component loggers, daily files, config/log.yaml)
|
||||
- phase: 19.8-02
|
||||
provides: bonfire global flags, Artisan list and help, Examples, CheckDocs, hints, Reported
|
||||
provides:
|
||||
- serve, queue:work, schedule:run and summer dev startup summaries and shutdown reports
|
||||
- http request log by route pattern on component http, on by default
|
||||
- Artisan checklist for every framework command, D-19 hints, TestCommandsDocumented gate
|
||||
- SummerCMS v0.4.0 tag, pushed, with its changelog post
|
||||
- fifteen shared plugins swept (help, examples, named loggers, warn-only TestCommandsDocumented), pushed
|
||||
- ten plugin patch releases and the five applications' pointer-bump and regenerate commits
|
||||
- email-proof release (sm-user-plugin v0.1.5, sm-sociallogin-plugin v0.1.2) with the app pins
|
||||
affects: [19.8-04, every application on SummerCMS, the shared plugins]
|
||||
actuals:
|
||||
tokens: 146594 # chars/4 over the realized diffs of all repositories (go.sum and dist excluded)
|
||||
tasks: 8
|
||||
commits: 37 # MEASURED in summercms.go: git rev-list --count plan_head_before..plan_head_after; most are other sessions' (19.5, 19.6, 19.6.1, 19.7 planning) — this plan's summercms.go commits are 6 code + 1 planning before this SUMMARY
|
||||
plan_head_before: f0ee26f39f6fd104df79a4cb759c7359be51b497
|
||||
plan_head_after: 053c643527ef9ed8e3fdcee904737efc30dc2ac3
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Plugin component logger: package-level `var pluginLog = diary.For(\"<code>\")` in a logger.go per package"
|
||||
- "Warn-only TestCommandsDocumented in each plugin repository (bonfire.CheckDocs, t.Logf)"
|
||||
- "Data output (--json, tokens, keys) through out.Println so -q keeps it"
|
||||
key-files:
|
||||
created:
|
||||
- modules/surf/summary.go
|
||||
- cmd/summer/commands_documented_test.go
|
||||
- "../sm-*-plugin/commands_documented_test.go and logger.go files (15 repositories)"
|
||||
modified:
|
||||
- modules/surf/serve.go
|
||||
- modules/surf/accesslog.go
|
||||
- modules/lagoon/migrations.go
|
||||
- modules/conga/worker.go
|
||||
- internal/dev/watch.go
|
||||
- docs/setup/upgrading.md
|
||||
- "../sm-*-app/main.go (regenerated), go.mod, go.sum and plugin gitlinks"
|
||||
key-decisions:
|
||||
- "Framework v0.4.0 tagged at 99b6df4, not at master HEAD: commits other sessions made during the run (c1f5ded todo, 19.6/19.6.1/19.7 planning) stay unpushed"
|
||||
- "Ten plugins mounted by applications got patch tags (user v0.1.6, journal v0.2.2, sitemap/menu/faq/paymentgateway/oauthserver/golem/feedback v0.1.1, pgstripe v0.1.3): sm-demo-app's go_mod_test refuses pseudo-version pins"
|
||||
- "chat and forum require sm-user-plugin v0.1.2, the first release with classes.DeletingEvent; v0.1.1 did not build without a workspace"
|
||||
- "sm-user-plugin swept on top of v0.1.5 (D-20 complete, nothing deferred)"
|
||||
patterns-established:
|
||||
- "Pattern: a plugin release that requires a new framework minor gets a patch tag, and applications pin the tag in go.mod together with the gitlink"
|
||||
requirements-completed: [SC-3, SC-4, SC-5, D-04, D-05, D-06, D-07, D-11, D-17, D-18, D-19, D-20, D-21, D-22, D-23, D-24, D-25, D-26, D-27, D-28, D-29]
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "serve startup summary, shutdown report and http request log by route pattern"
|
||||
requirement: D-04
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/surf#TestServeSummarySmoke, TestShutdownReportSmoke, TestAccessLogSmoke"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "modules/surf/serve_lifecycle_test.go#TestServeLifecycle (Docker)"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "The styled terminal block and its readability are visual"
|
||||
- id: D2
|
||||
description: "Every framework command documented, Artisan checklist flags, hints and result lines"
|
||||
requirement: D-17
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "cmd/summer/commands_documented_test.go#TestCommandsDocumented"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "go test -count=1 ./... with Docker (summercms.go)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "SummerCMS v0.4.0 tagged and pushed with the changelog post"
|
||||
requirement: D-26
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "git ls-remote --exit-code --tags origin v0.4.0; https://summercms.io/changelog/summercms-0-4-0"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "The changelog notes' wording is editorial"
|
||||
- id: D4
|
||||
description: "Fifteen shared plugins swept in one pushed commit each, pinned to v0.4.0"
|
||||
requirement: D-20
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "each plugin: go vet, go test -short, TestCommandsDocumented (no warnings), GOWORK=off build, build against the v0.4.0 tag"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Applications bumped and main.go regenerated; each binary boots list and help migrate"
|
||||
requirement: D-26
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "go build ./... and ./bin/<app> list, ./bin/<app> help migrate in sm-demo-app, sm-bm-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app, fonoteka.go"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Landing copy and roadmap deck describe the new console (published, not deployed)"
|
||||
requirement: SC-5
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "vue-summercmsio-app pnpm test; sm-demo-app go test ./plugins/demo/roadmap/..."
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "Copy quality is editorial; the deploys are a separate user step"
|
||||
duration: "segment B 3h 03m (2026-10-21T13:19:59Z to 16:23Z); segment A recorded in its own run"
|
||||
completed: 2026-10-21
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 19.8 Plan 03: console sweep and release Summary
|
||||
|
||||
**serve/queue:work/schedule:run summaries and shutdown reports, the http request log by route pattern and an Artisan-documented command set shipped as SummerCMS v0.4.0 (tag, push, changelog), followed by fifteen swept shared plugins, ten plugin patch releases and pointer-bump plus regenerate commits in five applications; the 19.5 email-proof release went out first.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Segment A** (Tasks 1-5 and the Task 6 checkpoint): recorded in its own run.
|
||||
- **Segment B** (email-proof release, Tasks 7 and 8): started 2026-10-21T13:19:59Z, completed 2026-10-21T16:23Z, about 3 h.
|
||||
- **Tasks:** 8 of 8.
|
||||
- **Repositories touched:** summercms.go, sm-summercmsio-app (and vue-summercmsio-app), sm-demo-app, sm-bm-app, sm-grzybyfunkcjonalne-app, fonoteka.go, sm-sociallogin-plugin and fifteen plugin repositories.
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `serve`: startup summary block (version, environment, URL, admin, database host:port/name, migration state, plugins, queue, scheduler, mail, cache, realtime, search), `startup summary` and `listening on <addr>` records off a terminal, shutdown report and exit 0. `queue:work`, `schedule:run` and `summer dev` (which now runs `serve`) do the same.
|
||||
- Request log on by default under component `http` with the route pattern, never the path; `request_id` capped at 128 bytes.
|
||||
- Application and tool commands: Long and Examples everywhere, `--json`, `--pretend` and `--force` where they fit, production confirmation on an interactive terminal only, result lines, D-19 hints, `TestCommandsDocumented` (fails) in cmd/summer.
|
||||
- Released SummerCMS **v0.4.0** with the changelog post https://summercms.io/changelog/summercms-0-4-0.
|
||||
- Fifteen shared plugins: imperative descriptions, Long, Examples, result lines, a `diary.For("<code>")` logger replacing every default-logger use, a warn-only `TestCommandsDocumented` (no warnings anywhere), README CLI sections and Dependencies naming v0.4.0. Added flags: `herald:vapid-generate --json`, `user:token:create --json`, `user:token:list --json`; progress bars on `journal:export-posts` and `user:backfill-handles`.
|
||||
- Applications run the new console: sm-demo-app, sm-bm-app, sm-grzybyfunkcjonalne-app and sm-summercmsio-app pushed; fonoteka.go local.
|
||||
- Landing copy and roadmap deck published with their repositories (no deploy).
|
||||
|
||||
## Task Commits
|
||||
|
||||
summercms.go (master):
|
||||
|
||||
1. **Task 1: serve summary, report and request log** - `c38dc1f` (feat)
|
||||
2. **Task 2: worker and scheduler reports, summer dev runs serve** - `168d65c` (feat)
|
||||
3. **Task 3: Artisan checklist for application commands, hints, coverage gate** - `9d06a78` (feat)
|
||||
4. **Task 4: help, examples and hints for every tool command** - `3346060` (feat)
|
||||
5. Test fixes after the Docker run (deviation 14): `2057643`, `4895b25` (fix)
|
||||
6. Email-proof release record and closed todo: `99b6df4` (docs)
|
||||
|
||||
Task 5 (landing and deck): vue-summercmsio-app `473e476`, sm-summercmsio-app `44c96f2`, sm-demo-app `b325460`.
|
||||
|
||||
Task 6: checkpoint, reply "release v0.4.0".
|
||||
|
||||
Task 7 and Task 8: one commit per plugin and two per application, listed in the Handoff (sections 8 and 9).
|
||||
|
||||
## Handoff
|
||||
|
||||
### 1. Segment A commits
|
||||
|
||||
summercms.go (master, pushed in segment B):
|
||||
|
||||
| Task | Hash | Subject |
|
||||
|------|------|---------|
|
||||
| 1 | `c38dc1f` | feat(surf): serve startup summary, shutdown report and http request log |
|
||||
| 2 | `168d65c` | feat(conga,dev): worker and scheduler reports, summer dev runs serve |
|
||||
| 3 | `9d06a78` | feat: Artisan checklist for application commands, hints and coverage gate |
|
||||
| 4 | `3346060` | feat(summer): help, examples and hints for every tool command |
|
||||
|
||||
summercms.go `origin/master..master` at the checkpoint: 43 commits, `c996790` (docs(roadmap): renumber console commands overhaul from phase 32 to 19.8) through `3346060`. They are this phase's commits (plans 01, 02, 03 and the phase planning docs), Phase 19.5's commits (plans 02-05 docs, tests, gate, security review, verification, UAT), `671d388` test(cooler) and `cb38c49` docs(notes). Latest tag on origin then: `v0.3.1`.
|
||||
|
||||
Sibling repositories (segment A, local until segment B):
|
||||
|
||||
| Repository | Branch | Hash | Subject |
|
||||
|------------|--------|------|---------|
|
||||
| sm-summercmsio-app/vue-summercmsio-app | master | `473e476` | feat: describe the Artisan-style console |
|
||||
| sm-summercmsio-app | master | `44c96f2` | chore: bump vue-summercmsio-app for console copy |
|
||||
| sm-demo-app | master | `b325460` | feat(demo): roadmap deck describes the new console |
|
||||
|
||||
sm-summercmsio-app also held `8581576` fix(scripts): changelog-post sends its own User-Agent (not this plan's); approved with the reply and pushed.
|
||||
|
||||
### 2. Framework API for the plugin sweep (names as built)
|
||||
|
||||
- `bonfire.Command` fields `Long string`, `Examples []bonfire.Example`, `Hidden bool`; `bonfire.Example{Command, Description string}`.
|
||||
- `bonfire.CheckDocs(commands []bonfire.Command) []bonfire.DocProblem`; `DocProblem{Command string; Missing []string}` with `String()`.
|
||||
- Errors: `bonfire.Reported(err) error`, `bonfire.WithHint(err, explanation, try string) error`, `bonfire.Usage(err) error`.
|
||||
- Output helpers: `bonfire.IsQuiet`, `bonfire.Interactive`, `bonfire.IsTerminal`, `bonfire.ConfirmProceed`, `bonfire.Process`, `bonfire.Current`, `bonfire.Failed`, `bonfire.Details`, `bonfire.GlobalsOf`, `bonfire.VerbosityOf`. Data goes through `out.Println`/`out.Printf` (kept under `-q`).
|
||||
- Logging: `diary.For("<component>") *slog.Logger`, `diary.HideOnTTY(ctx)`.
|
||||
|
||||
### 3. Release recommendation
|
||||
|
||||
- Recommended tag: v0.4.0; upgrading section `## Upgrading to 0.4` in `docs/setup/upgrading.md` (kept, the reply chose v0.4.0).
|
||||
|
||||
### 4. Repository state at the checkpoint (after `git fetch origin`)
|
||||
|
||||
| Repository | Branch | HEAD | Ahead/behind | Uncommitted | Framework require |
|
||||
|------------|--------|------|--------------|-------------|-------------------|
|
||||
| ../sm-paymentgateway-plugin | main | `16484ba` | 0/0 | none | v0.1.24 |
|
||||
| ../sm-pgstripe-plugin | main | `c48c3b4` | 0/0 | none | v0.2.2 |
|
||||
| ../sm-oauthserver-plugin | master | `0deb9a4` | 0/0 | none | v0.3.0 |
|
||||
| ../sm-journal-plugin | main | `48d6593` | 0/0 | `?? go.work.sum` (not this plan's, left alone) | v0.2.0 |
|
||||
| ../sm-sitemap-plugin | main | `46898c2` | 0/0 | none | v0.2.0 |
|
||||
| ../sm-faq-plugin | main | `2efe351` | 0/0 | none | v0.2.0 |
|
||||
| ../sm-menu-plugin | main | `f7eddd5` | 0/0 | none | v0.2.0 |
|
||||
| ../sm-chat-plugin | main | `f4aeb87` | 2/0 (`0aee785`, `f4aeb87`, approved) | none | v0.2.2 |
|
||||
| ../sm-chatchannels-plugin | main | `3738f45` | 2/0 (`7d49c3c`, `3738f45`, approved) | none | v0.2.2 |
|
||||
| ../sm-chatvideo-plugin | main | `66b3bc7` | 0/0 | none | v0.2.3 |
|
||||
| ../sm-forum-plugin | main | `6813f00` | 0/0 | none | v0.2.3 |
|
||||
| ../sm-herald-plugin | main | `0c7de4d` | 0/0 | none | v0.2.3 |
|
||||
| ../fonoteka.go/plugins/golem15/golem | master | `544aa28` | 0/0 | none | v0.1.9 |
|
||||
| ../fonoteka.go/plugins/golem15/feedback | master | `e06b140` | 0/0 | none | v0.1.9 |
|
||||
| ../fonoteka.go/plugins/golem15/user | master | `c7635c5` | 0/0 | none | v0.1.9 |
|
||||
|
||||
Applications: sm-demo-app `b325460` (1 ahead), sm-bm-app `99af8c2` (another session's `.planning/*` and tool directories uncommitted, left alone), sm-grzybyfunkcjonalne-app `6fc1a34` (1 ahead, approved), sm-summercmsio-app `44c96f2` (2 ahead), fonoteka.go `f4d5728` (31 ahead, never pushed).
|
||||
|
||||
### 5. Docker tests
|
||||
|
||||
- `TestServeLifecycle` (modules/surf): **PASS**. It was NOT RUN in segment A (no traffic reached containers on this host); after the host networking was fixed it failed on a missing app key and body limits in its fixture (deviation 14, `4895b25`) and then passed.
|
||||
- Full `go test -count=1 ./...` with Docker in summercms.go after `4895b25`: **PASS** (`TestQueueWork`, `TestScheduleRunForeground` fixed in `2057643`).
|
||||
- Plugin Docker suites in segment B: PASS in paymentgateway, pgstripe, oauthserver, journal, chat, chatchannels, forum, chatvideo, herald, golem, feedback; pre-existing failures in sitemap, faq, menu and the user plugin's `updates` package (section 8, deferred-items.md).
|
||||
|
||||
### 6. Phase 19.5 and the user plugin
|
||||
|
||||
- Phase 19.5 completed and closed during segment A's pause; its commits (`7170c5b` … `9ab28e1`) went out with the framework push.
|
||||
- The email-proof release ran first in segment B (section 8). sm-user-plugin was then swept on top of v0.1.5 (`9986342`, tag v0.1.6): **D-20 complete**, nothing deferred, no `user-plugin-console-sweep.md` todo.
|
||||
|
||||
### 7. Deviations so far
|
||||
|
||||
See `## Deviations from Plan` below.
|
||||
|
||||
### 8. Segment B: email-proof release and Task 7 results (recorded before Task 8)
|
||||
|
||||
- Segment B start check: every segment A hash resolves; repository states matched the handoff except the email-proof release made first and Phase 19.5's documented commits in summercms.go (`7170c5b` … `9ab28e1`).
|
||||
- Email-proof release (todo `email-proof-release.md`, steps 0-5): sm-user-plugin `v0.1.5` at `35cf166` (pushed `c7635c5..35cf166`); sm-sociallogin-plugin `9fd58db` and `v0.1.2` (pushed `d7ee28d..9fd58db`); sm-demo-app `f16ef6c`, `1afd7ff`, `a5703f9` (snapshot sync) pushed `61a00e9..a5703f9` with `b325460`; sm-bm-app `1e09c23`, `cc4b23f` pushed `99af8c2..cc4b23f` with `5fd0cc3`; fonoteka.go `2be9e1f` local. Recorded in 19.5-05-SUMMARY.md and the closed todo (summercms.go `99b6df4`).
|
||||
- Framework: master pushed `5cf5c4f..99b6df4` (64 commits); annotated tag **v0.4.0** at `99b6df4` (tag object `e30fd34`), pushed. `c1f5ded` (a todo capture another session committed during this run) and later 19.6, 19.6.1 and 19.7 planning commits were left local, not pushed.
|
||||
- Changelog: https://summercms.io/changelog/summercms-0-4-0 (post id 35, published at the tag date 2026-10-21T13:43:16Z).
|
||||
- Plugins, one commit each `feat: console help, examples and named loggers (SummerCMS v0.4.0)`, pushed; CheckDocs warnings: none.
|
||||
|
||||
| Repository | Branch | Commit | Pushed range |
|
||||
|------------|--------|--------|--------------|
|
||||
| sm-paymentgateway-plugin | main | `e0badb5` | `16484ba..e0badb5` |
|
||||
| sm-pgstripe-plugin | main | `6d07d98` | `c48c3b4..6d07d98` |
|
||||
| sm-oauthserver-plugin | master | `d31f8e7` | `0deb9a4..d31f8e7` |
|
||||
| sm-journal-plugin | main | `ffa2c8f` | `48d6593..ffa2c8f` |
|
||||
| sm-sitemap-plugin | main | `96c790d` | `46898c2..96c790d` |
|
||||
| sm-faq-plugin | main | `b3cc978` | `2efe351..b3cc978` |
|
||||
| sm-menu-plugin | main | `8656474` | `f7eddd5..8656474` |
|
||||
|
||||
- Checks per plugin: `go vet`, `go test -short`, `GOWORK=off go build ./...`, the warn-only `TestCommandsDocumented` (PASS, no warnings), and a build against the published v0.4.0 tag from a go.mod copy without the framework replace. Full Docker suites pass in paymentgateway, pgstripe, oauthserver and journal; sitemap (`TestSitemapAdminSmoke`), faq (`TestFaqAdminSmoke`, `TestFaqAdminPurgesSiteCache`) and menu (`TestMenuAdminSmoke`, `TestMenuAdminPurgesSiteCache`) have admin smoke tests that already fail at the pushed HEAD before the sweep and with framework v0.3.1 (validation messages arrive translated where the tests expect phrase keys); logged in deferred-items.md.
|
||||
|
||||
### 9. Task 8 results
|
||||
|
||||
Plugins, one commit each, pushed (CheckDocs warnings: none):
|
||||
|
||||
| Repository | Branch | Commit | Pushed range | Notes |
|
||||
|------------|--------|--------|--------------|-------|
|
||||
| sm-chat-plugin | main | `5fea654` | `4d0e764..5fea654` (with approved `0aee785`, `f4aeb87`) | requires sm-user-plugin v0.1.2 |
|
||||
| sm-chatchannels-plugin | main | `f3ac5d1` | `9026cd6..f3ac5d1` (with approved `7d49c3c`, `3738f45`) | tidy raises sm-user-plugin to v0.1.2 |
|
||||
| sm-forum-plugin | main | `2779341` | `6813f00..2779341` | requires sm-user-plugin v0.1.2 |
|
||||
| sm-chatvideo-plugin | main | `be688fe` | `66b3bc7..be688fe` | result line `Ended N video sessions.` |
|
||||
| sm-herald-plugin | main | `74c889c` | `0c7de4d..74c889c` | `herald:vapid-generate --json` |
|
||||
| sm-golem-plugin | master | `37748ae` | `544aa28..37748ae` | no default-logger sites; `feat: console help and examples` |
|
||||
| sm-feedback-plugin | master | `ce4b43e` | `e06b140..ce4b43e` | |
|
||||
| sm-user-plugin | master | `9986342` | `35cf166..9986342` | `--json` on token create/list, backfill progress, planning IDs removed |
|
||||
|
||||
Plugin tags (annotated, pushed): sm-user-plugin `v0.1.6` (`9986342`), sm-journal-plugin `v0.2.2` (`ffa2c8f`), sm-sitemap-plugin `v0.1.1` (`96c790d`), sm-menu-plugin `v0.1.1` (`8656474`), sm-faq-plugin `v0.1.1` (`b3cc978`), sm-paymentgateway-plugin `v0.1.1` (`e0badb5`), sm-pgstripe-plugin `v0.1.3` (`6d07d98`), sm-oauthserver-plugin `v0.1.1` (`d31f8e7`), sm-golem-plugin `v0.1.1` (`37748ae`), sm-feedback-plugin `v0.1.1` (`ce4b43e`). chat, chatchannels, forum, chatvideo and herald are not tagged (no application mounts them).
|
||||
|
||||
Applications (pointer bump, then regenerated main.go; `bin/<app> list` and `bin/<app> help migrate` exit 0; `grep -c 'bonfire.Execute(' main.go` is 1):
|
||||
|
||||
| Application | Bump commit | Regenerate commit | Pushed range |
|
||||
|-------------|-------------|-------------------|--------------|
|
||||
| sm-demo-app | `8d3a7f0` (user, journal, sitemap, menu, faq, paymentgateway, pgstripe, oauthserver, golem, feedback; go.mod pins the tags) | `7dc5022` | `a5703f9..7dc5022` |
|
||||
| sm-bm-app | `d1992f1` (user v0.1.6, tidy for v0.4.0) | `87c7442` | `cc4b23f..87c7442` |
|
||||
| sm-grzybyfunkcjonalne-app | `797ba0f` (user, journal, sitemap, menu, faq; tidy for v0.4.0) | `0bddc5b` | `6b9d13d..0bddc5b` (with approved `6fc1a34`) |
|
||||
| sm-summercmsio-app | `6eb30f3` (user, journal; tidy for v0.4.0) | `289aa77` | `752099d..289aa77` (with approved `8581576` and Task 5's `44c96f2`); vue-summercmsio-app `8771773..473e476` pushed first |
|
||||
| fonoteka.go | `59aa058` (user, golem, feedback; tidy) | `03de3e1` | not pushed (local only, 34 commits ahead) |
|
||||
|
||||
- g15office: no g15office tool was available in this session; the Phase 19.8 milestone task was neither found nor commented, and `.planning/G15OFFICE.md` is unchanged.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- Framework (segment A): `modules/surf/{serve.go,summary.go,accesslog.go,routelist_command.go}`, `modules/lagoon/{migrations.go,commands.go,keygen.go,connection.go,encrypted.go}`, `modules/conga/{worker.go,commands.go}`, `modules/cabana/*commands.go`, `modules/lighthouse/centrifugo/commands.go`, `modules/flare/commands.go`, `modules/bonfire/{errors.go,helpers.go}`, `modules/diary/*`, `internal/dev/watch.go`, `cmd/summer/*`, docs pages and READMEs listed in segment A.
|
||||
- Each swept plugin: console command files, `commands_documented_test.go`, `logger.go` per package that logged, README, go.mod, go.sum.
|
||||
- Each application: plugin gitlinks, go.mod, go.sum, main.go.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- v0.4.0 tagged at `99b6df4` (pushed explicitly) so commits other sessions made during the run stay local.
|
||||
- Patch tags for the ten plugins applications mount: sm-demo-app refuses pseudo-version pins (`go_mod_test.go`).
|
||||
- Package-level `pluginLog = diary.For("<code>")` in a `logger.go` per package; log messages unchanged (they keep their `golem15.<code>:` prefixes).
|
||||
- The `subscriptions` package of sm-pgstripe-plugin is its own plugin and logs as component `pgstripesubscriptions`.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
Segment A (1-14):
|
||||
|
||||
1. **[Rule 2 - API] bonfire.IsTerminal, bonfire.Details, bonfire.Detail.** Added as additive helpers for surf and conga. Commit `c38dc1f`.
|
||||
2. **[Rule 2 - API] conga.WorkerStats.HandedBack.** Distinguishes jobs Stop handed back to the queue (River retries them) from failures. Commit `c38dc1f`.
|
||||
3. **[Rule 3 - Blocking] lagoon.DatabaseLabel.** Exported DSN-to-"host:port/name" helper used by surf and conga. Commit `168d65c`.
|
||||
4. **[Interpretation] PendingMigrations includes the framework sets** before the plugins, matching `Migrate`. Commit `c38dc1f`.
|
||||
5. **[Interpretation] migrate:status --json** prints `{plugin, table, id, applied}` (gormigrate stores only the ID). Commit `9d06a78`.
|
||||
6. **[Rule 1 - Bug] summer dev restart order.** Stops the old server before starting the new one. Commit `168d65c`.
|
||||
7. **[Rule 2] summer dev gives the child the terminal.** Commit `168d65c`.
|
||||
8. **[Rule 3 - Blocking] blog:publish coverage moved to its own package** (`docs/examples/blog/console/publish_test.go`). Commit `9d06a78`.
|
||||
9. **[Interpretation] app.key hint in lagoon's encrypted.go, uploads-bucket hint in surf's publishUploads.** Commit `9d06a78`.
|
||||
10. **[Interpretation] serve admin status folded into the summary's Admin row.** Commit `c38dc1f`.
|
||||
11. **[Rule 3] sm-demo-app names.go** needed phase 19.8's Polish title. Commit `b325460`.
|
||||
12. **[Interpretation] Landing tests** need `pnpm generate` before `pnpm test`.
|
||||
13. **[Concurrency] sm-summercmsio-app held `8581576`** (not this plan's); reported at the checkpoint, approved with the reply, pushed.
|
||||
14. **[Rule 1 - Bug] Docker-backed tests after host networking was fixed.** `TestQueueWork/runs_until_cancelled` and `TestScheduleRunForeground` asserted pre-19.8 Info lines; `TestServeLifecycle` lacked an app key and body limits. Commits `2057643`, `4895b25`; the full Docker suite then passed.
|
||||
|
||||
Segment B (15-27):
|
||||
|
||||
15. **[Concurrency] Framework push and tag at `99b6df4`, not master HEAD.** `c1f5ded` (another session's todo capture, committed during the run) was not in the approved range, so `99b6df4:refs/heads/master` was pushed and tagged; `c1f5ded` and the later 19.6, 19.6.1 and 19.7 planning commits stay local for their sessions.
|
||||
16. **[Rule 3 - Blocking] sm-sociallogin-plugin is not readable over anonymous https.** `go get` of it (and the tag-proof builds) ran with a process-scoped `url.git@git.golem15.com:.insteadOf https://git.golem15.com/` (environment variables only; no git or Go configuration changed).
|
||||
17. **[Rule 3 - Blocking] Plugin patch tags.** The plan pinned applications by submodule commit only; sm-demo-app's go.mod pins every plugin by version and `go_mod_test.go` refuses pseudo-versions, so the ten plugins applications mount were tagged (Handoff 9) and sm-demo-app's go.mod moved to those tags.
|
||||
18. **[Rule 3 - Blocking] chat and forum require sm-user-plugin v0.1.2.** Both use `userclasses.DeletingEvent` (chat through the approved `0aee785`, forum already on its pushed HEAD) while requiring v0.1.1, which lacks it; `GOWORK=off go build` failed. Raised to v0.1.2 in their sweep commits; chatchannels and chatvideo follow through tidy.
|
||||
19. **[Rule 3] Application commits include go.mod and go.sum.** The plan said to commit only the submodule paths; the version pins (sm-demo-app, sm-bm-app) and the framework v0.4.0 requirement with its new dependencies (`GOWORK=off go mod tidy`) belong in the same bump commit, or the applications do not build without a workspace.
|
||||
20. **[Checklist changes]** `journal:import-posts` progress declined: it prints one line per post inside one transaction, which a bar would interleave (export got the bar). `journal:import-posts`, `sitemap:import` and `sitemap:import-winter` result lines move from Info to Success (text unchanged). `chatvideo:reconcile-sessions` prints `Ended N video sessions.` instead of `reconciled N session(s)`. `oauthserver:prune` gains `Pruned N expired rows and K signing keys.` after its six count lines. The pg sweeps keep their existing `Canceled N payments.`/`Canceled N orders.`/`Finished N orders.` and `Updated N of M currencies.` lines. `oauthserver:rotate-keys`, `golem:import-settings` and `feedback:import-settings` return `bonfire.Reported` after printing their own error, so it prints once.
|
||||
21. **[Interpretation] sm-golem-plugin has no default-logger use,** so it got no logger file and its commit is `feat: console help and examples (SummerCMS v0.4.0)`.
|
||||
22. **[Concurrency] fonoteka.go golem and feedback gitlinks.** Another session had left their checkouts at the v0.1.0 pin commits (`544aa28`, `e06b140`) unstaged; the bump to the sweep commits fast-forwards through them, so `59aa058` includes that move.
|
||||
23. **[Environment] fonoteka's binary needs `SUMMER_GOLEM15__USER__JWT__SECRET` to boot** (`list` and `help migrate`); checked with a throwaway test value. Unchanged behaviour of the user plugin.
|
||||
24. **[Concurrency] Roadmap deck not re-synced at the final push.** `sync-roadmap.sh --check` fails against local master only because of other sessions' unpushed planning; it passes against `origin/master`, so the deck was not synced to unpublished planning (deferred-items.md).
|
||||
25. **[Email-proof] sm-demo-app snapshot sync `a5703f9`** before its push, as the todo prescribes; sm-sociallogin-plugin's tidy also recorded the framework's charm indirect requirements (its go.mod replaces the framework with the local checkout).
|
||||
26. **[State] `state.advance-plan` and `state.record-session` not run.** STATE.md's current position belongs to the session executing 19.6/19.6.1 and holds its uncommitted edits; only the phase-scoped handlers ran (record-metric, add-decision, update-progress), and STATE.md is not committed here.
|
||||
27. **[Tooling] g15office unavailable.** No comment on the Phase 19.8 milestone task; email-proof todo step 6 (comment on `onunbz3l`, Testing, `sqnwn1zi` to Live) also open.
|
||||
|
||||
**Total deviations:** 27 (segment A 14, segment B 13). **Impact:** no command name or flag was removed or renamed; the additions are flags, help, result lines and loggers. The tags and pushes stayed within the reply (framework, plugins, applications except fonoteka.go); nothing was deployed or force-pushed.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- Pre-existing test failures in sitemap, faq, menu and the user plugin's `updates` package (deferred-items.md); each reproduced before the sweep and with framework v0.3.1.
|
||||
- Application binaries print plugin boot INFO records before `list` output (tracked by another session's todo `c1f5ded`).
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 19.8-04 (unit tests) can run against the released v0.4.0 and the swept plugins.
|
||||
- Open for the user: deploy the applications (sm-demo-app, sm-bm-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app) when ready; the user plugin's two additive migrations (`users.handle`, `users.email_verified_at`) reach grzyby and summercms.io with this bump. Re-run `/msd-verify-work 19.5` (email-proof released). g15office updates for 19.5 and 19.8.
|
||||
|
||||
## Task 6 reply
|
||||
|
||||
> release v0.4.0
|
||||
|
||||
Chosen tag: **v0.4.0**. Recorded with the reply: full release (framework tag, push, changelog post; plugin sweeps pushed; application pointer-bump and regenerate commits pushed). The user also approved that unpushed commits made outside this plan go out with the release (sm-summercmsio-app `8581576`, sm-chat-plugin `0aee785` `f4aeb87`, sm-chatchannels-plugin `7d49c3c` `3738f45`, sm-grzybyfunkcjonalne-app `6fc1a34`, sm-demo-app `b325460` and similar earlier-session commits in the same repositories). fonoteka.go is never pushed (local commits only). The email-proof release (sm-user-plugin v0.1.5, sm-sociallogin-plugin v0.1.2) runs first, before Task 7.
|
||||
|
||||
---
|
||||
*Phase: 19.8-console-commands-overhaul*
|
||||
*Completed: 2026-10-21*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All commits listed resolve in their repositories (summercms.go 7, sociallogin 1, sm-demo-app 6, sm-bm-app 4, grzybyfunkcjonalne 2, summercmsio 3 plus vue 1, fonoteka.go 3, fifteen plugin sweep commits); the key files exist; the Handoff heading is present once; tags v0.4.0 and the eleven plugin tags resolve on their remotes.
|
||||
353
.planning/phases/19.8-console-commands-overhaul/19.8-04-PLAN.md
Normal file
353
.planning/phases/19.8-console-commands-overhaul/19.8-04-PLAN.md
Normal file
@@ -0,0 +1,353 @@
|
||||
---
|
||||
phase: 19.8-console-commands-overhaul
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: ["19.8-01", "19.8-02", "19.8-03"]
|
||||
files_modified:
|
||||
- scripts/check-phase19.8.sh
|
||||
- modules/diary/diary_test.go
|
||||
- modules/diary/config_test.go
|
||||
- modules/diary/daily_test.go
|
||||
- modules/diary/console_test.go
|
||||
- modules/diary/slogtest_test.go
|
||||
- modules/sunscreen/sunscreen_test.go
|
||||
- modules/bonfire/globals_test.go
|
||||
- modules/bonfire/root_test.go
|
||||
- modules/bonfire/golden_test.go
|
||||
- modules/bonfire/errors_test.go
|
||||
- modules/bonfire/docs_test.go
|
||||
- modules/bonfire/widgets_test.go
|
||||
- modules/bonfire/live_test.go
|
||||
- modules/bonfire/version_test.go
|
||||
- modules/bonfire/testdata/list.golden
|
||||
- modules/bonfire/testdata/list_all.golden
|
||||
- modules/bonfire/testdata/list.json.golden
|
||||
- modules/bonfire/testdata/help_token_create.golden
|
||||
- modules/bonfire/testdata/help_passthrough.golden
|
||||
- modules/bonfire/testdata/error_hint.golden
|
||||
- modules/bonfire/testdata/error_unknown_command.golden
|
||||
- modules/bonfire/testdata/error_chain_verbose.golden
|
||||
- modules/bonfire/testdata/list_styled.golden
|
||||
- internal/docsite/check_commands_test.go
|
||||
- modules/surf/summary_test.go
|
||||
- modules/surf/accesslog_test.go
|
||||
- modules/conga/stats_test.go
|
||||
- modules/conga/logger_test.go
|
||||
- modules/lagoon/pending_test.go
|
||||
- modules/lagoon/commands_flags_test.go
|
||||
- modules/lagoon/logger_test.go
|
||||
- modules/cabana/token_json_test.go
|
||||
- modules/flare/commands_test.go
|
||||
- internal/dev/watch_test.go
|
||||
- cmd/summer/runtime_test.go
|
||||
- .planning/phases/19.8-console-commands-overhaul/19.8-04-SUMMARY.md
|
||||
autonomous: false
|
||||
requirements: [SC-5, D-03, D-08, D-09, D-10, D-12, D-13, D-14, D-15, D-16, D-17, D-18, D-19, D-21, D-23, D-25, D-27, D-28, D-29]
|
||||
user_setup: []
|
||||
|
||||
estimate:
|
||||
tokens: 230000
|
||||
raw_tokens: 230000
|
||||
tasks: 6
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "scripts/check-phase19.8.sh runs stages self-test, go, hello, docs, plugins, apps, landing and docker; a stage fails on a failing command or on a go test -json run that fails, skips a required test, matches zero tests, prints 'no tests to run' or misses a required name; --self-test proves those detectors on planted output; --all is green before /msd-verify-work (docker NOT RUN only when no daemon answers and PHASE198_REQUIRE_DOCKER is unset)."
|
||||
- "D-08, D-09, D-10, D-12: diary has unit tests for every config rule and its key-naming error, env strings, component floors and verbosity, quiet on the console only, daily rotation across midnight with an injected clock, retention deleting only base-YYYY-MM-DD+ext files, 0640 files, an unwritable directory, path confinement, redaction on every driver, control-character stripping, loggers created before Setup, Reset, HideOnTTY and concurrent writes under -race; the console handler and the JSON pipeline pass testing/slogtest."
|
||||
- "D-13..D-18: bonfire golden files pin the list (plain and --all), the JSON listing, per-command help (value, bare and repeatable flags with defaults, examples, long help, passthrough) and the error blocks (hint with {bin}, unknown command with suggestion, -v chain), rendered with the color profile forced off plus one styled list golden; exit codes 0, 1 and 2, Reported silence, duplicate and reserved-flag errors, PeekGlobals and the cobra flags agree, and Call never runs OnGlobals."
|
||||
- "D-03, D-27, D-28: a widget matrix test covers Spinner, Progress, Process, Table and every prompt across terminal, not a terminal, -q, --no-ansi and -n; the live models are tested through Update and View without a terminal; ConfirmProceed never reads input off a terminal; Secret with -n reads piped stdin and fails with a hint otherwise."
|
||||
- "D-19, D-21, D-23, D-25, D-29: tests cover the wrong-directory hint, migrate and rollback --pretend and the production confirmation rule (non-TTY proceeds), --json outputs (migrate:status, route:list, admin tokens, key:generate) and -q data, the request log's route pattern, request_id cap and default, Worker.Stats, PendingMigrations, the serve summary and shutdown report builders, summer dev starting serve with forwarded flags, and graceful shutdown returning nil."
|
||||
- "D-17: the gate's plugins stage runs each shared plugin's warn-only TestCommandsDocumented and an additive check (no exported func, method or type and no command Name or flag Name literal removed since the commit before its sweep commit); the apps stage builds each application and boots bin/<app> list."
|
||||
- "A bug found by these tests in code that is already in the plan 03 release is fixed in its own commit and reaches the applications only after a recorded go-ahead (patch tag, plugin requires, pointer bumps)."
|
||||
artifacts:
|
||||
- path: "scripts/check-phase19.8.sh"
|
||||
provides: "fail-closed multi-repository phase gate with --self-test"
|
||||
contains: "--self-test"
|
||||
- path: "modules/diary/slogtest_test.go"
|
||||
provides: "testing/slogtest conformance for the console handler and the pipeline"
|
||||
contains: "slogtest.TestHandler"
|
||||
- path: "modules/bonfire/golden_test.go"
|
||||
provides: "golden tests with an -update flag"
|
||||
contains: "flag.Bool(\"update\""
|
||||
- path: "modules/bonfire/widgets_test.go"
|
||||
provides: "widget degradation matrix"
|
||||
contains: "TestWidgetMatrix"
|
||||
key_links:
|
||||
- from: "scripts/check-phase19.8.sh"
|
||||
to: "modules/diary/slogtest_test.go"
|
||||
via: "required test names in the go stage"
|
||||
pattern: "TestSlogtest"
|
||||
- from: "modules/bonfire/golden_test.go"
|
||||
to: "modules/bonfire/testdata/list.golden"
|
||||
via: "assertGolden compares rendered output"
|
||||
pattern: "testdata"
|
||||
prohibitions:
|
||||
- statement: "No test is skipped, deleted or weakened to make the gate pass; a failing behaviour is fixed in code or reported."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Golden files and test fixtures contain no real token, key, password or DSN credential."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed or tagged without the Task 5 reply."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented, see plan 01): console commands reach the Artisan bar; a user sees what a command did, can ask for more or less of it, and learns from `--help` how to use it.
|
||||
|
||||
This plan's slice (plan 4 of 4, wave 4; project rule: unit tests are the last plan of every phase): a fail-closed phase gate first (the tracer), then unit and golden tests for diary, bonfire and every framework change of plans 1-3, wired into the gate together with the plugin, application, landing and Docker stages. The shared plugins already carry their tests in their single sweep commits (D-20 allows one commit per plugin repository), so this plan runs them from the gate rather than adding commits there. A conditional go-ahead covers fixes to code that plan 3 already released.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Spec-less probe fallback skipped: no requirement IDs (phase_req_ids null) and no SPEC.md; visible choice. Tests trace to SC-1..SC-5 and the D-IDs in `requirements`.
|
||||
|
||||
## Decisions taken in this plan (recorded assumptions and Claude's discretion)
|
||||
|
||||
- Goldens render with the color profile forced to NoTTY (no escape codes) through the injectable console constructor, plus one styled golden (`list_styled.golden`, ANSI256) so a palette change is visible in review. The `-update` flag and `assertGolden` helper copy internal/build/new_golden_test.go.
|
||||
- The console handler is checked with testing/slogtest through a small parser of its `key=value` attribute output (dotted group keys become nested maps); the full pipeline (component handler, sunscreen, stack) is checked through its JSON output.
|
||||
- No teatest dependency: models are tested by calling Update and View; one end-to-end run uses `tea.WithInput(nil)`, a buffer output and a fixed window size (RESEARCH Testing techniques).
|
||||
- The plugin additive check finds each plugin's sweep commit by its subject ("console help, examples and named loggers (SummerCMS v") and compares against its parent, so no base hash has to be typed; sm-user-plugin is skipped with a NOT SWEPT line when plan 3 deferred it.
|
||||
- Post-release fixes follow the Phase 19.5 precedent: Task 5 is presented only when Tasks 2-4 changed non-test code that is in the plan 3 tag.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `scripts/check-phase19.8.sh` with stages `self-test`, `go`, `hello`, `docs`, `plugins`, `apps`, `landing`, `docker` and `--all`; environment overrides `PHASE198_ROOT`, `PHASE198_REQUIRE_DOCKER`, `PLUGINS_ROOT`, `FONOTEKA`, `DEMO_APP`, `BM_APP`, `GRZYBY_APP`, `SUMMERCMSIO_APP`.
|
||||
- Test files (names under each task): diary `diary_test.go`, `config_test.go`, `daily_test.go`, `console_test.go`, `slogtest_test.go`; bonfire `globals_test.go`, `root_test.go`, `golden_test.go`, `errors_test.go`, `docs_test.go`, `widgets_test.go`, `live_test.go`, `version_test.go` and goldens under `modules/bonfire/testdata/`; docsite `check_commands_test.go`; surf `summary_test.go`; conga `stats_test.go`, `logger_test.go`; lagoon `pending_test.go`, `commands_flags_test.go`, `logger_test.go`; cabana `token_json_test.go`; flare `commands_test.go`; internal/dev `watch_test.go`; cmd/summer `runtime_test.go`.
|
||||
|
||||
<objective>
|
||||
Prove the phase: a gate that cannot pass on skipped or missing tests, unit and golden tests for every new behaviour in diary, bonfire and the swept framework commands, the shared plugins' and applications' checks run from one script, and a controlled path for fixing anything already released.
|
||||
|
||||
Purpose: SC-5 (unit tests in the last plan) and the D-IDs listed in requirements.
|
||||
Output: the gate script, test commits per module, a green `--all` run recorded in the SUMMARY, and, only if needed, the post-release go-ahead and its publication.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-CONTEXT.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-RESEARCH.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-01-SUMMARY.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-02-SUMMARY.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-03-SUMMARY.md
|
||||
|
||||
Repos and concurrency: tests and the gate are written in summercms.go only; the gate reads the sibling repositories and builds the applications without committing there (a build that rewrites an application's main.go must leave no diff, because plan 3 committed the regenerated main.go; if it does, report). Check `git status --porcelain` on each task's paths first. Stage by explicit path; no co-author tags; test commits separate from any fix commit; `go vet ./...`, `go test -short ./...` and `go -C examples/hello test ./...` green at every commit. Docker tests run on a Docker host, through `sudo -u golem` if the socket is denied; a Docker test that could not run is NOT RUN in the SUMMARY.
|
||||
|
||||
<interfaces>
|
||||
- Gate pattern: scripts/check-phase19.4.sh and scripts/check-phase19.5.sh (stage functions, required-name arrays run with `-run '^(a|b)$'`, go test -json parsing that fails on fail, skip, zero tests or a missing name, `--self-test` with planted output, `unset FORCE_COLOR`, `export NO_COLOR=1`, app names split as `"fono""teka.go"` so the docs checker never sees them whole).
|
||||
- Golden helper to copy: internal/build/new_golden_test.go:13 (`var update = flag.Bool("update", false, ...)`) and :76-96 (`assertGolden`, `firstDiff`).
|
||||
- Console test seam: modules/bonfire/output.go `newConsole(in, out, errW, lookup, tty, interactive *bool)` (plan 02 adds quiet, noInteraction and color mode fields); bonfire `DetectProfile`, `Globals`, `PeekGlobals`, `Execute`, `RenderError`, `Describe`, `CheckDocs`, `ConfirmProceed`, `Process`, `PrintAbove`, `BuildVersion` (plan 02 SUMMARY lists the final names).
|
||||
- diary: `Setup`, `Bootstrap`, `For`, `SetVerbosity`, `Verbosity`, `Reset`, `Close`, `ParseConfig`, `DefaultConfig`, `NewConsoleHandler`, `HideOnTTY`, `Options.Now` (plan 01 and 03 SUMMARYs).
|
||||
- Smoke tests already present (keep them; the gate requires them): TestDiaryTracerSmoke, TestConfigEnvSmoke, TestDailyDegradesSmoke, TestRiverLoggerUsesDiarySmoke, TestGormLoggerUsesDiarySmoke, TestListTracerSmoke, TestHelpSmoke, TestListJSONSmoke, TestErrorRenderSmoke, TestWidgetMatrixSmoke, TestServeSummarySmoke, TestShutdownReportSmoke, TestServeLifecycle (Docker), TestCommandsDocumented, TestGreeterDocumented (examples/hello), TestBuiltBinaryListsCommands (examples/hello).
|
||||
- Plugin repositories and application paths: plan 03 frontmatter and SUMMARY (which plugins were swept, the tag, which applications were bumped).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: scripts/check-phase19.8.sh runs the go stage with plans 1-3's smoke tests by name and fails when one is missing, skipped or red, proven by --self-test</name>
|
||||
<files>scripts/check-phase19.8.sh</files>
|
||||
<read_first>scripts/check-phase19.5.sh (whole file: structure, detectors, self-test), scripts/check-phase19.4.sh (stages, NOT RUN rules, additive check), 19.8-01-SUMMARY.md, 19.8-02-SUMMARY.md, 19.8-03-SUMMARY.md (final test names), 19.8-RESEARCH.md (Validation Architecture)</read_first>
|
||||
<action>Create scripts/check-phase19.8.sh (bash, `set -euo pipefail`, `unset FORCE_COLOR`, `export NO_COLOR=1`) in the 19.5 gate's structure with a header comment describing every stage. Roots: `ROOT` from PHASE198_ROOT or the script's parent; `PLUGINS_ROOT` default `$ROOT/..`; `FONOTEKA` default `$ROOT/../fono""teka.go`; application defaults next to ROOT. Stages in this task: `self-test` (planted go test -json streams for a pass, a fail, a skip, a subtest-only pass, zero tests and "no tests to run" must be classified correctly, and a missing required name must fail) and `go` (`go vet ./...`, `go test -count=1 -short ./...`, then required names per package with `go test -count=1 -short -json -run '^(...)$'`: diary TestDiaryTracerSmoke TestConfigEnvSmoke TestDailyDegradesSmoke; bonfire TestListTracerSmoke TestHelpSmoke TestListJSONSmoke TestErrorRenderSmoke TestWidgetMatrixSmoke; conga TestRiverLoggerUsesDiarySmoke; lagoon TestGormLoggerUsesDiarySmoke; surf TestServeSummarySmoke TestShutdownReportSmoke TestAccessLogSmoke; cmd/summer TestCommandsDocumented TestDocsTree; then `go test -count=1 -race -short ./modules/diary/... ./modules/bonfire/...`). Stubs that print "stage <name>: not implemented" and exit 1 for hello, docs, plugins, apps, landing and docker until Task 4 (so `--all` cannot pass early). `--all` runs self-test, go, hello, docs, plugins, apps, landing, then docker (NOT RUN docker when `docker info` fails, a failure with PHASE198_REQUIRE_DOCKER=1). Make it executable (git mode 100755). Commit: `test(19.8): phase gate with the smoke tests by name`.</action>
|
||||
<verify>
|
||||
<automated>bash scripts/check-phase19.8.sh --self-test && bash scripts/check-phase19.8.sh go</automated>
|
||||
<fails_when>Non-zero exit: a detector misclassifies planted output in --self-test, or a required smoke test is missing, skipped or failing, or vet, the short suite or the race run fail.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `test -x scripts/check-phase19.8.sh` exits 0 and `M=$(git ls-files -s scripts/check-phase19.8.sh) && test "${M:0:6}" = 100755` exits 0.
|
||||
- `grep -c 'NO_COLOR=1' scripts/check-phase19.8.sh` prints at least 1 and `grep -cE '"?fono""teka' scripts/check-phase19.8.sh` prints at least 1.
|
||||
- `bash scripts/check-phase19.8.sh --all; test $? -ne 0` exits 0 at this point (the unimplemented stages keep --all red).
|
||||
- `grep -cE 'TestDiaryTracerSmoke|TestListTracerSmoke|TestServeSummarySmoke' scripts/check-phase19.8.sh` prints at least 3.
|
||||
</acceptance_criteria>
|
||||
<done>One command proves the phase's smoke tests ran and passed by name, and the gate cannot be fooled by a skip, a typo or an empty run.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: diary, sunscreen and the River and GORM bridges have unit tests: config rules, env strings, floors, verbosity, quiet, daily rotation and retention, redaction on every driver, slogtest conformance, concurrency</name>
|
||||
<files>modules/diary/diary_test.go, modules/diary/config_test.go, modules/diary/daily_test.go, modules/diary/console_test.go, modules/diary/slogtest_test.go, modules/sunscreen/sunscreen_test.go, modules/conga/logger_test.go, modules/lagoon/logger_test.go, scripts/check-phase19.8.sh</files>
|
||||
<behavior>
|
||||
- TestConfigRules (table): every invalid driver, format, level, days, stack child, stack cycle and escaping path returns an error naming its exact key; valid YAML lists and comma strings parse alike.
|
||||
- TestConfigEnvStrings: SUMMER_LOG__ overrides as strings overlay defaults key by key; TestDefaultConfig equals the D-08 example with components river warn.
|
||||
- TestComponentFloors and TestVerbosityLevels: components[name] beats log.level; verbosity 1 lets INFO through for a warn component, 2 lets DEBUG through, 3 also sets AddSource; TestQuietConsoleOnly: quiet raises the console to WARN while a daily INFO record is still written.
|
||||
- TestDailyRotation (clock 23:59:59 to 00:00:01 gives two files), TestDailyRetention (days 2 deletes only matching names older than 2 days, keeps other files), TestDailyPermissions (0640 file, 0750 dir), TestDailyUnwritable (one warning, later writes succeed silently), TestDailyLazyOpen (no file without a record), TestPathConfinement (relative escape rejected, absolute accepted).
|
||||
- TestRedactionEveryDriver: console, stderr, daily and stack outputs show [REDACTED] for password, token and api_key attributes and a scrubbed Bearer string in the message; null writes nothing.
|
||||
- TestConsoleStripsControlChars, TestConsoleComponentPrefix (fixed width), TestConsoleStyledOnlyOnTerminal (NoTTY profile has no escape codes, ANSI256 has them).
|
||||
- TestForBeforeSetup, TestResetRestoresDefault, TestNoRecursionWithoutState, TestHideOnTTY (skipped by the styled console only), TestConcurrentWrites (100 goroutines, -race, every line intact).
|
||||
- TestSlogtestConsoleHandler and TestSlogtestPipeline pass testing/slogtest.TestHandler.
|
||||
- TestInstallDefaultUnchanged (sunscreen): InstallDefault still installs a redacting TextHandler on the given writer.
|
||||
- TestRiverLoggerComponent and TestRiverLoggerPublishedOverride (conga); TestGormLoggerLevels (lagoon): Warn by default, Info at verbosity 3, record-not-found ignored, parameterized SQL in the trace.
|
||||
</behavior>
|
||||
<read_first>modules/diary/*.go (all, from plans 01-03), modules/diary/tracer_smoke_test.go, modules/diary/config_smoke_test.go, modules/sunscreen/sunscreen_test.go, modules/conga/logger_smoke_test.go, modules/lagoon/logger_smoke_test.go, scripts/check-phase19.8.sh, Go source of testing/slogtest (TestHandler and its results contract), 19.8-RESEARCH.md (Pitfalls 4-8, Security Domain)</read_first>
|
||||
<action>Write the tests in the behavior list (red first against any behaviour that turns out wrong; fix the code in a separate `fix(diary): ...` commit when a test exposes a bug, and record it in the SUMMARY's "Post-release fixes" table when the code is already in the plan 03 tag). The slogtest console parser reads one line per record: the fields after the message as key=value pairs, dotted keys nested into maps, the time and level keys mapped to slog.TimeKey and slog.LevelKey, the message to slog.MessageKey. Concurrency and time come from Options.Now and goroutines with a WaitGroup; no sleeps as synchronisation. Add every new test name to the gate's go stage required lists (diary, sunscreen, conga, lagoon). Commits: `test(diary): config, channels, rotation, redaction and slogtest` and `test(conga,lagoon): River and GORM bridges`.</action>
|
||||
<verify>
|
||||
<automated>go test -count=1 -short -race ./modules/diary/... ./modules/sunscreen/... && go test -count=1 -short -run '^(TestSlogtestConsoleHandler|TestSlogtestPipeline|TestRedactionEveryDriver|TestDailyRotation|TestDailyRetention|TestConfigRules)$' -v ./modules/diary 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 6 && go test -count=1 -short ./modules/conga/... ./modules/lagoon/... && bash scripts/check-phase19.8.sh go</automated>
|
||||
<fails_when>Non-zero exit (including a race report), or the count of top-level "--- PASS: Test" lines is not 6 (slogtest, redaction, rotation, retention or the config table failed or did not run), or the gate's go stage misses a newly required name.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'slogtest.TestHandler' modules/diary/slogtest_test.go` prints at least 2.
|
||||
- `grep -cE 'func Test(ConfigRules|ConfigEnvStrings|ComponentFloors|VerbosityLevels|QuietConsoleOnly|DailyRotation|DailyRetention|DailyPermissions|DailyUnwritable|DailyLazyOpen|PathConfinement|RedactionEveryDriver|ConsoleStripsControlChars|ForBeforeSetup|ResetRestoresDefault|HideOnTTY|ConcurrentWrites)\(' modules/diary/*_test.go | awk -F: '{s+=$2} END {print s}'` prints 17.
|
||||
- `grep -cE 'TestSlogtestConsoleHandler|TestRedactionEveryDriver|TestGormLoggerLevels' scripts/check-phase19.8.sh` prints at least 3.
|
||||
- `grep -rnE 'time\.Sleep' modules/diary/*_test.go | wc -l` prints 0.
|
||||
</acceptance_criteria>
|
||||
<done>Every logging rule of D-08, D-09, D-10 and D-12 is pinned by a test that runs in the gate, including slogtest conformance and race-free concurrent writes.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: bonfire has golden and unit tests for list, help, the JSON listing, error blocks, exit codes, global flags, duplicates, the widget matrix, the live models and prompts; the docs checker knows the global flags</name>
|
||||
<files>modules/bonfire/globals_test.go, modules/bonfire/root_test.go, modules/bonfire/golden_test.go, modules/bonfire/errors_test.go, modules/bonfire/docs_test.go, modules/bonfire/widgets_test.go, modules/bonfire/live_test.go, modules/bonfire/version_test.go, modules/bonfire/testdata/list.golden, modules/bonfire/testdata/list_all.golden, modules/bonfire/testdata/list.json.golden, modules/bonfire/testdata/help_token_create.golden, modules/bonfire/testdata/help_passthrough.golden, modules/bonfire/testdata/error_hint.golden, modules/bonfire/testdata/error_unknown_command.golden, modules/bonfire/testdata/error_chain_verbose.golden, modules/bonfire/testdata/list_styled.golden, internal/docsite/check_commands_test.go, scripts/check-phase19.8.sh</files>
|
||||
<behavior>
|
||||
- TestPeekGlobals (table): -v, -vv, -vvv, --verbose twice, -q implies -n, --ansi then --no-ansi (last wins), --env X and --env=X, stop at --, unknown flags ignored, never an error; TestGlobalsArgs round-trips; TestGlobalFlagsAgree: the OnGlobals hook sees the same Globals as PeekGlobals for each table row.
|
||||
- TestReservedFlags (each reserved name and shorthand returns an error naming command and flag, no panic), TestDuplicateNames (two commands, and a command named list or help), TestCallNoGlobalsHook.
|
||||
- TestListGolden, TestListAllGolden, TestListJSONGolden, TestListStyledGolden; TestHelpGolden (admin:token:create shape from D-14 with value, bare and repeatable flags, defaults "*" and 90, long help, two examples) and TestHelpPassthroughGolden; TestHelpEqualsHelpFlag.
|
||||
- TestErrorGolden (hint with {bin} as bin/acme, unknown command with suggestion, chain at -v), TestExitCodes (0 success, 1 failure, 2 unknown command, unknown flag and wrong argument count), TestReportedSilent, TestErrorScrubbed (a Bearer token and a password attribute in the message), TestWrongDirectoryHint (temp tree: app root with summer.yaml, run from plugins/ without config/ gives `cd ..; bin/acme migrate`; summer gets no such hint).
|
||||
- TestCheckDocs: each missing item is reported, results sorted, a complete command reports nothing.
|
||||
- TestVersionHeader: tagged dep, replaced dep (local), go.work devel and the summer tool's own build (through an injectable build-info reader).
|
||||
- TestWidgetMatrix: Spinner, Progress, Process, Table, Info, Success, Warning, Error, Println across terminal, not a terminal, -q, --no-ansi and -n; not-a-terminal output byte-identical to the pre-phase format.
|
||||
- TestLiveModels: spinner, progress (Current and Fail), process (pending, running, done, failed rows) and selector (move, enter, confirmation screen, esc back, ctrl+c abort) through Update and View; one end-to-end program run with tea.WithInput(nil) into a buffer.
|
||||
- TestPromptsNoInteraction (defaults without reading stdin), TestSecretWithNoInteraction (piped stdin read; terminal-like stdin returns a HintError), TestConfirmProceed (non-interactive returns true without reading; interactive asks), TestPrintAbove (false without a program; a running program receives the line).
|
||||
- TestCommandWordGlobals (docsite): `bin/acme -v serve`, `summer -q migrate`, `bin/acme --env staging migrate`, `bin/acme --env=staging migrate`, `summer list`, `bin/acme help migrate` resolve to the right command word.
|
||||
</behavior>
|
||||
<read_first>modules/bonfire/*.go (all, from plans 01-03), modules/bonfire/*_smoke_test.go, modules/bonfire/output_test.go, modules/bonfire/prompts_test.go, internal/build/new_golden_test.go (:13, :76-96), internal/docsite/check_commands.go, existing internal/docsite tests for commandWord, scripts/check-phase19.8.sh, 19.8-CONTEXT.md (D-13, D-14 and D-18 previews)</read_first>
|
||||
<action>Write the tests in the behavior list. golden_test.go copies the `update` flag and `assertGolden` (path `testdata/<name>.golden`, first-difference message, "run go test -run <Test> -update to create it"). Goldens use a fixed Version (`SummerCMS acme v1.4.0 (framework v0.3.2)`) and the NoTTY profile except list_styled.golden (ANSI256). Create the goldens with `-update`, then read each one and confirm it matches the D-13, D-14 and D-18 previews in substance (sections, order, alignment); record any intentional difference in the SUMMARY. A behaviour that a test shows to be wrong is fixed in a separate `fix(bonfire): ...` commit (Post-release fixes table when already tagged). Add every new test name to the gate's go stage (bonfire and docsite lists). Commits: `test(bonfire): goldens for list, help and errors`, `test(bonfire): flags, exit codes and the widget matrix`, `test(docsite): command word with global flags`.</action>
|
||||
<verify>
|
||||
<automated>go test -count=1 -short -race ./modules/bonfire/... ./internal/docsite/... && go test -count=1 -short -run '^(TestListGolden|TestHelpGolden|TestErrorGolden|TestExitCodes|TestWidgetMatrix|TestLiveModels|TestPeekGlobals|TestWrongDirectoryHint)$' -v ./modules/bonfire 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 8 && go test -count=1 -short -run '^TestCommandWordGlobals$' -v ./internal/docsite 2>&1 | grep -q -- '^--- PASS: TestCommandWordGlobals' && bash scripts/check-phase19.8.sh go</automated>
|
||||
<fails_when>Non-zero exit (including a race report or a golden mismatch), the count of top-level "--- PASS: Test" lines is not 8, no "--- PASS: TestCommandWordGlobals", or the gate's go stage misses a newly required name.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `ls modules/bonfire/testdata/*.golden | wc -l` prints 9.
|
||||
- `grep -c 'flag.Bool("update"' modules/bonfire/golden_test.go` prints 1.
|
||||
- `grep -c 'Available commands:' modules/bonfire/testdata/list.golden` prints 1, `grep -c 'Examples:' modules/bonfire/testdata/help_token_create.golden` prints 1 and `grep -c 'Try:' modules/bonfire/testdata/error_hint.golden` prints 1.
|
||||
- `grep -c $'\x1b\\[' modules/bonfire/testdata/list.golden` prints 0 and `grep -c $'\x1b\\[' modules/bonfire/testdata/list_styled.golden` prints at least 1.
|
||||
- `grep -rnEi 'eyJ[a-zA-Z0-9_-]{10,}|sk-[A-Za-z0-9]{20,}|BEGIN (RSA |EC )?PRIVATE KEY' modules/bonfire/testdata | wc -l` prints 0.
|
||||
</acceptance_criteria>
|
||||
<done>The approved list, help and error layouts are pinned by goldens, and every flag, exit-code, widget and prompt rule of D-03, D-13..D-18, D-27 and D-28 is a test in the gate.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 4: The swept framework commands have unit tests (request log, summaries, reports, stats, pending migrations, --pretend, confirmation, --json, -q data, Reported, dev runs serve), the gate's remaining stages run, and --all is green</name>
|
||||
<files>modules/surf/summary_test.go, modules/surf/accesslog_test.go, modules/conga/stats_test.go, modules/lagoon/pending_test.go, modules/lagoon/commands_flags_test.go, modules/cabana/token_json_test.go, modules/flare/commands_test.go, internal/dev/watch_test.go, cmd/summer/runtime_test.go, scripts/check-phase19.8.sh</files>
|
||||
<behavior>
|
||||
- TestServeSummaryLines (verbosity 0 block, verbosity 1 per-plugin lines, quiet prints no block, DSN user and password never present, keyword-form DSN and unparsable DSN), TestServeRecords (one "startup summary" record with every fact, one record whose message equals "listening on <addr>", HideOnTTY used only when the block printed), TestShutdownReportAborted (in-flight requests past the deadline counted as aborted, drain error makes Run return it), TestServeGracefulNil.
|
||||
- TestAccessLogRequestIDCap (300-byte X-Request-Id stored as 128 bytes, control characters trimmed), TestAccessLogDefaultOn, TestAccessLogDisabled (http.access_log false), TestAccessLogNeverLogsPath (a token-bearing path and query never appear for matched, 404 and 405 routes), TestAccessLogComponentHTTP.
|
||||
- TestWorkerStats (done, failed and in-flight counters through runAttempt with fake job functions).
|
||||
- TestPendingMigrations (Docker: none applied, some applied, missing history table) and TestMigratePretend (Docker: lists pending IDs, applies nothing), TestRollbackPretend, TestDeferredPurgePretend (count only, no rows deleted).
|
||||
- TestConfirmProductionNonTTYProceeds (production, non-interactive console: migrate, migrate:rollback, queue:clear, deferred:purge run without reading stdin), TestConfirmProductionInteractiveNo (interactive console answering no returns a Reported cancel), TestForceSkipsConfirm.
|
||||
- TestKeyGenerateJSON and TestKeyGenerateQuiet (bare key under -q), TestMigrateStatusJSON, TestRouteListJSON, TestTokenCreateJSON and TestTokenListJSON (cabana, Docker or a fake store as the package's tests allow), TestTokenCreateQuietKeepsToken.
|
||||
- TestFlareReported (generate and test-push failures return errors matching bonfire.ErrReported and print one message).
|
||||
- TestStartBinServe (dev passes "serve" then the forwarded globals), TestDevReport (rebuild, failure and restart counts), TestDevGracefulNil.
|
||||
- TestDelegateExamples (every delegate has examples rendered with summer, and Long mentions that global flags pass through).
|
||||
</behavior>
|
||||
<read_first>modules/surf/summary.go, modules/surf/serve.go, modules/surf/accesslog.go, modules/surf/accesslog_test.go, modules/surf/serve_summary_smoke_test.go, modules/surf/serve_lifecycle_test.go, modules/conga/worker.go, modules/conga/commands.go, modules/lagoon/migrations.go, modules/lagoon/commands.go, modules/lagoon/keygen.go, modules/lagoon/postgres_test.go, modules/cabana/token_commands.go, modules/cabana/commands_test.go, modules/flare/commands.go, internal/dev/watch.go, internal/dev/watch_test.go, cmd/summer/runtime.go, cmd/summer/runtime_test.go, scripts/check-phase19.8.sh, scripts/check-phase19.4.sh (additive check and NOT RUN rules), 19.8-03-SUMMARY.md (tag, swept plugins, bumped applications, landing and deck commits)</read_first>
|
||||
<action>Write the tests in the behavior list (Docker ones skip under -short through the packages' TestMain that calls flag.Parse and testing.Short). Fix exposed bugs in separate `fix(<module>): ...` commits and list released ones in the SUMMARY's Post-release fixes table.
|
||||
|
||||
Then implement the gate's remaining stages in scripts/check-phase19.8.sh. `hello`: `go -C examples/hello vet ./...`, `go -C examples/hello test -count=1 ./...` and required names TestBuiltBinaryListsCommands, TestGreeterDocumented, TestHelpListsServe, TestBuiltBinaryUnknownCommandExitsNonZero. `docs`: the decision note names all six modules; TestDocsTree; `go run ./cmd/summer docs:build --check`; no file under modules/diary, modules/bonfire, docs/console or docs/services/logging.md names a consuming application (names built from split strings); every listed command in `go run ./cmd/summer list --format=json` has examples. `plugins`: for each swept repository from 19.8-03-SUMMARY.md (default list: the 12 D-20 repositories, sm-golem-plugin and sm-feedback-plugin under FONOTEKA, sm-user-plugin under FONOTEKA unless plan 3 deferred it, printed as NOT SWEPT): `go vet ./...`, `go test -count=1 -short ./...`, its `TestCommandsDocumented` passed by name (warn-only, D-17), `GOWORK=off go build ./...`, and the additive check: find the sweep commit by its subject, and in `git diff <sweep>^ <sweep> -- '*.go' ':!*_test.go'` no removed line declares an exported func, method or type and no removed `Name: "<x>"` literal in console files is missing from the added lines. `apps`: for each application, `go build ./...`, the generated main.go has no diff after `summer build` in a temporary copy (or `git diff --exit-code main.go` after build), `bin/<app> list` and `bin/<app> help migrate` exit 0. `landing`: `npm --prefix $SUMMERCMSIO_APP/vue-summercmsio-app run test`, the Go terminal tests of sm-summercmsio-app, sm-demo-app's roadmap tests and `scripts/sync-roadmap.sh --check`. `docker`: TestServeLifecycle, TestPendingMigrations, TestMigratePretend and the cabana JSON tests by name without -short; NOT RUN rules as in the 19.4 gate. Add every new name from this task to the go and docker lists, extend --self-test with one planted case per new detector (additive-check removal, NOT SWEPT handling).
|
||||
|
||||
Run `bash scripts/check-phase19.8.sh --all` on a Docker host until green and paste its stage summary into the SUMMARY. Post a short comment on the Phase 19.8 milestone task in g15office (ID recorded in .planning/G15OFFICE.md by plan 03) that the phase is executed and waiting on verification; if unavailable, record that. Commits: `test(surf,conga,lagoon): summaries, reports, stats and checklist flags`, `test(cabana,flare,dev,summer): json, reported errors and dev serve`, `test(19.8): gate stages for hello, docs, plugins, apps, landing and docker`.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./... && bash scripts/check-phase19.8.sh --self-test && LOG="${TMPDIR:-/tmp}/check-phase19.8.log" && bash scripts/check-phase19.8.sh --all >"$LOG" 2>&1 && tail -20 "$LOG" && ! grep -qE '^(FAIL|stage [a-z]+: not implemented)' "$LOG"</automated>
|
||||
<fails_when>Non-zero exit from vet, the short suite, the self-test or --all (any stage red, docker red with PHASE198_REQUIRE_DOCKER=1), or the log still contains a FAIL line or a "not implemented" stage.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'not implemented' scripts/check-phase19.8.sh` prints 0.
|
||||
- `grep -cE 'TestAccessLogNeverLogsPath|TestConfirmProductionNonTTYProceeds|TestStartBinServe|TestWorkerStats|TestPendingMigrations' scripts/check-phase19.8.sh` prints at least 5.
|
||||
- `grep -c 'GOWORK=off go' scripts/check-phase19.8.sh` prints at least 1 and `grep -c 'NOT RUN' scripts/check-phase19.8.sh` prints at least 1.
|
||||
- The SUMMARY holds the --all stage summary with every stage PASS, or docker as NOT RUN with the reason.
|
||||
</acceptance_criteria>
|
||||
<done>Every swept framework behaviour has a test, and one green --all run proves the framework, the example application, the docs, every shared plugin, every application, the landing site and the Docker-backed paths together.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 5: Go-ahead to release this plan's post-release fixes (presented only when the Post-release fixes table has a row)</name>
|
||||
<read_first>19.8-04-SUMMARY.md draft (Post-release fixes table), 19.8-03-SUMMARY.md (tag, plugin commits, application commits), CLAUDE.md (Commits and concurrency, changelog rule)</read_first>
|
||||
<acceptance_criteria>
|
||||
- When the table is empty, the SUMMARY says "no post-release fixes" and this checkpoint and Task 6 are skipped.
|
||||
- Otherwise the SUMMARY records the reply verbatim under a "Task 5 reply" heading, and before the reply `T=$(git tag --points-at HEAD) && test -z "$T"` exits 0.
|
||||
</acceptance_criteria>
|
||||
<decision>Release the listed fixes now, and with which patch tag?</decision>
|
||||
<context>Tasks 2-4 found bugs in code that is already in the plan 03 tag; each fix is committed separately on master and listed in the Post-release fixes table with the module, the symptom and the test that pins it. Releasing means a patch tag of the framework with a same-day changelog post; plugins only need a require bump when a fix changes an API they use (the table says so per row), and applications only need a regenerate commit when a fix changes the generated main. Nothing is deployed in any option.</context>
|
||||
<options>
|
||||
<option id="release">
|
||||
<name>release <tag> (for example "release v0.4.1")</name>
|
||||
<pros>Applications and plugins get the fixes pinned and reproducible; the changelog shows them.</pros>
|
||||
<cons>Another tag and changelog post the same day.</cons>
|
||||
</option>
|
||||
<option id="push-only">
|
||||
<name>push-only</name>
|
||||
<pros>Fixes are on origin/master for the next release; no tag today.</pros>
|
||||
<cons>Tagged consumers keep the plan 03 release until the next tag.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>hold</name>
|
||||
<pros>Nothing leaves this machine.</pros>
|
||||
<cons>The fixes stay local; the verifier sees them as unpublished.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "release vX.Y.Z", "push-only" or "hold". A release also authorises plugin require bumps and application regenerate commits named in the table, pushed when their unpushed commits are this phase's; nothing is ever force-pushed or deployed.</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 6: Under the Task 5 reply, publish the post-release fixes: patch tag and changelog post, then the plugin requires and application commits the table names</name>
|
||||
<precondition>19.8-04-SUMMARY.md (draft) records the Task 5 reply, or "no post-release fixes" (then this task records "nothing to publish" and stops).</precondition>
|
||||
<files>.planning/phases/19.8-console-commands-overhaul/19.8-04-SUMMARY.md</files>
|
||||
<read_first>19.8-04-SUMMARY.md draft (Post-release fixes table, Task 5 reply), 19.8-03-PLAN.md Task 7 (release steps to repeat), ../sm-summercmsio-app/DEPLOY.md ("Posting to the changelog")</read_first>
|
||||
<action>On "release vX.Y.Z": same push rules and steps as plan 03 Task 7 step (1) (fetch, origin..master holds only approved commits, vet and short tests green, annotated tag with the fixes listed, push master then the tag, changelog post with user-facing notes under ## Fixed, never print the token); then for each plugin row marked "needs require": `go get git.golem15.com/golem15/summercms@vX.Y.Z`, `go mod tidy`, vet and short tests, one commit `chore: require SummerCMS vX.Y.Z`, push; then for each application row marked "regenerate": regenerate main.go, build, `bin/<app> list`, one commit `build: regenerate main for SummerCMS vX.Y.Z`, push. On "push-only": push framework master only. On "hold": nothing. Record every tag, push range and the changelog post URL in the SUMMARY, then re-run `bash scripts/check-phase19.8.sh --all` and paste the summary.</action>
|
||||
<verify>
|
||||
<automated>LOG="${TMPDIR:-/tmp}/check-phase19.8-post.log" && bash scripts/check-phase19.8.sh --all >"$LOG" 2>&1 && tail -20 "$LOG" && ! grep -q '^FAIL' "$LOG"</automated>
|
||||
<fails_when>Non-zero exit from the gate, or a line starting with FAIL in its log (for example a plugin no longer builds at the patch tag). On "hold" or with no fixes the command is not run and the SUMMARY says nothing changed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- On release: `git ls-remote --exit-code --tags origin vX.Y.Z` exits 0 and the SUMMARY holds the changelog post URL.
|
||||
- The SUMMARY lists each published repository with its pushed range, or "nothing to publish".
|
||||
</acceptance_criteria>
|
||||
<done>Any fix to released code reaches its consumers only with the user's go-ahead, and the gate is green afterwards.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| test fixtures and goldens to the repository | committed files are public with the next push |
|
||||
| the gate to sibling repositories | the gate builds applications and reads plugin history without committing there |
|
||||
| this machine to git.golem15.com and summercms.io | only post-release fixes, only after the Task 5 reply |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.8-21 | Repudiation | scripts/check-phase19.8.sh | medium | mitigate | fails on failed, skipped, zero or missing required tests and on "no tests to run"; --self-test proves each detector on planted output; unimplemented stages keep --all red |
|
||||
| T-19.8-22 | Information disclosure | goldens and fixtures | low | mitigate | goldens use fixed fake values; acceptance scans testdata for JWT, sk- keys and private key headers |
|
||||
| T-19.8-23 | Tampering | released code with bugs | medium | mitigate | fixes in separate commits, listed with their pinning test; published only under the Task 5 reply with a patch tag and changelog |
|
||||
| T-19.8-24 | Tampering | shared plugin contracts | medium | mitigate | the plugins stage's additive check fails on any removed exported declaration or command or flag name in a sweep commit |
|
||||
| T-19.8-25 | Information disclosure | request log regression | high | mitigate | TestAccessLogNeverLogsPath covers matched, 404 and 405 routes with token-bearing paths and queries, run in the go stage |
|
||||
| T-19.8-SC | Tampering | dependencies | low | accept | this plan adds no module; tests use the standard library, testing/slogtest and the already-required testcontainers |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: --self-test and the go stage.
|
||||
- Task 2: diary, sunscreen, conga and lagoon unit tests with -race; slogtest.
|
||||
- Task 3: bonfire goldens and unit tests with -race; docsite command word.
|
||||
- Task 4: framework command tests; `--all` green across every stage (docker NOT RUN only without a daemon).
|
||||
- Tasks 5-6: only with post-release fixes; reply recorded; gate re-run.
|
||||
- Feedback sampling: after each test commit `go test -short` of the touched packages plus `bash scripts/check-phase19.8.sh go`; before /msd-verify-work, `bash scripts/check-phase19.8.sh --all`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- The phase gate exists, is fail-closed and green with --all.
|
||||
- Unit and golden tests cover diary, bonfire and every swept framework behaviour (project rule: unit tests in the last plan).
|
||||
- Shared plugins' warn-only coverage tests and additive checks run from the gate; applications build and boot list.
|
||||
- Released code receives fixes only through the recorded go-ahead.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.8-console-commands-overhaul/19.8-04-SUMMARY.md` when done, with the test commits, any fix commits and the Post-release fixes table (module, symptom, pinning test, in tag yes or no, needs require, regenerate), the --all stage summary, Docker tests run or NOT RUN, the Task 5 reply (or "no post-release fixes"), anything published in Task 6, and the g15office comment.
|
||||
</output>
|
||||
@@ -0,0 +1,301 @@
|
||||
---
|
||||
phase: 19.8-console-commands-overhaul
|
||||
plan: 04
|
||||
subsystem: console
|
||||
tags: [tests, gate, diary, bonfire, slogtest, goldens, surf, conga, lagoon, cabana, flare, dev]
|
||||
|
||||
requires:
|
||||
- phase: 19.8-01
|
||||
provides: diary (channels, components, daily files, console handler)
|
||||
- phase: 19.8-02
|
||||
provides: bonfire global flags, list, help, errors, widgets, prompts
|
||||
- phase: 19.8-03
|
||||
provides: swept framework commands, request log, summaries, v0.4.0, swept plugins, applications
|
||||
provides:
|
||||
- "scripts/check-phase19.8.sh: fail-closed gate (self-test, go, hello, docs, plugins, apps, landing, docker) with --self-test"
|
||||
- "Unit and golden tests for diary, sunscreen, bonfire, docsite and every swept framework command"
|
||||
- "fix(bonfire): PeekGlobals reads clustered shorthands, released as SummerCMS v0.4.1 with its changelog post"
|
||||
affects: [19.8 verification, the next framework tag]
|
||||
|
||||
actuals:
|
||||
tokens: 53163 # chars/4 over the added lines of this plan's diff (goldens excluded)
|
||||
tasks: 6 # Tasks 1-4, the Task 5 checkpoint and Task 6 (release v0.4.1)
|
||||
commits: 11 # MEASURED: git rev-list --count plan_head_before..plan_head_after
|
||||
plan_head_before: b906a31c368ad6a59ea8f6ac82121714ffad20da
|
||||
plan_head_after: 72b8383c1022a993b66c6cd8b4f739bdee062246
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Goldens under modules/bonfire/testdata with an -update flag; color detection cleared per test (plainEnv)"
|
||||
- "testing/slogtest for the console handler through a key=value line parser, and for the full pipeline through JSON"
|
||||
- "Interactive-terminal tests on a pseudo-terminal opened with syscall only (/dev/ptmx), no new dependency"
|
||||
- "Gate additive check finds each plugin's sweep commit by subject and diffs it against its parent"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- scripts/check-phase19.8.sh
|
||||
- modules/diary/diary_test.go
|
||||
- modules/diary/config_test.go
|
||||
- modules/diary/daily_test.go
|
||||
- modules/diary/console_test.go
|
||||
- modules/diary/slogtest_test.go
|
||||
- modules/bonfire/globals_test.go
|
||||
- modules/bonfire/root_test.go
|
||||
- modules/bonfire/golden_test.go
|
||||
- modules/bonfire/errors_test.go
|
||||
- modules/bonfire/docs_test.go
|
||||
- modules/bonfire/widgets_test.go
|
||||
- modules/bonfire/live_test.go
|
||||
- modules/bonfire/version_test.go
|
||||
- modules/bonfire/testdata/*.golden (9 files)
|
||||
- internal/docsite/check_commands_test.go
|
||||
- modules/surf/summary_test.go
|
||||
- modules/conga/stats_test.go
|
||||
- modules/conga/logger_test.go
|
||||
- modules/conga/confirm_linux_test.go
|
||||
- modules/lagoon/pending_test.go
|
||||
- modules/lagoon/commands_flags_test.go
|
||||
- modules/lagoon/logger_test.go
|
||||
- modules/lagoon/pty_linux_test.go
|
||||
- modules/cabana/token_json_test.go
|
||||
modified:
|
||||
- modules/bonfire/globals.go
|
||||
- modules/bonfire/version.go
|
||||
- modules/sunscreen/sunscreen_test.go
|
||||
- modules/surf/accesslog_test.go
|
||||
- modules/flare/commands_test.go
|
||||
- internal/dev/watch_test.go
|
||||
- cmd/summer/runtime_test.go
|
||||
|
||||
key-decisions:
|
||||
- "PeekGlobals reads a single-dash cluster letter by letter like pflag (-qn, -vq, -nvv) and stops at the first non-global letter; released as SummerCMS v0.4.1"
|
||||
- "BuildVersion reads build info through an unexported package variable (refactor, no behaviour change) so tests pin the tagged, replaced and go.work cases"
|
||||
- "Interactive confirmation is tested on a real pseudo-terminal (syscall ioctls on /dev/ptmx) instead of adding a pty dependency or a bonfire test seam"
|
||||
- "The landing stage runs nuxt generate before the TAP suite and checks the roadmap snapshot against origin/master; TestTerminalCommands (deployed one-liner) is opt-in"
|
||||
|
||||
requirements-completed: [SC-5, D-03, D-08, D-09, D-10, D-12, D-13, D-14, D-15, D-16, D-17, D-18, D-19, D-21, D-23, D-25, D-27, D-28, D-29]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Fail-closed gate: detectors proven on planted output, additive and NOT SWEPT rules on planted git repositories, docker NOT RUN rule"
|
||||
requirement: SC-5
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "bash scripts/check-phase19.8.sh --self-test"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "diary config rules, floors, verbosity, quiet, daily rotation/retention/permissions/degradation, path confinement, redaction on every driver, control characters, lifecycle, concurrency, slogtest"
|
||||
requirement: D-08
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/diary#TestConfigRules ... TestSlogtestPipeline (27 names in the gate)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "bonfire goldens (list, list --all, JSON, styled, help, passthrough help, three error blocks), flags, exit codes, duplicates, version header"
|
||||
requirement: D-13
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/bonfire#TestListGolden, TestHelpGolden, TestErrorGolden, TestPeekGlobals, TestGlobalFlagsAgree, TestExitCodes"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "The goldens were created with -update and read against the D-13, D-14 and D-18 previews; layout taste is a person's call"
|
||||
- id: D4
|
||||
description: "Widget matrix, live models, prompts, ConfirmProceed, Secret under -n, PrintAbove"
|
||||
requirement: D-03
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/bonfire#TestWidgetMatrix, TestLiveModels, TestPromptsNoInteraction, TestSecretWithNoInteraction, TestConfirmProceed, TestPrintAbove"
|
||||
status: pass
|
||||
- kind: manual_procedural
|
||||
ref: "throwaway program under script(1) on a pty: spinner, progress and process view print their final frames"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Swept framework commands: serve summary/records/report, request log, WorkerStats, PendingMigrations, --pretend, production question, --json, -q data, Reported, summer dev, delegates"
|
||||
requirement: D-29
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "go stage names for surf, conga, lagoon, flare, internal/dev, cmd/summer"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "docker stage: TestServeLifecycle, TestPendingMigrations, TestMigratePretend, TestRollbackPretend, TestDeferredPurgePretend, TestMigrateStatusJSON, TestTokenCreateJSON, TestTokenListJSON, TestTokenCreateQuietKeepsToken"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Plugins (15 repositories), applications (5) and landing run from the gate"
|
||||
requirement: D-17
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "bash scripts/check-phase19.8.sh --all (PHASE198_REQUIRE_DOCKER=1): every stage PASS"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 69min
|
||||
completed: 2026-10-21
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 19.8 Plan 04: unit tests and the phase gate Summary
|
||||
|
||||
**A fail-closed `scripts/check-phase19.8.sh` (eight stages, green with `--all` and Docker required) plus unit, golden, slogtest and pseudo-terminal tests for diary, bonfire and every swept framework command; the tests found one bug in released code, `PeekGlobals` ignoring clustered shorthands such as `-qn`, fixed in its own commit and waiting on the Task 5 go-ahead.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Started:** 2026-10-21T16:47:08Z
|
||||
- **Checkpoint reached:** 2026-10-21T17:56Z (about 69 min)
|
||||
- **Tasks:** 4 of 6 done; Task 5 (checkpoint:decision) presented; Task 6 waits on its reply
|
||||
- **Files:** 40 changed, 6,036 insertions, 7 deletions
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Gate with stages `self-test`, `go`, `hello`, `docs`, `plugins`, `apps`, `landing`, `docker`; detectors for `go test -json` and node `--test` TAP output, the additive check and the NOT SWEPT rule, each proven on planted input.
|
||||
- diary: 24 new tests incl. `testing/slogtest` conformance for the console handler and the pipeline, 100 concurrent writers under `-race`, rotation with an injected clock, retention, 0640 files under a strict umask.
|
||||
- bonfire: 9 goldens and 26 new tests (global flags, exit codes, widgets, live models, prompts, version header).
|
||||
- Framework commands: summaries and reports, request log (never the path), WorkerStats, the production question on a real pseudo-terminal, `--pretend`, `--json`, `-q` data, Reported failures, `summer dev` with a real child process.
|
||||
- `--all` runs the 15 swept plugins (short suites, coverage test, `GOWORK=off` build, additive check), the 5 applications (build, `summer build` leaves `main.go` unchanged, `list`, `help migrate`), the landing site and the Docker suites.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1 (tracer): gate with the smoke tests by name**: `9b2fa6c` test(19.8)
|
||||
2. **Task 2: diary, sunscreen, River and GORM**: `24f4643` test(diary), `db83a14` test(conga,lagoon)
|
||||
3. **Task 3: bonfire and docsite**: `efb043e` fix(bonfire) (post-release fix), `9641a1b` refactor(bonfire), `7b58ad5` test(bonfire) goldens, `eeeee59` test(bonfire) flags/exit codes/widgets, `fd52868` test(docsite)
|
||||
4. **Task 4: framework commands and the remaining stages**: `6720cae` test(surf,conga,lagoon), `2baec56` test(cabana,flare,dev,summer), `72b8383` test(19.8) gate stages
|
||||
5. **Task 5**: checkpoint presented (see below)
|
||||
6. **Task 6**: waits on the Task 5 reply
|
||||
|
||||
`commits: 11` is measured from the ledger (`b906a31..72b8383`); all eleven are this plan's. The tracer feedback gate re-ran Task 1's automated verify after its commit (green), so expansion continued.
|
||||
|
||||
## Post-release fixes
|
||||
|
||||
| # | Commit | Module | Symptom | Pinning test | In v0.4.0 | Needs require (plugins) | Regenerate (apps) |
|
||||
|---|--------|--------|---------|--------------|-----------|-------------------------|-------------------|
|
||||
| 1 | `efb043e` | bonfire | `PeekGlobals` read only `-v` clusters, so `-qn`, `-vq`, `-nvv` were missed before cobra parsed them: the generated main's pre-boot diary setup ignored them (boot logs not quieted), `Execute`'s error block rendered without the `-v` chain, and passthrough commands (summer's delegates) saw no globals. cobra itself parsed them, so the commands ran with the right options. | `TestPeekGlobals`, `TestGlobalFlagsAgree` (rows `combined -qn`, `combined -vq`, `combined -nvv`) | yes | no (no API change) | no (generated `main.go` unchanged; applications pick it up with a framework require bump) |
|
||||
|
||||
Not a fix, nothing to publish: `9641a1b` refactor(bonfire) reads build info through an unexported variable for `TestVersionHeader`; no behaviour change, it rides with the next tag.
|
||||
|
||||
Proposed patch tag: **v0.4.1** (framework only).
|
||||
|
||||
## Task 5 reply
|
||||
|
||||
> release v0.4.1, and yes, planning docs can go out
|
||||
|
||||
Before the reply `git tag --points-at HEAD` printed nothing (no tag on HEAD). Chosen tag: **v0.4.1** (framework only; no plugin require bumps, no application regenerate commits). The other sessions' unpushed planning-doc commits go out with the fix.
|
||||
|
||||
## --all stage summary
|
||||
|
||||
`PHASE198_REQUIRE_DOCKER=1 TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 bash scripts/check-phase19.8.sh --all` at `72b8383`:
|
||||
|
||||
```
|
||||
PASS self-test
|
||||
PASS go
|
||||
PASS hello
|
||||
PASS docs
|
||||
PASS plugins
|
||||
PASS apps
|
||||
NOT RUN TestTerminalCommands (the deployed one-liner end to end: needs network, Docker and ports 54320 and 8080; set SUMMERCMS_TERMINAL_CHECK=1)
|
||||
PASS landing
|
||||
PASS docker
|
||||
Phase 19.8 gate passed
|
||||
```
|
||||
|
||||
`go vet ./...`, `go test -count=1 -short ./...` and `--self-test` were green in the same run. No line of the log starts with `FAIL` or reports an unimplemented stage.
|
||||
|
||||
## Docker
|
||||
|
||||
- **Run.** The docker stage ran (daemon up, `PHASE198_REQUIRE_DOCKER=1`): `TestServeLifecycle`, `TestPendingMigrations`, `TestMigratePretend`, `TestRollbackPretend`, `TestDeferredPurgePretend`, `TestMigrateStatusJSON`, `TestTokenCreateJSON`, `TestTokenListJSON`, `TestTokenCreateQuietKeepsToken`: PASS.
|
||||
- The complete Docker suites of `modules/lagoon/...`, `modules/cabana`, `modules/surf` and `modules/conga` (`go test -count=1 ./...` without `-short`) also pass with the new tests.
|
||||
- Plugins: only their short suites run in the gate; the known Docker failures of sitemap, faq, menu and the user plugin's `updates` package (deferred-items.md, plan 03) are not part of this gate.
|
||||
|
||||
## Goldens against the previews
|
||||
|
||||
Read after `-update`; sections, order and alignment match D-13, D-14 and D-18. Intentional differences: the list also shows `help`; help prints the value placeholder as the upper-cased flag name (`--expires=EXPIRES`, the preview shows `DAYS`) and adds the `Global options` and `Help` sections the preview left out; the argument column aligns with the option labels.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See `key-decisions`. The two worth a look: the post-release fix above, and testing the production question on a real pseudo-terminal (`/dev/ptmx` with `syscall` ioctls, linux build tag), which needs no new dependency and no bonfire test seam.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] PeekGlobals ignored clustered shorthands**
|
||||
- **Found during:** Task 3 (`TestGlobalFlagsAgree`)
|
||||
- **Fix:** read single-dash clusters letter by letter; stop at the first non-global letter
|
||||
- **Files:** modules/bonfire/globals.go
|
||||
- **Commit:** `efb043e` (in the Post-release fixes table)
|
||||
|
||||
**2. [Rule 3 - Blocking] No injectable build-info reader**
|
||||
- **Found during:** Task 3 (`TestVersionHeader`)
|
||||
- **Fix:** `var readBuildInfo = debug.ReadBuildInfo`, used by `BuildVersion`
|
||||
- **Commit:** `9641a1b` (refactor, no behaviour change)
|
||||
|
||||
**3. [Rule 3 - Blocking] gate build of library-only plugins**
|
||||
- `go build -o dir/ ./...` refuses repositories without a main package; the plugins stage uses `GOWORK=off go build ./...`.
|
||||
|
||||
### Recorded differences
|
||||
|
||||
4. **Extra test files** not in the plan's list: `modules/conga/confirm_linux_test.go` (queue:clear's question, `TestQueueClearConfirm`), `modules/lagoon/pty_linux_test.go` (`TestConfirmProductionInteractiveNo`, `TestForceSkipsConfirm`), additional tests `TestDefaultConfig`, `TestConsoleLevelFromOptions`, `TestRouteListJSON` (in summary_test.go), `TestMigrateStatusJSON` (in pending_test.go, Docker).
|
||||
5. **Component names with dots.** `TestConfigRules` pins the nested form as refused and the literal YAML key as accepted (compass keeps it literal); the docs' "cannot contain dots" wording is a deferred item.
|
||||
6. **Version header.** Equal `devel` versions collapse to `SummerCMS acme devel`; pinned, deferred.
|
||||
7. **TestErrorScrubbed** covers the credential shapes `sunscreen.Scrub` knows (Bearer, `sk-`, `x-api-key:`) in the message, explanation, Try line and `-v` chain; a `password=` in a message is not scrubbed (sunscreen contract; deferred).
|
||||
8. **Widget matrix on a terminal.** Live views on a buffer draw no frames (no window size), so the terminal row checks the live path (three cursor-hide sequences, no fallback lines); `TestLiveModels` renders the frames with a fixed size and a pty run showed every final frame.
|
||||
9. **TestServeGracefulNil and TestShutdownReportAborted** drive `requestCounter` and `shutdownReport` through a real `http.Server` with serve's drain logic; serve's own wiring of them is covered by `TestServeLifecycle` (Docker).
|
||||
10. **Landing.** The stage runs `nuxt generate` before the TAP suite (plan 03 deviation 12); `TestTerminalCommands` is opt-in (NOT RUN line); `sync-roadmap.sh --check` uses `ROADMAP_REF=origin/master` because local master carries other sessions' unpushed planning commits.
|
||||
11. **Verify chain flake.** One run of Task 3's verify chain stopped red after its race step without a visible failure; every piece passed separately and the full chain passed on the rerun.
|
||||
|
||||
**Total deviations:** 3 auto-fixed (1 bug, 2 blocking), 8 recorded differences.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- Pre-existing gofmt drift in plan 03 files (`cmd/summer/main.go`, `runtime.go`, `runtime_test.go`, `modules/flare/commands.go`); logged in deferred-items.md.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. No new endpoint or input path; the goldens hold fixed fake values (secret scan over `modules/bonfire/testdata` finds no JWT, `sk-` key or private key header).
|
||||
|
||||
## g15office
|
||||
|
||||
No g15office tool is available in this executor session: the Phase 19.8 milestone task was not commented. Suggested comment once the Task 5 reply is in: "Plan 19.8-04 executed: phase gate green (--all with Docker), one post-release fix (PeekGlobals clustered shorthands); waiting on verification."
|
||||
|
||||
## Task 6: published
|
||||
|
||||
- **Range check** (after `git fetch`): `origin/master..v0.4.1` held 30 commits, all `.planning/` documents (this plan's and the 19.6, 19.6.1 and 19.7 sessions', `c1f5ded` included, approved with the reply) or this plan's code commits; no other session's code. `go vet ./...` and `go test -short ./...` green before tagging.
|
||||
- **Tag:** annotated **v0.4.1** (tag object `520d0c7`) on `8bacc22` (master tip when tagged: another session's `docs(19.6.1): map phase to its g15office task` landed after the reply commit `2ca68f8`; it is planning-only and inside the approved range). Contains `efb043e` and every test commit of this plan.
|
||||
- **Pushed:** framework `master` `99b6df4..8bacc22` (pushed as the exact tagged commit, `8bacc22:refs/heads/master`), then the tag. Nothing force-pushed. `git ls-remote --exit-code --tags origin v0.4.1` exits 0.
|
||||
- **Changelog:** https://summercms.io/changelog/summercms-0-4-1 (post id 36, published_at the tag date 2026-10-21T22:15:15Z), notes under `## Fixed` and `## Changed`, no planning IDs; the token was read from its file and never printed.
|
||||
- **Plugins and applications:** nothing to publish (no require bumps, no regenerate commits; the table needs none).
|
||||
- This SUMMARY's finalising commit is made after the push and stays local until the next push.
|
||||
|
||||
### Gate after the release
|
||||
|
||||
`PHASE198_REQUIRE_DOCKER=1 TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 bash scripts/check-phase19.8.sh --all` at `8bacc22`:
|
||||
|
||||
```
|
||||
PASS self-test
|
||||
PASS go
|
||||
PASS hello
|
||||
PASS docs
|
||||
PASS plugins
|
||||
PASS apps
|
||||
NOT RUN TestTerminalCommands (the deployed one-liner end to end: needs network, Docker and ports 54320 and 8080; set SUMMERCMS_TERMINAL_CHECK=1)
|
||||
FAIL landing
|
||||
```
|
||||
|
||||
`landing` fails on one check only: `sync-roadmap: plugins/demo/content/ROADMAP.md differs from origin/master`. The push published the other sessions' ROADMAP.md changes (19.6.1 inserted, 19.6 and 19.7 updates: 42 insertions, 6 deletions), so sm-demo-app's roadmap snapshot is now one sync behind origin, as deferred-items.md (plan 03) predicted ("re-sync after those commits are pushed"). Every other landing check passed in the same run (nuxt generate, 38/38 TAP, the terminal and roadmap Go tests), and with `ROADMAP_REF=99b6df4` (the previous origin/master) the landing stage passes. `--all` stops at the first red stage, so the docker stage was run on its own afterwards: **PASS** (TestServeLifecycle, the five lagoon checklist tests, the three cabana token JSON tests).
|
||||
|
||||
**Open for the user:** in sm-demo-app run `scripts/sync-roadmap.sh` and commit the refreshed `plugins/demo/content/ROADMAP.md` (a sibling-repository commit, not covered by the release reply, so not made here). After that `--all` is fully green.
|
||||
|
||||
## Next
|
||||
|
||||
Re-sync the demo roadmap snapshot (above), then `/msd-verify-work 19.8`.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
Every created file listed above exists (9 goldens), and the eleven commits `9b2fa6c`, `24f4643`, `db83a14`, `efb043e`, `9641a1b`, `7b58ad5`, `eeeee59`, `fd52868`, `6720cae`, `2baec56`, `72b8383` resolve.
|
||||
456
.planning/phases/19.8-console-commands-overhaul/19.8-05-PLAN.md
Normal file
456
.planning/phases/19.8-console-commands-overhaul/19.8-05-PLAN.md
Normal file
@@ -0,0 +1,456 @@
|
||||
---
|
||||
phase: 19.8-console-commands-overhaul
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
gap_closure: true
|
||||
files_modified:
|
||||
- modules/bonfire/live.go
|
||||
- modules/bonfire/output.go
|
||||
- modules/bonfire/widgets.go
|
||||
- modules/bonfire/helpers.go
|
||||
- modules/bonfire/errors.go
|
||||
- modules/bonfire/execute.go
|
||||
- modules/bonfire/README.md
|
||||
- modules/bonfire/live_test.go
|
||||
- modules/bonfire/live_signal_unix_test.go
|
||||
- modules/bonfire/widgets_test.go
|
||||
- modules/bonfire/errors_test.go
|
||||
- modules/diary/daily.go
|
||||
- modules/diary/diary.go
|
||||
- modules/diary/console.go
|
||||
- modules/diary/README.md
|
||||
- modules/diary/daily_test.go
|
||||
- modules/diary/diary_test.go
|
||||
- modules/lagoon/migrations.go
|
||||
- modules/lagoon/commands.go
|
||||
- modules/lagoon/README.md
|
||||
- modules/lagoon/pending_test.go
|
||||
- cmd/summer/runtime.go
|
||||
- cmd/summer/runtime_test.go
|
||||
- internal/dev/watch.go
|
||||
- internal/dev/watch_test.go
|
||||
- docs/console/introduction.md
|
||||
- docs/console/writing-commands.md
|
||||
- docs/console/scaffolding.md
|
||||
- docs/services/logging.md
|
||||
- docs/setup/upgrading.md
|
||||
- scripts/check-phase19.8.sh
|
||||
- .planning/phases/19.8-console-commands-overhaul/19.8-REVIEW-DISPOSITION.md
|
||||
- .planning/todos/pending/2026-10-22-console-review-info-findings.md
|
||||
- .planning/phases/19.8-console-commands-overhaul/19.8-05-SUMMARY.md
|
||||
autonomous: true
|
||||
requirements: [D-03, D-04, D-10, D-12, D-18, D-19, D-21, D-25, D-27, D-28, D-29]
|
||||
user_setup: []
|
||||
|
||||
estimate:
|
||||
tokens: 200000
|
||||
raw_tokens: 200000
|
||||
tasks: 7
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "CR-01 (D-03, D-27): lines printed while a Spinner, Progress or Process view runs (Output.Println and Printf, status lines, Progress Fail lines, bonfire.PrintAbove from diary) appear in the order they were printed and none is lost when the view ends; a line printed from another goroutine while the view finishes appears once, after the final frame (TestPrintAboveOrderedNoLoss)."
|
||||
- "WR-02 (D-03): only the console that started a live view sends its Println, Printf and status lines above it; every other console (a bonfire.Call buffer, a nested command, a console on a file) writes to its own writer. At most one view registers at a time (check and set under one lock; a widget that loses falls back to plain lines), and a panic in the work function still unregisters, finishes and waits for the view (TestPrintlnStaysWithItsConsole, TestLiveViewSingleOwner, TestLiveViewPanicUnregisters)."
|
||||
- "WR-01 (D-03): SIGINT or SIGTERM during a live view kills the view (cursor and terminal restored) and then still ends the process with the signal's default action, or reaches the command's own signal handler when it has one. The generated main and bonfire.Execute's context are unchanged (TestLiveViewRestoresTerminalOnSignal)."
|
||||
- "CR-02 (D-18): `summer <delegate>` exits with the app binary's exit code (an exit 2 from the binary stays 2) and prints no second error block; a binary killed by a signal still gets summer's error block and exit 1. The carrier is the additive bonfire.WithExitCode, honoured by bonfire.Execute (TestDelegateExitCodePassthrough, TestWithExitCode)."
|
||||
- "CR-03 (D-27, D-28, D-04): on a terminal the delegated binary gets summer's own stdout and stderr (so its production question, styled summaries and widgets work); off a terminal its stdout goes through summer's Output and its stderr to summer's stderr, never into stdout (TestDelegateStreams, TestAppStreams)."
|
||||
- "WR-03 (D-25, D-29): summer dev sends serve SIGTERM and waits the same grace as the delegates (longer than serve's 10-second drain) before it kills it (TestDevStopWaitsForGracefulShutdown)."
|
||||
- "WR-04 (D-10): a daily file that fails to open is skipped and retried one minute later by the writer's clock, or at the first record of a new date, whichever comes first; the warning is logged once per date (TestDailyRetriesAfterFailure; TestDailyUnwritable and TestDailyDegradesSmoke stay green)."
|
||||
- "WR-06 (D-10): daily writers replaced by Setup, Bootstrap, ApplyGlobals or Reset are retired: a late record through them is appended and the file closed again, so no descriptor stays open; diary.Close keeps its documented reopen-on-next-record behaviour for the installed writers. ApplyGlobals holds the diary lock for its whole run, so concurrent calls rebuild the channels once (TestRetiredDailyWriterKeepsNoFile, TestApplyGlobalsRebuildsOnce)."
|
||||
- "WR-05 (D-12, D-18): error blocks (message, explanation, Try lines, -v chain), process-view item names and errors, and table cells strip control characters other than tab before styling, through the additive bonfire.Sanitize, which diary's console handler also uses (TestRenderErrorStripsControl, TestProcessViewStripsControl, TestTableStripsControl, TestSanitize; TestConsoleStripsControlChars stays green)."
|
||||
- "WR-07 (D-21, D-04): Migrate applies exactly the sets migrationSets lists (one list for Migrate, PendingMigrations, --pretend, the production question, the startup summary and migrate:status --json), keeping each set's error wording; migrate's 'N applied' lines and its result line count what Migrate actually applied, also when it fails partway (TestMigrateAppliedLines, Docker)."
|
||||
- "WR-08 (D-19): the wrong-directory Try line keeps the command and its other arguments but replaces the value of every flag whose name contains password, passwd, secret, token or key with ***, in both the --flag value and --flag=value forms (TestWrongDirectoryMasksSecrets)."
|
||||
- "Each CR and WR finding is fixed in its own commit that contains its pinning test, and replaying the commit without its non-test, non-doc changes makes the test fail (RED replay in Task 7). 19.8-REVIEW-DISPOSITION.md marks CR-01..CR-03 and WR-01..WR-08 fixed with their commit; IN-01..IN-09 are deferred to one pending todo (severity minor, area framework)."
|
||||
- "No exported identifier of the framework is removed or changes its signature; the only API additions are bonfire.WithExitCode and bonfire.Sanitize. The generated main is unchanged, so no shared plugin needs a require bump and no application a regenerate commit unless the SUMMARY's Release impact table says otherwise."
|
||||
artifacts:
|
||||
- path: "modules/bonfire/live.go"
|
||||
provides: "ordered, drained printing above live views; owner-checked registration; signal-safe teardown"
|
||||
contains: "func runLive("
|
||||
- path: "modules/bonfire/live_test.go"
|
||||
provides: "CR-01 and WR-02 pinning tests"
|
||||
contains: "TestPrintAboveOrderedNoLoss"
|
||||
- path: "modules/bonfire/live_signal_unix_test.go"
|
||||
provides: "WR-01 pinning test through a re-executed helper process"
|
||||
contains: "TestLiveViewRestoresTerminalOnSignal"
|
||||
- path: "modules/bonfire/errors.go"
|
||||
provides: "bonfire.WithExitCode, bonfire.Sanitize in the error block, masked wrong-directory hint"
|
||||
contains: "func WithExitCode("
|
||||
- path: "cmd/summer/runtime.go"
|
||||
provides: "delegates with pass-through exit codes and separate streams"
|
||||
contains: "WithExitCode"
|
||||
- path: "modules/diary/daily.go"
|
||||
provides: "retry after a failed open; retired writers"
|
||||
contains: "retire"
|
||||
- path: "modules/lagoon/migrations.go"
|
||||
provides: "Migrate over migrationSets"
|
||||
contains: "migrationSets(gdb, plugins)"
|
||||
- path: ".planning/todos/pending/2026-10-22-console-review-info-findings.md"
|
||||
provides: "IN-01..IN-09 captured"
|
||||
contains: "IN-09"
|
||||
key_links:
|
||||
- from: "modules/bonfire/output.go"
|
||||
to: "modules/bonfire/live.go"
|
||||
via: "Println, Printf and emit reroute above a view only when this console owns it"
|
||||
pattern: "owner"
|
||||
- from: "modules/diary/console.go"
|
||||
to: "modules/bonfire/errors.go"
|
||||
via: "diary's console sanitizer delegates to bonfire.Sanitize"
|
||||
pattern: "bonfire.Sanitize"
|
||||
- from: "cmd/summer/runtime.go"
|
||||
to: "modules/bonfire/execute.go"
|
||||
via: "runAppBinary wraps a child exit in Reported and WithExitCode; exitCode honours the carrier"
|
||||
pattern: "WithExitCode"
|
||||
- from: "modules/lagoon/migrations.go"
|
||||
to: "modules/lagoon/commands.go"
|
||||
via: "migrate reports pending-before minus pending-after as its applied lines"
|
||||
pattern: "PendingMigrations"
|
||||
- from: "scripts/check-phase19.8.sh"
|
||||
to: "modules/bonfire/live_test.go"
|
||||
via: "the go stage requires the pinning tests by name"
|
||||
pattern: "TestPrintAboveOrderedNoLoss"
|
||||
prohibitions:
|
||||
- statement: "No exported framework identifier is removed, renamed or changes its signature (core shared plugins and applications build against them); additions only."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "WR-01 is fixed inside bonfire's live views: the generated main (internal/build), bonfire.Execute's context and Ctrl+C semantics of commands without a live view do not change."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "IN-01..IN-09 are not fixed in this plan; they go to the pending todo."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No new Go module dependency; nothing is pushed or tagged by this plan (plan 06 owns the release)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; plans 01-04 recorded the decision not to invent one): console commands in `summer` and in application binaries reach the Artisan bar; a user sees what a command did, can ask for more or less of it, and learns from `--help` how to use it.
|
||||
|
||||
This plan (gap closure 1 of 2, wave 1) closes the code review of the shipped console (19.8-REVIEW.md, v0.3.1..v0.4.1): the three critical findings CR-01..CR-03 and the eight warnings WR-01..WR-08. The nine info findings IN-01..IN-09 become one pending todo. Plan 06 releases the fixes as v0.4.2 under a go-ahead.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Spec-less probe fallback skipped: the phase has no REQUIREMENTS.md IDs (`Requirements: TBD`) and no SPEC.md, as in plans 01-04. `requirements` lists the CONTEXT decisions each finding breaks; `must_haves` derive from the review's findings.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion inside the review's suggested fixes)
|
||||
|
||||
- **Order of work.** The tracer (Task 1) is CR-01 alone: one finding taken through the whole loop that every later task repeats (pinning test red, fix, green, one commit). The review's "bonfire live view" group (CR-01, WR-01, WR-02) therefore spans Tasks 1 and 2.
|
||||
- **CR-01 mechanism.** Each PrintAbove call becomes one print message placed straight on the program's message queue by the calling goroutine (the bubbletea event loop prints such a message itself, in queue order), instead of a model reply that bubbletea runs as a command on its own goroutine. Unregistering, sending the finish message and waiting for the program happen as one step under the registry lock, so no line reaches a stopped program and no line races the final frame. The review's alternative (a FIFO in the model drained with `tea.Sequence` before quitting) is the fallback if bubbletea v2.1.0's source does not support the first approach.
|
||||
- **WR-01 mechanism.** The review offers a process-wide `signal.NotifyContext` around Execute or a handler inside `runLive`. This plan takes the handler inside `runLive`: a process-wide context would change Ctrl+C for every plugin command that ignores its context (the process would no longer stop) and would change the generated main, forcing regenerate commits in every application.
|
||||
- **CR-02 carrier.** An explicit, additive `bonfire.WithExitCode(err, code)`; bonfire does not honour every error that happens to have an `ExitCode()` method, so plugin commands that return an `*exec.ExitError` keep exiting 1.
|
||||
- **WR-05 home.** `bonfire.Sanitize`, as the review suggests; diary's console sanitizer delegates to it so one implementation exists. IN-08 (raw C1 bytes) stays in the todo and now points at `bonfire.Sanitize`.
|
||||
- **WR-04 retry.** Retry after one minute (writer's clock) or on a new date, whichever comes first; one warning per date. The existing warning message `daily log file disabled` is kept so the pinned tests and any log alerts keep matching.
|
||||
- **WR-06 retired writers.** A retired writer appends a late record and closes the file again (open, write, close), so neither a record nor a descriptor is lost. `diary.Close` keeps its documented behaviour (installed writers reopen on the next record).
|
||||
- **WR-07 applied lines.** migrate computes pending migrations before and after Migrate and prints the difference, also when Migrate fails partway (then it prints what was applied and returns the error).
|
||||
- **Review line numbers.** The review's line numbers for `modules/diary/daily.go` and `modules/bonfire/execute.go` do not match the source (the code is unchanged since v0.4.1); every task names the symbols to locate.
|
||||
|
||||
## Artifacts this plan produces
|
||||
|
||||
- Fix commits (one per finding, each with its pinning test) in bonfire, cmd/summer, internal/dev, diary and lagoon, with README and docs updates in the same commit where documented behaviour or exported API changes.
|
||||
- `scripts/check-phase19.8.sh` requiring the new tests by name.
|
||||
- `19.8-REVIEW-DISPOSITION.md` updated; `.planning/todos/pending/2026-10-22-console-review-info-findings.md`.
|
||||
- `19.8-05-SUMMARY.md` with the RED evidence per finding, the Release impact table and `plan_head_before`/`plan_head_after`.
|
||||
|
||||
<objective>
|
||||
Fix the eleven critical and warning findings of the 19.8 code review, each in its own commit with a test that fails without the fix, without breaking any exported API, and capture the nine info findings as a todo.
|
||||
|
||||
Purpose: the console overhaul shipped in v0.4.0/v0.4.1 loses and reorders output above live views, hides the terminal from summer's delegates, misreports their exit codes, leaves the cursor hidden after Ctrl+C, drops the daily log trail after one failed open and prints untrusted escape sequences; D-03, D-10, D-12, D-18, D-19, D-21, D-25, D-27, D-28 and D-29 promise otherwise.
|
||||
Output: eleven fix commits, one gate commit, one planning-docs commit, the SUMMARY with a Release impact table for plan 06.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-REVIEW.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-REVIEW-DISPOSITION.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-CONTEXT.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-04-SUMMARY.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/deferred-items.md
|
||||
|
||||
Repos and concurrency: summercms.go only. Several sessions work in this repository: before each task run `git status --porcelain` on its paths, stage only those paths by name (never `git add -A`), never touch `.planning/STATE.md` or `.planning/state.json` by hand, no co-author tags, planning docs and code in separate commits. Record `git rev-parse HEAD` as `plan_head_before` in the SUMMARY before the first commit. `go vet ./...` and `go test -short ./...` green at every commit; `go -C examples/hello test ./...` green before the last commit.
|
||||
|
||||
Fix loop for every finding (the tracer proves it once): write the pinning test, run it and keep the failing output (RED) for the SUMMARY, implement the fix, run the test and its package green (with `-race` for bonfire and diary), update README and docs when exported API or documented behaviour changes, commit the test, the fix and the docs together as one `fix(<module>): ...` commit. The RED replay in Task 7 re-proves each RED from the commits.
|
||||
|
||||
Docker: Docker-backed tests need `TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1`; if the socket is denied although the user is in the docker group, run the tests through `sudo -u golem`. A Docker test that could not run is NOT RUN in the SUMMARY, never PASS.
|
||||
|
||||
<interfaces>
|
||||
- bonfire console seam: `newConsole(in, out, errW, lookup, tty, interactive *bool)` and `setGlobals(Globals)` (modules/bonfire/output.go); `boolPtr`, `noEnv` (widgets_test.go); a terminal console on a bytes.Buffer runs the live path and the buffer receives the PrintAbove lines (TestPrintAbove).
|
||||
- bonfire live path: `runLive(ctx, out, c, model, work)`, `printAboveLines(s)`, `liveActive()`, `liveMu`, `activeProg`, `liveModel`, `printAboveMsg`, `finishMsg` (live.go); `console.live()`, `emit`, `Println`, `Printf` (output.go); `Spinner`, `Progress`, `liveProgress.Fail`, `Table` (widgets.go); `Process`, `PrintAbove` (helpers.go).
|
||||
- bubbletea v2.1.0 (path: `go list -m -f '{{.Dir}}' charm.land/bubbletea/v2`): tea.go `handleCommands` (each command on its own goroutine), the event loop's `case printLineMessage` (prints in the loop), `Program.Send` (returns once the program has stopped), `Program.Kill` (restores the terminal), `execSequenceMsg`; renderer.go `Println`.
|
||||
- bonfire errors: `RenderError`, `wrongDirectory(name, err, args, dir)`, `shellQuote`, `Reported`, `ErrReported`, `isUsage` (errors.go); `Execute`, `exitCode`, `Fail` (execute.go).
|
||||
- summer delegates: `appRunner`, `delegateCommand`, `runAppBinary`, `newAppCmd`, `runChild`, `appShutdownGrace` (cmd/summer/runtime.go); test helpers `captureAppRunner`, `runSummer`, `TestSignalHelperProcess`, `helperArgv`, `helperEnv`, `syncBuffer`, `waitForOutput` (cmd/summer/runtime_test.go).
|
||||
- summer dev: `Options{Build, Start, Debounce}`, `watch`, `child.stop`, `startBin` (internal/dev/watch.go); serve's drain timeout is the 10-second `context.WithTimeout` in modules/surf/serve.go.
|
||||
- diary: `dailyWriter{mu, disabled, file, date, now, warn}`, `Write`, `open`, `Close`, `newDailyWriter` (daily.go); `install`, `SetVerbosity`, `ApplyGlobals`, `Close`, `closeChannels`, `Reset`, `mu`, `current`, `gens` (diary.go); `channelBuilder.warn` with message `daily log file disabled` (channels.go); `sanitize` (console.go); `Options.Now`.
|
||||
- lagoon: `Migrate`, `migrator`, `migrationSet`, `migrationSets`, `PendingMigrations` (migrations.go); the `migrate` command's Run (commands.go); Docker helpers `pendingPlugins`, `migPlugin`, `runLagoon` (pending_test.go).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: Tracer: CR-01 lines printed above a live view keep their order and none is lost when the view ends (one fix through the whole loop: red test, fix, green, one commit)</name>
|
||||
<files>modules/bonfire/live.go, modules/bonfire/widgets.go, modules/bonfire/output.go, modules/bonfire/helpers.go, modules/bonfire/live_test.go</files>
|
||||
<behavior>
|
||||
- TestPrintAboveOrderedNoLoss, subtests spinner, progress and process: on a terminal console over a bytes.Buffer (newConsole with tty and interactive true), 20 runs per widget; in each run the work function prints 100 numbered lines (`line-000` .. `line-099`, mixing Output.Println, Output.Printf with a trailing newline, Output.Info and bonfire.PrintAbove), the progress subtest also records two failures through bonfire.Failed between them, and the last line is printed immediately before the work function returns. After the widget returns, the ANSI-stripped buffer holds every numbered line exactly once and in order, and both failure lines.
|
||||
- Subtest concurrent_finish: a goroutine started inside the work function keeps calling bonfire.PrintAbove after work returns; each of its lines appears exactly once in the buffer (above the view or after its final frame), none is lost.
|
||||
- TestPrintAbove, TestWidgetMatrix and TestLiveModels stay green.
|
||||
</behavior>
|
||||
<read_first>modules/bonfire/live.go (whole file), modules/bonfire/widgets.go (Spinner, Progress, liveProgress), modules/bonfire/output.go (emit, Println, Printf, live), modules/bonfire/helpers.go (Process, PrintAbove), modules/bonfire/widgets_test.go (TestPrintAbove and the console helpers), modules/bonfire/live_test.go, the bubbletea v2.1.0 sources named in the interfaces block, 19.8-REVIEW.md (CR-01)</read_first>
|
||||
<action>Per CR-01 (D-03, D-27). Verify the review's claim against bubbletea v2.1.0's source before changing anything: `liveModel.Update` answers each `printAboveMsg` with `tea.Println(...)`, a command that `handleCommands` runs on its own goroutine, so successive lines race back into the event loop and a line still in flight when `finishMsg` quits the program is dropped.
|
||||
|
||||
Write TestPrintAboveOrderedNoLoss in modules/bonfire/live_test.go and run it on the unchanged code; keep the failing output for the SUMMARY (out-of-order or missing lines). If it does not fail in three runs, raise the runs or the line count until it does, and record the numbers.
|
||||
|
||||
Fix in live.go (and where the callers send): every PrintAbove call (printAboveLines, and liveProgress.Fail's failure line) becomes ONE print message for the whole block, placed directly on the program's message queue by the calling goroutine in call order, so the event loop prints it itself without a command goroutine (for example send the message value that `tea.Println(block)` returns, through `Program.Send`, which also returns instead of blocking once the program has stopped; never call `Program.Println`, which blocks forever on a stopped program). Then make the end of runLive one step under `liveMu`: clear the registration, send `finishMsg` and wait for the program to finish while holding the lock, so a printer that arrives meanwhile waits and then writes directly after the final frame. Remove `printAboveMsg` handling from `liveModel` if nothing sends it any more. If the source shows the direct-queue approach does not hold, implement the review's alternative instead (a FIFO of pending lines in the model, drained with `tea.Sequence(<prints>..., tea.Quit)` on `finishMsg`) and record why.
|
||||
|
||||
No exported signature changes (bonfire.PrintAbove keeps its signature and meaning). The README's PrintAbove row already describes the intended behaviour; no docs change unless a sentence there contradicts the fix. Commit test and fix together: `fix(bonfire): keep the order of lines printed above a live view and drain them before it ends`.</action>
|
||||
<verify>
|
||||
<automated>go test -count=1 -race -short -run '^(TestPrintAboveOrderedNoLoss|TestPrintAbove|TestWidgetMatrix|TestLiveModels)$' -v ./modules/bonfire 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 4 && go vet ./... && go test -count=1 -short ./...</automated>
|
||||
<fails_when>CR-01: fewer than 4 top-level "--- PASS: Test" lines (a numbered line missing, duplicated or out of order in any of the 60 widget runs, a failure line missing, a concurrent_finish line lost or printed twice, or a race report), or vet or the short suite fail.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- CR-01: `go test -count=3 -race -short -run '^TestPrintAboveOrderedNoLoss$' ./modules/bonfire` exits 0 (three clean passes in a row).
|
||||
- CR-01: the SUMMARY records the RED output of TestPrintAboveOrderedNoLoss on the code before the fix (the first out-of-order or missing line).
|
||||
- The CR-01 fix commit (found by its subject in `git log --format='%h %s' <plan_head_before>..HEAD`) lists modules/bonfire/live_test.go together with the changed non-test files in `git show --stat`.
|
||||
</acceptance_criteria>
|
||||
<done>Output printed during any live view arrives complete and in order, proven by a deterministic buffer test that fails on the shipped code, in one commit.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: WR-02 then WR-01: a live view takes lines only from the console that owns it, registers by check and set and unregisters on panic; Ctrl+C or SIGTERM during a view restores the terminal and keeps the signal's effect</name>
|
||||
<files>modules/bonfire/live.go, modules/bonfire/output.go, modules/bonfire/widgets.go, modules/bonfire/helpers.go, modules/bonfire/live_test.go, modules/bonfire/live_signal_unix_test.go, modules/bonfire/README.md</files>
|
||||
<behavior>
|
||||
- TestPrintlnStaysWithItsConsole: while a Spinner runs on terminal console A (buffer A), console B (a non-terminal console over buffer B) calls Println, Printf and Info; buffer B holds all three and buffer A none of them; console A's own Println still prints above its view.
|
||||
- TestLiveViewSingleOwner: two terminal consoles start a Spinner at the same moment (a barrier, 200 rounds); in every round exactly one runs as a live view and the other prints its plain `[...] <label>` line; after each round bonfire.PrintAbove reports false.
|
||||
- TestLiveViewPanicUnregisters: a Spinner whose work panics; the test recovers the panic; afterwards bonfire.PrintAbove reports false and a new Spinner runs as a live view.
|
||||
- TestLiveViewRestoresTerminalOnSignal (unix build tag, the test binary re-executed as a helper with an environment switch, like cmd/summer's TestSignalHelperProcess): the helper runs a Spinner on a terminal console over its stdout pipe, prints `ready` on stderr and blocks; the test sends SIGINT. Subtest default: the helper dies by SIGINT (default action kept) and its stdout holds the show-cursor sequence after the last hide-cursor sequence. Subtest own_handler: the helper also holds its own signal.NotifyContext, exits 0 by itself after its context ends, and its stdout holds the show-cursor sequence after the last hide-cursor sequence.
|
||||
</behavior>
|
||||
<read_first>modules/bonfire/live.go and output.go after Task 1, modules/bonfire/widgets.go, modules/bonfire/helpers.go (Process passes its ctx), cmd/summer/runtime_test.go (TestSignalHelperProcess, helperArgv, helperEnv, waitForOutput: the re-exec helper pattern), modules/lagoon/pty_linux_test.go (the syscall-only pseudo-terminal helper, only if a pipe does not show the cursor sequences), internal/build/build.go (the generated main: must stay unchanged), 19.8-REVIEW.md (WR-01, WR-02)</read_first>
|
||||
<action>Two findings, two commits, WR-02 first because WR-01 builds on the registration it reworks.
|
||||
|
||||
WR-02 (D-03): verify against the source that `console.Println`, `console.Printf` and `emit` reroute through the single process-wide `activeProg` without checking which console started it, that `console.live()`'s `liveActive()` check and runLive's registration are separate steps, and that runLive unregisters without `defer`. Write the three WR-02 tests and record their RED output (the single-owner test may need more rounds to fail; if it does not fail in three runs, add an unexported test hook called between the check and the registration, nil in production, to force the interleaving, and record that). Fix: keep the owning console with the registered program; Println, Printf and emit reroute only when the calling console is the owner (bonfire.PrintAbove, used by diary, keeps rerouting to whichever view runs, as documented). Replace the separate check with a check-and-set claim under `liveMu` taken before the program starts; a widget that cannot claim prints its plain lines as nested widgets do today. Unregister, finish and wait in a deferred step (keeping Task 1's single locked step), so a panic in work leaves no orphaned registration and still re-panics. Commit: `fix(bonfire): reroute only the owning console's lines to a live view`.
|
||||
|
||||
WR-01 (D-03): verify that the live views run with bubbletea's own signal handling off, that Spinner and Progress pass a background context and that neither summer nor the generated main installs a signal handler, so SIGINT's default action ends the process with the cursor hidden. Write TestLiveViewRestoresTerminalOnSignal and record its RED output (no show-cursor after the last hide-cursor). First confirm on the unchanged code that the helper's stdout pipe carries the hide-cursor sequence; if a pipe never carries the cursor sequences, open a pseudo-terminal for the helper's stdout with a copy of the syscall-only helper from modules/lagoon/pty_linux_test.go (linux build tag, no new dependency). Fix inside runLive only: for the life of the view, relay SIGINT and SIGTERM to a channel with signal.Notify (skip a signal that signal.Ignored reports); on a signal, kill the program (Program.Kill restores the cursor and the terminal), wait for it to stop, clear the registration if it is still this view, stop relaying (signal.Stop), then re-raise the same signal on the own process (os.FindProcess(os.Getpid()) then Signal; if that fails, exit with 128 plus the signal number) so the default action, or the command's own registered handler, still applies. When the view ends normally, stop relaying before runLive returns. Do not change internal/build's generated main, bonfire.Execute's context or the summer main. README: the Spinner, Progress and Process rows or the widgets paragraph say that Ctrl+C during a view restores the terminal and then stops the command as before. Commit: `fix(bonfire): restore the terminal when a live view is interrupted`.</action>
|
||||
<verify>
|
||||
<automated>go test -count=1 -race -short -run '^(TestPrintlnStaysWithItsConsole|TestLiveViewSingleOwner|TestLiveViewPanicUnregisters|TestLiveViewRestoresTerminalOnSignal|TestPrintAboveOrderedNoLoss|TestPrintAbove|TestWidgetMatrix)$' -v ./modules/bonfire 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 7</automated>
|
||||
<fails_when>WR-02: TestPrintlnStaysWithItsConsole, TestLiveViewSingleOwner or TestLiveViewPanicUnregisters does not pass (a line lands in another console's view, two views run at once, or a panic leaves a registration behind). WR-01: TestLiveViewRestoresTerminalOnSignal does not pass (the cursor is left hidden, the default signal action is lost, or the command's own handler no longer receives the signal). Any race report, or Task 1's tests regress.</fails_when>
|
||||
<automated>go vet ./... && go test -count=1 -short ./... && F=$(git log --format= --name-only --grep='^fix(bonfire): restore the terminal when a live view is interrupted$' --grep='^fix(bonfire): reroute only the owning console' -- internal/build cmd/summer/main.go) && test -z "$F"</automated>
|
||||
<fails_when>vet or the short suite fail, or either fix commit touches the generated main (internal/build) or summer's main.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- WR-02: `go test -count=3 -race -short -run '^(TestPrintlnStaysWithItsConsole|TestLiveViewSingleOwner|TestLiveViewPanicUnregisters)$' ./modules/bonfire` exits 0, and the SUMMARY records each test's RED output.
|
||||
- WR-01: `go test -count=1 -short -run '^TestLiveViewRestoresTerminalOnSignal$' -v ./modules/bonfire 2>&1 | grep -cE -- '--- PASS: TestLiveViewRestoresTerminalOnSignal/(default|own_handler)'` prints 2, and the SUMMARY records the RED output.
|
||||
- `git log --format=%s <plan_head_before>..HEAD` lists the WR-02 and WR-01 fix subjects as two separate commits (other sessions' commits may sit between them), and `git show --stat` of each lists its test file.
|
||||
</acceptance_criteria>
|
||||
<done>A live view belongs to one console, cannot be registered twice or left registered, and an interrupted view gives the terminal back without changing how the signal ends the command.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: summer delegates and dev: CR-02 exit codes pass through without a second error block, CR-03 the binary gets the terminal and a separate stderr, WR-03 dev waits serve's full drain, WR-08 the wrong-directory hint masks secret flag values</name>
|
||||
<files>cmd/summer/runtime.go, cmd/summer/runtime_test.go, modules/bonfire/execute.go, modules/bonfire/errors.go, modules/bonfire/errors_test.go, modules/bonfire/README.md, internal/dev/watch.go, internal/dev/watch_test.go, docs/console/introduction.md, docs/console/writing-commands.md, docs/console/scaffolding.md, docs/setup/upgrading.md</files>
|
||||
<behavior>
|
||||
- TestWithExitCode (bonfire): bonfire.Execute returns 2 for a command returning Reported(WithExitCode(err, 2)) and prints no error block; WithExitCode(err, 3) without Reported prints the block and returns 3; codes below 1 or above 255 return ExitFailure; WithExitCode(nil, 2) is nil; errors.Is and errors.As still see the wrapped error.
|
||||
- TestDelegateExitCodePassthrough (cmd/summer): a summer root runs a delegate whose child (the test binary re-executed as a helper) writes a line on stderr and exits 2; Execute returns 2 and summer's own output and error streams hold no ERROR block and no `summer: migrate` text. A child killed by SIGKILL gives exit 1 and one summer error block naming the signal.
|
||||
- TestAppStreams (cmd/summer): the stream choice for a terminal Output returns os.Stdout and os.Stderr; for a non-terminal Output it returns that Output and os.Stderr.
|
||||
- TestDelegateStreams (cmd/summer): with a non-terminal buffer console, a helper child writing `data-line` to stdout and `log-line` to stderr leaves `data-line` in the buffer and no `log-line` in it.
|
||||
- TestDevStopWaitsForGracefulShutdown (internal/dev): a helper child that traps SIGTERM, sleeps 2.5 seconds and exits 0 is stopped through child.stop and reports exit 0 (not killed); dev's stop grace equals the delegates' grace and exceeds 10 seconds.
|
||||
- TestWrongDirectoryMasksSecrets (bonfire): from a temp tree (application root with summer.yaml, cwd without config/), args `admin:create --email a@b.c --password hunter2`, `--password=hunter2`, `--client-secret s3cr3t`, `--api-token=t0k3n` and `--key k3y` give a Try line without hunter2, s3cr3t, t0k3n or k3y, with `--password ***` or `--password=***` in their place and `--email a@b.c` unchanged; TestWrongDirectoryHint stays green.
|
||||
</behavior>
|
||||
<read_first>cmd/summer/runtime.go (delegateCommand, runAppBinary, newAppCmd, runChild, appShutdownGrace), cmd/summer/runtime_test.go (captureAppRunner, runSummer, the signal helper), cmd/summer/main.go (how the root is built), modules/bonfire/execute.go (Execute, exitCode, Fail), modules/bonfire/errors.go (Reported, RenderError, wrongDirectory, shellQuote), modules/bonfire/errors_test.go and wrongdir_smoke_test.go (TestWrongDirectoryHint), internal/dev/watch.go (watch's stream choice, child.stop, startBin), internal/dev/watch_test.go (TestStartBinServe, TestDevGracefulNil), modules/surf/serve.go (the 10-second drain), docs/console/introduction.md ("Exit codes", "Commands that summer delegates"), docs/console/scaffolding.md (summer dev), docs/console/writing-commands.md (wrong-directory example), docs/setup/upgrading.md ("Upgrading to 0.4"), modules/bonfire/README.md (error block paragraph, API table), 19.8-REVIEW.md (CR-02, CR-03, WR-03, WR-08)</read_first>
|
||||
<action>Four findings, four commits, in this order. Verify each claim against the source first and record the pinning test's RED output.
|
||||
|
||||
CR-02 (D-18): runAppBinary returns `summer: <argv>: exit status N` wrapping the child's *exec.ExitError, so Execute renders a second error block under the one the binary printed and maps it to exit 1. Add to bonfire an exported carrier `WithExitCode(err error, code int) error` (unexported type with Error, Unwrap; nil stays nil) and make `exitCode` honour it before the usage check (a code outside 1..255 maps to ExitFailure). In runAppBinary, when the child exited with a code (`ExitError.Exited()`), return `bonfire.Reported(bonfire.WithExitCode(<the wrapped error>, code))`; a child ended by a signal keeps today's error block and exit 1. Factor the part of runAppBinary after locating the binary into a function the tests can call with the test binary as the child. README: an API row for bonfire.WithExitCode and the Reported row amended ("exit code 1, or the code of bonfire.WithExitCode"); docs/console/introduction.md: the delegates section says summer exits with the binary's exit code; docs/setup/upgrading.md: one bullet under "Upgrading to 0.4" behaviour changes. Commit: `fix(summer): pass the app binary's exit code through and print its error once`.
|
||||
|
||||
CR-03 (D-27, D-28, D-04): newAppCmd sets the child's stdout and stderr to summer's Output, so the child never sees a terminal (no production question, no styled summary, no widgets) and its stderr lands in summer's stdout. Add a stream choice used by the delegates: on a terminal Output (bonfire.IsTerminal) the child gets os.Stdout and os.Stderr; otherwise stdout goes to the Output (keeps tests and Call roots capturing) and stderr to os.Stderr. newAppCmd takes the two writers (unexported; adjust its existing tests). The child keeps os.Stdin. Docs: the delegates section of docs/console/introduction.md says the binary writes to the terminal directly and its stderr stays separate, so `summer route:list --json > routes.json` captures only the JSON; extend the upgrading bullet. Commit: `fix(summer): give delegated commands the terminal and keep their stderr apart`.
|
||||
|
||||
WR-03 (D-25, D-29): child.stop in internal/dev/watch.go kills serve two seconds after SIGTERM while serve drains for up to ten. Move the grace into one constant shared by the delegates and dev: `ShutdownGrace` in internal/dev (15 seconds, with a comment naming serve's 10-second drain; internal, so not public API), used by child.stop, with cmd/summer's appShutdownGrace defined as `dev.ShutdownGrace`. The helper child for the test may be the test binary re-executed with an environment switch. docs/console/scaffolding.md: the summer dev paragraph says Ctrl+C waits for serve's shutdown report (up to its drain time) before stopping it. Commit: `fix(dev): give serve the full shutdown grace before killing it`.
|
||||
|
||||
WR-08 (D-19): wrongDirectory builds the Try line from every argument, so a `--password` value reaches stderr and CI logs. When building the Try line, replace the value of every long flag whose name (case-insensitive) contains password, passwd, secret, token or key with `***`, in the `--flag value` form (the next argument when it does not start with `-`) and the `--flag=value` form; write the mask without shell quoting; leave every other argument as today. README (error block paragraph) and docs/console/writing-commands.md (the wrong-directory paragraph) say secret-looking flag values are masked. Commit: `fix(bonfire): mask secret flag values in the wrong-directory hint`.
|
||||
|
||||
No other exported signature changes; bonfire.Reported, bonfire.Fail and bonfire.Execute keep their signatures.</action>
|
||||
<verify>
|
||||
<automated>go test -count=1 -race -short -run '^(TestWithExitCode|TestWrongDirectoryMasksSecrets|TestWrongDirectoryHint|TestExitCodes|TestReportedSilent|TestErrorGolden)$' -v ./modules/bonfire 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 6</automated>
|
||||
<fails_when>CR-02 (bonfire side): TestWithExitCode fails (the carrier code is ignored, a block is printed for a Reported carrier, or an out-of-range code escapes). WR-08: TestWrongDirectoryMasksSecrets fails (a secret value appears in the Try line or another argument changed). Or TestWrongDirectoryHint, TestExitCodes, TestReportedSilent or the error goldens regress.</fails_when>
|
||||
<automated>go test -count=1 -short -run '^(TestDelegateExitCodePassthrough|TestAppStreams|TestDelegateStreams|TestRunChildForwardsSignals|TestNewAppCmdCancelSendsSIGTERM|TestDelegateExamples)$' -v ./cmd/summer 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 6</automated>
|
||||
<fails_when>CR-02: TestDelegateExitCodePassthrough fails (summer exits 1 for a child's 2, prints a second error block, or a signalled child is silent). CR-03: TestAppStreams or TestDelegateStreams fails (the child's stderr reaches summer's stdout, or a terminal Output does not hand the child the process's own streams). Or the existing delegate signal tests regress.</fails_when>
|
||||
<automated>go test -count=1 -short -run '^(TestDevStopWaitsForGracefulShutdown|TestStartBinServe|TestDevReport|TestDevGracefulNil)$' -v ./internal/dev 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 4 && go vet ./... && go test -count=1 -short ./... && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>WR-03: TestDevStopWaitsForGracefulShutdown fails (serve is killed before its drain ends, or dev's grace differs from the delegates'). Or the dev tests, vet, the short suite or the docs checker (a README or docs identifier that does not exist) fail.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- CR-02: `grep -c '^func WithExitCode(' modules/bonfire/errors.go modules/bonfire/execute.go | awk -F: '{s+=$2} END {print s}'` prints 1 and `grep -c 'bonfire.WithExitCode' modules/bonfire/README.md` prints at least 1.
|
||||
- CR-03: `grep -c 'os.Stderr' cmd/summer/runtime.go` prints at least 1 and TestDelegateStreams passes.
|
||||
- WR-03: `grep -c 'dev.ShutdownGrace' cmd/summer/runtime.go` prints at least 1 and `grep -c 'ShutdownGrace' internal/dev/watch.go` prints at least 2 (declaration and use in child.stop); TestDevStopWaitsForGracefulShutdown passes.
|
||||
- WR-08: TestWrongDirectoryMasksSecrets passes and `grep -ci 'masked\|\*\*\*' docs/console/writing-commands.md` prints at least 1.
|
||||
- `git log --format=%s <plan_head_before>..HEAD` lists the four fix subjects as four separate commits; the SUMMARY records each pinning test's RED output.
|
||||
</acceptance_criteria>
|
||||
<done>summer's delegates behave like running the binary directly (terminal, separate streams, same exit code, one error block), summer dev lets serve finish its shutdown, and the wrong-directory hint never repeats a secret.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 4: diary: WR-04 a failed daily open is retried after a minute or on the next date, WR-06 replaced daily writers keep no descriptor and ApplyGlobals rebuilds once</name>
|
||||
<files>modules/diary/daily.go, modules/diary/diary.go, modules/diary/daily_test.go, modules/diary/diary_test.go, modules/diary/README.md, docs/services/logging.md</files>
|
||||
<behavior>
|
||||
- TestDailyRetriesAfterFailure: with an injected clock at 2026-10-21 10:00:00 and a regular file where the log directory should be, the first record warns once (`daily log file disabled`); the test removes the blocking file; a record at 10:00:30 writes nothing and does not warn again; a record at 10:01:01 creates summer-2026-10-21.log holding it. A second scenario: the failure on 2026-10-21 23:59:59, blocker removed, a record at 2026-10-22 00:00:01 creates summer-2026-10-22.log; if the new date fails as well, exactly one more warning appears for it.
|
||||
- TestRetiredDailyWriterKeepsNoFile: a daily writer that has written once is retired (as install and Reset do with the previous channels); a later record still lands in the day's file and afterwards the writer holds no open file; diary.Close on the installed writers still lets the next record reopen the file.
|
||||
- TestApplyGlobalsRebuildsOnce: after Setup with color auto, 50 goroutines released by a barrier call ApplyGlobals with color never; the channels are rebuilt exactly once (the generation counter advances by one) and every call's verbosity is applied; run under -race.
|
||||
- TestDailyUnwritable, TestDailyDegradesSmoke, TestDailyRotation, TestConcurrentWrites and TestResetRestoresDefault stay green.
|
||||
</behavior>
|
||||
<read_first>modules/diary/daily.go (dailyWriter, Write, open, Close), modules/diary/diary.go (install, SetVerbosity, ApplyGlobals, Close, closeChannels, Reset), modules/diary/channels.go (buildChannels, channelBuilder.warn), modules/diary/daily_test.go (TestDailyUnwritable, the setup helper, lockedBuffer, dailyOnly), modules/diary/diary_test.go, modules/diary/README.md (Daily files), docs/services/logging.md (the paragraph about a file that cannot be opened), 19.8-REVIEW.md (WR-04, WR-06), 19.8-01-PLAN.md threat T-19.8-06</read_first>
|
||||
<action>Two findings, two commits. Locate the code by symbol (the review's daily.go line numbers do not match the file). Verify each claim and record the pinning tests' RED output.
|
||||
|
||||
WR-04 (D-10): dailyWriter.Write sets `disabled` on the first failed open and never clears it, so one transient failure (ENOSPC, EMFILE, a directory briefly removed at rotation) stops file logging until restart. Replace the permanent flag with a retry point: after a failed open, skip writes (still returning len(p), nil) until one minute has passed by the writer's clock or the local date changes, whichever comes first, then try to open again; warn through the existing warn function once per failed date (keep the message `daily log file disabled` so the pinned tests and existing alerts match). T-19.8-06's rule stands: boot never fails on a log file. Update the dailyWriter doc comment, modules/diary/README.md (Daily files bullet) and docs/services/logging.md (the cannot-be-opened paragraph: the channel skips records and tries again a minute later or on the next day, warning once per day). Commit: `fix(diary): retry a daily file that failed to open`.
|
||||
|
||||
WR-06 (D-10): install and Reset swap the state and then close the old channels' daily writers, but a goroutine holding the old state reopens the file through the closed writer and nothing closes it again; ApplyGlobals reads the state outside `mu`, so two concurrent calls can both rebuild. Add a retire step for replaced channels, separate from Close: a retired daily writer closes its file, and every later write opens the day's file, appends the record and closes it again (no record lost, no descriptor kept). install and Reset retire the old channels; the public diary.Close keeps closing the installed writers with reopen on the next record, as documented. Make ApplyGlobals hold `mu` for its whole run, using lock-free internal variants of install and SetVerbosity so nothing locks twice. Commit: `fix(diary): retire replaced daily writers and serialise ApplyGlobals`.
|
||||
|
||||
No exported signature changes in diary.</action>
|
||||
<verify>
|
||||
<automated>go test -count=1 -race -short -run '^(TestDailyRetriesAfterFailure|TestRetiredDailyWriterKeepsNoFile|TestApplyGlobalsRebuildsOnce|TestDailyUnwritable|TestDailyDegradesSmoke|TestDailyRotation|TestConcurrentWrites|TestResetRestoresDefault)$' -v ./modules/diary 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 8</automated>
|
||||
<fails_when>WR-04: TestDailyRetriesAfterFailure fails (no retry after a minute or on a new date, a retry before the minute, or more than one warning per date). WR-06: TestRetiredDailyWriterKeepsNoFile or TestApplyGlobalsRebuildsOnce fails (a retired writer keeps a file open or loses the record, diary.Close no longer reopens, or concurrent ApplyGlobals calls rebuild more than once). Or a race report, or the existing daily, rotation, concurrency or reset tests regress.</fails_when>
|
||||
<automated>go vet ./... && go test -count=1 -short ./... && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>vet, the short suite or the docs checker fail.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- WR-04: `grep -ci 'next day\|a minute\|one minute' docs/services/logging.md` prints at least 1 and TestDailyRetriesAfterFailure passes.
|
||||
- WR-06: `go test -count=3 -race -short -run '^(TestRetiredDailyWriterKeepsNoFile|TestApplyGlobalsRebuildsOnce)$' ./modules/diary` exits 0.
|
||||
- `git log --format=%s <plan_head_before>..HEAD` lists the WR-04 and WR-06 fix subjects as two separate commits; the SUMMARY records each pinning test's RED output.
|
||||
</acceptance_criteria>
|
||||
<done>A long-running server keeps its file log trail through transient failures and setup changes, without leaking descriptors.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 5: WR-05 error blocks, process views and tables strip control characters through the additive bonfire.Sanitize, which diary's console handler also uses</name>
|
||||
<files>modules/bonfire/errors.go, modules/bonfire/live.go, modules/bonfire/widgets.go, modules/bonfire/helpers.go, modules/bonfire/errors_test.go, modules/bonfire/live_test.go, modules/bonfire/widgets_test.go, modules/bonfire/README.md, modules/diary/console.go, docs/console/writing-commands.md</files>
|
||||
<behavior>
|
||||
- TestSanitize: tab kept, CR and LF become spaces, ESC, BEL, other C0 controls and DEL removed, clean strings returned unchanged; the same table as diary's TestConsoleStripsControlChars.
|
||||
- TestRenderErrorStripsControl: with color forced on (Globals Color always), an error whose message, HintError explanation and Try text and wrapped cause (at -v) carry `\x1b]0;pwned\x07` and `\x1b[2J` renders a block that contains neither injected sequence (the palette's own styling sequences may remain) and still shows the visible text.
|
||||
- TestProcessViewStripsControl: the process model's View, with an item name and an item error carrying the same sequences, contains neither.
|
||||
- TestTableStripsControl: Table on a terminal console with color forced, and on a non-terminal console, with a cell and a header carrying the sequences, prints neither.
|
||||
- TestConsoleStripsControlChars (diary) and the bonfire goldens stay green.
|
||||
</behavior>
|
||||
<read_first>modules/diary/console.go (sanitize and its callers), modules/bonfire/errors.go (RenderError, writeStyled, causes), modules/bonfire/live.go (processModel.View), modules/bonfire/widgets.go (Table, failureLine), modules/bonfire/helpers.go (Process, failure), modules/bonfire/errors_test.go, modules/bonfire/live_test.go, modules/bonfire/widgets_test.go, modules/diary/console_test.go (TestConsoleStripsControlChars), modules/bonfire/README.md, docs/console/writing-commands.md, 19.8-REVIEW.md (WR-05, IN-08), 19.8-01-PLAN.md threat T-19.8-03</read_first>
|
||||
<action>Per WR-05 (D-12, D-18; extends threat T-19.8-03 from diary's console to bonfire's output). Verify against the source that RenderError passes text through sunscreen.Scrub only (which keeps ESC and other control characters), that processModel.View prints item names and `it.err.Error()` unchanged and that Table prints cells unchanged, and that the colorprofile writer passes such sequences through when color is on. Write the four tests with color forced on (the NoTTY profile strips all escape sequences and would hide the defect) and record their RED output.
|
||||
|
||||
Move diary's sanitizer into bonfire as the exported `Sanitize(s string) string` with the same rules (tab kept, CR and LF to a space, every other control character dropped) and make diary's console handler call it, deleting diary's copy. Apply it before styling to: every error-block line (message, explanation, Try lines and the -v chain, after splitting into lines and alongside sunscreen.Scrub), process-view item names and item errors, the failure lines of Progress and Process, and table headers and cells in both the terminal and the plain layout. Do not add the IN-08 change (invalid UTF-8 and raw C1 bytes); the todo records it against bonfire.Sanitize.
|
||||
|
||||
README: an API row for bonfire.Sanitize and one sentence in the error block and widgets text that printed text is stripped of control characters; docs/console/writing-commands.md: one sentence where error output and tables are described. Commit: `fix(bonfire): strip control characters from error blocks, process views and tables`.</action>
|
||||
<verify>
|
||||
<automated>go test -count=1 -race -short -run '^(TestSanitize|TestRenderErrorStripsControl|TestProcessViewStripsControl|TestTableStripsControl|TestErrorGolden|TestListGolden|TestWidgetMatrix|TestLiveModels)$' -v ./modules/bonfire 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 8 && go test -count=1 -race -short -run '^(TestConsoleStripsControlChars|TestSlogtestConsoleHandler)$' ./modules/diary && go vet ./... && go test -count=1 -short ./... && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>WR-05: an injected OSC or CSI sequence survives in an error block, process view or table (TestRenderErrorStripsControl, TestProcessViewStripsControl, TestTableStripsControl), TestSanitize disagrees with diary's rules, diary's console tests regress, a golden changes, or vet, the short suite or the docs checker fail.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- WR-05: `grep -c '^func Sanitize(' modules/bonfire/*.go | awk -F: '{s+=$2} END {print s}'` prints 1, `grep -c 'bonfire.Sanitize' modules/diary/console.go` prints at least 1 and `grep -c 'bonfire.Sanitize' modules/bonfire/README.md` prints at least 1.
|
||||
- `git log --format=%h <plan_head_before>..HEAD -- modules/bonfire/testdata` prints nothing (no golden changed by this plan).
|
||||
- The SUMMARY records the RED output of the three WR-05 output tests.
|
||||
</acceptance_criteria>
|
||||
<done>Text from databases, remote services or admins can no longer move the cursor or retitle the operator's terminal through error blocks, process views or tables.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 6: WR-07 Migrate and PendingMigrations read one migration set list, and migrate's applied lines count what Migrate actually applied</name>
|
||||
<files>modules/lagoon/migrations.go, modules/lagoon/commands.go, modules/lagoon/pending_test.go, modules/lagoon/README.md</files>
|
||||
<precondition>A Docker daemon answers (`docker info`, directly or through `sudo -u golem`); TestMigrateAppliedLines is a testcontainers test. Without one, stop the task and report it.</precondition>
|
||||
<behavior>
|
||||
- TestMigrateAppliedLines (Docker, skipped under -short through the package's TestMain): two fixture plugins, alpha with one migration that succeeds and beta with one whose SQL fails; `migrate` on a fresh database prints `alpha: 1 applied` and the framework sets' applied lines, returns beta's error and prints no success line; after replacing beta's migration with a valid one, a second `migrate` prints `beta: 1 applied` and `Applied 1 migrations`, and a third prints `Nothing to migrate`.
|
||||
- TestPendingMigrations, TestMigratePretend, TestMigrateStatusJSON, TestMigrateRunsSystemFilesFirst and TestTwoPluginMigrationSetsIsolated stay green.
|
||||
</behavior>
|
||||
<read_first>modules/lagoon/migrations.go (Migrate, migrator, migrationSet, migrationSets, PendingMigrations), modules/lagoon/commands.go (the migrate command's Run), modules/lagoon/pending_test.go (pendingPlugins, migPlugin, runLagoon, TestMain), modules/lagoon/migrations_test.go (TestMigrateRunsSystemFilesFirst), modules/lagoon/README.md, 19.8-REVIEW.md (WR-07)</read_first>
|
||||
<action>Per WR-07 (D-21, D-04). Verify that Migrate lists the framework sets and the plugin loop by hand while PendingMigrations reads migrationSets, and that migrate prints its applied lines from the list computed before Migrate ran (so a failure partway prints none). Write TestMigrateAppliedLines and record its RED output (no applied lines when beta fails).
|
||||
|
||||
Make Migrate iterate `migrationSets(gdb, plugins)`, so the startup summary, --pretend, the production question's count, the applied lines and migrate:status --json read the same list. Give each set a label so Migrate's error wording stays as today (`lagoon: migrate system_files: ...`, `backend admin`, `deferred bindings`, `queue`, `<plugin id>`). In the migrate command, compute PendingMigrations before and after Migrate and print the per-set applied lines and the result line from the difference; when Migrate fails, print the lines for what was applied and then return its error. Migrate's signature and its order of sets do not change. README: the migrate description says the applied lines count what was applied, also when a migration fails. Commit: `fix(lagoon): one migration set list for Migrate and PendingMigrations`.</action>
|
||||
<verify>
|
||||
<automated>TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 go test -count=1 -timeout 10m -run '^(TestMigrateAppliedLines|TestPendingMigrations|TestMigratePretend|TestMigrateStatusJSON|TestMigrateRunsSystemFilesFirst|TestTwoPluginMigrationSetsIsolated)$' -v ./modules/lagoon 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 6 && go vet ./... && go test -count=1 -short ./...</automated>
|
||||
<fails_when>WR-07: TestMigrateAppliedLines fails (no applied lines on a partial failure, wrong counts, or a success line after a failure), any of the five existing migration tests regress, a test was skipped (fewer than 6 passes means Docker did not run: report NOT RUN, do not count it as passed), or vet or the short suite fail.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- WR-07: `grep -v '^\s*//' modules/lagoon/migrations.go | grep -c 'attach.Migrations'` prints 1 and `grep -v '^\s*//' modules/lagoon/migrations.go | grep -c 'QueueMigrations(sqlDB)'` prints 1 (the framework sets are listed once, in migrationSets).
|
||||
- WR-07: `grep -c 'migrationSets(gdb, plugins)' modules/lagoon/migrations.go` prints at least 2 (Migrate and PendingMigrations).
|
||||
- `git log --format=%s <plan_head_before>..HEAD` lists the WR-07 fix subject; the SUMMARY records the RED output.
|
||||
</acceptance_criteria>
|
||||
<done>A migration set added once is applied, counted, pretended and reported everywhere, and migrate reports what it really applied.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 7: The gate requires every pinning test, each fix is replayed red without its code, the full suites are green with Docker, the disposition record is updated and IN-01..IN-09 become one pending todo</name>
|
||||
<files>scripts/check-phase19.8.sh, .planning/phases/19.8-console-commands-overhaul/19.8-REVIEW-DISPOSITION.md, .planning/todos/pending/2026-10-22-console-review-info-findings.md, .planning/phases/19.8-console-commands-overhaul/19.8-05-SUMMARY.md</files>
|
||||
<read_first>scripts/check-phase19.8.sh (the required-name arrays and run_go, run_docker), 19.8-REVIEW-DISPOSITION.md (frontmatter and table format, the note on setting dispositions by hand), 19.8-REVIEW.md (IN-01..IN-09), .planning/todos/pending/2026-10-21-quiet-plugin-boot-logs-on-console-commands.md (todo format), this plan's commits (git log plan_head_before..HEAD)</read_first>
|
||||
<action>(1) Gate. Add every new test name to the required arrays in scripts/check-phase19.8.sh with a `# Plan 05 (review fixes)` comment: BONFIRE_TESTS gets TestPrintAboveOrderedNoLoss, TestPrintlnStaysWithItsConsole, TestLiveViewSingleOwner, TestLiveViewPanicUnregisters, TestLiveViewRestoresTerminalOnSignal, TestWithExitCode, TestWrongDirectoryMasksSecrets, TestSanitize, TestRenderErrorStripsControl, TestProcessViewStripsControl, TestTableStripsControl; DIARY_TESTS gets TestDailyRetriesAfterFailure, TestRetiredDailyWriterKeepsNoFile, TestApplyGlobalsRebuildsOnce; SUMMER_TESTS gets TestDelegateExitCodePassthrough, TestAppStreams, TestDelegateStreams; DEV_TESTS gets TestDevStopWaitsForGracefulShutdown; DOCKER_LAGOON_TESTS gets TestMigrateAppliedLines. Use the names the tasks actually committed (record any rename). Leave IN-09's naming of consuming applications alone (it is in the todo). Commit: `test(19.8): gate requires the review fixes' tests`.
|
||||
|
||||
(2) RED replay. For each of the eleven fix commits C, in a temporary detached worktree under the session scratchpad (`git worktree add --detach <dir> C`), reverse-apply C's changes outside test files and Markdown (`git -C <dir> show C -- . ':!*_test.go' ':!*.md' > <dir>.patch && git -C <dir> apply -R <dir>.patch`), run C's pinning test by name and expect it to FAIL (a compile failure counts for the tests of the two new exported identifiers; say so in the table), then `git worktree remove --force <dir>`. The lagoon replay needs Docker. Record per finding: commit, pinning test, RED kind (assertion or compile), result. A pinning test that passes on the reverted code means the test does not pin the fix: stop and fix the test in its own `test(...)` commit before continuing.
|
||||
|
||||
(3) Full verification: `go vet ./...`; `TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 go test -count=1 ./...` (through `sudo -u golem` if the socket is denied; the known wristband flake TestOIDCSubjectIsRandomSmoke from deferred-items.md may be rerun once and recorded); `go -C examples/hello test -count=1 ./...`; `bash scripts/check-phase19.8.sh go`; `bash scripts/check-phase19.8.sh docs`; `TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 bash scripts/check-phase19.8.sh docker`. API check: no exported func, method or type declaration line removed between plan_head_before and HEAD in non-test Go files under modules/ unless the identical line is re-added (`comm -23` of the removed and added `func`/`type` lines, filtered to exported names, prints nothing).
|
||||
|
||||
(4) Records. Edit 19.8-REVIEW-DISPOSITION.md by hand: CR-01..CR-03 and WR-01..WR-08 `disposition: fixed` in the frontmatter and `fixed` in the table, with Source `fix <short hash> (19.8-05)`; IN-01..IN-09 `deferred` with Source `todo .planning/todos/pending/2026-10-22-console-review-info-findings.md`; `open:` set to the number of findings still open (0). Create the todo in the existing format: frontmatter `created` (ISO time), `title: Console review info findings (IN-01..IN-09)`, `area: framework`, `severity: minor`, `g15office: pending` (the orchestrator mirrors it; no g15office tool here), `files:` the files the nine findings name; body: one section per finding with its file, the problem and the review's suggested fix, noting that IN-08's fix now belongs in bonfire.Sanitize (Task 5) and that IN-07 should keep diary's new use of bonfire.Sanitize in mind. Commit both planning files together: `docs(19.8): review fixes recorded, info findings captured as a todo`.
|
||||
|
||||
(5) SUMMARY: plan_head_before and plan_head_after, the commits per finding, the RED replay table, the verification results (Docker run or NOT RUN), the API check result, and a Release impact table (finding, commit, module, exported API added, needs require in plugins yes/no, regenerate in apps yes/no). Expected: API added bonfire.WithExitCode and bonfire.Sanitize, no plugin uses them, the generated main is unchanged, so no require bump and no regenerate; any row that differs says why. Add a suggested g15office comment for the orchestrator ("Plan 19.8-05: 11 review findings fixed with pinning tests; info findings tracked in a todo; release v0.4.2 next").</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go -C examples/hello test -count=1 ./... && bash scripts/check-phase19.8.sh go && bash scripts/check-phase19.8.sh docs && TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 bash scripts/check-phase19.8.sh docker</automated>
|
||||
<fails_when>vet, the example application's tests, or the gate's go, docs or docker stage fail (a pinning test missing, skipped or red by name, a stale docs identifier, or a Docker test red; with no Docker daemon the docker stage refuses, which is a failure here).</fails_when>
|
||||
<automated>TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 go test -count=1 ./... && grep -c 'disposition: fixed' .planning/phases/19.8-console-commands-overhaul/19.8-REVIEW-DISPOSITION.md | grep -qx 11 && grep -c 'disposition: deferred' .planning/phases/19.8-console-commands-overhaul/19.8-REVIEW-DISPOSITION.md | grep -qx 9 && test -f .planning/todos/pending/2026-10-22-console-review-info-findings.md</automated>
|
||||
<fails_when>The full suite with Docker fails, the disposition record does not show 11 fixed and 9 deferred findings, or the todo file is missing.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -cE 'TestPrintAboveOrderedNoLoss|TestDelegateExitCodePassthrough|TestDailyRetriesAfterFailure|TestDevStopWaitsForGracefulShutdown|TestMigrateAppliedLines|TestRenderErrorStripsControl|TestWrongDirectoryMasksSecrets' scripts/check-phase19.8.sh` prints at least 7.
|
||||
- The SUMMARY's RED replay table has eleven rows, each with result "failed on reverted code".
|
||||
- `grep -cE '^(severity: minor|area: framework|g15office: pending)$' .planning/todos/pending/2026-10-22-console-review-info-findings.md` prints 3 and `grep -cE '^## IN-0[1-9]' .planning/todos/pending/2026-10-22-console-review-info-findings.md` prints 9.
|
||||
- The SUMMARY holds the Release impact table and the API check result (no exported declaration removed).
|
||||
- None of this plan's commits (the hashes the SUMMARY lists) includes `.planning/STATE.md` or `.planning/state.json`: for each listed hash, `git show --name-only --format= <hash>` names neither file. Other sessions' edits and commits to them are left alone.
|
||||
</acceptance_criteria>
|
||||
<done>Every review fix is pinned by a gated test that is proven to fail without it, the whole framework suite is green with Docker, the review record says what happened to each finding, and plan 06 has the table it needs to release.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| database, remote services and admin input to the operator's terminal | error text, item names and table cells printed by commands |
|
||||
| command arguments to stderr and CI logs | the wrong-directory hint repeats arguments |
|
||||
| summer to the delegated app binary | streams, terminal, exit code and signals |
|
||||
| long-running processes to the filesystem | daily log files over days of uptime |
|
||||
| framework to shared core plugins and applications | exported APIs and the generated main |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.8-26 | Tampering | bonfire error block, process view, table (WR-05) | medium | mitigate | bonfire.Sanitize applied to every printed line, item, error and cell before styling; tests with color forced on prove OSC and CSI sequences are gone |
|
||||
| T-19.8-27 | Information disclosure | wrong-directory hint (WR-08) | medium | mitigate | values of flags named like password, passwd, secret, token or key replaced by *** in the Try line; TestWrongDirectoryMasksSecrets |
|
||||
| T-19.8-28 | Elevation of privilege | summer delegates skip the production question (CR-03) | high | mitigate | the child gets summer's own streams on a terminal, so bonfire.Interactive and ConfirmProceed see a terminal; TestAppStreams pins the choice |
|
||||
| T-19.8-29 | Tampering | --json and -q data captured through summer (CR-03) | medium | mitigate | the child's stderr goes to stderr, never into stdout; TestDelegateStreams |
|
||||
| T-19.8-30 | Repudiation | daily log trail lost after one failed open (WR-04) | medium | mitigate | retry after a minute or on the next date, one warning per date; TestDailyRetriesAfterFailure |
|
||||
| T-19.8-31 | Denial of service | descriptors leaked by replaced daily writers (WR-06) | low | mitigate | retired writers open, append and close per late record; TestRetiredDailyWriterKeepsNoFile |
|
||||
| T-19.8-32 | Denial of service | signal handling in live views (WR-01) | medium | mitigate | the view relays SIGINT and SIGTERM only while it runs, restores the terminal, then re-raises so the default action or the command's own handler applies; both paths tested in a helper process |
|
||||
| T-19.8-33 | Tampering | shared plugin and application contracts | medium | mitigate | additions only (WithExitCode, Sanitize); Task 7's API check finds no removed exported declaration; the generated main is untouched (Task 2 verify) |
|
||||
| T-19.8-SC | Tampering | dependencies | low | accept | this plan adds no module; tests use the standard library, the existing charm modules and testcontainers |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1 (tracer): CR-01 red then green, -race, three clean runs.
|
||||
- Task 2: WR-02 and WR-01 tests, generated main untouched.
|
||||
- Task 3: bonfire, cmd/summer and internal/dev tests for CR-02, CR-03, WR-03, WR-08; docs checker.
|
||||
- Task 4: diary tests for WR-04 and WR-06 with -race; docs checker.
|
||||
- Task 5: WR-05 tests with color forced on; goldens unchanged; docs checker.
|
||||
- Task 6: WR-07 Docker test plus the existing migration tests.
|
||||
- Task 7: gate names, RED replay of all eleven commits, `go vet ./...`, `go test ./...` with Docker, examples/hello, gate stages go, docs and docker, API check, disposition and todo.
|
||||
- Feedback sampling: after every commit `go vet ./...` and `go test -short ./...`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- CR-01..CR-03 and WR-01..WR-08 fixed, one commit each, each with a gated pinning test proven red without the fix.
|
||||
- No exported API removed or changed; two additive identifiers documented in the bonfire README and docs.
|
||||
- `go vet ./...`, `go test ./...` with Docker, examples/hello and the gate's go, docs and docker stages green.
|
||||
- 19.8-REVIEW-DISPOSITION.md: 11 fixed, 9 deferred; the IN todo exists with `g15office: pending` for the orchestrator.
|
||||
- The SUMMARY's Release impact table tells plan 06 whether any plugin or application needs a commit.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.8-console-commands-overhaul/19.8-05-SUMMARY.md` when done, with plan_head_before and plan_head_after, the commit per finding with its pinning test and RED output, the RED replay table, the verification results (Docker run or NOT RUN), the API check, the Release impact table, the todo path, and the suggested g15office comment for the orchestrator.
|
||||
</output>
|
||||
@@ -0,0 +1,255 @@
|
||||
---
|
||||
phase: 19.8-console-commands-overhaul
|
||||
plan: 05
|
||||
subsystem: console
|
||||
tags: [review-fixes, bonfire, bubbletea, signals, summer, delegates, dev, diary, lagoon, migrations, gate]
|
||||
status: complete
|
||||
gap_closure: true
|
||||
|
||||
requires:
|
||||
- phase: 19.8-01..04
|
||||
provides: the shipped console (v0.4.0, v0.4.1) and its review 19.8-REVIEW.md
|
||||
provides:
|
||||
- "CR-01..CR-03 and WR-01..WR-08 fixed, one commit each with a pinning test proven red without the fix"
|
||||
- "Additive API: bonfire.WithExitCode, bonfire.Sanitize"
|
||||
- "scripts/check-phase19.8.sh requires the 19 new pinning tests by name"
|
||||
- "IN-01..IN-09 captured in .planning/todos/pending/2026-10-22-console-review-info-findings.md"
|
||||
affects: [19.8-06 release v0.4.2, bonfire live views, summer delegates, summer dev, diary daily files, lagoon migrate]
|
||||
|
||||
actuals:
|
||||
tokens: 21548 # chars/4 over the added and removed lines of this plan's diff (86191 chars)
|
||||
tasks: 7
|
||||
commits: 13 # MEASURED: git rev-list --count plan_head_before..plan_head_after
|
||||
plan_head_before: e8f5b1c89c639e5d1bcaf3096ddf3f05ea58864a
|
||||
plan_head_after: d311d029c3e86736f18fb7e186ca51073fd510b8
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Print above a bubbletea program by sending tea.Println(block)() straight to the program queue under a lock, never as a Cmd"
|
||||
- "Live view registry: owner console + program, check-and-set claim, deferred unregister/finish/wait under one lock"
|
||||
- "Signal relay scoped to a live view: Kill, unregister, signal.Stop, re-raise the same signal"
|
||||
- "Re-executed test binary as a helper process (live view signals, delegate exit codes, dev stop grace)"
|
||||
- "Exit-code carrier error (WithExitCode) honoured by Execute before the usage check"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- modules/bonfire/live_signal_unix_test.go
|
||||
- modules/diary/retire_internal_test.go
|
||||
- .planning/todos/pending/2026-10-22-console-review-info-findings.md
|
||||
modified:
|
||||
- modules/bonfire/live.go
|
||||
- modules/bonfire/output.go
|
||||
- modules/bonfire/widgets.go
|
||||
- modules/bonfire/helpers.go
|
||||
- modules/bonfire/errors.go
|
||||
- modules/bonfire/execute.go
|
||||
- modules/bonfire/README.md
|
||||
- modules/diary/daily.go
|
||||
- modules/diary/diary.go
|
||||
- modules/diary/console.go
|
||||
- modules/diary/README.md
|
||||
- modules/lagoon/migrations.go
|
||||
- modules/lagoon/commands.go
|
||||
- modules/lagoon/README.md
|
||||
- cmd/summer/runtime.go
|
||||
- internal/dev/watch.go
|
||||
- docs/console/introduction.md
|
||||
- docs/console/writing-commands.md
|
||||
- docs/console/scaffolding.md
|
||||
- docs/services/logging.md
|
||||
- docs/setup/upgrading.md
|
||||
- scripts/check-phase19.8.sh
|
||||
- .planning/phases/19.8-console-commands-overhaul/19.8-REVIEW-DISPOSITION.md
|
||||
|
||||
key-decisions:
|
||||
- "CR-01: each PrintAbove block is one tea.Println message sent with Program.Send under liveMu (the direct-queue approach held against bubbletea v2.1.0's event loop); liveModel and printAboveMsg removed"
|
||||
- "WR-01: signal handling lives inside runLive (relay SIGINT/SIGTERM only while a view runs, Kill, re-raise); generated main and bonfire.Execute's context unchanged"
|
||||
- "CR-02: explicit additive bonfire.WithExitCode carrier; bonfire does not honour arbitrary ExitCode() errors, so plugin commands returning *exec.ExitError still exit 1"
|
||||
- "WR-03: dev.ShutdownGrace (internal, 15s) shared by summer dev and summer's delegates"
|
||||
- "WR-04: retry a failed daily open after one minute (writer clock) or on a new date; warning text kept, once per date"
|
||||
- "WR-06: retired daily writers open, append and close per late record; diary.Close keeps reopen-on-next-record"
|
||||
- "WR-05: diary's sanitizer moved to bonfire.Sanitize, diary calls it; IN-08 left to the todo"
|
||||
- "WR-07: migrate reports pending-before minus pending-after, also on a partial failure"
|
||||
|
||||
patterns-established:
|
||||
- "Each review finding: pinning test red on unchanged code, fix, green, one fix(...) commit with test and docs"
|
||||
|
||||
requirements-completed: [D-03, D-04, D-10, D-12, D-18, D-19, D-21, D-25, D-27, D-28, D-29]
|
||||
|
||||
duration: 40min
|
||||
completed: 2026-10-22
|
||||
---
|
||||
|
||||
# Phase 19.8 Plan 05: Code Review Fixes Summary
|
||||
|
||||
**The 11 critical and warning findings of the 19.8 code review are fixed. Each fix is its own commit, and its pinning test fails when the fix is reverted. Lines printed above live views keep their order and none is lost. A live view takes lines only from the console that started it. Ctrl+C during a view restores the terminal. summer's delegates keep the app binary's exit code and give it the terminal. summer dev waits for serve's drain. Daily log files retry after a failed open, and replaced daily writers are retired. Error blocks, process views and tables strip control characters. Migrate and PendingMigrations read one list of migration sets. The wrong-directory hint masks secret flag values. The nine info findings are in one todo.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 40 min
|
||||
- **Started:** 2026-10-22T00:04:11Z
|
||||
- **Completed:** 2026-10-22T00:44:00Z
|
||||
- **Tasks:** 7 of 7
|
||||
- **Files modified:** 33 (1695 insertions, 282 deletions)
|
||||
|
||||
## Commits per finding
|
||||
|
||||
| Finding | Commit | Subject | Pinning tests |
|
||||
|---------|--------|---------|---------------|
|
||||
| CR-01 | b077dc3 | fix(bonfire): keep the order of lines printed above a live view and drain them before it ends | TestPrintAboveOrderedNoLoss (spinner, progress, process, concurrent_finish) |
|
||||
| WR-02 | 86e4e27 | fix(bonfire): reroute only the owning console's lines to a live view | TestPrintlnStaysWithItsConsole, TestLiveViewSingleOwner, TestLiveViewPanicUnregisters |
|
||||
| WR-01 | d151a25 | fix(bonfire): restore the terminal when a live view is interrupted | TestLiveViewRestoresTerminalOnSignal (default, own_handler) |
|
||||
| CR-02 | 323413c | fix(summer): pass the app binary's exit code through and print its error once | TestDelegateExitCodePassthrough, TestWithExitCode |
|
||||
| CR-03 | 9c89705 | fix(summer): give delegated commands the terminal and keep their stderr apart | TestDelegateStreams, TestAppStreams |
|
||||
| WR-03 | fc7706d | fix(dev): give serve the full shutdown grace before killing it | TestDevStopWaitsForGracefulShutdown |
|
||||
| WR-08 | 9b62285 | fix(bonfire): mask secret flag values in the wrong-directory hint | TestWrongDirectoryMasksSecrets |
|
||||
| WR-04 | c586c6f | fix(diary): retry a daily file that failed to open | TestDailyRetriesAfterFailure |
|
||||
| WR-06 | 39677f1 | fix(diary): retire replaced daily writers and serialise ApplyGlobals | TestRetiredDailyWriterKeepsNoFile, TestApplyGlobalsRebuildsOnce |
|
||||
| WR-05 | a9025c0 | fix(bonfire): strip control characters from error blocks, process views and tables | TestSanitize, TestRenderErrorStripsControl, TestProcessViewStripsControl, TestTableStripsControl |
|
||||
| WR-07 | 020fe83 | fix(lagoon): one migration set list for Migrate and PendingMigrations | TestMigrateAppliedLines (Docker) |
|
||||
| gate | 1bf64b0 | test(19.8): gate requires the review fixes' tests | (all of the above by name) |
|
||||
| records | d311d02 | docs(19.8): review fixes recorded, info findings captured as a todo | n/a |
|
||||
|
||||
No other session's commit sits between them. None of these commits touches `.planning/STATE.md` or `.planning/state.json`.
|
||||
|
||||
## RED output on the code before each fix
|
||||
|
||||
Each pinning test was first run on the unchanged code (only the test added) during execution.
|
||||
|
||||
- **CR-01** TestPrintAboveOrderedNoLoss: `spinner: run 0: line 15 is line-016, want line-015`; `progress: run 0: line 67 is line-068, want line-067`; `process: run 3: line 67 is line-068, want line-067`; `concurrent_finish: run 0: bg-016 printed 0 times` (lost), with `bg-014` printed before `bg-013`.
|
||||
- **WR-02** TestPrintlnStaysWithItsConsole: `console B's writer lacks "b-println"` / `console B's "b-println" printed above console A's view`. TestLiveViewSingleOwner: `round 0: 0 consoles printed their plain line to their own writer, want 1` (B's `[...] label-0` landed in A's view). TestLiveViewPanicUnregisters: `the panicked view is still registered`.
|
||||
- **WR-01** TestLiveViewRestoresTerminalOnSignal/default: `cursor left hidden: no show-cursor after the last hide-cursor in "\x1b[?25l\x1b[?2004h\r\x1b[J"`. The own_handler subtest passed on the old code too. It pins that the fix keeps the command's own handler working.
|
||||
- **CR-02** TestDelegateExitCodePassthrough/exit_code: `exit 1, want the binary's 2` and `summer printed an error block of its own: stderr "\n ERROR summer: migrate --bogus: exit status 2 ..."`. The test ran on the code after the pure refactor that factored out `runAppCmd`. TestWithExitCode is new API, so its RED is a compile failure.
|
||||
- **CR-03** TestDelegateStreams: `the binary's stderr landed in summer's stdout: "data-line\nlog-line\n"`.
|
||||
- **WR-03** TestDevStopWaitsForGracefulShutdown: `child was not allowed to finish its shutdown: signal: killed`, after 2.02s.
|
||||
- **WR-08** TestWrongDirectoryMasksSecrets: `Try = "cd ..; {bin} admin:create --email a@b.c --password hunter2", want "... --password ***"`, and the same for `--password=`, `--client-secret`, `--api-token=`, `--key` and `--PASSWD`.
|
||||
- **WR-04** TestDailyRetriesAfterFailure: `one_minute_later: read daily file: ... summer-2026-10-21.log: no such file or directory`; `next_date: ... summer-2026-10-22.log: no such file or directory`; `next_date_fails_too: 1 warnings over two failed dates, want 2`.
|
||||
- **WR-06** TestRetiredDailyWriterKeepsNoFile: `a late record through the replaced writer left its file open`. TestApplyGlobalsRebuildsOnce: `the channels were rebuilt 2 times, want 1`, then `3 times` (it failed in 2 of 3 runs, because the race is timing-dependent).
|
||||
- **WR-05** TestRenderErrorStripsControl: `error block carries "\x1b]0;pwned\a"` (with color on, the OSC title and `\x1b[2J` reached the message, explanation, Try and Caused-by lines). TestProcessViewStripsControl: `process view carries "\x1b]0;pwned\a"`. TestTableStripsControl: `terminal true: table carries "\x1b]0;pwned\a"` (the BEL also broke the box width), and the same with `terminal false`.
|
||||
- **WR-07** TestMigrateAppliedLines (Docker): `the set applied before the failure is not reported` and `framework set summercms.attach / summercms.cabana / summercms.deferred / summercms.conga not reported`.
|
||||
|
||||
## RED replay from the commits (Task 7)
|
||||
|
||||
For each fix commit C: a detached worktree at C under the session scratchpad, `git show C -- . ':!*_test.go' ':!*.md' | git apply -R`, then C's pinning tests by name, then the worktree removed.
|
||||
|
||||
| Finding | Commit | Pinning test | RED kind | Result |
|
||||
|---------|--------|--------------|----------|--------|
|
||||
| CR-01 | b077dc3 | TestPrintAboveOrderedNoLoss | compile (`undefined: printLine`: TestLiveModels in the same test file uses the helper the fix adds) | failed on reverted code |
|
||||
| WR-02 | 86e4e27 | TestPrintlnStaysWithItsConsole, TestLiveViewSingleOwner, TestLiveViewPanicUnregisters | assertion | failed on reverted code |
|
||||
| WR-01 | d151a25 | TestLiveViewRestoresTerminalOnSignal | assertion (default: cursor left hidden) | failed on reverted code |
|
||||
| CR-02 | 323413c | TestDelegateExitCodePassthrough | compile (`undefined: runAppCmd`, factored out in the same commit; TestWithExitCode tests the new exported identifier) | failed on reverted code |
|
||||
| CR-03 | 9c89705 | TestDelegateStreams, TestAppStreams | compile (`undefined: appStreams`, newAppCmd arity) | failed on reverted code |
|
||||
| WR-03 | fc7706d | TestDevStopWaitsForGracefulShutdown | compile (`undefined: ShutdownGrace`) | failed on reverted code |
|
||||
| WR-08 | 9b62285 | TestWrongDirectoryMasksSecrets | assertion | failed on reverted code |
|
||||
| WR-04 | c586c6f | TestDailyRetriesAfterFailure | assertion | failed on reverted code |
|
||||
| WR-06 | 39677f1 | TestRetiredDailyWriterKeepsNoFile, TestApplyGlobalsRebuildsOnce | assertion | failed on reverted code |
|
||||
| WR-05 | a9025c0 | TestSanitize, TestRenderErrorStripsControl, TestProcessViewStripsControl, TestTableStripsControl | compile (`undefined: Sanitize`, the new exported identifier) | failed on reverted code |
|
||||
| WR-07 | 020fe83 | TestMigrateAppliedLines (Docker) | assertion | failed on reverted code |
|
||||
|
||||
The plan expected compile REDs only for the two new exported identifiers (CR-02, WR-05). Four replays (CR-01, CR-02, CR-03, WR-03) compile-fail because the fix commit adds an unexported helper or constant that the test file uses. For all four, the section above records the assertion RED from running the test on the unchanged code before the fix.
|
||||
|
||||
## Verification
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| `go vet ./...` | PASS |
|
||||
| `go test -short ./...` after every commit | PASS. The known flake TestOIDCSubjectIsRandomSmoke (deferred-items.md: a random UUID contains `ada`) failed once after the WR-07 commit and passed on one rerun |
|
||||
| `TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 go test -count=1 ./...` (Docker, run directly) | PASS |
|
||||
| `go -C examples/hello test -count=1 ./...` | PASS |
|
||||
| `bash scripts/check-phase19.8.sh self-test` / `go` / `hello` / `docs` | PASS / PASS (the race stage over diary, sunscreen, bonfire and docsite included) / PASS / PASS |
|
||||
| `TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 bash scripts/check-phase19.8.sh docker` | PASS (TestServeLifecycle; six lagoon tests including TestMigrateAppliedLines; three cabana tests) |
|
||||
| `go test -count=3 -race` for the CR-01, WR-02 and WR-06 tests | PASS |
|
||||
| Goldens (`modules/bonfire/testdata`) | unchanged |
|
||||
| Generated main (`internal/build`) and `cmd/summer/main.go` | untouched by every commit |
|
||||
|
||||
Docker-backed tests: **run** (direct Docker socket; `sudo -u golem` was not needed).
|
||||
|
||||
## API check
|
||||
|
||||
The check took the `func` and `type` lines removed in non-test Go files under `modules/` between plan_head_before and HEAD, kept those with exported names, and removed those added again (`comm -23`). Three lines remain: the `Init`, `Update` and `View` methods of the **unexported** type `liveModel`, which CR-01 deleted. They are not public API. **No exported framework declaration was removed or changed its signature.** Exported identifiers added: `bonfire.WithExitCode(err error, code int) error` and `bonfire.Sanitize(s string) string`. The `Error` and `Unwrap` methods of the unexported type `exitCodeError` are also new. Unexported signatures changed: `runLive` now returns `(started bool, err error)`, and `newAppCmd` takes separate stdout and stderr writers.
|
||||
|
||||
## Release impact (for plan 06)
|
||||
|
||||
| Finding | Commit | Module | Exported API added | Require bump in plugins | Regenerate in apps |
|
||||
|---------|--------|--------|--------------------|-------------------------|--------------------|
|
||||
| CR-01 | b077dc3 | bonfire | none | no | no |
|
||||
| WR-02 | 86e4e27 | bonfire | none | no | no |
|
||||
| WR-01 | d151a25 | bonfire | none | no | no |
|
||||
| CR-02 | 323413c | bonfire, cmd/summer | bonfire.WithExitCode | no (no plugin uses it) | no |
|
||||
| CR-03 | 9c89705 | cmd/summer | none | no | no |
|
||||
| WR-03 | fc7706d | internal/dev, cmd/summer | none (dev.ShutdownGrace is internal) | no | no |
|
||||
| WR-08 | 9b62285 | bonfire | none | no | no |
|
||||
| WR-04 | c586c6f | diary | none | no | no |
|
||||
| WR-06 | 39677f1 | diary | none | no | no |
|
||||
| WR-05 | a9025c0 | bonfire, diary | bonfire.Sanitize | no (no plugin uses it) | no |
|
||||
| WR-07 | 020fe83 | lagoon | none | no | no |
|
||||
|
||||
The generated main is unchanged, so no application needs a regenerate commit. Applications get the fixes by moving to v0.4.2 (`require` bump plus `go mod tidy`) and rebuilding their binary. The fixes reach plugins through the application's framework version, so no shared plugin needs a commit. Developers get the summer CLI fixes (CR-02, CR-03, WR-03) by reinstalling `summer` at v0.4.2. `docs/setup/upgrading.md` describes the delegate changes as "Since 0.4.2".
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] WR-06 tests live in a new internal test file**
|
||||
- **Found during:** Task 4
|
||||
- **Issue:** TestRetiredDailyWriterKeepsNoFile and TestApplyGlobalsRebuildsOnce need unexported state (`dailyWriter`, `gens`, `install`), but daily_test.go and diary_test.go are the external package `diary_test`.
|
||||
- **Fix:** Added `modules/diary/retire_internal_test.go` (package diary). The gate runs it by name like the others.
|
||||
- **Commit:** 39677f1
|
||||
|
||||
**2. [Rule 2 - Missing critical] Sanitize also applied to the spinner's and progress bar's current item**
|
||||
- **Found during:** Task 5
|
||||
- **Issue:** `Current(item)` shows the same kind of untrusted item name as the process view, in the spinner and progress frames.
|
||||
- **Fix:** `Sanitize(m.current)` in both views, alongside the planned call sites.
|
||||
- **Commit:** a9025c0
|
||||
|
||||
**3. [Rule 1 - Bug] Delegate long help no longer says "summer fails when the app binary fails"**
|
||||
- **Found during:** Task 3 (CR-02)
|
||||
- **Fix:** The text now says "When the app binary fails, summer exits with its exit code."
|
||||
- **Commit:** 323413c
|
||||
|
||||
**4. TestAppStreams takes the terminal decision as a parameter**
|
||||
- **Found during:** Task 3 (CR-03)
|
||||
- **Issue:** From package main, a terminal `bonfire.Output` can only be built on a pseudo-terminal.
|
||||
- **Fix:** `appStreams(out, terminal bool)`. runAppBinary passes `bonfire.IsTerminal(out)`, and the test checks both branches. TestDelegateStreams runs the whole delegate path with a shell-script binary in a temporary application.
|
||||
- **Commit:** 9c89705
|
||||
|
||||
**5. Migrate's wording when `gdb.DB()` fails**
|
||||
- **Found during:** Task 6
|
||||
- **Issue:** Migrate now reads `migrationSets`, which calls `gdb.DB()` before any set runs. That error now reads `lagoon: migration sets: ...` instead of `lagoon: migrate queue: ...`. The wording of each set's migrate error (`system_files`, `backend admin`, `deferred bindings`, `queue`, plugin ID) is kept through a `label` field.
|
||||
- **Commit:** 020fe83
|
||||
|
||||
**6. TestLiveModels "print above" subtest rewritten**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** `liveModel` and `printAboveMsg` were removed. The plan allowed this.
|
||||
- **Fix:** The subtest now asserts that `printLine` is one message rather than a command. The end-to-end subtest sends `printLine`.
|
||||
- **Commit:** b077dc3
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 6 (1 blocking, 1 missing critical, 1 bug, 3 recorded choices). **Impact:** none on scope. No exported API changed beyond the two planned additions.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- `gofmt -l` reports pre-existing formatting drift in files this plan did not create (`cmd/summer/runtime.go`'s rows struct, `cmd/summer/main.go`, several cabana, compass, tide and flare files). It is out of scope and left alone. The code this plan added is gofmt-clean.
|
||||
- The wristband flake from deferred-items.md recurred once (see Verification).
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Todo
|
||||
|
||||
`.planning/todos/pending/2026-10-22-console-review-info-findings.md`: IN-01..IN-09, `severity: minor`, `area: framework`, `g15office: pending` (the orchestrator mirrors it). It notes that IN-08's fix now belongs in `bonfire.Sanitize` and that IN-07 must keep diary's use of `bonfire.Sanitize` in mind.
|
||||
|
||||
## Suggested g15office comment (for the orchestrator)
|
||||
|
||||
"Plan 19.8-05: 11 review findings fixed with pinning tests; info findings tracked in a todo; release v0.4.2 next."
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
Plan 19.8-06 can tag v0.4.2 once the user gives the go-ahead. Nothing was pushed or tagged.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All created files exist on disk. All 13 commit hashes listed above are in `git log` (b077dc3, 86e4e27, d151a25, 323413c, 9c89705, fc7706d, 9b62285, c586c6f, 39677f1, a9025c0, 020fe83, 1bf64b0, d311d02).
|
||||
211
.planning/phases/19.8-console-commands-overhaul/19.8-06-PLAN.md
Normal file
211
.planning/phases/19.8-console-commands-overhaul/19.8-06-PLAN.md
Normal file
@@ -0,0 +1,211 @@
|
||||
---
|
||||
phase: 19.8-console-commands-overhaul
|
||||
plan: 06
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["19.8-05"]
|
||||
gap_closure: true
|
||||
files_modified:
|
||||
- .planning/phases/19.8-console-commands-overhaul/19.8-06-SUMMARY.md
|
||||
- ../sm-demo-app/plugins/demo/content/ROADMAP.md
|
||||
- ../sm-demo-app/plugins/demo/roadmap/names.go
|
||||
autonomous: false
|
||||
requirements: [D-26]
|
||||
user_setup: []
|
||||
|
||||
estimate:
|
||||
tokens: 60000
|
||||
raw_tokens: 60000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-26: nothing is tagged or pushed before the Task 1 reply, which the SUMMARY records verbatim; before the reply no tag points at HEAD."
|
||||
- "On 'release vX.Y.Z' (proposed v0.4.2): after git fetch, origin/master..master holds only plan 05's code commits and planning documents (this phase's and other sessions'); vet, the short suite and the docs checker are green at the commit that gets tagged; an annotated vX.Y.Z tag listing the fixes in user words points at that commit; master is pushed as exactly that commit, then the tag; nothing is force-pushed; the same-day changelog post with user-facing notes under ## Fixed exists and its URL is in the SUMMARY; the token is never printed."
|
||||
- "On 'push-only': the same range check and checks, then master pushed as the tested commit; no tag, no changelog post. On 'hold': nothing leaves this machine."
|
||||
- "Shared plugins get a require bump and applications a regenerate commit only for rows of plan 05's Release impact table that say so (expected: none, because the two API additions are used by no plugin and the generated main did not change)."
|
||||
- "sm-demo-app's roadmap snapshot equals origin/master's .planning/ROADMAP.md, every phase in it has a Polish title and every open phase a Polish summary, in separate sm-demo-app commits (Polish names first, then the snapshot) that are pushed only under 'release' or 'push-only'; when the snapshot already matches, sm-demo-app is not touched."
|
||||
- "`PHASE198_REQUIRE_DOCKER=1 TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 bash scripts/check-phase19.8.sh --all` ends with 'Phase 19.8 gate passed' (TestTerminalCommands may print NOT RUN)."
|
||||
- "Nothing is deployed in any option."
|
||||
artifacts:
|
||||
- path: ".planning/phases/19.8-console-commands-overhaul/19.8-06-SUMMARY.md"
|
||||
provides: "the release proposal, the reply, the tag, pushed ranges, the changelog URL and the --all stage summary"
|
||||
contains: "## Task 1 reply"
|
||||
key_links:
|
||||
- from: "framework tag vX.Y.Z"
|
||||
to: "https://summercms.io/changelog"
|
||||
via: "scripts/changelog-post.py source and publish in ../sm-summercmsio-app"
|
||||
pattern: "changelog-post.py"
|
||||
- from: "../sm-demo-app/plugins/demo/content/ROADMAP.md"
|
||||
to: "origin/master:.planning/ROADMAP.md"
|
||||
via: "scripts/sync-roadmap.sh with ROADMAP_REF=origin/master; the gate's landing stage checks it"
|
||||
pattern: "sync-roadmap"
|
||||
prohibitions:
|
||||
- statement: "No push or tag before the Task 1 reply; never a force-push; never a deploy."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Another session's code commit in origin/master..master stops the release: nothing is pushed until the user decides."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The changelog token is never printed, copied or minted; a missing or refused token stops and asks the user."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; plans 01-04 recorded the decision not to invent one): console commands reach the Artisan bar. This plan (gap closure 2 of 2, wave 2) publishes plan 05's review fixes to the applications' build path: a patch tag with its changelog post, under a go-ahead, then proves the whole phase again with the gate.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Spec-less probe fallback skipped: no REQUIREMENTS.md IDs and no SPEC.md (as in plans 01-05). The release follows D-26 (nothing pushed or tagged without the go-ahead) and the procedure of plan 03 Task 7 step (1) and plan 04 Tasks 5-6.
|
||||
|
||||
## Decisions taken in this plan
|
||||
|
||||
- **Same shape as v0.4.1.** The checkpoint and the release copy plan 04 Tasks 5-6: range check after a fetch, annotated tag, push of the exact tagged commit to master, then the tag, then the changelog post. The proposed tag is v0.4.2 unless another tag appeared after v0.4.1, in which case the proposal is the next patch after the newest tag.
|
||||
- **Plugins and applications.** Only rows of plan 05's Release impact table that say "needs require" or "regenerate" lead to commits there; plan 05 expects none. A consumer picks the fixes up with its next framework require bump.
|
||||
- **Demo roadmap snapshot.** The gate's landing stage compares sm-demo-app's `plugins/demo/content/ROADMAP.md` with origin/master's `.planning/ROADMAP.md`. The release push publishes this phase's and other sessions' roadmap changes, which turned the landing stage red after v0.4.1. Task 3 re-syncs it when the check fails, adding Polish names first for any new phase (TestRoadmapPolishTitles), so every sm-demo-app commit is green.
|
||||
|
||||
<objective>
|
||||
Release plan 05's fixes as v0.4.2 under the user's go-ahead, with the same-day changelog post, keep the demo roadmap snapshot in step with what was published, and finish with a green phase gate.
|
||||
|
||||
Purpose: D-26 (nothing published without the go-ahead) and the changelog rule in CLAUDE.md; the fixes only help applications once they are tagged.
|
||||
Output: on release, tag vX.Y.Z on origin and a changelog post; on push-only, master on origin; in every option, a green `--all` run in the SUMMARY.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-05-SUMMARY.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-04-SUMMARY.md
|
||||
|
||||
Release procedure to repeat: 19.8-03-PLAN.md Task 7 step (1) and 19.8-04-PLAN.md Tasks 5 and 6 (and the "Task 6: published" section of 19.8-04-SUMMARY.md). Changelog: ../sm-summercmsio-app/DEPLOY.md "Posting to the changelog" and the usage header of ../sm-summercmsio-app/scripts/changelog-post.py.
|
||||
|
||||
Concurrency: other sessions commit planning documents (and sometimes code) to summercms.go and may push. Stage by explicit path, never `git add -A`, never touch `.planning/STATE.md` or `.planning/state.json` by hand, no co-author tags. In sm-demo-app commit only the two roadmap paths and check `git status --porcelain` and `git log origin/master..master` there first. Docker tests need `TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1`; if the socket is denied, run through `sudo -u golem`.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 1: Go-ahead to publish the review fixes: release v0.4.2, push-only, or hold</name>
|
||||
<read_first>19.8-05-SUMMARY.md (fix commits, RED replay, verification, Release impact table), 19.8-04-SUMMARY.md ("Task 6: published": how v0.4.1 went out), 19.8-04-PLAN.md Task 5 (the checkpoint this one copies), CLAUDE.md (Commits and concurrency, the changelog rule)</read_first>
|
||||
<acceptance_criteria>
|
||||
- Before the checkpoint returns, a draft 19.8-06-SUMMARY.md holds a `## Release proposal` section: the output of `git fetch origin` and of `git log --format='%h %s' origin/master..master`, each commit classified as plan 05 code, planning documents (this phase or another session) or other code (with its paths from `git show --name-only --format= <hash>`); the newest tag (`git tag --sort=-v:refname`, first line) and the proposed tag; plan 05's Release impact table; the result of `ROADMAP_REF=origin/master ../sm-demo-app/scripts/sync-roadmap.sh --check`; and plan 05's verification status (Docker run or NOT RUN).
|
||||
- Before the reply, `T=$(git tag --points-at HEAD) && test -z "$T"` exits 0.
|
||||
- After the reply, the SUMMARY records it verbatim under `## Task 1 reply`, with the chosen tag on a release.
|
||||
</acceptance_criteria>
|
||||
<decision>Publish plan 05's fixes now, and with which patch tag?</decision>
|
||||
<context>Plan 05 fixed the eleven critical and warning findings of the code review, each in its own commit with a gated pinning test, and recorded a Release impact table. Releasing means an annotated patch tag (proposed v0.4.2) on the tested master commit, pushing master as exactly that commit and then the tag, and a same-day changelog post with the fixes under ## Fixed. Pushing master also publishes every unpushed planning-document commit of other sessions listed in the proposal; another session's code commit in the range blocks the release until you decide. Plugins and applications get commits only where the Release impact table says so (expected: none). After the push, sm-demo-app's roadmap snapshot is re-synced if it no longer matches origin/master (Task 3). Nothing is deployed in any option. If the proposal shows another session's code in the range, a red check or a Docker NOT RUN in plan 05, the recommended answer is hold.</context>
|
||||
<options>
|
||||
<option id="release">
|
||||
<name>release <tag> (for example "release v0.4.2")</name>
|
||||
<pros>Applications and plugins can pin the fixes; the changelog shows them the same day; the demo roadmap snapshot follows the published roadmap.</pros>
|
||||
<cons>Another tag and changelog post one day after v0.4.1; publishes other sessions' planning commits.</cons>
|
||||
</option>
|
||||
<option id="push-only">
|
||||
<name>push-only</name>
|
||||
<pros>The fixes are on origin/master for the next release; no tag or post today.</pros>
|
||||
<cons>Tagged consumers keep v0.4.1 and its defects until the next tag.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>hold</name>
|
||||
<pros>Nothing leaves this machine.</pros>
|
||||
<cons>The fixes stay local; verification sees them unpublished.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "release vX.Y.Z", "push-only" or "hold". "release" and "push-only" also authorise pushing sm-demo-app's Polish-name and roadmap-snapshot commits from Task 3 when they are its only unpushed commits. A release also authorises the plugin require bumps and application regenerate commits that plan 05's Release impact table names (expected none). Nothing is ever force-pushed or deployed.</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Under the Task 1 reply, publish: range check, checks, annotated tag on the tested commit, push master then the tag, same-day changelog post; plugin and application commits only where plan 05's table asks</name>
|
||||
<precondition>19.8-06-SUMMARY.md (draft) records the Task 1 reply under `## Task 1 reply`. On "hold" this task records "release held, nothing published" and stops.</precondition>
|
||||
<files>.planning/phases/19.8-console-commands-overhaul/19.8-06-SUMMARY.md</files>
|
||||
<read_first>19.8-06-SUMMARY.md draft (Release proposal, Task 1 reply), 19.8-05-SUMMARY.md (fix commits and Release impact table), 19.8-03-PLAN.md Task 7 step (1), 19.8-04-SUMMARY.md ("Task 6: published"), ../sm-summercmsio-app/DEPLOY.md ("Posting to the changelog"), ../sm-summercmsio-app/scripts/changelog-post.py (usage header only)</read_first>
|
||||
<action>Per D-26, under the recorded reply. Never force-push, never deploy, never print the changelog token.
|
||||
|
||||
On "release vX.Y.Z":
|
||||
(1) `git fetch origin`; list `git log --format='%h %s' origin/master..master` and classify each commit with `git show --name-only --format= <hash>`: a commit touching only `.planning/` is a planning document and may go out; a commit touching anything else must be one of plan 05's commits listed in its SUMMARY. Any other code commit stops the task: record it in the SUMMARY and ask the user (nothing pushed). Confirm the newest tag is still the one the proposal named; if another tag appeared since, stop and ask (the reply's tag may now be wrong).
|
||||
(2) At master HEAD with a clean tree for this phase's paths (`git status --porcelain`; other sessions' STATE.md and state.json edits are left alone): `go vet ./...`, `go test -count=1 -short ./...` and `go run ./cmd/summer docs:build --check` green. Record the tested commit's hash.
|
||||
(3) `git tag -a vX.Y.Z <tested hash>` with a message: first line `SummerCMS vX.Y.Z`, a blank line, one sentence ("Fixes from the review of the 0.4 console."), then one bullet per fix in user words (no CR/WR IDs, no planning or quick codes): lines printed during spinners and progress bars keep their order and none is lost; Ctrl+C during a spinner or progress bar restores the cursor; a command's output never lands in another command's live view; summer's delegated commands get the terminal (production question, styled output), keep their stderr apart from data and exit with the binary's exit code; summer dev lets serve finish its shutdown; a daily log file that failed to open is retried; replaced log files are closed; error blocks, process views and tables strip terminal control characters; the wrong-directory hint masks password, secret, token and key values; migrate reports what it actually applied (adjust to what plan 05 actually shipped).
|
||||
(4) `git push origin <tested hash>:refs/heads/master` (a fast-forward of exactly the tagged commit, so commits landing meanwhile are not published), then `git push origin vX.Y.Z`; `git ls-remote --exit-code --tags origin vX.Y.Z` exits 0.
|
||||
(5) Changelog, the same day, from ../sm-summercmsio-app: `scripts/changelog-post.py source vX.Y.Z`; write user-facing Markdown notes to a file in the session scratchpad: no H1, a `## Fixed` section with the bullets above (more readable sentences are fine; no planning IDs, no quick codes); then `scripts/changelog-post.py publish vX.Y.Z <notes file> --excerpt "<one line: console fixes for 0.4>"`. If the token file is missing or the token is refused, stop and ask the user. Record the post URL.
|
||||
(6) For each plan 05 Release impact row marked "needs require": in that plugin repository `go get git.golem15.com/golem15/summercms@vX.Y.Z`, `go mod tidy`, vet and short tests, one commit `chore: require SummerCMS vX.Y.Z`, push. For each row marked "regenerate": regenerate main.go, build, `bin/<app> list`, one commit `build: regenerate main for SummerCMS vX.Y.Z`, push. With no such rows record "plugins and applications: nothing to publish".
|
||||
|
||||
On "push-only": steps (1) and (2), then `git push origin <tested hash>:refs/heads/master`; no tag, no changelog post.
|
||||
|
||||
Record in the SUMMARY: the range classification after the fetch, the tested hash, the tag object, the pushed range, the changelog URL, and every plugin or application commit (or "none").</action>
|
||||
<verify>
|
||||
<automated>T="${RELEASE_TAG:?set RELEASE_TAG to the tag named in the Task 1 reply}" && git ls-remote --exit-code --tags origin "$T" && git fetch origin && git merge-base --is-ancestor "$T^{commit}" origin/master && test "$(git cat-file -t "$T")" = tag</automated>
|
||||
<fails_when>On release: the newest reachable tag is not on origin, the tagged commit is not on origin/master, or the tag is lightweight instead of annotated. On "push-only" or "hold" this command is not run; the SUMMARY records the pushed range or "nothing published".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- On release: `git ls-remote --exit-code --tags origin vX.Y.Z` exits 0, `git cat-file -t vX.Y.Z` prints `tag`, and the SUMMARY holds the changelog post URL (https://summercms.io/changelog/...).
|
||||
- The SUMMARY lists every published repository with its pushed range, or "nothing published" on hold.
|
||||
- The SUMMARY's range classification lists no "other code" commit, or records that the task stopped because of one.
|
||||
</acceptance_criteria>
|
||||
<done>The fixes are published exactly as the user chose, with a tag and changelog post on release, and nothing else of another session's code went out.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: The demo roadmap snapshot matches the published roadmap (Polish names for new phases first, then the snapshot, in sm-demo-app, only when the check fails), and the whole phase gate is green with Docker required</name>
|
||||
<files>../sm-demo-app/plugins/demo/roadmap/names.go, ../sm-demo-app/plugins/demo/content/ROADMAP.md, .planning/phases/19.8-console-commands-overhaul/19.8-06-SUMMARY.md</files>
|
||||
<read_first>../sm-demo-app/scripts/sync-roadmap.sh (header: ROADMAP_REF, --check), ../sm-demo-app/plugins/demo/roadmap/names.go (phaseText entries, the client-name denylist), ../sm-demo-app/plugins/demo/roadmap/*_test.go (TestRoadmapPolishTitles, TestRoadmapNoClientNames), `git -C ../sm-demo-app log --oneline -5` (the earlier "Polish name for roadmap phase" and "sync the ROADMAP.md snapshot" commits as the pattern), scripts/check-phase19.8.sh (run_landing), 19.8-04-SUMMARY.md ("Gate after the release": the red landing stage after v0.4.1)</read_first>
|
||||
<action>(1) Conditional roadmap sync. `git fetch origin` in summercms.go, then `ROADMAP_REF=origin/master ../sm-demo-app/scripts/sync-roadmap.sh --check`. If it prints ok, record "snapshot in step, sm-demo-app untouched" and go to (2). Otherwise, in sm-demo-app: check `git status --porcelain` and `git log --oneline origin/master..master` (another session's uncommitted roadmap edits or unpushed commits there: stop and report); run `ROADMAP_REF=origin/master scripts/sync-roadmap.sh` to write the snapshot, then `go test -count=1 -short -run '^(TestRoadmapParse|TestRoadmapDeckOrder|TestRoadmapNoClientNames|TestRoadmapPolishTitles)$' ./plugins/demo/roadmap`. If TestRoadmapPolishTitles names a phase without a Polish title (or an open phase without a Polish summary), add its `phaseText` entry to plugins/demo/roadmap/names.go in the style of the existing entries: a short Polish title and, for open phases, a one-line Polish summary of the roadmap goal, naming no client application (TestRoadmapNoClientNames enforces the denylist). Commit so that every commit is green: first names.go alone (`feat(demo): Polish name for roadmap phase <N>`, one commit naming all new phases if several), then the snapshot alone (`chore: sync the ROADMAP.md snapshot (summercms <short hash of origin/master>)`), staged by path. Re-run the four roadmap tests. Push sm-demo-app only when the Task 1 reply was "release" or "push-only" and `git log origin/master..master` there lists only these commits; on "hold" leave them local and say so. Never deploy.
|
||||
|
||||
(2) Gate. Run `PHASE198_REQUIRE_DOCKER=1 TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 bash scripts/check-phase19.8.sh --all` (through `sudo -u golem` if the Docker socket is denied) and paste its stage summary into the SUMMARY. A red stage is diagnosed and fixed through the owning repository's normal flow (a framework defect goes back to a plan 05 style fix commit and blocks completion; a sibling repository's unrelated pre-existing failure is recorded with evidence that it also fails without this plan's commits) and the gate re-run until green.
|
||||
|
||||
(3) Record in the SUMMARY a suggested g15office comment for the orchestrator (no g15office tool here): on release "Phase 19.8 review fixes released as SummerCMS vX.Y.Z (changelog: <URL>); phase gate green; ready for /msd-verify-work", otherwise the matching hold or push-only wording.</action>
|
||||
<verify>
|
||||
<automated>ROADMAP_REF=origin/master ../sm-demo-app/scripts/sync-roadmap.sh --check && LOG="${TMPDIR:?set TMPDIR to the session scratchpad}/check-phase19.8-06.log" && PHASE198_REQUIRE_DOCKER=1 TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 bash scripts/check-phase19.8.sh --all >"$LOG" 2>&1 && tail -20 "$LOG" && grep -q '^Phase 19.8 gate passed$' "$LOG" && ! grep -qE '^(FAIL|NOT RUN docker)' "$LOG"</automated>
|
||||
<fails_when>The demo snapshot differs from origin/master's ROADMAP.md, the gate exits non-zero or lacks "Phase 19.8 gate passed", a stage line starts with FAIL, or docker did not run (PHASE198_REQUIRE_DOCKER=1 turns a missing daemon into a failure).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The SUMMARY holds the --all stage summary with every stage PASS (TestTerminalCommands may be NOT RUN).
|
||||
- When sm-demo-app was changed: `git -C ../sm-demo-app log --format=%s -2` shows the Polish-name commit (when one was needed) before the snapshot commit, `git -C ../sm-demo-app show --name-only --format= HEAD` names only plugins/demo/content/ROADMAP.md, and on release or push-only `git -C ../sm-demo-app log --oneline origin/master..master` prints nothing.
|
||||
- When sm-demo-app was not changed, the SUMMARY says the snapshot was already in step.
|
||||
</acceptance_criteria>
|
||||
<done>The published roadmap and the demo deck agree, and one green gate run proves the whole phase after the release.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| this machine to git.golem15.com | pushes and tags publish framework code, other sessions' planning commits and sm-demo-app commits |
|
||||
| this machine to summercms.io | the changelog post, authenticated by a token file |
|
||||
| roadmap to the public demo deck | phase titles and summaries shown to prospective clients |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.8-34 | Repudiation | release publication | high | mitigate | blocking checkpoint before any push; after a fetch every commit in origin/master..master is classified by its paths, and any code commit not listed by plan 05 stops the release |
|
||||
| T-19.8-35 | Tampering | pushed master | medium | mitigate | push exactly the tested, tagged commit as a fast-forward (`<hash>:refs/heads/master`); never force-push; vet, short tests and the docs checker green at that commit |
|
||||
| T-19.8-36 | Information disclosure | changelog token | medium | mitigate | changelog-post.py reads the token file itself; the token is never printed, copied or minted; a missing or refused token stops and asks the user |
|
||||
| T-19.8-37 | Information disclosure | demo roadmap deck | medium | mitigate | new Polish names follow the existing entries and TestRoadmapNoClientNames rejects client application names before the snapshot commit |
|
||||
| T-19.8-SC | Tampering | dependencies | low | accept | no module is added; plugin requires move only to this framework's own tag, and only for rows of plan 05's table |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: proposal written before the checkpoint; no tag on HEAD before the reply; reply recorded verbatim.
|
||||
- Task 2: on release, tag annotated and on origin, its commit on origin/master, changelog URL recorded; on push-only, pushed range recorded; on hold, nothing published.
|
||||
- Task 3: demo snapshot equals origin/master's roadmap; --all green with Docker required.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- The user's choice is executed exactly: release (tag, push, changelog), push-only or hold; nothing deployed or force-pushed.
|
||||
- No other session's code went out with the release.
|
||||
- The demo roadmap snapshot and Polish names are current, in separate sm-demo-app commits, only when needed.
|
||||
- `bash scripts/check-phase19.8.sh --all` is green with Docker required.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.8-console-commands-overhaul/19.8-06-SUMMARY.md` when done, with the release proposal, the Task 1 reply verbatim, the range classification, the tested hash, the tag and tag object, the pushed ranges, the changelog post URL, plugin and application commits (or "none"), the sm-demo-app commits (or "snapshot already in step"), the --all stage summary, and the suggested g15office comment for the orchestrator.
|
||||
</output>
|
||||
@@ -0,0 +1,229 @@
|
||||
---
|
||||
phase: 19.8-console-commands-overhaul
|
||||
plan: 06
|
||||
subsystem: release
|
||||
tags: [release, v0.4.2, changelog, demo-roadmap, gate]
|
||||
status: complete
|
||||
gap_closure: true
|
||||
requires:
|
||||
- phase: 19.8-05
|
||||
provides: the eleven review fixes and the Release impact table
|
||||
provides:
|
||||
- "SummerCMS v0.4.2: annotated tag 60b13ae on ead8e42, on origin with master fast-forwarded to it"
|
||||
- "Changelog post https://summercms.io/changelog/summercms-0-4-2 (id 37)"
|
||||
- "sm-demo-app roadmap snapshot synced to origin/master ead8e42 (aa4de22, pushed)"
|
||||
- "Phase 19.8 --all gate green with Docker required"
|
||||
affects: [applications and plugins pinning SummerCMS (fixes available from v0.4.2), demo roadmap deck]
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: []
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- .planning/phases/19.8-console-commands-overhaul/19.8-06-SUMMARY.md
|
||||
- ../sm-demo-app/plugins/demo/content/ROADMAP.md
|
||||
key-decisions:
|
||||
- "Reply 'release' read as the proposed tag v0.4.2"
|
||||
- "Tagged the commit that records the go-ahead (ead8e42, planning-only on top of the proposal) after vet, short tests and the docs checker passed there"
|
||||
- "No plugin require bumps or application regenerate commits: no row of plan 05's Release impact table asks for one"
|
||||
- "No Polish name needed: the roadmap change adds no phase (19.6.1 already had one, 762bb90)"
|
||||
requirements-completed: [D-26]
|
||||
actuals:
|
||||
tokens: 4000 # chars/4 over the realized diff (this SUMMARY and the 6-line roadmap snapshot change)
|
||||
tasks: 3
|
||||
commits: 2 # MEASURED: git rev-list --count plan_head_before..HEAD before the final docs commit
|
||||
plan_head_before: 8020b177ffc70f1b980ae769b80f55598998ce77
|
||||
plan_head_after: ead8e422113d43c9ee828a92a628783634f65294
|
||||
duration: 35min
|
||||
completed: 2026-10-22
|
||||
---
|
||||
|
||||
# Phase 19.8 Plan 06: Release of the Review Fixes Summary
|
||||
|
||||
**SummerCMS v0.4.2 is tagged and on origin, with its changelog post at https://summercms.io/changelog/summercms-0-4-2. The tag is annotated, on ead8e42, and master was fast-forwarded to exactly that commit. The demo roadmap snapshot follows the published roadmap, and the phase 19.8 `--all` gate is green with Docker required. Nothing was deployed or force-pushed.**
|
||||
|
||||
## Release proposal
|
||||
|
||||
Prepared on 2026-10-22 at master `8020b17` (before this draft's own commit). All commands below are read-only towards origin, apart from `git fetch`, which only updates local refs.
|
||||
|
||||
### `git fetch origin`
|
||||
|
||||
Exit 0, no output: origin/master is still `6c2164a` (`docs(19.8-04): v0.4.1 released; roadmap progress 4/4 plans`). No other session has pushed since v0.4.1.
|
||||
|
||||
### `git log --format='%h %s' origin/master..master` (18 commits), classified
|
||||
|
||||
| Commit | Subject | Class | Paths |
|
||||
|--------|---------|-------|-------|
|
||||
| 8020b17 | docs(19.8-05): roadmap progress 5/6 plans | planning, this phase | .planning/ROADMAP.md |
|
||||
| 89c8325 | docs(19.8-05): complete review fixes plan | planning, this phase | .planning/phases/19.8-.../19.8-05-SUMMARY.md |
|
||||
| d311d02 | docs(19.8): review fixes recorded, info findings captured as a todo | planning, this phase | 19.8-REVIEW-DISPOSITION.md, .planning/todos/pending/2026-10-22-console-review-info-findings.md |
|
||||
| 1bf64b0 | test(19.8): gate requires the review fixes' tests | plan 05 code | scripts/check-phase19.8.sh |
|
||||
| 020fe83 | fix(lagoon): one migration set list for Migrate and PendingMigrations | plan 05 code (WR-07) | modules/lagoon/{README.md,commands.go,migrations.go,pending_test.go} |
|
||||
| a9025c0 | fix(bonfire): strip control characters from error blocks, process views and tables | plan 05 code (WR-05) | docs/console/writing-commands.md, modules/bonfire/{README.md,errors.go,errors_test.go,helpers.go,live.go,live_test.go,widgets.go,widgets_test.go}, modules/diary/console.go |
|
||||
| 39677f1 | fix(diary): retire replaced daily writers and serialise ApplyGlobals | plan 05 code (WR-06) | modules/diary/{daily.go,diary.go,retire_internal_test.go} |
|
||||
| c586c6f | fix(diary): retry a daily file that failed to open | plan 05 code (WR-04) | docs/services/logging.md, modules/diary/{README.md,daily.go,daily_test.go} |
|
||||
| 9b62285 | fix(bonfire): mask secret flag values in the wrong-directory hint | plan 05 code (WR-08) | docs/console/writing-commands.md, modules/bonfire/{README.md,errors.go,errors_test.go} |
|
||||
| fc7706d | fix(dev): give serve the full shutdown grace before killing it | plan 05 code (WR-03) | cmd/summer/runtime.go, docs/console/scaffolding.md, internal/dev/{watch.go,watch_test.go} |
|
||||
| 9c89705 | fix(summer): give delegated commands the terminal and keep their stderr apart | plan 05 code (CR-03) | cmd/summer/{runtime.go,runtime_test.go}, docs/console/introduction.md, docs/setup/upgrading.md |
|
||||
| 323413c | fix(summer): pass the app binary's exit code through and print its error once | plan 05 code (CR-02) | cmd/summer/{runtime.go,runtime_test.go}, docs/console/introduction.md, docs/setup/upgrading.md, modules/bonfire/{README.md,errors.go,errors_test.go,execute.go} |
|
||||
| d151a25 | fix(bonfire): restore the terminal when a live view is interrupted | plan 05 code (WR-01) | modules/bonfire/{README.md,live.go,live_signal_unix_test.go} |
|
||||
| 86e4e27 | fix(bonfire): reroute only the owning console's lines to a live view | plan 05 code (WR-02) | modules/bonfire/{README.md,helpers.go,live.go,live_test.go,output.go,widgets.go} |
|
||||
| b077dc3 | fix(bonfire): keep the order of lines printed above a live view and drain them before it ends | plan 05 code (CR-01) | modules/bonfire/{live.go,live_test.go,widgets.go} |
|
||||
| e8f5b1c | docs(19.8): gap-closure plans for the code review findings and v0.4.2 | planning, this phase | .planning/ROADMAP.md, 19.8-05-PLAN.md, 19.8-06-PLAN.md |
|
||||
| dd12382 | docs(19.8): code review of the console overhaul | planning, this phase | 19.8-REVIEW.md |
|
||||
| 0b1d7e5 | docs(19.8): record code review disposition | planning, this phase | 19.8-REVIEW-DISPOSITION.md |
|
||||
|
||||
**Result:** 12 plan 05 code commits (all twelve listed in 19.8-05-SUMMARY.md), 6 planning documents of this phase, **0 planning documents of other sessions, 0 other code**. This draft's own commit (`docs(19.8-06): ...`, planning only) will be the 19th. The range check is repeated after a fresh fetch in Task 2 step (1).
|
||||
|
||||
### Tags
|
||||
|
||||
- Newest tag (`git tag --sort=-v:refname | head -1`): **v0.4.1** (on `8bacc22`, an ancestor of origin/master).
|
||||
- **Proposed tag: v0.4.2.**
|
||||
- `T=$(git tag --points-at HEAD) && test -z "$T"`: exits 0 (no tag on HEAD).
|
||||
|
||||
### Release impact table (from 19.8-05-SUMMARY.md)
|
||||
|
||||
| Finding | Commit | Module | Exported API added | Require bump in plugins | Regenerate in apps |
|
||||
|---------|--------|--------|--------------------|-------------------------|--------------------|
|
||||
| CR-01 | b077dc3 | bonfire | none | no | no |
|
||||
| WR-02 | 86e4e27 | bonfire | none | no | no |
|
||||
| WR-01 | d151a25 | bonfire | none | no | no |
|
||||
| CR-02 | 323413c | bonfire, cmd/summer | bonfire.WithExitCode | no (no plugin uses it) | no |
|
||||
| CR-03 | 9c89705 | cmd/summer | none | no | no |
|
||||
| WR-03 | fc7706d | internal/dev, cmd/summer | none (dev.ShutdownGrace is internal) | no | no |
|
||||
| WR-08 | 9b62285 | bonfire | none | no | no |
|
||||
| WR-04 | c586c6f | diary | none | no | no |
|
||||
| WR-06 | 39677f1 | diary | none | no | no |
|
||||
| WR-05 | a9025c0 | bonfire, diary | bonfire.Sanitize | no (no plugin uses it) | no |
|
||||
| WR-07 | 020fe83 | lagoon | none | no | no |
|
||||
|
||||
No row asks for a require bump or a regenerate: **plugins and applications get no commits** under any reply.
|
||||
|
||||
### Demo roadmap snapshot
|
||||
|
||||
- `ROADMAP_REF=origin/master ../sm-demo-app/scripts/sync-roadmap.sh --check`: **`sync-roadmap: ok`** (exit 0). sm-demo-app is clean (`git status --porcelain` empty) and has no unpushed commits (`origin/master..master` empty).
|
||||
- Against local master the check fails, as expected: the unpushed `.planning/ROADMAP.md` changes are 19.8's own plan list and progress (`4/4` to `5/6`, plans 05 and 06 listed). They add no new phase, so no Polish name is needed. After a push (release or push-only), Task 3 makes one sm-demo-app commit, `chore: sync the ROADMAP.md snapshot (summercms <hash>)`, and pushes it only under release or push-only.
|
||||
|
||||
### Plan 05 verification status and checks at HEAD now
|
||||
|
||||
- Plan 05: Docker-backed tests **run** (direct socket): `TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 go test -count=1 ./...` PASS and the gate's docker stage PASS (TestMigrateAppliedLines included). Gate stages self-test, go, hello, docs PASS.
|
||||
- Re-run now at `8020b17`: `go vet ./...` **PASS**; `go run ./cmd/summer docs:build --check` **PASS** ("no problems found"); `go test -count=1 -short ./...` **PASS** with `GOCACHE` in the session scratchpad (tmpfs).
|
||||
- **Environment warning:** the first short run failed in `cmd/summer` (TestNewCommandViaCLI: `go mod tidy ... no space left on device`). `/home` is full (477G, 1.7G free; `~/.cache/go-build` is 33G) and `/media/nvme` is at 99% (19G free). The code is not at fault: the same suite is green with the build cache on tmpfs. Task 2 step (2) and the Task 3 `--all` gate (nuxt generate, Docker images, app builds) will run with `GOCACHE` on tmpfs; freeing space on `/home` (for example `go clean -cache`) is the user's call and was not done here.
|
||||
|
||||
### Recommendation
|
||||
|
||||
**release v0.4.2.** The range holds only plan 05's code and this phase's planning documents, every check is green (Docker included in plan 05), and the table needs no plugin or application commits.
|
||||
|
||||
## Task 1 reply
|
||||
|
||||
The user's reply, verbatim:
|
||||
|
||||
> release
|
||||
|
||||
Read as the recommended option with the proposed tag: **release v0.4.2** (RELEASE_TAG=v0.4.2). The reply authorises an annotated tag v0.4.2 on the tested commit, a fast-forward push of exactly that commit to origin master followed by the tag, the same-day changelog post, the sm-demo-app roadmap snapshot commit and its push (when it is the only unpushed commit there), then the `--all` gate. No force push, no deploy.
|
||||
|
||||
The reply was recorded in `ead8e42 docs(19.8-06): record the go-ahead: release v0.4.2` before anything was tagged or pushed. That commit is the one tagged.
|
||||
|
||||
## Task 2: published
|
||||
|
||||
### Range check after a fresh fetch
|
||||
|
||||
- `git fetch origin`: exit 0, no output. origin/master was still `6c2164a`, so no other session had pushed.
|
||||
- Newest tag: still **v0.4.1**, and origin had only v0.4.0 and v0.4.1 in `v0.4.*`. The proposed tag was still correct.
|
||||
- `git log --format='%h %s' origin/master..ead8e42`: **20 commits**. These are the 18 classified in the proposal, plus `cf06185` (the proposal, which touches only `19.8-06-SUMMARY.md`) and `ead8e42` (the reply, the same file). Class totals: 12 plan 05 code commits, 8 planning documents of this phase, **0 planning documents of other sessions, 0 other code**.
|
||||
- `git merge-base --is-ancestor origin/master ead8e42`: the push is a fast-forward.
|
||||
|
||||
### Checks at the tested commit `ead8e42`
|
||||
|
||||
GOCACHE pointed at the session scratchpad (tmpfs) for every go command. `go clean` was not run.
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| `go vet ./...` | PASS |
|
||||
| `go test -count=1 -short ./...` | PASS (exit 0, every package ok) |
|
||||
| `go run ./cmd/summer docs:build --check` | PASS ("docs:build: no problems found") |
|
||||
|
||||
### Tag
|
||||
|
||||
- `git tag -a v0.4.2 ead8e42`: tag object **`60b13aee5dca32b7202e35f92e605652f66b8a97`**, `git cat-file -t v0.4.2` = `tag`, `v0.4.2^{commit}` = `ead8e422113d43c9ee828a92a628783634f65294`.
|
||||
- Message: `SummerCMS v0.4.2`, then "Fixes from the review of the 0.4 console.", then a Fixed list of nine bullets in user words and an Added list (`bonfire.WithExitCode`, `bonfire.Sanitize`). It has no finding IDs and no planning or quick codes.
|
||||
|
||||
### Pushed ranges
|
||||
|
||||
| Repository | Push | Range |
|
||||
|------------|------|-------|
|
||||
| summercms (framework) | `git push origin ead8e42…:refs/heads/master` | `6c2164a..ead8e42` (fast-forward, 20 commits) |
|
||||
| summercms (framework) | `git push origin v0.4.2` | new tag v0.4.2. `git ls-remote --exit-code --tags origin v0.4.2` → `60b13ae… refs/tags/v0.4.2` |
|
||||
| sm-demo-app | `git push origin HEAD:refs/heads/master` | `762bb90..aa4de22` (1 commit, see Task 3) |
|
||||
|
||||
The Task 2 verify command (`ls-remote` the tag, fetch, the tagged commit is an ancestor of origin/master, the tag is annotated) passed.
|
||||
|
||||
### Changelog
|
||||
|
||||
- `scripts/changelog-post.py source v0.4.2` (from ../sm-summercmsio-app): TAG v0.4.2, DATE 2026-10-22T02:54:37+02:00, PREVIOUS v0.4.1, and 21 commit subjects.
|
||||
- The notes file is in the session scratchpad (`v0.4.2-notes.md`). It has a one-line intro, `## Fixed` (nine bullets) and `## Added` (the two new functions). It has no H1, no planning IDs and no quick codes.
|
||||
- `scripts/changelog-post.py publish v0.4.2 <notes> --excerpt "Console fixes for 0.4: ordered output under spinners, terminal restored on Ctrl+C, exit codes passed through by summer."` answered `posted id=37 https://summercms.io/changelog/summercms-0-4-2 (published_at 2026-10-22T00:54:37+00:00)`.
|
||||
- **Post URL: https://summercms.io/changelog/summercms-0-4-2** (HTTP 200 right after publishing). The script read the token file itself. The token was never printed, copied or minted.
|
||||
|
||||
### Plugins and applications
|
||||
|
||||
Plugins and applications: nothing to publish. No row of plan 05's Release impact table asks for a require bump or a regenerate. The `--all` gate's plugins and apps stages confirm that every shared plugin and application still builds against the local framework, and that `summer build` leaves the generated main.go and plugins.gen.go unchanged.
|
||||
|
||||
## Task 3: demo roadmap and the gate
|
||||
|
||||
### Roadmap snapshot
|
||||
|
||||
- After the push, `ROADMAP_REF=origin/master ../sm-demo-app/scripts/sync-roadmap.sh --check` failed as expected: the snapshot differed from origin/master. This is 19.8's own plan list and progress.
|
||||
- sm-demo-app before the change: `git status --porcelain` was empty and `origin/master..master` was empty after a fetch. Its HEAD was `762bb90 feat(demo): Polish name for roadmap phase 19.6.1`, already pushed by another session.
|
||||
- `ROADMAP_REF=origin/master scripts/sync-roadmap.sh` wrote the snapshot from origin/master (ead8e42): `plugins/demo/content/ROADMAP.md`, 4 insertions and 2 deletions.
|
||||
- TestRoadmapParse, TestRoadmapDeckOrder, TestRoadmapNoClientNames and TestRoadmapPolishTitles: PASS. No new phase, so **no Polish-name commit was needed**.
|
||||
- Commit **`aa4de22 chore: sync the ROADMAP.md snapshot (summercms ead8e42)`**. It touches only `plugins/demo/content/ROADMAP.md` and was the only unpushed commit. It was pushed `762bb90..aa4de22`, and `origin/master..master` is now empty. Nothing was deployed.
|
||||
|
||||
### `--all` gate
|
||||
|
||||
`PHASE198_REQUIRE_DOCKER=1 TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 bash scripts/check-phase19.8.sh --all` ran with GOCACHE and TMPDIR in the session scratchpad and Docker reached directly (no `sudo -u golem`). Exit 0. The landing pre-check `sync-roadmap --check` printed ok.
|
||||
|
||||
| Stage | Result | Notes |
|
||||
|-------|--------|-------|
|
||||
| self-test | PASS | |
|
||||
| go | PASS | vet, short and the named tests: diary 30, sunscreen 2, bonfire 42, docsite 2, conga 5, lagoon 7, surf 13, flare 1, dev 4, cmd/summer 6. Race stage over diary, sunscreen, bonfire and docsite |
|
||||
| hello | PASS | vet, 13 tests, then 5 named tests |
|
||||
| docs | PASS | TestDocsTree; docs:build reported no problems; 67 files contain no client name; all 35 summer commands have examples |
|
||||
| plugins | PASS | 15 shared plugins |
|
||||
| apps | PASS | 5 applications (sm-demo-app, sm-bm-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app, fonoteka.go): build, generated files unchanged, `list` and `help migrate` exit 0 |
|
||||
| landing | PASS | nuxt generate, tap 38/38, 4 terminal tests. **NOT RUN TestTerminalCommands** (allowed: needs network, Docker and ports; `SUMMERCMS_TERMINAL_CHECK=1`). 4 roadmap tests; "roadmap: snapshot matches origin/master" |
|
||||
| docker | PASS | TestServeLifecycle; 6 lagoon tests including TestMigrateAppliedLines; 3 cabana tests |
|
||||
|
||||
Final line: **`Phase 19.8 gate passed`**. No line starts with `FAIL` or `NOT RUN docker`. The full log is `check-phase19.8-06.log` in the session scratchpad.
|
||||
|
||||
### Suggested g15office comment (for the orchestrator)
|
||||
|
||||
> Phase 19.8 review fixes released as SummerCMS v0.4.2 (changelog: https://summercms.io/changelog/summercms-0-4-2); phase gate green; ready for /msd-verify-work
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
**1. [Rule 3 - Blocking] Build cache on tmpfs.** `/home` is at 99% (5.1G free) and `/media/nvme` is at 99%. Every go command, including the gate, ran with `GOCACHE` (and, for the gate, `TMPDIR`) in the session scratchpad on tmpfs. The user did not ask for space to be freed, so `go clean` was not run.
|
||||
|
||||
**2. Tagged commit.** The plan tags "master HEAD". HEAD at release time was `ead8e42`, the planning-only commit that records the reply. It was committed before the checks, so the checks ran on exactly the commit that was tagged and pushed.
|
||||
|
||||
Otherwise the plan was executed as written.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. The pushes, the tag, the post and the demo snapshot are the surfaces in the plan's threat register. T-19.8-34 to T-19.8-37 were mitigated as planned:
|
||||
- a fetch and range classification came before the push
|
||||
- the push was a fast-forward of exactly the tested, tagged commit
|
||||
- the token stayed in its file
|
||||
- TestRoadmapNoClientNames passed before the snapshot commit
|
||||
|
||||
## Final docs commit
|
||||
|
||||
This SUMMARY's completion commit is **local only**. The plan does not push it, and it is not part of v0.4.2.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
Found: cf06185, ead8e42 (local and origin/master), tag object 60b13ae (local and on origin as refs/tags/v0.4.2), sm-demo-app aa4de22 (on origin/master), changelog post (HTTP 200).
|
||||
224
.planning/phases/19.8-console-commands-overhaul/19.8-CONTEXT.md
Normal file
224
.planning/phases/19.8-console-commands-overhaul/19.8-CONTEXT.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# Phase 19.8: Console commands overhaul - Context
|
||||
|
||||
**Gathered:** 2026-10-21
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Console commands in `summer` and in application binaries reach the bar set by Laravel's Artisan. Three pieces: a new `diary` logging module, the shared console layer in `bonfire` (global flags, help, `list`, widgets, errors), and a sweep of every existing command's help and output across `summercms.go` and the shared plugin repos. Command logic, names and flags do not change. Planned shape stays at 4 plans: (1) `diary`, (2) `bonfire` flags/help/list/widgets, (3) sweep, (4) unit tests.
|
||||
|
||||
The phase was added as Phase 32 on 2026-10-20 and renumbered to 19.8 on 2026-10-21.
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Console stack and look
|
||||
- **D-01:** The console look follows SSU's terminal UX (`/media/nvme/dev/pxpx/ssu/internal/cli/tui`): spinner with counter and current item, multi-row live process view, progress model, arrow-key selector with confirmation screen, lipgloss status styles. The earlier Scala `summer-bonfire` design was itself modelled on SSU; the braille spinner and cyan-to-green gradient bar already in Go `bonfire` came from it. — **Reversibility:** costly — every command's output and the widget API depend on it
|
||||
- **D-02:** Adopt the charm stack in `bonfire`: `charmbracelet/lipgloss` (styling, tables), `charmbracelet/bubbles` + `bubbletea` (live views, spinner, progress), `charmbracelet/huh` (prompts), `charmbracelet/fang` (cobra styling and error rendering). A decision note naming these dependencies and versions lands before any code (project rule: new dependency needs a decision note). Port SSU's widgets rather than inventing new ones. The existing `bonfire.Output` interface stays source-compatible for plugins (no breaking changes in shared plugins). — **Reversibility:** costly — adds dependencies to every application binary; removing them later means rewriting the widgets
|
||||
- **D-03:** Every widget degrades on non-TTY, `-q`, `--no-ansi` and `-n`: no animation, plain lines, prompts return defaults (or fail with a hint when no default exists).
|
||||
|
||||
### What `serve` shows
|
||||
- **D-04:** At default verbosity `serve` prints a startup summary and then **all INFO logs**, restyled through `diary`'s console handler (consistent prefixes per component instead of today's raw `slog` text mixed with styled lines). `-q` reduces to warnings/errors; `-v`/`-vv`/`-vvv` add debug detail.
|
||||
- **D-05:** The output format follows the TTY: styled console output on a terminal; structured logs (JSON or logfmt per channel config) when stdout is not a TTY (systemd, Docker). The startup summary is also emitted as log records, so journald keeps it.
|
||||
- **D-06:** Startup summary is a compact aligned block of about 8–12 lines: app name and version (plus framework version), environment, URL(s), database and migration state ("up to date" / "3 pending"), plugins (count and names), queue and scheduler, mail, cache and realtime drivers. `-v` adds per-plugin details.
|
||||
- **D-07:** Shutdown (Ctrl+C / SIGTERM) prints a short report: draining, requests drained, workers stopped with job counts (done, in flight returned to the queue), uptime, and any drain errors. The same pattern applies to other long-running commands (`queue:work`, `dev`).
|
||||
|
||||
### Logging (`diary`)
|
||||
- **D-08:** `config/log.yaml` mirrors Laravel's `config/logging.php` shape: `log.default` names the default channel, `log.level` the global level, `log.channels` is a map of named channels with `driver` (`console`, `stderr`, `daily`, `stack`, `null`), `level`, `format`, `path`, `days`, and `channels` for a stack. `SUMMER_LOG_*` env overrides work through `compass` as for any key. This replaces the `log.level`/`log.channel` pair in the roadmap text with `log.level`/`log.default`. — **Reversibility:** costly — published config keys in every application's `config/log.yaml`
|
||||
```yaml
|
||||
# config/log.yaml
|
||||
default: stack
|
||||
level: info
|
||||
channels:
|
||||
stack:
|
||||
driver: stack
|
||||
channels: [console, daily]
|
||||
console:
|
||||
driver: console # styled on TTY, json/logfmt otherwise
|
||||
format: auto
|
||||
daily:
|
||||
driver: daily
|
||||
path: storage/logs/summer.log
|
||||
days: 14
|
||||
format: json
|
||||
level: warn
|
||||
components:
|
||||
river: warn
|
||||
surf: info
|
||||
```
|
||||
- **D-09:** Per-component levels through a `log.components` map (`river: warn`, `journal: debug`). Plugins and modules get a named logger from `diary` (for example `diary.For("journal")`) instead of `slog.Default`; verbosity flags raise every component.
|
||||
- **D-10:** Daily log files are **on everywhere** by default, like Laravel: `summer new` and the framework defaults use a `stack` of `console` + `daily` at `storage/logs/`, with retention (14 days default). Console output stays on as well.
|
||||
- **D-11:** The request log is INFO from `surf` under a named `http` component (method, path, status, duration, request id), so it shows by default and can be silenced with `components.http: warn`.
|
||||
- **D-12:** `sunscreen` redaction wraps every channel; `sunscreen.Install`/`InstallDefault` keep working as compatibility shims.
|
||||
|
||||
### Help and `list`
|
||||
- **D-13:** Bare `<binary>` / `summer` prints an Artisan-style list: version header (`SummerCMS acme v1.4.0 (framework v0.3.2)`), usage, global options, then commands grouped by namespace (top-level first, then `admin`, `make`, `migrate`…) with aligned descriptions. Styled with lipgloss in the same palette as the rest of the console.
|
||||
```
|
||||
SummerCMS acme v1.4.0 (framework v0.3.2)
|
||||
|
||||
Usage:
|
||||
bin/acme <command> [options] [arguments]
|
||||
|
||||
Options:
|
||||
-h, --help Display help for the given command
|
||||
-q, --quiet Do not output any message
|
||||
-v|vv|vvv Increase verbosity
|
||||
--ansi|no-ansi Force (or disable) ANSI output
|
||||
-n, --no-interaction Do not ask any interactive question
|
||||
--env The environment the command runs under
|
||||
|
||||
Available commands:
|
||||
list List commands
|
||||
migrate Run the database migrations
|
||||
serve Serve the application
|
||||
admin
|
||||
admin:create Create a backend administrator
|
||||
admin:token:create Create a personal API token
|
||||
make
|
||||
make:command Create a new console command
|
||||
make:model Create a new model and migration
|
||||
```
|
||||
- **D-14:** Per-command help uses the Artisan layout: Description / Usage / Arguments / Options (with defaults) / Examples. fang is used for styling and error rendering, not for its own help layout.
|
||||
```
|
||||
Description:
|
||||
Create a personal API token for a backend admin
|
||||
|
||||
Usage:
|
||||
admin:token:create <login> [options]
|
||||
|
||||
Arguments:
|
||||
login The admin's login
|
||||
|
||||
Options:
|
||||
--scopes=SCOPES Comma-separated scopes [default: "*"]
|
||||
--expires=DAYS Days until expiry [default: 90]
|
||||
--json Print the token as JSON
|
||||
|
||||
Examples:
|
||||
# Token for the MCP server, journal only
|
||||
bin/acme admin:token:create jane --scopes=journal.*
|
||||
|
||||
# Script-friendly output
|
||||
bin/acme admin:token:create jane --json
|
||||
```
|
||||
- **D-15:** New additive `bonfire.Command` fields: `Examples []Example` (command line + description, rendered as a section) and `Hidden bool` (runnable, left out of `list`; `list --all` shows it). Additive only, so existing plugins compile unchanged.
|
||||
- **D-16:** `list --format=json` outputs every command with arguments, options and examples, for docs checks and AI agents.
|
||||
- **D-17:** Documentation coverage check: `bonfire` exports a helper that reports commands lacking a description or examples. It **fails** for `summercms.go`'s own commands and **warns only** in plugin repos. Roadmap success criterion 3 is reworded to match.
|
||||
|
||||
### Errors
|
||||
- **D-18:** Errors render as a styled block: the cause, a short explanation, then a `Try:` hint with the exact command that fixes it, then "Run with -v for details". `-v` adds the wrapped error chain. Exit codes: 1 for failure, 2 for usage errors.
|
||||
```
|
||||
ERROR No application found in /home/jane/acme/plugins.
|
||||
|
||||
summer make:command needs an application root (summer.yaml).
|
||||
|
||||
Try:
|
||||
cd ..; bin/acme make:command acme.blog:publish
|
||||
|
||||
Run with -v for details.
|
||||
```
|
||||
- **D-19:** Hints for known failure modes are part of the sweep: run from the wrong directory (suggest `cd ..; bin/<app> <command>`), pending migrations (suggest `bin/<app> migrate`), missing config or env, database unreachable. Detecting the database when run from the wrong directory is **not** fixed; only the message improves.
|
||||
|
||||
### Sweep
|
||||
- **D-20:** The sweep covers `summercms.go` and every shared plugin repo with commands: `sm-paymentgateway-plugin`, `sm-oauthserver-plugin`, `sm-journal-plugin`, `sm-sitemap-plugin`, `sm-chat-plugin`, `sm-chatchannels-plugin`, `sm-chatvideo-plugin`, `sm-faq-plugin`, `sm-menu-plugin`, `sm-herald-plugin`, `sm-forum-plugin`, `sm-pgstripe-plugin`, and `sm-user-plugin`. Changes per plugin: description, long help, examples, named loggers, output polish. One commit per plugin repo, pushed first, then pointer bumps in the apps as separate commits. No breaking changes to command names or flags.
|
||||
- **D-21:** The Artisan checklist applies where it fits, not uniformly: `--force` confirmation on destructive commands, `--pretend` on migrate-like and bulk writers, `--json` on commands whose output scripts read (lists, tokens, status), progress for bulk loops. The plan records each command's checklist result so the gaps are explicit.
|
||||
- **D-22:** One-shot commands end with a result line and a meaningful exit code.
|
||||
|
||||
### Settled after research (2026-10-21)
|
||||
- **D-23:** Amends D-11. The `http` request log records the **route pattern** (e.g. `/share/{token}`), never the raw path, because share/invite tokens travel in paths. It is on by default at INFO (method, route pattern, status, duration, request id), and `components.http: warn` silences it. The `http.access_log` default flips accordingly.
|
||||
- **D-24:** The sweep also covers `sm-golem-plugin` and `sm-feedback-plugin`, in addition to the D-20 list.
|
||||
- **D-25:** `summer dev` starts the application binary with `serve` (today it runs the binary with no arguments, which prints help and exits). This is the one logic fix allowed in the sweep, together with the D-07 shutdown report for `dev`.
|
||||
- **D-26:** Release order: plan 3 reaches a human checkpoint before tagging and pushing the framework (plus the same-day changelog post). Plugin commits and pushes, then app pointer bumps, follow after the go-ahead. Nothing is pushed or tagged without it.
|
||||
- **D-27:** `-q` suppresses decoration and info, but still prints the data a command exists to produce (tokens, keys, `--json` output) and errors.
|
||||
- **D-28:** Production confirmation prompts appear only on an interactive TTY. Non-TTY runs (deploys over SSH) proceed as they do today and never block. Existing `--force` meanings (importers, `pg:update-currencies`: "replace existing data") are not changed; where `--force` is already taken, confirmation is skipped with `-n`.
|
||||
- **D-29:** A graceful Ctrl+C/SIGTERM shutdown of `serve`/`dev`/`queue:work` exits 0. A drain error exits 1.
|
||||
- **D-30:** SSU's multi-select selector is not ported in this phase (no command needs it). Port spinner, process view, progress, single selector and confirm.
|
||||
|
||||
### Claude's Discretion
|
||||
- Exact palette, symbols and spacing, as long as they match SSU's styles and stay consistent between `list`, help, widgets and log lines.
|
||||
- Console handler choice inside `diary` (lipgloss-styled handler of our own, or `charmbracelet/log`), to be settled in the dependency decision note.
|
||||
- Exact semantics of `--env` and `-n` defaults, following Artisan.
|
||||
|
||||
### Folded Todos
|
||||
- **Reject duplicate console command names in bonfire.NewRoot** (`.planning/todos/pending/bonfire-duplicate-command-names.md`): `NewRoot` fails with an error naming the duplicated command; update the bonfire README and `docs/console/writing-commands.md`. Fits plan 2.
|
||||
- **Scaffolded files carry a DO NOT EDIT header, and make:command cannot reach the application** (`.planning/todos/pending/scaffold-generated-header-and-command-deps.md`): folded **partially**. The scaffolder itself is not changed; the errors these commands throw get proper messages with actionable hints (D-18, D-19). The todo stays open for the scaffolder fix.
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### Phase scope
|
||||
- `.planning/ROADMAP.md` § "Phase 19.8: Console commands overhaul": goal, success criteria, planned shape
|
||||
|
||||
### Console look (reference implementation)
|
||||
- `/media/nvme/dev/pxpx/ssu/internal/cli/tui/`: SSU's widgets on the charm stack (`spinner.go`, `process.go`, `progress.go`, `selector.go`, `selector_keys.go`, `confirm.go`, `styles.go`, `tui.go`); the target look
|
||||
- `../.depreciated/modules/summer-bonfire/PLAN.md` § "Custom-Built Console Widgets" and "SSU Feature Mapping": the earlier Scala design mapping SSU's widgets, including non-TTY fallbacks
|
||||
|
||||
### Existing modules
|
||||
- `modules/bonfire/README.md`, `modules/bonfire/command.go`, `modules/bonfire/root.go`, `modules/bonfire/output.go`, `modules/bonfire/widgets.go`: current command API, cobra root, Output interface, spinner and gradient bar
|
||||
- `modules/sunscreen/sunscreen.go`, `modules/sunscreen/README.md`: redaction handler and `InstallDefault` (one hard-coded TextHandler at INFO)
|
||||
- `modules/compass/README.md`: config layering, `SUMMER_` env overrides, per-environment directories
|
||||
- `internal/build/build.go`: generated `main`, where `sunscreen` is installed and plugin commands are appended
|
||||
|
||||
### Todos
|
||||
- `.planning/todos/pending/bonfire-duplicate-command-names.md`
|
||||
- `.planning/todos/pending/scaffold-generated-header-and-command-deps.md`
|
||||
|
||||
### Project rules
|
||||
- `.planning/research/STACK.md`: decided dependency set; new charm dependencies need a decision note in `.planning/notes/`
|
||||
- `CLAUDE.md` § Documentation rules and § Sibling repositories: README/docs in the same commit; plugin commit first, then app pointer bump
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `bonfire.Output` (`output.go`): Info/Success/Error/Warning, Table, Spinner, Progress, Ask/Confirm/Choice/Secret; has TTY, interactive and color detection already. Keep the interface; re-implement behind it with the charm stack.
|
||||
- `bonfire.Command`/`Flag`/`Arg` (`command.go`): add `Examples` and `Hidden` additively; flag defaults are already declared and can render as `[default: …]`.
|
||||
- `widgets.go`: braille spinner frames and 256-color gradient (cyan to green), the Scala/SSU heritage to keep visually.
|
||||
- `sunscreen.Wrap(slog.Handler)`: wraps any handler, so every `diary` channel can be wrapped.
|
||||
|
||||
### Established Patterns
|
||||
- Commands are `namespace:verb` (plus bare `build`, `dev`, `new`, `serve`, `migrate`); grouping by namespace in `list` follows the name before the colon.
|
||||
- cobra root with `SilenceUsage`/`SilenceErrors`; help currently falls back to cobra's default layout.
|
||||
- Config is YAML sections per file through `compass` (`config/log.yaml` → `log.*`), with per-environment overrides.
|
||||
|
||||
### Integration Points
|
||||
- `internal/build` generated `main`: replaces the `sunscreen.InstallDefault` call with `diary` setup and builds the root through `bonfire`.
|
||||
- `surf`: request logging under the `http` component.
|
||||
- `conga`/River: River's logger is routed through a `diary` component logger (`river`).
|
||||
- `serve`, `dev`, `queue:work`: startup summary and shutdown report.
|
||||
- About 40 real commands: 13 framework files in `modules/`, `cmd/summer`, `internal/`, plus 12 plugin repos and `sm-user-plugin`.
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- "Magic animations" means SSU's terminal UX: the Scala-era bonfire plan was explicitly "inspired by SSU", so SSU's TUI package is the source to port.
|
||||
- The Artisan list and help layouts above (D-13, D-14) and the error block (D-18) are the approved previews.
|
||||
- Error hints must be useful in practice, e.g. `cd ..; bin/<appname> <command>` when a command runs from the wrong directory.
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
None; the discussion stayed within phase scope.
|
||||
|
||||
### Reviewed Todos (not folded)
|
||||
- The 20 other keyword matches (benchmark, cache purge, uploads XSS, plugin ports, OIDC parity, etc.) are unrelated to the console and stay in their own phases or todos.
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 19.8-console-commands-overhaul*
|
||||
*Context gathered: 2026-10-21*
|
||||
@@ -0,0 +1,162 @@
|
||||
# Phase 19.8: Console commands overhaul - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-10-21
|
||||
**Phase:** 19.8-console-commands-overhaul
|
||||
**Areas discussed:** Todos, What serve shows, Console stack, Logging config shape, Help and list look, Sweep depth and plugins
|
||||
|
||||
---
|
||||
|
||||
## Todos
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Duplicate command names | bonfire.NewRoot fails on duplicate names | ✓ |
|
||||
| make:command app access | Scaffolder header and app access for generated commands | partial |
|
||||
|
||||
**User's choice:** Duplicate names folded fully. For make:command: "errors like these that make:command throw need a proper messaging too. They may error, but they should suggest `cd ..; bin/<appname> <command>` and be useful. We don't need to fix database detection when run from wrong directory, but think about these when working on commands console outputs."
|
||||
|
||||
---
|
||||
|
||||
## What serve shows
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Summary + requests | Summary, then one line per request; plugin INFO at -v | |
|
||||
| Summary only | Summary, then warnings/errors only | |
|
||||
| Summary + all INFO | Summary, then every INFO log restyled | ✓ |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| TTY decides | Styled on terminal, structured logs otherwise | ✓ |
|
||||
| Config decides | `log.channel` alone picks the format | |
|
||||
| Env decides | app.env picks the format | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Compact block | ~8–12 aligned lines | ✓ |
|
||||
| One line | Single summary line | |
|
||||
| Full table | Every subsystem as a table | |
|
||||
|
||||
**Notes:** The user asked about "magic animations from the previous Scala project". The Scala research repo only had a static ASCII sun banner; the deprecated Scala `summer-bonfire` plan was "inspired by SSU's terminal UX". The user confirmed SSU (`/media/nvme/dev/pxpx/ssu`) is the source to reuse. Banner options were withdrawn.
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Short report | Drained requests, workers, uptime, errors | ✓ |
|
||||
| Single line | "stopped after …" | |
|
||||
| You decide | Planner picks | |
|
||||
|
||||
---
|
||||
|
||||
## Console stack
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Adopt charmbracelet | bubbletea/bubbles/lipgloss as in SSU | |
|
||||
| Port to stdlib | Hand-written ANSI widgets | |
|
||||
| lipgloss only | Styling only | |
|
||||
|
||||
**User's choice:** asked for Go alternatives. Presented: charm stack (bubbletea, bubbles, lipgloss, huh, fang, log), tint, pterm, mpb, go-pretty, termenv.
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Charm stack | lipgloss + bubbles/bubbletea + huh + fang | ✓ |
|
||||
| pterm | One batteries-included dependency | |
|
||||
| Researcher decides | Research compares first | |
|
||||
|
||||
---
|
||||
|
||||
## Logging config shape
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Laravel shape | `default`, `channels` map, drivers | ✓ |
|
||||
| Flat keys | `log.level`, `log.format`, `log.file` | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Yes, config map | `log.components` per-logger levels | ✓ |
|
||||
| Global level only | | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Off by default | Console only; daily channel commented out | |
|
||||
| On in production | Daily JSON in production | |
|
||||
| On everywhere | Always write storage/logs plus console | ✓ |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| INFO from surf | `http` component at INFO | ✓ |
|
||||
| Only at -v | DEBUG | |
|
||||
| Separate access channel | Own channel | |
|
||||
|
||||
---
|
||||
|
||||
## Help and list look
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Artisan list | Version header, options, namespace groups | ✓ |
|
||||
| fang default | fang root help, flat list | |
|
||||
| fang + namespace groups | fang look with cobra groups | |
|
||||
|
||||
**Notes:** The user asked for a preview of fang's default, which was missing from the first question; the question was re-asked with previews for all options.
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Examples field | `Command.Examples []Example`, checked | ✓ |
|
||||
| Inside Long | Text only | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Yes, Hidden field | Out of `list`, `list --all` shows | ✓ |
|
||||
| No | | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| list --format=json | Machine-readable list | ✓ |
|
||||
| Not needed | | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Artisan layout | Description/Usage/Arguments/Options/Examples | ✓ |
|
||||
| fang layout | Uppercase sections | |
|
||||
|
||||
---
|
||||
|
||||
## Sweep depth and plugins
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Framework + all plugins | summercms.go and every sm-*-plugin with commands | ✓ |
|
||||
| Framework + core only | user, journal, paymentgateway, oauthserver | |
|
||||
| Framework only | | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Where it applies | --force/--pretend/--json/progress where they fit | ✓ |
|
||||
| Uniform everywhere | | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Error + hint | Styled block with `Try:` fix command | ✓ |
|
||||
| fang default errors | Message only | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Fail in each repo | Coverage test fails everywhere | |
|
||||
| Warn only | Logs gaps, passes | ✓ |
|
||||
|
||||
**Notes:** Warn-only conflicted with roadmap criterion 3 (test fails). Resolved: fail for framework commands, warn in plugin repos; criterion 3 reworded.
|
||||
|
||||
## Claude's Discretion
|
||||
|
||||
- Exact palette, symbols, spacing (consistent with SSU's styles)
|
||||
- Console slog handler inside diary (own lipgloss handler or charmbracelet/log), settled in the decision note
|
||||
- Exact `--env` and `-n` semantics, following Artisan
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
None.
|
||||
238
.planning/phases/19.8-console-commands-overhaul/19.8-PATTERNS.md
Normal file
238
.planning/phases/19.8-console-commands-overhaul/19.8-PATTERNS.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# Phase 19.8: Console commands overhaul - Pattern Map
|
||||
|
||||
**Mapped:** 2026-10-21
|
||||
**Files analyzed:** 22 (new + modified, plus the plugin sweep as one class)
|
||||
**Analogs found:** 20 / 22
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|---|---|---|---|---|
|
||||
| `modules/diary/diary.go` (Setup, For, SetVerbosity, Install compat) | service/provider | transform (slog pipeline) | `modules/sunscreen/sunscreen.go` | role-match |
|
||||
| `modules/diary/config.go` (log.* parsing) | config | transform | `modules/conga/client.go` `settingsFromApp` (34-60), `modules/cooler/cooler.go` `maxEntries` (297-307) | exact |
|
||||
| `modules/diary/component.go`, `channels.go` | middleware (slog.Handler) | transform | `modules/sunscreen/sunscreen.go` `Wrap`/`handler` (82-125) | exact |
|
||||
| `modules/diary/console.go` | middleware (slog.Handler) | streaming to TTY | sunscreen handler + SSU `tui/styles.go` | partial |
|
||||
| `modules/diary/daily.go` | utility | file-I/O | none (stdlib, see RESEARCH Pattern 3) | none |
|
||||
| `modules/diary/*_test.go` | test | - | `modules/sunscreen/sunscreen_test.go`, `example_test.go` | exact |
|
||||
| `modules/diary/README.md` + root README row | docs | - | `modules/sunscreen/README.md` | exact |
|
||||
| `modules/bonfire/command.go` (+Examples, Hidden) | model | - | itself (lines 15-56) | exact |
|
||||
| `modules/bonfire/root.go` (RootOption, persistent flags, duplicates) | controller | request-response | itself (13-100) | exact |
|
||||
| `modules/bonfire/globals.go`, `help.go`, `list.go`, `errors.go` | utility | transform | `root.go` + `call.go` (`Catalog`, `findCommand`) | role-match |
|
||||
| `modules/bonfire/styles.go` | config | - | SSU `internal/cli/tui/styles.go` + `bonfire/output.go` 11-25 | exact |
|
||||
| `modules/bonfire/output.go`, `widgets.go` (charm TTY path) | component | streaming | itself (output.go 27-80, widgets.go 27-66) | exact |
|
||||
| `modules/bonfire/live.go` (spinner/process/progress) | component | event-driven (tea) | SSU `tui/spinner.go`, `process.go`, `progress.go` | exact (port v1->v2) |
|
||||
| `modules/bonfire/prompts.go` (huh + fallback; selector/confirm) | component | request-response | itself + SSU `tui/selector.go`, `confirm.go` | exact |
|
||||
| `modules/bonfire/testdata/*.golden` + tests | test | - | `internal/build/new_golden_test.go` `assertGolden` (76-96) | exact |
|
||||
| `internal/build/build.go` `generateMain` + goldens | config/codegen | transform | itself (lines ~94-151) | exact |
|
||||
| `modules/surf/accesslog.go` (component `http`, route pattern) | middleware | request-response | itself (61-108) | exact |
|
||||
| `modules/surf/serve.go` (startup summary, shutdown report, exit 0) | controller | long-running | itself (28-~100) | exact |
|
||||
| `modules/conga/client.go` / `commands.go` / `worker.go` (River logger, counters, report) | service | event-driven | `client.go` 102-123, `commands.go` 27-60 | exact |
|
||||
| `modules/lagoon` `Pending(gdb, plugins)` | service | CRUD (read) | `lagoon.Status` | role-match |
|
||||
| `internal/dev/watch.go` `startBin` (+`serve`) | utility | process | itself (201-210) | exact |
|
||||
| `cmd/summer/docs.go`, `internal/docsite/check_commands.go` | utility | batch | itself (`docsCommands` 144-165; `commandWord` 108) | exact |
|
||||
| Plugin sweep (`../sm-*-plugin/console/*.go`) | controller | request-response | `../sm-sitemap-plugin/console/import.go` | exact |
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `modules/diary/*` (new module)
|
||||
|
||||
**Analog:** `modules/sunscreen/sunscreen.go` (198 lines, leaf module, slog handler, README in standard structure).
|
||||
|
||||
Handler wrap pattern (lines 82-100) to copy for the component filter / proxy handler; diary channels must call `sunscreen.Wrap` on every leaf (Wrap is idempotent):
|
||||
```go
|
||||
func Wrap(next slog.Handler) slog.Handler {
|
||||
if h, ok := next.(*handler); ok {
|
||||
return h
|
||||
}
|
||||
return &handler{next: next}
|
||||
}
|
||||
|
||||
func InstallDefault(w io.Writer) {
|
||||
slog.SetDefault(slog.New(Wrap(slog.NewTextHandler(w, nil))))
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
next slog.Handler
|
||||
}
|
||||
```
|
||||
Implement all four methods (`Enabled`, `Handle`, `WithAttrs`, `WithGroup`, lines 102-125) and keep `InstallDefault` working as a shim (D-12).
|
||||
|
||||
**Config reading** (copy `modules/conga/client.go` 34-60): defaults first, nil-safe on `app.Config`, `Has`/`Lookup` for maps, tolerant parse of strings vs ints (RESEARCH Pitfall 4, env strings like `"14"`, `"console,daily"`):
|
||||
```go
|
||||
if app == nil || app.Config == nil {
|
||||
return s
|
||||
}
|
||||
c := app.Config
|
||||
if raw := strings.TrimSpace(c.String("queue.job_timeout")); raw != "" {
|
||||
if d, err := time.ParseDuration(raw); err == nil && d > 0 {
|
||||
s.jobTimeout = d
|
||||
} else if n := c.Int("queue.job_timeout"); n > 0 { ... }
|
||||
}
|
||||
if raw, ok := c.Lookup("queue.queues"); ok {
|
||||
if m, ok := raw.(map[string]any); ok { ... }
|
||||
}
|
||||
```
|
||||
Errors name the key, as in `modules/cooler/cooler.go` 297-307:
|
||||
`fmt.Errorf("cooler: %s must be a whole number of at least 1", CacheMaxEntriesKey)` -> `diary: log.channels.daily.days must ...`.
|
||||
Compass accessors available: `String`, `Int`, `Bool`, `Has`, `Lookup`, `LoadSection` (`modules/compass/config.go` 113-165).
|
||||
|
||||
**README:** copy the structure of `modules/sunscreen/README.md` (H1, one sentence, import line, `**Name:**`, Overview, Role in the framework, Features, Usage, API reference, Configuration, Dependencies, Testing) and add a row in root `README.md`. Every identifier must exist (docs checker).
|
||||
|
||||
### `modules/bonfire/command.go` (additive fields)
|
||||
|
||||
Current struct (lines 15-32): `Name, Description, Long, Passthrough, Flags []Flag, Args []Arg, Run`. Add `Examples []Example` and `Hidden bool` at the end only; `Example{Command, Description string}` as a new type next to `Flag`/`Arg` (35-56). No change to `Input`.
|
||||
|
||||
### `modules/bonfire/root.go`
|
||||
|
||||
Analog: itself. Keep `NewRoot(name, commands, out)` and `NewRootIO` signatures; add variadic `opts ...RootOption` only. Current root setup (lines 29-47):
|
||||
```go
|
||||
root := &cobra.Command{
|
||||
Use: name,
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true},
|
||||
}
|
||||
for _, command := range commands {
|
||||
cmd, err := wrap(command, output)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root.AddCommand(cmd)
|
||||
}
|
||||
```
|
||||
Duplicate-name check goes in this loop (todo `bonfire-duplicate-command-names.md`; RESEARCH Pitfall 15). `wrap` (50-100) maps `Hidden` -> `cmd.Hidden`; flag registration pattern (`StringP`/`NoOptDefVal = "true"` for `Bare`) is where Pitfall 1 (global shorthand collisions with `-v`/`-q`/`-n`) must be checked.
|
||||
|
||||
`list`/`help` resolve commands through `Catalog`/`findCommand` (`modules/bonfire/call.go` 44-72); reuse it for `list --format=json` and `CheckDocs`.
|
||||
|
||||
### `modules/bonfire/output.go`, `widgets.go`, `styles.go`, `live.go`
|
||||
|
||||
Interface stays (output.go 27-43); add optional interfaces + helpers (RESEARCH anti-pattern: no new methods on `Output`). TTY policy lives in `console` (50-80: `tty`, `interactive`, `color`, injectable `lookup`) and is the switch for D-03 degradation.
|
||||
|
||||
Existing non-TTY fallback to preserve (widgets.go 27-34):
|
||||
```go
|
||||
func (c *console) Spinner(message string, fn func() error) error {
|
||||
if fn == nil { fn = func() error { return nil } }
|
||||
if !c.tty {
|
||||
c.Printf("[...] %s\n", message)
|
||||
return fn()
|
||||
}
|
||||
```
|
||||
**Port source (outside repo, not git-tracked here, read-only):** `/media/nvme/dev/pxpx/ssu/internal/cli/tui/{spinner,process,progress,selector,selector_keys,confirm,styles,tui}.go`. SSU is on bubbletea/bubbles/lipgloss **v1**; translate to v2 import paths and API per RESEARCH "Porting SSU's spinner" (line ~487). Model shape to keep (spinner.go):
|
||||
```go
|
||||
type SpinnerModel struct {
|
||||
spinner spinner.Model
|
||||
label string
|
||||
total, done, failed int
|
||||
current string
|
||||
err error
|
||||
complete bool
|
||||
}
|
||||
func NewSpinnerModel() SpinnerModel {
|
||||
s := spinner.New()
|
||||
s.Spinner = spinner.MiniDot
|
||||
s.Style = StatusCurrentStyle
|
||||
...
|
||||
}
|
||||
func (m SpinnerModel) Init() tea.Cmd { return m.spinner.Tick }
|
||||
```
|
||||
Palette (styles.go): `Color("2")` green success, `"6"` cyan current, `"3"` yellow warn, `"1"` red / bold red error, `"240"` muted; `HeaderStyle = Bold`. Drop the `git.SubmoduleStatus` coupling (`StyleForStatus`). Skip the multi-select selector (D-30).
|
||||
|
||||
### `modules/bonfire/testdata/*.golden` and golden tests
|
||||
|
||||
Copy `assertGolden` + `-update` flag from `internal/build/new_golden_test.go` (line 13 and 76-96):
|
||||
```go
|
||||
var update = flag.Bool("update", false, "rewrite golden files")
|
||||
|
||||
func assertGolden(t *testing.T, dir, rel string, got []byte) {
|
||||
t.Helper()
|
||||
path := filepath.Join("testdata", dir, goldenName(rel))
|
||||
if *update { os.MkdirAll(...); os.WriteFile(path, got, 0o644) }
|
||||
want, err := os.ReadFile(path)
|
||||
if err != nil { t.Fatalf("%s: %v (run go test -run %s -update to create it)", rel, err, t.Name()) }
|
||||
if !bytes.Equal(got, want) { t.Errorf(... firstDiff(want, got)) }
|
||||
}
|
||||
```
|
||||
Render goldens with color off (inject `lookup`/`tty` via `newConsole`).
|
||||
|
||||
### `internal/build/build.go` `generateMain`
|
||||
|
||||
Analog: itself. Strings.Builder template, `formatSource` at end. Current body to change:
|
||||
```go
|
||||
b.WriteString("func main() {\n")
|
||||
b.WriteString("\tif err := run(os.Args[1:], os.Stdout); err != nil {\n")
|
||||
b.WriteString("\t\tfmt.Fprintln(os.Stderr, err)\n")
|
||||
b.WriteString("\t\tos.Exit(1)\n")
|
||||
...
|
||||
b.WriteString("\tsunscreen.InstallDefault(os.Stderr)\n")
|
||||
b.WriteString("\tcfg, err := compass.Load(\"config\")\n")
|
||||
...
|
||||
b.WriteString("\tcommands := lagoon.RuntimeCommands(app, plugins)\n")
|
||||
b.WriteString("\tcommands = append(commands, conga.RuntimeCommands(app, plugins)...)\n")
|
||||
...
|
||||
fmt.Fprintf(&b, "\troot, err := bonfire.NewRoot(%s, commands, out)\n", strconv.Quote(m.Binary))
|
||||
b.WriteString("\troot.SetArgs(args)\n")
|
||||
b.WriteString("\treturn root.Execute()\n")
|
||||
```
|
||||
Target shape: RESEARCH "Generated main after this phase" (line ~461): `os.Exit(run(...))`, `bonfire.PeekGlobals` + `diary.Setup` before `compass.Open`, `bonfire.Execute` returning exit code. Keep every `commands = append(commands, pkg.Ctor(app...` line verbatim (docsite parses them). Update `internal/build/testdata/new/` and `new-no-plugin/` goldens with `go test ./internal/build -run Golden -update`; add `config/log.yaml` to `summer new` output.
|
||||
|
||||
### `modules/surf/accesslog.go`
|
||||
|
||||
Analog: itself (61-108). Already logs `pattern`, never the path (D-23). Change `accessLogger` to `diary.For("http")` with fallback to the app-published logger (keep this lookup order):
|
||||
```go
|
||||
func accessLogger(app *backpack.App) *slog.Logger {
|
||||
if app != nil {
|
||||
if log, ok := app.Lookup[*slog.Logger](); ok && log != nil {
|
||||
return log
|
||||
}
|
||||
}
|
||||
return slog.Default()
|
||||
}
|
||||
```
|
||||
Add request id attr to the `attrs` slice (74-79); add atomic served/in-flight counters here for the shutdown report.
|
||||
|
||||
### `modules/surf/serve.go`
|
||||
|
||||
Analog: itself (28-~100). Sequence: flag read, `signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)`, `lagoon.OpenFromApp`, `lagoon.Publish`, `publishUploads`, `installCooler`, `BuildRouter`, `r.compile()`. Insert summary after bind (values collected once, rendered as lipgloss block on TTY and as INFO records component `serve`), report after drain; graceful exit returns nil (D-29). Mirror for `conga` `queue:work`/`schedule:run` in `modules/conga/commands.go` 27-60 (`withDB(ctx, app, func() error { return work(...) })`).
|
||||
|
||||
### `modules/conga/client.go` (River logger)
|
||||
|
||||
Lines 102-123: `baseConfig(s, log)` sets `Logger: log`; `loggerFromApp` is the same lookup as surf. Replace the source with `diary.For("river")` (default component level `warn`, D-09). Counters go in `Manager.runAttempt` (`worker.go:243`).
|
||||
|
||||
### `internal/dev/watch.go` `startBin`
|
||||
|
||||
Line 201-202: `cmd := exec.Command(binPath)` -> `exec.Command(binPath, "serve")` (D-25). Test via the injectable `opts.Start` (line 49).
|
||||
|
||||
### `cmd/summer/docs.go` + `internal/docsite/check_commands.go`
|
||||
|
||||
`docsCommands()` (144-165) builds the app command list from module constructors with `backpack.New(&compass.Config{})`; add `list`, `help` and wire `bonfire.CheckDocs` (fail for framework commands, D-17). `commandWord` (check_commands.go:108) must learn bool globals (`-v`, `-q`, `-n`, `--no-ansi`) per Pitfall 2. Errors reported via `reportDocsProblems` (168).
|
||||
|
||||
### Plugin sweep (`../sm-*-plugin`)
|
||||
|
||||
**Analog:** `../sm-sitemap-plugin/console/import.go` (lines 27-45). Constructor `func X(app *backpack.App) bonfire.Command`, Args/Flags literal, `Bare: true` for booleans, `Run` reads `in.Argument`/`in.Flag`, DB via local `lookupDB`. Sweep adds `Examples: []bonfire.Example{...}`, a result line on success, `--json` / `--force` / `--pretend` where D-21 says, without changing existing `--force` meanings (D-28). Commit in each plugin repo first, then app pointer bumps.
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Logger lookup (apply to surf, conga, cabana, lagoon GORM bridge)
|
||||
Source: `modules/surf/accesslog.go` 101-108 / `modules/conga/client.go` 116-123. Replace with `diary.For("<component>")`; keep app-published logger override for tests.
|
||||
|
||||
### Command definition
|
||||
Source: `modules/conga/commands.go` 27-60, `modules/surf/serve.go` 28-41. Name `namespace:verb` (validated by `validCommandName`), Description sentence-case without period, `Bare: true` flags parsed with `strconv.ParseBool`.
|
||||
|
||||
### Errors
|
||||
No hint type exists yet; known-failure sites listed in RESEARCH line ~526 (e.g. `modules/lagoon/connection.go:25`). Wrap with new `bonfire.Hint`; drop the print-then-sentinel pattern in `modules/flare` (Pitfall 13).
|
||||
|
||||
### Piped stdin prompts
|
||||
`modules/cabana/commands_test.go` 241-262 (WR-13) is the regression test that `Secret` reads a plain line on non-TTY; huh must only engage on interactive TTY.
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|---|---|---|---|
|
||||
| `modules/diary/daily.go` | utility | file-I/O | No rotating writer in repo; use RESEARCH Pattern 3 (stdlib, injected clock) |
|
||||
| `modules/bonfire/errors.go` (Hint/UsageError/Execute) | utility | transform | No styled error renderer exists; use RESEARCH "Error with hint" |
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `modules/{bonfire,sunscreen,surf,conga,cooler,compass}`, `internal/{build,dev,docsite}`, `cmd/summer`, `../sm-sitemap-plugin/console`, `/media/nvme/dev/pxpx/ssu/internal/cli/tui` (external port source, not part of this repo).
|
||||
**Tracked-source check:** analogs are tracked repo files; no `.msd/` mirror paths used.
|
||||
**Pattern extraction date:** 2026-10-21
|
||||
794
.planning/phases/19.8-console-commands-overhaul/19.8-RESEARCH.md
Normal file
794
.planning/phases/19.8-console-commands-overhaul/19.8-RESEARCH.md
Normal file
@@ -0,0 +1,794 @@
|
||||
# Phase 19.8: Console commands overhaul - Research
|
||||
|
||||
**Researched:** 2026-10-21
|
||||
**Domain:** Go CLI UX (cobra + charm v2 stack), log/slog architecture, cross-repo command sweep
|
||||
**Confidence:** HIGH for stack, APIs and inventory (read from source and probed); MEDIUM for UX semantics the CONTEXT leaves open (flagged in Open Questions)
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
|
||||
#### Console stack and look
|
||||
- **D-01:** The console look follows SSU's terminal UX (`/media/nvme/dev/pxpx/ssu/internal/cli/tui`): spinner with counter and current item, multi-row live process view, progress model, arrow-key selector with confirmation screen, lipgloss status styles. The earlier Scala `summer-bonfire` design was itself modelled on SSU; the braille spinner and cyan-to-green gradient bar already in Go `bonfire` came from it. — **Reversibility:** costly — every command's output and the widget API depend on it
|
||||
- **D-02:** Adopt the charm stack in `bonfire`: `charmbracelet/lipgloss` (styling, tables), `charmbracelet/bubbles` + `bubbletea` (live views, spinner, progress), `charmbracelet/huh` (prompts), `charmbracelet/fang` (cobra styling and error rendering). A decision note naming these dependencies and versions lands before any code (project rule: new dependency needs a decision note). Port SSU's widgets rather than inventing new ones. The existing `bonfire.Output` interface stays source-compatible for plugins (no breaking changes in shared plugins). — **Reversibility:** costly — adds dependencies to every application binary; removing them later means rewriting the widgets
|
||||
- **D-03:** Every widget degrades on non-TTY, `-q`, `--no-ansi` and `-n`: no animation, plain lines, prompts return defaults (or fail with a hint when no default exists).
|
||||
|
||||
#### What `serve` shows
|
||||
- **D-04:** At default verbosity `serve` prints a startup summary and then **all INFO logs**, restyled through `diary`'s console handler (consistent prefixes per component instead of today's raw `slog` text mixed with styled lines). `-q` reduces to warnings/errors; `-v`/`-vv`/`-vvv` add debug detail.
|
||||
- **D-05:** The output format follows the TTY: styled console output on a terminal; structured logs (JSON or logfmt per channel config) when stdout is not a TTY (systemd, Docker). The startup summary is also emitted as log records, so journald keeps it.
|
||||
- **D-06:** Startup summary is a compact aligned block of about 8–12 lines: app name and version (plus framework version), environment, URL(s), database and migration state ("up to date" / "3 pending"), plugins (count and names), queue and scheduler, mail, cache and realtime drivers. `-v` adds per-plugin details.
|
||||
- **D-07:** Shutdown (Ctrl+C / SIGTERM) prints a short report: draining, requests drained, workers stopped with job counts (done, in flight returned to the queue), uptime, and any drain errors. The same pattern applies to other long-running commands (`queue:work`, `dev`).
|
||||
|
||||
#### Logging (`diary`)
|
||||
- **D-08:** `config/log.yaml` mirrors Laravel's `config/logging.php` shape: `log.default` names the default channel, `log.level` the global level, `log.channels` is a map of named channels with `driver` (`console`, `stderr`, `daily`, `stack`, `null`), `level`, `format`, `path`, `days`, and `channels` for a stack. `SUMMER_LOG_*` env overrides work through `compass` as for any key. This replaces the `log.level`/`log.channel` pair in the roadmap text with `log.level`/`log.default`. — **Reversibility:** costly — published config keys in every application's `config/log.yaml`
|
||||
```yaml
|
||||
# config/log.yaml
|
||||
default: stack
|
||||
level: info
|
||||
channels:
|
||||
stack:
|
||||
driver: stack
|
||||
channels: [console, daily]
|
||||
console:
|
||||
driver: console # styled on TTY, json/logfmt otherwise
|
||||
format: auto
|
||||
daily:
|
||||
driver: daily
|
||||
path: storage/logs/summer.log
|
||||
days: 14
|
||||
format: json
|
||||
level: warn
|
||||
components:
|
||||
river: warn
|
||||
surf: info
|
||||
```
|
||||
- **D-09:** Per-component levels through a `log.components` map (`river: warn`, `journal: debug`). Plugins and modules get a named logger from `diary` (for example `diary.For("journal")`) instead of `slog.Default`; verbosity flags raise every component.
|
||||
- **D-10:** Daily log files are **on everywhere** by default, like Laravel: `summer new` and the framework defaults use a `stack` of `console` + `daily` at `storage/logs/`, with retention (14 days default). Console output stays on as well.
|
||||
- **D-11:** The request log is INFO from `surf` under a named `http` component (method, path, status, duration, request id), so it shows by default and can be silenced with `components.http: warn`.
|
||||
- **D-12:** `sunscreen` redaction wraps every channel; `sunscreen.Install`/`InstallDefault` keep working as compatibility shims.
|
||||
|
||||
#### Help and `list`
|
||||
- **D-13:** Bare `<binary>` / `summer` prints an Artisan-style list: version header (`SummerCMS acme v1.4.0 (framework v0.3.2)`), usage, global options, then commands grouped by namespace (top-level first, then `admin`, `make`, `migrate`…) with aligned descriptions. Styled with lipgloss in the same palette as the rest of the console.
|
||||
```
|
||||
SummerCMS acme v1.4.0 (framework v0.3.2)
|
||||
|
||||
Usage:
|
||||
bin/acme <command> [options] [arguments]
|
||||
|
||||
Options:
|
||||
-h, --help Display help for the given command
|
||||
-q, --quiet Do not output any message
|
||||
-v|vv|vvv Increase verbosity
|
||||
--ansi|no-ansi Force (or disable) ANSI output
|
||||
-n, --no-interaction Do not ask any interactive question
|
||||
--env The environment the command runs under
|
||||
|
||||
Available commands:
|
||||
list List commands
|
||||
migrate Run the database migrations
|
||||
serve Serve the application
|
||||
admin
|
||||
admin:create Create a backend administrator
|
||||
admin:token:create Create a personal API token
|
||||
make
|
||||
make:command Create a new console command
|
||||
make:model Create a new model and migration
|
||||
```
|
||||
- **D-14:** Per-command help uses the Artisan layout: Description / Usage / Arguments / Options (with defaults) / Examples. fang is used for styling and error rendering, not for its own help layout.
|
||||
```
|
||||
Description:
|
||||
Create a personal API token for a backend admin
|
||||
|
||||
Usage:
|
||||
admin:token:create <login> [options]
|
||||
|
||||
Arguments:
|
||||
login The admin's login
|
||||
|
||||
Options:
|
||||
--scopes=SCOPES Comma-separated scopes [default: "*"]
|
||||
--expires=DAYS Days until expiry [default: 90]
|
||||
--json Print the token as JSON
|
||||
|
||||
Examples:
|
||||
# Token for the MCP server, journal only
|
||||
bin/acme admin:token:create jane --scopes=journal.*
|
||||
|
||||
# Script-friendly output
|
||||
bin/acme admin:token:create jane --json
|
||||
```
|
||||
- **D-15:** New additive `bonfire.Command` fields: `Examples []Example` (command line + description, rendered as a section) and `Hidden bool` (runnable, left out of `list`; `list --all` shows it). Additive only, so existing plugins compile unchanged.
|
||||
- **D-16:** `list --format=json` outputs every command with arguments, options and examples, for docs checks and AI agents.
|
||||
- **D-17:** Documentation coverage check: `bonfire` exports a helper that reports commands lacking a description or examples. It **fails** for `summercms.go`'s own commands and **warns only** in plugin repos. Roadmap success criterion 3 is reworded to match.
|
||||
|
||||
#### Errors
|
||||
- **D-18:** Errors render as a styled block: the cause, a short explanation, then a `Try:` hint with the exact command that fixes it, then "Run with -v for details". `-v` adds the wrapped error chain. Exit codes: 1 for failure, 2 for usage errors.
|
||||
```
|
||||
ERROR No application found in /home/jane/acme/plugins.
|
||||
|
||||
summer make:command needs an application root (summer.yaml).
|
||||
|
||||
Try:
|
||||
cd ..; bin/acme make:command acme.blog:publish
|
||||
|
||||
Run with -v for details.
|
||||
```
|
||||
- **D-19:** Hints for known failure modes are part of the sweep: run from the wrong directory (suggest `cd ..; bin/<app> <command>`), pending migrations (suggest `bin/<app> migrate`), missing config or env, database unreachable. Detecting the database when run from the wrong directory is **not** fixed; only the message improves.
|
||||
|
||||
#### Sweep
|
||||
- **D-20:** The sweep covers `summercms.go` and every shared plugin repo with commands: `sm-paymentgateway-plugin`, `sm-oauthserver-plugin`, `sm-journal-plugin`, `sm-sitemap-plugin`, `sm-chat-plugin`, `sm-chatchannels-plugin`, `sm-chatvideo-plugin`, `sm-faq-plugin`, `sm-menu-plugin`, `sm-herald-plugin`, `sm-forum-plugin`, `sm-pgstripe-plugin`, and `sm-user-plugin`. Changes per plugin: description, long help, examples, named loggers, output polish. One commit per plugin repo, pushed first, then pointer bumps in the apps as separate commits. No breaking changes to command names or flags.
|
||||
- **D-21:** The Artisan checklist applies where it fits, not uniformly: `--force` confirmation on destructive commands, `--pretend` on migrate-like and bulk writers, `--json` on commands whose output scripts read (lists, tokens, status), progress for bulk loops. The plan records each command's checklist result so the gaps are explicit.
|
||||
- **D-22:** One-shot commands end with a result line and a meaningful exit code.
|
||||
|
||||
### Claude's Discretion
|
||||
- Exact palette, symbols and spacing, as long as they match SSU's styles and stay consistent between `list`, help, widgets and log lines.
|
||||
- Console handler choice inside `diary` (lipgloss-styled handler of our own, or `charmbracelet/log`), to be settled in the dependency decision note.
|
||||
- Exact semantics of `--env` and `-n` defaults, following Artisan.
|
||||
|
||||
### Folded Todos
|
||||
- **Reject duplicate console command names in bonfire.NewRoot** (`.planning/todos/pending/bonfire-duplicate-command-names.md`): `NewRoot` fails with an error naming the duplicated command; update the bonfire README and `docs/console/writing-commands.md`. Fits plan 2.
|
||||
- **Scaffolded files carry a DO NOT EDIT header, and make:command cannot reach the application** (`.planning/todos/pending/scaffold-generated-header-and-command-deps.md`): folded **partially**. The scaffolder itself is not changed; the errors these commands throw get proper messages with actionable hints (D-18, D-19). The todo stays open for the scaffolder fix.
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
None; the discussion stayed within phase scope.
|
||||
|
||||
#### Reviewed Todos (not folded)
|
||||
- The 20 other keyword matches (benchmark, cache purge, uploads XSS, plugin ports, OIDC parity, etc.) are unrelated to the console and stay in their own phases or todos.
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
No requirement IDs are mapped (null). The sources are the ROADMAP success criteria (SC1..SC5) and D-01..D-22. Related completed requirement: CLI-01 (rich output with non-TTY degradation, summer-bonfire design), which this phase must not regress.
|
||||
|
||||
| Source | Description | Research support |
|
||||
|--------|-------------|------------------|
|
||||
| SC1 | `diary` module: `log.default/level/channels/components`, stderr, stack, daily+retention+JSON, styled console handler, component loggers, sunscreen on every channel, daily files on by default; charm stack with decision note first | Standard Stack, Pattern 1-3, Code Examples (diary), Pitfalls 4-8 |
|
||||
| SC2 | Global flags `-v/-vv/-vvv`, `-q`, `--ansi/--no-ansi`, `-n`, `--env`; verbosity sets diary level at runtime (River, plugins) | Pattern 4 (globals pre-scan + persistent flags), probe results, Pitfalls 1-3 |
|
||||
| SC3 | Description + long help + examples + documented args/options with defaults; namespaced `list`; `help <command>`; coverage check fails in framework, warns in plugins | Pattern 5 (help/list), Command Inventory, Validation Architecture |
|
||||
| SC4 | Sweep with Artisan checklist; serve/queue:work startup summary + shutdown report; result lines and exit codes | Command Inventory with per-command checklist, Pattern 6 (summary/shutdown), Pitfalls 9-14 |
|
||||
| SC5 | READMEs, docs console+logging pages, landing site, roadmap presentation; unit tests in last plan | Docs Locations, Validation Architecture |
|
||||
</phase_requirements>
|
||||
|
||||
## Project Constraints (from CLAUDE.md)
|
||||
|
||||
- Standard library first; a new dependency needs a research doc or a decision note naming it (`.planning/notes/`). The decision note for the charm stack lands before code (D-02). [VERIFIED: CLAUDE.md]
|
||||
- YAML via `goccy/go-yaml`, never `gopkg.in/yaml.v3`. (fang/huh pull `gopkg.in/yaml.v3` only as an indirect test dependency of testify; nothing in this repo may import it.) [VERIFIED: CLAUDE.md; proxy go.mod of fang v2.0.1]
|
||||
- `go vet ./...` and `go test ./...` green at every commit; Docker tests skip under `-short` with `flag.Parse()` + `testing.Short()` in TestMain. [VERIFIED: CLAUDE.md]
|
||||
- New module: beach name, README in the standard structure (H1, summary sentence, import line, `**Name:**`, Overview, Features, Usage, API reference, Configuration, CLI commands, Dependencies, Testing), and a row in the root `README.md` table reusing the summary sentence. [VERIFIED: CLAUDE.md]
|
||||
- Exported API, config key, CLI command or dependency change updates the module README and the affected `docs/` pages in the same commit; every identifier in README/docs must exist (docs checker); config keys are checked by hand. [VERIFIED: CLAUDE.md]
|
||||
- Framework code and docs never name a consuming application (use `acme`, `blog`). Gate scripts in this repo split app names (`"fono""teka.go"`) to dodge the checker. [VERIFIED: CLAUDE.md; scripts/check-phase19.4.sh:62]
|
||||
- Core plugins (user, blog, pages, payment...) must not get breaking changes unless asked. [VERIFIED: ~/.claude/CLAUDE.md, ../CLAUDE.md]
|
||||
- Plugins: commit and push the plugin first, then the pointer bump in the app as a separate commit. No push/tag/deploy without the user's go-ahead. No co-author tags. Planning docs and code in separate commits. Stage files by path; never `git add -A`; several sessions run concurrently. [VERIFIED: CLAUDE.md]
|
||||
- MSD overrides: lean plans (plan count confirmed with the user: 4); unit tests are the last plan; STATE.md via `msd_run query state.*`; g15office comment when the phase advances. [VERIFIED: CLAUDE.md]
|
||||
- Release tags get a summercms.io changelog post the same day (only if this phase is tagged). [VERIFIED: CLAUDE.md]
|
||||
|
||||
## Summary
|
||||
|
||||
The console layer today is a thin cobra adapter (`modules/bonfire`) with hand-written ANSI widgets, and logging is a single `slog.NewTextHandler(os.Stderr)` wrapped by `sunscreen` and installed by the generated `main` before config loads. About 60 call sites across framework modules log through `slog.Default()` or a per-module `loggerFromApp(app)` helper that falls back to `slog.Default()`; River gets that logger through `river.Config.Logger`; GORM uses its own default logger that writes straight to stdout. That is exactly why `serve` output looks mixed today.
|
||||
|
||||
The charm stack must be the **v2 line on `charm.land/...` import paths**, not the v1 line SSU pins (`github.com/charmbracelet/lipgloss v1.0.0`, `bubbletea v1.3.0`, `bubbles v0.20.0`). fang v1.0.0 already depends on `charm.land/lipgloss/v2` (beta), so mixing SSU's v1 versions with any fang would compile two lipgloss majors into every binary. All five v2 modules (lipgloss v2.0.6, bubbletea v2.1.0, bubbles v2.2.1, huh v2.0.3, fang v2.0.1) declare `go` ≤ 1.26 and work with this repo's `go 1.27.0`. Porting SSU's widgets means translating v1 APIs: `View() string` becomes `View() tea.View`, `tea.KeyMsg` becomes `tea.KeyPressMsg`, `progress.WithDefaultGradient/WithGradient` become `WithDefaultBlend/WithColors`, and `bar.Width = n` becomes `SetWidth(n)`. lipgloss v2 `Render()` always emits ANSI, so `--no-ansi`/`NO_COLOR` must be applied at the writer through `colorprofile.Writer`. A probe showed the full stack adds about 2.1 MB to a stripped binary.
|
||||
|
||||
fang is the weakest fit. `fang.Execute` always calls `root.SetHelpFunc`, sets `root.Version` (unless `WithoutVersion()`), adds a hidden `man` command (unless `WithoutManpage()`), and reads the color profile from the environment only. Its exported value for us is `ErrorHandler`/`WithErrorHandler` and the `Styles`/`ColorScheme` types. Errors that occur before the root exists (config load in the generated `main`) never pass through it. Recommendation: keep fang (D-02 locks it) but use it narrowly: `fang.Execute(ctx, root, fang.WithoutVersion(), fang.WithoutManpage(), fang.WithoutCompletions(), fang.WithErrorHandler(bonfire.renderError))`, set the Artisan help func on **every subcommand** (cobra resolves a command's own `helpFunc` before its parent's), and render the root's help/list through root `RunE` plus our own `help` command. The bonfire error renderer also has to be callable directly from `main`. For `diary`, use stdlib `slog.NewMultiHandler` (stack, Go 1.26+), `slog.JSONHandler`/`TextHandler` (json/logfmt), `slog.DiscardHandler` (null), a stdlib daily-rotating writer, and **our own lipgloss console handler**. `charm.land/log/v2` fails `testing/slogtest` on group semantics (probe output below), so it is the wrong choice for a handler that sits under `sunscreen` and every module.
|
||||
|
||||
**Primary recommendation:** Plan 1 writes the decision note (charm v2 set + "no charm log, no lumberjack"), then builds `diary` (proxy-backed `diary.For`, component filter, channels, daily writer) and rewires the generated `main`. Plan 2 adds globals pre-scan + persistent flags, Artisan help/list/JSON, error renderer with hints, duplicate rejection and charm-backed widgets behind the unchanged `Output` interface. Plan 3 sweeps 52 framework commands (18 summer tool, 15 delegates, 19 app binary) plus the 2 in-repo examples, and 26 plugin commands with a recorded checklist, startup summary and shutdown report, and the docs/landing/roadmap updates. Plan 4 adds goldens, slogtest, TTY/non-TTY matrix and the phase gate script.
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| Global flag parsing (-v/-q/-n/--env/--ansi) | `bonfire` (cobra persistent flags + `PeekGlobals` pre-scan) | generated `main` (applies env before `compass`) | Flags must be known before config load and plugin boot, which happen before cobra parses |
|
||||
| Log routing, levels, channels, rotation | `diary` (new framework module) | `sunscreen` (redaction leaf wrapper) | Single owner of slog default; modules ask it for named loggers |
|
||||
| Console styling palette | `bonfire` (exported styles) | `diary` console handler imports them | One palette for list/help/widgets/log lines (D-01 discretion) |
|
||||
| Help, list, JSON catalog, coverage check | `bonfire` | `cmd/summer` docs checker consumes JSON/coverage | Commands are bonfire values; bonfire owns rendering |
|
||||
| Error rendering + hints + exit codes | `bonfire` (`Hint` error type, renderer, `Execute`) | modules wrap known failures (`lagoon`, `internal/build`) | Hints are domain knowledge; rendering is console concern |
|
||||
| Startup summary / shutdown report | `surf` (serve), `conga` (queue:work, schedule:run), `cmd/summer` (dev) | `lagoon` (pending-migration count), `conga` (job counters) | Each long-running command knows what it booted |
|
||||
| Request log | `surf` (`http` component) | `diary` | Already in `surf/accesslog.go`; only the logger and defaults change |
|
||||
| Third-party loggers (River, GORM) | `conga`, `lagoon` | `diary.For("river")`, `diary.For("db")` | Owners of the client configs |
|
||||
| Plugin command help/loggers | each `sm-*-plugin` repo | apps (pointer bumps) | D-20 one commit per plugin repo |
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core (new direct dependencies, all need the decision note)
|
||||
|
||||
| Library (import path) | Version | Purpose | Why standard |
|
||||
|---|---|---|---|
|
||||
| `charm.land/lipgloss/v2` | v2.0.6 (2026-08-11) | Styles, `lipgloss/v2/table` for boxed tables, layout for list/help/errors | De facto Go terminal styling; v2 is the only line fang/huh v2 build on [VERIFIED: proxy.golang.org @latest + go.mod] |
|
||||
| `charm.land/bubbletea/v2` | v2.1.0 (2026-10-08) | Program runtime for live spinner/process/progress views | SSU's runtime; v2 API (`View() tea.View`, `KeyPressMsg`) [VERIFIED: proxy + UPGRADE_GUIDE_V2.md] |
|
||||
| `charm.land/bubbles/v2` | v2.2.1 (2026-08-24) | `spinner` (MiniDot), `progress` (gradient bar), `key`/`help` | SSU's components [VERIFIED: proxy + module source] |
|
||||
| `charm.land/huh/v2` | v2.0.3 (2026-03-10) | Ask/Confirm/Choice/Secret on an interactive TTY; `WithAccessible` | Charm's prompt library; `Form.WithInput/WithOutput/WithAccessible`, `ErrUserAborted` exist [VERIFIED: module source form.go:54-61, 235-351] |
|
||||
| `charm.land/fang/v2` | v2.0.1 (2026-03-11) | Error rendering hook (`WithErrorHandler`) and Windows VT enabling around cobra | Locked by D-02; use narrowly (see Pattern 5) [VERIFIED: module source fang.go:21-170] |
|
||||
| `github.com/charmbracelet/colorprofile` | v0.4.3 (transitive, imported directly) | `colorprofile.Writer` to apply `--ansi/--no-ansi/NO_COLOR` to lipgloss v2 output | lipgloss v2 `Render()` always emits ANSI; downsampling is the writer's job [CITED: lipgloss v2 UPGRADE_GUIDE_V2.md "Printing and Color Downsampling"] |
|
||||
|
||||
Module go directives: lipgloss `go 1.25.0`, bubbletea `go 1.26.0`, bubbles `go 1.25.0`, huh `go 1.25.8`, fang `go 1.25.0`; repo `go 1.27.0`; toolchain here `go1.27.2`. [VERIFIED: proxy .mod files; go.mod:3; `go version`]
|
||||
MVS side effects: fang requires `spf13/cobra v1.9.1`/`pflag v1.0.6`, repo keeps `cobra v1.10.2`/`pflag v1.0.9`; `golang.org/x/text` stays at repo's v0.42.0. [VERIFIED: proxy go.mod + repo go.mod]
|
||||
|
||||
### Supporting (stdlib, no new dependency)
|
||||
|
||||
| Package | Purpose | When |
|
||||
|---|---|---|
|
||||
| `log/slog` `NewMultiHandler` | `stack` driver fan-out; calls only enabled handlers, clones the record, joins errors | Go 1.26+ [VERIFIED: $GOROOT/src/log/slog/multi_handler.go:35-45] |
|
||||
| `log/slog` `JSONHandler`, `TextHandler`, `DiscardHandler`, `LevelVar` | json / logfmt / null / runtime levels | [VERIFIED: `go doc log/slog.DiscardHandler`] |
|
||||
| `testing/slogtest` | Conformance test for the console and daily handlers | Plan 4 |
|
||||
| `runtime/debug.ReadBuildInfo` | App and framework versions for the list header | See Pattern 5 [VERIFIED: probe below] |
|
||||
| `gorm.io/gorm/logger.NewSlogLogger` | Route GORM's logger through `diary.For("db")` | Already in gorm v1.31.2 [VERIFIED: gorm logger/slog.go:24-32] |
|
||||
|
||||
### Alternatives Considered
|
||||
|
||||
| Instead of | Could use | Tradeoff / verdict |
|
||||
|---|---|---|
|
||||
| charm v2 (`charm.land/*/v2`) | SSU's v1 pins (`github.com/charmbracelet/*` v1) | Rejected: fang v1.0.0 requires `charm.land/lipgloss/v2 v2.0.0-beta.3...` → two lipgloss majors; v1 is maintenance-only. Port SSU code with the v2 API renames. [VERIFIED: proxy go.mod of fang v1.0.0] |
|
||||
| Own lipgloss console handler | `charm.land/log/v2` v2.0.1 | Rejected: fails `testing/slogtest` (WithGroup treated as a prefix, empty attrs printed, empty-key groups not inlined). It also brings its own level and prefix model that duplicates diary's. [VERIFIED: probe output in Code Examples] |
|
||||
| Stdlib daily writer (~120 lines) | `gopkg.in/natefinch/lumberjack.v2` (SSU uses it) | Rejected: rotates by **size**, not by day; no `name-YYYY-MM-DD.log` naming; would need a decision note anyway. [VERIFIED: SSU internal/logging/logging.go uses `MaxSizeMB`/`MaxBackups`] |
|
||||
| `slog.NewMultiHandler` | hand-written fan-out (SSU `MultiHandler`) | Use stdlib; SSU's version does not clone records. |
|
||||
| huh for prompts on TTY | keep line prompts only | huh gives the arrow-key selector D-01 asks for; keep the line-based path for non-TTY (existing tests and piped-stdin contracts). |
|
||||
|
||||
**Installation (plan 1, after the decision note):**
|
||||
```bash
|
||||
go get charm.land/lipgloss/v2@v2.0.6 charm.land/bubbletea/v2@v2.1.0 charm.land/bubbles/v2@v2.2.1 charm.land/huh/v2@v2.0.3 charm.land/fang/v2@v2.0.1 github.com/charmbracelet/colorprofile@v0.4.3
|
||||
go mod tidy
|
||||
```
|
||||
Only `modules/bonfire` (and `modules/diary` for lipgloss/colorprofile) may import them.
|
||||
|
||||
## Package Legitimacy Audit
|
||||
|
||||
The MSD seam only supports npm/pypi/crates (`package-legitimacy check --ecosystem go` is rejected with a usage error), so Go modules were checked by hand against proxy.golang.org and the `charm.land` vanity server.
|
||||
|
||||
| Module | Registry | Age (first tag of repo) | Source repo (go-import meta / proxy Origin) | Verdict | Disposition |
|
||||
|---|---|---|---|---|---|
|
||||
| charm.land/lipgloss/v2 v2.0.6 | proxy.golang.org | 2021-04-02 (v0.1.0) | `charm.land/lipgloss git https://github.com/charmbracelet/lipgloss`, hash 733ce535 | OK | Approved |
|
||||
| charm.land/bubbletea/v2 v2.1.0 | proxy.golang.org | 2020-04-20 | github.com/charmbracelet/bubbletea, hash f9df43c4 | OK | Approved |
|
||||
| charm.land/bubbles/v2 v2.2.1 | proxy.golang.org | 2020-06-08 | github.com/charmbracelet/bubbles, hash 49094810 | OK | Approved |
|
||||
| charm.land/huh/v2 v2.0.3 | proxy.golang.org | 2023-12-08 | github.com/charmbracelet/huh, hash 3c0116c6 | OK | Approved |
|
||||
| charm.land/fang/v2 v2.0.1 | proxy.golang.org | 2025-06-18 | github.com/charmbracelet/fang, hash d89b30af | OK (young, 2 v2 tags) | Approved; note in decision note |
|
||||
| github.com/charmbracelet/colorprofile v0.4.3 | proxy.golang.org | transitive of all above | github.com/charmbracelet/colorprofile | OK | Approved |
|
||||
| charm.land/log/v2 | — | — | — | not recommended (slogtest failures) | Not added |
|
||||
| gopkg.in/natefinch/lumberjack.v2 | — | — | — | not needed | Not added |
|
||||
|
||||
All names come from the projects' own go.mod/README files in the module cache and the `charm.land` go-import meta tags. They were not taken from web search. [VERIFIED: curl proxy.golang.org `@latest`, `.mod`, `@v/list`; curl `https://charm.land/<m>/v2?go-get=1`]
|
||||
**Packages removed due to [SLOP]:** none. **Flagged [SUS]:** none. There are no postinstall hooks (Go modules have none).
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### System Architecture Diagram
|
||||
|
||||
```
|
||||
argv ──► generated main ──► bonfire.PeekGlobals(args) ──► {env, verbosity, quiet, ansi, noInteraction}
|
||||
│ │
|
||||
│ ├─► compass.Open({Dir:"config", Env: env})
|
||||
│ └─► diary.Setup(cfg, {Verbosity, Quiet, Color, Stdout/Stderr})
|
||||
│ │ slog.SetDefault(root logger)
|
||||
▼ ▼
|
||||
backpack.New(cfg) ─► party.Activate ─► plugins log via diary.For("<plugin>") ─┐
|
||||
│ │
|
||||
▼ ▼
|
||||
commands = lagoon/conga/surf/cabana + plugins diary root handler (proxy, swappable)
|
||||
│ │ component filter (log.components + verbosity floor)
|
||||
bonfire.NewRoot(name, commands, out, opts...) │ sunscreen.Wrap per channel
|
||||
│ rejects duplicate names ▼
|
||||
│ persistent flags (-v -q -n --env --ansi) slog.NewMultiHandler(stack)
|
||||
│ help func on every subcommand ├─ console: TTY → lipgloss lines (stderr)
|
||||
│ list / help commands │ non-TTY → logfmt|json
|
||||
▼ ├─ daily: storage/logs/summer-YYYY-MM-DD.log (json), retention
|
||||
bonfire.Execute(ctx, root) ─► fang.Execute(... WithErrorHandler(render))
|
||||
│ │ └─ null / stderr
|
||||
│ └─ error → styled block (cause, explanation, Try:, -v chain) → exit 1|2
|
||||
▼
|
||||
Command.Run(ctx, Input, Output)
|
||||
│ Output: console impl; TTY & !quiet & !noAnsi & !noInteraction → bubbletea views (spinner/process/progress)
|
||||
│ otherwise → plain lines (existing fallbacks)
|
||||
│ prompts: interactive → huh; piped stdin → line read; -n → default or hinted error
|
||||
▼
|
||||
serve / queue:work / dev: startup summary (rendered block + log records) … signal … shutdown report
|
||||
```
|
||||
|
||||
### Recommended Project Structure
|
||||
|
||||
```
|
||||
modules/diary/ # NEW (beach name: a beach diary/logbook; README "**Name:**" line)
|
||||
├── diary.go # Setup, For, SetVerbosity, Root(), Install compat
|
||||
├── config.go # log.* parsing (tolerant of env strings: "14", "console,daily")
|
||||
├── component.go # component filter handler + proxy handler
|
||||
├── console.go # lipgloss console handler (TTY) using bonfire styles
|
||||
├── daily.go # daily rotating writer + retention
|
||||
├── channels.go # driver factory: console, stderr, daily, stack, null
|
||||
├── *_test.go # slogtest, rotation with injected clock, env overrides
|
||||
└── README.md
|
||||
modules/bonfire/
|
||||
├── command.go # + Examples, Hidden (additive)
|
||||
├── root.go # NewRoot(name, cmds, out, opts ...RootOption); duplicates; persistent flags
|
||||
├── globals.go # Globals, PeekGlobals(args), Verbosity
|
||||
├── help.go / list.go # Artisan help + list (txt/json), Catalog JSON, CheckDocs
|
||||
├── errors.go # Hint, UsageError, ErrReported, render, Execute → exit code
|
||||
├── styles.go # palette (ported from SSU tui/styles.go + current ANSI set)
|
||||
├── output.go / widgets.go # same Output interface; charm-backed TTY path
|
||||
├── live.go # ported SSU SpinnerModel / ProcessModel / ProgressModel (v2 API)
|
||||
├── prompts.go # huh on TTY; line fallback unchanged
|
||||
└── testdata/*.golden # list/help/error goldens
|
||||
```
|
||||
|
||||
### Pattern 1: Proxy-backed named loggers (init-order safe)
|
||||
**What:** `diary.For(name)` returns `slog.New(&componentHandler{name: name})`. The handler resolves the current root handler at `Enabled`/`Handle` time from an `atomic.Pointer`, so loggers created in package `var` blocks or before `diary.Setup` still route correctly after setup and after `SetVerbosity`.
|
||||
**When:** All framework modules (replace `loggerFromApp` fallbacks and bare `slog.Default()`), River (`diary.For("river")`), GORM (`diary.For("db")`), plugins (`diary.For("journal")`).
|
||||
**Component filter:** `Enabled(ctx, lvl)` = `lvl >= floor(name)` && `root.Enabled(ctx, lvl)`, where `floor(name)` = `components[name]` else `log.level`, lowered by verbosity. Records from plain `slog.Default()` carry no component and use `log.level`. `WithAttrs` that sets key `component` rebinds the name, so `slog.Default().With("component","x")` also works. The console handler prints the component as a fixed-width prefix; JSON/logfmt get a `component` attribute.
|
||||
**Example:**
|
||||
```go
|
||||
// Source: design for this phase (stdlib log/slog semantics)
|
||||
type componentHandler struct {
|
||||
name string
|
||||
attrs []slog.Attr // pre-bound; replayed onto the live root at Handle time
|
||||
group []string
|
||||
}
|
||||
|
||||
func (h *componentHandler) Enabled(ctx context.Context, l slog.Level) bool {
|
||||
return l >= current().floor(h.name) && current().handler.Enabled(ctx, l)
|
||||
}
|
||||
|
||||
func (h *componentHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||
r.AddAttrs(slog.String("component", h.name))
|
||||
return current().bind(h.attrs, h.group).Handle(ctx, r)
|
||||
}
|
||||
```
|
||||
Note: rebinding `WithAttrs/WithGroup` onto a swapped root needs care. Keep `attrs`/`group` on the proxy and apply them to the live handler lazily (cache per root generation) so a swap after `With(...)` keeps the attributes.
|
||||
|
||||
### Pattern 2: Channels as sunscreen-wrapped leaves under a stdlib MultiHandler
|
||||
**What:** Each configured channel builds a leaf handler, which is wrapped with `sunscreen.Wrap` (literal D-12; `Wrap` returns an already-wrapped handler unchanged [VERIFIED: modules/sunscreen/sunscreen.go:82-87]). A `stack` is `slog.NewMultiHandler(children...)`. Each leaf owns a `slog.LevelVar` for its `level`, and the console leaf's LevelVar is what `-q` raises to WARN.
|
||||
**Level rule (recommendation):** a record reaches a channel when `level >= max(floor(component), channel.level)`. Verbosity: `-v` sets every component floor to at most INFO (so `river: warn` shows INFO), `-vv` sets DEBUG everywhere, and `-vvv` adds DEBUG plus `AddSource` on the console and GORM `Info` (SQL traces, parameterized). `-q` sets the console leaf to WARN; files are unaffected.
|
||||
|
||||
### Pattern 3: Daily writer (stdlib)
|
||||
`path: storage/logs/summer.log` → files `storage/logs/summer-2026-10-21.log` (Laravel naming). Under a mutex: pick the file by `now()` date, reopen on date change with `O_APPEND|O_CREATE|O_WRONLY` (0o640), `MkdirAll` (0o750), then delete `summer-*.log` whose date is older than `days` at open and on each rotation. Each record is buffered and written with one `Write` call (append writes of a whole line from `serve` and `queue:work` processes do not interleave on a local filesystem). The path is relative to the **application root** (the parent of the config dir), not the process CWD. If opening fails, warn once on the console and disable the channel. **Boot never fails because of a log file.** Inject `now func() time.Time` for tests.
|
||||
|
||||
### Pattern 4: Globals before boot, flags after
|
||||
The generated `main` loads config and activates plugins **before** cobra parses flags [VERIFIED: internal/build/build.go:119-148]. So:
|
||||
1. `g := bonfire.PeekGlobals(args)`: a tolerant scan (stops at `--`, recognises `-v/-vv/-vvv/--verbose`, `-q/--quiet`, `-n/--no-interaction`, `--ansi/--no-ansi`, `--env X`/`--env=X`), never errors.
|
||||
2. `compass.Open(compass.Options{Dir: "config", Env: g.Env})` (`Options.Env` wins over `SUMMER_ENV` [VERIFIED: modules/compass/config.go:193-199]).
|
||||
3. `diary.Setup(cfg, diary.Options{Verbosity: g.Verbosity, Quiet: g.Quiet, ...})`.
|
||||
4. `bonfire.NewRoot(...)` registers the same flags as cobra persistent flags so help, validation and `Input` all see them. A `RootOption` callback (`bonfire.OnGlobals(func(Globals))`) lets `main` re-apply them to `diary` in `PersistentPreRunE` (same values; keeps `bonfire` free of a `diary` import).
|
||||
Cobra probe: `-vvv migrate` → 3, `migrate -vv -q -n --env staging x` → all set, `--env staging migrate` works, bare root with `RunE` runs (list), `-v` alone runs root, and **passthrough commands receive `-v` as a raw arg** (`-v serve --addr :1 -vv` → args `["-v","--addr",":1","-vv"]`), so `summer serve -v` forwards verbosity to the app binary unchanged. [VERIFIED: probe in scratchpad, output quoted in Code Examples]
|
||||
|
||||
### Pattern 5: Artisan help/list with fang narrowly
|
||||
- `wrap()` sets `cmd.SetHelpFunc(artisanHelp)` on every subcommand. Cobra's `HelpFunc()` returns the command's own `helpFunc` before walking to the parent, so fang's root-level `SetHelpFunc` never reaches subcommands. [VERIFIED: cobra@v1.10.2 command.go:484-501; fang v2 fang.go:129-139]
|
||||
- Root: `RunE` renders the list (bare binary). `bin --help` hits the root help func, which fang has set, so either (a) skip `fang.Execute` and call `root.ExecuteContext` + our renderer directly (fang then contributes only `Styles`/`DefaultErrorHandler`/types), or (b) call `fang.Execute` and pre-route `-h/--help` with no command to the list in `bonfire.Execute` before delegating. **Recommendation: (b)**, which honours D-02 and D-14 and stays small. Always pass `fang.WithoutVersion()` (fang's `--version`/`-v` would collide with verbosity; cobra only adds `-v` for version when no `v` shorthand exists [VERIFIED: cobra command.go:1238-1258]), `fang.WithoutManpage()` (else a hidden `man` command appears in our list/duplicate checks), `fang.WithoutCompletions()`.
|
||||
- fang wraps stderr with `colorprofile.NewWriter(root.ErrOrStderr(), os.Environ())` before calling the error handler [VERIFIED: fang.go:166-169]. To honour `--no-ansi/--ansi`, the handler type-asserts `*colorprofile.Writer` and overrides `.Profile`.
|
||||
- Version header: `debug.ReadBuildInfo()`. The app version is `Main.Version` (Go ≥1.24 stamps the VCS tag/pseudo-version, `+dirty` when modified [CITED: go.dev/doc/go1.24]). For the framework version, find the `Deps` entry for `git.golem15.com/golem15/summercms`. Probe results: with `replace => ../fw` the dep reads `version="v0.3.2"`, `Replace={"../fw","(devel)"}`; under go.work the dep reads `version="(devel)"`, `Replace=nil`. Render `framework v0.3.2 (local)` / `framework devel`. [VERIFIED: probe]
|
||||
- Grouping: top-level names (no colon) first, then by the segment before the first colon (`admin:token:create` → `admin`), sorted; `Hidden` skipped unless `list --all`; include cobra's `help` and our `list`.
|
||||
- `list --format=json`: `{"application":{name,version,framework},"global_options":[...],"commands":[{name,description,long,usage,hidden,passthrough,arguments:[{name,description,required}],options:[{name,shorthand,description,default,bare,repeatable}],examples:[{command,description}]}]}`.
|
||||
- `bonfire.CheckDocs(cmds []Command) []DocProblem` (name, missing: description|long|examples|arg description|flag description). The framework test fails on any problem; plugin repos call it from a test with `t.Log` (warn only).
|
||||
|
||||
### Pattern 6: Startup summary and shutdown report
|
||||
- Summary values are collected after binding and rendered twice: as an aligned lipgloss block on a TTY, and as INFO log records (component `serve`) so journald and files keep them (D-05). Keep one record whose message contains **`listening on <addr>`** verbatim: app smoke scripts grep `listening on 127.0.0.1:$PORT` [VERIFIED: ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh:306, ../sm-summercmsio-app/scripts/smoke.sh:125; modules/surf/serve.go:245].
|
||||
- Migration state: new additive `lagoon.Pending(gdb, plugins) (int, error)` comparing each plugin's `Migrations()` IDs with the history table (`lagoon.Status` only returns applied IDs [VERIFIED: modules/lagoon/migrations.go:169-205]).
|
||||
- Drivers: read from config keys seen via grep in modules (`mail.driver`, `cache.driver`, `realtime.driver`, `search.driver`, `queue.work_in_serve`, `queue.queues.*`, `app.name`, `app.url`). [ASSUMED: grep-observed only; the planner opens each defining file before use]
|
||||
- Shutdown counters: `surf` adds an atomic in-flight/served counter next to `accessLog`. `conga` counts done/failed/in-flight in `Manager.runAttempt` [VERIFIED: modules/conga/worker.go:243] and exposes an additive `Worker.Stats()`. Uptime comes from the start time. `Worker.Stop` already escalates to `StopAndCancel` after the context deadline [VERIFIED: worker.go:172-192]. Report in-flight-at-stop as "returned to the queue" (River retries cancelled jobs).
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **Adding methods to `bonfire.Output`.** Add optional interfaces plus helpers instead (`bonfire.VerbosityOf(out)`, `bonfire.Process(out, items, op, fn)`, `bonfire.ItemProgress` for `Current(item)`/`Fail(item, err)`). No external implementers exist today (only `modules/bonfire/widgets.go` defines `Spinner(message string`), but D-02 asks for source compatibility.
|
||||
- **Starting a bubbletea program without `tea.WithInput(nil)` for spinners/progress.** With input enabled, bubbletea puts stdin in raw mode (Ctrl+C becomes a key, not SIGINT) and, if stdin is not a TTY, opens `/dev/tty` or fails with `bubbletea: error opening TTY` [VERIFIED: bubbletea v2 tea.go:1034-1044].
|
||||
- **Letting bubbletea own signals inside commands.** Use `tea.WithoutSignalHandler()` and `tea.WithContext(ctx)`; the command's own `signal.NotifyContext` stays authoritative (serve/conga/docs:serve/parity use it).
|
||||
- **Writing log lines to the terminal while a live view renders.** The view redraws over them. During a live view, the console handler must route lines through `Program.Println` (prints above the view [VERIFIED: tea.go:1412-1434]). It must never call it after the program has quit (`p.msgs` send would block).
|
||||
- **Logging URL paths in the request log.** See Pitfall 9.
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't build | Use instead | Why |
|
||||
|---|---|---|---|
|
||||
| Fan-out handler | custom MultiHandler | `slog.NewMultiHandler` | Clones records, honours Enabled, joins errors [VERIFIED: GOROOT multi_handler.go] |
|
||||
| JSON / logfmt | custom encoders | `slog.JSONHandler` / `slog.TextHandler` | Correct escaping, groups, time format |
|
||||
| Color downsampling / NO_COLOR | env checks around every Render | `colorprofile.Writer{Forward, Profile}` | lipgloss v2 always emits ANSI |
|
||||
| Box tables | the current `boxLine/boxRow` | `lipgloss/v2/table` with `lipgloss.NormalBorder()` (same ┌┬┐ glyphs) | Width-aware (East Asian, emoji); keep the non-TTY tab-separated path as is |
|
||||
| Spinner/progress animation | goroutine + `\r` loops | bubbles `spinner.MiniDot` (same frames as today's `spinnerFrames` [VERIFIED: widgets.go:11; SSU spinner.go]) + `progress.New(WithColors(cyan, green))` | Already SSU's look |
|
||||
| Arrow-key prompts | raw-mode key handling | `huh.NewSelect/NewConfirm/NewInput().EchoMode(huh.EchoModePassword)` | Accessible mode, aborts |
|
||||
| "Did you mean" | Levenshtein | `cmd.SuggestionsFor(arg)` (cobra, exported) | Cobra already formats suggestions into the error text (probe) |
|
||||
| GORM → slog | custom gorm logger | `logger.NewSlogLogger(diary.For("db"), logger.Config{SlowThreshold: 200*time.Millisecond, LogLevel: logger.Warn, IgnoreRecordNotFoundError: true, ParameterizedQueries: true})` | Exists in gorm v1.31.2; parameterized avoids secrets in SQL traces |
|
||||
| slog conformance | ad-hoc assertions | `testing/slogtest.TestHandler` | Catches group/attr bugs (it caught charm log) |
|
||||
|
||||
**Key insight:** most of `diary` is configuration and policy over stdlib slog. The genuinely new code is the component filter/proxy, the console renderer and the daily writer, and each is small and testable.
|
||||
|
||||
## Runtime / Deployed-State Impact
|
||||
|
||||
This is not a rename phase; the full Runtime State Inventory is omitted. Deployed state still changes:
|
||||
- **Files on disk:** daily logs appear under `<app>/storage/logs/` on every deployed instance once binaries are rebuilt (D-10). `/storage/` is already git-ignored in new apps [VERIFIED: internal/build/testdata/new/dot-gitignore.golden]. Hosts must have the directory writable (supervisor/systemd users on rome; Docker volumes); the channel must degrade, not crash.
|
||||
- **Config:** new `config/log.yaml` in `summer new` output and goldens. Existing apps rely on framework defaults (no file needed). `SUMMER_LOG__*` env vars use the `__` separator (`SUMMER_LOG__COMPONENTS__RIVER=warn`), **not** `SUMMER_LOG_*` as written in D-08 [VERIFIED: modules/compass/env.go:31-44].
|
||||
- **Generated code:** every app's committed `main.go` (generated) changes on the next `summer build`; `examples/hello/main.go` is committed generated code and must be regenerated.
|
||||
- **Deploy scripts:** production runs `./bin/<app>.new migrate` over non-interactive SSH [VERIFIED: ../sm-bm-app/DEPLOY.md:35; ../sm-demo-app/DEPLOY.md:359]. Any new confirmation on `migrate` must not block non-TTY runs (Pitfall 11).
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Global shorthands vs command flags
|
||||
**What goes wrong:** pflag panics (`unable to redefine "v" shorthand`) when a command's flag reuses `v`, `q` or `n` under another name; same-name local flags silently shadow persistent ones. [VERIFIED: pflag@v1.0.9 flag.go:898-917]
|
||||
**Status:** No command in summercms.go, the D-20 plugins or the user plugin declares any `Shorthand`, and none uses the names `verbose/quiet/ansi/no-ansi/no-interaction/env/format/all`. [VERIFIED: grep across repos]
|
||||
**How to avoid:** `NewRoot` validates command flags against reserved names/shorthands and returns an error (not a panic) naming the command. `list` owns `--format/--all` locally.
|
||||
|
||||
### Pitfall 2: The docs checker's flag model
|
||||
`internal/docsite.commandWord` assumes the root has only `--help/-h` and that "any other --name or two-character -x consumes the next token". With bool globals, docs lines like `bin/acme -v serve` would be misread (it would take `serve` as the value of `-v`). Update `commandWord` to know the bool globals (`-v -q -n --ansi --no-ansi --quiet --verbose --no-interaction`) and the value-taking `--env`. [VERIFIED: internal/docsite/check_commands.go:96-110]
|
||||
Also add `list` and `help` to `docsCommands()` (they come from bonfire, not `toolCommands()`), and keep the generated-main lines in the `commands = append(commands, pkg.Ctor(app...` shape that `TestDocsCommandsMirrorGeneratedMain` parses with a regex. [VERIFIED: cmd/summer/docs_test.go:415-470]
|
||||
|
||||
### Pitfall 3: Scheduler `Call` and process-global verbosity
|
||||
`bonfire.Call` builds a fresh root per call (used by the scheduler through `Catalog`) [VERIFIED: call.go:17-34]. Global flags will exist there too, but the `OnGlobals` hook that mutates `diary` must be installed only on the binary's main root, not in `Call`. Otherwise a scheduled entry with `-v` changes logging for the whole process. `Call` output is non-TTY with empty stdin, so prompts take defaults and confirmations must treat it as non-interactive.
|
||||
|
||||
### Pitfall 4: `compass` map keys and env strings
|
||||
- koanf uses `.` as delimiter, so component names must not contain dots (`acme.blog` would nest). Use plugin codes like `journal`, `oauthserver`. Document it.
|
||||
- Env overrides arrive as strings: `SUMMER_LOG__CHANNELS__DAILY__DAYS=30` → `"30"`, `SUMMER_LOG__CHANNELS__STACK__CHANNELS=console,daily` → a string, not a list. diary's parser must accept both (strconv for ints, comma split for lists). The `summer new` http.yaml stub warns that env strings are rejected for numeric keys there [VERIFIED: internal/build/stubs/new.tmpl]; diary must not copy that strictness.
|
||||
|
||||
### Pitfall 5: Loggers captured before setup
|
||||
`sunscreen.InstallDefault` runs first today, so later `slog.Default()` calls are already redacted. With diary, `main` must call `diary.Setup` (or an early `diary.Bootstrap(stderr)` before config) **before** `compass`/`party`, because config errors and plugin `Register` logs happen there. The proxy pattern (Pattern 1) removes ordering bugs for `diary.For`. Calls to `slog.Default()` made before setup still get the bootstrap handler.
|
||||
|
||||
### Pitfall 6: GORM bypasses slog entirely
|
||||
`lagoon` opens GORM with `&gorm.Config{}` [VERIFIED: modules/lagoon/connection.go:57], so GORM uses `logger.Default = New(log.New(os.Stdout, "\r\n", log.LstdFlags), Config{SlowThreshold: 200 * time.Millisecond, LogLevel: Warn, IgnoreRecordNotFoundError: false, Colorful: true})` [VERIFIED: gorm@v1.31.2 logger/logger.go:76-81]. That is colored raw text on **stdout**, including every "record not found". Route it through `logger.NewSlogLogger` with `ParameterizedQueries: true` (SQL values can carry tokens/PII). Setting `IgnoreRecordNotFoundError: true` changes output and is intended.
|
||||
|
||||
### Pitfall 7: Handler concurrency and the shared terminal
|
||||
slog handlers are called concurrently (HTTP handlers, River workers). The console handler holds a `*sync.Mutex` shared across `WithAttrs/WithGroup` clones and writes each record in one `Write`. The daily writer locks around rotate+write. Bonfire output (stdout) and logs (stderr) share one terminal: the live-view rule in Anti-Patterns applies. Run `go test -race ./modules/diary/... ./modules/bonfire/...` (CGO/gcc available here).
|
||||
|
||||
### Pitfall 8: Daily files in production
|
||||
The path resolves against the app root. Unwritable dirs (read-only container FS) → warn + disable channel. Retention deletes only files matching `<base>-YYYY-MM-DD<ext>`, never other files in the directory. File mode 0o640 because logs carry user identifiers.
|
||||
|
||||
### Pitfall 9: D-11 vs the request log's security contract
|
||||
D-11 asks for "method, **path**, status, duration, request id" and "shows by default". Today:
|
||||
- the URL path is **deliberately never logged** because share/invitation tokens travel in paths, including for 404/405 before routing (documented in `docs/services/logging.md` "Request log" and `modules/surf/accesslog.go:42-60`);
|
||||
- the attributes are `slog.String("method", req.Method)`, `slog.String("pattern", entry.pattern)`, status, `duration_ms`, optional `client_gone` [VERIFIED: accesslog.go:75-76];
|
||||
- the log is **off by default** (`const accessLogKey = "http.access_log"`) [VERIFIED: accesslog.go:14, 38].
|
||||
**Recommendation:** keep `pattern` instead of `path`. Add `request_id` from `X-Request-Id`, trimmed and capped at 128 bytes the way `surf/errorpage.go` already does (`errorPageMaxRequestID = 128`). Turning the log on by default needs a user decision: either flip `http.access_log` default to true with `components.http` as the silencer, or keep the key and document it. This is in Open Questions; do not silently log paths.
|
||||
|
||||
### Pitfall 10: `-q` must not swallow data
|
||||
Scripts read `admin:token:create` output via `out.Println(plain)` [VERIFIED: modules/cabana/token_commands.go:117], `key:generate` via `out.Success(key)`, `herald:vapid-generate` env lines. Artisan's `-q` silences everything. Recommendation: under `-q`, suppress `Info/Success/Warning`, banners, spinners and progress; keep `Println/Printf/Write` (data), `--json` output and `Error`. For `key:generate`, print the bare key when stdout is not a TTY (or add `--json`). This is in Open Questions.
|
||||
|
||||
### Pitfall 11: `--force`/`--pretend` semantics already taken, and deploys
|
||||
- In the importers and `pg:update-currencies`, `--force` already means "replace existing data"/"force update", not "skip confirmation" [VERIFIED: plugin console files]. Do not repurpose it.
|
||||
- Importers already have `--dry-run`. Adding `--pretend` aliases is additive but optional; `--pretend` fits `migrate`/`migrate:rollback` (Artisan name).
|
||||
- Artisan's ConfirmableTrait asks in production. Production deploys run `migrate` over non-TTY SSH, so a production confirmation that fails without `--force` on non-TTY would break every deploy. Recommendation: confirm only on an interactive TTY. Non-TTY keeps today's behaviour, so the change stays non-breaking (D-20); `--force` skips the prompt.
|
||||
|
||||
### Pitfall 12: Prompts and piped stdin
|
||||
`admin:create`/`admin:reset-password` read the password from piped stdin through `out.Secret` (test "WR-13" in `modules/cabana/commands_test.go:241-262`). `Secret` reads a plain line when stdin is not a terminal [VERIFIED: prompts.go:88-106]. Keep the line path whenever stdin is not a TTY. Use huh only when both stdin and stdout are TTYs and `-n` is unset. With `-n` and no default (Secret), fail with a hint ("pipe the password on stdin").
|
||||
|
||||
### Pitfall 13: Errors printed twice and odd exit codes
|
||||
- `flare` returns sentinel errors after printing its own message: `errGenerateFailed = errors.New(GenerateVAPIDKeysCommandName + ": failed")`, `errTestPushFailed = ...` [VERIFIED: modules/flare/commands.go:20-21]. `docs:build --check` does the same (`errors.New(cmd + " failed")`). Add `bonfire.ErrReported` (and `bonfire.Reported(err)`) so the renderer exits 1 without a second message. Migrate those sites.
|
||||
- `summer dev` returns `ctx.Err()` on Ctrl+C, and `summer main` prints it and exits 1. Map `context.Canceled` from a signal to a clean shutdown (exit 0 after the report, or 130). Decide one convention for dev/serve/queue:work.
|
||||
- Usage errors → exit 2: wrap `positionalArgs` failures and `root.SetFlagErrorFunc` errors in `bonfire.UsageError`. Detect `unknown command` by prefix (fang's own approach). Turn cobra's appended "Did you mean this?" text into the `Try:` hint.
|
||||
|
||||
### Pitfall 14: `summer dev` starts the binary with no arguments
|
||||
`startBin` runs `exec.Command(binPath)` [VERIFIED: internal/dev/watch.go:201-210]. A bare app binary prints help (today) or the list (after this phase), then exits, so `summer dev` never serves. D-07 asks for a shutdown report for `dev`. Recommendation: start `bin/<app> serve` and forward `-v/-q`. This is a behaviour fix and must be confirmed (Open Questions).
|
||||
|
||||
### Pitfall 15: Duplicate-name rejection can stop apps from starting
|
||||
Each app has exactly one plugin appending `centrifugo.Commands` (demo plugin, bm plugin, the Płytarium domain plugin, which also appends `flare.Commands`) [VERIFIED: grep]. No duplicates exist today. After the change, a second plugin appending the same shared command would make `NewRoot` fail at boot. Plan 2 verification: build each app in the meta repo and run `bin/<app> list`.
|
||||
|
||||
### Pitfall 16: Cross-repo ordering and concurrency
|
||||
- Plugins compile against the working framework through `replace ... => ../summercms.go` in their go.mod/go.work, but tagged consumers ignore dependency replaces. Plugin commits that use `Examples`, `Hidden` or `diary` need a framework release tag before their `require summercms vX` bump is meaningful. Tagging needs the user's go-ahead. [VERIFIED: sibling go.mod files]
|
||||
- Phase 19.5 is executing in `sm-user-plugin` (canonical checkout `../fonoteka.go/plugins/golem15/user`, per the 19.4/19.5 gate scripts) and `sm-sociallogin-plugin` right now [VERIFIED: STATE.md; scripts/check-phase19.5.sh:65-66]. Sweep the user plugin after 19.5 lands or coordinate.
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Generated main after this phase (shape; keep the `commands = append(commands, …(app…` lines)
|
||||
```go
|
||||
// Source: derived from internal/build/build.go:94-151 (current template)
|
||||
func run(args []string, out io.Writer) int {
|
||||
g := bonfire.PeekGlobals(args)
|
||||
diary.Bootstrap(os.Stderr, g) // redacting console until config is read
|
||||
cfg, err := compass.Open(compass.Options{Dir: "config", Env: g.Env})
|
||||
if err != nil {
|
||||
return bonfire.Fail(os.Stderr, err, g) // styled block, exit 1
|
||||
}
|
||||
if err := diary.Setup(cfg, diary.Options{Globals: g}); err != nil {
|
||||
return bonfire.Fail(os.Stderr, err, g)
|
||||
}
|
||||
app := backpack.New(cfg)
|
||||
plugins, err := party.Activate(app, PluginIDs)
|
||||
// ... unchanged command assembly ...
|
||||
root, err := bonfire.NewRoot("acme", commands, out,
|
||||
bonfire.WithVersion(bonfire.BuildVersion()), bonfire.OnGlobals(diary.Apply))
|
||||
if err != nil {
|
||||
return bonfire.Fail(os.Stderr, err, g)
|
||||
}
|
||||
return bonfire.Execute(context.Background(), root, args) // 0, 1 or 2
|
||||
}
|
||||
```
|
||||
Keep `func main() { os.Exit(run(os.Args[1:], os.Stdout)) }`. The `internal/build` goldens (`main.go.golden`) are updated with `go test ./internal/build -run Golden -update` (flag defined at `internal/build/new_golden_test.go:13`).
|
||||
|
||||
### Porting SSU's spinner to bubbletea v2 (API translation)
|
||||
```go
|
||||
// Source: SSU internal/cli/tui/spinner.go (v1) translated per charm v2 upgrade guides
|
||||
func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case progressMsg: // {Done, Total, Item, Err}
|
||||
m.done, m.total, m.current = msg.Done, msg.Total, msg.Item
|
||||
return m, nil
|
||||
case doneMsg:
|
||||
m.err = msg.Err
|
||||
return m, tea.Quit
|
||||
case spinner.TickMsg:
|
||||
var cmd tea.Cmd
|
||||
m.spin, cmd = m.spin.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m spinnerModel) View() tea.View { // v1: View() string
|
||||
line := fmt.Sprintf("%s %s %d/%d", m.spin.View(), m.label, m.done, m.total)
|
||||
if m.current != "" {
|
||||
line += " " + styles.Muted.Render(m.current+"…")
|
||||
}
|
||||
return tea.NewView(line)
|
||||
}
|
||||
|
||||
// Run with: tea.NewProgram(model, tea.WithInput(nil), tea.WithOutput(w),
|
||||
// tea.WithoutSignalHandler(), tea.WithContext(ctx))
|
||||
```
|
||||
Progress bar: `progress.New(progress.WithColors(lipgloss.Color("#00afaf"), lipgloss.Color("#00af00")), progress.WithoutPercentage())` (cyan→green, matching today's `progressColor` 256-color codes 37/36/34 [VERIFIED: widgets.go:133-142]); `bar.SetWidth(40)` replaces SSU's `bar.Width = 40`.
|
||||
|
||||
### Error with hint
|
||||
```go
|
||||
// Source: design for D-18/D-19
|
||||
return bonfire.WithHint(err,
|
||||
"summer make:command needs an application root (summer.yaml).",
|
||||
"cd ..; {bin} make:command acme.blog:publish") // {bin} → "bin/<root name>" or "summer"
|
||||
```
|
||||
Known-failure sites for hints: `lagoon: database.dsn is empty (set SUMMER_DATABASE__DSN)` [VERIFIED: modules/lagoon/connection.go:25] → Try: `cp .env.example .env` / `docker compose up -d --wait`; `lagoon: ping postgres:` (unreachable) → Try: `docker compose up -d --wait`; `build: no summer.yaml found from <dir>` [VERIFIED: internal/build/manifest.go:215] → Try: `cd <app>; summer <cmd>`. For the wrong directory: `compass` silently returns an empty config when `config/` is missing [VERIFIED: config.go:237-242]. So when a command fails **and** `config/` is absent in CWD **and** a parent holds `summer.yaml`, decorate the error with `cd <rel>; bin/<app> <args>` (D-19: message only). The generated main cannot import `internal/build`, so the parent-walk lives in `bonfire` or `compass`.
|
||||
|
||||
### Probe evidence (quoted)
|
||||
charm log v2 against `testing/slogtest` (abridged):
|
||||
```
|
||||
missing key "time": this test expects slog.TimeKey, slog.LevelKey and slog.MessageKey
|
||||
unexpected key "": a Handler should ignore an empty Attr
|
||||
missing key "c": a Handler should inline the Attrs of a group with an empty key
|
||||
unexpected key "a": a Handler should handle the WithGroup method
|
||||
missing group "G": a Handler should handle the WithGroup method
|
||||
missing group "G": a Handler should handle multiple WithGroup and WithAttr calls
|
||||
FAIL probe 0.004s
|
||||
```
|
||||
(The "time" lines come from `ReportTimestamp: false`; the group and empty-attr failures are real.)
|
||||
|
||||
Cobra global flags:
|
||||
```
|
||||
["-vvv" "migrate"] => migrate v=3 q=false n=false env="" args=[]
|
||||
["migrate" "-vv" "-q" "-n" "--env" "staging" "x"] => migrate v=2 q=true n=true env="staging" args=[x]
|
||||
["-v" "serve" "--addr" ":1" "-vv"] => serve(passthrough) v=0 args=["-v" "--addr" ":1" "-vv"]
|
||||
["--env" "staging" "migrate"] => migrate v=0 q=false n=false env="staging" args=[]
|
||||
["migrat"] => ERR: unknown command "migrat" for "app"\n\nDid you mean this?\n\tmigrate
|
||||
[] => ROOT list v= 0
|
||||
```
|
||||
BuildInfo:
|
||||
```
|
||||
main="v1.4.0"
|
||||
dep example.com/fw version="v0.3.2" replace={"../fw" "(devel)"}
|
||||
--- go.work mode
|
||||
main="v1.4.0+dirty"
|
||||
dep example.com/fw version="(devel)" replace=<nil>
|
||||
```
|
||||
Binary size (stripped, `-trimpath -s -w`): cobra+x/term baseline 2,527,495 B; + lipgloss/table, bubbletea, bubbles spinner+progress, huh, fang 4,649,223 B (≈ +2.1 MB).
|
||||
|
||||
## Command Inventory (sweep input, D-20/D-21)
|
||||
|
||||
Legend: **D** description, **L** long help, **E** examples (no command has E today; the field does not exist yet). Checklist columns hold proposed outcomes for the plan to confirm or decline: Force = `--force` confirmation, Pretend = `--pretend`/dry-run, Json = `--json`, Prog = progress/table for bulk, Res = result line + exit code.
|
||||
|
||||
### summercms.go: `summer` tool (`cmd/summer`, 18 own + 15 delegates)
|
||||
|
||||
| Command | File | D | L | Notes / checklist |
|
||||
|---|---|---|---|---|
|
||||
| build | main.go:51 | ✓ | – | Res ✓ (`built X in Y`); Prog: spinner around `go build` on TTY |
|
||||
| make:plugin, make:model (`--no-migration`), make:migration, make:command, make:job, make:admin-controller | main.go:65-211 | ✓ | – | Res: `created <path>` lines; D-19 hints for wrong dir (folded todo); E needed |
|
||||
| plugin:add `<dir>` | main.go:269 | ✓ | – | Res ✓ |
|
||||
| new `<name>` (`--framework --no-plugin --no-git`) | main.go:296 | ✓ | – | Writes `config/log.yaml` (D-10); golden update |
|
||||
| dev | main.go:255 | ✓ | – | Long-running: summary + shutdown report (D-07); Pitfall 14; output via raw `fmt.Fprintf` in internal/dev/watch.go:79-150 |
|
||||
| parity:proxy/record/replay/broadcasts/upstream | parity.go:19-104 | ✓ | – | Many `(true/false)` string flags: show defaults; E needed |
|
||||
| docs:build (`--check`), docs:sync, docs:serve | docs.go:25-99 | ✓ | – | `ErrReported` for problem reports; docs:serve long-running |
|
||||
| 15 delegates (migrate, migrate:rollback, migrate:status, serve, queue:work, queue:clear, schedule:run, route:list, key:generate, admin:create, admin:reset-password, admin:token:create/list/revoke/scopes) | runtime.go:27-43 | ✓ | ✓ (generated) | Passthrough; add E per row; `deferred:purge` and `websockets:*` are not delegated (by design, documented) |
|
||||
|
||||
### summercms.go: application binary (framework, 19)
|
||||
|
||||
| Command | File | D | L | Force | Pretend | Json | Prog | Res |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| migrate | lagoon/commands.go:26 | ✓ | – | TTY+production only (Pitfall 11) | add `--pretend` | – | per-plugin progress | ✓ "migrations applied" → count |
|
||||
| migrate:rollback `--plugin` | lagoon/commands.go:39 | ✓ | – | TTY confirm | `--pretend` | – | – | ✓ |
|
||||
| migrate:status | lagoon/commands.go:60 | ✓ | – | – | – | add | table ✓ | – |
|
||||
| deferred:purge `--days` | lagoon/commands.go:82 | ✓ | – | TTY confirm | add (reports counts) | – | progress | ✓ |
|
||||
| key:generate | lagoon/keygen.go:26 | ✓ | – | – | – | bare key on non-TTY / `--json` | – | ✓ |
|
||||
| queue:work `--queue`* | conga/commands.go:31 | ✓ | – | – | – | – | summary + shutdown | – |
|
||||
| schedule:run `--once` | conga/commands.go:45 | ✓ | – | – | – | – | summary + shutdown | ✓ (once) |
|
||||
| queue:clear `[queue]` | conga/commands.go:64 | ✓ (ends with "."; fix) | – | TTY confirm | – | – | – | ✓ "Cleared N jobs" |
|
||||
| serve `--addr` | surf/serve.go:30 | ✓ | – | – | – | – | summary + shutdown | – |
|
||||
| route:list | surf/routelist_command.go:18 | ✓ | – | – | – | add | table ✓ | – |
|
||||
| admin:create `--email --password --login --superuser --role` | cabana/commands.go:21 | ✓ | – | – | – | – | – | ✓ |
|
||||
| admin:reset-password `<identifier>` | cabana/commands.go:35 | ✓ | – | – | – | – | – | ✓ |
|
||||
| admin:token:create `<identifier> --name --scope* --expires=90d` | cabana/token_commands.go:21 | ✓ | – | – | – | add | – | ✓ (token on stdout) |
|
||||
| admin:token:list `[identifier] --revoked` | token_commands.go:36 | ✓ | – | – | – | add | table ✓ | – |
|
||||
| admin:token:revoke `<id>` | token_commands.go:49 | ✓ | – | – | – | – | – | ✓ |
|
||||
| admin:token:scopes | token_commands.go:59 | ✓ | – | – | – | add | table ✓ | – |
|
||||
| websockets:health | lighthouse/centrifugo/commands.go:31 | ✓ | – | – | – | add | – | exit 1 on failure |
|
||||
| websockets:generate-vapid-keys `--update --show-current` | flare/commands.go:50 | ✓ | – | – | – | – | – | `ErrReported` |
|
||||
| websockets:test-push `<user_id> --show-config` | flare/commands.go:212 | ✓ | – | – | – | – | – | `ErrReported`; Confirm default true under `-n` |
|
||||
| examples: greeter:hello, blog:publish | examples/hello/plugins/greeter/plugin.go:80; docs/examples/blog/console/publish.go:18 | ✓ | – | – | – | – | – | include in the framework coverage check |
|
||||
|
||||
### Shared plugins (D-20)
|
||||
|
||||
| Repo | Commands (file) | Notes |
|
||||
|---|---|---|
|
||||
| sm-paymentgateway-plugin | pg:cancel-created-payments, pg:cancel-sent-payments, pg:cancel-placed-orders, pg:finish-orders (bulk writers, **scheduled daily**), pg:update-currencies (`--force` = force update; **every 10 min**), paymentgateway:import-winter (`--settings --force --dry-run`) (console/*.go; schedule.go:17-21) | Pretend candidates for the bulk writers; run under scheduler `Call` (non-interactive); 16 slog sites |
|
||||
| sm-oauthserver-plugin | oauthserver:prune (scheduled daily), oauthserver:rotate-keys (`--prune`, scheduled interval), oauthserver:import-winter (`--file --key-dir --settings --uploads-dir --force --dry-run`) | Pretend for prune |
|
||||
| sm-journal-plugin | journal:export-posts (`--path --dry-run`), journal:import-posts (`--path --force --dry-run`) | Progress for bulk; `--force` = update existing |
|
||||
| sm-sitemap-plugin | sitemap:import `<file>` (`--theme --append`), sitemap:import-winter (`--definitions --settings --theme --map* --page-url* --force --dry-run`) | |
|
||||
| sm-chat-plugin / sm-chatchannels-plugin / sm-forum-plugin | chat:/chatchannels:/forum:rewrite-context-types (idempotent bulk rewrite; description ends with ".") | Pretend candidate |
|
||||
| sm-chatvideo-plugin | chatvideo:reconcile-sessions (console/reconcile.go:14) | |
|
||||
| sm-faq-plugin | faq:import-winter (`--file --force --dry-run`) | |
|
||||
| sm-menu-plugin | menu:import-winter (`--menus --entries --map* --force --dry-run`) | |
|
||||
| sm-herald-plugin | herald:vapid-generate (prints env lines with a private key) | Data on stdout under `-q` |
|
||||
| sm-pgstripe-plugin | pgstripe:import-winter (`--settings --app-key --force --dry-run --webhook-secret-target`) | |
|
||||
| sm-user-plugin (`../fonoteka.go/plugins/golem15/user`) | user:token:create `<email>` (`--name=api --scopes=read,write --expires`), user:token:list, user:token:revoke `<id>`, user:require-password-change `<email>`, user:backfill-handles (bulk) | Json for token create/list; descriptions carry planning IDs ("(D-15)", "(D-21)"): remove them |
|
||||
|
||||
Total plugin commands: 26 (paymentgateway 6, user 5, oauthserver 3, journal 2, sitemap 2, one each in chat, chatchannels, chatvideo, faq, forum, herald, menu, pgstripe). **No plugin command has a long help or examples today.** Descriptions mix trailing periods and capitalised verbs ("Cancels…", "Updates…"); normalise to imperative, no trailing period.
|
||||
|
||||
**Apps that need pointer bumps after each plugin commit** [VERIFIED: app .gitmodules]:
|
||||
- sm-user-plugin → sm-bm-app, sm-demo-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app, fonoteka.go
|
||||
- sm-journal-plugin → sm-demo-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app
|
||||
- sm-sitemap-plugin, sm-menu-plugin, sm-faq-plugin → sm-demo-app, sm-grzybyfunkcjonalne-app
|
||||
- sm-paymentgateway-plugin, sm-pgstripe-plugin, sm-oauthserver-plugin → sm-demo-app
|
||||
- sm-chat, sm-chatchannels, sm-chatvideo, sm-forum, sm-herald → no app in `..` mounts them (nothing to bump)
|
||||
|
||||
Not in D-20 but carrying commands: `sm-golem-plugin`, `sm-feedback-plugin` (`ImportSettings`, mounted in sm-demo-app/fonoteka.go). App-local plugins (demo:seed/reset, the Płytarium domain plugin, bm) are out of scope.
|
||||
|
||||
## Docs and Presentation Locations
|
||||
|
||||
| What | Path |
|
||||
|---|---|
|
||||
| Logging page (rewrite: diary, config/log.yaml, channels, components, daily, request log) | `docs/services/logging.md` |
|
||||
| Console pages | `docs/console/introduction.md` ("Getting help" → list/help/global options), `writing-commands.md` (Examples, Hidden, duplicates now rejected, hints, ErrReported, named loggers, -q/-v semantics), `setup-and-maintenance.md` (serve/queue:work summary, new `--json/--pretend`), `scaffolding.md` (`summer new` writes config/log.yaml; dev report), `utilities.md` |
|
||||
| Configuration | `docs/services/configuration.md`, `docs/setup/configuration.md` (log keys, `SUMMER_LOG__*`) |
|
||||
| Upgrade notes | `docs/setup/upgrading.md` (daily logs on, duplicate names fail, GORM log routing, request log) |
|
||||
| Module READMEs | new `modules/diary/README.md`; `bonfire`, `sunscreen` (shim wording: `InstallDefault` is the only install function; `sunscreen.Install` in the roadmap does not exist), `surf`, `conga`, `lagoon`, `cabana`, `flare`, `lighthouse` |
|
||||
| Root module table | `README.md` (new diary row; bonfire row at line 97, sunscreen at 117) |
|
||||
| Example app | `examples/hello/main.go` (committed generated main; separate workspace module) |
|
||||
| Landing site | `../sm-summercmsio-app/vue-summercmsio-app/i18n/locales/en.json` (`features.console`, `winter.concepts.commands`), `app/components/FeaturesSection.vue`, `TerminalCard.vue`, `app/data/terminal.json` (checked by `../sm-summercmsio-app/terminal_check_test.go`) |
|
||||
| Roadmap presentation | Lives in **`../sm-demo-app`**, not the summercms.io app: `plugins/demo/content/ROADMAP.md` (snapshot, refresh with `scripts/sync-roadmap.sh`, `--check` fails when stale), curated text `plugins/demo/roadmap/curated.go` and `names.go` (sm-demo-app README "Roadmap deck") |
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old approach | Current approach | When changed | Impact |
|
||||
|---|---|---|---|
|
||||
| `github.com/charmbracelet/{lipgloss,bubbletea,bubbles,huh,fang}` v1 | `charm.land/*/v2` vanity paths | lipgloss v2 GA / bubbletea v2 GA in 2026 | SSU code must be translated |
|
||||
| lipgloss Renderer downsampling | `Render()` emits full ANSI; `colorprofile.Writer`/`lipgloss.Fprintln` downsample | lipgloss v2 | Apply `--no-ansi` at the writer |
|
||||
| `View() string`, `tea.KeyMsg` struct | `View() tea.View`, `tea.KeyPressMsg` | bubbletea v2 | Port all SSU models |
|
||||
| `progress.WithGradient/WithDefaultGradient`, `.Width` field | `WithColors/WithDefaultBlend`, `SetWidth()` | bubbles v2 | |
|
||||
| Hand-rolled multi handler | `slog.NewMultiHandler` | Go 1.26 | |
|
||||
| Binary version via `-ldflags -X` | `debug.ReadBuildInfo().Main.Version` VCS stamp | Go 1.24 | No ldflags in `summer build` needed |
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if wrong |
|
||||
|---|---|---|---|
|
||||
| A1 | Config keys for the summary (`mail.driver`, `cache.driver`, `realtime.driver`, `search.driver`, `queue.work_in_serve`, `queue.queues.*`) are the canonical ones | Pattern 6 | Wrong summary lines; planner reads the defining files first |
|
||||
| A2 | River retries jobs cancelled by `StopAndCancel`, so in-flight-at-stop is "returned to the queue" | Pattern 6 | Report wording wrong; verify against River docs/tests |
|
||||
| A3 | koanf's `LoadSection`/unmarshal is weakly typed for the `log` subtree | Pitfall 4 | Parse diary config manually from `Lookup` to avoid depending on it |
|
||||
| A4 | Verbosity mapping (-v INFO floor, -vv DEBUG, -vvv DEBUG+source+SQL) | Pattern 2 | User may want a different mapping (D-04 only fixes -q and "debug detail") |
|
||||
| A5 | `-q` keeps data output (Println/--json/Error) | Pitfall 10 | Deviates from Artisan; needs confirmation |
|
||||
| A6 | Logfmt (`TextHandler`) as the `format: auto` non-TTY default | Pattern 2 | D-05 allows JSON or logfmt; logfmt keeps today's text and smoke greps |
|
||||
| A7 | Daily file dates use the process local time zone (not `app.timezone`) | Pattern 3 | File boundaries differ from the scheduler's |
|
||||
|
||||
## Open Questions (RESOLVED)
|
||||
|
||||
1. **Request log path and default (D-11 vs the security contract).** What we know: paths are never logged by design, and the log is off by default via `http.access_log`. Recommendation: log `pattern` + `request_id` (no path), and ask whether to flip `http.access_log` to default-on with `components.http` as the silencer. RESOLVED: D-23.
|
||||
2. **`-q` semantics for data output.** Recommendation: keep data/`--json`/errors (Pitfall 10). RESOLVED: D-27.
|
||||
3. **Production confirmation on `migrate`/destructive commands.** Recommendation: TTY-only confirmation, non-TTY unchanged, `--force` skips (Pitfall 11). RESOLVED: D-28.
|
||||
4. **`summer dev` should run `serve`.** Recommendation: yes, with forwarded verbosity (Pitfall 14). RESOLVED: D-25.
|
||||
5. **Exit code after a signal-initiated graceful shutdown** (0 or 130). Recommendation: 0 when the shutdown report has no drain errors. RESOLVED: D-29.
|
||||
6. **`sm-golem-plugin` / `sm-feedback-plugin` commands** are outside the D-20 list. Include them in the sweep or leave them. RESOLVED: D-24.
|
||||
7. **Framework release tag for plugin `require` bumps.** Plugin commits using new APIs compile via replace, but tagged consumers need a framework tag; the user's go-ahead is needed for tag and push. RESOLVED: D-26.
|
||||
8. **Selector widget scope.** No command needs multi-select today. Recommendation: huh Select for `Choice`; port SSU's SelectorModel only when a command needs it (keeps plan 2 lean). RESOLVED: D-30.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required by | Available | Version | Fallback |
|
||||
|---|---|---|---|---|
|
||||
| Go toolchain | everything | ✓ | go1.27.2 (go.mod `go 1.27.0`) | — |
|
||||
| proxy.golang.org / charm.land | `go get` charm modules | ✓ | reachable this session | — |
|
||||
| gcc / CGO | `go test -race` | ✓ | CGO_ENABLED=1 | skip -race |
|
||||
| Docker | Postgres-backed serve/migrate tests (pending count) | client 29.9.1 present; daemon access not confirmed | — | `-short` tests; run Docker stage via `sudo -u golem` per CLAUDE.md |
|
||||
| Node/npm/pnpm | landing site tests (`../sm-summercmsio-app/vue-summercmsio-app`) | ✓ | node v22.23.3, npm 12.2.1, pnpm 12.14.0 | — |
|
||||
| SSU source | port reference | ✓ | `/media/nvme/dev/pxpx/ssu` @ 0061e3d (2026-09-11) | — |
|
||||
|
||||
No blocking gaps.
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| Framework | Go `testing` (+ `testing/slogtest`), golden files with an `-update` flag (pattern: `internal/build/new_golden_test.go:13`) |
|
||||
| Config file | none (go test) |
|
||||
| Quick run command | `go test -short ./modules/bonfire/... ./modules/diary/... ./modules/sunscreen/... ./internal/build/... ./internal/docsite/... ./cmd/summer/...` |
|
||||
| Full suite command | `go vet ./... && go test ./... && (cd examples/hello && go test ./...)` |
|
||||
| Phase gate | `scripts/check-phase19.8.sh --all` (new; pattern of `scripts/check-phase19.4.sh`: per-repo stages, required test names, fail on skip/zero tests, `NO_COLOR=1`, additive-export check for plugin repos) |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
| Ref | Behavior | Type | Automated command | Exists? |
|
||||
|---|---|---|---|---|
|
||||
| SC1 | diary console/daily handlers pass slogtest | unit | `go test ./modules/diary -run TestSlogtest` | ❌ Wave 0 |
|
||||
| SC1 | component floors, verbosity changes, `-q` on console only | unit | `go test ./modules/diary -run 'TestComponent|TestVerbosity'` | ❌ |
|
||||
| SC1 | daily rotation + retention with injected clock; unwritable dir degrades | unit | `go test ./modules/diary -run TestDaily` | ❌ |
|
||||
| SC1 | `SUMMER_LOG__…` env strings (`"30"`, `"console,daily"`) parse | unit | `go test ./modules/diary -run TestConfigEnv` | ❌ |
|
||||
| SC1 | every channel redacts (`sunscreen`), `InstallDefault` still works | unit | `go test ./modules/diary ./modules/sunscreen -run 'TestRedact|TestInstallDefault'` | partial (sunscreen ✓) |
|
||||
| SC1 | River/GORM/plain `slog.Default()` records route through diary with components | unit | `go test ./modules/conga ./modules/lagoon -short -run TestLogger` | ❌ |
|
||||
| SC2 | `PeekGlobals` table (stops at `--`, `-vvv`, `--env=x`) and cobra persistent flags agree | unit | `go test ./modules/bonfire -run 'TestPeekGlobals|TestGlobalFlags'` | ❌ |
|
||||
| SC2 | reserved shorthand/name collision returns an error (no panic) | unit | `go test ./modules/bonfire -run TestReservedFlags` | ❌ |
|
||||
| SC2 | `Call` does not trigger `OnGlobals` | unit | `go test ./modules/bonfire -run TestCallNoGlobalsHook` | ❌ |
|
||||
| SC3 | list golden (namespaces, hidden, `--all`), help golden (Description/Usage/Arguments/Options defaults/Examples), passthrough help unchanged | golden | `go test ./modules/bonfire -run 'TestListGolden|TestHelpGolden'` (`-update` to rewrite) | ❌ |
|
||||
| SC3 | `list --format=json` schema | unit | `go test ./modules/bonfire -run TestListJSON` | ❌ |
|
||||
| SC3 | coverage: framework commands documented (fails) | unit | `go test ./cmd/summer -run TestCommandsDocumented` | ❌ |
|
||||
| SC3 | duplicate names rejected with the name in the error | unit | `go test ./modules/bonfire -run TestDuplicateNames` | ❌ |
|
||||
| SC3 | docs checker accepts `bin/acme -v serve`, `summer list`, `bin/acme help migrate` | unit | `go test ./internal/docsite -run TestCommandWord` + `go test ./cmd/summer -run TestDocsTree` | partial |
|
||||
| D-18 | error block golden; exit 1 vs 2; `ErrReported` silent; hint `{bin}` substitution; `-v` chain | golden/unit | `go test ./modules/bonfire -run 'TestErrorGolden|TestExitCodes'` | ❌ |
|
||||
| D-03 | widget matrix: TTY vs non-TTY vs `-q` vs `--no-ansi` vs `-n` (existing non-TTY fallbacks unchanged) | unit | `go test ./modules/bonfire -run 'TestNonTTY|TestWidgetMatrix'` | partial (non-TTY ✓) |
|
||||
| D-03 | ported models: Update/View as pure functions (no terminal) | unit | `go test ./modules/bonfire -run TestLiveModels` | ❌ |
|
||||
| D-03 | piped Secret still reads stdin; `-n` Secret without default fails with hint | unit | `go test ./modules/bonfire ./modules/cabana -short -run 'TestSecret|TestAdminPasswordWithoutFlag'` | partial |
|
||||
| SC4 | serve summary lines (pure builder like `serveBanner`), `listening on` record kept | unit | `go test ./modules/surf -short -run TestServeSummary` | partial (`serve_test.go` checks banner) |
|
||||
| SC4 | shutdown report counters (surf in-flight, conga Stats) | unit | `go test ./modules/surf ./modules/conga -short -run 'TestShutdownReport|TestWorkerStats'` | ❌ |
|
||||
| SC4 | `lagoon.Pending` | integration (Docker) | `go test ./modules/lagoon -run TestPending` | ❌ |
|
||||
| SC4 | per-plugin warn-only coverage | unit | `go test ./...` in each plugin repo (`TestCommandsDocumented` logs) | ❌ |
|
||||
| SC5 | generated main + `summer new` goldens | golden | `go test ./internal/build -run Golden` | ✓ (update) |
|
||||
| SC5 | landing terminal commands still valid | unit | `cd ../sm-summercmsio-app && go test -run Terminal` | ✓ |
|
||||
|
||||
### Testing techniques
|
||||
- **TTY simulation:** `newConsole(in, out, err, lookup, tty, interactive *bool)` already injects TTY/interactive flags [VERIFIED: modules/bonfire/output.go:65-91]. Extend it with a forced `colorprofile.Profile` so goldens contain no ANSI (or a pinned `ANSI` profile for one styled golden).
|
||||
- **bubbletea:** test models by calling `Update`/`View` directly. For an end-to-end run use `tea.WithInput(nil)`, `tea.WithOutput(&buf)`, `tea.WithWindowSize(80, 24)`. Do not add `teatest` (another dependency).
|
||||
- **slog handlers:** `slogtest.TestHandler` (JSON parse of the output) for the console handler's non-TTY/JSON modes and the daily writer; the race detector for concurrent writes.
|
||||
- **Goldens:** `modules/bonfire/testdata/{list,help_*,error_*}.golden`, rewritten with `go test ./modules/bonfire -update`.
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** quick run command above.
|
||||
- **Per wave merge:** full suite + `summer docs:build --check`.
|
||||
- **Phase gate:** `scripts/check-phase19.8.sh --all` green before `/msd-verify-work`; manual UAT on a real terminal (spinner/progress/huh prompts, Ctrl+C report) is required because animations cannot be asserted fully.
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `modules/diary/*_test.go` (new module)
|
||||
- [ ] `modules/bonfire/testdata/*.golden` + `-update` flag in bonfire tests
|
||||
- [ ] `scripts/check-phase19.8.sh` (multi-repo gate with `--self-test`)
|
||||
- [ ] `TestCommandsDocumented` in `cmd/summer` and in each D-20 plugin repo (warn-only)
|
||||
|
||||
## Security Domain
|
||||
|
||||
| ASVS category | Applies | Standard control |
|
||||
|---|---|---|
|
||||
| V2 Authentication | no (no auth changes) | — |
|
||||
| V3 Session | no | — |
|
||||
| V4 Access Control | no | — |
|
||||
| V5 Input Validation | yes | `--env` value validated by `compass.sanitizeEnv` (letters, digits, `-`, `_`; rejects `..`, `/`, `\`) [VERIFIED: modules/compass/env.go:110-118]; diary config: whitelist drivers/formats, reject unknown levels |
|
||||
| V6 Cryptography | no | — |
|
||||
| V7 Error handling & logging | **yes** | sunscreen on every channel; no URL paths/query/headers in the request log; GORM SQL parameterized; `-v` error chains on stderr only; log files 0o640, dirs 0o750; never print secrets in hints/summaries (summary shows driver names, never DSNs or keys; centrifugo API key is already never printed) |
|
||||
| V12 Files | yes | Daily writer path confined under the app root (clean + prefix check like `compass.Persist` does for overrides); retention deletes only `<base>-YYYY-MM-DD<ext>` matches |
|
||||
|
||||
| Threat pattern | STRIDE | Mitigation |
|
||||
|---|---|---|
|
||||
| Tokens in logged paths (share/invite links) | Information disclosure | Keep `pattern`, not path (Pitfall 9) |
|
||||
| Secrets in SQL traces at `-vvv` | Information disclosure | `ParameterizedQueries: true`; sunscreen still applies |
|
||||
| DSN/keys in the startup summary or error chain | Information disclosure | Summary prints driver/host only; run error text through `sunscreen.Scrub` in the renderer |
|
||||
| Log path traversal via `log.channels.*.path` | Tampering | Clean and confine to the app root; refuse absolute paths outside it unless explicitly allowed |
|
||||
| Terminal escape injection via logged user data | Tampering | The console handler strips control characters from message/attr values (stdlib Text/JSON handlers already quote) |
|
||||
| Log disk exhaustion | DoS | Retention days; daily files only |
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- proxy.golang.org `@latest`/`.mod`/`@v/list` for charm.land/{lipgloss,bubbletea,bubbles,huh,fang,log}/v2 and github.com/charmbracelet/* v1; charm.land go-import meta tags
|
||||
- Module source read this session (scratch module cache): fang v2.0.1 `fang.go`, `help.go`, `theme.go`; bubbletea v2.1.0 `tea.go`, `options.go`, `UPGRADE_GUIDE_V2.md`; bubbles v2.2.1 `progress`, `spinner`, `UPGRADE_GUIDE_V2.md`; huh v2.0.3 `form.go`, `field_input.go`; lipgloss v2.0.6 `query.go`, `table`, `UPGRADE_GUIDE_V2.md`; charm log v2.0.1 `logger_121.go`
|
||||
- cobra v1.10.2 `command.go`; pflag v1.0.9 `flag.go`; gorm v1.31.2 `logger/logger.go`, `logger/slog.go`; Go stdlib `log/slog/multi_handler.go`
|
||||
- Repo files read: modules/bonfire/*, modules/sunscreen/sunscreen.go, modules/compass/{config,env}.go + README, internal/build/build.go + stubs + goldens, internal/dev/watch.go, internal/docsite/check_commands.go, cmd/summer/{main,runtime,docs,parity}.go + docs_test.go, modules/{lagoon,conga,surf,cabana,flare,lighthouse/centrifugo} command files, surf/accesslog.go, conga/{client,worker}.go, lagoon/{connection,migrations}.go
|
||||
- SSU `/media/nvme/dev/pxpx/ssu`: go.mod, internal/cli/tui/*.go, internal/cli/root.go, internal/logging/*.go
|
||||
- Probes (scratchpad): slogtest vs charm log; cobra global flags; BuildInfo replace/go.work; binary size
|
||||
- go.dev/doc/go1.24 (VCS version stamping)
|
||||
|
||||
### Secondary (MEDIUM)
|
||||
- `../.depreciated/modules/summer-bonfire/PLAN.md` (Scala design, non-TTY table)
|
||||
- Sibling repos: plugin console files, app `.gitmodules`/go.mod/go.work, DEPLOY.md, smoke scripts, sm-demo-app README roadmap deck
|
||||
|
||||
### Tertiary (LOW)
|
||||
- None used for recommendations.
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH (versions, go directives, dependency graph and APIs read from source; size probed)
|
||||
- Architecture: HIGH for the flag/help/fang mechanics (source + probes); MEDIUM for level-mapping policy (A4)
|
||||
- Pitfalls: HIGH (each tied to a file/line or a probe)
|
||||
- Inventory: HIGH (grep + read of every command file across 14 repos)
|
||||
|
||||
**Research date:** 2026-10-21
|
||||
**Valid until:** 2026-11-20 (charm v2 is moving; re-check `@latest` before `go get`)
|
||||
@@ -0,0 +1,178 @@
|
||||
---
|
||||
phase: 19.8
|
||||
review: 19.8-REVIEW.md
|
||||
titles: json
|
||||
findings:
|
||||
- id: WR-09
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "Lines printed through a live view after its context is cancelled are silently dropped"
|
||||
- id: WR-10
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "A delegate killed by a signal prints its full argv, including a --password value, and exits 1"
|
||||
- id: WR-11
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "The wrong-directory hint leaves secret values that start with \"-\" unmasked"
|
||||
- id: WR-12
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "migrate:status --json still drops the framework sets that migrate applies"
|
||||
- id: WR-13
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "migrate reports failure after a successful Migrate when the follow-up read fails"
|
||||
- id: WR-14
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "Process's plain path prints item names without Sanitize"
|
||||
- id: IN-10
|
||||
severity: info
|
||||
disposition: deferred
|
||||
title: "console.Error during a live view writes straight to the terminal and tears the view"
|
||||
- id: IN-11
|
||||
severity: info
|
||||
disposition: deferred
|
||||
title: "A blank line printed during a live view is dropped"
|
||||
- id: IN-12
|
||||
severity: info
|
||||
disposition: deferred
|
||||
title: "Signal relay edge cases: a signal during view start-up, and Windows"
|
||||
- id: IN-13
|
||||
severity: info
|
||||
disposition: deferred
|
||||
title: "A delegate's stderr always goes to os.Stderr, bypassing the root's error writer"
|
||||
- id: IN-14
|
||||
severity: info
|
||||
disposition: fixed
|
||||
title: "The ErrReported doc comment still says the exit code is 1"
|
||||
- id: IN-15
|
||||
severity: info
|
||||
disposition: fixed
|
||||
title: "runLive builds the bubbletea program before it knows it may run"
|
||||
- id: CR-01
|
||||
severity: critical
|
||||
disposition: fixed
|
||||
title: "Lines printed above a live view are reordered and can be lost"
|
||||
- id: CR-02
|
||||
severity: critical
|
||||
disposition: fixed
|
||||
title: "summer delegates print a second error block and turn exit code 2 into 1"
|
||||
- id: CR-03
|
||||
severity: critical
|
||||
disposition: fixed
|
||||
title: "summer delegates hide the terminal from the app and merge its stderr into stdout"
|
||||
- id: WR-01
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "Ctrl+C during a spinner, progress bar or process view leaves the cursor hidden"
|
||||
- id: WR-02
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "Any console's Println and Printf are sent to whichever live view is running"
|
||||
- id: WR-03
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "summer dev kills serve 2 seconds after SIGTERM, but serve drains for up to 10"
|
||||
- id: WR-04
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "One failed open switches a daily log file off until the process exits"
|
||||
- id: WR-05
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "Error blocks, process views and tables write untrusted text to the terminal without stripping escape sequences"
|
||||
- id: WR-06
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "Setup and ApplyGlobals close daily files that other goroutines may still be writing through"
|
||||
- id: WR-07
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "PendingMigrations keeps its own copy of Migrate's migration sets"
|
||||
- id: WR-08
|
||||
severity: warning
|
||||
disposition: fixed
|
||||
title: "The wrong-directory hint repeats every argument, including a --password value, on stderr"
|
||||
- id: IN-01
|
||||
severity: info
|
||||
disposition: deferred
|
||||
title: "A forced-color console drops HideOnTTY records even when stderr is not a terminal"
|
||||
- id: IN-02
|
||||
severity: info
|
||||
disposition: deferred
|
||||
title: "PeekGlobals and cobra disagree on some argument forms"
|
||||
- id: IN-03
|
||||
severity: info
|
||||
disposition: deferred
|
||||
title: "isUsage recognises cobra's unknown-command error by its text"
|
||||
- id: IN-04
|
||||
severity: info
|
||||
disposition: deferred
|
||||
title: "The --json flag is read two different ways"
|
||||
- id: IN-05
|
||||
severity: info
|
||||
disposition: deferred
|
||||
title: "summer's scaffolding result lines are now hidden by -q"
|
||||
- id: IN-06
|
||||
severity: info
|
||||
disposition: deferred
|
||||
title: "fang replaces the root help function that NewRootIO installs"
|
||||
- id: IN-07
|
||||
severity: info
|
||||
disposition: deferred
|
||||
title: "diary pulls the whole TUI stack into security modules"
|
||||
- id: IN-08
|
||||
severity: info
|
||||
disposition: deferred
|
||||
title: "The console sanitizer passes raw C1 bytes through on its fast path"
|
||||
- id: IN-09
|
||||
severity: info
|
||||
disposition: deferred
|
||||
title: "The phase gate names consuming applications"
|
||||
open: 0
|
||||
total: 32
|
||||
recorded: 2026-10-22T01:19:33.337Z
|
||||
---
|
||||
|
||||
# Phase 19.8: Code Review Disposition
|
||||
|
||||
| Finding | Severity | Disposition | Source |
|
||||
|---------|----------|-------------|--------|
|
||||
| WR-09 | warning | fixed | fix fce232d (quick 261022-4z8) |
|
||||
| WR-10 | warning | fixed | fix 17c722d (quick 261022-4z8); the exit-130 suggestion is deferred to todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
|
||||
| WR-11 | warning | fixed | fix 707372b (quick 261022-4z8) |
|
||||
| WR-12 | warning | fixed | fix 57a849d, reworked in 139f5e3 so that lagoon.Status stays plugin-only and migrate:status adds the framework sets itself (quick 261022-4z8) |
|
||||
| WR-13 | warning | fixed | fix 0691d26 (quick 261022-4z8) |
|
||||
| WR-14 | warning | fixed | fix 48e13f9 (quick 261022-4z8) |
|
||||
| IN-10 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
|
||||
| IN-11 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
|
||||
| IN-12 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
|
||||
| IN-13 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
|
||||
| IN-14 | info | fixed | docs 7c15095 (quick 261022-4z8) |
|
||||
| IN-15 | info | fixed | fix 1ac75ad (quick 261022-4z8) |
|
||||
| CR-01 | critical | fixed | fix b077dc3 (19.8-05) (not in the current review) |
|
||||
| CR-02 | critical | fixed | fix 323413c (19.8-05) (not in the current review) |
|
||||
| CR-03 | critical | fixed | fix 9c89705 (19.8-05) (not in the current review) |
|
||||
| WR-01 | warning | fixed | fix d151a25 (19.8-05) (not in the current review) |
|
||||
| WR-02 | warning | fixed | fix 86e4e27 (19.8-05) (not in the current review) |
|
||||
| WR-03 | warning | fixed | fix fc7706d (19.8-05) (not in the current review) |
|
||||
| WR-04 | warning | fixed | fix c586c6f (19.8-05) (not in the current review) |
|
||||
| WR-05 | warning | fixed | fix a9025c0 (19.8-05) (not in the current review); remainder WR-14 (fix 48e13f9) |
|
||||
| WR-06 | warning | fixed | fix 39677f1 (19.8-05) (not in the current review) |
|
||||
| WR-07 | warning | fixed | fix 020fe83 (19.8-05) (not in the current review); remainder WR-12 (fix 57a849d) |
|
||||
| WR-08 | warning | fixed | fix 9b62285 (19.8-05) (not in the current review); remainder WR-11 (fix 707372b) |
|
||||
| IN-01 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-02 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-03 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-04 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-05 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-06 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-07 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-08 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
| IN-09 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
|
||||
|
||||
Dispositions: `open` (recorded, not yet triaged), `fixed`, `skipped`, `deferred`.
|
||||
Set `deferred` by hand and put the reason in the Source cell; both are preserved. A `|` in the reason is kept as prose and escaped on the next run.
|
||||
Re-running the gate keeps every row it can. A row the current review no longer reports is kept and its Source cell flagged, so a finding does not leave this record silently. ONE exception: when a finding id is REUSED by a different finding, the earlier decision cannot keep a row — the id is taken — and it is dropped. A RECORDED decision (anything but `open`) is named on the console when that happens; a row still at `open` is replaced silently, because `open` records no decision to lose.
|
||||
253
.planning/phases/19.8-console-commands-overhaul/19.8-REVIEW.md
Normal file
253
.planning/phases/19.8-console-commands-overhaul/19.8-REVIEW.md
Normal file
@@ -0,0 +1,253 @@
|
||||
---
|
||||
phase: 19.8-console-commands-overhaul
|
||||
reviewed: 2026-10-22T01:17:37Z
|
||||
depth: standard
|
||||
diff_range: dd12382..0ebed68 (gap-closure plan 19.8-05, released as v0.4.2)
|
||||
files_reviewed: 31
|
||||
files_reviewed_list:
|
||||
- cmd/summer/runtime.go
|
||||
- cmd/summer/runtime_test.go
|
||||
- docs/console/introduction.md
|
||||
- docs/console/scaffolding.md
|
||||
- docs/console/writing-commands.md
|
||||
- docs/services/logging.md
|
||||
- docs/setup/upgrading.md
|
||||
- internal/dev/watch.go
|
||||
- internal/dev/watch_test.go
|
||||
- modules/bonfire/errors.go
|
||||
- modules/bonfire/errors_test.go
|
||||
- modules/bonfire/execute.go
|
||||
- modules/bonfire/helpers.go
|
||||
- modules/bonfire/live.go
|
||||
- modules/bonfire/live_signal_unix_test.go
|
||||
- modules/bonfire/live_test.go
|
||||
- modules/bonfire/output.go
|
||||
- modules/bonfire/README.md
|
||||
- modules/bonfire/widgets.go
|
||||
- modules/bonfire/widgets_test.go
|
||||
- modules/diary/console.go
|
||||
- modules/diary/daily.go
|
||||
- modules/diary/daily_test.go
|
||||
- modules/diary/diary.go
|
||||
- modules/diary/README.md
|
||||
- modules/diary/retire_internal_test.go
|
||||
- modules/lagoon/commands.go
|
||||
- modules/lagoon/migrations.go
|
||||
- modules/lagoon/pending_test.go
|
||||
- modules/lagoon/README.md
|
||||
- scripts/check-phase19.8.sh
|
||||
findings:
|
||||
critical: 0
|
||||
warning: 6
|
||||
info: 6
|
||||
total: 12
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# Phase 19.8: Code Review Report (re-review after 19.8-05)
|
||||
|
||||
**Reviewed:** 2026-10-22T01:17:37Z
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 31
|
||||
**Status:** issues_found
|
||||
|
||||
## Summary
|
||||
|
||||
This is an incremental re-review of the gap-closure commits `dd12382..HEAD` (b077dc3 to 020fe83, plus the gate and docs commits). I read every listed file in full where it changed. I checked the fixes against the bubbletea v2.1.0 sources (`Program.Send`, `Kill`, `shutdown`, `eventLoop`, `cursedRenderer.insertAbove`) and against pflag v1.0.10. I also ran two scratch programs under a pseudo-terminal (`script -qec`).
|
||||
|
||||
Verification:
|
||||
|
||||
- `go vet` is clean on the touched packages.
|
||||
- `go test -short -race` passes for bonfire and diary.
|
||||
- The new summer and dev tests (`TestDelegate*`, `TestAppStreams`, `TestDevStopWaitsForGracefulShutdown`) pass.
|
||||
|
||||
All three critical findings from the first review are fixed. So are WR-01 to WR-06. WR-05, WR-07 and WR-08 are fixed as described, but each leaves a gap of the same kind elsewhere, so they are only partly closed (WR-12, WR-14, WR-10 and WR-11 below).
|
||||
|
||||
The fixes introduced no new critical defect. The main new problems are:
|
||||
|
||||
- **Lost lines after cancellation.** A live view whose context is cancelled stays registered, so lines printed through it are dropped while `printAbove` reports success. I confirmed this on a pty.
|
||||
- **Signal-killed delegates.** Since the CR-02 fix, only a signal-killed delegate gets summer's error block. That block repeats the full argv, including a `--password` value, and the exit code is 1.
|
||||
- **Migration accounting.** `migrate:status --json` still uses its own plugin-only list. `migrate` reports a failure when only the read after a successful `Migrate` fails.
|
||||
|
||||
New finding IDs continue after the first review's IDs (WR-09 and up, IN-10 and up). `19.8-REVIEW-DISPOSITION.md` drops a recorded decision when its ID is reused for a different finding, so the old IDs are not reused here.
|
||||
|
||||
## Prior Findings: Verified State
|
||||
|
||||
| ID | Verified state | Evidence |
|
||||
|----|----------------|----------|
|
||||
| CR-01 | **Fixed** | `printAbove` sends one `printLineMessage` per call straight into the program's unbuffered queue while holding `liveMu` (live.go:477-487). The event loop handles it inline (`insertAbove`, bubbletea tea.go:888), so lines keep their order. `finishMsg` is sent under the same lock only after every earlier send has been received (live.go:387-393), so no line is lost when the view quits. On a pty the lines came out in order. `TestPrintAboveOrderedNoLoss` covers spinner, progress, process and a concurrent finish. A remaining gap after context cancellation is WR-09. |
|
||||
| CR-02 | **Fixed** | `runAppCmd` wraps an exited child as `Reported(WithExitCode(err, code))` (runtime.go:170-176), and `exitCode` honours the code (execute.go:76-85). `TestDelegateExitCodePassthrough` checks that the code is 2 and that summer prints no block of its own. The signal-killed branch has new problems, see WR-10. |
|
||||
| CR-03 | **Fixed** | `appStreams` gives the child `os.Stdout`/`os.Stderr` on a terminal and `out`/`os.Stderr` otherwise (runtime.go:155-160). The child's stderr no longer lands in summer's stdout, and on a terminal the child sees it and asks the production question. A minor gap is IN-13. |
|
||||
| WR-01 | **Fixed** | `watchSignals` relays SIGINT/SIGTERM, calls `prog.Kill()` (which restores the cursor) and re-raises the signal (live.go:410-466). `TestLiveViewRestoresTerminalOnSignal` covers both the default action and the command's own handler. Edge cases are in IN-12. |
|
||||
| WR-02 | **Fixed** | `activeView` records the owner, and `printAbove(c, …)` reroutes only the owner's output (live.go:477-481). The slot is claimed with check-and-set under `liveMu` (live.go:362-368), and cleanup is deferred, so it also runs on a panic (live.go:378-395). |
|
||||
| WR-03 | **Fixed** | `child.stop` waits `dev.ShutdownGrace` (15s), and summer's delegates use the same constant (watch.go:22-27, 243; runtime.go:185). |
|
||||
| WR-04 | **Fixed** | A failed open is retried after `dailyRetry` (1 minute) or on the next date, with one warning per date (daily.go:70-99). The test covers both paths. |
|
||||
| WR-05 | **Partly fixed** | Error blocks (every line, the Try lines and the causes), process-view names and errors, spinner and progress current items, failure lines and table cells now go through `Sanitize`. `Process`'s plain path still prints item names raw (WR-14). The deferred IN-08 (a raw C1 byte passes `Sanitize`'s fast path) now applies to every one of these sinks, not only diary. |
|
||||
| WR-06 | **Fixed** | Replaced writers are retired: a late record opens the file, appends and closes it again (daily.go:100-104, 166-174). `ApplyGlobals` holds `mu` for its whole run (diary.go:228-240). There is no reentrancy on `mu` from the handler path. |
|
||||
| WR-07 | **Partly fixed** | `Migrate` and `PendingMigrations` now share `migrationSets`, and the applied lines are computed from the pending list before and after the run. `Status` (used by `migrate:status`, and by its `--json` output) still walks only `plugins` (WR-12). A new error path is in WR-13. |
|
||||
| WR-08 | **Partly fixed** | `tryArgs` masks `--flag=value` and `--flag value` (errors.go:310-331). A value that starts with `-` is not masked, although pflag accepts it (WR-11). The same argv now leaks through the delegate's signal error (WR-10). |
|
||||
| IN-01..IN-09 | Deferred (todo `2026-10-22-console-review-info-findings.md`) | Not re-reported. IN-08 is now wider, as noted under WR-05. |
|
||||
|
||||
## Narrative Findings (AI reviewer)
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-09: Lines printed through a live view after its context is cancelled are silently dropped
|
||||
|
||||
**File:** `modules/bonfire/live.go:354-360, 477-487` (with `modules/bonfire/helpers.go:150-170`)
|
||||
**Issue:** `runLive` passes the command's context to bubbletea (`tea.WithContext(ctx)`). `Process` passes the caller's `ctx`. When that context is cancelled, the program shuts down: `Program.Send` now returns at once because `p.ctx` is done (bubbletea tea.go:1211-1216). However, `activeView` still points at the view until `work` returns. Only the deferred cleanup or the signal relay clears it, and the relay runs only for SIGINT/SIGTERM. Meanwhile:
|
||||
|
||||
- `printAbove` hands the line to a dead program and still returns `true`, so the caller never falls back to a plain write.
|
||||
- This applies to `out.Println`/`Printf` data, to Success/Info/Warning lines on stdout, and to every diary console record routed through `bonfire.PrintAbove`.
|
||||
- These lines are lost for as long as the current `fn(ctx, item)` keeps running after the cancellation.
|
||||
- Because the program was killed, the final frame and the failed rows are never rendered either.
|
||||
|
||||
**Runtime check:** a `Process` whose first item calls `cancel()`, waits 300 ms and then calls `out.Println("after-cancel-line")` and `bonfire.PrintAbove(...)` printed neither line in 3 of 3 pty runs. `PrintAbove` returned `true`. `out.Error`, which goes to stderr and so bypasses `printAbove`, did print.
|
||||
|
||||
This is the CR-01 failure mode (a line reported as printed but never shown) on a new path. It hits any `Process` caller that uses a deadline, a parent cancel or a non-signal shutdown.
|
||||
**Fix:** Have `printAbove` refuse a program that has stopped, and unregister the view when the program ends on its own:
|
||||
```go
|
||||
type liveView struct {
|
||||
owner *console
|
||||
prog *tea.Program
|
||||
done <-chan struct{} // the `finished` channel of runLive
|
||||
}
|
||||
|
||||
func printAbove(owner *console, s string) bool {
|
||||
liveMu.Lock()
|
||||
defer liveMu.Unlock()
|
||||
v := activeView
|
||||
if v == nil || (owner != nil && v.owner != owner) {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case <-v.done: // program gone (ctx cancelled, Run failed): print plainly
|
||||
activeView = nil
|
||||
return false
|
||||
default:
|
||||
}
|
||||
v.prog.Send(printLine(strings.TrimSuffix(s, "\n")))
|
||||
return true
|
||||
}
|
||||
```
|
||||
`Send` can still race with a cancellation that lands between the check and the send. To close that window, also watch `ctx.Done()` in a goroutine that clears `activeView` under `liveMu`. Add a pty or buffer test that cancels the context mid-item and asserts that later lines appear.
|
||||
|
||||
### WR-10: A delegate killed by a signal prints its full argv, including a --password value, and exits 1
|
||||
|
||||
**File:** `cmd/summer/runtime.go:170-178`
|
||||
**Issue:** After the CR-02 fix, every child failure that is not a normal exit returns `fmt.Errorf("summer: %s: %w", strings.Join(argv, " "), err)` unmasked. This covers death by a signal (`exit.Exited()` false) and every `cmd.Start` error. `RenderError` passes the message only through `sunscreen.Scrub`, which recognises Bearer, `sk-` and `x-api-key` shapes, so `summer admin:create --email a@b.c --password hunter2` prints `ERROR summer: admin:create --email a@b.c --password hunter2: signal: interrupt` on stderr. It happens when the user presses Ctrl+C, when a CI timeout sends SIGKILL, or on an OOM kill.
|
||||
|
||||
This is the leak WR-08 fixed for the wrong-directory hint, on a path that the CR-02 fix now makes the only one that prints summer's own block.
|
||||
|
||||
In addition, 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 (any delegate without its own handler, or one interrupted during a live view) now produces a spurious ERROR block and exit 1. Shells and CI expect 130 (128+SIGINT). The docs ("A binary killed by a signal gets an error block from summer and exit code 1") describe this behaviour but do not justify it.
|
||||
**Fix:** Mask the argv with the same helper the hint uses (export it, for example `bonfire.MaskArgs`), or name only the command word. Map signal deaths to 128+n, and treat SIGINT/SIGTERM as already reported:
|
||||
```go
|
||||
var exit *exec.ExitError
|
||||
if errors.As(err, &exit) {
|
||||
if ws, ok := exit.Sys().(syscall.WaitStatus); ok && ws.Signaled() {
|
||||
code := 128 + int(ws.Signal())
|
||||
if ws.Signal() == syscall.SIGINT || ws.Signal() == syscall.SIGTERM {
|
||||
return bonfire.Reported(bonfire.WithExitCode(err, code))
|
||||
}
|
||||
return bonfire.WithExitCode(fmt.Errorf("summer: %s: %w", argv[0], err), code)
|
||||
}
|
||||
…
|
||||
}
|
||||
```
|
||||
|
||||
### WR-11: The wrong-directory hint leaves secret values that start with "-" unmasked
|
||||
|
||||
**File:** `modules/bonfire/errors.go:325-329`
|
||||
**Issue:** `tryArgs` masks the next argument after `--password` only when it does not start with `-`. pflag takes the next argument as the value of a non-boolean flag whatever its first character is (pflag v1.0.10 flag.go:1020-1023, the `'--flag arg'` branch). So `bin/acme admin:create --email a@b.c --password -Xk9!q` runs with the password `-Xk9!q`, and the Try line then prints it in clear text. A dash is a common first character for generated passwords. The docs (writing-commands.md) promise that such values are masked.
|
||||
**Fix:** Mask the next argument whenever the flag takes a value. A secret-named long flag in `--name value` form always consumes the next argument, unless that argument is `--` or the flag is known to be boolean:
|
||||
```go
|
||||
if i+1 < len(args) && args[i+1] != "--" {
|
||||
b.WriteString(" ***")
|
||||
i++
|
||||
}
|
||||
```
|
||||
Add a test case with a value that starts with a dash.
|
||||
|
||||
### WR-12: migrate:status --json still drops the framework sets that migrate applies
|
||||
|
||||
**File:** `modules/lagoon/migrations.go:141-160`, `modules/lagoon/commands.go:270-289`
|
||||
**Issue:** WR-07's point was that the commands which report on migrations must read the list `Migrate` applies. `Status` still walks only `plugins`. `migrate:status --json` then emits pending refs only when `ref.Plugin == row.Plugin`. The pending migrations of `summercms.attach`, `summercms.cabana`, the deferred-bindings set and the queue set (all returned by `PendingMigrations`) are therefore silently omitted, both from the JSON (whose example says "pending migrations included") and from the table.
|
||||
|
||||
After a framework upgrade that adds a River queue migration, `migrate --pretend` lists it but `migrate:status --json` reports nothing pending. A deploy gate that checks for `"applied": false` passes when it should not.
|
||||
**Fix:** Build `Status` from `migrationSets(gdb, plugins)` (skipping or including empty plugin sets consistently), so all three commands read one list. Extend `TestMigrateStatusJSON` to assert that a framework set's pending migration appears.
|
||||
|
||||
### WR-13: migrate reports failure after a successful Migrate when the follow-up read fails
|
||||
|
||||
**File:** `modules/lagoon/commands.go:173-180`
|
||||
**Issue:** After `Migrate` returns, the command calls `PendingMigrations` again. If that read fails (a dropped connection, a statement timeout, a permission problem on a history table), it returns `errors.Join(migrateErr, err)`, which is non-nil even when `migrateErr` is nil. A migration that was fully applied then:
|
||||
|
||||
- prints no applied lines and no `Applied N migrations`;
|
||||
- exits 1.
|
||||
|
||||
A deploy pipeline will treat it as failed and may roll back or alert, even though the schema change is in place.
|
||||
**Fix:** When `migrateErr == nil`, a failed follow-up read must not turn the run into a failure. Fall back to reporting `pending` as applied, with a warning:
|
||||
```go
|
||||
after, err := PendingMigrations(gdb, plugins)
|
||||
applied := pending
|
||||
if err == nil {
|
||||
applied = appliedSince(pending, after)
|
||||
} else if migrateErr == nil {
|
||||
out.Warning(fmt.Sprintf("Could not re-read the migration history: %v", err))
|
||||
} else {
|
||||
return errors.Join(migrateErr, err)
|
||||
}
|
||||
```
|
||||
|
||||
### WR-14: Process's plain path prints item names without Sanitize
|
||||
|
||||
**File:** `modules/bonfire/helpers.go:183` (also `modules/bonfire/live.go:313-317`, the `operation` label)
|
||||
**Issue:** The WR-05 fix sanitises item names in the live process view (live.go:292) and in failure lines (`failureText`). The plain path, however, prints a successful item with `out.Success(item)`, and `status` writes it unchanged. The plain path runs whenever stdout is a terminal but the view does not animate: with `-n`, inside another live view (now the normal fallback when `runLive` reports `started == false`), or with a forced color profile. In those cases an item name that holds an escape sequence (a remote file name, an admin-typed title, a post slug) reaches the terminal unchanged. The same item is cleaned on one path and raw on the other.
|
||||
**Fix:** `out.Success(Sanitize(item))` in `Process`, or sanitise inside `console.status` and `console.Error` so every status line is covered regardless of caller. Add a plain-path case to `TestProcessViewStripsControl`.
|
||||
|
||||
## Info
|
||||
|
||||
### IN-10: console.Error during a live view writes straight to the terminal and tears the view
|
||||
|
||||
**File:** `modules/bonfire/output.go:232-238`
|
||||
**Issue:** `Error` writes to `c.err`, and `emit` reroutes only writes to `c.out`. When stderr and stdout are the same terminal (the usual case), an `out.Error(...)` from inside `Spinner`, `Progress` or `Process` lands mid-frame. A pty run showed the spinner glyph left on the error line (`⠹ ✗ stderr-error-line`), with the renderer continuing on the next row. diary's console records on stderr do go above the view through `PrintAbove`, so the two stderr paths behave differently.
|
||||
**Fix:** In `emit`, also reroute `w == c.err` when the console owns the running view and `c.err` is a terminal (`printAbove(c, s)`).
|
||||
|
||||
### IN-11: A blank line printed during a live view is dropped
|
||||
|
||||
**File:** `modules/bonfire/live.go:485`
|
||||
**Issue:** `out.Println()` gives `"\n"`. `TrimSuffix` turns that into `""`, and bubbletea's `insertAbove` returns early for an empty string (cursed_renderer.go:776-778). `printAbove` still reports `true`, so the blank separator line is lost. It is printed when no view runs.
|
||||
**Fix:** Send `" "` (or `"\u200b"`) for an empty block, or skip the trim when the block is only a newline.
|
||||
|
||||
### IN-12: Signal relay edge cases: a signal during view start-up, and Windows
|
||||
|
||||
**File:** `modules/bonfire/live.go:370-375, 427-436, 454-466`
|
||||
**Issue:** This comes from reading bubbletea v2.1.0; I did not reproduce it.
|
||||
|
||||
1. **Start-up race.** `prog.Run` starts on its own goroutine just before the relay is registered. If a signal arrives before `Run` has created and started its renderer, `Kill` runs `shutdown(true)` while `p.renderer` is still nil. `shutdownOnce` then makes `Run`'s own `shutdown(killed)` a no-op after it has started the renderer and hidden the cursor, so the cursor stays hidden.
|
||||
2. **Windows.** On Windows, `p.Signal(os.Interrupt)` fails, so `raise` calls `os.Exit(130)`. That skips the command's own handler (for example, `summer dev` stopping its serve child) and its deferred cleanup.
|
||||
|
||||
**Fix:** Before killing, wait until the program reports it has started (for example, have the wrapped model's `Init` close a `ready` channel). On platforms where `raise` cannot deliver the signal, return without exiting when another handler is registered.
|
||||
|
||||
### IN-13: A delegate's stderr always goes to os.Stderr, bypassing the root's error writer
|
||||
|
||||
**File:** `cmd/summer/runtime.go:155-160`
|
||||
**Issue:** Off a terminal, the child's stdout goes through `out`, but its stderr goes to the real `os.Stderr`, not to the error stream of the root that ran the command. For a summer root built with `NewRootIO(…, errW)` (tests, embedding), the app binary's error block cannot be captured. `TestDelegateExitCodePassthrough` therefore cannot check that the binary's own block was printed.
|
||||
**Fix:** Pass the root's error writer through (for example, a `bonfire.ErrWriter(out)` accessor), defaulting to `os.Stderr`.
|
||||
|
||||
### IN-14: The ErrReported doc comment still says the exit code is 1
|
||||
|
||||
**File:** `modules/bonfire/errors.go:73-75`
|
||||
**Issue:** The comment says "the error block prints nothing and the exit code is 1". With `WithExitCode`, a Reported error can now exit with any code from 1 to 255 (the README was updated, the comment was not).
|
||||
**Fix:** "… and the exit code is 1, or the code of WithExitCode."
|
||||
|
||||
### IN-15: runLive builds the bubbletea program before it knows it may run
|
||||
|
||||
**File:** `modules/bonfire/live.go:354-366`
|
||||
**Issue:** `tea.NewProgram` runs before the slot check. It calls `context.WithCancel(ctx)`, and with `TEA_TRACE` set it opens a trace file. When the slot is taken (`started == false`, now the normal fallback for nested widgets), that cancel is never called. With `Process`'s cancellable `ctx`, the child context stays registered on the parent until the parent ends, and the trace file descriptor leaks. A `Process` nested in a loop accumulates both.
|
||||
**Fix:** Claim the slot first and construct the program only after `activeView` is set (or call `prog.Kill()` on the losing path).
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-10-22T01:17:37Z_
|
||||
_Reviewer: Claude (msd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
44
.planning/phases/19.8-console-commands-overhaul/19.8-UAT.md
Normal file
44
.planning/phases/19.8-console-commands-overhaul/19.8-UAT.md
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
status: testing
|
||||
phase: 19.8-console-commands-overhaul
|
||||
source: [19.8-VERIFICATION.md]
|
||||
started: 2026-10-22T02:41:10.440Z
|
||||
updated: 2026-10-22T02:41:10.440Z
|
||||
---
|
||||
|
||||
## Current Test
|
||||
|
||||
number: 1
|
||||
name: On a real terminal, run 'summer', 'bin/<app>', 'bin/<app> help admin:token:create' and 'bin/<app> bogus'
|
||||
expected: |
|
||||
Colors, alignment and sections match the approved D-13 list, D-14 help and D-18 error previews; --no-ansi removes every escape code
|
||||
awaiting: user response
|
||||
|
||||
## Tests
|
||||
|
||||
### 1. On a real terminal, run 'summer', 'bin/<app>', 'bin/<app> help admin:token:create' and 'bin/<app> bogus'
|
||||
expected: Colors, alignment and sections match the approved D-13 list, D-14 help and D-18 error previews; --no-ansi removes every escape code
|
||||
result: [pending]
|
||||
|
||||
### 2. On a real terminal, run a command that uses Spinner, Progress, Process, Choice, Ask, Confirm and Secret (or the throwaway pty program from plan 02); press Ctrl+C during a spinner
|
||||
expected: SSU-like animation, arrow-key selector with confirmation screen, huh prompts; diary log lines print above the view; after Ctrl+C the cursor is visible and the shell is usable
|
||||
result: [pending]
|
||||
|
||||
### 3. Run 'bin/<app> serve' against a real database on a terminal, then with -q, then with -v, then press Ctrl+C; run 'summer dev' and stop it
|
||||
expected: A compact startup block (version, env, URL, DB and migration state, plugins, queue, scheduler, mail, cache, realtime, search), then INFO lines with component prefixes. -q shows only warnings. -v adds per-plugin lines. Ctrl+C prints the shutdown report and exits 0
|
||||
result: [pending]
|
||||
|
||||
### 4. Resolve the judgment-tier prohibitions listed under 'Prohibitions' below (the LLM-judge verdicts there are non-authoritative)
|
||||
expected: Each prohibition is confirmed, or rejected with a reason. The plan 03 'never print a secret' prohibition is no longer flagged as violated: both known leaks (WR-10, WR-11) are fixed and reproduced as fixed with built binaries
|
||||
result: [pending]
|
||||
|
||||
## Summary
|
||||
|
||||
total: 4
|
||||
passed: 0
|
||||
issues: 0
|
||||
pending: 4
|
||||
skipped: 0
|
||||
blocked: 0
|
||||
|
||||
## Gaps
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
phase: "19.8"
|
||||
slug: "console-commands-overhaul"
|
||||
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
|
||||
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
created: "2026-10-21"
|
||||
---
|
||||
|
||||
# Phase 19.8 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | {pytest 7.x / jest 29.x / vitest / go test / other} |
|
||||
| **Config file** | {path or "none — Wave 0 installs"} |
|
||||
| **Quick run command** | `{quick command}` |
|
||||
| **Full suite command** | `{full command}` |
|
||||
| **Estimated runtime** | ~19.8 seconds |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** Run `{quick run command}`
|
||||
- **After every plan wave:** Run `{full suite command}`
|
||||
- **Before `/msd-verify-work`:** Full suite must be green
|
||||
- **Max feedback latency:** 19.8 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||
| 19.8-01-01 | 01 | 1 | REQ-{XX} | T-19.8-01 / — | {expected secure behavior or "N/A"} | unit | `{command}` | ✅ / ❌ W0 | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `{tests/test_file.py}` — stubs for REQ-{XX}
|
||||
- [ ] `{tests/conftest.py}` — shared fixtures
|
||||
- [ ] `{framework install}` — if no framework detected
|
||||
|
||||
*If none: "Existing infrastructure covers all phase requirements."*
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| {behavior} | REQ-{XX} | {reason} | {steps} |
|
||||
|
||||
*If none: "All phase behaviors have automated verification."*
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 19.8s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** {pending / approved YYYY-MM-DD}
|
||||
@@ -0,0 +1,482 @@
|
||||
---
|
||||
phase: 19.8-console-commands-overhaul
|
||||
verified: 2026-10-22T02:37:57Z
|
||||
status: human_needed
|
||||
score: 57/57 must-haves verified
|
||||
covered_files:
|
||||
- ".gitignore"
|
||||
- ".planning/phases/19.8-console-commands-overhaul/19.8-01-PLAN.md"
|
||||
- ".planning/phases/19.8-console-commands-overhaul/19.8-01-SUMMARY.md"
|
||||
- ".planning/phases/19.8-console-commands-overhaul/19.8-02-PLAN.md"
|
||||
- ".planning/phases/19.8-console-commands-overhaul/19.8-02-SUMMARY.md"
|
||||
- ".planning/phases/19.8-console-commands-overhaul/19.8-03-PLAN.md"
|
||||
- ".planning/phases/19.8-console-commands-overhaul/19.8-03-SUMMARY.md"
|
||||
- ".planning/phases/19.8-console-commands-overhaul/19.8-04-PLAN.md"
|
||||
- ".planning/phases/19.8-console-commands-overhaul/19.8-04-SUMMARY.md"
|
||||
- ".planning/phases/19.8-console-commands-overhaul/19.8-05-PLAN.md"
|
||||
- ".planning/phases/19.8-console-commands-overhaul/19.8-05-SUMMARY.md"
|
||||
- ".planning/phases/19.8-console-commands-overhaul/19.8-06-PLAN.md"
|
||||
- ".planning/phases/19.8-console-commands-overhaul/19.8-06-SUMMARY.md"
|
||||
- ".planning/quick/261022-4z8-fix-the-open-19-8-re-review-findings-wr-/261022-4z8-PLAN.md"
|
||||
- ".planning/quick/261022-4z8-fix-the-open-19-8-re-review-findings-wr-/261022-4z8-SUMMARY.md"
|
||||
- "README.md"
|
||||
- "cmd/summer/commands_documented_test.go"
|
||||
- "cmd/summer/docs.go"
|
||||
- "cmd/summer/docs_test.go"
|
||||
- "cmd/summer/main.go"
|
||||
- "cmd/summer/parity.go"
|
||||
- "cmd/summer/runtime.go"
|
||||
- "cmd/summer/runtime_test.go"
|
||||
- "docs/console/introduction.md"
|
||||
- "docs/console/scaffolding.md"
|
||||
- "docs/console/setup-and-maintenance.md"
|
||||
- "docs/console/utilities.md"
|
||||
- "docs/console/writing-commands.md"
|
||||
- "docs/database/migrations.md"
|
||||
- "docs/examples/blog/console/publish.go"
|
||||
- "docs/examples/blog/console/publish_test.go"
|
||||
- "docs/services/configuration.md"
|
||||
- "docs/services/logging.md"
|
||||
- "docs/setup/configuration.md"
|
||||
- "docs/setup/installation.md"
|
||||
- "docs/setup/porting-a-plugin.md"
|
||||
- "docs/setup/upgrading.md"
|
||||
- "examples/hello/hello_test.go"
|
||||
- "examples/hello/main.go"
|
||||
- "examples/hello/plugins/greeter/plugin.go"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "internal/argmask/argmask.go"
|
||||
- "internal/build/artifact.go"
|
||||
- "internal/build/build.go"
|
||||
- "internal/build/build_test.go"
|
||||
- "internal/build/manifest.go"
|
||||
- "internal/build/new.go"
|
||||
- "internal/build/new_golden_test.go"
|
||||
- "internal/build/scaffold.go"
|
||||
- "internal/build/stubs/new.tmpl"
|
||||
- "internal/build/testdata/new-no-plugin/CLAUDE.md.golden"
|
||||
- "internal/build/testdata/new/CLAUDE.md.golden"
|
||||
- "internal/build/testdata/new/config__log.yaml.golden"
|
||||
- "internal/build/testdata/new/main.go.golden"
|
||||
- "internal/dev/watch.go"
|
||||
- "internal/dev/watch_test.go"
|
||||
- "internal/docsite/check_commands.go"
|
||||
- "internal/docsite/check_commands_test.go"
|
||||
- "internal/docsite/checks_test.go"
|
||||
- "modules/beachcomber/README.md"
|
||||
- "modules/beachcomber/beachcomber.go"
|
||||
- "modules/bonfire/README.md"
|
||||
- "modules/bonfire/command.go"
|
||||
- "modules/bonfire/docs.go"
|
||||
- "modules/bonfire/docs_test.go"
|
||||
- "modules/bonfire/errors.go"
|
||||
- "modules/bonfire/errors_test.go"
|
||||
- "modules/bonfire/example_test.go"
|
||||
- "modules/bonfire/execute.go"
|
||||
- "modules/bonfire/globals.go"
|
||||
- "modules/bonfire/globals_test.go"
|
||||
- "modules/bonfire/golden_test.go"
|
||||
- "modules/bonfire/help.go"
|
||||
- "modules/bonfire/help_smoke_test.go"
|
||||
- "modules/bonfire/helpers.go"
|
||||
- "modules/bonfire/list.go"
|
||||
- "modules/bonfire/list_smoke_test.go"
|
||||
- "modules/bonfire/live.go"
|
||||
- "modules/bonfire/live_signal_unix_test.go"
|
||||
- "modules/bonfire/live_test.go"
|
||||
- "modules/bonfire/output.go"
|
||||
- "modules/bonfire/prompts.go"
|
||||
- "modules/bonfire/root.go"
|
||||
- "modules/bonfire/root_test.go"
|
||||
- "modules/bonfire/selector.go"
|
||||
- "modules/bonfire/styles.go"
|
||||
- "modules/bonfire/testdata/error_chain_verbose.golden"
|
||||
- "modules/bonfire/testdata/error_hint.golden"
|
||||
- "modules/bonfire/testdata/error_unknown_command.golden"
|
||||
- "modules/bonfire/testdata/help_passthrough.golden"
|
||||
- "modules/bonfire/testdata/help_token_create.golden"
|
||||
- "modules/bonfire/testdata/list.golden"
|
||||
- "modules/bonfire/testdata/list.json.golden"
|
||||
- "modules/bonfire/testdata/list_all.golden"
|
||||
- "modules/bonfire/testdata/list_styled.golden"
|
||||
- "modules/bonfire/version.go"
|
||||
- "modules/bonfire/version_test.go"
|
||||
- "modules/bonfire/widgets.go"
|
||||
- "modules/bonfire/widgets_smoke_test.go"
|
||||
- "modules/bonfire/widgets_test.go"
|
||||
- "modules/bonfire/wrongdir_smoke_test.go"
|
||||
- "modules/bouncer/README.md"
|
||||
- "modules/bouncer/turnstile.go"
|
||||
- "modules/cabana/README.md"
|
||||
- "modules/cabana/account.go"
|
||||
- "modules/cabana/account_preferences.go"
|
||||
- "modules/cabana/account_tokens.go"
|
||||
- "modules/cabana/actions.go"
|
||||
- "modules/cabana/api_tokens.go"
|
||||
- "modules/cabana/auth.go"
|
||||
- "modules/cabana/commands.go"
|
||||
- "modules/cabana/contracts.go"
|
||||
- "modules/cabana/crud.go"
|
||||
- "modules/cabana/field_dependent.go"
|
||||
- "modules/cabana/field_file.go"
|
||||
- "modules/cabana/field_type.go"
|
||||
- "modules/cabana/http.go"
|
||||
- "modules/cabana/log.go"
|
||||
- "modules/cabana/partial_render.go"
|
||||
- "modules/cabana/query.go"
|
||||
- "modules/cabana/relation_field.go"
|
||||
- "modules/cabana/reorder.go"
|
||||
- "modules/cabana/singleton.go"
|
||||
- "modules/cabana/token_commands.go"
|
||||
- "modules/cabana/token_guard.go"
|
||||
- "modules/cabana/token_json_test.go"
|
||||
- "modules/conga/README.md"
|
||||
- "modules/conga/client.go"
|
||||
- "modules/conga/commands.go"
|
||||
- "modules/conga/commands_test.go"
|
||||
- "modules/conga/confirm_linux_test.go"
|
||||
- "modules/conga/conga.go"
|
||||
- "modules/conga/logger_smoke_test.go"
|
||||
- "modules/conga/logger_test.go"
|
||||
- "modules/conga/schedule_test.go"
|
||||
- "modules/conga/stats_test.go"
|
||||
- "modules/conga/worker.go"
|
||||
- "modules/cooler/README.md"
|
||||
- "modules/cooler/cooler.go"
|
||||
- "modules/cooler/memory.go"
|
||||
- "modules/cooler/ratelimit.go"
|
||||
- "modules/cooler/rediscache.go"
|
||||
- "modules/cooler/sitecache.go"
|
||||
- "modules/diary/README.md"
|
||||
- "modules/diary/channels.go"
|
||||
- "modules/diary/component.go"
|
||||
- "modules/diary/config.go"
|
||||
- "modules/diary/config_smoke_test.go"
|
||||
- "modules/diary/config_test.go"
|
||||
- "modules/diary/console.go"
|
||||
- "modules/diary/console_test.go"
|
||||
- "modules/diary/daily.go"
|
||||
- "modules/diary/daily_test.go"
|
||||
- "modules/diary/diary.go"
|
||||
- "modules/diary/diary_test.go"
|
||||
- "modules/diary/example_test.go"
|
||||
- "modules/diary/retire_internal_test.go"
|
||||
- "modules/diary/slogtest_test.go"
|
||||
- "modules/diary/tracer_smoke_test.go"
|
||||
- "modules/flare/README.md"
|
||||
- "modules/flare/commands.go"
|
||||
- "modules/flare/commands_test.go"
|
||||
- "modules/flare/flare.go"
|
||||
- "modules/lagoon/README.md"
|
||||
- "modules/lagoon/attach/bucket.go"
|
||||
- "modules/lagoon/attach/thumb.go"
|
||||
- "modules/lagoon/commands.go"
|
||||
- "modules/lagoon/commands_flags_test.go"
|
||||
- "modules/lagoon/connection.go"
|
||||
- "modules/lagoon/encrypted.go"
|
||||
- "modules/lagoon/fill.go"
|
||||
- "modules/lagoon/keygen.go"
|
||||
- "modules/lagoon/logger_smoke_test.go"
|
||||
- "modules/lagoon/logger_test.go"
|
||||
- "modules/lagoon/migrations.go"
|
||||
- "modules/lagoon/pending_test.go"
|
||||
- "modules/lagoon/pty_linux_test.go"
|
||||
- "modules/lagoon/purge.go"
|
||||
- "modules/lagoon/transaction.go"
|
||||
- "modules/lighthouse/README.md"
|
||||
- "modules/lighthouse/centrifugo/client.go"
|
||||
- "modules/lighthouse/centrifugo/commands.go"
|
||||
- "modules/lighthouse/lighthouse.go"
|
||||
- "modules/phrasebook/README.md"
|
||||
- "modules/phrasebook/translator.go"
|
||||
- "modules/postcard/README.md"
|
||||
- "modules/postcard/drivers.go"
|
||||
- "modules/postcard/mailer.go"
|
||||
- "modules/sunscreen/README.md"
|
||||
- "modules/sunscreen/sunscreen.go"
|
||||
- "modules/sunscreen/sunscreen_test.go"
|
||||
- "modules/surf/README.md"
|
||||
- "modules/surf/accesslog.go"
|
||||
- "modules/surf/accesslog_test.go"
|
||||
- "modules/surf/routelist_command.go"
|
||||
- "modules/surf/serve.go"
|
||||
- "modules/surf/serve_lifecycle_test.go"
|
||||
- "modules/surf/serve_summary_smoke_test.go"
|
||||
- "modules/surf/serve_test.go"
|
||||
- "modules/surf/summary.go"
|
||||
- "modules/surf/summary_test.go"
|
||||
- "modules/wristband/README.md"
|
||||
- "modules/wristband/oidc_token.go"
|
||||
- "scripts/check-phase19.8.sh"
|
||||
covered_digest: "v2:sha256:472046bb65cca6ab5a4a3a8b7f02a7c66f6f385e90fbecc1f4a7baf1b80b9f9d"
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
re_verification:
|
||||
previous_status: gaps_found
|
||||
previous_score: 54/57
|
||||
gaps_closed:
|
||||
- "CR-01 (D-03, D-27): lines printed while a live view runs appear in order and none is lost when the view ends, including after its context is cancelled (WR-09, fix fce232d)"
|
||||
- "WR-08 (D-19) and the plan 03 secrets prohibition: secret flag values are masked in the wrong-directory Try line whatever their first character (WR-11, fix 707372b) and in summer's delegate error on a signal death or start failure (WR-10, fix 17c722d), through the shared internal/argmask"
|
||||
- "WR-07 (D-21, D-04): migrate:status (table and --json) lists the same sets migrationSets gives Migrate, framework sets included (WR-12, fix 57a849d, reworked in 139f5e3 so that lagoon.Status stays plugin-only)"
|
||||
gaps_remaining: []
|
||||
regressions: []
|
||||
human_verification:
|
||||
- test: "On a real terminal, run 'summer', 'bin/<app>', 'bin/<app> help admin:token:create' and 'bin/<app> bogus'"
|
||||
expected: "Colors, alignment and sections match the approved D-13 list, D-14 help and D-18 error previews; --no-ansi removes every escape code"
|
||||
why_human: "Visual layout and palette; the goldens were produced with -update from the code itself, so only a person can compare them with the previews"
|
||||
- test: "On a real terminal, run a command that uses Spinner, Progress, Process, Choice, Ask, Confirm and Secret (or the throwaway pty program from plan 02); press Ctrl+C during a spinner"
|
||||
expected: "SSU-like animation, arrow-key selector with confirmation screen, huh prompts; diary log lines print above the view; after Ctrl+C the cursor is visible and the shell is usable"
|
||||
why_human: "Animation, keyboard interaction and terminal restore cannot be asserted by the short suite (plans 01, 02 and 04 recorded human_judgment: true)"
|
||||
- test: "Run 'bin/<app> serve' against a real database on a terminal, then with -q, then with -v, then press Ctrl+C; run 'summer dev' and stop it"
|
||||
expected: "A compact startup block (version, env, URL, DB and migration state, plugins, queue, scheduler, mail, cache, realtime, search), then INFO lines with component prefixes. -q shows only warnings. -v adds per-plugin lines. Ctrl+C prints the shutdown report and exits 0"
|
||||
why_human: "Readability of the styled block is a visual judgment; TestServeLifecycle (Docker) covers the records and exit code"
|
||||
- test: "Resolve the judgment-tier prohibitions listed under 'Prohibitions' below (the LLM-judge verdicts there are non-authoritative)"
|
||||
expected: "Each prohibition is confirmed, or rejected with a reason. The plan 03 'never print a secret' prohibition is no longer flagged as violated: both known leaks (WR-10, WR-11) are fixed and reproduced as fixed with built binaries"
|
||||
why_human: "Judgment-tier prohibitions need explicit human resolution in interactive verification"
|
||||
---
|
||||
|
||||
# Phase 19.8: Console commands overhaul Verification Report
|
||||
|
||||
**Phase Goal:** Console commands in `summer` and in application binaries reach the bar set by Laravel's Artisan. Logging, the shared console layer in `bonfire`, and every command's help and output change; the command logic stays. A user sees what a command did, can ask for more or less of it, and learns from `--help` how to use it without opening the docs.
|
||||
**Verified:** 2026-10-22T02:37:57Z (initial verification 2026-10-22T01:32:12Z)
|
||||
**Status:** human_needed
|
||||
**Re-verification:** Yes, after gap closure (quick task 261022-4z8, commits 7c15095..a48afbd plus follow-ups 139f5e3 and b1fba7e)
|
||||
|
||||
**Plain verdict:** The phase goal is achieved. All 57 must-haves (5 roadmap success criteria and 52 plan truths) are verified in the codebase, and the three gaps of the first report are closed. Nothing is missing in the code. The status is `human_needed`, not `passed`, only because the verifier contract allows `passed` only when the human verification list is empty. That list still holds the four checks of the first report: three visual or terminal checks (palette and layout, live widgets and Ctrl+C on a real terminal, the serve startup block) and the explicit resolution of the judgment-tier prohibitions. No `*-UAT.md` exists for this phase, so none of them is settled by recorded evidence. They are confirmation checks, not suspected defects. Once a person has done them, the phase can be marked passed.
|
||||
|
||||
**MVP mode note:** ROADMAP marks the phase `mode: mvp`, but the goal is not a user story (`user-story.validate` returns `valid: false`). All six plans recorded the decision not to invent a story. This report applies the standard goal-backward method to the goal and SC-1..SC-5 and does not include a User Flow Coverage table.
|
||||
|
||||
## Re-verification (2026-10-22, after quick 261022-4z8)
|
||||
|
||||
The SUMMARY claims were not taken as evidence. Each gap was re-checked against the code at HEAD `4141cd9` (the code tree has no uncommitted changes; only foreign planning files are dirty), with the pinning tests run by the verifier and with an independent check using built binaries.
|
||||
|
||||
### Commits under review
|
||||
|
||||
| Commit | Finding | Gap |
|
||||
|--------|---------|-----|
|
||||
| `fce232d` fix(bonfire): end a live view when its context is cancelled and print later lines plainly | WR-09 | 1 |
|
||||
| `1ac75ad` fix(bonfire): claim the live view slot before building the program | IN-15 | (1, same code) |
|
||||
| `48e13f9` fix(bonfire): strip control characters from Process's plain item lines and the view's label | WR-14 | follow-up |
|
||||
| `707372b` fix(bonfire): mask secret flag values that start with a dash | WR-11 | 2 |
|
||||
| `17c722d` fix(summer): mask secret flag values in a delegate's error | WR-10 | 2 |
|
||||
| `57a849d` fix(lagoon): migrate:status reads the sets migrate applies | WR-12 | 3 |
|
||||
| `139f5e3` fix(lagoon): keep Status plugin-only; migrate:status adds the framework sets itself | WR-12 rework | 3 |
|
||||
| `0691d26` fix(lagoon): warn instead of failing when the re-read after a successful migrate fails | WR-13 | follow-up |
|
||||
| `7c15095`, `a48afbd`, `b1fba7e` | IN-14 doc comment; disposition and todo records | records |
|
||||
|
||||
`19.8-REVIEW-DISPOSITION.md` shows `open: 0`, `total: 32`. WR-09..WR-14, IN-14 and IN-15 are fixed with these hashes; IN-10..IN-13 and the exit-130 suggestion are deferred to `.planning/todos/pending/2026-10-22-console-review-info-findings.md`. The commits are local: 20 commits ahead of the local `origin/master` (`ead8e42`), and no tag contains `fce232d`. So the fixes are not in released v0.4.2 yet. That is a release matter, not a goal gap.
|
||||
|
||||
### Gap 1 (WR-09): lines after a cancelled live view. CLOSED
|
||||
|
||||
- **Code:** `modules/bonfire/live.go`. `tea.NewProgram` no longer takes the caller's ctx (no `tea.WithContext`). `runLive` claims the slot under `liveMu` before it builds the program (lines 368-382). `watchContext` (lines 426-448) does the following on `ctx.Done()`, all under `liveMu`: it clears `activeView`, sends `finishMsg{err: ctx.Err()}` and waits for `finished`. When the program ends first, the watcher only clears the view. `liveView.done` is the `finished` channel, and `printAbove` (lines 529-546) returns false through `case <-v.done:` for a program that has stopped, and unregisters it. The deferred cleanup's `prog.Send` after the watcher has finished the program cannot block, because bubbletea's Run cancels its own context on return. diary's console handler writes the line itself when `bonfire.PrintAbove` returns false (`modules/diary/console.go:165`).
|
||||
- **Pinning test:** `TestPrintAboveAfterCancel` cancels in the middle of an item. It asserts that the view is unregistered, that `Println`, `Info` and `PrintAbove` lines each appear exactly once, and that `PrintAbove` reports false. The verifier ran it with the rest of the live-view set (`TestNestedProcessBuildsNoProgram`, `TestProcessViewStripsControl`, `TestPrintAboveOrderedNoLoss`, `TestPrintlnStaysWithItsConsole`, `TestLiveViewSingleOwner`, `TestLiveViewPanicUnregisters`, `TestLiveViewRestoresTerminalOnSignal`) under `-race -count=3`: all PASS.
|
||||
- **Independent pty reproduction:** this is the same scenario that failed 3 of 3 in the first verification. A scratch probe was built from a `git archive HEAD` export: inside a `bonfire.Process` item it calls `cancel()`, then `out.Println("after-cancel-line")`, then `bonfire.PrintAbove(...)`. It ran 5 times under `script(1)`. In 5 of 5 runs `after-cancel-line` printed once after the last frame and `\e[?25h` (cursor restored), `PrintAbove` returned **false** (so its caller prints the line), and `Process` returned `context canceled`. Before the fix the line was lost and `PrintAbove` returned true.
|
||||
|
||||
### Gap 2 (WR-11 and WR-10): secret flag values in clear. CLOSED
|
||||
|
||||
- **Code:** `internal/argmask/argmask.go` is the one implementation and imports only `strings`. `Join` masks the argument after a secret-named long flag (`password`, `passwd`, `secret`, `token`, `key`) whatever its first character; only `--` is left alone. It also masks the `--flag=value` form. bonfire's `tryArgs` and the `cd` part of the Try line call `argmask.Join` and `argmask.Quote` (errors.go:288-301); bonfire's old local helpers are gone. `cmd/summer/runtime.go:174` `runAppCmd` builds its error from `argmask.Join(argv)` on every branch, and no `strings.Join(argv` remains. An exit code from the child is still passed through with `Reported(WithExitCode(...))`.
|
||||
- **Pinning tests (PASS, verifier run):** `TestWrongDirectoryMasksSecrets` (dash-leading cases included); `TestRunAppCmdMasksSecrets/killed_by_a_signal` and `/start_failure`; `TestDelegateExitCodePassthrough/exit_code`, `/killed_by_a_signal`, `/killed_by_a_signal_masks_secrets`.
|
||||
- **Independent checks with built binaries** (built into the scratchpad from HEAD):
|
||||
- `cd plugins; ../bin/hello admin:create --email a@b.c --password hunter2` printed `Try: cd ..; bin/hello admin:create --email a@b.c --password ***` (exit 1).
|
||||
- The same command with `--password -Xk9q` printed `--password ***` (it was in clear text before the fix).
|
||||
- `summer admin:create --email a@b.c --password hunter2 --api-token=t0k3n --client-secret -Xk9q` was delegated to a fake app binary that kills itself with SIGKILL. It printed `ERROR summer: admin:create --email a@b.c --password *** --api-token=*** --client-secret ***: signal: killed` and exited 1, which is what plan 05's CR-02 truth specifies. With `-v` the chain shows only `signal: killed`.
|
||||
- A delegate that exits 2 makes summer exit 2 with no second error block, so the CR-02 behavior is unchanged.
|
||||
|
||||
### Gap 3 (WR-12): migrate:status --json omits the framework sets. CLOSED
|
||||
|
||||
- **Code:** `modules/lagoon/migrations.go`. The new unexported `setStatus` (line 160) takes its sets from `migrationSets(gdb, plugins)`, the same list `Migrate` (line 69) and `PendingMigrations` (line 251) use. `migrate:status` reads `setStatus` (commands.go:282), so every `PendingMigrations` ref, whose `Plugin` field is the set id, matches a row, and framework pending migrations appear with `"applied": false`. The exported `lagoon.Status` is back to its pre-task behavior: the verifier compared it with the `a28957f` version. It has the same plugin-only `HasMigrations` iteration and the same empty row for a plugin without a history table; only the row-reading loop moved into the shared unexported `historyRows`. The exported API diff `a28957f..HEAD` over modules, cmd and internal adds only `argmask.Join` and `argmask.Quote`, which are in `internal/`, so they are not framework API. Nothing was removed or changed.
|
||||
- **Pinning tests (Docker, PASS, verifier run with `TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1`):** `TestMigrateStatusJSON` deletes the newest `summercms.attach` history row and asserts that `--json` (and `--json -q`) lists that migration as pending. It also asserts that the table lists `summercms.attach, summercms.cabana, deferred, queue, demo.alpha, demo.beta` in order. `TestStatusPluginRows` pins the plugin-only `Status`. `TestMigrateRereadFailureWarns`, `TestMigrateAppliedLines`, `TestPendingMigrations`, `TestMigratePretend`, `TestTwoPluginMigrationSetsIsolated` and `TestDocsMigrate` also PASS.
|
||||
- **Consumer regression check:** `TestRollbackIsolatesFonotekaFullSchema` in `../fonoteka.go/parity` (it calls `lagoon.Status`, `replace => ../summercms.go`) PASSES against HEAD. It failed at `a48afbd`, before the 139f5e3 rework.
|
||||
|
||||
### Regression spot-checks (nothing else regressed)
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| `go vet ./...` | PASS |
|
||||
| `go test -short -count=1 ./...` (all packages, live tree) | PASS, every package ok |
|
||||
| `scripts/check-phase19.8.sh self-test`, `go`, `hello`, `docs` (live tree, now clean of foreign code edits) | all PASS ("phase19.8 go passed", with race on diary, sunscreen, bonfire and docsite; SUMMER list 7 named tests including TestRunAppCmdMasksSecrets) |
|
||||
| `PHASE198_REQUIRE_DOCKER=1 scripts/check-phase19.8.sh docker` | PASS (8 lagoon tests including TestMigrateRereadFailureWarns and TestStatusPluginRows; 3 cabana token JSON tests) |
|
||||
| `summer` bare / `summer bogus` / `summer list --format=json` | exit 0 with the grouped list (59 lines) / ERROR block, `Try: summer list`, exit 2 / 35 commands, 0 without examples |
|
||||
| `summer help make:command`, `bin/hello help migrate:status` | Description, Usage, Arguments, global options, Help, Examples; migrate:status's Long help names the framework sets |
|
||||
| `bin/hello key:generate -q`, `bin/hello route:list --json` | key printed, exit 0; JSON route list |
|
||||
| Debt markers (TBD/FIXME/XXX) in files changed `a28957f..HEAD`; TODO/HACK added | none |
|
||||
| Generated main / internal/build changed since the first verification | no (`git diff 36e2240..HEAD -- cmd/summer/main.go internal/build` is empty) |
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Roadmap Success Criteria
|
||||
|
||||
| # | Success criterion | Status | Evidence |
|
||||
|---|-------------------|--------|----------|
|
||||
| SC-1 | `diary` module owns logging (Laravel-shaped keys, stderr/stack/daily/console channels, component loggers), sunscreen on every channel, compat shim, daily files on by default, charm stack with a decision note before code | ✓ VERIFIED | `modules/diary` (3,248 lines): `log.default/level/channels/components` constants (config.go:16-24); `DefaultConfig` is a stack of console and daily (`storage/logs/summer.log`, JSON, warn, 14 days); every leaf goes through `sunscreen.Wrap` (channels.go:117-166). README has a `**Name:**` mnemonic; root README row at line 104. The note commit `e6039b4` is an ancestor of the first go.mod lipgloss commit `e903cc3` (merge-base check). go.mod has lipgloss v2.0.6, bubbletea v2.1.0, bubbles v2.2.1, huh v2.0.3, fang v2.0.1, colorprofile v0.4.3. `go list` shows only bonfire and diary import charm. `sunscreen.InstallDefault` keeps its signature. `summer new` writes `config/log.yaml` (golden). Re-verification: unchanged by the gap-closure commits; diary tests PASS under race. |
|
||||
| SC-2 | Global flags `-v/-vv/-vvv`, `-q`, `--ansi/--no-ansi`, `-n`, `--env`; verbosity sets the diary level at runtime, so River and plugin logs are hidden or consistent at the default level and shown in full at -vv | ✓ VERIFIED | The flags appear in the help output of both binaries (re-checked). The generated main does `PeekGlobals`, `diary.Bootstrap`, `compass.Open(...Env: g.Env)`, `diary.Setup`, `bonfire.OnGlobals(diary.ApplyGlobals)` (examples/hello/main.go, unchanged). TestVerbosityLevels, TestComponentFloors, TestQuietConsoleOnly, TestRiverLoggerComponent, TestGlobalFlagsAgree and TestCallNoGlobalsHook PASS in the gate's go stage. |
|
||||
| SC-3 | Every command has a description, long help with examples, documented args and options with defaults; `list` grouped by namespace; `summer help <cmd>` and `<binary> help <cmd>` work; the coverage check fails for the framework and warns in plugins | ✓ VERIFIED | Re-checked: bare `summer` prints the grouped list (exit 0); `summer help make:command` and `bin/hello help migrate:status` print the full help layout; `summer list --format=json` lists 35 commands, none without examples. `cmd/summer/commands_documented_test.go` (`t.Error` per `bonfire.CheckDocs` problem) PASSES in the go stage. |
|
||||
| SC-4 | Sweep of all commands with the Artisan checklist (`--force`, `--pretend`, progress/tables, `--json`) without breaking names or flags; serve and queue:work print a startup summary and a shutdown report; one-shot commands end with a result line and an exit code | ✓ VERIFIED | As in the first report (migrate `--pretend`/`--force`, `route:list --json`, `key:generate -q`, unknown command exit 2, missing DSN exit 1 with a hint, serve summary and report, 15 plugin repos swept). The edge paths flagged in the first report are now fixed: `migrate:status --json` covers the framework sets, and `migrate` no longer exits 1 after a successful run when only the re-read fails (WR-13). Docker: TestServeLifecycle, TestMigratePretend, TestRollbackPretend, TestDeferredPurgePretend, TestMigrateStatusJSON, TestMigrateAppliedLines, TestMigrateRereadFailureWarns and the cabana token JSON tests PASS. |
|
||||
| SC-5 | diary and bonfire READMEs, docs console and logging pages, landing site and roadmap deck reflect the new console; unit tests in the last plan | ✓ VERIFIED | As in the first report (5 docs/console pages, the logging page, "Upgrading to 0.4", the landing "Console" card, the deck row in sm-demo-app). The gap-closure commits updated the bonfire and lagoon READMEs, the docs/console pages and docs/database/migrations.md in the same commits (WR-10's README fold-in is recorded as a deviation in the quick SUMMARY). The gate's docs stage PASSES at HEAD (67 files clean, 35 commands with examples). |
|
||||
|
||||
### Plan must_haves (merged; 52 truths)
|
||||
|
||||
| Plan | Truths | Status | Evidence / notes |
|
||||
|------|--------|--------|------------------|
|
||||
| 01 diary | 8 | 8 ✓ | Unchanged since the first report; the diary named tests PASS in the gate's go stage at HEAD. |
|
||||
| 02 bonfire | 8 | 8 ✓ | Unchanged contract; goldens, globals, help, JSON listing, error block and exit codes, degradation matrix all PASS in the go stage. The Output, Input and Progress interfaces and the Command struct are not touched by the gap-closure commits. |
|
||||
| 03 sweep | 9 | 9 ✓ | As before. The secrets prohibition is no longer violated by a known path (see Prohibitions). |
|
||||
| 04 tests | 7 | 7 ✓ | `check-phase19.8.sh` self-test, go, hello, docs and docker stages PASS at HEAD (verifier run). The gate now requires TestNestedProcessBuildsNoProgram, TestPrintAboveAfterCancel, TestRunAppCmdMasksSecrets, TestMigrateRereadFailureWarns and TestStatusPluginRows by name. |
|
||||
| 05 review fixes | 13 | **13 ✓** (was 10 ✓, 3 partial) | **CR-01** ✓: gap 1 closed (TestPrintAboveAfterCancel plus the pty probe, 5/5). **WR-08** ✓: gap 2 closed (dash-leading values and the delegate error path masked; TestWrongDirectoryMasksSecrets, TestRunAppCmdMasksSecrets, built-binary checks). **WR-07** ✓: gap 3 closed (migrate:status reads migrationSets; TestMigrateStatusJSON with a pending framework migration). The API truth still holds: compared with the first report, the gap-closure diff adds no exported identifier under modules/ and changes none. CR-02, CR-03, WR-01..WR-06 and the commit/RED-replay/disposition truths are still verified (pinning tests PASS; disposition `open: 0`). |
|
||||
| 06 release | 7 | 7 ✓ | Unchanged: v0.4.2 annotated tag on `ead8e42`, changelog post, deck sync. |
|
||||
|
||||
**Score:** 57/57 truths verified (5 SC + 52 plan truths; 0 failed; 0 present-but-behavior-unverified). The three behavior-dependent truths that had failed (CR-01's cancellation/cleanup invariant, the WR-08 masking, the WR-07 status list) each have a passing behavioral test named above, so they count as verified on behavior, not on presence.
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `.planning/notes/2026-10-21-console-charm-stack.md` | D-02 decision note | ✓ VERIFIED | Committed before any charm import |
|
||||
| `modules/diary/{diary,config,component,console,daily,channels}.go` | diary module | ✓ VERIFIED | Substantive and wired |
|
||||
| `modules/bonfire/{globals,execute,list,help,docs,errors,live,helpers,styles,version}.go` | console layer | ✓ VERIFIED | live.go now has the watcher, `liveView.done` and the claim-before-build; helpers.go sanitizes the plain Process path |
|
||||
| `internal/argmask/argmask.go` | shared secret masking (new) | ✓ VERIFIED | Imported by `modules/bonfire/errors.go` and `cmd/summer/runtime.go`; the only masking implementation |
|
||||
| `modules/surf/summary.go`, `accesslog.go` | serve summary, report and request log | ✓ VERIFIED | |
|
||||
| `modules/lagoon/migrations.go` | PendingMigrations, migrationSets, Status | ✓ VERIFIED (was PARTIAL) | `migrationSets(gdb, plugins)` used by Migrate, setStatus and PendingMigrations; exported Status unchanged in behavior |
|
||||
| `modules/lagoon/commands.go` | migrate, migrate:status | ✓ VERIFIED | `setStatus` for the status command; re-read warning (WR-13) |
|
||||
| `modules/conga/worker.go` | Worker.Stats | ✓ VERIFIED | |
|
||||
| `internal/dev/watch.go` | dev runs serve | ✓ VERIFIED | |
|
||||
| `cmd/summer/commands_documented_test.go` | failing coverage gate | ✓ VERIFIED | |
|
||||
| `cmd/summer/runtime.go` | delegate exit code, streams, masked errors | ✓ VERIFIED (was PARTIAL) | `argmask.Join(argv)` on every error branch |
|
||||
| `scripts/check-phase19.8.sh` | fail-closed gate | ✓ VERIFIED | all local stages green at HEAD |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status |
|
||||
|------|----|-----|--------|
|
||||
| generated main (internal/build) | diary | `diary.Bootstrap` then `diary.Setup(cfg` | ✓ WIRED |
|
||||
| generated main | bonfire.Execute / OnGlobals | `os.Exit(run(...))`, `bonfire.OnGlobals(diary.ApplyGlobals)` | ✓ WIRED |
|
||||
| diary/channels.go | sunscreen | `sunscreen.Wrap(` on every leaf | ✓ WIRED |
|
||||
| conga/client.go | diary | `diary.For("river")` | ✓ WIRED |
|
||||
| lagoon/connection.go | diary | `NewSlogLogger(diary.For("db")` | ✓ WIRED |
|
||||
| diary/console.go | bonfire | `bonfire.PrintAbove(` (console.go:165), plain write on false; `bonfire.Sanitize` | ✓ WIRED (PrintAbove now reports false after a cancellation, so the line is written) |
|
||||
| surf/accesslog.go | diary | `diary.For("http")` | ✓ WIRED |
|
||||
| surf/serve.go | lagoon | `lagoon.PendingMigrations(` | ✓ WIRED |
|
||||
| lagoon/commands.go migrate:status | lagoon/migrations.go migrationSets | `setStatus(gdb, plugins)` | ✓ WIRED (new) |
|
||||
| modules/bonfire/errors.go | internal/argmask | `argmask.Join` / `argmask.Quote` | ✓ WIRED (new) |
|
||||
| cmd/summer/runtime.go | internal/argmask | `argmask.Join(argv)` | ✓ WIRED (new) |
|
||||
| cmd/summer tests | bonfire.CheckDocs | `bonfire.CheckDocs(` | ✓ WIRED |
|
||||
| cmd/summer/runtime.go | bonfire/execute.go | `Reported(WithExitCode(...))` | ✓ WIRED |
|
||||
| v0.4.2 tag | changelog | changelog-post.py | ✓ (post id 37, HTTP 200) |
|
||||
|
||||
### Data-Flow Trace (Level 4)
|
||||
|
||||
| Artifact | Data | Source | Real data | Status |
|
||||
|----------|------|--------|-----------|--------|
|
||||
| serve startup summary | migration state, plugins, drivers | `lagoon.PendingMigrations`, `party` plugins, config lookups | yes (TestServeLifecycle with Postgres) | ✓ FLOWING |
|
||||
| `migrate:status` / `--json` | applied and pending list per set | `setStatus` over `migrationSets` + `PendingMigrations` | yes, framework sets included (TestMigrateStatusJSON with Postgres) | ✓ FLOWING (was PARTIAL) |
|
||||
| `list --format=json` | command catalog | `bonfire.Describe` over registered commands | yes (35 commands) | ✓ FLOWING |
|
||||
| http request log | method, pattern, status | `recordPattern` in the route mux | yes (TestAccessLog*) | ✓ FLOWING |
|
||||
|
||||
### Behavioral Spot-Checks (verifier-run, re-verification)
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| Bare tool prints the Artisan list | `summer --no-ansi` | grouped list, exit 0 | ✓ PASS |
|
||||
| Per-command help | `summer help make:command`, `bin/hello help migrate:status` | Description, Usage, Arguments, Global options, Help, Examples | ✓ PASS |
|
||||
| Unknown command is a usage error | `summer bogus` | ERROR block, `Try: summer list`, exit 2 | ✓ PASS |
|
||||
| JSON catalog complete | `summer list --format=json` | 35 commands, 0 without examples | ✓ PASS |
|
||||
| -q keeps data | `bin/hello key:generate -q` | key printed, exit 0 | ✓ PASS |
|
||||
| --json for scripts | `bin/hello route:list --json` | JSON route list | ✓ PASS |
|
||||
| Wrong-directory hint masks secrets | `cd plugins; ../bin/hello admin:create --email a@b.c --password hunter2` | `--password ***` | ✓ PASS |
|
||||
| Same with a dash-leading value | `... --password -Xk9q` | `--password ***` | ✓ PASS (was FAIL) |
|
||||
| Signal-killed delegate masks argv | `summer admin:create ... --password hunter2 --api-token=t0k3n --client-secret -Xk9q` (fake app binary that SIGKILLs itself) | `--password *** --api-token=*** --client-secret ***: signal: killed`, exit 1 | ✓ PASS (new) |
|
||||
| Delegate exit code passthrough | fake app binary exiting 2 | summer exit 2, no second block | ✓ PASS |
|
||||
| Lines after a cancelled Process view | scratch probe under `script(1)`, 5 runs | `after-cancel-line` printed once after the last frame in 5/5; PrintAbove returned false (caller writes); cursor restored | ✓ PASS (was FAIL) |
|
||||
| Pending framework migration in `migrate:status --json` | TestMigrateStatusJSON (Docker) | listed with `applied: false`; the table lists the framework sets first | ✓ PASS (was gap) |
|
||||
| Exported `lagoon.Status` consumer | `../fonoteka.go` `go test -run TestRollbackIsolatesFonotekaFullSchema ./parity/` (Docker) | ok | ✓ PASS |
|
||||
|
||||
### Probe Execution
|
||||
|
||||
No `scripts/*/tests/probe-*.sh` exist. The phase's probe is `scripts/check-phase19.8.sh`:
|
||||
|
||||
| Stage | Command | Result | Status |
|
||||
|-------|---------|--------|--------|
|
||||
| self-test | `bash scripts/check-phase19.8.sh self-test` | "phase19.8 self-test passed" | PASS |
|
||||
| go | `bash scripts/check-phase19.8.sh go` (live tree; the code tree is clean) | "phase19.8 go passed" (vet, short, race, named tests), exit 0 | PASS |
|
||||
| hello | `bash scripts/check-phase19.8.sh hello` | "phase19.8 hello passed", exit 0 | PASS |
|
||||
| docs | `bash scripts/check-phase19.8.sh docs` | "phase19.8 docs passed", exit 0 | PASS |
|
||||
| docker | `PHASE198_REQUIRE_DOCKER=1 TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 bash scripts/check-phase19.8.sh docker` | 8 lagoon and 3 cabana tests passed, exit 0 | PASS |
|
||||
| plugins, apps, landing | not re-run | These stages touch sibling repos, and another session is working in `../sm-demo-app`. The gap-closure commits do not change the generated main or any exported API, so no plugin or app pin is affected. Plan 06 recorded `--all` green at ead8e42 | NOT RUN by verifier |
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
There are no REQUIREMENTS.md IDs for this phase (none orphaned). The plans' `requirements:` fields cite SC-1..SC-5 and D-01..D-30:
|
||||
|
||||
| Source | IDs | Status |
|
||||
|--------|-----|--------|
|
||||
| CONTEXT decisions | D-01..D-30 | 30/30 honored; D-03, D-19, D-21 and D-27, which the plan 05 gaps touched, are now fully satisfied |
|
||||
| Plan 01 | SC-1, SC-2, D-01, D-02, D-04, D-05, D-08, D-09, D-10, D-12 | ✓ SATISFIED |
|
||||
| Plan 02 | SC-2, SC-3, D-01..D-04, D-13..D-18, D-27, D-28, D-30 | ✓ SATISFIED |
|
||||
| Plan 03 | SC-3, SC-4, SC-5, D-04..D-07, D-11, D-17..D-29 | ✓ SATISFIED |
|
||||
| Plan 04 | SC-5 and D-03, D-08..D-10, D-12..D-19, D-21, D-23, D-25, D-27..D-29 | ✓ SATISFIED |
|
||||
| Plan 05 | D-03, D-04, D-10, D-12, D-18, D-19, D-21, D-25, D-27..D-29 | ✓ SATISFIED (was PARTIAL) |
|
||||
| Plan 06 | D-26 | ✓ SATISFIED |
|
||||
|
||||
### Prohibitions
|
||||
|
||||
| Plan | Prohibition | Tier | Disposition |
|
||||
|------|-------------|------|-------------|
|
||||
| 01 | Only bonfire and diary import charm modules | judgment (checked mechanically) | Holds: `internal/argmask` imports only `strings` |
|
||||
| 01 | Nothing imports gopkg.in/yaml.v3 | judgment (checked mechanically) | Holds |
|
||||
| 01-03 | No consuming application named in framework code and docs | judgment | LLM-judge: holds (gate docs stage, 67 files clean at HEAD); non-authoritative |
|
||||
| 01-06 | Nothing pushed or tagged before the go-ahead; no force-push; no deploy | judgment | LLM-judge: holds (the gap-closure commits are local and untagged); non-authoritative |
|
||||
| 02 | No method added to Output, Input or Progress | judgment (checked mechanically) | Holds: no exported change in the gap-closure diff |
|
||||
| 02 | No prompt blocks a non-TTY, -n or -q run | judgment | Holds per TestPromptsNoInteraction, TestConfirmProceed, TestConfirmProductionNonTTYProceeds |
|
||||
| 03 | No command name or flag removed or renamed | judgment | LLM-judge: holds (migrate:status kept its name, flags, Description and Examples; only its Long help changed); non-authoritative |
|
||||
| 03 | Summary, hints and logs never print a DSN password, key, token or secret | judgment | LLM-judge: **holds for every known path** (was violated). The wrong-directory hint with a dash-leading value and the delegate signal and start-failure errors now mask through `internal/argmask`; reproduced with built binaries. Non-authoritative |
|
||||
| 04 | No test skipped or weakened; goldens free of credentials | judgment | LLM-judge: holds. The WR-12 rework returned TestTwoPluginMigrationSetsIsolated and TestDocsMigrate to their pre-57a849d form rather than weakening them; non-authoritative |
|
||||
| 05 | No exported identifier removed or changed | judgment (checked mechanically) | Holds: `git diff a28957f..HEAD` over modules, cmd and internal (non-test) shows only `argmask.Join` and `argmask.Quote` added, both under `internal/`; `lagoon.Status` keeps its signature and its plugin-only result |
|
||||
| 05 | WR-01 fixed inside live views; generated main unchanged | judgment | Holds: internal/build and cmd/summer/main.go are unchanged since the first verification |
|
||||
| 06 | Changelog token never printed | judgment | LLM-judge: holds per SUMMARY; non-authoritative |
|
||||
|
||||
### Test Quality Audit
|
||||
|
||||
| Test area | Linked | Skipped | Circular | Assertion level | Verdict |
|
||||
|-----------|--------|---------|----------|-----------------|---------|
|
||||
| diary (30 named) | SC-1, D-08..D-12 | 1 conditional (root) | no | value/behavioral (slogtest conformance) | OK |
|
||||
| bonfire goldens | D-13, D-14, D-18 | 0 | partial: created with `-update` | value | OK, provenance PARTIAL (human item 1) |
|
||||
| bonfire behavior (live, signals, exit codes) | D-03, D-18, D-27 | 0 | no | behavioral (helper processes, pty, cancellation) | OK; cancellation now covered by TestPrintAboveAfterCancel |
|
||||
| lagoon Docker | D-21, WR-07, WR-12, WR-13 | 0 under Docker | no | value | OK; TestMigrateStatusJSON asserts a pending framework migration |
|
||||
| cmd/summer runtime | CR-02, CR-03, WR-03, WR-10 | POSIX-only conditionals | no | behavioral | OK; signal-death and start-failure leak cases covered |
|
||||
|
||||
Disabled tests on requirements: 0. Circular: 0 blocking. Insufficient assertions: 0. One small note carried from the quick verification: printAbove's "Run ended on its own" branch (not via cancellation) has no committed pinning test; the cancellation branch it shares code with is pinned.
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| internal/build/artifact.go | 182 | `"TODO: describe " + cmdName` | ℹ️ Info | A template string the scaffolder writes into generated commands (pre-phase) |
|
||||
| cmd/summer/main.go, runtime.go, runtime_test.go, modules/flare/commands.go | n/a | gofmt drift | ℹ️ Info | Pre-existing, recorded in deferred-items.md and in the quick SUMMARY |
|
||||
| modules/lagoon/commands.go | migrate:status Description | "Show per-plugin migration history" / example "Show the applied migrations per plugin" | ℹ️ Info | The command now also lists framework sets; the Long help says so, but the one-line description still says per-plugin. Kept unchanged on purpose (P-06); cosmetic |
|
||||
|
||||
No TBD, FIXME or XXX in any file this phase or the gap-closure task changed; no TODO or HACK added.
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
The goal is achieved; these are the confirmation checks from the first report. Three are visual or terminal checks the contract always routes to a person, and one is the explicit resolution of judgment-tier prohibitions. None is a suspected defect.
|
||||
|
||||
1. **Styled list, help and error on a real terminal.** Run `summer`, `bin/<app>`, `bin/<app> help admin:token:create` and `bin/<app> bogus`. Expected: the output matches the D-13, D-14 and D-18 previews in palette and alignment. Why human: the goldens came from the code itself.
|
||||
2. **Live widgets and prompts.** Run Spinner, Progress, Process, Choice, Ask, Confirm and Secret on a pty, then press Ctrl+C during a spinner. Expected: SSU-like look; log lines print above the view; the cursor comes back. Why human: animation and keyboard input. (The cancellation path of gap 1 is now covered by a test and a pty probe; this item is about look and feel.)
|
||||
3. **serve on a real application.** Run it at the default verbosity, with `-q` and with `-v`, then stop it with Ctrl+C; also run `summer dev`. Expected: a readable startup block, component-prefixed INFO lines, a shutdown report, exit 0. Why human: readability.
|
||||
4. **Judgment-tier prohibitions.** Confirm or reject the non-authoritative verdicts in the Prohibitions table. The secrets prohibition is no longer flagged as violated.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No gaps remain. All five roadmap success criteria and all 52 plan truths hold in the codebase at HEAD `4141cd9`. The three gaps of the first report were each re-checked in the code, with the named pinning tests, and with an independent run that reproduced the original failure scenario:
|
||||
|
||||
1. **Live view lifecycle (WR-09):** a cancelled view now ends with its last frame and is unregistered without waiting for the work function. Later lines print plainly, and `PrintAbove` reports false. The pty probe passed 5/5, where the first verification had failed 3/3.
|
||||
2. **Argv masking (WR-11, WR-10):** one helper (`internal/argmask`) masks dash-leading values and is used by both the wrong-directory hint and summer's delegate errors. Built binaries no longer print `-Xk9q`, `hunter2` or `t0k3n`.
|
||||
3. **Migration reporting (WR-12):** `migrate:status` (table and `--json`) reads the same `migrationSets` as `migrate`, so framework sets and their pending migrations are listed. The exported `lagoon.Status` keeps its plugin-only contract, and the fonoteka parity consumer passes.
|
||||
|
||||
The follow-up items WR-13, WR-14, IN-14 and IN-15 are fixed too. IN-10..IN-13, IN-01..IN-09 and the exit-130 idea are recorded as deferred in the console review todo. The gap-closure commits are local and unreleased; shipping them needs a v0.4.x tag on the user's go-ahead.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-10-22T02:37:57Z_
|
||||
_Verifier: Claude (msd-verifier)_
|
||||
@@ -0,0 +1,3 @@
|
||||
# Phase 19.8 API coverage
|
||||
|
||||
No external API integration: phase restyles local console output and logging using in-process Go libraries.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Phase 19.8 deferred items
|
||||
|
||||
Out-of-scope findings recorded during execution. Not fixed in this phase.
|
||||
|
||||
## Found during 19.8-01
|
||||
|
||||
- **Flaky wristband test.** `TestOIDCSubjectIsRandomSmoke` (`modules/wristband/oidc_security_test.go:151`) rejects a subject that contains `ada` (to catch the fixture user's name), but a random UUIDv4 contains `ada` in about 0.7% of runs. It failed once during the 19.8-01 short suite (`sub = "64a89757-94a7-4699-bf28-4329d8c7eada"`) and passed on three reruns. Fix: compare against the fixture's name as a whole, or check that the subject differs from it, instead of matching a substring of hex.
|
||||
- **Pre-existing gofmt drift.** `gofmt -l` lists `internal/build/registry.go`, `modules/cabana/account_tokens.go` (no newline at end of file) and some test files (`modules/cabana/*_test.go`, `modules/compass/*_test.go`, `modules/tide/*_test.go`). Unchanged by this plan beyond the logger lines.
|
||||
- **examples/hello go.mod is stale outside the workspace.** `GOWORK=off go -C examples/hello build .` asks for `go mod tidy`: its go.mod lacks requirements the framework already had before this phase (aws-sdk, bluemonday, redis, among others). Inside the repository's go.work every build and test passes, so the example modules' go.mod and go.sum were left as they were.
|
||||
|
||||
## Found during 19.8-03 (segment B, plugin sweep)
|
||||
|
||||
Each failure below also fails at the plugin's commit before the sweep, with framework master and with framework v0.3.1 (checked in scratch worktrees), so it is not caused by the sweep or by v0.4.0. Not fixed here.
|
||||
|
||||
- **sm-sitemap-plugin `TestSitemapAdminSmoke`** (`sitemap_smoke_test.go:607`): expects the phrase key `golem15.sitemap::lang.item.reference_required` in a 422 body, gets the translated text "The item reference is required.".
|
||||
- **sm-faq-plugin `TestFaqAdminSmoke`, `TestFaqAdminPurgesSiteCache`** (`site_cache_admin_test.go:208`): a create without a locale returns "Choose a language." where the test expects the phrase key.
|
||||
- **sm-menu-plugin `TestMenuAdminSmoke`, `TestMenuAdminPurgesSiteCache`** (`site_cache_admin_test.go:228`): a create returns 422 "Choose a menu or a parent entry." where the test expects success or the phrase key.
|
||||
- **sm-user-plugin `updates` migration tests** (`TestExtendUsersMigration`, `TestCreateUserThrottleMigration`, `TestCreateJwtBlacklistMigration`, `user_session_test.go`): "survived rollback", a varying subset per run; also at v0.1.5 with framework v0.3.1.
|
||||
- **Plugin boot logs on `list`.** Application binaries print INFO boot records (for example `paymentgateway: operators registered`) before `list` and `help` output. Already captured by another session as a todo (summercms.go `c1f5ded`, "Quiet plugin boot logs on console commands like --help").
|
||||
- **sm-demo-app roadmap snapshot vs local master.** `scripts/sync-roadmap.sh --check` fails against local framework master while other sessions' 19.6 and 19.6.1 planning commits are unpushed; it passes with `ROADMAP_REF=origin/master`. Re-sync after those commits are pushed.
|
||||
|
||||
## Found during 19.8-04 (unit tests)
|
||||
|
||||
Recorded, not fixed here: none of them is a defect in released behaviour.
|
||||
|
||||
- **gofmt drift from plan 03.** `gofmt -l` lists `cmd/summer/main.go`, `cmd/summer/runtime.go`, `cmd/summer/runtime_test.go` (map key alignment) and `modules/flare/commands.go`; each already differs at the plan 03 release. The tests added here are formatted.
|
||||
- **Dotted component names in YAML.** The diary README and `docs/services/logging.md` say component names cannot contain dots. compass keeps a dotted YAML key literal (`components: {golem15.blog: debug}` parses as component `golem15.blog` and works for `diary.For("golem15.blog")`); only the nested form, which `SUMMER_LOG__` variables produce, is refused. `TestConfigRules` pins both. Either reject the literal form or reword the docs ("use names without dots so environment overrides can reach them").
|
||||
- **`Version.Header` collapses equal versions.** An application built as `devel` against a `devel` framework prints `SummerCMS acme devel` without `(framework devel)`, the same rule that gives `SummerCMS summer v0.4.0`. Go stamps VCS pseudo-versions on applications, so it shows only with `-buildvcs=false`. Pinned by `TestVersionHeader`.
|
||||
- **Error blocks scrub credential shapes only.** `RenderError` passes text through `sunscreen.Scrub` (Bearer tokens, `sk-` keys, `x-api-key:`); a `password=...` written into an error message is printed as is. The plan's `TestErrorScrubbed` wording ("a password attribute in the message") is covered for the shapes Scrub knows; extending Scrub is a sunscreen change for every log line and needs its own decision.
|
||||
- **Live views on a non-terminal writer draw no frames.** `runLive` takes the window size from the terminal; with a buffer the renderer draws nothing but the PrintAbove lines. The widget matrix therefore proves the live path ran (cursor hidden, no fallback lines) and `TestLiveModels` renders the frames with a fixed size; a pty run of spinner, progress and process view printed every final frame (`✓ fast spinner`, `4/4`, `✓ a`, `✗ b bad row`, `Importing 2/2 … 100%`).
|
||||
- **Landing end-to-end check.** `TestTerminalCommands` (sm-summercmsio-app) runs the deployed one-liner and needs network, Docker and ports 54320 and 8080; the gate runs it only with `SUMMERCMS_TERMINAL_CHECK=1` and prints NOT RUN otherwise.
|
||||
- **Stray binary in sm-grzybyfunkcjonalne-app.** An untracked `sm-grzybyfunkcjonalne-app` executable sits in that application's root (present before this plan; a `go build` without `-o`). Not this plan's; left alone.
|
||||
@@ -0,0 +1,392 @@
|
||||
---
|
||||
phase: quick-261022-2zy
|
||||
plan: 01
|
||||
quick_id: 261022-2zy
|
||||
slug: user-and-sociallogin-plugin-security-gap
|
||||
type: execute
|
||||
mode: quick
|
||||
wave: 1
|
||||
depends_on: []
|
||||
autonomous: false
|
||||
requirements:
|
||||
- 2zy-1 # /register mass assignment (organisation fields, IP columns, account flags)
|
||||
- 2zy-2 # no Entra tenant ID in sign-in data
|
||||
- 2zy-3 # no post-login event; listener errors discarded
|
||||
- 2zy-4 # offboarding: sessions and API tokens survive deactivate/ban, /refresh never reloads the user
|
||||
- 2zy-5 # stale organisation_role after a membership change
|
||||
files_modified:
|
||||
# sm-user-plugin, worked in the sm-demo-app submodule checkout
|
||||
- ../sm-demo-app/plugins/golem15/user/models/user.go
|
||||
- ../sm-demo-app/plugins/golem15/user/controllers/registration.go
|
||||
- ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go
|
||||
- ../sm-demo-app/plugins/golem15/user/controllers/organisations_admin_controller.go
|
||||
- ../sm-demo-app/plugins/golem15/user/controllers/users_admin_controller.go
|
||||
- ../sm-demo-app/plugins/golem15/user/classes/admin_actions.go
|
||||
- ../sm-demo-app/plugins/golem15/user/classes/admin_actions_test.go
|
||||
- ../sm-demo-app/plugins/golem15/user/classes/events.go
|
||||
- ../sm-demo-app/plugins/golem15/user/register_test.go
|
||||
- ../sm-demo-app/plugins/golem15/user/offboarding_test.go
|
||||
- ../sm-demo-app/plugins/golem15/user/admin_organisations_edge_test.go
|
||||
- ../sm-demo-app/plugins/golem15/user/README.md
|
||||
# sm-sociallogin-plugin, standalone repo
|
||||
- ../sm-sociallogin-plugin/classes/flow.go
|
||||
- ../sm-sociallogin-plugin/classes/provider.go
|
||||
- ../sm-sociallogin-plugin/classes/providers_goth.go
|
||||
- ../sm-sociallogin-plugin/classes/codes.go
|
||||
- ../sm-sociallogin-plugin/classes/registration.go
|
||||
- ../sm-sociallogin-plugin/classes/events.go
|
||||
- ../sm-sociallogin-plugin/matching_test.go
|
||||
- ../sm-sociallogin-plugin/providers_goth_test.go
|
||||
- ../sm-sociallogin-plugin/fakeprovider_test.go
|
||||
- ../sm-sociallogin-plugin/signin_event_test.go
|
||||
- ../sm-sociallogin-plugin/README.md
|
||||
# summercms.go framework (Task 4, after phase 19.8 has released)
|
||||
- modules/cabana/relation.go
|
||||
- modules/cabana/relation_field.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/clear_on_change_test.go
|
||||
- modules/cabana/README.md
|
||||
- docs/backend/relation-manager.md
|
||||
# only after the user's go-ahead (Task 5)
|
||||
- ../sm-demo-app/plugins/golem15/user (submodule pointer)
|
||||
- ../sm-demo-app/plugins/golem15/sociallogin (submodule pointer)
|
||||
- ../sm-bm-app/plugins/golem15/user (submodule pointer)
|
||||
- ../sm-bm-app/plugins/golem15/sociallogin (submodule pointer)
|
||||
- ../fonoteka.go/plugins/golem15/user (submodule pointer)
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/user (submodule pointer)
|
||||
- ../sm-summercmsio-app/plugins/golem15/user (submodule pointer)
|
||||
|
||||
estimate:
|
||||
tokens: 220000
|
||||
raw_tokens: 220000
|
||||
tasks: 5
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "/register and controllers.RegisterUser fill only name, surname, email, password, preferred_locale and marketing_consent; body values for organisation_id, organisation_role, created_ip_address, last_ip_address, is_onboarded and must_change_password are ignored, and the IP columns come only from RegisterOptions.IP. models.User.Fillable() is unchanged, so admin forms, form extensions and RegisterUser callers keep working."
|
||||
- "Deactivation is self-service reversible: a password login or a social sign-in of a deactivated (soft-deleted) account restores it, sends the reactivate mail and issues a fresh working token. Deactivating also ends every earlier session JWT and revokes every personal API token, and those stay dead after the restore."
|
||||
- "A ban is the hard lock: a banned user (deactivated or not) is refused by password login, by social sign-in on every path (identity, verified email match, link, pending completion) without being restored, and by /refresh; banning ends every earlier session JWT and revokes every personal API token, and unban does not revive them."
|
||||
- "POST /_user/api/v1/refresh answers 401 {error: Could not refresh token, msg: User not found} for a token whose user is deactivated, banned, or that was issued before the user's tokens_valid_after; a valid refresh still answers 200 and stamps last_seen."
|
||||
- "A Microsoft (Entra) sign-in stores the tenant ID from the id_token tid claim in the identity's profile_data.tenant_id; other providers' profile_data keeps exactly name, email, avatar."
|
||||
- "Every successful social sign-in (login, register, link, pending completion) fires classes.SignedInEvent with provider, provider ID, identity row ID, tenant ID, provider email, action and created flag; its listener errors and the user.Register / user.Created listener errors (social and /register) are logged and never fail or undo the sign-in or registration."
|
||||
- "cabana's RelationContract (hasMany) and FieldRelationContract (belongsTo) accept ClearOnChange; the user plugin uses it so that adding or removing a member in the Organisations members manager, and changing the organisation in the Users form, clears organisation_role unless the same save writes it; deleting an organisation still clears both columns."
|
||||
artifacts:
|
||||
- path: ../sm-demo-app/plugins/golem15/user/models/user.go
|
||||
provides: "RegisterFillable, the explicit registration allow-list"
|
||||
contains: "func (User) RegisterFillable"
|
||||
- path: ../sm-demo-app/plugins/golem15/user/classes/admin_actions.go
|
||||
provides: "RevokeUserAccess; DeactivateUsers and BanUsers revoke sessions and API tokens"
|
||||
contains: "func RevokeUserAccess"
|
||||
- path: ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go
|
||||
provides: "Refresh subject check; /register logs listener errors"
|
||||
contains: "ThrottleStates"
|
||||
- path: ../sm-sociallogin-plugin/classes/events.go
|
||||
provides: "SignedInEvent, SignedInEventName"
|
||||
contains: "type SignedInEvent struct"
|
||||
- path: ../sm-sociallogin-plugin/classes/providers_goth.go
|
||||
provides: "Microsoft code exchange that keeps the id_token and reads tid"
|
||||
contains: "id_token"
|
||||
- path: modules/cabana/relation_field.go
|
||||
provides: "FieldRelationContract.ClearOnChange applied in assignBelongsTo"
|
||||
contains: "ClearOnChange"
|
||||
- path: modules/cabana/relation.go
|
||||
provides: "RelationContract.ClearOnChange applied on hasMany link and unlink"
|
||||
contains: "ClearOnChange"
|
||||
key_links:
|
||||
- from: ../sm-demo-app/plugins/golem15/user/controllers/registration.go
|
||||
to: ../sm-demo-app/plugins/golem15/user/models/user.go
|
||||
via: "lagoon.Fill with RegisterFillable() instead of Fillable() at registration.go:73"
|
||||
pattern: "RegisterFillable\\(\\)"
|
||||
- from: ../sm-demo-app/plugins/golem15/user/middleware/api_token.go
|
||||
to: ../sm-demo-app/plugins/golem15/user/classes/admin_actions.go
|
||||
via: "token.IsUsableAt(now) refuses rows whose revoked_at RevokeUserAccess set"
|
||||
pattern: "revoked_at"
|
||||
- from: ../sm-sociallogin-plugin/classes/flow.go
|
||||
to: ../sm-sociallogin-plugin/classes/events.go
|
||||
via: "app.Events.Fire(ctx, &SignedInEvent{...}) in signIn and linkIdentity"
|
||||
pattern: "SignedInEvent\\{"
|
||||
- from: ../sm-sociallogin-plugin/classes/providers_goth.go
|
||||
to: ../sm-sociallogin-plugin/classes/flow.go
|
||||
via: "Profile.TenantID -> upsertIdentity profile_data tenant_id and PendingPayload.TenantID"
|
||||
pattern: "TenantID"
|
||||
- from: ../sm-demo-app/plugins/golem15/user/controllers/organisations_admin_controller.go
|
||||
to: modules/cabana/relation.go
|
||||
via: "members RelationContract ClearOnChange organisation_role, applied before Save(child) in linkRelated/unlinkRelated"
|
||||
pattern: "ClearOnChange"
|
||||
- from: ../sm-demo-app/plugins/golem15/user/controllers/users_admin_controller.go
|
||||
to: modules/cabana/relation_field.go
|
||||
via: "organisation FieldRelationContract ClearOnChange organisation_role, applied in assignBelongsTo before the row write"
|
||||
pattern: "ClearOnChange"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Close the five security gaps reported while planning sm-servers-app in the core plugins sm-user-plugin and sm-sociallogin-plugin, plus one small additive framework feature in cabana, without breaking any app that uses them (fonoteka.go, sm-bm-app, sm-demo-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app).
|
||||
|
||||
1. (2zy-1) /register mass assignment: organisation fields, the IP columns and account flags.
|
||||
2. (2zy-2) No Entra tenant ID in sign-in data.
|
||||
3. (2zy-3) No post-login event; register/created listener errors discarded.
|
||||
4. (2zy-4) Offboarding: sessions and personal API tokens survive deactivate and ban; /refresh never reloads the user.
|
||||
5. (2zy-5) Stale organisation_role after a membership change.
|
||||
|
||||
Purpose: an application (sm-servers-app first) can let users in through Entra, assign organisations by tenant and email domain, and lock people out with a ban.
|
||||
Output: local commits in the sm-user-plugin checkout at ../sm-demo-app/plugins/golem15/user, in ../sm-sociallogin-plugin and in summercms.go, each with tests and README/docs in the same commit; then a blocking go-ahead before any push, tag, changelog post or submodule pointer bump.
|
||||
|
||||
Decisions (user-confirmed or planner's discretion; the executor does not revisit them):
|
||||
- OD-1, confirmed by the user: deactivation is self-service and reversible, a ban is the lock. Password /login already restores a deactivated account (WinterCMS parity, session_test.go TestLogin around :140-156), and social sign-in keeps doing the same through controllers.FinishLogin (registration.go:98-123) — no change in sociallogin's checkCanSignIn and TestSoftDeleted stays as it is. What deactivation adds is the end of earlier sessions and personal API tokens: the user signs in again (which reactivates) and gets a fresh token. /refresh refuses deactivated users so a session never slides silently past a deactivation. A ban is refused everywhere: password login (classes.RejectIfThrottled, existing), social sign-in (checkCanSignIn, existing, now pinned for banned and deactivated accounts), /refresh (new), session JWTs and API tokens (revoked at ban, new).
|
||||
- OD-2, confirmed: the profile_data key is tenant_id, written only when a tenant is known.
|
||||
- OD-3, decided: cabana gets an additive ClearOnChange []string on RelationContract (hasMany) and on FieldRelationContract (belongsTo), instead of a GORM hook on models.User. Reason: the change is small (three call sites: the hasMany branches of linkRelated and unlinkRelated in modules/cabana/relation.go around :955 and :1250, and assignBelongsTo in modules/cabana/relation_field.go:648-663; plus two boot validations), declarative, a no-op at its zero value, and it covers both admin paths: the members manager saves the child with Save(child) and no controller hook, and the user form assigns the picked organisation after FormBeforeUpdate (modules/cabana/crud.go: formBeforeUpdate, then assignBelongsTo, then Save), so neither path can be handled in the plugin without a model-wide hook. A declarative column list was preferred over new pact hook interfaces because a hook for the belongsTo path would need the old and the new key, which ClearOnChange compares internally. Cost: the user plugin's stale-role commit needs a summercms tag that contains ClearOnChange, so it is the last user-plugin commit and ships in its own tag (see Task 5).
|
||||
- Registration allow-list (user's "yes add it"): RegisterUser fills from a new explicit list, User.RegisterFillable(): name, surname, email, password (the hash), preferred_locale, marketing_consent. Not settable from a registration body any more: organisation_id, organisation_role (membership is the admin's or the application's), created_ip_address, last_ip_address (server-set from RegisterOptions.IP only; today a body value survives when opts.IP is empty), is_onboarded (application-owned onboarding state), must_change_password (set by the admin and the require-password-change console command). Already outside Fillable and unchanged: is_activated, activated_at, activation_code(_issued_at), reset_password_code(_issued_at), has_self_set_password, tokens_valid_after, email_verified_at, handle, permissions, last_seen. User.Fillable() itself stays as it is: cabana intersects it with the admin form's fields (and an app's form extensions), and no other caller fills it from client input. Verified frontends send only email, password, password_confirmation, name, surname, marketing_consent plus event-only keys (fonoteka vue app stores/auth.ts:370-378, sm-bm-app frontend api/user.ts:29, vue-demo-app useDemoAuth.ts:34), and every RegisterUser caller passes keys inside the new list (fonoteka onboarding.go:102 and console/dev_seed.go:362, sociallogin registration.go:121-129).
|
||||
- Listener errors are logged, never returned: user.Register / user.Created in /register and in social account creation, and the new sociallogin.SignedIn event. A committed registration or sign-in is never undone by a listener, as in WinterCMS.
|
||||
- The login event lives in sociallogin (classes.SignedInEvent): provider, identity and tenant are sociallogin concepts. One event covers login, register, link and pending completion; Created=true marks an account created by that sign-in.
|
||||
- No framework change for tokens or refresh: front-end personal API tokens live in the user plugin (models/api_token.go:28 table user_api_tokens, guard middleware/api_token.go:35-83); cabana's BackendAPIToken (backend_api_tokens) is for backend administrators and untouched. /refresh is the plugin's controllers.Refresh (api_controller.go:178-215, bouncer.Refresh at :193); bouncer.RefreshAudienceFor would demand an aud claim (PHP-era tokens have none) and knows nothing of bans, so the plugin checks the subject itself.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@CLAUDE.md
|
||||
@.planning/STATE.md
|
||||
@../sm-demo-app/plugins/golem15/user/README.md
|
||||
@../sm-sociallogin-plugin/README.md
|
||||
@modules/cabana/README.md
|
||||
@docs/backend/relation-manager.md
|
||||
|
||||
Working copies (observed at planning time, 2026-10-22):
|
||||
- sm-user-plugin: ../sm-demo-app/plugins/golem15/user, detached HEAD 9986342 (= tag v0.1.6 = origin/master); local branch master is stale at 24b16b1 (an ancestor). All five apps mount the plugin at 9986342. sm-demo-app's go.work only replaces the module (it is not in the use list), so every go command in this checkout runs with GOWORK=off; the plugin's go.mod line 176 replaces summercms with ../../../../summercms.go, so it builds against summercms.go's working tree, including other sessions' uncommitted edits (verified: GOWORK=off go vet ./... passes).
|
||||
- sm-sociallogin-plugin: ../sm-sociallogin-plugin on master at 9fd58db (= origin/master), clean. Its tracked go.work replaces sm-user-plugin with ../fonoteka.go/plugins/golem15/user (9986342), so its own tests run against the published user plugin; no sociallogin change depends on new user-plugin API.
|
||||
- summercms.go: phase 19.8 (console overhaul) is executing. At planning time the working tree had another session's uncommitted edits in modules/lagoon (19.8-05) and planning files; nothing in modules/cabana, modules/pact or docs/backend. Remaining 19.8 plans: 19.8-05 (bonfire, diary, lagoon, cmd/summer, internal/dev, docs/console, docs/services/logging.md: no overlap with Task 4's files) and 19.8-06 (release v0.4.2, whose range check requires origin/master..master to hold only 19.8-05's code commits and planning documents). A cabana commit before 19.8-06 runs would trip that check, hence Task 4's precondition. Latest tag: v0.4.1.
|
||||
- Do not edit fonoteka.go or other app checkouts before Task 5's go-ahead. In summercms.go and the plugin repos, stage files by explicit path, never git add -A, never touch .planning/STATE.md or state.json by hand, no co-author tags.
|
||||
|
||||
Source facts the tasks rely on (file:line in the checkouts above):
|
||||
- user models/user.go:84-92 Fillable(); controllers/registration.go:43-89 RegisterUser fills at :73 and copies opts.IP into the input map at :67-70 only when non-empty; api_controller.go:672 passes readFields(r) (the raw body). /update (api_controller.go:443+) writes only name, surname, email and does not use Fillable.
|
||||
- cabana admin fill: fillAllowed (modules/cabana/crud.go:1184-1205) intersects Fillable with the form's writable fields; protectedFillKey (:1407) excludes organisation_id; the user form writes organisation_id through the organisation FieldRelationContract with WritableForeignKey (users_admin_controller.go:370-388). fonoteka sets organisation_id and organisation_role with Updates(map)/Exec (onboarding.go:119-135, invitation_service.go:266 and :413, org_provisioner.go:63-70).
|
||||
- api_controller.go:685-686 discards FireRegisterEvent / FireCreatedEvent errors; sociallogin classes/registration.go:181-183 does the same.
|
||||
- Members manager: organisations_admin_controller.go:76-90 hasMany contract on users.organisation_id; docs/backend/relation-manager.md:190 (link adopts children whose ForeignKey is NULL, unlink sets it back to NULL). Known-gap test: admin_organisations_edge_test.go:183-213 TestAdminOrganisationRoleOutlivesMembership (assertions at :195-197 and :199-202). Organisation delete already clears both columns (organisations_admin_controller.go:99-117).
|
||||
- cabana relation code: RelationContract (modules/cabana/relation.go:38-58), validateRelationContract (:479) and validateHasManyContract (:558), linkRelated (:917-975, hasMany Save at :955), unlinkRelated (:1231-1260, hasMany Save at :1250); FieldRelationContract (relation_field.go:22-45), compileFieldRelation (:205), assignBelongsTo (:648-663); the form save order in crud.go: lagoon.Fill(target, fillAllowed(...), projected, ...), validation, formBeforeUpdate/formBeforeCreate, applyPermissionValues, checkRelationScope, checkRelationLocks, assignBelongsTo, Save/Create. Helpers: fieldByColumn (http.go:1204), setModelColumn (relation.go:1332), modelFields (model_fields.go:28). Existing tests: relation_child_test.go (hasMany CRUD, deferred), phase121_form_test.go:582 TestWritableForeignKeySmoke, phase121_relation_lock_test.go:235/:279, phase121_schema_boot_test.go.
|
||||
- Deactivation: classes/admin_actions.go:95-113 DeactivateUsers only sets deleted_at; BanUsers :140-168 only writes user_throttle; RestoreUsers :117-134; ThrottleStates :21-57. users_admin_controller.go:629-647 wraps DeactivateUsers and fires DeactivatingEvent. users_admin_controller.go:246 already stamps tokens_valid_after on an admin password reset.
|
||||
- Sessions: classes/user_lookup.go:35-60 PrincipalByID loads scoped and carries TokensValidAfter (the bouncer jwt guard refuses iat before it, modules/bouncer/jwt.go:172); classes/session_user.go:74 does the same for RequestUser. middleware/api_token.go:61-70 accepts any token row with IsUsableAt and a principal. JWT iat has whole-second precision.
|
||||
- Refresh: api_controller.go:178-215; the "never loads the user row" comment and TouchLastSeen at :204-212; session_test.go:225 TestRefresh (craftToken, bearer, insertUser helpers in session_test.go). bouncer.ErrSubjectRejected text is "User not found" (modules/bouncer/jwt.go:20,144). golang-jwt/jwt/v5 is already a direct requirement of the user plugin.
|
||||
- sociallogin classes/flow.go: identityUser :328-338 and userByEmail :272-281 load Unscoped; loginWithIdentity :287-307 calls checkCanSignIn :357-371 (activation, ban) before FinishLogin restores; linkIdentity :128-164 issues its own token; upsertIdentity :385-422 writes profile_data :412-416; signIn :311-324. matching_test.go:365 TestSoftDeleted (restore subtests, kept) and :461 TestInactiveAndBanned (inactive and banned on identity, verified_match, link, pending_completion).
|
||||
- sociallogin Microsoft: providers_goth.go:72-82 Complete calls goth's sess.Authorize, and goth v1.82.0 providers/azureadv2/session.go Authorize keeps only access/refresh token and expiry (the id_token is dropped; Graph /me has no tenant). newMicrosoft :225-254. Profile classes/provider.go:26-41. PendingPayload classes/codes.go:46-77. providers_goth_test.go:38 TestGothProviders (microsoft fake token at :128-130 and :181). settings_test.go:305 TestErrorMessages scans flowErr keys.
|
||||
- Tests that start Postgres need Docker and skip under -short. If the Docker socket is denied although the user is in the docker group, run the go test command through sudo -u golem (CLAUDE.md).
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: sm-user-plugin: explicit registration allow-list, server-set IP columns, logged /register listener errors</name>
|
||||
<files>../sm-demo-app/plugins/golem15/user/models/user.go, ../sm-demo-app/plugins/golem15/user/controllers/registration.go, ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go, ../sm-demo-app/plugins/golem15/user/register_test.go, ../sm-demo-app/plugins/golem15/user/README.md</files>
|
||||
<precondition>Docker is reachable for testcontainers (directly or through sudo -u golem), and git -C ../sm-demo-app/plugins/golem15/user status --short is empty.</precondition>
|
||||
<behavior>
|
||||
- TestRegisterIgnoresProtectedFields: seed an organisation row; POST /_user/api/v1/register with email, password, name, surname, preferred_locale "pl", marketing_consent true, plus organisation_id (the seeded id), organisation_role "owner", created_ip_address and last_ip_address "203.0.113.9", is_onboarded true and must_change_password true, answers 200. The stored user has name, surname, preferred_locale and marketing_consent as sent; organisation_id and organisation_role NULL; is_onboarded and must_change_password false; created_ip_address and last_ip_address equal to the request's client address, not 203.0.113.9.
|
||||
- Direct controllers.RegisterUser with the same fields: with RegisterOptions.IP "198.51.100.7" both IP columns hold it; with an empty IP both are NULL whatever the fields say; the other columns as above.
|
||||
- Existing TestRegister, TestRegisterEventPayload (the register event payload still carries the client's extra keys, password and confirmation removed), TestRegisterUserPasswordLess and the sociallogin-shaped RegisterUser calls stay green; the admin Users form tests (admin_users_test.go) stay green, proving Fillable() is untouched.
|
||||
</behavior>
|
||||
<action>
|
||||
Branch setup first (the checkout is detached): in ../sm-demo-app/plugins/golem15/user run git fetch origin, git checkout master and git merge --ff-only origin/master. If origin/master is no longer 9986342, note the new base in the SUMMARY and continue on it. Every go command in this checkout runs with GOWORK=off.
|
||||
|
||||
Allow-list (2zy-1): add func (User) RegisterFillable() []string to models/user.go returning name, surname, email, password, preferred_locale, marketing_consent, with a doc comment naming what a registration body may set and listing what it may not (organisation_id, organisation_role, created_ip_address, last_ip_address, is_onboarded, must_change_password; the activation, reset, proof, handle and permission columns are never fillable). Leave Fillable() (:84-92) as it is and rewrite only its doc comment: the mass-assignment backstop of the admin form (cabana intersects it with the form's fields and keeps organisation_id protected) and of trusted application code; registration uses RegisterFillable.
|
||||
|
||||
RegisterUser (controllers/registration.go:43-89): fill with user.RegisterFillable() instead of user.Fillable() at :73; drop the copy of opts.IP into the input map (:67-70) and instead, after the fill, set CreatedIPAddress and LastIPAddress on the struct from opts.IP when it is not empty, so a body value can never reach those columns. Keep validation, hashing, PasswordLess, Activate and AssignHandle exactly as now. Rewrite the RegisterUser and RegisterOptions doc comments accordingly (fields can set only the RegisterFillable columns; organisation membership and role are set with an explicit update after creation, as an application's onboarding does; IP is the only source of the IP columns).
|
||||
|
||||
Listener errors (2zy-3, user side): at api_controller.go:685-686 replace the discarded results with Error-level logs through the package logger pluginLog (controllers/logger.go): "golem15.user: register listener failed" and "golem15.user: created listener failed", with user_id and err; the registration still succeeds. Update the FireRegisterEvent / FireCreatedEvent doc comments (registration.go:125-150): /register logs that error and continues.
|
||||
|
||||
Tests per the behavior block in register_test.go (reuse sessionApp, disableRegisterThrottle, postJSON; read how the harness sets the request's client address and assert against that).
|
||||
|
||||
README.md (same commits as the code): the registration part of Features/Usage lists what a /register body can and cannot set and that the IP columns are server-set; the Extension events bullet (:39) says /register logs listener errors and continues and that RegisterEvent.Payload is untrusted client input (it still carries every extra key); the RegisterUser / RegisterOptions / FireRegisterEvent / FireCreatedEvent entries (:170-178) match the new comments; add RegisterFillable to the models API list; update the Testing section.
|
||||
|
||||
Commits in the user-plugin checkout, explicit paths, no co-author tags, conventional style as in the repo log: (a) fix: registration fills only an explicit allow-list and never takes the IP columns from the body (models/user.go, registration.go, register_test.go, README); (b) fix: log /register listener failures instead of discarding them (api_controller.go, registration.go comments, README). go vet and the full go test before each commit. Do not push.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user && GOWORK=off go vet ./... && GOWORK=off go test -count=1 -run 'TestRegister|TestAdminUser' ./... && GOWORK=off go test -count=1 ./... && sed -n '/^func (User) Fillable/,/^}/p' models/user.go | grep -c 'organisation_role'</automated>
|
||||
</verify>
|
||||
<done>Registration fills only RegisterFillable columns and takes the IP columns only from RegisterOptions.IP; Fillable() still lists its twelve columns (the grep prints 1); /register listener failures are logged; the whole user-plugin suite is green; two commits on master in the user-plugin checkout; nothing pushed.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Offboarding: deactivate and ban end sessions and API tokens, /refresh refuses revoked subjects (user plugin); bans pinned and semantics documented (both plugins)</name>
|
||||
<files>../sm-demo-app/plugins/golem15/user/classes/admin_actions.go, ../sm-demo-app/plugins/golem15/user/classes/admin_actions_test.go, ../sm-demo-app/plugins/golem15/user/classes/events.go, ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go, ../sm-demo-app/plugins/golem15/user/offboarding_test.go, ../sm-demo-app/plugins/golem15/user/README.md, ../sm-sociallogin-plugin/matching_test.go, ../sm-sociallogin-plugin/README.md</files>
|
||||
<behavior>
|
||||
- classes: RevokeUserAccess(ctx, db, ids) stamps users.tokens_valid_after with the current time truncated to whole seconds (soft-deleted rows included) and sets revoked_at on every not-yet-revoked user_api_tokens row of those users, leaving earlier revoked_at values unchanged; an empty list writes nothing. DeactivateUsers and BanUsers call it for every selected user (already deactivated or banned ones included) and return the same counts as before.
|
||||
- offboarding_test.go, deactivate: a user with a session JWT issued at least one second before the action and a usr_ API token; after the admin deactivate bulk action the JWT is refused by GET /fetch and by POST /refresh (401, error "Could not refresh token"), the API token is refused by the APITokenGuard; a password login right after the deactivation (same second allowed) restores the account, sends the reactivate mail and returns a token that GET /fetch accepts; the old JWT and the old API token stay refused.
|
||||
- offboarding_test.go, ban: after the ban bulk action the old JWT is refused by /fetch and /refresh and the API token by the guard; password login answers the invalid-login response; after unban the old JWT and API token stay refused and a fresh login works.
|
||||
- offboarding_test.go, refresh subject checks without RevokeUserAccess: a user soft-deleted directly (deleted_at only) -> refresh 401; a user banned directly through a user_throttle row (no stamp) -> refresh 401; a token whose iat precedes tokens_valid_after set directly -> refresh 401; a suspended (not banned) user -> refresh 200; a normal user -> refresh 200 with last_seen stamped; a refused refresh stamps no last_seen. Existing TestRefresh, TestLogin (restore and reactivate mail) and TestDeactivatingEvent stay green.
|
||||
- sociallogin TestInactiveAndBanned (matching_test.go:461) gains a banned-and-deactivated user: identity sign-in, verified email match and pending completion all answer the banned message, the account stays soft-deleted, no reactivate mail is sent and no identity row is created for it. TestSoftDeleted (restore of a deactivated, not banned account) stays unchanged and green.
|
||||
</behavior>
|
||||
<action>
|
||||
User plugin, in ../sm-demo-app/plugins/golem15/user (master after Task 1, GOWORK=off):
|
||||
|
||||
Add the exported RevokeUserAccess(ctx context.Context, db *gorm.DB, userIDs []uint) error to classes/admin_actions.go with the file's transaction convention (callers pass their transaction; use fresh(ctx, db)). One cutoff for both writes: time.Now() truncated to whole seconds, because JWT iat has whole-second precision and a token minted right after the revocation (a reactivating login) must be valid; a token minted earlier within that same second also stays valid, which the doc comment states. Writes: an unscoped UpdateColumn of users.tokens_valid_after for the ids, and an update of models.APIToken setting revoked_at where user_id is in the ids and revoked_at IS NULL. Doc comment: it ends every session JWT issued before the cutoff (the jwt guard, RequestUser and /refresh honour tokens_valid_after) and every personal API token of the users; applications may call it for their own offboarding, and after writing a ban directly to user_throttle.
|
||||
|
||||
DeactivateUsers (:95-113): after the deleted_at writes call RevokeUserAccess for every non-nil selected user with an id, whether deactivated now or before (a repeated deactivate also cleans accounts deactivated before this change); keep the returned count. BanUsers (:140-168): call RevokeUserAccess for all ids after the throttle writes; keep the count. RestoreUsers and UnbanUsers stay unchanged: revoked tokens stay revoked. Update the doc comments of DeactivateUsers, BanUsers and DeactivatingEvent (classes/events.go:59-67: sessions and API tokens are already revoked when it fires).
|
||||
|
||||
Refresh (2zy-4) in controllers/api_controller.go:178-215: keep the bouncer.Refresh call and its existing 401 body for token-level failures. After it succeeds, read the presented token's sub and iat with github.com/golang-jwt/jwt/v5 ParseUnverified (bouncer.Refresh already verified signature, audience, refresh window and blacklist), load the user with a scoped query by id through gdb and the request context, and refuse when the user is not found (missing or deactivated: a deactivated user signs in again, which reactivates the account), when TokensValidAfter is set and iat is before it, or when classes.ThrottleStates (with classes.ContextWithThrottle(ctx, app)) reports a ban. A refusal answers 401 with the existing shape: error "Could not refresh token", msg bouncer.ErrSubjectRejected.Error(); a database failure answers writeOpaque500. Suspension is deliberately not checked: it is the failed-password throttle, and refusing refresh on it would let anyone who knows an email log its owner out. Move TouchLastSeen after the checks so a refused refresh stamps nothing; replace the comment at :204-205 with one describing the subject check.
|
||||
|
||||
Tests per the behavior block: unit cases in classes/admin_actions_test.go (its Postgres setup); offboarding_test.go in the plugin root with the admin harness for bulk actions (admin_harness_test.go) and the session helpers in session_test.go, plus the API-token guard path used by api_tokens_test.go; capture the reactivate mail the way session_test.go TestLogin does.
|
||||
|
||||
User README.md: a short "Deactivation and bans" subsection under Features, and matching edits to the JWT sessions bullet (:20), Last seen (:33), Admin screens (:28) and the Bulk actions row (:129): deactivation is self-service and reversible (a password login or a golem15.sociallogin sign-in restores the account and sends the reactivate mail; an administrator can restore it too), and it ends every earlier session and personal API token, so the user signs in again and gets a fresh token, while old API tokens stay revoked; /refresh refuses deactivated and banned users and tokens from before tokens_valid_after; a ban is the lock: password login, social sign-in and refresh refuse it and its sessions and API tokens are revoked; unban and restore revive no token. Add RevokeUserAccess to the classes API list (:184) and an upgrade note: accounts deactivated or banned before this version keep their API tokens until deactivated or banned again (the actions are idempotent) or RevokeUserAccess runs for them. Update the Testing section.
|
||||
|
||||
Commit in the user-plugin checkout: fix: deactivate and ban revoke sessions and API tokens, and /refresh refuses deactivated, banned and cut-off subjects (admin_actions.go, admin_actions_test.go, events.go, api_controller.go, offboarding_test.go, README.md). No push.
|
||||
|
||||
Sociallogin, in ../sm-sociallogin-plugin (master, own go.work): no code change. Extend TestInactiveAndBanned with the banned-and-deactivated case per the behavior block (seed it like the banned user plus a soft delete; reuse the trash/restored helpers pattern from TestSoftDeleted). README.md: the matching table row (:49) keeps the restore and the line at :55 becomes: every sign-in requires an activated account without a ban; a deactivated account is restored by a social sign-in exactly as by a golem15.user password login (with the reactivate mail), a banned one is refused on every path and never restored. Add a Security notes bullet stating the same split (deactivation is reversible by its owner; a ban is the lock, checked before any restore or identity write). Update the Testing paragraph.
|
||||
|
||||
Commit in ../sm-sociallogin-plugin: test: a banned account is refused and never restored by social sign-in, and the deactivation semantics are documented (matching_test.go, README.md). No push.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user && GOWORK=off go vet ./... && GOWORK=off go test -count=1 -run 'TestOffboarding|TestRefresh|TestLogin|TestDeactivatingEvent|TestAdminUserActions|TestRevokeUserAccess|TestSharedAPIToken|TestAPIToken' ./... && GOWORK=off go test -count=1 ./... && cd /media/nvme/dev/golem15/summercms.io/summercms/sm-sociallogin-plugin && go vet ./... && go test -count=1 -run 'TestSoftDeleted|TestInactiveAndBanned|TestMatching' ./... && go test -count=1 ./...</automated>
|
||||
</verify>
|
||||
<done>Deactivate and ban revoke earlier session JWTs and personal API tokens; a deactivated user reactivates by signing in and gets a working token; a banned user is refused by login, social sign-in and refresh and is never restored; /refresh refuses deactivated, banned and cut-off subjects with the existing 401 body; both suites green; one commit in each repo; nothing pushed.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: sm-sociallogin-plugin: Entra tenant ID in profile_data, SignedInEvent after every social sign-in, logged listener errors</name>
|
||||
<files>../sm-sociallogin-plugin/classes/provider.go, ../sm-sociallogin-plugin/classes/providers_goth.go, ../sm-sociallogin-plugin/classes/codes.go, ../sm-sociallogin-plugin/classes/flow.go, ../sm-sociallogin-plugin/classes/registration.go, ../sm-sociallogin-plugin/classes/events.go, ../sm-sociallogin-plugin/providers_goth_test.go, ../sm-sociallogin-plugin/fakeprovider_test.go, ../sm-sociallogin-plugin/signin_event_test.go, ../sm-sociallogin-plugin/README.md</files>
|
||||
<behavior>
|
||||
- TestGothProviders microsoft (providers_goth_test.go): the fake token answer carries an id_token whose payload has aud = the client id and tid = a GUID; Complete returns Profile.TenantID = that GUID in lower case. Without id_token, with a malformed id_token, with an aud that is not the client id, or with a tid that is not a GUID, TenantID is empty and the sign-in still succeeds. invalid_grant still maps to ErrCodeExpired; no request leaves the provider's hosts; the timeout case holds; the authorization URL cases are unchanged.
|
||||
- signin_event_test.go (Postgres, fake provider with a TenantID): an identity sign-in fires one SignedInEvent with User, Provider "fake", ProviderID, IdentityID (the golem15 oauth identity row id), TenantID, Email (provider email), Action "login", Created false; a register-action creation fires it with Action "register", Created true; a link fires it with Action "link"; a pending completion fires it once; a sign-in that restores a deactivated account fires it once with Action "login".
|
||||
- signin_event_test.go: profile_data holds tenant_id for a profile with a tenant and exactly name, email, avatar without one; a pending sign-in keeps the tenant through PendingPayload (tenant_id in the stored payload, omitted when empty) and the completed identity has profile_data.tenant_id.
|
||||
- signin_event_test.go: a SignedInEvent listener returning an error does not fail the sign-in (token and user payload returned) and the error is logged; a listener that changes the user's name in the database is reflected in the returned user payload; a register/created listener returning an error does not fail social account creation and is logged.
|
||||
</behavior>
|
||||
<action>
|
||||
All work in ../sm-sociallogin-plugin on master (after Task 2's commit). Keep the plugin's logging style (slog.Default(), as in controllers/*.go): sociallogin requires summercms v0.3.1, so do not use diary.
|
||||
|
||||
Tenant ID (2zy-2, key tenant_id per OD-2). Add TenantID string to Profile (classes/provider.go:26-41): the directory tenant the account signed in from (Microsoft Entra tid), empty for providers without tenants. Add TenantID to PendingPayload (classes/codes.go:46-59) with JSON key tenant_id and omitempty; copy it in pendingOutcome (flow.go:233-252) and back in PendingPayload.Profile() (codes.go:63-77). In upsertIdentity (flow.go:385-422) add "tenant_id" to profile_data only when p.TenantID is not empty, so other providers keep exactly name, email, avatar (profile_data is json:"-" on the user plugin's OAuthIdentity, so no API response changes).
|
||||
|
||||
Microsoft exchange (classes/providers_goth.go). goth's azureadv2 Session.Authorize drops the id_token, so the microsoft provider performs the code exchange itself and keeps goth for the authorization URL and the Graph profile. Give gothProvider an optional complete override (a func field taking ctx, the provider, the goth session string and the callback values, returning Profile and error); Complete (:72-82) delegates to it when set, so google, facebook and github are untouched. newMicrosoft (:225-254) builds an oauth2.Config identical to goth's: ClientID, ClientSecret, RedirectURL from cfg; AuthURL https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize and TokenURL https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token with the same tenant default (DefaultTenant); scopes from the same azureadv2 ScopeType constants converted with string(). The override exchanges the callback's code with context.WithValue(ctx, oauth2.HTTPClient, p.client); maps a failure through p.exchangeError (invalid_grant still becomes ErrCodeExpired, no response body in the error); refuses a token that is not Valid(); builds an azureadv2.Session with AccessToken, RefreshToken and ExpiresAt from the token; calls p.gp.FetchUser with it and passes the result through the existing microsoft profile mapping; then sets TenantID from token.Extra("id_token"). Add a helper (for example entraTenantID(idToken, clientID string) string) that splits the compact JWT into three parts, base64url-decodes the payload (raw URL encoding, tolerating padding), reads the string claims tid and aud, and returns the lower-cased tid only when aud equals clientID and tid is a canonical GUID (8-4-4-4-12 hex), otherwise the empty string. The signature is not verified: the id_token comes straight from the token endpoint over TLS in a client-authenticated back-channel exchange (OpenID Connect Core 3.1.3.7); write that reason in the helper's doc comment. Never log the id_token, the access token or the code. Update newMicrosoft's doc comment.
|
||||
|
||||
SignedInEvent (2zy-3). Create classes/events.go with SignedInEventName = "sociallogin.SignedIn" and type SignedInEvent struct with User (*usermodels.User), Provider (registry name), ProviderID (the identity's provider_id), IdentityID (uint, the golem15 oauth identity row id), TenantID, Email (provider-reported, possibly empty), Action (ActionLogin, ActionRegister or ActionLink) and Created (true when this sign-in created the account); doc comment: fired after every successful social sign-in, after the identity is stored and FinishLogin ran (so a deactivated account is already restored), before the session token is minted, outside any transaction; listener errors are logged and never fail the sign-in; the user row is re-read afterwards so listener writes reach the token payload. Make upsertIdentity return the identity row id with the error. Change signIn (flow.go:311-324) to receive the provider, the profile, the identity id and the created flag: after FinishLogin it fires the event through app.Events (skipped when app or app.Events is nil), logs a listener error with slog.Default().Error("golem15.sociallogin: sign-in listener failed", "provider", ..., "user_id", ..., "err", ...), then re-reads the user row by id (scoped; any read failure, including a row a listener removed, is returned as an internal error), then IssueToken and APIArray as now. loginWithIdentity (:287-307) and createUser (registration.go:112-185; Created true, Action register) pass the values through; linkIdentity (:128-164) fires the event with Action link and Created false after its transaction and re-reads the user the same way before IssueToken. In createUser replace the discarded FireRegisterEvent / FireCreatedEvent results (registration.go:181-183) with the same Error-level logging ("register listener failed", "created listener failed") and keep the comment that a committed registration is never undone.
|
||||
|
||||
Tests per the behavior block: extend the microsoft cases in providers_goth_test.go (build the unsigned id_token in the test from base64url-encoded JSON header and payload plus any signature segment; the fake token bodies at :128-130 and :181 gain an id_token field); extend fakeprovider_test.go so a fake profile can carry a TenantID; add signin_event_test.go with a listener on the app's event bus registered the way existing tests do (search this repo's and the user plugin's tests for event listener registration) and a slog handler swap to assert the logged failure.
|
||||
|
||||
README.md: Features gains the Microsoft tenant line (profile_data.tenant_id from the id_token tid claim, only when known; other providers unchanged) and an Events subsection documenting SignedInEvent / SignedInEventName with every field, when it fires (login, register, link, pending completion, including a restoring sign-in), Created, the re-read, and that listener errors (and user.Register / user.Created listener errors during social registration) are logged and never fail the sign-in; the Account matching section notes that the pending payload keeps the tenant; Security notes gain the tid trust basis (back-channel id_token, aud must equal the client id, GUID format, signature not checked and why) and the advice to match TenantID together with Provider "microsoft" when assigning organisations by tenant; the API reference adds SignedInEvent, SignedInEventName, Profile.TenantID and PendingPayload.TenantID; the Testing paragraph lists the new tests.
|
||||
|
||||
Commits in ../sm-sociallogin-plugin, explicit paths, no co-author tags: (a) feat: record the Entra tenant ID of a Microsoft sign-in in profile_data (provider.go, providers_goth.go, codes.go, flow.go tenant parts, providers_goth_test.go, fakeprovider_test.go, README); keep tenant assertions that need the event out of this commit; (b) feat: fire SignedInEvent after every social sign-in and log listener failures (events.go, flow.go, registration.go, signin_event_test.go, README). No push.
|
||||
|
||||
Cross-check both plugins together (no repo is modified): write a go.work in your scratchpad directory with go 1.27.0, a use directive with the absolute path of ../sm-sociallogin-plugin, and replace directives sending git.golem15.com/golem15/summercms to the absolute path of summercms.go and git.golem15.com/golem15/sm-user-plugin to the absolute path of ../sm-demo-app/plugins/golem15/user; run the sociallogin suite with GOWORK set to that file and report the result in the SUMMARY.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-sociallogin-plugin && go vet ./... && go test -count=1 -run 'TestGothProviders|TestSignedInEvent|TestSoftDeleted|TestInactiveAndBanned|TestMatching|TestRegisterComplete|TestLink|TestErrorMessages' ./... && go test -count=1 ./... && grep -c 'tenant_id' README.md</automated>
|
||||
</verify>
|
||||
<done>Microsoft sign-ins store profile_data.tenant_id from a validated tid; SignedInEvent fires once per successful login, register, link and pending completion with every field; listener errors are logged and never fail a sign-in; the sociallogin suite is green against the published user plugin (its go.work) and against the Task 1-2 user plugin (scratch GOWORK); two commits; nothing pushed.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 4: cabana ClearOnChange (summercms.go) and the user plugin's stale-role fix on top of it</name>
|
||||
<files>modules/cabana/relation.go, modules/cabana/relation_field.go, modules/cabana/crud.go, modules/cabana/clear_on_change_test.go, modules/cabana/README.md, docs/backend/relation-manager.md, ../sm-demo-app/plugins/golem15/user/controllers/organisations_admin_controller.go, ../sm-demo-app/plugins/golem15/user/controllers/users_admin_controller.go, ../sm-demo-app/plugins/golem15/user/admin_organisations_edge_test.go, ../sm-demo-app/plugins/golem15/user/README.md</files>
|
||||
<precondition>Phase 19.8 has released (summercms.go tag v0.4.2 exists and 19.8-06 is complete), or the user has explicitly allowed this cabana commit into the 19.8 release range; and git status --porcelain -- modules/cabana modules/pact docs/backend in summercms.go is empty (no other session editing those paths).</precondition>
|
||||
<behavior>
|
||||
- clear_on_change_test.go, hasMany (Postgres, the relation_child_test.go fixtures): a RelationContract with ClearOnChange naming a nullable child column; link sets the foreign key and clears that column on the linked child; unlink clears the foreign key and the column; a contract without ClearOnChange leaves the column untouched (existing behaviour); a deferred link committed by the parent's first save clears it too.
|
||||
- clear_on_change_test.go, belongsTo form field: a FieldRelationContract with ClearOnChange; an update that changes the key (to another record, and to empty) clears the column; an update that keeps the key leaves it; an update that changes the key and also submits the column through its own form field keeps the submitted value; a create that sets the key and does not submit the column clears it.
|
||||
- clear_on_change_test.go, boot: ClearOnChange on a belongsToMany field contract or a belongsToMany relation contract, a column the model does not have, a non-pointer (not nullable) column, the ForeignKey itself, or the primary key each stop the start-up with an error naming the controller and the column.
|
||||
- user plugin admin_organisations_edge_test.go:183-213 flipped and renamed TestAdminOrganisationMembershipClearsRole: after the members-manager unlink both organisation_id and organisation_role are NULL; after linking into the second organisation the role stays NULL; an organisation-less user with a leftover role, linked through the members manager, ends with role NULL; changing a user's organisation in the Users form to another organisation, and to none, clears the role; a Users form save that keeps the organisation keeps the role; deleting an organisation still clears both (existing part).
|
||||
</behavior>
|
||||
<action>
|
||||
Framework, in summercms.go (OD-3). Add ClearOnChange []string to cabana.RelationContract (modules/cabana/relation.go:38-58) and to cabana.FieldRelationContract (modules/cabana/relation_field.go:22-45), each with a doc comment: nullable columns of the related child (hasMany) or of the record (belongsTo) set to NULL whenever this relation changes the foreign key, so values that only make sense for one parent (a role in an organisation, for example) never move to another or outlive the membership; the zero value changes nothing.
|
||||
|
||||
Validation at boot: in validateHasManyContract / validateRelationContract (relation.go:479 and :558) and in compileFieldRelation (relation_field.go:205), refuse ClearOnChange on a belongsToMany contract, and refuse any listed column that the model does not have (fieldByColumn over modelFields), whose field is not a pointer, or that is the contract's ForeignKey or the model's primary key; the error names the controller and the column, in the style of the existing contract errors.
|
||||
|
||||
Application: in linkRelated's hasMany branch (relation.go:917-975, before the Save at :955) and in unlinkRelated's hasMany branch (:1231-1260, before the Save at :1250), after setModelColumn sets the foreign key, set each ClearOnChange column of the child to nil with setModelColumn (link and unlink always change the key: link adopts only children whose key is NULL, unlink only children of this parent). In assignBelongsTo (relation_field.go:648-663), before setting the foreign key, read the current key value of the model (fieldByColumn; nil pointer or zero counts as empty) and compare it with the submitted id (or empty); when they differ, set each ClearOnChange column to nil unless the same save writes that column through its own form field. To know that, assignBelongsTo receives the set of fill keys this save wrote: the keys of fillAllowed(cc, target, op) that are present in projected, computed in the save function in crud.go where both are in scope; pass it from the single assignBelongsTo call site in crud.go. No other behaviour changes; the deferred unlink of a not-yet-saved parent writes no child row and needs nothing.
|
||||
|
||||
Tests: modules/cabana/clear_on_change_test.go per the behavior block, reusing the Postgres fixtures and helpers of relation_child_test.go / phase122_fixture_test.go and the form-save helpers of phase121_form_test.go (its TestWritableForeignKeySmoke is the closest model); boot cases next to the style of phase121_schema_boot_test.go. Docker-backed parts skip under -short like their neighbours.
|
||||
|
||||
Docs in the same commit (CLAUDE.md): modules/cabana/README.md, the relations sentence in Features (around :28) and the API reference rows for FieldRelationContract and RelationContract (around :272 and :275) mention ClearOnChange; docs/backend/relation-manager.md gets a short section after the WritableForeignKey paragraph (around :52) for belongsTo fields and a sentence in the hasMany part (around :117-133 and the link/unlink description at :190), with an organisation membership and role as the example and the "unless the same save writes it" rule. If you add a code example through an src= snippet, put it in an example_*_test.go and run summer docs:sync. Run go run ./cmd/summer docs:build --check and go test -run TestDocsTree ./cmd/summer.
|
||||
|
||||
Commit in summercms.go, explicit paths only (other sessions have uncommitted files there; leave them alone), no co-author tags: feat(cabana): ClearOnChange clears dependent columns when a relation changes a foreign key (relation.go, relation_field.go, crud.go, clear_on_change_test.go, README.md, docs/backend/relation-manager.md). go vet ./... and go test -short ./... green (and go test ./modules/cabana/... with Docker); if a failure comes only from another session's uncommitted files, record it in the SUMMARY and do not touch those files. Do not push or tag.
|
||||
|
||||
User plugin, in ../sm-demo-app/plugins/golem15/user (master after Task 2, GOWORK=off; it builds against summercms.go's working tree through the go.mod replace, so it sees the commit above). Set ClearOnChange: organisation_role on the members RelationContract (organisations_admin_controller.go:76-90) and on the organisation FieldRelationContract (users_admin_controller.go:370-388). Update the doc comments at organisations_admin_controller.go:21-27 ("sets and clears users.organisation_id and nothing else") and :73-75, and at users_admin_controller.go:364-369: adding or removing a member, and changing the organisation in the user form, clear organisation_role unless the same save writes it. Flip and rename the known-gap test and add the user-form cases per the behavior block (adminEnv harness: newAdminEnv, admin, call/expect, seedUser, reload; find the users update route in admin_users_test.go), and replace the test's doc comment (12.1-SECURITY-REVIEW open item 6 is closed). README.md: the Organisations bullet (:26) and the Organisations screen bullet (:30) state the role rule; add a Dependencies note that this behaviour needs a summercms version with cabana ClearOnChange (the tag decided at Task 5); update the Testing section. Leave go.mod's summercms require as it is in this commit: the require bump to the released tag is a separate commit at Task 5, once the tag exists.
|
||||
|
||||
Commit in the user-plugin checkout: fix: clear a stale organisation_role when a user's organisation changes in the admin (organisations_admin_controller.go, users_admin_controller.go, admin_organisations_edge_test.go, README.md). This must be the last commit on the user plugin's master, so the earlier fixes can be tagged without it. No push.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./... && go test -count=1 -run 'TestClearOnChange|TestRelationChild|TestWritableForeignKey' ./modules/cabana/... && go test -count=1 -short ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check && cd /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user && GOWORK=off go vet ./... && GOWORK=off go test -count=1 -run 'TestAdminOrganisation|TestAdminUser' ./... && GOWORK=off go test -count=1 ./...</automated>
|
||||
</verify>
|
||||
<done>cabana.RelationContract and cabana.FieldRelationContract accept ClearOnChange with boot validation, documented in the cabana README and docs/backend/relation-manager.md, tested on Postgres; the user plugin clears organisation_role on member add/remove and on a user-form organisation change; the known-gap test asserts the closed gap; framework short suite, docs checker and user-plugin suite green; one commit in summercms.go and one (last) commit in the user plugin; nothing pushed or tagged.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 5: Go-ahead for push, tags, changelog and submodule pointer bumps</name>
|
||||
<decision>Whether and how far to publish: the summercms.go cabana commit, the user-plugin commits (two releasable sets), the sociallogin commits, and the submodule pointers in the apps.</decision>
|
||||
<context>
|
||||
Local commits after Tasks 1-4. sm-user-plugin master (../sm-demo-app/plugins/golem15/user): Task 1 (two commits) and Task 2 (one) need only summercms v0.4.0; Task 4's commit, the last one, needs a summercms tag containing ClearOnChange. sm-sociallogin-plugin master: Task 2 (one) and Task 3 (two); go.mod unchanged (still requires sm-user-plugin v0.1.5 and summercms v0.3.1). summercms.go master: one cabana commit after the 19.8 release. Review with git log --oneline origin/master..master in each repo.
|
||||
Publishing steps for publish-all, in this order (CLAUDE.md: framework and plugins first, then each app's pointer bump as its own commit; ssu:status / ssu:push can drive the submodule parts):
|
||||
(1) summercms.go: git fetch, confirm origin/master..master holds the cabana commit (plus planning docs), vet, short suite and docs checker green, push master, annotated tag v0.4.3 (proposed: the tag after 19.8's v0.4.2), push the tag, and the same-day changelog post from ../sm-summercmsio-app with scripts/changelog-post.py (source, then publish with user-facing notes; never print the token).
|
||||
(2) sm-user-plugin: tag v0.1.7 at the Task 2 commit (registration allow-list, listener logging, offboarding; compatible with summercms v0.4.0); then a commit "chore: require summercms v0.4.3" (go mod edit of the require, then GOWORK=off go mod tidy once the tag is fetchable, tests green), tag v0.1.8 there (adds the stale-role fix); push master and both tags.
|
||||
(3) sm-sociallogin-plugin: push master, tag v0.1.3, push the tag.
|
||||
(4) Apps, one commit per app and plugin, staging only the pointer and go.mod/go.sum: the user plugin in fonoteka.go, sm-bm-app, sm-demo-app, sm-grzybyfunkcjonalne-app and sm-summercmsio-app; sociallogin in sm-bm-app and sm-demo-app. An app moving to user v0.1.8 also needs summercms v0.4.3 or newer (bump its summercms require where it pins one); an app that cannot move its framework yet takes v0.1.7. Run each app's go build ./... before committing; leave other sessions' edits alone; push each app.
|
||||
</context>
|
||||
<options>
|
||||
<option id="publish-all">
|
||||
<name>Release the framework (v0.4.3 + changelog), both user-plugin tags, sociallogin, then bump every app</name>
|
||||
<pros>All fixes, the stale-role cleanup included, reach every app.</pros>
|
||||
<cons>Needs 19.8 released first and a framework bump in each app taking v0.1.8; touches five app repos where other sessions may be working.</cons>
|
||||
</option>
|
||||
<option id="plugins-first">
|
||||
<name>Push the user plugin up to the Task 2 commit only (git push origin with that commit as master), tag v0.1.7; push and tag sociallogin v0.1.3; keep the framework and stale-role commits local</name>
|
||||
<pros>The register, offboarding, tenant and event fixes ship now with no framework release.</pros>
|
||||
<cons>The stale-role fix waits for the framework release; app pointer bumps are still separate decisions.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Keep everything local</name>
|
||||
<pros>Nothing leaves the machine; the plan can still change.</pros>
|
||||
<cons>The gaps stay open in every app.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Select: publish-all (optionally naming which apps), plugins-first, or hold.</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| anonymous client -> POST /_user/api/v1/register | untrusted JSON body is mass-assigned into the users row |
|
||||
| session JWT holder -> POST /_user/api/v1/refresh | a token of a user who has since been deactivated, banned or cut off |
|
||||
| personal API token holder -> plugin APIs (APITokenGuard) | long-lived secret of a user who has since been deactivated or banned |
|
||||
| OAuth provider (Microsoft token endpoint, Graph) -> sociallogin | identity, email and tenant asserted by the provider |
|
||||
| administrator -> cabana Organisations / Users screens | membership changes that must not carry privileges across organisations |
|
||||
| sociallogin -> application listeners | plugin code reacting to sign-ins; failures must not change the sign-in outcome |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-2zy-01 | E | /register via RegisterUser mass assignment (organisation fields, is_onboarded, must_change_password) | critical | mitigate | Task 1: explicit RegisterFillable allow-list; TestRegisterIgnoresProtectedFields over HTTP and RegisterUser |
|
||||
| T-2zy-02 | S | created_ip_address / last_ip_address from the body when RegisterOptions.IP is empty | low | mitigate | Task 1: IP columns set only from RegisterOptions.IP after the fill |
|
||||
| T-2zy-03 | E | organisation_role kept after a membership change (members manager, Users form) | high | mitigate | Task 4: cabana ClearOnChange on both contracts; flipped known-gap test plus form cases |
|
||||
| T-2zy-04 | E | controllers.Refresh sliding sessions of deactivated, banned or cut-off users | high | mitigate | Task 2: subject check after bouncer.Refresh (scoped load, tokens_valid_after, ThrottleStates ban); offboarding_test.go |
|
||||
| T-2zy-05 | E | session JWTs and user_api_tokens surviving deactivate/ban and reviving on restore/unban | high | mitigate | Task 2: RevokeUserAccess from DeactivateUsers and BanUsers; restore/unban revive nothing |
|
||||
| T-2zy-06 | E | a banned account restored or signed in socially | high | mitigate | Existing checkCanSignIn ban check runs before FinishLogin; Task 2 pins it for banned-and-deactivated accounts on every path |
|
||||
| T-2zy-07 | S | Entra tid read from an id_token without signature verification | medium | mitigate | Task 3: only from the back-channel token response over TLS, aud must equal the client id, GUID format, empty otherwise; README trust note; apps pair TenantID with Provider microsoft |
|
||||
| T-2zy-08 | I | new log lines | medium | mitigate | Tasks 1 and 3: log only provider, user_id and err; never id_token, access token, code or secrets (TestSettings no-secret-in-logs stays green) |
|
||||
| T-2zy-09 | R | discarded user.Register / user.Created / sign-in listener errors | low | mitigate | Tasks 1 and 3: Error-level log lines with user_id; control flow unchanged |
|
||||
| T-2zy-10 | D | suspension (failed-password throttle) used as a refresh refusal | medium | accept | Refresh checks bans only, so failing logins against an email cannot log its owner out |
|
||||
| T-2zy-11 | E | deactivated account reactivated by its owner (password or social sign-in) | low | accept | User decision OD-1: deactivation is self-service and reversible; a ban is the lock and is refused everywhere |
|
||||
| T-2zy-12 | E | a JWT minted within the same second before a revocation stays valid until it expires | low | accept | Cutoff truncated to whole seconds (JWT iat precision) so a reactivating login in that second works; refresh and login still refuse bans |
|
||||
| T-2zy-SC | T | npm/pip/cargo/go installs | low | accept | No new dependency: golang-jwt/jwt/v5 (user plugin), golang.org/x/oauth2 and goth (sociallogin) are already direct requirements; cabana uses only existing code |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- User plugin (../sm-demo-app/plugins/golem15/user): GOWORK=off go vet ./... and GOWORK=off go test -count=1 ./... green with Docker after every task.
|
||||
- Sociallogin (../sm-sociallogin-plugin): go vet ./... and go test -count=1 ./... green with its own go.work, and the scratch-GOWORK run against the modified user plugin green.
|
||||
- summercms.go after Task 4: go vet ./..., go test -short ./..., go test ./modules/cabana/... (Docker), TestDocsTree and summer docs:build --check green; only the Task 4 paths staged.
|
||||
- No push, tag, changelog post or app commit before the Task 5 decision.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 2zy-1: a /register body sets only name, surname, email, password, preferred_locale and marketing_consent; IP columns are server-set; admin and application flows unchanged.
|
||||
- 2zy-2: Microsoft identities carry profile_data.tenant_id; SignedInEvent carries TenantID.
|
||||
- 2zy-3: SignedInEvent fires on every successful social sign-in with provider and identity; register, created and sign-in listener errors are logged.
|
||||
- 2zy-4: deactivation ends sessions and API tokens and is undone only by the owner signing in again (fresh token) or an administrator; a ban is refused by login, social sign-in and refresh and revokes sessions and API tokens; /refresh refuses deactivated, banned and cut-off subjects.
|
||||
- 2zy-5: cabana ClearOnChange exists and is documented; adding or removing a member and changing the organisation in the Users form clear organisation_role; the known-gap test asserts the closed gap.
|
||||
- Both plugin READMEs document the allow-list, the new event, the profile_data key, the refresh rule and the deactivation/ban semantics in the commits that change them; the cabana README and docs/backend/relation-manager.md document ClearOnChange in the framework commit.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/261022-2zy-user-and-sociallogin-plugin-security-gap/261022-2zy-SUMMARY.md` when done, listing every commit hash per repo (summercms.go, sm-user-plugin, sm-sociallogin-plugin), the scratch-GOWORK cross-check result, anything skipped because another session had the files open, and the Task 5 outcome.
|
||||
</output>
|
||||
@@ -0,0 +1,149 @@
|
||||
---
|
||||
phase: quick-261022-2zy
|
||||
plan: 01
|
||||
quick_id: 261022-2zy
|
||||
subsystem: sm-user-plugin, sm-sociallogin-plugin, cabana
|
||||
tags: [security, registration, offboarding, entra, events, cabana, relations]
|
||||
status: complete
|
||||
requires: [summercms v0.4.2 released]
|
||||
provides:
|
||||
- User.RegisterFillable (registration allow-list)
|
||||
- classes.RevokeUserAccess; deactivate/ban revoke sessions and API tokens; /refresh subject check
|
||||
- sociallogin Profile.TenantID, PendingPayload.TenantID, profile_data.tenant_id
|
||||
- sociallogin classes.SignedInEvent / SignedInEventName
|
||||
- cabana RelationContract.ClearOnChange and FieldRelationContract.ClearOnChange
|
||||
affects: [fonoteka.go, sm-bm-app, sm-demo-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app]
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: [explicit registration allow-list, revocation cutoff in whole seconds, declarative ClearOnChange on relation contracts]
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-demo-app/plugins/golem15/user/offboarding_test.go
|
||||
- ../sm-sociallogin-plugin/classes/events.go
|
||||
- ../sm-sociallogin-plugin/signin_event_test.go
|
||||
- ../sm-sociallogin-plugin/tenant_test.go
|
||||
- modules/cabana/clear_on_change_test.go
|
||||
- modules/cabana/clear_on_change_field_test.go
|
||||
modified:
|
||||
- ../sm-demo-app/plugins/golem15/user/models/user.go
|
||||
- ../sm-demo-app/plugins/golem15/user/controllers/registration.go
|
||||
- ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go
|
||||
- ../sm-demo-app/plugins/golem15/user/classes/admin_actions.go
|
||||
- ../sm-demo-app/plugins/golem15/user/controllers/organisations_admin_controller.go
|
||||
- ../sm-demo-app/plugins/golem15/user/controllers/users_admin_controller.go
|
||||
- ../sm-sociallogin-plugin/classes/flow.go
|
||||
- ../sm-sociallogin-plugin/classes/providers_goth.go
|
||||
- ../sm-sociallogin-plugin/classes/registration.go
|
||||
- modules/cabana/relation.go
|
||||
- modules/cabana/relation_field.go
|
||||
- modules/cabana/crud.go
|
||||
decisions:
|
||||
- "Registration fills only User.RegisterFillable (name, surname, email, password, preferred_locale, marketing_consent); IP columns only from RegisterOptions.IP; Fillable() unchanged"
|
||||
- "RevokeUserAccess cutoff is time.Now() truncated to whole seconds (JWT iat precision)"
|
||||
- "/refresh refuses missing/deactivated, banned and pre-tokens_valid_after subjects with the existing 401 body; suspension is not checked"
|
||||
- "Microsoft provider exchanges the code itself to keep the id_token; tid trusted only with aud == client id and GUID format"
|
||||
- "cabana ClearOnChange is applied only on hasMany link/unlink and on a belongsTo key change in the form save; created-child commits are untouched"
|
||||
metrics:
|
||||
duration: ~95 min
|
||||
completed: 2026-10-22
|
||||
actuals:
|
||||
tokens: 46000
|
||||
tasks: 4
|
||||
commits: 1
|
||||
plan_head_before: 9563bc8
|
||||
plan_head_after: 9516a2b
|
||||
---
|
||||
|
||||
# Quick 261022-2zy: user and sociallogin plugin security gaps Summary
|
||||
|
||||
Registration allow-list, session and API-token revocation on deactivate/ban with a /refresh subject check, Entra tenant ID and a SignedInEvent in sociallogin, and an additive cabana `ClearOnChange` that clears a stale `organisation_role`. Tasks 1 to 4 are committed locally in three repos. Task 5 (push, tags, changelog, pointer bumps) is waiting for the user's go-ahead.
|
||||
|
||||
`commits`/`plan_head_*` in the frontmatter measure summercms.go only (one framework commit since 9563bc8). The plugin repos are listed below.
|
||||
|
||||
## Commits
|
||||
|
||||
### sm-user-plugin (`../sm-demo-app/plugins/golem15/user`, branch master, base 9986342 = origin/master = v0.1.6)
|
||||
|
||||
| Hash | Subject |
|
||||
|------|---------|
|
||||
| 499b6dc | test: migration rollback counts include the email_verified_at migration (deviation, see below) |
|
||||
| b6b7ac7 | fix: registration fills only an explicit allow-list and never takes the IP columns from the body |
|
||||
| e0b5ffe | fix: log /register listener failures instead of discarding them |
|
||||
| c3712dc | fix: deactivate and ban revoke sessions and API tokens, and /refresh refuses deactivated, banned and cut-off subjects |
|
||||
| aa83969 | fix: clear a stale organisation_role when a user's organisation changes in the admin (last commit; needs summercms with ClearOnChange) |
|
||||
|
||||
### sm-sociallogin-plugin (`../sm-sociallogin-plugin`, master, base 9fd58db = origin/master)
|
||||
|
||||
| Hash | Subject |
|
||||
|------|---------|
|
||||
| 04f4583 | test: a banned account is refused and never restored by social sign-in, and the deactivation semantics are documented |
|
||||
| 5c9f846 | feat: record the Entra tenant ID of a Microsoft sign-in in profile_data |
|
||||
| 4d3b2dd | feat: fire SignedInEvent after every social sign-in and log listener failures |
|
||||
|
||||
go.mod unchanged (still requires sm-user-plugin v0.1.5 and summercms v0.3.1).
|
||||
|
||||
### summercms.go (master, after v0.4.2)
|
||||
|
||||
| Hash | Subject |
|
||||
|------|---------|
|
||||
| 9516a2b | feat(cabana): ClearOnChange clears dependent columns when a relation changes a foreign key |
|
||||
|
||||
Nothing pushed or tagged. The sm-demo-app working tree now shows `M plugins/golem15/user`, the checkout moved to aa83969. That pointer bump is not committed; it belongs to Task 5.
|
||||
|
||||
## Test results
|
||||
|
||||
- User plugin: `GOWORK=off go vet ./...` and `GOWORK=off go test -count=1 ./...` (Docker) green before every commit, the last one included.
|
||||
- Sociallogin: `go vet ./...` and `go test -count=1 ./...` green with its own go.work, which uses the published user plugin at 9986342.
|
||||
- Scratch-GOWORK cross-check: sociallogin against summercms.go's working tree and the modified user plugin (aa83969), using `/tmp/claude-1000/.../scratchpad/go.work`. Green: `ok git.golem15.com/golem15/sm-sociallogin-plugin`.
|
||||
- summercms.go: `go vet ./...`, `go test -count=1 -short ./...`, `go test -count=1 ./modules/cabana/...` (Docker, 82 s), `go test -run '^TestDocsTree$' ./cmd/summer` and `go run ./cmd/summer docs:build --check` all green.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Pre-existing migration rollback test failures in the user plugin**
|
||||
- **Found during:** Task 1, the first full suite run.
|
||||
- **Issue:** `updates/` TestUserGroupsMigration, TestExtendUsersMigration, TestCreateUserThrottleMigration and TestCreateJwtBlacklistMigration failed at origin/master. Their rollback counts were one short since migration 202610210001 (email_verified_at, v0.1.6) was added. This blocked "full suite green at every commit".
|
||||
- **Fix:** Bumped the four rollback counts and their comments. Test-only change, committed separately.
|
||||
- **Commit:** 499b6dc
|
||||
|
||||
**2. [Rule 2] Extra test for the /register listener-error logging**
|
||||
- Added TestRegisterListenerErrors (register_test.go) to commit e0b5ffe. The plan had no test for that commit.
|
||||
|
||||
**3. [Rule 1] TestAdminOrganisationMembers pinned the old behaviour**
|
||||
- admin_organisations_test.go expected a foreign member's role to survive unlink and link. It now expects the unlink to clear the role. Committed with aa83969.
|
||||
|
||||
**4. Test file layout (sociallogin)**
|
||||
- The tenant tests that do not need the event live in a new `tenant_test.go` (TestTenantProfileData) inside commit 5c9f846. The plan kept event-dependent assertions out of that commit; this keeps the tenant behaviour tested in the same commit that adds it.
|
||||
- `fakeprovider_test.go` was not changed: the fake provider already returns whatever `classes.Profile` it is given, so the new `TenantID` field passes through.
|
||||
- To capture mails, TestInactiveAndBanned now boots with the `log` mail driver and an slog handler. A deactivated-without-ban control subtest proves the capture sees the reactivate mail.
|
||||
|
||||
**5. Test file layout (cabana)**
|
||||
- The belongsTo cases are in an internal-package file, `clear_on_change_field_test.go` (TestClearOnChangeFieldBoot, TestClearOnChangeBelongsTo). They use a small dedicated fixture because no roster column is both nullable and fillable.
|
||||
- The hasMany cases are in `clear_on_change_test.go` (TestClearOnChangeRelationBoot, TestClearOnChangeHasMany), on the df fixtures.
|
||||
- The docs checker refuses Go code blocks without `src=`. So the docs example is the existing `example_form_seams_test.go` MembersController team contract, which gained `ClearOnChange: []string{"team_role"}` and a `Member.TeamRole` field; `summer docs:sync` refreshed the snippet. Its Example output is unchanged.
|
||||
- Boot errors name the relation or field and the column, and `bootErr` adds the plugin and controller, as the existing contract errors do.
|
||||
|
||||
**6. ClearOnChange scope**
|
||||
- Applied in linkRelated and unlinkRelated for hasMany, which covers deferred link and unlink because they use the same path, and in assignBelongsTo.
|
||||
- Not applied when a deferred commit attaches a child created in the form session, or when CreateChild sets the key: that would wipe values the admin just entered in the child form ("no other behaviour changes").
|
||||
|
||||
**7. User README dependency note**
|
||||
- It says the role cleanup needs "SummerCMS v0.4.3 or later". If Task 5 picks a different tag, update this line together with the go.mod require bump.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None beyond the plan's threat model. The new log lines carry only provider, user_id and err; the id_token, access token and code are never logged.
|
||||
|
||||
## Task 5: awaiting go-ahead
|
||||
|
||||
Blocking checkpoint:decision. Choose publish-all, plugins-first or hold. Nothing has been pushed, tagged, posted or pointer-bumped.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Files exist: offboarding_test.go, classes/events.go (sociallogin), signin_event_test.go, tenant_test.go, modules/cabana/clear_on_change_test.go, modules/cabana/clear_on_change_field_test.go.
|
||||
- Commits exist: 499b6dc, b6b7ac7, e0b5ffe, c3712dc, aa83969 (user plugin); 04f4583, 5c9f846, 4d3b2dd (sociallogin); 9516a2b (summercms.go).
|
||||
@@ -0,0 +1,367 @@
|
||||
---
|
||||
phase: quick-261022-4z8
|
||||
plan: 01
|
||||
quick_id: 261022-4z8
|
||||
slug: fix-the-open-19-8-re-review-findings-wr-
|
||||
type: execute
|
||||
mode: quick-full
|
||||
wave: 1
|
||||
depends_on: []
|
||||
autonomous: true
|
||||
requirements:
|
||||
- WR-09 # lines lost through a live view after its context is cancelled (verification gap 1)
|
||||
- WR-10 # signal-killed delegate prints raw argv with secrets (verification gap 2)
|
||||
- WR-11 # dash-leading secret values not masked in tryArgs (verification gap 2)
|
||||
- WR-12 # migrate:status --json omits the framework sets (verification gap 3)
|
||||
- WR-13 # migrate exits 1 when only the re-read after a successful Migrate fails
|
||||
- WR-14 # Process's plain path prints item names without Sanitize
|
||||
- IN-14 # ErrReported doc comment still says exit code 1
|
||||
- IN-15 # runLive builds the program before claiming the slot
|
||||
- 4z8-docs # disposition record + todo (IN-10..IN-13, exit-130 suggestion deferred)
|
||||
files_modified:
|
||||
# Task 1: bonfire (IN-14, WR-14, IN-15, WR-09)
|
||||
- modules/bonfire/errors.go
|
||||
- modules/bonfire/helpers.go
|
||||
- modules/bonfire/live.go
|
||||
- modules/bonfire/live_test.go
|
||||
- modules/bonfire/README.md
|
||||
- docs/console/writing-commands.md
|
||||
- scripts/check-phase19.8.sh
|
||||
# Task 2: secret masking (WR-11, WR-10)
|
||||
- modules/bonfire/errors.go
|
||||
- modules/bonfire/errors_test.go
|
||||
- internal/argmask/argmask.go
|
||||
- cmd/summer/runtime.go
|
||||
- cmd/summer/runtime_test.go
|
||||
- docs/console/introduction.md
|
||||
- docs/console/writing-commands.md
|
||||
- scripts/check-phase19.8.sh
|
||||
# Task 3: lagoon (WR-12, WR-13) + records
|
||||
- modules/lagoon/migrations.go
|
||||
- modules/lagoon/commands.go
|
||||
- modules/lagoon/migrations_test.go
|
||||
- modules/lagoon/example_test.go
|
||||
- modules/lagoon/pending_test.go
|
||||
- modules/lagoon/README.md
|
||||
- docs/console/setup-and-maintenance.md
|
||||
- docs/database/migrations.md
|
||||
- scripts/check-phase19.8.sh
|
||||
- .planning/phases/19.8-console-commands-overhaul/19.8-REVIEW-DISPOSITION.md
|
||||
- .planning/todos/pending/2026-10-22-console-review-info-findings.md
|
||||
|
||||
estimate:
|
||||
tokens: 160000
|
||||
raw_tokens: 160000
|
||||
tasks: 3
|
||||
confidence: low # estimate-calibration: factor 1, sample_count 0
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "WR-09: when the context passed to bonfire.Process is cancelled while its live view runs, the view ends with its last frame and is unregistered without waiting for the work function to return; afterwards Output.Println, Printf, Info and bonfire.PrintAbove print plainly (PrintAbove reports false) and none of those lines is lost. printAbove also reports false for a program whose Run has ended on its own."
|
||||
- "WR-14: bonfire.Process's plain path (no animation: -n on a terminal, forced color off a terminal, nested in another view) prints item names through bonfire.Sanitize; the process view's operation label is sanitized too."
|
||||
- "IN-15: runLive claims the single live slot before it builds the bubbletea program; a widget that loses the slot builds no program, so no child context is registered on the caller's context and no TEA_TRACE file is opened."
|
||||
- "IN-14: the ErrReported doc comment says the exit code is 1 or the code given with WithExitCode."
|
||||
- "WR-11: the wrong-directory Try line masks the argument after a secret-named long flag in the '--flag value' form whatever its first character (a value such as -Xk9!q becomes ***); only a following '--' is left unmasked."
|
||||
- "WR-10: summer's error for an app binary killed by a signal or failing to start names the argv with the same masking as the Try line (one shared implementation in internal/argmask), so a --password, --api-token=... or dash-leading secret value never reaches stderr; the exit code for a signal death stays 1. No exported framework identifier is added, removed or changed."
|
||||
- "WR-12: lagoon.Status (and so migrate:status and migrate:status --json) reads migrationSets, the list Migrate and PendingMigrations use: framework sets first, then plugins with migrations; --json lists every pending migration PendingMigrations returns, a pending framework-set migration included. Status keeps its signature and StatusRow its fields."
|
||||
- "WR-13: when Migrate succeeds but the follow-up PendingMigrations read fails, migrate prints a warning, reports every migration that was pending before the run as applied, prints 'Applied N migrations' and exits 0; when both fail it still returns both errors."
|
||||
- "Every code finding is its own commit with its pinning test (assertion RED recorded on the unchanged code), README/docs updated in the same commit; no Co-Authored-By trailer; nothing pushed or tagged."
|
||||
- "19.8-REVIEW-DISPOSITION.md marks WR-09..WR-14, IN-14 and IN-15 fixed with their commit hashes (frontmatter and table, open: 0), notes the WR-05/WR-07/WR-08 remainders (WR-14, WR-12, WR-11), and marks IN-10..IN-13 deferred; the todo carries IN-10..IN-13 and the exit-130 suggestion."
|
||||
artifacts:
|
||||
- path: modules/bonfire/live.go
|
||||
provides: "runLive claims the slot before tea.NewProgram, ends and unregisters the view on ctx cancellation; liveView.done; printAbove refuses a finished program"
|
||||
contains: "case <-v.done:"
|
||||
- path: modules/bonfire/helpers.go
|
||||
provides: "Process's plain path sanitizes item names"
|
||||
contains: "out.Success(Sanitize(item))"
|
||||
- path: modules/bonfire/errors.go
|
||||
provides: "ErrReported comment with WithExitCode; tryArgs delegating to internal/argmask"
|
||||
contains: "argmask.Join"
|
||||
- path: internal/argmask/argmask.go
|
||||
provides: "Join (shell-quoted argv with secret flag values masked) and Quote, shared by bonfire and cmd/summer"
|
||||
contains: "func Join(args []string) string"
|
||||
- path: cmd/summer/runtime.go
|
||||
provides: "runAppCmd names argv through argmask.Join"
|
||||
contains: "argmask.Join(argv)"
|
||||
- path: modules/lagoon/migrations.go
|
||||
provides: "Status built from migrationSets"
|
||||
contains: "func Status(gdb *gorm.DB, plugins []party.Plugin) ([]StatusRow, error)"
|
||||
- path: modules/lagoon/commands.go
|
||||
provides: "migrate warns instead of failing on a failed re-read after success"
|
||||
contains: "Could not re-read the migration history"
|
||||
- path: modules/bonfire/live_test.go
|
||||
provides: "TestPrintAboveAfterCancel (WR-09), TestNestedProcessBuildsNoProgram (IN-15), plain-path cases in TestProcessViewStripsControl (WR-14)"
|
||||
contains: "func TestPrintAboveAfterCancel"
|
||||
- path: cmd/summer/runtime_test.go
|
||||
provides: "TestRunAppCmdMasksSecrets and the masked signal subtest of TestDelegateExitCodePassthrough (WR-10)"
|
||||
contains: "func TestRunAppCmdMasksSecrets"
|
||||
- path: modules/lagoon/pending_test.go
|
||||
provides: "TestMigrateStatusJSON pending framework migration (WR-12), TestMigrateRereadFailureWarns (WR-13)"
|
||||
contains: "func TestMigrateRereadFailureWarns"
|
||||
- path: .planning/phases/19.8-console-commands-overhaul/19.8-REVIEW-DISPOSITION.md
|
||||
provides: "dispositions recorded"
|
||||
contains: "open: 0"
|
||||
key_links:
|
||||
- from: cmd/summer/runtime.go
|
||||
to: internal/argmask/argmask.go
|
||||
via: "runAppCmd wraps every child error as summer: <argmask.Join(argv)>: <err>"
|
||||
pattern: "argmask\\.Join\\(argv\\)"
|
||||
- from: modules/bonfire/errors.go
|
||||
to: internal/argmask/argmask.go
|
||||
via: "tryArgs and the cd line use argmask.Join and argmask.Quote (one masking implementation)"
|
||||
pattern: "argmask\\.(Join|Quote)"
|
||||
- from: modules/lagoon/migrations.go
|
||||
to: modules/lagoon/migrations.go
|
||||
via: "Migrate, Status and PendingMigrations all call migrationSets(gdb, plugins)"
|
||||
pattern: "migrationSets\\(gdb, plugins\\)"
|
||||
- from: modules/bonfire/live.go
|
||||
to: modules/bonfire/helpers.go
|
||||
via: "Process passes its ctx to runLive; runLive's cancel watcher finishes and unregisters the view; printAbove checks liveView.done"
|
||||
pattern: "case <-v\\.done"
|
||||
- from: modules/bonfire/helpers.go
|
||||
to: modules/bonfire/errors.go
|
||||
via: "Process plain path calls Sanitize on the item"
|
||||
pattern: "Sanitize\\(item\\)"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Fix the open findings of the 19.8 code re-review (`19.8-REVIEW.md`): WR-09, WR-10, WR-11 and WR-12 (the three verification gaps in `19.8-VERIFICATION.md`), WR-13, WR-14, IN-14 and IN-15. Then record the dispositions and defer IN-10..IN-13 plus WR-10's exit-130 suggestion to the existing todo.
|
||||
|
||||
Purpose: close the three gaps that keep phase 19.8 at `gaps_found`: lines lost after a cancelled live view, secrets leaking through argv, and `migrate:status --json` missing framework sets. The four quality follow-ups go in the same pass.
|
||||
Output: nine local commits on master. There is one `fix(...)`/`docs(bonfire)` commit per finding, each with its pinning test and README/docs, and then one `docs(19.8)` records commit. Nothing is pushed, tagged, released or posted to the changelog.
|
||||
|
||||
Shape: this is a gap-closure-style quick task. The orchestrator fixed the grouping (three tasks), so there is no tracer task. Each finding follows RED → fix → GREEN → commit, the pattern of plan 19.8-05 (`19.8-05-SUMMARY.md`, "Each review finding: pinning test red on unchanged code, fix, green, one fix(...) commit with test and docs").
|
||||
|
||||
**One commit per finding (hard rule).** Each task holds several findings, and each finding is its own commit, in the order the task lists. The commit subject follows the repo's style and ends with the finding ID in parentheses. There is NO `Co-Authored-By` trailer: the user's CLAUDE.md overrides any attribution reminder. Planning docs and code are never in the same commit.
|
||||
|
||||
Decisions (planner's discretion; the user is asleep and the run is unattended, so the executor does not revisit these and records them in the SUMMARY):
|
||||
|
||||
- **P-01 (WR-09 design).** The live program no longer takes the caller's context. Drop bubbletea's context program option from `tea.NewProgram` in `runLive`. Instead, `runLive` starts a watcher goroutine. When `ctx` is done, the watcher takes `liveMu`, unregisters the view if it is still the active one, sends `finishMsg{err: ctx.Err()}`, waits for `finished` and releases `liveMu`. This is the same unregister/finish/wait-under-one-lock step as the deferred cleanup. When `finished` closes first (Run ended on its own, a signal kill, or the normal end), the watcher only unregisters the view under `liveMu` if it is still active. In addition, `liveView` gets a `done <-chan struct{}` field (runLive's `finished` channel, created before the claim). `printAbove` checks it under `liveMu` after the owner check. When it is closed, `printAbove` unregisters the view and returns false. Why not the review's check-then-Send: a cancellation landing between the check and `Program.Send` would still drop the line, because bubbletea's Send returns as soon as its context is done. With P-01 the program stays alive until the watcher has unregistered it under the lock, so every line is either delivered to a running program or printed plainly. P-01 also renders the final frame with the failed rows, which the review notes the killed program never did. Spinner and Progress pass `context.Background()`, so P-01 does not change them. Fallback: if the graceful finish deadlocks or cannot be made to pass under `-race`, implement the review's version and record the deviation. That version keeps the context option, checks `ctx.Done()` and `done` in `printAbove`, and clears `activeView` when `finished` closes.
|
||||
- **P-02 (IN-15).** Claim first, then build. Under `liveMu`, return `false, nil` if `activeView` is set. Otherwise build the program while still holding the lock, register the view and unlock. `NewProgram` takes no bonfire lock and calls nothing that prints, so this cannot deadlock.
|
||||
- **P-03 (WR-14).** Sanitize at the call sites: `out.Success(Sanitize(item))` in `Process`'s plain path, and `Sanitize(op)` for the process view's operation label. Do not sanitize inside `console.status`: callers pass intentionally styled (ANSI) messages to Info/Success/Warning, and those must keep their styling.
|
||||
- **P-04 (WR-11).** In the `--flag value` form, mask the next argument after a secret-named long flag whenever it exists and is not `--`, whatever its first character. `tryArgs` cannot see flag types, so a secret-named boolean flag over-masks the following argument. That is the safe side. The existing case `sync --token --force` therefore changes from `--token --force` to `--token ***`, and the test comment says why. pflag also takes `--force` as the token's value there (v1.0.10 flag.go, the `'--flag arg'` branch).
|
||||
- **P-05 (WR-10).** The user asked for the same masking as `tryArgs` with no new exported API unless needed. It is not needed: the masking moves to a new internal package `internal/argmask`, importable from both `modules/bonfire` and `cmd/summer` (same module root). It provides `Join(args []string) string` (shell-quoted, space-separated, secret values masked, no leading space) and `Quote(s string) string`, which is bonfire's former `shellQuote`. bonfire keeps the unexported `tryArgs` as a thin wrapper (empty for no args, otherwise a space plus `argmask.Join(args)`). It calls `argmask.Quote` for the `cd` path and drops its own `secretWords`/`secretFlag`/`shellQuote`, which nothing else references (verified by grep at planning time). `runAppCmd` builds its error with `argmask.Join(argv)` in every branch: start failure, signal death, and the already-Reported exit branch. The exit code stays 1 on a signal death (plan 05's CR-02 truth). The exit-130 suggestion goes to the todo.
|
||||
- **P-06 (WR-12).** `Status` iterates `migrationSets(gdb, plugins)`: framework sets first (`StatusRow.Plugin` is the set's history ID, for example `summercms.attach`, the same convention as `MigrationRef.Plugin`), then plugins with at least one migration. A plugin whose `HasMigrations` returns none no longer gets an empty row; Migrate and PendingMigrations skip it too, which keeps the three readers consistent. The signature of `Status`, the `StatusRow` type and its fields are unchanged. No consumer outside this repo calls `lagoon.Status` (grep over the meta repo at planning time). The `migrate:status` Description stays as it is, because changing it would churn the list goldens and the list excerpt in `docs/console/introduction.md`. Its Long help and the docs change. The `--json` builder in commands.go keeps its loop: it now covers framework rows, because `row.Plugin` equals `ref.Plugin` for them.
|
||||
- **P-07 (WR-13).** When `migrateErr == nil` and the re-read fails, `applied` is the pending list from before the run. migrate prints `out.Warning("Could not re-read the migration history: " + bonfire.Sanitize(err.Error()))` (wording may be refined; it must contain `Could not re-read the migration history`), then the per-set lines and `Applied N migrations`, and returns nil. When both fail, it keeps `errors.Join(migrateErr, err)`.
|
||||
- **P-08.** Each new top-level pinning test name joins its list in `scripts/check-phase19.8.sh`, in the same commit as the test, so the phase gate keeps requiring it. Tests extended in place are already listed.
|
||||
- **P-09.** `docs/setup/upgrading.md` is not changed, because no release is cut. The SUMMARY gets a "Release-note input" section for whoever tags next: `lagoon.Status` and `migrate:status` now list framework sets; a cancelled Process view ends with its final frame; secret masking now covers dash-leading values and delegate signal errors.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@CLAUDE.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-REVIEW.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-VERIFICATION.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-05-SUMMARY.md
|
||||
@.planning/phases/19.8-console-commands-overhaul/19.8-REVIEW-DISPOSITION.md
|
||||
@.planning/todos/pending/2026-10-22-console-review-info-findings.md
|
||||
|
||||
Source, read once each, only the regions named:
|
||||
@modules/bonfire/live.go (runLive, liveView, watchSignals, printAbove: about lines 323-487; processModel.View: about lines 287-320)
|
||||
@modules/bonfire/helpers.go (Process: lines 140-180)
|
||||
@modules/bonfire/errors.go (ErrReported: about line 73; wrongDirectory, tryArgs, shellQuote: about lines 250-340)
|
||||
@modules/bonfire/output.go (emit, Println, Printf, status: lines 170-245)
|
||||
@modules/bonfire/live_test.go (lockedBuffer, TestPrintAboveOrderedNoLoss, TestProcessViewStripsControl)
|
||||
@modules/bonfire/errors_test.go (TestWrongDirectoryMasksSecrets, carriesInjected)
|
||||
@modules/bonfire/widgets_test.go (TestTableStripsControl: the console construction with Globals{Color: ColorAlways})
|
||||
@cmd/summer/runtime.go (appStreams, runAppCmd, newAppCmd, runChild: lines 130-238)
|
||||
@cmd/summer/runtime_test.go (TestExitHelperProcess, exitHelperRunner, TestDelegateExitCodePassthrough: lines 290-370)
|
||||
@modules/lagoon/migrations.go (Status, migrationSets, PendingMigrations)
|
||||
@modules/lagoon/commands.go (migrate Run: lines 151-195; migrate:status: lines 255-295)
|
||||
@modules/lagoon/pending_test.go (runLagoon, TestMigrateStatusJSON, TestMigrateAppliedLines)
|
||||
@modules/lagoon/migrations_test.go (TestTwoPluginMigrationSetsIsolated: lines 138-260)
|
||||
@modules/lagoon/example_test.go (migrateBlog, TestDocsMigrate: lines 335-372)
|
||||
@scripts/check-phase19.8.sh (test name lists: lines 110-200)
|
||||
|
||||
Environment rules for every step (from the orchestrator; non-negotiable):
|
||||
- **GOCACHE.** Every go command, including any script that calls go (`scripts/check-phase19.8.sh`), runs with `env GOCACHE=/tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/e016575e-9db1-44e1-a366-55d9ae1a9f8b/scratchpad/gocache`. Below, `$GC` stands for that literal path; write it out in full, because the Bash tool runs fish. Wrap multi-statement scripts or `VAR=$(…)` in `bash -c '…'`. The disk is nearly full: do not create other build caches and do not delete the gocache. The orchestrator deletes it at the end.
|
||||
- **Docker.** Docker-backed tests run with `env GOCACHE=$GC TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 go test …`. If the Docker socket is denied, run the same command through `sudo -u golem` (CLAUDE.md).
|
||||
- **Concurrent sessions.** Record `BASE=$(git rev-parse HEAD)` before the first edit. Before editing any file, run `git status --short -- <file>`. If it shows changes this task did not make, do not edit that file. Record the skipped update in the SUMMARY as a follow-up. Never touch, stage or commit `.planning/STATE.md`, `.planning/state.json`, `.msd/`, any cabana or `docs/backend` file, or other quick or phase directories. Commit with explicit paths only: `git add -- <paths>` then `git commit -m "<subject>" -- <paths>`. The `-- <paths>` form commits only those paths, even if another session has staged something. Never use `git add -A` or `git add .`.
|
||||
- **Foreign test failures.** If `cmd/summer` docs tests (TestDocsTree and others) fail only because of another session's uncommitted files, export clean HEAD with `bash -c 'D=/tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/e016575e-9db1-44e1-a366-55d9ae1a9f8b/scratchpad/clean-head; mkdir -p "$D" && git archive HEAD | tar -x -C "$D"'`, run the failing package's tests there with the same GOCACHE, record both results in the SUMMARY, then remove that export directory.
|
||||
- **Known flake.** `TestOIDCSubjectIsRandomSmoke` (wristband; deferred-items.md) can fail once at random. Rerun it once before treating it as a failure.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: bonfire live views and Process: IN-14, WR-14, IN-15, WR-09 (four commits)</name>
|
||||
<files>modules/bonfire/errors.go, modules/bonfire/helpers.go, modules/bonfire/live.go, modules/bonfire/live_test.go, modules/bonfire/README.md, docs/console/writing-commands.md, scripts/check-phase19.8.sh</files>
|
||||
<behavior>
|
||||
- IN-14: no test (doc comment only).
|
||||
- WR-14 (extend TestProcessViewStripsControl): (a) the existing model case also builds the process model with operation "Push\x1b]0;pwned\x07ing", and the View carries none of the injected sequences while showing "Push" and "ing"; (b) plain path, terminal: newConsole(tty true, interactive true) plus setGlobals(Globals{Color: ColorAlways, NoInteraction: true}), so live() is false and the profile is colored; Process over the item "evil\x1b]0;pwned\x07item" with fn returning nil prints no injected sequence, and "evil" and "item" are visible; (c) plain path off a terminal: tty false plus Globals{Color: ColorAlways}, same assertions. (b) and (c) fail on the unchanged code; TestTableStripsControl shows the colored writer passes OSC and BEL through.
|
||||
- IN-15 (new TestNestedProcessBuildsNoProgram): on a tty console, inside c.Spinner("outer", …), call t.Setenv("TEA_TRACE", <tempdir>/trace.log), then run Process with a test-only counting context over one item "x". The counting context is a type whose Done returns a non-nil channel that is never closed, whose Err and Value return nil, and which implements AfterFunc(f func()) (stop func() bool), counting registrations minus stops under a mutex. context.WithCancel registers a child through the parent's AfterFunc when the parent is not a stdlib cancel context. Assert: fn ran once; the trace file does not exist; outstanding registrations are 0; the "x" success line reached the buffer. On the unchanged code NewProgram opens the trace file and registers one child that is never stopped, so the test fails.
|
||||
- WR-09 (new TestPrintAboveAfterCancel): use a tty console on a lockedBuffer, ctx and cancel from context.WithCancel, and items "first", "second", "third". fn records each item it gets. On "second" it calls cancel(), then polls for up to 2s (every 5ms) until activeView is nil, reading it under liveMu; if the view is still registered it reports t.Errorf("view still registered … after the cancellation"). Then it calls c.Println("after-cancel-println"), c.Info("after-cancel-info") and PrintAbove("after-cancel-printabove"). PrintAbove must report false; when it does, fn writes "after-cancel-printabove\n" to the buffer, as diary does. fn then returns nil. After Process: errors.Is(err, context.Canceled) is true; fn never got "third"; ansi.Strip(output) contains each of the three after-cancel lines exactly once; PrintAbove("after") is false. On the unchanged code the poll times out, PrintAbove reports true and the lines are lost (the review reproduced this 3 of 3 on a pty), so the test fails without a sleep-based race.
|
||||
</behavior>
|
||||
<action>
|
||||
Work in this order. Each step is one commit. Before the first edit, record BASE (see context). For every code finding, write or extend the test first and run it on the unchanged code. Paste the failing output (the RED) into your notes for the SUMMARY. Only then fix, run it GREEN, update the docs and commit.
|
||||
|
||||
1. **IN-14 (doc only, commit 1).** In modules/bonfire/errors.go, change the ErrReported doc comment so it says the error block prints nothing and the exit code is 1, or the code given with WithExitCode (review IN-14 fix text). The `var ErrReported` line itself stays byte-identical. No test. Commit: `docs(bonfire): the ErrReported comment names the WithExitCode code (IN-14)`, paths modules/bonfire/errors.go.
|
||||
|
||||
2. **WR-14 (commit 2).** Extend TestProcessViewStripsControl in modules/bonfire/live_test.go as described in behavior (a)-(c), reusing carriesInjected from errors_test.go. It is the same package, `bonfire`. Run it RED. Fix per P-03: in modules/bonfire/helpers.go, the plain path's success line becomes out.Success(Sanitize(item)). The failure line already goes through failureText. In modules/bonfire/live.go processModel.View, sanitize the operation label (`op`) before rendering. Update modules/bonfire/README.md line 30 ("Table cells, process-view item names and errors, and failure lines are stripped…") to cover Process item names on both paths and the operation label. writing-commands.md:155 already promises this, so leave it. Commit: `fix(bonfire): strip control characters from Process's plain item lines and the view's label (WR-14)`, paths helpers.go, live.go, live_test.go, README.md.
|
||||
|
||||
3. **IN-15 (commit 3).** Add TestNestedProcessBuildsNoProgram to modules/bonfire/live_test.go per behavior and run it RED. Fix per P-02 in runLive: take liveMu; if activeView is set, unlock and return false, nil. Otherwise build the program with the existing options, set activeView and unlock. Keep the rest of runLive unchanged in this commit. Update runLive's doc comment: it claims the slot before building the program, so a widget that loses the slot builds no program. Add TestNestedProcessBuildsNoProgram to the bonfire list in scripts/check-phase19.8.sh (the array that ends with `TestProcessViewStripsControl TestTableStripsControl`), under a comment line `# quick 261022-4z8 (re-review fixes): IN-15, WR-09.` Commit: `fix(bonfire): claim the live view slot before building the program (IN-15)`, paths live.go, live_test.go, scripts/check-phase19.8.sh.
|
||||
|
||||
4. **WR-09 (commit 4).** Add TestPrintAboveAfterCancel per behavior and run it RED. It must fail on the poll and on the lost lines, not hang. Fix per P-01 in modules/bonfire/live.go:
|
||||
- (a) Add `done <-chan struct{}` to liveView, with a field comment. Create `finished` before the claim and set it on the view.
|
||||
- (b) Remove bubbletea's context program option from the NewProgram call, so the program's lifetime is the view's.
|
||||
- (c) After `go prog.Run()`, start the watcher goroutine described in P-01. Select on ctx.Done() and finished. The ctx branch unregisters, sends finishMsg{err: ctx.Err()} and waits for finished, all while holding liveMu. The finished branch only unregisters under liveMu. Both branches clear activeView only when it is still this view. A nil ctx.Done() (Background) just waits for finished.
|
||||
- (d) In printAbove, read activeView into a local named `v`, as in the review's sketch. After the owner check, add a non-blocking select with a `case <-v.done:` branch. When `done` is closed, set activeView to nil and return false.
|
||||
- (e) Update the doc comments of runLive, liveView and printAbove. Say that cancelling ctx ends the view with its last frame and unregisters it without waiting for work, so lines printed after the cancellation print plainly below it. Say also that a program whose Run ended on its own is never sent lines.
|
||||
|
||||
Check that the deferred cleanup still works when the watcher has already finished the program. Its Send returns at once (Run's deferred cancel) and finished is closed. Keep watchSignals as it is.
|
||||
|
||||
Run TestPrintAboveAfterCancel, TestNestedProcessBuildsNoProgram, TestPrintAboveOrderedNoLoss, TestPrintlnStaysWithItsConsole, TestLiveViewSingleOwner, TestLiveViewPanicUnregisters, TestLiveViewRestoresTerminalOnSignal, TestPrintAbove and TestLiveModels with -race -count=3. All must pass.
|
||||
|
||||
Docs: in modules/bonfire/README.md line 30 and docs/console/writing-commands.md line 77, add one sentence: when the context given to bonfire.Process is cancelled, the view ends with its last frame and later lines print plainly below it. Add TestPrintAboveAfterCancel to the same gate list line as IN-15.
|
||||
|
||||
Commit: `fix(bonfire): end a live view when its context is cancelled and print later lines plainly (WR-09)`, paths live.go, live_test.go, README.md, docs/console/writing-commands.md, scripts/check-phase19.8.sh.
|
||||
|
||||
After each commit, run `env GOCACHE=$GC go vet ./modules/bonfire/` and `env GOCACHE=$GC go test -short ./modules/bonfire/ ./modules/diary/`. diary routes console records through bonfire.PrintAbove.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>env GOCACHE=/tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/e016575e-9db1-44e1-a366-55d9ae1a9f8b/scratchpad/gocache go test -count=3 -race ./modules/bonfire/ -run 'TestPrintAboveAfterCancel|TestNestedProcessBuildsNoProgram|TestProcessViewStripsControl|TestPrintAboveOrderedNoLoss|TestPrintlnStaysWithItsConsole|TestLiveViewSingleOwner|TestLiveViewPanicUnregisters|TestLiveViewRestoresTerminalOnSignal|TestPrintAbove$|TestLiveModels' && env GOCACHE=/tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/e016575e-9db1-44e1-a366-55d9ae1a9f8b/scratchpad/gocache go test -short -count=1 ./modules/bonfire/ ./modules/diary/ && env GOCACHE=/tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/e016575e-9db1-44e1-a366-55d9ae1a9f8b/scratchpad/gocache go vet ./modules/bonfire/ && grep -v '^\s*//' modules/bonfire/live.go | grep -c 'WithContext(' | grep -qx 0 && git log --format=%s -4 > /tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/e016575e-9db1-44e1-a366-55d9ae1a9f8b/scratchpad/t1-subjects && grep -c '(IN-14)\|(WR-14)\|(IN-15)\|(WR-09)' /tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/e016575e-9db1-44e1-a366-55d9ae1a9f8b/scratchpad/t1-subjects | grep -qx 4</automated>
|
||||
</verify>
|
||||
<done>Four commits exist, in the order IN-14, WR-14, IN-15, WR-09, each touching only its listed paths and none with a Co-Authored-By trailer. The RED output of WR-14, IN-15 and WR-09 on unchanged code is captured for the SUMMARY. The bonfire tests pass with -race -count=3. TestPrintAboveAfterCancel and TestNestedProcessBuildsNoProgram are in the gate's bonfire list. The README and writing-commands.md describe the cancel behaviour and the sanitized plain path.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: secret masking: WR-11, WR-10 (two commits)</name>
|
||||
<files>modules/bonfire/errors.go, modules/bonfire/errors_test.go, internal/argmask/argmask.go, cmd/summer/runtime.go, cmd/summer/runtime_test.go, docs/console/introduction.md, docs/console/writing-commands.md, scripts/check-phase19.8.sh</files>
|
||||
<behavior>
|
||||
- WR-11 (extend TestWrongDirectoryMasksSecrets): new cases. `admin:create --email a@b.c --password -Xk9!q` gives `cd ..; {bin} admin:create --email a@b.c --password ***`. `sync --client-secret -s3cr3t --verbose` gives `cd ..; {bin} sync --client-secret *** --verbose`. `sync --token -- x` gives `cd ..; {bin} sync --token -- x`. The existing case `sync --token --force` now expects `cd ..; {bin} sync --token ***` (P-04; the test comment says pflag takes `--force` as the value). Add "-Xk9" and "-s3cr3t" to the list of secrets that must not appear. The new dash cases fail on the unchanged code.
|
||||
- WR-10 (new TestRunAppCmdMasksSecrets; skipped on Windows like its neighbours). argv is `admin:create --email a@b.c --password hunter2 --api-token=t0k3n --client-secret -Xk9q`. Subtest "killed by a signal": newAppCmd(ctx, os.Args[0], ".", []string{"-test.run=^TestExitHelperProcess$"}, io.Discard, io.Discard) with SUMMER_EXIT_HELPER=sigkill appended to cmd.Env, then runAppCmd(cmd, argv). The error is not ErrReported, contains "signal: killed", "--password ***", "--api-token=***" and "--client-secret ***", and contains none of hunter2, t0k3n, -Xk9q. Subtest "start failure": newAppCmd with a binary path inside t.TempDir() that does not exist; same masking assertions. Extend TestDelegateExitCodePassthrough: give its run helper an args parameter, keep the two existing subtests unchanged, and add a subtest "killed by a signal masks secrets". It runs `migrate --password hunter2` through bonfire.Execute with the sigkill helper and asserts exit 1 (bonfire.ExitFailure; P-05 keeps it), exactly one " ERROR " block, "--password ***" in stderr, and no "hunter2" anywhere. All new assertions fail on the unchanged code (raw join of argv).
|
||||
</behavior>
|
||||
<action>
|
||||
Do WR-11 first, then WR-10. Each is its own commit.
|
||||
|
||||
1. **WR-11 (commit 5).** Extend TestWrongDirectoryMasksSecrets per behavior and run it RED. Fix per P-04 in tryArgs (modules/bonfire/errors.go): in the `--flag value` branch, mask args[i+1] when it exists and is not exactly `--`, and drop the leading-dash exclusion. Update tryArgs's doc comment: the next argument is masked whatever its first character, except `--`; a secret-named boolean flag over-masks its follower, which is the safe side. docs/console/writing-commands.md line 205 already promises masking. Add "(also a value that starts with `-`)" after `--password ***` there, so the docs state the rule. Commit: `fix(bonfire): mask secret flag values that start with a dash (WR-11)`, paths errors.go, errors_test.go, docs/console/writing-commands.md.
|
||||
|
||||
2. **WR-10 (commit 6).** Add TestRunAppCmdMasksSecrets and the TestDelegateExitCodePassthrough subtest per behavior, and run them RED. Fix per P-05:
|
||||
- (a) Create internal/argmask/argmask.go with a package doc comment ("Package argmask renders a command line for messages with the values of secret-named long flags masked; bonfire's wrong-directory hint and summer's delegate errors share it"). Move bonfire's secret-word list, the secret-flag check, the masking loop (as `Join`, which joins with single spaces and has no leading space) and the shell quoting (as `Quote`) into it unchanged, apart from the WR-11 rule now in place. It imports only the standard library.
|
||||
- (b) In modules/bonfire/errors.go, tryArgs returns "" for no args and otherwise a space followed by argmask.Join(args). The `cd` path uses argmask.Quote. Delete bonfire's now-unused secretWords, secretFlag and shellQuote, after grepping the package (tests included) to confirm nothing else uses them. TestWrongDirectoryMasksSecrets and TestWrongDirectoryHint must pass unchanged.
|
||||
- (c) In cmd/summer/runtime.go runAppCmd, compute `args := argmask.Join(argv)` once and build the wrapped error from it in place of the raw join. The `strings` import may become unused; remove it if so. Update runAppCmd's doc comment: the error names the command with secret-looking flag values masked; a binary ended by a signal keeps exit code 1.
|
||||
- (d) Add TestRunAppCmdMasksSecrets to the SUMMER list in scripts/check-phase19.8.sh, after the line holding TestDelegateExitCodePassthrough, with a comment `# quick 261022-4z8 (re-review fixes): WR-10.`
|
||||
- (e) Docs: in docs/console/introduction.md line 146, extend the sentence about a binary killed by a signal. The block names the command with the values of secret-looking flags masked as `***`. Keep exit code `1` in the text.
|
||||
|
||||
Run the API check (see verification) to confirm no exported identifier changed. Commit: `fix(summer): mask secret flag values in a delegate's error (WR-10)`, paths internal/argmask/argmask.go, modules/bonfire/errors.go, cmd/summer/runtime.go, cmd/summer/runtime_test.go, docs/console/introduction.md, scripts/check-phase19.8.sh.
|
||||
|
||||
After each commit, run `env GOCACHE=$GC go vet ./internal/argmask/ ./modules/bonfire/ ./cmd/summer/` and the verify command below.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>env GOCACHE=/tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/e016575e-9db1-44e1-a366-55d9ae1a9f8b/scratchpad/gocache go test -count=1 ./modules/bonfire/ -run 'TestWrongDirectory' && env GOCACHE=/tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/e016575e-9db1-44e1-a366-55d9ae1a9f8b/scratchpad/gocache go test -count=1 ./cmd/summer/ -run 'TestDelegateExitCodePassthrough|TestRunAppCmdMasksSecrets|TestDelegateStreams|TestAppStreams|TestRunChildForwardsSignals' && env GOCACHE=/tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/e016575e-9db1-44e1-a366-55d9ae1a9f8b/scratchpad/gocache go vet ./internal/argmask/ ./modules/bonfire/ ./cmd/summer/ && grep -c 'strings.Join(argv' cmd/summer/runtime.go | grep -qx 0 && grep -q 'argmask.Join(argv)' cmd/summer/runtime.go</automated>
|
||||
</verify>
|
||||
<done>Two commits exist, WR-11 then WR-10, with no trailers. RED output is captured for both. internal/argmask is the only masking implementation, used by bonfire's Try line and by runAppCmd. A signal-killed or unstartable delegate's error never contains a secret value, and a signal death still exits 1. The API check shows no exported identifier removed or changed and none added under modules/. introduction.md and writing-commands.md describe the masking.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: lagoon WR-12 and WR-13 (two commits), then the records commit</name>
|
||||
<files>modules/lagoon/migrations.go, modules/lagoon/commands.go, modules/lagoon/migrations_test.go, modules/lagoon/example_test.go, modules/lagoon/pending_test.go, modules/lagoon/README.md, docs/console/setup-and-maintenance.md, docs/database/migrations.md, scripts/check-phase19.8.sh, .planning/phases/19.8-console-commands-overhaul/19.8-REVIEW-DISPOSITION.md, .planning/todos/pending/2026-10-22-console-review-info-findings.md</files>
|
||||
<precondition>Docker is reachable for testcontainers: `docker info` succeeds, or `sudo -u golem docker info` does. Otherwise stop: WR-12/WR-13 tests are Docker-only.</precondition>
|
||||
<behavior>
|
||||
- WR-12 (extend TestMigrateStatusJSON, Docker). After Migrate(gdb, [alpha]), delete the newest attach history row: the ID of the last entry of attach.Migrations, from the table HistoryTableName("summercms.attach"). That migration is now pending. Compute pending := PendingMigrations(gdb, plugins). For both arg variants, check three things. (1) The entries with applied false, in order, equal pending (plugin and id), each with Table equal to HistoryTableName(ref.Plugin). (2) An entry {summercms.attach, <deleted id>, applied false} is present. (3) The entries whose plugin starts with "demo." equal the old want list exactly. It fails on the unchanged code because the framework entry is missing.
|
||||
- WR-12 existing tests updated to the new Status contract (not RED tests). TestTwoPluginMigrationSetsIsolated (migrations_test.go) asserts the framework rows come first in migrationSets order (summercms.attach, summercms.cabana, DeferredHistoryID, QueueHistoryID). It then checks the demo.alpha and demo.beta rows by Plugin, not by index 0 and 1, with all existing ID assertions kept. TestDocsMigrate (example_test.go, package lagoon_test) asserts that the last line is the existing acme.blog want line and the lines before it start with lagoon's framework set IDs. Leave the docs region code between `docs:start migrate` and `docs:end migrate` byte-identical, so docs/database/migrations.md needs no `summer docs:sync`.
|
||||
- WR-13 (new TestMigrateRereadFailureWarns, Docker, dedicatedDB "lagoon_reread_warns"). Use plugin demo.rereadfirst, whose one migration creates a table, then plugin demo.rereadlast, whose one migration runs `ALTER TABLE <HistoryTableName("demo.rereadfirst")> RENAME COLUMN id TO id_gone`. Plugin IDs allow only lowercase letters, digits and dots. Compute before := PendingMigrations on the fresh DB. Run runLagoon(t, "local", dsn, plugins, "migrate"). Expect exit 0, stdout containing "Could not re-read the migration history", "demo.rereadfirst: 1 applied", "demo.rereadlast: 1 applied" and fmt.Sprintf("Applied %d migrations", len(before)). On the unchanged code the run exits 1 and prints no applied lines.
|
||||
</behavior>
|
||||
<action>
|
||||
1. **WR-12 (commit 7).** Extend TestMigrateStatusJSON per behavior and run it RED with Docker. Fix per P-06 in modules/lagoon/migrations.go: Status calls migrationSets(gdb, plugins) and builds one StatusRow per set (Plugin = set id, Table = HistoryTableName(set id); IDs from the history table ordered by id, or none when the table is missing, exactly as today). The error label becomes `lagoon: status <set id>`. Update the doc comments of StatusRow ("one migration set's recorded IDs: Plugin is the plugin ID or the framework set's history ID") and Status (it reads the history of every set Migrate applies, framework sets first). In modules/lagoon/commands.go, update the migrate:status Long text: it lists the framework's own sets and each plugin with migrations, and --json prints every declared migration of those sets. Keep the Description and Examples. Keep the JSON loop; it is now correct, see P-06.
|
||||
|
||||
Update the existing tests per behavior: TestTwoPluginMigrationSetsIsolated and TestDocsMigrate. Grep `Status(` across modules/lagoon tests for any other caller.
|
||||
|
||||
Docs:
|
||||
- modules/lagoon/README.md line 138 (`lagoon.Status` row): it lists the applied IDs of every set Migrate applies, framework sets first, then plugins.
|
||||
- modules/lagoon/README.md line 261 and docs/console/setup-and-maintenance.md line 17: the table covers the framework sets and every plugin with migrations; --json prints every declared migration of those sets.
|
||||
- docs/database/migrations.md line 84: migrate:status prints the history table and applied IDs of the framework's sets and of each plugin.
|
||||
|
||||
Commit: `fix(lagoon): migrate:status reads the sets migrate applies (WR-12)`, paths migrations.go, commands.go, migrations_test.go, example_test.go, pending_test.go, README.md, docs/console/setup-and-maintenance.md, docs/database/migrations.md.
|
||||
|
||||
2. **WR-13 (commit 8).** Add TestMigrateRereadFailureWarns to modules/lagoon/pending_test.go per behavior and run it RED with Docker. Fix per P-07 in the migrate Run of modules/lagoon/commands.go. After the follow-up PendingMigrations, the applied list starts as the pre-run pending list. On a nil re-read error it becomes appliedSince(pending, after). On a re-read error with migrateErr nil, print the warning, which includes the sanitized error, and continue. When both fail, return both errors joined. The per-set lines, the migrateErr return and the success line keep their current order.
|
||||
|
||||
Add a comment explaining why a failed read after a successful Migrate must not fail the run: the schema change is in place, and a deploy would roll back or alert. In modules/lagoon/README.md and docs/console/setup-and-maintenance.md (the migrate row, line 15), add one clause: if the history cannot be re-read after a successful run, migrate warns and counts every pending migration as applied. Add TestMigrateRereadFailureWarns to DOCKER_LAGOON_TESTS in scripts/check-phase19.8.sh, under `# quick 261022-4z8 (re-review fixes): WR-13.`
|
||||
|
||||
Commit: `fix(lagoon): warn instead of failing when the re-read after a successful migrate fails (WR-13)`, paths commands.go, pending_test.go, README.md, docs/console/setup-and-maintenance.md, scripts/check-phase19.8.sh.
|
||||
|
||||
3. **Full verification** (before the records commit). Run the commands in the plan-level verification section: vet, the full Docker test run, the hello workspace, the gate's self-test, go and docker stages, the API check and gofmt on the touched Go files. Fix any failure in the commit of the finding it belongs to; amend only if that commit is still the newest, otherwise add a follow-up `fix(...)` commit and record it. Apply the foreign-failure rule from the context section if it applies.
|
||||
|
||||
4. **Records commit (commit 9, docs only).** Edit .planning/phases/19.8-console-commands-overhaul/19.8-REVIEW-DISPOSITION.md:
|
||||
- In the frontmatter `findings`, set disposition `fixed` for WR-09, WR-10, WR-11, WR-12, WR-13, WR-14, IN-14 and IN-15, and `deferred` for IN-10, IN-11, IN-12 and IN-13. Set `open: 0` and keep `total: 32`.
|
||||
- In the table, set the same dispositions. Each fixed row's Source cell is `fix <short hash> (quick 261022-4z8)`, matching the existing `fix b077dc3 (19.8-05)` style; IN-14's says `docs <hash> (quick 261022-4z8)`. The WR-10 cell adds `; the exit-130 suggestion is deferred to todo .planning/todos/pending/2026-10-22-console-review-info-findings.md`. IN-10..IN-13 cells read `todo .planning/todos/pending/2026-10-22-console-review-info-findings.md`.
|
||||
- Keep the WR-05, WR-07 and WR-08 dispositions `fixed` and append to their Source cells `; remainder WR-14 (fix <hash>)`, `; remainder WR-12 (fix <hash>)` and `; remainder WR-11 (fix <hash>)` respectively. Leave every other row unchanged.
|
||||
|
||||
Edit .planning/todos/pending/2026-10-22-console-review-info-findings.md:
|
||||
- Append sections `## IN-10`, `## IN-11`, `## IN-12` and `## IN-13`, each with File, Problem and Suggested fix taken from 19.8-REVIEW.md (locate code by symbol, as the existing sections do).
|
||||
- Append a section `## WR-10 follow-up: exit code 128+n for a delegate killed by a signal`. It records that plan 05 specified exit 1, which this quick task kept, and gives the review's suggested mapping: SIGINT/SIGTERM as already reported with 128+n, other signals 128+n with the masked block. It also notes that docs/console/introduction.md documents exit 1 and must change with it.
|
||||
- Update the frontmatter: title "Console review info findings (IN-01..IN-13) and the delegate signal exit code"; add modules/bonfire/live.go, modules/bonfire/output.go and cmd/summer/runtime.go to `files`; leave `g15office: pending`.
|
||||
- Extend the Context paragraph with one sentence naming the re-review and this quick task.
|
||||
|
||||
Commit: `docs(19.8): re-review fixes recorded, IN-10..IN-13 and the exit-130 suggestion deferred`, paths the two files only.
|
||||
|
||||
5. Write the SUMMARY (261022-4z8-SUMMARY.md in this quick directory). Model it on 19.8-05-SUMMARY.md and include: plan_head_before (BASE) and plan_head_after; a commits-per-finding table; the RED output per finding; the verification table; the API check; decisions P-01..P-09 as applied, with any deviation; the release-note input (P-09); skipped doc updates, if any; and a suggested g15office comment. Do not commit the SUMMARY or PLAN and do not touch STATE.md; the orchestrator does that. Do not delete the gocache; the orchestrator removes it at the end.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>env GOCACHE=/tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/e016575e-9db1-44e1-a366-55d9ae1a9f8b/scratchpad/gocache TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 go test -count=1 ./modules/lagoon/ -run 'TestMigrateStatusJSON|TestMigrateRereadFailureWarns|TestMigrateAppliedLines|TestPendingMigrations|TestTwoPluginMigrationSetsIsolated|TestDocsMigrate|TestMigratePretend' && grep -v '^\s*//' modules/lagoon/migrations.go | grep -c 'migrationSets(gdb, plugins)' | grep -qx 3 && grep -q '^open: 0$' .planning/phases/19.8-console-commands-overhaul/19.8-REVIEW-DISPOSITION.md && grep -q '^## IN-13' .planning/todos/pending/2026-10-22-console-review-info-findings.md</automated>
|
||||
</verify>
|
||||
<done>Commits 7 (WR-12), 8 (WR-13) and 9 (records) exist, with no trailers and only their listed paths. The Docker-backed lagoon tests pass, with RED output captured for WR-12 and WR-13. migrate:status --json lists pending framework migrations. A failed re-read after a successful migrate exits 0 with a warning. The disposition shows open: 0 with hashes in the Source cells. The todo carries IN-10..IN-13 and the exit-130 follow-up. The full verification passes. The SUMMARY is written and not committed.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| command line → stderr/CI logs | argv typed by an operator (passwords, tokens) is echoed in hints and errors that CI and log collectors capture |
|
||||
| untrusted text → terminal | item names (remote file names, admin-typed titles, slugs) reach the terminal through Process lines |
|
||||
| database → deploy pipeline | migrate's exit code and applied lines drive deploy gates and rollbacks |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-4z8-01 | Information disclosure | modules/bonfire/errors.go tryArgs (WR-11) | high | mitigate | Mask the argument after a secret-named long flag whatever its first character, except `--`; pinned by the dash cases in TestWrongDirectoryMasksSecrets |
|
||||
| T-4z8-02 | Information disclosure | cmd/summer/runtime.go runAppCmd (WR-10) | high | mitigate | Error built from internal/argmask.Join(argv) on every branch; TestRunAppCmdMasksSecrets (signal death, start failure) and the masked TestDelegateExitCodePassthrough subtest |
|
||||
| T-4z8-03 | Tampering | modules/bonfire/helpers.go Process plain path, processModel operation label (WR-14) | medium | mitigate | Sanitize(item) and Sanitize(op); plain-path cases in TestProcessViewStripsControl |
|
||||
| T-4z8-04 | Repudiation | lagoon migrate:status --json (WR-12) | medium | mitigate | Status reads migrationSets, so a deploy gate on "applied": false sees pending framework migrations; TestMigrateStatusJSON |
|
||||
| T-4z8-05 | Denial of service (ops) | lagoon migrate re-read (WR-13) | medium | mitigate | A successful Migrate never exits 1 because of a failed read; warning printed; TestMigrateRereadFailureWarns |
|
||||
| T-4z8-06 | Repudiation | bonfire live views (WR-09) | low | mitigate | Lines after a cancellation print plainly instead of being dropped while reported as printed; TestPrintAboveAfterCancel |
|
||||
| T-4z8-07 | Denial of service | runLive on the losing path (IN-15) | low | mitigate | No program, child context or TEA_TRACE fd created when the slot is taken; TestNestedProcessBuildsNoProgram |
|
||||
| T-4z8-08 | Information disclosure | IN-08 C1 bytes through Sanitize (deferred earlier) | low | accept | Still deferred in the todo; out of this task's scope |
|
||||
| T-4z8-SC | Tampering | npm/pip/cargo installs | high | accept | No package installs and no new Go module dependency in this plan (internal/argmask is stdlib-only) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Run after the WR-13 commit, before the records commit. Write `$GC` out in full; it is the gocache path from the context section.
|
||||
|
||||
1. `env GOCACHE=$GC go vet ./...`, which must pass.
|
||||
2. `env GOCACHE=$GC TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 go test -count=1 ./...`, which must pass. Rerun the wristband flake once; apply the foreign-failure rule to cmd/summer docs tests.
|
||||
3. `env GOCACHE=$GC go test -count=3 -race ./modules/bonfire/ ./modules/diary/`, which must pass.
|
||||
4. `env GOCACHE=$GC go -C examples/hello test -count=1 ./...` (the separate workspace module), which must pass.
|
||||
5. `env GOCACHE=$GC bash scripts/check-phase19.8.sh self-test`, then `env GOCACHE=$GC bash scripts/check-phase19.8.sh go`, `env GOCACHE=$GC bash scripts/check-phase19.8.sh docs` and `env GOCACHE=$GC TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 bash scripts/check-phase19.8.sh docker`, which must pass. Do not run the plugins, apps or landing stages; they touch sibling repos.
|
||||
6. API check (no exported break). Replace BASE with the recorded hash and run this. S is the session scratchpad, so the repo gets no stray files:
|
||||
`bash -c 'S=/tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/e016575e-9db1-44e1-a366-55d9ae1a9f8b/scratchpad; d() { git diff BASE..HEAD -U0 -- "$@" ":(exclude)*_test.go"; }; X="^(func (\([^)]*\) )?[A-Z]|type [A-Z]|var [A-Z]|const [A-Z])"; d modules cmd internal | grep -E "^-(func|type|var|const) " | sed "s/^-//" | sort > $S/R; d modules cmd internal | grep -E "^\+(func|type|var|const) " | sed "s/^+//" | sort > $S/A; d modules | grep -E "^\+(func|type|var|const) " | sed "s/^+//" | sort > $S/AM; echo removed-or-changed:; comm -23 $S/R $S/A | grep -E "$X"; echo added-under-modules:; comm -13 $S/R $S/AM | grep -E "$X"; rm -f $S/R $S/A $S/AM'`
|
||||
Both lists must be empty. internal/argmask's exported Join and Quote live in an internal package, so they are not framework API and do not count.
|
||||
7. `gofmt -l` on every Go file this task touched must print nothing.
|
||||
8. Write `git log --format='%s%n%b' BASE..HEAD` to a scratchpad file, check that git exited 0, then `grep -ci 'co-authored-by'` on that file must print 0. `git log --format=%s BASE..HEAD` must show eight finding subjects ending in (IN-14), (WR-14), (IN-15), (WR-09), (WR-11), (WR-10), (WR-12) and (WR-13), plus the docs(19.8) records commit.
|
||||
9. `git status --short` must show no change of ours left unstaged and no staged foreign file; foreign dirty files (STATE.md, state.json, .msd/, other sessions' edits) are left as they were.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All three verification gaps of 19.8 are closed by code with pinning tests: gap 1 by WR-09, gap 2 by WR-11 and WR-10, gap 3 by WR-12. WR-13, WR-14, IN-14 and IN-15 are fixed too.
|
||||
- Nine local commits: eight findings, each with its test and docs, and one records commit. No trailers, no push, no tag, no changelog post.
|
||||
- `go vet ./...` and `go test ./...` (with Docker) pass, as do the bonfire and diary race runs, the hello workspace, and the gate's self-test, go, docs and docker stages.
|
||||
- No exported framework identifier is removed, added or changed. `lagoon.Status` keeps its signature and now returns the framework sets first.
|
||||
- The disposition record shows open: 0. IN-10..IN-13 and the exit-130 suggestion are in the todo.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Write `.planning/quick/261022-4z8-fix-the-open-19-8-re-review-findings-wr-/261022-4z8-SUMMARY.md` when done (not committed by the executor).
|
||||
|
||||
For the orchestrator after execution:
|
||||
- Commit PLAN and SUMMARY per the quick workflow, in a separate docs commit. Do not touch `.planning/STATE.md` or `state.json`; the user forbade it for this run.
|
||||
- Delete the gocache directory `/tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/e016575e-9db1-44e1-a366-55d9ae1a9f8b/scratchpad/gocache` after the last go command (executor and any verifier).
|
||||
- Phase 19.8 can now be re-verified to clear its `gaps_found` status. Releasing the fixes (v0.4.x) needs the user's go-ahead.
|
||||
</output>
|
||||
@@ -0,0 +1,258 @@
|
||||
---
|
||||
phase: quick-261022-4z8
|
||||
plan: 01
|
||||
quick_id: 261022-4z8
|
||||
subsystem: console
|
||||
tags: [review-fixes, bonfire, bubbletea, live-views, secrets, summer, delegates, lagoon, migrations, gate]
|
||||
status: complete
|
||||
gap_closure: true
|
||||
|
||||
requires:
|
||||
- phase: 19.8-05
|
||||
provides: the review fixes released as v0.4.2 and their re-review (19.8-REVIEW.md, 19.8-VERIFICATION.md gaps 1-3)
|
||||
provides:
|
||||
- "WR-09..WR-14, IN-14 and IN-15 fixed, one commit each, each code fix with a pinning test proven red on the unchanged code"
|
||||
- "internal/argmask: one secret-masking implementation shared by bonfire's Try line and summer's delegate errors"
|
||||
- "lagoon.Status / migrate:status list the framework sets first (same list as Migrate and PendingMigrations)"
|
||||
- "scripts/check-phase19.8.sh requires the 4 new pinning tests by name"
|
||||
- "19.8-REVIEW-DISPOSITION.md at open: 0; IN-10..IN-13 and the exit-130 suggestion in the console review todo"
|
||||
affects: [phase 19.8 re-verification (gaps_found), next v0.4.x release, bonfire live views, summer delegates, lagoon migrate]
|
||||
|
||||
actuals:
|
||||
tokens: 12206 # chars/4 over the added and removed lines of this task's diff (48825 chars)
|
||||
tasks: 3
|
||||
commits: 9 # MEASURED: git rev-list --count plan_head_before..plan_head_after
|
||||
plan_head_before: a28957f7d0bc6dfe17b6b79fd7b5aada3d222546
|
||||
plan_head_after: a48afbd11e648ba7024c687a18cce3bede24bf51
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Live view lifetime decoupled from the caller's context: a watcher ends and unregisters the view under liveMu on ctx cancellation"
|
||||
- "Claim the single live slot before building the bubbletea program"
|
||||
- "printAbove refuses a view whose Run has ended (liveView.done)"
|
||||
- "Framework-internal helper package (internal/argmask) shared by a module and the summer CLI instead of a new exported API"
|
||||
- "Counting context (AfterFunc) to observe child-context registrations in a test"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- internal/argmask/argmask.go
|
||||
modified:
|
||||
- modules/bonfire/errors.go
|
||||
- modules/bonfire/helpers.go
|
||||
- modules/bonfire/live.go
|
||||
- modules/bonfire/live_test.go
|
||||
- modules/bonfire/errors_test.go
|
||||
- modules/bonfire/README.md
|
||||
- cmd/summer/runtime.go
|
||||
- cmd/summer/runtime_test.go
|
||||
- modules/lagoon/migrations.go
|
||||
- modules/lagoon/commands.go
|
||||
- modules/lagoon/migrations_test.go
|
||||
- modules/lagoon/example_test.go
|
||||
- modules/lagoon/pending_test.go
|
||||
- modules/lagoon/README.md
|
||||
- docs/console/writing-commands.md
|
||||
- docs/console/introduction.md
|
||||
- docs/console/setup-and-maintenance.md
|
||||
- docs/database/migrations.md
|
||||
- scripts/check-phase19.8.sh
|
||||
- .planning/phases/19.8-console-commands-overhaul/19.8-REVIEW-DISPOSITION.md
|
||||
- .planning/todos/pending/2026-10-22-console-review-info-findings.md
|
||||
|
||||
key-decisions:
|
||||
- "P-01 applied as planned: the live program no longer takes the caller's context; watchContext ends the view with its last frame and unregisters it under liveMu; printAbove checks liveView.done. The review's fallback (check-then-Send) was not needed: the design passes -race -count=3."
|
||||
- "P-05 applied: the masking moved to internal/argmask (Join, Quote); no exported framework identifier added, removed or changed."
|
||||
- "P-06 applied: lagoon.Status iterates migrationSets; a plugin whose HasMigrations returns none no longer gets an empty row."
|
||||
- "P-07 applied: a failed re-read after a successful Migrate warns and exits 0, counting the pre-run pending list as applied."
|
||||
|
||||
metrics:
|
||||
duration: "~20 min"
|
||||
completed: 2026-10-22
|
||||
---
|
||||
|
||||
# Quick 261022-4z8: the open 19.8 re-review findings Summary
|
||||
|
||||
**Cancelled live views now end with their last frame and print later lines plainly, delegate errors and Try lines mask every secret flag value (dash-leading included) through one shared `internal/argmask`, and `migrate:status` lists the framework sets that `migrate` applies. Eight review findings were fixed in eight commits, plus one records commit.**
|
||||
|
||||
This closes the three verification gaps of phase 19.8: gap 1 by WR-09, gap 2 by WR-11 and WR-10, gap 3 by WR-12. WR-13, WR-14, IN-14 and IN-15 are fixed as well. IN-10..IN-13 and WR-10's exit-130 suggestion are deferred to the existing todo.
|
||||
|
||||
## Commits per finding
|
||||
|
||||
| # | Finding | Commit | Subject | Pinning test |
|
||||
|---|---------|--------|---------|--------------|
|
||||
| 1 | IN-14 | 7c15095 | docs(bonfire): the ErrReported comment names the WithExitCode code (IN-14) | none (doc comment only) |
|
||||
| 2 | WR-14 | 48e13f9 | fix(bonfire): strip control characters from Process's plain item lines and the view's label (WR-14) | TestProcessViewStripsControl (extended: operation label, two plain-path subtests) |
|
||||
| 3 | IN-15 | 1ac75ad | fix(bonfire): claim the live view slot before building the program (IN-15) | TestNestedProcessBuildsNoProgram (new) |
|
||||
| 4 | WR-09 | fce232d | fix(bonfire): end a live view when its context is cancelled and print later lines plainly (WR-09) | TestPrintAboveAfterCancel (new) |
|
||||
| 5 | WR-11 | 707372b | fix(bonfire): mask secret flag values that start with a dash (WR-11) | TestWrongDirectoryMasksSecrets (extended) |
|
||||
| 6 | WR-10 | 17c722d | fix(summer): mask secret flag values in a delegate's error (WR-10) | TestRunAppCmdMasksSecrets (new), TestDelegateExitCodePassthrough (new subtest) |
|
||||
| 7 | WR-12 | 57a849d | fix(lagoon): migrate:status reads the sets migrate applies (WR-12) | TestMigrateStatusJSON (extended); TestTwoPluginMigrationSetsIsolated and TestDocsMigrate updated to the new contract |
|
||||
| 8 | WR-13 | 0691d26 | fix(lagoon): warn instead of failing when the re-read after a successful migrate fails (WR-13) | TestMigrateRereadFailureWarns (new, Docker) |
|
||||
| 9 | records | a48afbd | docs(19.8): re-review fixes recorded, IN-10..IN-13 and the exit-130 suggestion deferred | n/a |
|
||||
|
||||
No commit carries a Co-Authored-By trailer (`grep -ci co-authored-by` over `git log --format='%s%n%b' BASE..HEAD` prints 0). Nothing was pushed or tagged, and nothing was posted to the changelog.
|
||||
|
||||
## RED output on the unchanged code
|
||||
|
||||
**WR-14** (TestProcessViewStripsControl):
|
||||
```
|
||||
--- FAIL: TestProcessViewStripsControl (0.00s)
|
||||
live_test.go:546: process view carries "\x1b]0;pwned\a": "... Push\x1b]0;pwned\aing 1/2 ..."
|
||||
--- FAIL: TestProcessViewStripsControl/terminal_with_-n (0.00s)
|
||||
live_test.go:577: plain Process line carries "\x1b]0;pwned\a": "\x1b[32m✓\x1b[m evil\x1b]0;pwned\aitem\n"
|
||||
--- FAIL: TestProcessViewStripsControl/no_terminal,_color_forced (0.00s)
|
||||
live_test.go:577: plain Process line carries "\x1b]0;pwned\a": "\x1b[32m✓\x1b[m evil\x1b]0;pwned\aitem\n"
|
||||
```
|
||||
|
||||
**IN-15** (TestNestedProcessBuildsNoProgram):
|
||||
```
|
||||
--- FAIL: TestNestedProcessBuildsNoProgram (0.00s)
|
||||
live_test.go:649: the nested Process built a program: the TEA_TRACE file exists
|
||||
live_test.go:652: 1 child contexts still registered on the caller's context, want 0
|
||||
```
|
||||
|
||||
**WR-09** (TestPrintAboveAfterCancel). It fails on the 2s poll, not by hanging:
|
||||
```
|
||||
--- FAIL: TestPrintAboveAfterCancel (2.00s)
|
||||
live_test.go:686: view still registered 2s after the cancellation
|
||||
live_test.go:691: PrintAbove reported true after the cancellation
|
||||
live_test.go:708: "after-cancel-println" printed 0 times, want 1:
|
||||
live_test.go:708: "after-cancel-info" printed 0 times, want 1:
|
||||
live_test.go:708: "after-cancel-printabove" printed 0 times, want 1:
|
||||
```
|
||||
|
||||
**WR-11** (TestWrongDirectoryMasksSecrets):
|
||||
```
|
||||
["sync" "--token" "--force"]: Try = "cd ..; {bin} sync --token --force", want "cd ..; {bin} sync --token ***"
|
||||
["admin:create" "--email" "a@b.c" "--password" "-Xk9!q"]: Try = "... --password '-Xk9!q'", want "... --password ***"
|
||||
["admin:create" ... "-Xk9!q"]: Try line repeats "-Xk9"
|
||||
["sync" "--client-secret" "-s3cr3t" "--verbose"]: Try = "... --client-secret -s3cr3t --verbose", want "... --client-secret *** --verbose"
|
||||
```
|
||||
|
||||
**WR-10** (TestDelegateExitCodePassthrough, TestRunAppCmdMasksSecrets):
|
||||
```
|
||||
--- FAIL: TestDelegateExitCodePassthrough/killed_by_a_signal_masks_secrets
|
||||
want one error block with the password masked, got 1:
|
||||
stderr "\n ERROR summer: migrate --password hunter2: signal: killed\n\n Run with -v for details.\n\n"
|
||||
the password reached the output
|
||||
--- FAIL: TestRunAppCmdMasksSecrets/killed_by_a_signal
|
||||
error lacks "--password ***": summer: admin:create --email a@b.c --password hunter2 --api-token=t0k3n --client-secret -Xk9q: signal: killed
|
||||
error repeats "hunter2" / "t0k3n" / "-Xk9q"
|
||||
--- FAIL: TestRunAppCmdMasksSecrets/start_failure
|
||||
error lacks "--password ***": summer: admin:create ... --password hunter2 ...: fork/exec .../missing: no such file or directory
|
||||
```
|
||||
|
||||
**WR-12** (TestMigrateStatusJSON, Docker):
|
||||
```
|
||||
--- FAIL: TestMigrateStatusJSON (0.38s)
|
||||
pending_test.go:318: [migrate:status --json]: pending entries = [{demo.beta ... 202610210001_create_pending_beta} {demo.beta ... 202610210002_seed_pending_beta}],
|
||||
want PendingMigrations' [{summercms.attach summer_migrations_summercms_attach 202609180001_create_system_files ...} ...]
|
||||
pending_test.go:321: [migrate:status --json]: the pending framework migration 202609180001_create_system_files is not listed
|
||||
(same two failures for [migrate:status --json -q])
|
||||
```
|
||||
|
||||
**WR-13** (TestMigrateRereadFailureWarns, Docker):
|
||||
```
|
||||
--- FAIL: TestMigrateRereadFailureWarns (0.42s)
|
||||
pending_test.go:428: migrate: exit 1, want 0
|
||||
ERROR lagoon: pending demo.rereadfirst: ERROR: column "id" does not exist (SQLSTATE 42703)
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| `go vet ./...` | pass |
|
||||
| `TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 go test -count=1 ./...` (Docker suite) | pass, 43 packages ok, exit 0. The wristband flake did not occur. |
|
||||
| `go test -count=3 -race ./modules/bonfire/ ./modules/diary/` | pass (bonfire 15.6s, diary 2.2s) |
|
||||
| Task 1 `-race -count=3` live-view set (TestPrintAboveAfterCancel, TestNestedProcessBuildsNoProgram, TestProcessViewStripsControl, TestPrintAboveOrderedNoLoss, TestPrintlnStaysWithItsConsole, TestLiveViewSingleOwner, TestLiveViewPanicUnregisters, TestLiveViewRestoresTerminalOnSignal, TestPrintAbove, TestLiveModels) | pass |
|
||||
| `go -C examples/hello test -count=1 ./...` | pass |
|
||||
| `scripts/check-phase19.8.sh self-test` | pass |
|
||||
| `scripts/check-phase19.8.sh go` | pass (bonfire list now 44 tests, SUMMER list 7) |
|
||||
| `scripts/check-phase19.8.sh docs` | pass |
|
||||
| `scripts/check-phase19.8.sh docker` | pass (lagoon list now 7 tests, TestMigrateRereadFailureWarns included) |
|
||||
| Task verify greps | `WithContext(` outside comments in live.go: 0; `strings.Join(argv` in runtime.go: 0; `argmask.Join(argv)` present; `migrationSets(gdb, plugins)` outside comments in migrations.go: 3; `open: 0`; `## IN-13` in the todo |
|
||||
| gofmt on touched Go files | Clean in every hunk this task wrote. `gofmt -l` still lists `cmd/summer/runtime.go` and `cmd/summer/runtime_test.go`, but they were already unformatted at BASE (a struct field alignment at runtime.go:27 and a map alignment at runtime_test.go:81, both untouched by this task). Out of scope, not fixed. |
|
||||
| `git status --short` | Only foreign files remain (STATE.md, state.json, .msd/, two .gitkeep files, this quick dir). Nothing of ours is unstaged, and nothing foreign was staged. |
|
||||
|
||||
The plugins, apps and landing stages of the gate were not run, as the plan says (they touch sibling repos).
|
||||
|
||||
## API check (no exported break)
|
||||
|
||||
`git diff BASE..HEAD -U0 -- modules cmd internal ':(exclude)*_test.go'`:
|
||||
|
||||
- removed-or-changed exported: **(empty)**
|
||||
- added under modules/ exported: **(empty)**
|
||||
|
||||
Every changed declaration is unexported: bonfire's `secretWords`, `secretFlag` and `shellQuote` were removed (moved), and `watchContext` was added. `argmask.Join` and `argmask.Quote` are exported but sit in `internal/`, so they are not framework API. `lagoon.Status` keeps its signature, and `lagoon.StatusRow` keeps its fields.
|
||||
|
||||
## Decisions as applied
|
||||
|
||||
- **P-01 (WR-09):** Applied as designed. `tea.WithContext` was removed from `tea.NewProgram`, and `liveView` gained `done <-chan struct{}`, which is runLive's `finished` channel, created before the claim. The new unexported `watchContext(ctx, view, finished)` holds the watcher. On `ctx.Done()` it unregisters the view, sends `finishMsg{err: ctx.Err()}` and waits for `finished`, all under `liveMu`. On `finished` it only unregisters. In both branches it clears `activeView` only if the view is still the active one. The deferred cleanup now also waits for the watcher (`stopWatch()`) after releasing `liveMu`, so no goroutine outlives `runLive`. `printAbove` reads `activeView` into `v` and, after the owner check, does a non-blocking `case <-v.done:` that unregisters and returns false. watchSignals is unchanged. The fallback was not needed.
|
||||
- **P-02 (IN-15):** Applied. `finished` is created before the lock, and the program is built under `liveMu` after the slot check.
|
||||
- **P-03 (WR-14):** Applied at the call sites: `out.Success(Sanitize(item))` and `op := Sanitize(m.operation)`. `console.status` is unchanged.
|
||||
- **P-04 (WR-11):** Applied. The `sync --token --force` case now expects `--token ***`, and the test comment explains why (pflag takes `--force` as the value).
|
||||
- **P-05 (WR-10):** Applied. `internal/argmask` provides `Join` and `Quote` and imports only `strings`. bonfire keeps `tryArgs` as a thin wrapper. `runAppCmd` uses `argmask.Join(argv)` on every branch, a signal death still exits 1, and runtime.go's `strings` import was dropped. modules/bonfire is the first module to import a package under `internal/`; Go's internal rule allows it, because both sit in the same module root.
|
||||
- **P-06 (WR-12):** Applied. The `migrate:status` Description and Examples are unchanged, and only the Long help changed. The goldens are untouched, since no golden carries the Long text. The `--json` loop in commands.go is unchanged.
|
||||
- **P-07 (WR-13):** Applied. A `switch` takes the re-read result: on success it uses `appliedSince`, when Migrate and the re-read both fail it returns `errors.Join`, and when only the re-read fails it warns (`Could not re-read the migration history: <sanitized error>`) and keeps the pre-run pending list.
|
||||
- **P-08:** Applied. TestNestedProcessBuildsNoProgram and TestPrintAboveAfterCancel joined the BONFIRE list, TestRunAppCmdMasksSecrets the SUMMER list, and TestMigrateRereadFailureWarns DOCKER_LAGOON_TESTS. Each joined in the same commit as its test.
|
||||
- **P-09:** `docs/setup/upgrading.md` was not changed.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
**1. [CLAUDE.md doc rule] The WR-10 commit also updates modules/bonfire/README.md**
|
||||
- **Found during:** Task 2, after the WR-10 commit.
|
||||
- **Issue:** CLAUDE.md says a change to a module's dependencies updates its README in the same commit. bonfire now imports `internal/argmask`, and the README's Dependencies section did not say so. The README's error-block paragraph also did not state the dash-leading masking rule.
|
||||
- **Fix:** Added `internal/argmask` to the README's Dependencies line, and "also a value that starts with `-`" to the Try-line masking sentence. Folded into the WR-10 commit with `git commit --amend -- modules/bonfire/README.md` while it was still the newest commit, and before any hash was recorded anywhere. The final WR-10 hash is 17c722d.
|
||||
- **Note:** The plan's WR-10 path list did not include the README. The WR-11 sentence in the README landed in the WR-10 commit rather than in the WR-11 commit; writing-commands.md got the WR-11 sentence in the WR-11 commit as planned.
|
||||
|
||||
**2. [Test detail] TestNestedProcessBuildsNoProgram checks the success line as "✓ x"**
|
||||
- The plan says the "x" success line must reach the buffer. A bare `"x"` substring check would be weak, so the test matches `symCheck+" x"` in the stripped output. The behaviour pinned is unchanged.
|
||||
|
||||
**3. [Test detail] TestMigrateStatusJSON's pending entries use the table from HistoryTableName(ref.Plugin)**
|
||||
- Implemented as planned. `attach.Migrations` holds one migration, `202609180001_create_system_files`, so "the newest attach row" is that one.
|
||||
|
||||
No other deviations. No finding was skipped.
|
||||
|
||||
## Skipped doc updates / follow-ups
|
||||
|
||||
- None skipped. None of the files this task edited had foreign uncommitted changes, which `git status --short -- <file>` checked before each edit.
|
||||
- Pre-existing gofmt drift in `cmd/summer/runtime.go:27`, `cmd/summer/runtime_test.go:81` and `cmd/summer/main.go`, plus several cabana, compass, flare and tide files. It is not caused by this task and was not fixed, per the scope boundary. It may be worth a `/msd-fast` gofmt pass when no other session is editing cabana.
|
||||
|
||||
## Release-note input (P-09, for whoever tags next)
|
||||
|
||||
- `lagoon.Status` and `migrate:status` (table and `--json`) now list the framework's own migration sets first (`summercms.attach`, `summercms.cabana`, deferred bindings, queue), then each plugin with migrations. `--json` now shows a pending framework migration as `"applied": false`. A plugin whose `Migrations()` returns none no longer gets an empty row.
|
||||
- `migrate` no longer exits 1 when the migrations succeeded but reading the history back failed. It prints a warning and counts the pending migrations as applied.
|
||||
- When the context of a `bonfire.Process` view is cancelled, the view ends with its last frame and later lines print plainly below it. Before, they were lost.
|
||||
- `bonfire.Process` strips control characters from item names on its plain path and from the operation label.
|
||||
- Secret masking now also covers values that start with `-` (`--password -Xk9`), and summer's error for a delegate killed by a signal, or failing to start, masks secret flag values. A signal death still exits 1.
|
||||
|
||||
## Suggested g15office comment
|
||||
|
||||
> 19.8 re-review fixes landed locally (quick 261022-4z8). There are nine commits on master, not pushed: WR-09..WR-14, IN-14 and IN-15 are fixed with pinning tests, and IN-10..IN-13 plus the exit-130 idea are deferred to the console review todo. The disposition is at open: 0. Next: re-verify phase 19.8 to clear gaps_found, then a v0.4.x release on the user's go-ahead.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. No new network endpoint, auth path or schema change was added. The threat register's mitigations T-4z8-01..07 are each pinned by the tests listed above.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: internal/argmask/argmask.go
|
||||
- FOUND: 7c15095, 48e13f9, 1ac75ad, fce232d, 707372b, 17c722d, 57a849d, 0691d26, a48afbd (`git rev-list --count a28957f..a48afbd` = 9)
|
||||
|
||||
## Follow-up: WR-12 rework
|
||||
|
||||
Verification found that 57a849d changed what the exported `lagoon.Status` returns (framework sets first, no row for a plugin with no migrations). That broke `TestRollbackIsolatesFonotekaFullSchema` in `../fonoteka.go/parity`, so the plan's P-06 premise (no outside caller) was wrong. The orchestrator decided to keep `Status` as it was and to keep the WR-12 fix in the command only.
|
||||
|
||||
- **139f5e3** `fix(lagoon): keep Status plugin-only; migrate:status adds the framework sets itself (WR-12)`. `lagoon.Status` is back to its pre-57a849d behaviour: one row per plugin that implements `pact.HasMigrations`, in plugin order, with an empty row for a plugin with no migrations. `migrate:status` (table and `--json`) reads the new unexported `setStatus`, which takes its sets from `migrationSets`, so it still agrees with `migrate --pretend`. Both share the unexported `historyRows`. No exported API was added or changed. `TestTwoPluginMigrationSetsIsolated` and `TestDocsMigrate` are back to their pre-57a849d form. The new `TestStatusPluginRows` pins `Status`, and `TestMigrateStatusJSON` now also checks the table output. The new test is added to `scripts/check-phase19.8.sh`. The lagoon README and `docs/database/migrations.md` describe `Status` as plugin-only again.
|
||||
- **b1fba7e** `docs(19.8): ...`. The WR-12 disposition row names both 57a849d and 139f5e3.
|
||||
- **RED/GREEN.** In a scratch copy where the command calls the restored `Status` (that is, the command as it was before 57a849d), `TestMigrateStatusJSON` fails: the pending framework migration is missing from `--json`, and the table lists only the `demo.*` rows. In a scratch copy of the 57a849d code with the new tests, `TestStatusPluginRows`, `TestTwoPluginMigrationSetsIsolated` and `TestDocsMigrate` fail. All of them pass at 139f5e3.
|
||||
- **Checks.** `go vet ./...` passes, as do `summer docs:build --check` and `scripts/check-phase19.8.sh self-test`. `TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 go test -count=1 ./modules/lagoon/...` and the full Docker `go test -count=1 ./...` exit 0.
|
||||
- **fonoteka** (not modified, `replace => ../summercms.go`). `TestRollbackIsolatesFonotekaFullSchema` passes again. `TestMigrateSeedsCanonicalGenres` and `TestRollbackLastIsolatesFonoteka` still fail, and they did before this task: they now get past the row check (`status[0]` is `golem15.user`) but expect 11 user-plugin migrations, and the user plugin has 13.
|
||||
- **Release-note correction.** The first bullet of "Release-note input" above is superseded. `lagoon.Status` does not change. Only `migrate:status` (table and `--json`) now lists the framework's own sets first, and it lists only plugins that have migrations.
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
phase: quick-261022-4z8
|
||||
verified: 2026-10-22T04:30:00Z
|
||||
status: passed
|
||||
score: 12/12 must-haves verified (WR-12 resolved by follow-up 139f5e3)
|
||||
covered_files:
|
||||
- .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-PLAN.md
|
||||
- .planning/quick/261022-4z8-fix-the-open-19-8-re-review-findings-wr-/261022-4z8-SUMMARY.md
|
||||
- .planning/todos/pending/2026-10-22-console-review-info-findings.md
|
||||
- cmd/summer/runtime.go
|
||||
- cmd/summer/runtime_test.go
|
||||
- docs/console/introduction.md
|
||||
- docs/console/setup-and-maintenance.md
|
||||
- docs/console/writing-commands.md
|
||||
- docs/database/migrations.md
|
||||
- internal/argmask/argmask.go
|
||||
- modules/bonfire/README.md
|
||||
- modules/bonfire/errors.go
|
||||
- modules/bonfire/errors_test.go
|
||||
- modules/bonfire/helpers.go
|
||||
- modules/bonfire/live.go
|
||||
- modules/bonfire/live_test.go
|
||||
- modules/lagoon/README.md
|
||||
- modules/lagoon/commands.go
|
||||
- modules/lagoon/example_test.go
|
||||
- modules/lagoon/migrations.go
|
||||
- modules/lagoon/migrations_test.go
|
||||
- modules/lagoon/pending_test.go
|
||||
- scripts/check-phase19.8.sh
|
||||
covered_digest: "v2:sha256:a7e6e0cc40bcaa62cc411ab11ce6be4f7002259775e1e01c5e30862d9a068518"
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
human_verification:
|
||||
- test: "Decide whether the lagoon.Status contract change (WR-12) is acceptable as non-breaking, given that a sibling consumer depends on the old row set"
|
||||
expected: "Either (a) accept it: update fonoteka.go/parity (withoutSharedPlugins or the index assertions) to the new framework-first rows, and record lagoon.Status's changed result in the next release notes / docs/setup/upgrading.md as a behaviour change; or (b) reject it: keep lagoon.Status plugin-only and add the framework sets only in migrate:status"
|
||||
why_human: "The function signature and StatusRow fields are unchanged, so no compile-level API break exists; but the result set changed and the plan's P-06 premise ('No consumer outside this repo calls lagoon.Status') is false. fonoteka.go/parity calls lagoon.Status in three tests and asserts len(status)==2 and status[0].Plugin=='golem15.user'. Verified: TestRollbackIsolatesFonotekaFullSchema passes against summercms.go at a28957f and fails against a48afbd (status now starts with summercms.attach, summercms.cabana, summercms.deferred, summercms.conga). Whether that counts as an 'exported API break' under the task's constraint is a policy decision."
|
||||
---
|
||||
|
||||
# Quick 261022-4z8: the open 19.8 re-review findings Verification Report
|
||||
|
||||
**Task goal:** Fix the open findings of the 19.8 code re-review (WR-09, WR-10, WR-11, WR-12, WR-13, WR-14, IN-14, IN-15). The constraints are one commit per finding, each with a test that fails without the fix; no exported API breaks; `go vet` and the Docker `go test ./...` pass; the disposition record and the info todo are updated; local commits only.
|
||||
**Verified:** 2026-10-22T04:30:00Z
|
||||
**Status:** human_needed
|
||||
**Re-verification:** No (initial verification)
|
||||
|
||||
Every RED claim in SUMMARY.md was replayed independently. For each finding the parent of the fix commit was exported with `git archive` to the scratchpad, the test file(s) from the fix commit were overlaid, and the named test was run. The same test was then run on the fix commit itself. Every finding was red before its fix and green after it.
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | WR-09: cancelling Process's ctx ends the view with its last frame, unregisters it without waiting for work; later Println/Info/PrintAbove print plainly (PrintAbove false), none lost; printAbove refuses a program whose Run ended on its own | ✓ VERIFIED | live.go: `tea.WithContext` removed (no non-comment `WithContext(`); `watchContext` unregisters, sends `finishMsg{ctx.Err()}` and waits for `finished` under liveMu; `liveView.done`; `printAbove` has `case <-v.done:`. TestPrintAboveAfterCancel: RED at fce232d^ (the view was still registered after 2s, PrintAbove returned true, and all three lines were printed 0 times), GREEN at fce232d. `-race -count=10` on the live-view set passes. bubbletea v2.1.0 `Send` selects on `p.ctx.Done()` and `Run` does `defer p.cancel()`, so the deferred cleanup's Send after the watcher finishes cannot block. The "ended on its own" branch was confirmed by a throwaway verifier probe in a scratch copy (activeView with a closed `done`: printAbove returns false and unregisters). No committed test pins that branch (Info). |
|
||||
| 2 | WR-14: Process's plain path prints item names through Sanitize; the view's operation label is sanitized | ✓ VERIFIED | helpers.go:183 `out.Success(Sanitize(item))`; live.go:313 `op := Sanitize(m.operation)`. TestProcessViewStripsControl: RED at 48e13f9^ (label, both plain-path subtests), GREEN at 48e13f9. |
|
||||
| 3 | IN-15: runLive claims the slot before building the program; the loser builds none (no child ctx, no TEA_TRACE file) | ✓ VERIFIED | live.go: the `activeView != nil` check under liveMu comes before `tea.NewProgram`. TestNestedProcessBuildsNoProgram: RED at 1ac75ad^ (the trace file existed and 1 child ctx stayed registered), GREEN at 1ac75ad. |
|
||||
| 4 | IN-14: the ErrReported doc comment names the WithExitCode code | ✓ VERIFIED | errors.go:74-76. The `var ErrReported` line is unchanged. This finding only changes a doc comment, so no test is possible. |
|
||||
| 5 | WR-11: the Try line masks the argument after a secret-named long flag whatever its first character; only `--` is left alone | ✓ VERIFIED | argmask.go `args[i+1] != "--"`. TestWrongDirectoryMasksSecrets: RED at 707372b^ (`-Xk9!q` and `-s3cr3t` in clear text), GREEN at 707372b. |
|
||||
| 6 | WR-10: the error for a signal-killed or unstartable delegate masks argv through the shared internal/argmask; exit 1 is kept; no exported identifier changes | ✓ VERIFIED | runtime.go:174 `argmask.Join(argv)` wraps every branch, and no `strings.Join(argv` remains. bonfire's tryArgs and cd line use `argmask.Join` and `argmask.Quote`; bonfire's own secretWords, secretFlag and shellQuote are gone, so only one implementation exists. TestRunAppCmdMasksSecrets and TestDelegateExitCodePassthrough/killed_by_a_signal_masks_secrets: RED at 17c722d^ (hunter2, t0k3n and -Xk9q in the error), GREEN at 17c722d. |
|
||||
| 7 | WR-12: lagoon.Status reads migrationSets (framework sets first); --json lists every pending migration including framework ones; the signature and StatusRow are unchanged | ✓ VERIFIED | migrations.go:150. `migrationSets(gdb, plugins)` appears 3 times outside comments (Migrate, Status, PendingMigrations). TestMigrateStatusJSON (Docker): RED at 57a849d^ (pending `summercms.attach 202609180001_create_system_files` missing), GREEN at 57a849d. See truth 12 for the consumer impact. |
|
||||
| 8 | WR-13: when Migrate succeeds but the re-read fails, migrate warns, reports the pre-run pending list as applied and exits 0; when both fail, both errors are returned | ✓ VERIFIED | commands.go:177-192 `switch`: `err == nil` gives appliedSince; `migrateErr != nil` gives `errors.Join`; otherwise it warns with `Could not re-read the migration history`. TestMigrateRereadFailureWarns (Docker): RED at 0691d26^ (exit 1), GREEN at 0691d26. The both-fail branch is checked by reading the code only; it is a one-line return kept from before. |
|
||||
| 9 | One commit per finding with its pinning test and docs; no Co-Authored-By trailer; nothing pushed or tagged | ✓ VERIFIED | `git log a28957f..a48afbd` shows 9 commits: 8 finding commits (IN-14, WR-14, IN-15, WR-09, WR-11, WR-10, WR-12, WR-13) and 1 docs(19.8) records commit. `grep -ci co-authored` = 0. No tag points at or contains these commits. The branch is `ahead 16` of origin/master. Minor deviation: modules/bonfire/README.md's WR-11 masking sentence landed in the WR-10 commit (recorded in SUMMARY). |
|
||||
| 10 | The disposition marks WR-09..WR-14, IN-14 and IN-15 fixed with hashes (open: 0) plus the WR-05/07/08 remainders, and IN-10..IN-13 deferred; the todo carries IN-10..IN-13 and the exit-130 suggestion | ✓ VERIFIED | 19.8-REVIEW-DISPOSITION.md has `open: 0`, `total: 32`, and table rows 143-154 and 162-165 with the matching hashes. The todo has `## IN-10`..`## IN-13` and `## WR-10 follow-up: exit code 128+n …`, and its title and files are updated. |
|
||||
| 11 | `go vet ./...` and the Docker `go test ./...` pass | ✓ VERIFIED | `go vet ./...` passes. `TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 go test -count=1 ./...` exits 0 with 43 packages ok (lagoon 40s and lagoon/attach 54s, so the Docker tests ran). |
|
||||
| 12 | No exported API breaks | ? UNCERTAIN | The compile-level API is intact: the diff shows no exported func, type, var or const removed or changed, and none added under modules/ (argmask.Join and argmask.Quote are in internal/). However, **lagoon.Status's result changed** (framework rows first; a plugin with zero migrations no longer gets a row). The plan's P-06 premise that no outside consumer calls it is false: `fonoteka.go/parity/migrate_test.go` (3 calls) and `rollback_isolation_full_test.go` call it, and fonoteka uses `replace => ../summercms.go`. Reproduced: TestRollbackIsolatesFonotekaFullSchema PASSES against a28957f and FAILS against a48afbd (`status = [summercms.attach …]`, `len != 2`). Needs a human decision (see Human Verification). |
|
||||
|
||||
**Score:** 11/12 truths verified (0 present but behavior-unverified, 1 uncertain)
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `modules/bonfire/live.go` | slot claimed before NewProgram; watcher; liveView.done; printAbove refuses a finished program | ✓ VERIFIED | contains `case <-v.done:`; watchContext wired in runLive |
|
||||
| `modules/bonfire/helpers.go` | Sanitize on plain path | ✓ VERIFIED | `out.Success(Sanitize(item))` |
|
||||
| `modules/bonfire/errors.go` | ErrReported comment; tryArgs via argmask | ✓ VERIFIED | `argmask.Join`, `argmask.Quote` |
|
||||
| `internal/argmask/argmask.go` | Join + Quote, stdlib only | ✓ VERIFIED | imports only `strings`; used by bonfire and cmd/summer |
|
||||
| `cmd/summer/runtime.go` | runAppCmd uses argmask.Join(argv) | ✓ VERIFIED | line 174 |
|
||||
| `modules/lagoon/migrations.go` | Status from migrationSets, signature unchanged | ✓ VERIFIED | line 146 signature identical |
|
||||
| `modules/lagoon/commands.go` | re-read warning | ✓ VERIFIED | `Could not re-read the migration history` |
|
||||
| `modules/bonfire/live_test.go` | TestPrintAboveAfterCancel, TestNestedProcessBuildsNoProgram, plain-path cases | ✓ VERIFIED | present, red-before/green-after replayed |
|
||||
| `cmd/summer/runtime_test.go` | TestRunAppCmdMasksSecrets + masked signal subtest | ✓ VERIFIED | present, replayed |
|
||||
| `modules/lagoon/pending_test.go` | TestMigrateStatusJSON framework pending, TestMigrateRereadFailureWarns | ✓ VERIFIED | present, replayed with Docker |
|
||||
| `19.8-REVIEW-DISPOSITION.md` | open: 0 | ✓ VERIFIED | |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|----|-----|--------|---------|
|
||||
| cmd/summer/runtime.go | internal/argmask | `argmask.Join(argv)` | WIRED | the only argv rendering in runAppCmd |
|
||||
| modules/bonfire/errors.go | internal/argmask | `argmask.Join` / `argmask.Quote` | WIRED | the old local helpers were deleted |
|
||||
| lagoon Migrate/Status/PendingMigrations | migrationSets | `migrationSets(gdb, plugins)` | WIRED | 3 call sites |
|
||||
| modules/bonfire/live.go | helpers.go Process | ctx → runLive → watchContext; printAbove `case <-v.done` | WIRED | |
|
||||
| helpers.go | Sanitize | `Sanitize(item)` | WIRED | |
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| RED/GREEN replay per finding (WR-14, IN-15, WR-09, WR-11, WR-10) | `git archive <fix>^` + overlay the fix commit's test file(s), `go test -run <test>`; then the same at `<fix>` | all red before the fix, all green after | ✓ PASS |
|
||||
| RED/GREEN replay WR-12, WR-13 (Docker) | same, with `TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1` | red before the fix (framework pending missing; exit 1), green after | ✓ PASS |
|
||||
| bonfire + diary under race | `go test -count=3 -race ./modules/bonfire/ ./modules/diary/` | ok | ✓ PASS |
|
||||
| live-view set stress | `go test -count=10 -race -run 'TestPrintAboveAfterCancel\|TestNestedProcessBuildsNoProgram\|TestPrintAboveOrderedNoLoss\|TestLiveViewSingleOwner\|TestLiveViewPanicUnregisters\|TestLiveViewRestoresTerminalOnSignal'` | ok (49s) | ✓ PASS |
|
||||
| printAbove ended-program branch | throwaway probe test in a scratch copy (not committed) | PASS | ✓ PASS |
|
||||
| hello workspace | `go -C examples/hello test -count=1 ./...` | ok | ✓ PASS |
|
||||
| Downstream consumer (fonoteka parity) | copies of fonoteka.go against summercms.go at a28957f and at a48afbd | TestRollbackIsolatesFonotekaFullSchema passes at base and fails at head; the other two Status tests already fail at base (user plugin migration count 13 vs 11, unrelated drift) and fail earlier at head | ✗ regression in a sibling repo (see truth 12) |
|
||||
|
||||
### Probe Execution
|
||||
|
||||
| Probe | Command | Result | Status |
|
||||
|-------|---------|--------|--------|
|
||||
| `scripts/check-phase19.8.sh self-test` | `bash scripts/check-phase19.8.sh self-test` | exit 0 | PASS |
|
||||
| `scripts/check-phase19.8.sh go` | `bash scripts/check-phase19.8.sh go` | exit 0 | PASS |
|
||||
| `scripts/check-phase19.8.sh docs` | `bash scripts/check-phase19.8.sh docs` | exit 0 | PASS |
|
||||
| `scripts/check-phase19.8.sh docker` | with `TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1` | exit 0 | PASS |
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Status | Evidence |
|
||||
|-------------|-------------|--------|----------|
|
||||
| WR-09 | 261022-4z8-PLAN | ✓ SATISFIED | truth 1 |
|
||||
| WR-10 | 261022-4z8-PLAN | ✓ SATISFIED | truth 6 |
|
||||
| WR-11 | 261022-4z8-PLAN | ✓ SATISFIED | truth 5 |
|
||||
| WR-12 | 261022-4z8-PLAN | ✓ SATISFIED (consumer impact: truth 12) | truth 7 |
|
||||
| WR-13 | 261022-4z8-PLAN | ✓ SATISFIED | truth 8 |
|
||||
| WR-14 | 261022-4z8-PLAN | ✓ SATISFIED | truth 2 |
|
||||
| IN-14 | 261022-4z8-PLAN | ✓ SATISFIED | truth 4 |
|
||||
| IN-15 | 261022-4z8-PLAN | ✓ SATISFIED | truth 3 |
|
||||
| 4z8-docs | 261022-4z8-PLAN | ✓ SATISFIED | truth 10 |
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| (added lines in a28957f..a48afbd) | none | TBD/FIXME/XXX/TODO/HACK | none | no debt markers added |
|
||||
| cmd/summer/runtime.go, cmd/summer/runtime_test.go | 27, 81 | gofmt drift | ℹ️ Info | already present at a28957f (checked); not introduced by this task |
|
||||
| modules/bonfire/live.go | printAbove `case <-v.done` | branch not pinned by a committed test | ℹ️ Info | behaviour confirmed by a verifier probe; a small committed test would pin it |
|
||||
| modules/lagoon/migrations.go | Status | consumer-visible result change with no upgrade note | ⚠️ Warning | P-09 deliberately skipped docs/setup/upgrading.md; the SUMMARY's release-note input mentions it, but the false "no outside consumer" premise means fonoteka.go needs a follow-up |
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
### 1. Accept or reject the lagoon.Status contract change
|
||||
|
||||
**Test:** Decide whether WR-12's change to `lagoon.Status` (framework sets first; a plugin with zero migrations no longer gets a row) is acceptable under "no exported API breaks".
|
||||
**Expected:** If you accept it, update `fonoteka.go/parity` (`withoutSharedPlugins` should also drop the `summercms.*` rows, or the index assertions should change) and note the behaviour change in the next release notes and upgrade docs. If you reject it, keep `Status` plugin-only and add the framework sets inside the `migrate:status` command instead.
|
||||
**Why human:** This is a policy call. The Go signature is unchanged. But a sibling consumer (fonoteka.go, `replace => ../summercms.go`) calls `lagoon.Status` and now fails `TestRollbackIsolatesFonotekaFullSchema`, which passes against the pre-task framework. The plan's statement that no outside consumer exists was wrong.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No code gap. All eight findings are fixed as the review asked. Each has a pinning test that was independently proven red on the parent commit and green on the fix. The shared masking is in one place (`internal/argmask`). `go vet`, the full Docker suite, the race runs, the hello workspace and the four phase-gate stages all pass. The records are updated, and the commits are local, untagged and without trailers.
|
||||
|
||||
One item needs a human decision. The WR-12 fix changes what `lagoon.Status` returns. That is a behavioural, not compile-level, change to an exported function, and it breaks one previously passing test in the sibling consumer `fonoteka.go/parity`, contrary to the plan's premise. Note also that fonoteka's other two Status-based parity tests were already failing before this task (user plugin migration count drift), so fonoteka's parity suite was not green either way.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-10-22T04:30:00Z_
|
||||
_Verifier: Claude (msd-verifier)_
|
||||
|
||||
## Resolution of the human_needed item (orchestrator, 2026-10-22)
|
||||
|
||||
The open item was the WR-12 behaviour change to the exported `lagoon.Status`, which broke
|
||||
`fonoteka.go/parity` `TestRollbackIsolatesFonotekaFullSchema`. Decision (no consumer breaks):
|
||||
keep `lagoon.Status` plugin-only as before 57a849d; `migrate:status` builds its rows from the
|
||||
shared `migrationSets` through the unexported `setStatus`. Follow-up commits `139f5e3` (fix,
|
||||
`TestStatusPluginRows` pins `Status`, `TestMigrateStatusJSON` still red without the fix) and
|
||||
`b1fba7e` (disposition). Full Docker `go test ./...` green; the fonoteka test passes again. The two
|
||||
other fonoteka `Status` tests fail as before this task (they expect 11 user-plugin migrations, the
|
||||
plugin now has 13) — unrelated, pre-existing.
|
||||
@@ -47,6 +47,12 @@ This extends `.planning/research/go-ecosystem.md` (2026-09-16 GitHub-API-verifie
|
||||
| air-verse/air | v1.67.4 (Aug 1, 2026), actively maintained | Dev watch-rebuild loop | This is the "watch loop that rebuilds the compiled-plugin binary on change" the plugin architecture already calls for. Config via `.air.toml`; point `cmd`/`bin` at `go build -o ./tmp/summer ./cmd/summer && ./tmp/summer serve`. |
|
||||
| golangci-lint | v2.13.2 (Aug 27, 2026) | Linting | v2's config schema (`version: "2"` in `.golangci.yml`) is a breaking change from v1 — do not copy a v1 config from an older Go project without migrating it. |
|
||||
| `github.com/markbates/goth` (core plus providers google, facebook, github, azureadv2; never gothic) | v1.82.0 | OAuth 2.0 client for social sign-in in sm-sociallogin-plugin | Decided in .planning/notes/2026-10-20-sociallogin-goth.md (Phase 19.5 D-02); plugin-only, the framework go.mod does not require it. |
|
||||
| `charm.land/lipgloss/v2` | v2.0.6 | Console styles, layout and tables (bonfire palette, diary console handler) | Decided in .planning/notes/2026-10-21-console-charm-stack.md (Phase 19.8 D-02); imported only by modules/bonfire and modules/diary |
|
||||
| `charm.land/bubbletea/v2` | v2.1.0 | Runtime for the live spinner, process and progress views | Decided in .planning/notes/2026-10-21-console-charm-stack.md (Phase 19.8 D-02); imported only by modules/bonfire and modules/diary |
|
||||
| `charm.land/bubbles/v2` | v2.2.1 | Spinner and progress components | Decided in .planning/notes/2026-10-21-console-charm-stack.md (Phase 19.8 D-02); imported only by modules/bonfire and modules/diary |
|
||||
| `charm.land/huh/v2` | v2.0.3 | Interactive prompts on a terminal | Decided in .planning/notes/2026-10-21-console-charm-stack.md (Phase 19.8 D-02); imported only by modules/bonfire and modules/diary |
|
||||
| `charm.land/fang/v2` | v2.0.1 | Cobra error rendering hook (used narrowly) | Decided in .planning/notes/2026-10-21-console-charm-stack.md (Phase 19.8 D-02); imported only by modules/bonfire and modules/diary |
|
||||
| `github.com/charmbracelet/colorprofile` | v0.4.3 | Color profile detection and downsampling writer (`--ansi`, `--no-ansi`, `NO_COLOR`) | Decided in .planning/notes/2026-10-21-console-charm-stack.md (Phase 19.8 D-02); imported only by modules/bonfire and modules/diary |
|
||||
|
||||
### Development Tools
|
||||
|
||||
|
||||
@@ -11,3 +11,5 @@ g15office: u4rtpcl1
|
||||
Found while writing `docs/console/writing-commands.md` (Phase 11.1 plan 03). The page describes the current behaviour: names are not checked for duplicates, so keep commands in the plugin's own namespace.
|
||||
|
||||
Suggested fix: make `bonfire.NewRoot` fail with an error naming the duplicated command when two commands share a name. `bonfire.NewCatalog`, which the scheduler calls through, has no error return, but the generated `main` calls `bonfire.NewRoot` before it runs any command, so that check stops the binary before a duplicate can run. Update the bonfire README and the docs page in the same change (the D-13 docs rule). The API change is outside the docs phase boundary, so it is not made in Phase 11.1.
|
||||
|
||||
Done in Phase 19.8 plan 02, commit 8d4d7ce: `bonfire.NewRoot` fails with `bonfire.ErrDuplicateCommand` naming the command (also for commands named `list` or `help`), and with `bonfire.ErrReservedFlag` for flags that reuse a global option; the bonfire README and `docs/console/writing-commands.md` describe it.
|
||||
70
.planning/todos/done/email-proof-release.md
Normal file
70
.planning/todos/done/email-proof-release.md
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
created: 2026-10-21T15:00:00.000Z
|
||||
title: Release the email-proof fix (sm-user-plugin v0.1.5, sm-sociallogin-plugin v0.1.2) and move the app pins
|
||||
priority: high
|
||||
area: sm-user-plugin, sm-sociallogin-plugin
|
||||
severity: major
|
||||
g15office: sqnwn1zi
|
||||
files:
|
||||
- ../fonoteka.go/plugins/golem15/user
|
||||
- ../sm-sociallogin-plugin/go.mod
|
||||
- ../sm-sociallogin-plugin/go.sum
|
||||
- ../sm-demo-app/plugins/golem15/user
|
||||
- ../sm-demo-app/plugins/golem15/sociallogin
|
||||
- ../sm-demo-app/go.mod
|
||||
- ../sm-demo-app/go.sum
|
||||
- ../sm-bm-app/plugins/golem15/user
|
||||
- ../sm-bm-app/plugins/golem15/sociallogin
|
||||
- ../sm-bm-app/go.mod
|
||||
- ../sm-bm-app/go.sum
|
||||
---
|
||||
|
||||
## Hand-off
|
||||
|
||||
For the 19.8 session (console commands overhaul): review this release and decide on it together with the framework v0.4.0 release. On 2026-10-21 the user answered the 19.5-05 Task 6 checkpoint with **hold**: nothing was pushed or tagged and no pointer moved. Every step below needs the user's go-ahead; never force-push, never deploy.
|
||||
|
||||
The plugin tags live in the plugins' own repositories (`sm-user-plugin`, `sm-sociallogin-plugin`). They are separate from the framework `v0.4.0` tag on summercms.go and need no framework release; the framework tag does not carry them.
|
||||
|
||||
## Problem
|
||||
|
||||
Phase 19.5 plan 05 (gap closure) made a verified social sign-in link only onto an account whose owner proved the address: `users.email_verified_at` (migration `202610210001_add_users_email_verified_at`, stamped by the mailed activation code and the reset-code password reset, cleared on an email change), and sociallogin's `ownerVerifiedEmail` reads it (D-26). Details: `.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-05-SUMMARY.md`.
|
||||
|
||||
The fix sits in local commits only. Until it is released, sm-demo-app and sm-bm-app keep the pre-account-takeover path (19.5 VERIFICATION item 1): an auto-mode `/register` account with someone else's address is linked when that address's owner signs in with Google, handing the sign-in to the squatter.
|
||||
|
||||
Unreleased commits (`git log --oneline origin/master..master`, fetched 2026-10-21 about 12:25Z):
|
||||
|
||||
| Repository | Unpushed commits | This plan's? |
|
||||
|------------|------------------|--------------|
|
||||
| sm-user-plugin (`../fonoteka.go/plugins/golem15/user`) | `35cf166`, `a99cb46`, `add1f7a` | all 19.5-05 |
|
||||
| sm-sociallogin-plugin | `79cebb9`, `c4ebf05`, `962190b`, `3bb5650` | all 19.5-05 (79cebb9: README, CR-02 wording) |
|
||||
| sm-bm-app | `5fd0cc3` (bm OAuth callback view test) | 19.5-05 Task 4 |
|
||||
| sm-demo-app | `b325460` feat(demo): roadmap deck describes the new console | **foreign**: another session (19.8), touches only `plugins/demo/content/ROADMAP.md` and `plugins/demo/roadmap/` |
|
||||
| fonoteka.go | 31 commits (`f4d5728` … `ca746fc`) | all foreign; fonoteka.go is never pushed by this release |
|
||||
| summercms.go | this plan's `7170c5b`, `30e0cd9`, `c2c8c6a`, `7e43873`, `8c7794e` among others | never pushed by this release |
|
||||
|
||||
Proposed tags (free on the remotes at the time of the check; no tag points at either plugin's master):
|
||||
|
||||
| Repository | Tag | Points at |
|
||||
|------------|-----|-----------|
|
||||
| sm-user-plugin | `v0.1.5` | `35cf166` |
|
||||
| sm-sociallogin-plugin | `v0.1.2` | the new commit `chore: require sm-user-plugin v0.1.5` on top of `79cebb9` |
|
||||
|
||||
Current pins: sm-demo-app and sm-bm-app `plugins/golem15/user` c43ba87 (v0.1.4) and `plugins/golem15/sociallogin` d7ee28d (v0.1.1); sm-demo-app go.mod requires sm-user-plugin v0.1.4 and sm-sociallogin-plugin v0.1.1; sm-bm-app go.mod requires sm-user-plugin v0.1.4 and sm-sociallogin-plugin v0.0.0 (replace to the submodule; only the user line names a version); fonoteka.go gitlink c43ba87, with the worktree already at `35cf166` and left unstaged.
|
||||
|
||||
## Solution
|
||||
|
||||
D-21 order: user plugin first, then sociallogin, then the apps (user bump, then sociallogin bump). Before every push: `git fetch origin`, no uncommitted change to the release paths, and `git log --oneline origin/master..master` lists only the expected commits; otherwise stop and report. Stage by path.
|
||||
|
||||
0. Docker gate runs: the NordLayer VPN's nftables table drops loopback traffic to published container ports, so testcontainers Postgres times out. Restore it with `sudo nft insert rule inet nordlayer input iifname "lo" accept` and `sudo nft insert rule inet nordlayer output oifname "lo" accept` (lost when the VPN reconnects). Probe in fonoteka.go: `TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 go test -short -count=1 -timeout 120s -run '^TestPermissionSetScan$' ./plugins/golem15/user/classes` finishes in about 15 s when it works. Full gate: `PHASE195_REQUIRE_DOCKER=1 TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 bash scripts/check-phase19.5.sh --all` (summercms.go).
|
||||
1. sm-user-plugin (`../fonoteka.go/plugins/golem15/user`): vet and the short suite green through the fonoteka.go workspace; push master; annotated tag `v0.1.5` at `35cf166` (message: users.email_verified_at records a proved address (mail activation, reset code); an email change clears it; no backfill; routes, payloads and config unchanged); push the tag.
|
||||
2. sm-sociallogin-plugin: go.mod require `git.golem15.com/golem15/sm-user-plugin v0.1.5`, keep the summercms replace; `GOWORK=off GOPRIVATE='git.golem15.com/*' go mod tidy`; vet, short suite and `GOWORK=off go build ./...` green; commit `chore: require sm-user-plugin v0.1.5`; push master; annotated tag `v0.1.2` (message: a verified sign-in links only onto an account whose owner proved the address; accounts registered with a password in auto mode, created by an administrator or created before golem15.user v0.1.5 go to the pending screen; requires sm-user-plugin v0.1.5); push the tag.
|
||||
3. sm-demo-app, two commits: `plugins/golem15/user` to v0.1.5 with go.mod/go.sum, `bash scripts/check-phase19.5.sh --demo` and `scripts/check-pins.sh` green, commit `chore: bump sm-user-plugin to v0.1.5`; then `plugins/golem15/sociallogin` to v0.1.2 the same way, commit `chore: bump sm-sociallogin-plugin to v0.1.2`; gate green after each; push. **The push carries the foreign `b325460` (19.8 roadmap deck) unless that session has pushed it first; include it only with the user's or the 19.8 session's say-so.**
|
||||
4. sm-bm-app: the same two commits in the same order (only the versioned require line changes, keep the replace lines, add the go.sum change to the commit it belongs to); `bash scripts/check-phase19.5.sh --bm` green after each; push, including `5fd0cc3`.
|
||||
5. fonoteka.go: `git -C plugins/golem15/user describe --exact-match` names v0.1.5; `bash scripts/check-phase19.5.sh --fonoteka`; stage only `plugins/golem15/user`, commit `chore: bump sm-user-plugin to v0.1.5`, local only (its unpushed range is other work). Other sessions' gitlink changes (feedback, golem) stay untouched.
|
||||
6. g15office: comment on the Phase 19.5 milestone task `onunbz3l` (released, awaiting re-verification), set it to Testing, and close this todo's task to Live.
|
||||
|
||||
When done: record the tags, pushed ranges and final pins in 19.5-05-SUMMARY.md (or a follow-up note), move this todo to `todos/done/`, and re-run `/msd-verify-work 19.5`.
|
||||
|
||||
## Done
|
||||
|
||||
Released 2026-10-21 by the 19.8 session (plan 19.8-03, before Task 7) under the user's "release v0.4.0" go-ahead, steps 0-5 as written: sm-user-plugin `v0.1.5` at `35cf166` (pushed `c7635c5..35cf166`), sm-sociallogin-plugin `9fd58db` chore: require sm-user-plugin v0.1.5 and `v0.1.2` (pushed `d7ee28d..9fd58db`), sm-demo-app `f16ef6c` and `1afd7ff` plus a snapshot sync `a5703f9` (pushed `61a00e9..a5703f9`, carrying `b325460`), sm-bm-app `1e09c23` and `cc4b23f` (pushed `99af8c2..cc4b23f`, carrying `5fd0cc3`), fonoteka.go `2be9e1f` local only. Tags, ranges and final pins: 19.5-05-SUMMARY.md, "Release (2026-10-21, after the hold)". Step 6 (g15office: comment on `onunbz3l`, set it to Testing, this task `sqnwn1zi` to Live) was not done: no g15office tool in that session.
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
created: 2026-10-21T13:33:21.495Z
|
||||
title: Quiet plugin boot logs on console commands like --help
|
||||
area: framework
|
||||
severity: minor
|
||||
g15office: izelucbh
|
||||
files:
|
||||
- ../sm-demo-app/plugins/golem15/paymentgateway/boot.go:129
|
||||
- ../sm-demo-app/plugins/golem15/paymentgatewaysubscriptions/boot.go:86
|
||||
- modules/diary
|
||||
- modules/bonfire
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Every console command prints the plugins' boot-time INFO records, even `--help`. From sm-demo-app, after phase 19.8 plans 01–03:
|
||||
|
||||
```
|
||||
$ bin/demo serve --help
|
||||
time=2026-10-21T15:31:35.392+02:00 level=INFO msg="paymentgateway: operators registered" codes="[golem15.paymentgateway::lang.channels.cash golem15.paymentgateway::lang.channels.manualTransfer golem15.pgstripe::lang.operators.stripe]"
|
||||
time=2026-10-21T15:31:35.394+02:00 level=INFO msg="golem15.paymentgatewaysubscriptions: registries collected" subscriber_types=[user] gateways=[stripe]
|
||||
Serve the application HTTP API
|
||||
```
|
||||
|
||||
Registration chatter belongs to `serve` (or `-v`), not to `--help`, `list` or a one-off command.
|
||||
|
||||
Two causes:
|
||||
|
||||
1. sm-paymentgateway-plugin (`boot.go:129`) and sm-paymentgatewaysubscriptions-plugin (`boot.go:86`) log through `slog.Default().Info`, not a diary component logger. That is why the lines are plain slog text (`time=… level=INFO msg=…`) and not diary console output. Plan 19.8-01 moved only the framework modules onto diary components, not the shared plugins.
|
||||
2. Plugins `Boot` before bonfire dispatches the command, so whatever a plugin logs at Info level during boot shows on every command.
|
||||
|
||||
Not critical; found 2026-10-21 during phase 19.8.
|
||||
|
||||
## Solution
|
||||
|
||||
TBD. Options, possibly combined:
|
||||
|
||||
- In the payment plugins, log these records at Debug through `diary.For("<component>")`. These are shared core plugins, so the change must not break anything.
|
||||
- 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`.
|
||||
@@ -0,0 +1,144 @@
|
||||
---
|
||||
created: 2026-10-22T00:43:48.000Z
|
||||
title: Console review info findings (IN-01..IN-13) and the delegate signal exit code
|
||||
area: framework
|
||||
severity: minor
|
||||
g15office: rwb3fd9p
|
||||
files:
|
||||
- modules/diary/console.go
|
||||
- modules/diary/diary.go
|
||||
- modules/bonfire/globals.go
|
||||
- modules/bonfire/errors.go
|
||||
- modules/bonfire/root.go
|
||||
- modules/bonfire/execute.go
|
||||
- modules/cabana/token_commands.go
|
||||
- modules/lighthouse/centrifugo/commands.go
|
||||
- modules/surf/routelist_command.go
|
||||
- modules/lagoon/commands.go
|
||||
- cmd/summer/main.go
|
||||
- modules/bouncer/turnstile.go
|
||||
- modules/wristband/oidc_token.go
|
||||
- scripts/check-phase19.8.sh
|
||||
- modules/bonfire/live.go
|
||||
- modules/bonfire/output.go
|
||||
- cmd/summer/runtime.go
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The 19.8 code review (`.planning/phases/19.8-console-commands-overhaul/19.8-REVIEW.md`, v0.3.1..v0.4.1) found 3 critical issues, 8 warnings and 9 info findings. Plan 19.8-05 fixed the critical issues and the warnings. The nine info findings below are deferred to this todo. Line numbers are the review's; locate the code by symbol. The re-review of those fixes (same file, gap-closure range dd12382..0ebed68) found six warnings and six info findings; quick task 261022-4z8 fixed the warnings, IN-14 and IN-15, and deferred IN-10..IN-13 and WR-10's exit-code suggestion to this todo (the sections after IN-09).
|
||||
|
||||
## IN-01: A forced-color console drops HideOnTTY records even when stderr is not a terminal
|
||||
|
||||
**File:** `modules/diary/console.go` (`consoleHandler`, the HideOnTTY check)
|
||||
|
||||
**Problem:** The styled `consoleHandler` drops every `HideOnTTY` record. It is also used when color is forced (`--ansi`, `FORCE_COLOR`) on a non-terminal stderr. So with stdout on a terminal and stderr redirected (`FORCE_COLOR=1 bin/acme serve 2>serve.log`), the "startup summary" and "listening on" records never reach the file. The HideOnTTY docs promise that only a terminal console skips them.
|
||||
|
||||
**Suggested fix:** Check `isTerminal(w)` once when the handler is built, and drop the records only when it is true (the rule `ttyFilter` already uses).
|
||||
|
||||
## IN-02: PeekGlobals and cobra disagree on some argument forms
|
||||
|
||||
**File:** `modules/bonfire/globals.go` (`PeekGlobals`)
|
||||
|
||||
**Problem:** `PeekGlobals` ignores `--quiet=true`, `--no-interaction=true`, `--verbose=2` and `--ansi=false`, but pflag honours them. It also reads a command flag value that looks like a global (`--name -q`) as a global, where pflag takes it as the flag's value. As a result the pre-boot diary setup, the error block's `-v` chain and the passthrough globals can differ from what the command runs with.
|
||||
|
||||
**Suggested fix:** Parse the `--flag=value` forms with `strconv.ParseBool` and `strconv.Atoi`, or re-parse the args with a throwaway pflag FlagSet that holds only the global flags (`ParseErrorsWhitelist.UnknownFlags = true`).
|
||||
|
||||
## IN-03: isUsage recognises cobra's unknown-command error by its text
|
||||
|
||||
**File:** `modules/bonfire/errors.go` (`isUsage`, `RenderError`)
|
||||
|
||||
**Problem:** Any error whose message starts with "unknown command" exits 2 and is rewritten as `Command "…" is not defined.`. A plugin error worded that way would be misreported.
|
||||
|
||||
**Suggested fix:** Turn cobra's error into a `UsageError` in one place (a root `Args` or `ValidArgsFunction` check, or match it once in `Execute` before rendering), and test it with `errors.As`.
|
||||
|
||||
## IN-04: The --json flag is read two different ways
|
||||
|
||||
**Files:** `modules/cabana/token_commands.go`, `modules/lighthouse/centrifugo/commands.go`, `modules/surf/routelist_command.go` vs `modules/lagoon/commands.go` (`flagOn`)
|
||||
|
||||
**Problem:** cabana, centrifugo and route:list compare the value with `== "true"`, while lagoon uses `strconv.ParseBool`. So `--json=1` works for `migrate:status` but not for `admin:token:list`.
|
||||
|
||||
**Suggested fix:** Add one bonfire helper (for example `bonfire.FlagBool(in, name)`) and use it everywhere. It would be an additive API, so plugins keep building.
|
||||
|
||||
## IN-05: summer's scaffolding result lines are now hidden by -q
|
||||
|
||||
**File:** `cmd/summer/main.go` (the scaffolding commands' `Created` and `Added` lines)
|
||||
|
||||
**Problem:** `created <path>` and `added <dir>` used to print through `Printf`. They now print through `Success` as `Created …` and `Added …`, so `-q` hides them and their wording changed. Scripts that read the created paths stop working without notice.
|
||||
|
||||
**Suggested fix:** Print the created paths with `Println` as data (D-27), or mention the change in `docs/setup/upgrading.md`.
|
||||
|
||||
## IN-06: fang replaces the root help function that NewRootIO installs
|
||||
|
||||
**Files:** `modules/bonfire/root.go`, `modules/bonfire/execute.go`
|
||||
|
||||
**Problem:** `fang.Execute` calls `root.SetHelpFunc` with fang's own help function, so under `Execute` bonfire's root help function is dead code. Today it is unreachable, because `Execute` strips `-h` and `--help` when no command word is given. A future path that calls the root's help would print fang's layout instead of the Artisan list.
|
||||
|
||||
**Suggested fix:** Drop fang and call `root.ExecuteContext` with `RenderError` directly (bonfire already turns off every fang feature except the error handler), or restore the root help function after fang sets its own.
|
||||
|
||||
## IN-07: diary pulls the whole TUI stack into security modules
|
||||
|
||||
**Files:** `modules/diary/diary.go`, `modules/bouncer/turnstile.go`, `modules/wristband/oidc_token.go`
|
||||
|
||||
**Problem:** `diary` imports `bonfire`, which imports cobra, fang, bubbletea, bubbles and huh. So bouncer and wristband, which log through diary, link the console UI stack, and a security review of them has more code to cover.
|
||||
|
||||
**Suggested fix:** Move `ColorMode`, `DetectProfile`, `Palette`, `PrintAbove` and now `Sanitize` into a small leaf package (or into diary itself), so diary no longer imports bonfire. Since plan 19.8-05, diary's console channel also calls `bonfire.Sanitize` (WR-05). Keep `bonfire.Sanitize` as the exported entry point when the leaf package is introduced, because it is public API since v0.4.2.
|
||||
|
||||
## IN-08: The console sanitizer passes raw C1 bytes through on its fast path
|
||||
|
||||
**File:** `modules/bonfire/errors.go` (`bonfire.Sanitize`, formerly diary's `sanitize`)
|
||||
|
||||
**Problem:** A lone `0x9b` byte (8-bit CSI) is invalid UTF-8. `range` decodes it as `U+FFFD`, which is not a control character, so the function returns the original string with the raw byte in it. Terminals that interpret 8-bit C1 controls then see an escape sequence.
|
||||
|
||||
**Suggested fix:** Since plan 19.8-05 the sanitizer is `bonfire.Sanitize`, used by error blocks, process views, tables and diary's console. Run `strings.ToValidUTF8(s, "<22>")` before the control-character scan there, and add the case to `TestSanitize`.
|
||||
|
||||
## IN-09: The phase gate names consuming applications
|
||||
|
||||
**File:** `scripts/check-phase19.8.sh` (the `DEMO_APP`, `BM_APP`, `GRZYBY_APP` and `SUMMERCMSIO_APP` defaults)
|
||||
|
||||
**Problem:** The script hard-codes `sm-demo-app`, `sm-bm-app`, `sm-grzybyfunkcjonalne-app` and `sm-summercmsio-app`, and splits a consumer's name into two strings to get past the name check. CLAUDE.md says framework code never names a consuming application. Earlier phase gates do the same, but this framework repository is published.
|
||||
|
||||
**Suggested fix:** Read the sibling paths from environment variables only, with no defaults, or move the cross-repository stages to a gate outside the framework repository.
|
||||
|
||||
## IN-10: console.Error during a live view writes straight to the terminal and tears the view
|
||||
|
||||
**File:** `modules/bonfire/output.go` (`console.Error`, `emit`)
|
||||
|
||||
**Problem:** `Error` writes to `c.err`, and `emit` reroutes only writes to `c.out`. When stderr and stdout are the same terminal (the usual case), an `out.Error(...)` from inside `Spinner`, `Progress` or `Process` lands mid-frame. A pty run showed the spinner glyph left on the error line (`⠹ ✗ stderr-error-line`), with the renderer continuing on the next row. diary's console records on stderr do go above the view through `PrintAbove`, so the two stderr paths behave differently.
|
||||
|
||||
**Suggested fix:** In `emit`, also reroute `w == c.err` when the console owns the running view and `c.err` is a terminal (`printAbove(c, s)`).
|
||||
|
||||
## IN-11: A blank line printed during a live view is dropped
|
||||
|
||||
**File:** `modules/bonfire/live.go` (`printAbove`, the `strings.TrimSuffix` of the block)
|
||||
|
||||
**Problem:** `out.Println()` gives `"\n"`. `TrimSuffix` turns that into `""`, and bubbletea's `insertAbove` returns early for an empty string (cursed_renderer.go in bubbletea v2.1.0). `printAbove` still reports `true`, so the blank separator line is lost. It is printed when no view runs.
|
||||
|
||||
**Suggested fix:** Send `" "` (or `"\u200b"`) for an empty block, or skip the trim when the block is only a newline.
|
||||
|
||||
## IN-12: Signal relay edge cases: a signal during view start-up, and Windows
|
||||
|
||||
**File:** `modules/bonfire/live.go` (`runLive`, `watchSignals`, `raise`)
|
||||
|
||||
**Problem:** This comes from reading bubbletea v2.1.0; the reviewer did not reproduce it.
|
||||
|
||||
1. **Start-up race.** `prog.Run` starts on its own goroutine just before the relay is registered. If a signal arrives before `Run` has created and started its renderer, `Kill` runs `shutdown(true)` while `p.renderer` is still nil. `shutdownOnce` then makes `Run`'s own `shutdown(killed)` a no-op after it has started the renderer and hidden the cursor, so the cursor stays hidden.
|
||||
2. **Windows.** On Windows, `p.Signal(os.Interrupt)` fails, so `raise` calls `os.Exit(130)`. That skips the command's own handler (for example, `summer dev` stopping its serve child) and its deferred cleanup.
|
||||
|
||||
**Suggested fix:** Before killing, wait until the program reports it has started (for example, have the wrapped model's `Init` close a `ready` channel). On platforms where `raise` cannot deliver the signal, return without exiting when another handler is registered.
|
||||
|
||||
## IN-13: A delegate's stderr always goes to os.Stderr, bypassing the root's error writer
|
||||
|
||||
**File:** `cmd/summer/runtime.go` (`appStreams`)
|
||||
|
||||
**Problem:** Off a terminal, the child's stdout goes through `out`, but its stderr goes to the real `os.Stderr`, not to the error stream of the root that ran the command. For a summer root built with `NewRootIO(…, errW)` (tests, embedding), the app binary's error block cannot be captured. `TestDelegateExitCodePassthrough` therefore cannot check that the binary's own block was printed.
|
||||
|
||||
**Suggested fix:** Pass the root's error writer through (for example, a `bonfire.ErrWriter(out)` accessor), defaulting to `os.Stderr`.
|
||||
|
||||
## WR-10 follow-up: exit code 128+n for a delegate killed by a signal
|
||||
|
||||
**File:** `cmd/summer/runtime.go` (`runAppCmd`)
|
||||
|
||||
**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.
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
created: 2026-10-22T03:30:00.000Z
|
||||
title: User plugin logout leaves the session cookie valid for up to 60 minutes
|
||||
area: user-plugin
|
||||
severity: major
|
||||
g15office: ttf7tinw
|
||||
files:
|
||||
- ../sm-user-plugin (mounted at plugins/golem15/user in the apps)
|
||||
- controllers/api_controller.go
|
||||
- classes/session_user.go
|
||||
- routes.go
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Reported by the user: after logout the auth cookie is not invalidated at once; requests keep
|
||||
authenticating until the token expires about 60 minutes later. If true, a logout on a shared
|
||||
machine does not end the session, and a stolen cookie survives the victim's logout.
|
||||
|
||||
## To check
|
||||
|
||||
- What logout does: clear the cookie only, or also revoke the server-side session / refresh
|
||||
token, and whether the access JWT is checked against a revocation list (bouncer) or only by
|
||||
signature and `exp`.
|
||||
- Whether the 60 minutes is the access-token TTL.
|
||||
- Parity with the PHP (WinterCMS) user plugin's logout behaviour; the frontend contract stays.
|
||||
|
||||
## Fix direction
|
||||
|
||||
Logout revokes the server-side session so the access token is refused on the next request
|
||||
(same mechanism c3712dc uses for deactivate/ban), clears the cookies with matching
|
||||
attributes, and a test pins "request after logout is 401". No breaking change to the plugin
|
||||
API (core plugin, many projects).
|
||||
|
||||
## Outcome (2026-10-22)
|
||||
|
||||
Confirmed and fixed locally; closes when released. Debug session:
|
||||
`.planning/debug/user-logout-session-stays-valid.md`.
|
||||
|
||||
- Plugin root cause: `Logout` read the token from the `Authorization` header only, so a
|
||||
cookie-only logout (sociallogin `token_cookie: true`) answered 401 and revoked nothing, and the
|
||||
cookie kept working for the 60-minute access TTL; logout cleared no cookie; the blacklist row
|
||||
lived only until `exp`, so a logged-out token could be refreshed again once it was swept.
|
||||
Fix: sm-user-plugin `358ee5d` (new `classes.EndSession`, blacklist until the end of the refresh
|
||||
window, forget cookies with the configured attributes, `TestLogoutEndsTheSession`).
|
||||
- Demo frontend root cause: `useDemoAuth().logout()` only cleared its own cookie and never called
|
||||
`/logout`. Fix: vue-demo-app `f397df2`.
|
||||
- Ships in sm-user-plugin v0.1.8 (with `aa83969`, needs SummerCMS v0.4.3) and the sm-demo-app
|
||||
pointer bumps. Hand check after deploy: cookie-only logout, then a `jwt.auth` route with the old
|
||||
cookie answers 401.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
created: 2026-10-21T00:00:00.000Z
|
||||
title: Let password-less social accounts set a first password through change-password
|
||||
area: sm-user-plugin
|
||||
priority: medium
|
||||
files:
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go
|
||||
- ../sm-sociallogin-plugin/testdata/PHP_METHOD_MAP.md
|
||||
g15office: not created (no g15office tool in the executing session; see 19.5-04 checkpoint)
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Social sign-in (golem15.sociallogin, Phase 19.5, D-10) creates password-less accounts: a random password nobody knows and `has_self_set_password` false. The PHP user plugin lets such an account set its first password through change-password without the current one, after confirming a short-lived, single-use code emailed to its own address, and disarms the relaxation after the first successful write (`OAuthPasswordBootstrapTest`: `test_oauth_account_sets_password_without_current_password`, `test_disarm_closes_the_no_current_password_window`, `test_confirmation_code_is_single_use_and_time_limited`).
|
||||
|
||||
The Go user plugin has no such flow: `POST /_user/api/v1/change-password` answers an opaque 500 for an account without a self-set password, and only forgot/reset works. Phase 19.5 had no CONTEXT decision for it, so the three PHP methods are mapped not-applicable in `sm-sociallogin-plugin/testdata/PHP_METHOD_MAP.md`. An application cutover whose frontend relies on the PHP flow (428 `requires_confirmation`, then `password_bootstrap_code`) needs it.
|
||||
|
||||
## Solution
|
||||
|
||||
Port the PHP bootstrap to sm-user-plugin as an additive change: for `has_self_set_password = false`, change-password without `current_password` issues a confirmation code mailed to the account (answers 428 with `requires_confirmation`), verifies `password_bootstrap_code` (hashed, single use, time limited), writes the password and sets `has_self_set_password` true (disarm). A real-password account stays byte-identical (`TestChangePassword/real_password_account_still_requires_current_password`). The code needs columns or a cache entry (decide in the phase; a migration needs a decision note), a mail template, and the PHP tests above ported, which turns their three map rows into ported rows.
|
||||
@@ -101,6 +101,7 @@ The `migrate`, `migrate:status`, `migrate:rollback`, `serve` and admin commands
|
||||
| [compass](modules/compass/README.md) | Layered YAML configuration with per-environment directories, `SUMMER_` environment overrides, embedded plugin defaults and dot-path access. | Points you the right way; configuration tells the application how to behave in each environment. |
|
||||
| [conga](modules/conga/README.md) | Background jobs on River over the shared Postgres pool: transactional dispatch, a `summer_jobs` progress record, in-process or dedicated workers, and a wall-clock scheduler. | A line of dancers moving one after another; jobs queue up and are worked through in order. |
|
||||
| [cooler](modules/cooler/README.md) | Cache drivers and shared counters for SummerCMS: memory and Redis caches with a key prefix and TTLs, a Redis-backed rate-limit store, and the site-cache purge after admin saves. | The cool box you bring to the beach; it keeps things fresh and at hand for everyone in the group. |
|
||||
| [diary](modules/diary/README.md) | Application logging modelled on Laravel's logging config: named channels (console, stderr, daily files, stacks), per-component levels and redaction on every channel. | A beach diary records each day; the log keeps one file per day. |
|
||||
| [festival](modules/festival/README.md) | Typed, synchronous event bus with listener priorities, payload collection and stop-when-handled dispatch. | A summer festival announces events and the crowd reacts; it is the event bus. |
|
||||
| [fetchguard](modules/fetchguard/README.md) | Guarded outbound HTTP client and fetcher that blocks private and reserved addresses, enforces host, size and timeout limits, and offers an explicit trusted mode for operator-configured endpoints. | A lifeguard for outbound fetches; it watches every request the application sends out and stops the dangerous ones. |
|
||||
| [flare](modules/flare/README.md) | Web Push delivery with VAPID (RFC 8292) and aes128gcm payload encryption (RFC 8291) behind a small Pusher interface. | A signal flare reaches people far away; it sends Web Push notifications to browsers. |
|
||||
|
||||
42
cmd/summer/commands_documented_test.go
Normal file
42
cmd/summer/commands_documented_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/conga"
|
||||
"git.golem15.com/golem15/summercms/modules/flare"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/lighthouse/centrifugo"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
)
|
||||
|
||||
// applicationCommands is every command the framework gives an application
|
||||
// binary, built the way docsCommands does. The blog example's blog:publish
|
||||
// is checked by its own package's test: the tool must not import example
|
||||
// plugins (TestToolDoesNotImportExamplePlugins).
|
||||
func applicationCommands() []bonfire.Command {
|
||||
app := backpack.New(&compass.Config{})
|
||||
var cmds []bonfire.Command
|
||||
cmds = append(cmds, lagoon.RuntimeCommands(app, nil)...)
|
||||
cmds = append(cmds, conga.RuntimeCommands(app, nil)...)
|
||||
cmds = append(cmds, surf.ServeCommand(app, nil), surf.RouteListCommand(app, nil))
|
||||
cmds = append(cmds, cabana.RuntimeCommands(app)...)
|
||||
cmds = append(cmds, centrifugo.Commands(app)...)
|
||||
cmds = append(cmds, flare.Commands(app)...)
|
||||
return cmds
|
||||
}
|
||||
|
||||
// TestCommandsDocumented is the framework's documentation coverage gate
|
||||
// (D-17): every command of the summer tool (its delegates included) and of
|
||||
// an application binary has a description, long help, examples and
|
||||
// described arguments and options, and the test fails on any gap.
|
||||
func TestCommandsDocumented(t *testing.T) {
|
||||
commands := append(toolCommands(), applicationCommands()...)
|
||||
for _, problem := range bonfire.CheckDocs(commands) {
|
||||
t.Error(problem)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
@@ -24,6 +24,17 @@ func docsBuildCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "docs:build",
|
||||
Description: "Build the documentation site",
|
||||
Long: `Checks every page under docs/ and every module README, then writes the static
|
||||
site to site/ (or --out): HTML pages, a raw .md copy of each page, llms.txt,
|
||||
llms-full.txt and the search index. It fails, writing nothing, when a page
|
||||
names an identifier that does not exist, links to a missing page or anchor,
|
||||
shows a command neither summer nor an application binary has, or has a src=
|
||||
code block that differs from its source. --check only reports. Run it from
|
||||
the framework root.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "docs:build --check", Description: "Check the docs and write nothing"},
|
||||
{Command: "docs:build --base-url /docs/ --out /tmp/site", Description: "Build the site for /docs/ into /tmp/site"},
|
||||
},
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "root", Description: "Repository root; src= paths and modules/ resolve against it", Default: "."},
|
||||
{Name: "src", Description: "Docs source directory (default <root>/docs)"},
|
||||
@@ -63,6 +74,14 @@ func docsSyncCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "docs:sync",
|
||||
Description: "Rewrite src= code blocks from their sources",
|
||||
Long: `Rewrites every code block that has a src= reference with the current text of
|
||||
its source (a file, or a function, test or example in it), so the docs show
|
||||
what the code does. Run it after changing code a page shows; docs:build
|
||||
--check fails until you do.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "docs:sync", Description: "Refresh every src= block of the docs in this repository"},
|
||||
{Command: "docs:sync --root ../summercms.go", Description: "Refresh the docs of another checkout"},
|
||||
},
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "root", Description: "Repository root; src= paths resolve against it", Default: "."},
|
||||
{Name: "src", Description: "Docs source directory (default <root>/docs)"},
|
||||
@@ -89,6 +108,14 @@ func docsServeCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "docs:serve",
|
||||
Description: "Build the documentation site and preview it on a local address",
|
||||
Long: `Builds the documentation site like docs:build into a temporary directory and
|
||||
serves it on a loopback address. It watches the docs sources and the files
|
||||
their src= references point to, and rebuilds on change; a failed rebuild keeps
|
||||
the last good site. Ctrl+C stops the server and exits 0.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "docs:serve", Description: "Preview the docs of the current repository on " + docsite.DefaultServeAddr},
|
||||
{Command: "docs:serve --addr 127.0.0.1:4100", Description: "Use another port"},
|
||||
},
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "root", Description: "Repository root; src= paths and modules/ resolve against it", Default: "."},
|
||||
{Name: "src", Description: "Docs source directory (default <root>/docs)"},
|
||||
@@ -136,13 +163,15 @@ func docsOptions(in bonfire.Input) docsite.Options {
|
||||
}
|
||||
|
||||
// docsCommands collects the command names docs pages may show: the summer
|
||||
// tool's own commands, and every command the generated application main
|
||||
// tool's own commands, list and help (added by bonfire.NewRoot), and every
|
||||
// command the generated application main
|
||||
// registers (TestDocsCommandsMirrorGeneratedMain keeps this list in step
|
||||
// with internal/build) plus the realtime and push commands applications
|
||||
// append. The constructors only capture the app, so an empty config is
|
||||
// enough; nothing runs.
|
||||
func docsCommands() *docsite.Commands {
|
||||
var tool []string
|
||||
// list and help come from bonfire.NewRoot, in both programs.
|
||||
tool := []string{"list", "help"}
|
||||
for _, c := range toolCommands() {
|
||||
tool = append(tool, c.Name)
|
||||
}
|
||||
@@ -156,7 +185,7 @@ func docsCommands() *docsite.Commands {
|
||||
appCmds = append(appCmds, cabana.RuntimeCommands(app)...)
|
||||
appCmds = append(appCmds, centrifugo.Commands(app)...)
|
||||
appCmds = append(appCmds, flare.Commands(app)...)
|
||||
names := make([]string, 0, len(appCmds))
|
||||
names := []string{"list", "help"}
|
||||
for _, c := range appCmds {
|
||||
names = append(names, c.Name)
|
||||
}
|
||||
@@ -164,11 +193,11 @@ func docsCommands() *docsite.Commands {
|
||||
}
|
||||
|
||||
// reportDocsProblems prints one line per problem and a summary, and returns
|
||||
// a short error so the binary exits 1.
|
||||
// a Reported error, so the binary exits 1 without repeating the list.
|
||||
func reportDocsProblems(out bonfire.Output, cmd string, problems []docsite.Problem) error {
|
||||
for _, p := range problems {
|
||||
out.Printf("%s\n", p)
|
||||
}
|
||||
out.Printf("%s: %d problems, nothing written\n", cmd, len(problems))
|
||||
return errors.New(cmd + " failed")
|
||||
return bonfire.Reported(fmt.Errorf("%s failed", cmd))
|
||||
}
|
||||
|
||||
@@ -401,12 +401,12 @@ func first(lines []string) string {
|
||||
|
||||
func TestDocsCommandNames(t *testing.T) {
|
||||
cmds := docsCommands()
|
||||
for _, want := range []string{"docs:build", "make:plugin", "migrate:status"} {
|
||||
for _, want := range []string{"docs:build", "make:plugin", "migrate:status", "list", "help"} {
|
||||
if !slices.Contains(cmds.Tool, want) {
|
||||
t.Errorf("Tool is missing %s: %v", want, cmds.Tool)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"key:generate", "route:list", "admin:create", "queue:clear", "websockets:health"} {
|
||||
for _, want := range []string{"key:generate", "route:list", "admin:create", "queue:clear", "websockets:health", "list", "help"} {
|
||||
if !slices.Contains(cmds.App, want) {
|
||||
t.Errorf("App is missing %s: %v", want, cmds.App)
|
||||
}
|
||||
|
||||
@@ -1,28 +1,31 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"git.golem15.com/golem15/summercms/internal/build"
|
||||
"git.golem15.com/golem15/summercms/internal/dev"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/diary"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
)
|
||||
|
||||
func main() {
|
||||
root, err := bonfire.NewRoot("summer", toolCommands(), os.Stdout)
|
||||
args := os.Args[1:]
|
||||
g := bonfire.PeekGlobals(args)
|
||||
diary.Bootstrap(os.Stderr, diary.FromGlobals(g))
|
||||
root, err := bonfire.NewRoot("summer", toolCommands(), os.Stdout,
|
||||
bonfire.WithVersion(bonfire.BuildVersion()), bonfire.OnGlobals(diary.ApplyGlobals))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
root.SetArgs(os.Args[1:])
|
||||
if err := root.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
os.Exit(bonfire.Fail(os.Stderr, "summer", err, g))
|
||||
}
|
||||
os.Exit(bonfire.Execute(context.Background(), root, args))
|
||||
}
|
||||
|
||||
func toolCommands() []bonfire.Command {
|
||||
@@ -52,12 +55,39 @@ func buildCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "build",
|
||||
Description: "Generate plugin imports and build the app binary",
|
||||
Long: `Reads summer.yaml of the application in the current directory (or the nearest
|
||||
one above it), generates plugins.gen.go and main.go from it, and runs go build
|
||||
into bin/<binary>, passing the manifest's build.tags as -tags. On a terminal a
|
||||
spinner runs while go build works. It ends with "Built bin/<binary> in <time>";
|
||||
a compile error is printed and summer exits 1. Do not edit the generated
|
||||
files: every build rewrites them.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "build", Description: "Regenerate main.go and build bin/<binary>"},
|
||||
{Command: "build -q", Description: "Build and print only errors"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return build.App(ctx, dir, out)
|
||||
var log bytes.Buffer
|
||||
run := func() error { return build.App(ctx, dir, &log) }
|
||||
if bonfire.IsTerminal(out) {
|
||||
err = out.Spinner("Building", run)
|
||||
} else {
|
||||
err = run()
|
||||
}
|
||||
for _, line := range strings.SplitAfter(log.String(), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if rest, ok := strings.CutPrefix(line, "built "); ok && err == nil {
|
||||
out.Success("Built bin/" + strings.TrimSuffix(rest, "\n"))
|
||||
continue
|
||||
}
|
||||
out.Printf("%s", line)
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -66,6 +96,15 @@ func makePluginCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "make:plugin",
|
||||
Description: "Scaffold a compiling plugin module from a vendor.plugin id",
|
||||
Long: `Creates plugins/<plugin> in the current application: a Go module with plugin.go
|
||||
(the plugin ID, Register and Boot) and go.mod, with a replace for a local
|
||||
framework checkout when the application uses one. Register it with plugin:add
|
||||
so the next summer build compiles it in. Run it inside an application; the
|
||||
directory must not exist yet.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "make:plugin acme.blog", Description: "Create plugins/blog with the ID acme.blog"},
|
||||
{Command: "plugin:add plugins/blog", Description: "Then register it in summer.yaml, go.mod and go.work"},
|
||||
},
|
||||
Args: []bonfire.Arg{{
|
||||
Name: "id",
|
||||
Description: "Plugin ID in vendor.plugin form",
|
||||
@@ -84,7 +123,7 @@ func makePluginCommand() bonfire.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Printf("created %s\n", created)
|
||||
out.Success("Created " + created)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -94,6 +133,17 @@ func makeModelCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "make:model",
|
||||
Description: "Generate a GORM model and create-table migration",
|
||||
Long: `Writes models/<name>.go with a GORM model and, unless --no-migration, a
|
||||
create-table migration updates/<timestamp>_create_<table>.go, in the plugin
|
||||
with this ID (or the plugin whose directory you are in, when the ID is left
|
||||
out). The table is the plugin ID and the plural name in snake case, such as
|
||||
acme_blog_posts. The plugin's registry.gen.go lists the new files, so the
|
||||
plugin picks them up on the next build. Existing files and names are never
|
||||
overwritten.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "make:model acme.blog Post", Description: "A Post model and its create-table migration"},
|
||||
{Command: "make:model Post --no-migration", Description: "Inside plugins/blog: only the model"},
|
||||
},
|
||||
Args: makeArtifactArgs(),
|
||||
Flags: []bonfire.Flag{{
|
||||
Name: "no-migration",
|
||||
@@ -122,6 +172,15 @@ func makeMigrationCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "make:migration",
|
||||
Description: "Generate an ordered gormigrate stub",
|
||||
Long: `Writes updates/<timestamp>_<name>.go with an empty gormigrate migration (up
|
||||
and down steps to fill in) in the plugin with this ID or the plugin whose
|
||||
directory you are in. The 14-digit timestamp keeps migrations in the order you
|
||||
created them. registry.gen.go adds it to the plugin's migrations; run migrate
|
||||
to apply it.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "make:migration acme.blog AddSlugToPosts", Description: "An empty migration in acme.blog"},
|
||||
{Command: "make:migration AddSlugToPosts", Description: "The same from inside plugins/blog"},
|
||||
},
|
||||
Args: makeArtifactArgs(),
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
dir, err := os.Getwd()
|
||||
@@ -145,6 +204,16 @@ func makeCommandCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "make:command",
|
||||
Description: "Generate a plugin console command",
|
||||
Long: `Writes console/<name>.go with a bonfire.Command named <plugin>:<name> in the
|
||||
plugin with this ID or the plugin whose directory you are in; the generated
|
||||
registry adds it to the commands the plugin returns. The generated function
|
||||
takes no arguments, so a command that needs the database or another service
|
||||
takes a parameter instead and is returned from the plugin's Commands by hand
|
||||
(see the porting guide's blog:publish).`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "make:command acme.blog Publish", Description: "A blog:publish command in acme.blog"},
|
||||
{Command: "make:command Publish", Description: "The same from inside plugins/blog"},
|
||||
},
|
||||
Args: makeArtifactArgs(),
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
dir, err := os.Getwd()
|
||||
@@ -168,6 +237,14 @@ func makeJobCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "make:job",
|
||||
Description: "Generate a plugin job without importing River",
|
||||
Long: `Writes jobs/<name>.go with a queued job built with conga.Job (no River import)
|
||||
in the plugin with this ID or the plugin whose directory you are in; the
|
||||
generated registry adds it to the jobs the plugin returns. Dispatch it with
|
||||
conga.Manager.Dispatch; queue:work or serve runs it.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "make:job acme.blog SendDigest", Description: "A SendDigest job in acme.blog"},
|
||||
{Command: "make:job SendDigest", Description: "The same from inside plugins/blog"},
|
||||
},
|
||||
Args: makeArtifactArgs(),
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
dir, err := os.Getwd()
|
||||
@@ -191,6 +268,16 @@ func makeAdminControllerCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "make:admin-controller",
|
||||
Description: "Generate an admin controller with fields.yaml and columns.yaml",
|
||||
Long: `Writes controllers/<name>.go, its config_form.yaml and config_list.yaml, and
|
||||
starter fields.yaml and columns.yaml under models/<name>/, in the plugin with
|
||||
this ID or the plugin whose directory you are in. The controller requires the
|
||||
permission <plugin>.access_<name>, which you declare in the plugin's
|
||||
Permissions(), and its NewRecord returns nil until you return your model, so
|
||||
the screens stay closed until you finish it.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "make:admin-controller acme.blog Posts", Description: "A Posts controller in acme.blog"},
|
||||
{Command: "make:admin-controller Posts", Description: "The same from inside plugins/blog"},
|
||||
},
|
||||
Args: makeArtifactArgs(),
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
dir, err := os.Getwd()
|
||||
@@ -244,7 +331,7 @@ func flagTrue(in bonfire.Input, name string) bool {
|
||||
|
||||
func printArtifact(out bonfire.Output, result build.ArtifactResult) error {
|
||||
for _, path := range result.Files {
|
||||
out.Printf("created %s\n", path)
|
||||
out.Success("Created " + path)
|
||||
}
|
||||
if result.Hint != "" {
|
||||
out.Printf("%s\n", result.Hint)
|
||||
@@ -256,12 +343,27 @@ func devCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "dev",
|
||||
Description: "Watch sources and rebuild the app binary",
|
||||
Long: `Builds the application in the current directory, runs bin/<binary> serve and
|
||||
watches the application's Go, YAML, go.mod, go.work, summer.yaml and .env
|
||||
files (bin/, tmp/, .git/, main.go and plugins.gen.go excluded). After a change
|
||||
it rebuilds and restarts serve; a failed build keeps the running server.
|
||||
|
||||
The global options summer dev was given (-v, -q, --ansi, --no-ansi, -n,
|
||||
--env) are passed on to serve. Ctrl+C stops serve, which prints its own
|
||||
shutdown report, then prints the dev report (rebuilds, failed builds,
|
||||
restarts, uptime) and exits 0.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "dev", Description: "Build, serve and rebuild on every change"},
|
||||
{Command: "dev -v", Description: "Pass -v on to serve: per-plugin summary lines and info logs of every component"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return dev.Watch(ctx, dir, out)
|
||||
ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
return dev.Watch(ctx, dir, out, bonfire.GlobalsOf(out).Args())
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -270,6 +372,15 @@ func addPluginCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "plugin:add",
|
||||
Description: "Register a local plugin module in the app manifest and workspace",
|
||||
Long: `Adds a plugin that already exists on disk (a directory with plugin.go and
|
||||
go.mod, for example one made by make:plugin or a shared plugin checked out as
|
||||
a git submodule) to the application: its ID and module go into summer.yaml, a
|
||||
require and a local replace into the application's go.mod, and the directory
|
||||
into go.work. Run summer build afterwards. A plugin ID or module that is
|
||||
already registered is an error.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "plugin:add plugins/acme/blog", Description: "Register the plugin in plugins/acme/blog"},
|
||||
},
|
||||
Args: []bonfire.Arg{{
|
||||
Name: "dir",
|
||||
Description: "Local plugin directory",
|
||||
@@ -287,7 +398,7 @@ func addPluginCommand() bonfire.Command {
|
||||
if err := build.AddPlugin(ctx, dir, pluginDir); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Printf("added %s\n", pluginDir)
|
||||
out.Success("Added " + pluginDir + " to summer.yaml, go.mod and go.work; run summer build")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -297,6 +408,16 @@ func newCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "new",
|
||||
Description: "Create a SummerCMS application in sm-<name>-app",
|
||||
Long: `Creates sm-<name>-app in the current directory: summer.yaml, go.mod and go.work
|
||||
(pointing at a local framework checkout found next to it, or --framework),
|
||||
config/ including config/log.yaml, .env with a fresh app.key and
|
||||
.env.example, compose.yaml for PostgreSQL, a starter plugin (unless
|
||||
--no-plugin) and a git repository (unless --no-git). It prints the next steps:
|
||||
start the database, summer build, summer migrate and summer serve.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "new acme", Description: "Create sm-acme-app with a starter plugin"},
|
||||
{Command: "new acme --no-plugin --no-git", Description: "An empty application without a git repository"},
|
||||
},
|
||||
Args: []bonfire.Arg{{
|
||||
Name: "name",
|
||||
Description: "Application name: lower-case letters and digits, starting with a letter; not a Go keyword, main or internal",
|
||||
|
||||
@@ -18,6 +18,16 @@ func parityProxyCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "parity:proxy",
|
||||
Description: "Record named HTTP sessions through a loopback reverse proxy",
|
||||
Long: `Runs a recording reverse proxy on a loopback address in front of the reference
|
||||
backend (--upstream). Point a real client (the frontend, an MCP server) at it
|
||||
and use it: each named session, taken from the X-Parity-Session request header
|
||||
or --session, is written as one fixture under --fixtures (nuxt/ or mcp/).
|
||||
Captured secrets go to the --vars file (mode 0600), never into fixtures.
|
||||
Existing fixtures are kept unless --update is true. Ctrl+C stops it.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "parity:proxy --upstream http://127.0.0.1:8000 --fixtures testdata/parity --vars /tmp/parity/vars.yaml", Description: "Record sessions against a reference backend on port 8000"},
|
||||
{Command: "parity:proxy --session checkout --rules testdata/parity/rules.yaml --vars /tmp/parity/vars.yaml", Description: "Record everything as the checkout session with capture rules"},
|
||||
},
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "listen", Description: "Loopback bind address", Default: tide.DefaultListen},
|
||||
{Name: "upstream", Description: "Fixed loopback HTTP origin", Default: tide.DefaultUpstream},
|
||||
@@ -35,6 +45,16 @@ func parityRecordCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "parity:record",
|
||||
Description: "Record HTTP responses for a one-flow YAML spec",
|
||||
Long: `Sends the requests of a YAML spec (--spec) to the reference backend (--target)
|
||||
and records the responses as a fixture (--output). With --manifest it instead
|
||||
records the cases of a route manifest that have no fixture yet, at most
|
||||
--next-batch (15) per run; --resume skips valid existing fixtures. Captured
|
||||
secrets go to the --vars file. The true/false flags take the words true or
|
||||
false.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "parity:record --spec testdata/parity/posts.spec.yaml --target http://127.0.0.1:8000 --output testdata/parity/posts.yaml --vars /tmp/parity/vars.yaml", Description: "Record one flow"},
|
||||
{Command: "parity:record --manifest testdata/parity/routes.yaml --fixtures testdata/parity --target http://127.0.0.1:8000 --vars /tmp/parity/vars.yaml --next-batch 10", Description: "Record the next ten unrecorded routes of a manifest"},
|
||||
},
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "spec", Description: "YAML request spec path"},
|
||||
{Name: "target", Description: "Base URL of the HTTP backend"},
|
||||
@@ -57,6 +77,15 @@ func parityReplayCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "parity:replay",
|
||||
Description: "Replay recorded fixtures against an HTTP backend",
|
||||
Long: `Replays recorded fixtures (a file or a directory, --fixtures) against a backend,
|
||||
normally the port (--target), and reports each difference after masking IDs
|
||||
and timestamps. --self-check fails on any mismatch; with --manifest and
|
||||
--require-recorded a required case without a fixture fails too. It exits 1
|
||||
when a fixture does not match.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "parity:replay --fixtures testdata/parity --target http://127.0.0.1:8080 --vars /tmp/parity/vars.yaml", Description: "Replay every fixture against the port"},
|
||||
{Command: "parity:replay --fixtures testdata/parity/posts.yaml --target http://127.0.0.1:8000 --self-check true", Description: "Check a fixture against the reference backend itself"},
|
||||
},
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "fixtures", Description: "Recorded YAML fixture path or directory"},
|
||||
{Name: "target", Description: "Base URL of the HTTP backend"},
|
||||
@@ -73,6 +102,17 @@ func parityBroadcastsCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "parity:broadcasts",
|
||||
Description: "Record the Centrifugo publications a reference backend sends during a flow into a golden",
|
||||
Long: `Starts a fake Centrifugo server on a loopback address (--listen), runs the flow
|
||||
(--flow) against the reference backend (--target), which must be configured
|
||||
to publish to it, and writes the publications it received into a golden file
|
||||
(--out). --step records only one step, earlier steps running as setup; --ids
|
||||
replaces id:* variables with placeholders; --settle waits for late
|
||||
publications after each step; --pending marks the golden as recorded but not
|
||||
yet asserted.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "parity:broadcasts --flow testdata/parity/chat.flow.yaml --target http://127.0.0.1:8000 --vars /tmp/parity/vars.yaml --out testdata/parity/chat.broadcasts.yaml", Description: "Record the publications of a chat flow"},
|
||||
{Command: "parity:broadcasts --flow testdata/parity/chat.flow.yaml --target http://127.0.0.1:8000 --vars /tmp/parity/vars.yaml --out testdata/parity/send.broadcasts.yaml --step send --ids id:thread", Description: "Record one step with the thread id as a placeholder"},
|
||||
},
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "flow", Description: "YAML flow spec to run against the target"},
|
||||
{Name: "target", Description: "Loopback base URL of the reference backend"},
|
||||
@@ -95,6 +135,16 @@ func parityUpstreamCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "parity:upstream",
|
||||
Description: "Record the vendor HTTPS calls a reference backend sends through a loopback recording proxy into an upstream sidecar",
|
||||
Long: `Runs a loopback HTTPS recording proxy (--listen) with a locally generated CA
|
||||
kept in --ca-dir. Configure the reference backend to send its vendor API calls
|
||||
(payments, mail, maps) through it and to trust the CA. In script mode each
|
||||
call is answered from --script; in forward mode it is sent once to the real
|
||||
vendor. When interrupted with Ctrl+C it writes the calls, credentials masked
|
||||
with the --vars values, into the upstream sidecar --out.`,
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "parity:upstream --ca-dir /tmp/parity/ca --out testdata/parity/pay.upstream.yaml --script testdata/parity/pay.script.yaml --vars /tmp/parity/vars.yaml", Description: "Answer vendor calls from a script"},
|
||||
{Command: "parity:upstream --ca-dir /tmp/parity/ca --out testdata/parity/pay.upstream.yaml --mode forward --vars /tmp/parity/vars.yaml", Description: "Forward each call once to the real vendor"},
|
||||
},
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "listen", Description: "Loopback address of the recording proxy", Default: tide.DefaultUpstreamProxyListen},
|
||||
{Name: "ca-dir", Description: "Directory of the local parity CA, outside the fixtures tree (key kept mode 0600)"},
|
||||
|
||||
@@ -9,11 +9,11 @@ import (
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/internal/argmask"
|
||||
"git.golem15.com/golem15/summercms/internal/build"
|
||||
"git.golem15.com/golem15/summercms/internal/dev"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
)
|
||||
|
||||
@@ -21,29 +21,65 @@ import (
|
||||
var appRunner = runAppBinary
|
||||
|
||||
// delegateCommands returns every command summer runs on the app binary. One
|
||||
// table is the single source: name, base description and a hint naming the
|
||||
// common flags or arguments the app binary accepts.
|
||||
// table is the single source: name, base description, a hint naming the
|
||||
// common flags or arguments the app binary accepts, and two examples.
|
||||
func delegateCommands() []bonfire.Command {
|
||||
rows := []struct{ name, description, hint string }{
|
||||
{"migrate", "Run plugin migrations in the app binary", ""},
|
||||
{"migrate:rollback", "Roll back the last migration of a plugin in the app binary", "--plugin <id>"},
|
||||
{"migrate:status", "Show per-plugin migration history in the app binary", ""},
|
||||
{"serve", "Run the app HTTP server", "--addr <host:port> (default :8080)"},
|
||||
{"queue:work", "Run background job workers in the app binary", "--queue <name> (repeatable)"},
|
||||
{"queue:clear", "Clear pending queued jobs in the app binary", `[queue] argument (default "default")`},
|
||||
{"schedule:run", "Run the scheduler in the app binary (or once for system cron)", "--once"},
|
||||
{"route:list", "List the app's routes from the app binary", ""},
|
||||
{"key:generate", "Print a fresh application key from the app binary", ""},
|
||||
{"admin:create", "Create a backend administrator in the app binary", "--email <email> (required), --login, --role <code>, --superuser; the password is prompted or read from stdin"},
|
||||
{"admin:reset-password", "Reset a backend administrator's password in the app binary", "<identifier> (login or email); the password is prompted or read from stdin"},
|
||||
{"admin:token:create", "Create a personal API token for a backend administrator in the app binary", "<identifier> --name <text> --scope <name> (repeatable) [--expires 30d|90d|365d|never|YYYY-MM-DD]"},
|
||||
{"admin:token:list", "List personal API tokens in the app binary", "[<identifier>] [--revoked]"},
|
||||
{"admin:token:revoke", "Revoke a personal API token by id in the app binary", "<id>"},
|
||||
{"admin:token:scopes", "List available personal API token scopes in the app binary", ""},
|
||||
ex := func(cmd, desc string) bonfire.Example { return bonfire.Example{Command: cmd, Description: desc} }
|
||||
rows := []struct {
|
||||
name, description, hint string
|
||||
examples []bonfire.Example
|
||||
}{
|
||||
{"migrate", "Run plugin migrations in the app binary", "--pretend, --force", []bonfire.Example{
|
||||
ex("migrate", "Apply every pending migration"),
|
||||
ex("migrate --pretend", "List the pending migrations without applying them")}},
|
||||
{"migrate:rollback", "Roll back the last migration of a plugin in the app binary", "--plugin <id>, --pretend, --force", []bonfire.Example{
|
||||
ex("migrate:rollback --plugin acme.blog", "Roll back the last migration of acme.blog"),
|
||||
ex("migrate:rollback --plugin acme.blog --pretend", "Name the migration that would roll back")}},
|
||||
{"migrate:status", "Show per-plugin migration history in the app binary", "--json", []bonfire.Example{
|
||||
ex("migrate:status", "Show the applied migrations per plugin"),
|
||||
ex("migrate:status --json", "Machine-readable status")}},
|
||||
{"serve", "Run the app HTTP server", "--addr <host:port> (default :8080)", []bonfire.Example{
|
||||
ex("serve", "Serve on port 8080"),
|
||||
ex("serve --addr 127.0.0.1:8123", "Serve on loopback port 8123")}},
|
||||
{"queue:work", "Run background job workers in the app binary", "--queue <name> (repeatable)", []bonfire.Example{
|
||||
ex("queue:work", "Work every known queue"),
|
||||
ex("queue:work --queue mail", "Work only the mail queue")}},
|
||||
{"queue:clear", "Clear pending queued jobs in the app binary", `[queue] argument (default "default"), --force`, []bonfire.Example{
|
||||
ex("queue:clear", "Clear the default queue"),
|
||||
ex("queue:clear imports --force", "Clear the imports queue without the production question")}},
|
||||
{"schedule:run", "Run the scheduler in the app binary (or once for system cron)", "--once", []bonfire.Example{
|
||||
ex("schedule:run", "Run the scheduler in the foreground"),
|
||||
ex("schedule:run --once", "Run the entries due now and exit")}},
|
||||
{"route:list", "List the app's routes from the app binary", "--json", []bonfire.Example{
|
||||
ex("route:list", "Show every route"),
|
||||
ex("route:list --json", "Machine-readable route list")}},
|
||||
{"key:generate", "Print a fresh application key from the app binary", "--json", []bonfire.Example{
|
||||
ex("key:generate", "Print a new key"),
|
||||
ex("key:generate -q", "Print only the key")}},
|
||||
{"admin:create", "Create a backend administrator in the app binary", "--email <email> (required), --login, --role <code>, --superuser; the password is prompted or read from stdin", []bonfire.Example{
|
||||
ex("admin:create --email admin@example.com --superuser", "Create a superuser; the password is asked for"),
|
||||
ex("admin:create --email editor@example.com --role editor", "Create an administrator with a role")}},
|
||||
{"admin:reset-password", "Reset a backend administrator's password in the app binary", "<identifier> (login or email); the password is prompted or read from stdin", []bonfire.Example{
|
||||
ex("admin:reset-password admin@example.com", "Reset a password; it is asked for"),
|
||||
ex("admin:reset-password admin", "Reset by login")}},
|
||||
{"admin:token:create", "Create a personal API token for a backend administrator in the app binary", "<identifier> --name <text> --scope <name> (repeatable) [--expires 30d|90d|365d|never|YYYY-MM-DD] [--json]", []bonfire.Example{
|
||||
ex("admin:token:create jane --name agent --scope journal.posts", "A token limited to one scope"),
|
||||
ex("admin:token:create jane --scope journal.posts --json", "The token and its details as JSON")}},
|
||||
{"admin:token:list", "List personal API tokens in the app binary", "[<identifier>] [--revoked] [--json]", []bonfire.Example{
|
||||
ex("admin:token:list", "Every active token"),
|
||||
ex("admin:token:list jane --revoked", "Every token of one administrator")}},
|
||||
{"admin:token:revoke", "Revoke a personal API token by id in the app binary", "<id>", []bonfire.Example{
|
||||
ex("admin:token:revoke 12", "Revoke token 12"),
|
||||
ex("admin:token:revoke 7 -q", "Revoke token 7 and print nothing but errors")}},
|
||||
{"admin:token:scopes", "List available personal API token scopes in the app binary", "--json", []bonfire.Example{
|
||||
ex("admin:token:scopes", "Show every scope"),
|
||||
ex("admin:token:scopes --json", "Machine-readable scope list")}},
|
||||
}
|
||||
cmds := make([]bonfire.Command, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
cmds = append(cmds, delegateCommand(r.name, r.description, r.hint))
|
||||
c := delegateCommand(r.name, r.description, r.hint)
|
||||
c.Examples = r.examples
|
||||
cmds = append(cmds, c)
|
||||
}
|
||||
return cmds
|
||||
}
|
||||
@@ -53,7 +89,8 @@ func delegateCommands() []bonfire.Command {
|
||||
func delegateCommand(name, description, hint string) bonfire.Command {
|
||||
long := description + ".\n\nsummer runs bin/<binary> " + name +
|
||||
" in the application directory, building the binary first when it is missing. Every flag and argument after " +
|
||||
name + ", including --help, is passed to the app binary unchanged."
|
||||
name + ", including --help, is passed to the app binary unchanged, and so are the global options (-v, -q, --ansi, --no-ansi, -n, --env). " +
|
||||
"When the app binary fails, summer exits with its exit code."
|
||||
if hint != "" {
|
||||
long += "\n\nCommon flags: " + hint + "."
|
||||
}
|
||||
@@ -104,30 +141,64 @@ func runAppBinary(ctx context.Context, argv []string, out bonfire.Output) error
|
||||
return fmt.Errorf("summer: refusing to recurse into %s", bin)
|
||||
}
|
||||
}
|
||||
cmd := newAppCmd(ctx, bin, appDir, argv, out)
|
||||
stdout, stderr := appStreams(out, bonfire.IsTerminal(out))
|
||||
return runAppCmd(newAppCmd(ctx, bin, appDir, argv, stdout, stderr), argv)
|
||||
}
|
||||
|
||||
// appStreams picks the app binary's stdout and stderr. On a terminal
|
||||
// (terminal is bonfire.IsTerminal(out)) the binary gets summer's own
|
||||
// streams, so it sees the terminal: it asks the production question, prints
|
||||
// styled summaries and animates its widgets. Otherwise its stdout goes
|
||||
// through out (tests and bonfire.Call roots capture it) and its stderr to
|
||||
// summer's stderr, so log lines and error blocks never mix into data such
|
||||
// as --json output.
|
||||
func appStreams(out bonfire.Output, terminal bool) (stdout, stderr io.Writer) {
|
||||
if terminal {
|
||||
return os.Stdout, os.Stderr
|
||||
}
|
||||
return out, os.Stderr
|
||||
}
|
||||
|
||||
// runAppCmd runs cmd, the app binary started with argv, forwarding SIGINT
|
||||
// and SIGTERM to it. When the binary exits with a code, the error carries
|
||||
// that code (bonfire.WithExitCode) and is marked as already reported. The
|
||||
// error names the command through argmask.Join, with the values of
|
||||
// secret-looking flags masked, on every branch: a binary that fails to
|
||||
// start or is ended by a signal gets summer's error block, and a signal
|
||||
// death keeps exit code 1.
|
||||
func runAppCmd(cmd *exec.Cmd, argv []string) error {
|
||||
sigs := make(chan os.Signal, 2)
|
||||
signal.Notify(sigs, os.Interrupt, syscall.SIGTERM)
|
||||
defer signal.Stop(sigs)
|
||||
if err := runChild(cmd, sigs); err != nil {
|
||||
return fmt.Errorf("summer: %s: %w", strings.Join(argv, " "), err)
|
||||
err = fmt.Errorf("summer: %s: %w", argmask.Join(argv), err)
|
||||
var exit *exec.ExitError
|
||||
if errors.As(err, &exit) && exit.Exited() {
|
||||
// The binary printed its own error; summer exits with its
|
||||
// code and prints nothing more. A binary ended by a signal
|
||||
// printed nothing, so it gets summer's error block.
|
||||
return bonfire.Reported(bonfire.WithExitCode(err, exit.ExitCode()))
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// appShutdownGrace is how long a cancelled app binary may take to exit after
|
||||
// SIGTERM before it is killed. It is longer than the 10-second shutdown
|
||||
// timeout of serve and the workers, so they finish a graceful shutdown.
|
||||
const appShutdownGrace = 15 * time.Second
|
||||
// SIGTERM before it is killed: the grace summer dev gives serve as well,
|
||||
// longer than the 10-second shutdown timeout of serve and the workers.
|
||||
const appShutdownGrace = dev.ShutdownGrace
|
||||
|
||||
// newAppCmd builds the command that runs the app binary bin with argv in dir.
|
||||
// The child inherits summer's stdin, so the hidden password prompts of
|
||||
// admin:create and admin:reset-password read from the terminal. Cancelling
|
||||
// ctx sends SIGTERM, and the child is killed only after appShutdownGrace.
|
||||
func newAppCmd(ctx context.Context, bin, dir string, argv []string, out io.Writer) *exec.Cmd {
|
||||
// newAppCmd builds the command that runs the app binary bin with argv in
|
||||
// dir, writing to stdout and stderr (see appStreams). The child inherits
|
||||
// summer's stdin, so the hidden password prompts of admin:create and
|
||||
// admin:reset-password read from the terminal. Cancelling ctx sends
|
||||
// SIGTERM, and the child is killed only after appShutdownGrace.
|
||||
func newAppCmd(ctx context.Context, bin, dir string, argv []string, stdout, stderr io.Writer) *exec.Cmd {
|
||||
cmd := exec.CommandContext(ctx, bin, argv...)
|
||||
cmd.Dir = dir
|
||||
cmd.Stdout = out
|
||||
cmd.Stderr = out
|
||||
cmd.Stdout = stdout
|
||||
cmd.Stderr = stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Env = os.Environ()
|
||||
cmd.Cancel = func() error {
|
||||
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -189,7 +191,7 @@ func TestRunChildForwardsSignals(t *testing.T) {
|
||||
for _, sig := range []os.Signal{os.Interrupt, syscall.SIGTERM} {
|
||||
t.Run(sig.String(), func(t *testing.T) {
|
||||
var out syncBuffer
|
||||
cmd := newAppCmd(context.Background(), os.Args[0], ".", helperArgv(), &out)
|
||||
cmd := newAppCmd(context.Background(), os.Args[0], ".", helperArgv(), &out, &out)
|
||||
helperEnv(cmd)
|
||||
sigs := make(chan os.Signal, 1)
|
||||
done := make(chan error, 1)
|
||||
@@ -218,7 +220,7 @@ func TestNewAppCmdCancelSendsSIGTERM(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
var out syncBuffer
|
||||
cmd := newAppCmd(ctx, os.Args[0], ".", helperArgv(), &out)
|
||||
cmd := newAppCmd(ctx, os.Args[0], ".", helperArgv(), &out, &out)
|
||||
helperEnv(cmd)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- runChild(cmd, nil) }()
|
||||
@@ -236,3 +238,233 @@ func TestNewAppCmdCancelSendsSIGTERM(t *testing.T) {
|
||||
t.Fatalf("child was not stopped with SIGTERM:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDelegateExamples: every delegate has two examples rendered after
|
||||
// summer in its help, and its long help says that flags and the global
|
||||
// options pass to the app binary; global options before or after the
|
||||
// command word reach the app binary.
|
||||
func TestDelegateExamples(t *testing.T) {
|
||||
got := captureAppRunner(t)
|
||||
for _, c := range delegateCommands() {
|
||||
if len(c.Examples) != 2 {
|
||||
t.Errorf("%s has %d examples, want 2", c.Name, len(c.Examples))
|
||||
}
|
||||
if !strings.Contains(c.Long, "global options (-v, -q, --ansi, --no-ansi, -n, --env)") || !strings.Contains(c.Long, "passed to the app binary unchanged") {
|
||||
t.Errorf("%s: long help does not say that global options pass through:\n%s", c.Name, c.Long)
|
||||
}
|
||||
out, err := runSummer(t, "help", c.Name)
|
||||
if err != nil {
|
||||
t.Fatalf("help %s: %v", c.Name, err)
|
||||
}
|
||||
for _, e := range c.Examples {
|
||||
if !strings.HasPrefix(e.Command, c.Name) || e.Description == "" {
|
||||
t.Errorf("%s: example %+v does not start with the command or lacks a description", c.Name, e)
|
||||
}
|
||||
if !strings.Contains(out, " summer "+e.Command+"\n") || !strings.Contains(out, "# "+e.Description) {
|
||||
t.Errorf("help %s lacks the example %q:\n%s", c.Name, e.Command, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
if *got != nil {
|
||||
t.Fatalf("help ran the app binary with %q", *got)
|
||||
}
|
||||
if problems := bonfire.CheckDocs(delegateCommands()); len(problems) != 0 {
|
||||
t.Errorf("delegates lack documentation: %v", problems)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{[]string{"-v", "migrate", "--pretend"}, []string{"migrate", "-v", "--pretend"}},
|
||||
{[]string{"migrate", "-qn"}, []string{"migrate", "-qn"}},
|
||||
{[]string{"--env", "staging", "queue:work"}, []string{"queue:work", "--env", "staging"}},
|
||||
{[]string{"--no-ansi", "serve", "--addr", ":8123"}, []string{"serve", "--no-ansi", "--addr", ":8123"}},
|
||||
} {
|
||||
got := captureAppRunner(t)
|
||||
if out, err := runSummer(t, tc.args...); err != nil {
|
||||
t.Fatalf("%q: %v\n%s", tc.args, err, out)
|
||||
}
|
||||
if !slices.Equal(*got, tc.want) {
|
||||
t.Errorf("%q: argv = %q, want %q", tc.args, *got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExitHelperProcess is not a real test: TestDelegateExitCodePassthrough
|
||||
// re-executes the test binary with SUMMER_EXIT_HELPER set to get a child
|
||||
// that prints an error on stderr and exits 2, or that is killed by SIGKILL.
|
||||
func TestExitHelperProcess(t *testing.T) {
|
||||
switch os.Getenv("SUMMER_EXIT_HELPER") {
|
||||
case "exit2":
|
||||
fmt.Fprintln(os.Stderr, "ERROR the app binary failed")
|
||||
os.Exit(2)
|
||||
case "sigkill":
|
||||
p, _ := os.FindProcess(os.Getpid())
|
||||
_ = p.Signal(syscall.SIGKILL)
|
||||
time.Sleep(10 * time.Second)
|
||||
os.Exit(3)
|
||||
}
|
||||
}
|
||||
|
||||
// exitHelperRunner replaces appRunner with one that runs the exit helper
|
||||
// as the app binary.
|
||||
func exitHelperRunner(t *testing.T, mode string) {
|
||||
t.Helper()
|
||||
prev := appRunner
|
||||
appRunner = func(ctx context.Context, argv []string, out bonfire.Output) error {
|
||||
stdout, stderr := appStreams(out, bonfire.IsTerminal(out))
|
||||
cmd := newAppCmd(ctx, os.Args[0], ".", []string{"-test.run=^TestExitHelperProcess$"}, stdout, stderr)
|
||||
cmd.Env = append(os.Environ(), "SUMMER_EXIT_HELPER="+mode)
|
||||
return runAppCmd(cmd, argv)
|
||||
}
|
||||
t.Cleanup(func() { appRunner = prev })
|
||||
}
|
||||
|
||||
// TestDelegateExitCodePassthrough (CR-02, WR-10): summer exits with the
|
||||
// app binary's exit code and prints no error block of its own; a binary
|
||||
// killed by a signal gets summer's error block, with secret flag values
|
||||
// masked, and exit 1.
|
||||
func TestDelegateExitCodePassthrough(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("SIGKILL is POSIX-only")
|
||||
}
|
||||
run := func(t *testing.T, mode string, args ...string) (string, string, int) {
|
||||
t.Helper()
|
||||
exitHelperRunner(t, mode)
|
||||
var out, errW syncBuffer
|
||||
root, err := bonfire.NewRootIO("summer", toolCommands(), strings.NewReader(""), &out, &errW)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code := bonfire.Execute(context.Background(), root, args)
|
||||
return out.String(), errW.String(), code
|
||||
}
|
||||
|
||||
t.Run("exit code", func(t *testing.T) {
|
||||
out, errOut, code := run(t, "exit2", "migrate", "--bogus")
|
||||
if code != 2 {
|
||||
t.Errorf("exit %d, want the binary's 2", code)
|
||||
}
|
||||
for _, s := range []string{out, errOut} {
|
||||
if strings.Contains(s, " ERROR ") || strings.Contains(s, "summer: migrate") {
|
||||
t.Errorf("summer printed an error block of its own:\nstdout %q\nstderr %q", out, errOut)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("killed by a signal", func(t *testing.T) {
|
||||
out, errOut, code := run(t, "sigkill", "migrate", "--bogus")
|
||||
if code != bonfire.ExitFailure {
|
||||
t.Errorf("exit %d, want 1", code)
|
||||
}
|
||||
if n := strings.Count(errOut, " ERROR "); n != 1 || !strings.Contains(errOut, "summer: migrate --bogus: signal: killed") {
|
||||
t.Errorf("want one error block naming the signal, got %d:\nstdout %q\nstderr %q", n, out, errOut)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("killed by a signal masks secrets", func(t *testing.T) {
|
||||
out, errOut, code := run(t, "sigkill", "migrate", "--password", "hunter2")
|
||||
if code != bonfire.ExitFailure {
|
||||
t.Errorf("exit %d, want 1", code)
|
||||
}
|
||||
if n := strings.Count(errOut, " ERROR "); n != 1 || !strings.Contains(errOut, "--password ***") {
|
||||
t.Errorf("want one error block with the password masked, got %d:\nstdout %q\nstderr %q", n, out, errOut)
|
||||
}
|
||||
if strings.Contains(out+errOut, "hunter2") {
|
||||
t.Errorf("the password reached the output:\nstdout %q\nstderr %q", out, errOut)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestRunAppCmdMasksSecrets (WR-10): the error of an app binary killed by a
|
||||
// signal or failing to start names the command with the values of
|
||||
// secret-named flags masked, as the wrong-directory Try line does.
|
||||
func TestRunAppCmdMasksSecrets(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("SIGKILL is POSIX-only")
|
||||
}
|
||||
argv := []string{"admin:create", "--email", "a@b.c", "--password", "hunter2", "--api-token=t0k3n", "--client-secret", "-Xk9q"}
|
||||
check := func(t *testing.T, err error, want ...string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("runAppCmd returned nil")
|
||||
}
|
||||
if errors.Is(err, bonfire.ErrReported) {
|
||||
t.Errorf("error is marked as reported: %v", err)
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, w := range append(want, "--password ***", "--api-token=***", "--client-secret ***") {
|
||||
if !strings.Contains(msg, w) {
|
||||
t.Errorf("error lacks %q: %s", w, msg)
|
||||
}
|
||||
}
|
||||
for _, secret := range []string{"hunter2", "t0k3n", "-Xk9q"} {
|
||||
if strings.Contains(msg, secret) {
|
||||
t.Errorf("error repeats %q: %s", secret, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("killed by a signal", func(t *testing.T) {
|
||||
cmd := newAppCmd(context.Background(), os.Args[0], ".", []string{"-test.run=^TestExitHelperProcess$"}, io.Discard, io.Discard)
|
||||
cmd.Env = append(cmd.Env, "SUMMER_EXIT_HELPER=sigkill")
|
||||
check(t, runAppCmd(cmd, argv), "signal: killed")
|
||||
})
|
||||
|
||||
t.Run("start failure", func(t *testing.T) {
|
||||
cmd := newAppCmd(context.Background(), filepath.Join(t.TempDir(), "missing"), ".", argv, io.Discard, io.Discard)
|
||||
check(t, runAppCmd(cmd, argv))
|
||||
})
|
||||
}
|
||||
|
||||
// TestDelegateStreams (CR-03): off a terminal the app binary's stdout goes
|
||||
// through summer's Output and its stderr to summer's stderr, never into
|
||||
// summer's stdout.
|
||||
func TestDelegateStreams(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("the fake app binary is a shell script")
|
||||
}
|
||||
app := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(app, "summer.yaml"), []byte("module: example.com/acme\nbinary: acme\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(app, "bin"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
script := "#!/bin/sh\necho data-line\necho log-line >&2\n"
|
||||
if err := os.WriteFile(filepath.Join(app, "bin", "acme"), []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Chdir(app)
|
||||
out, err := runSummer(t, "route:list", "--json")
|
||||
if err != nil {
|
||||
t.Fatalf("route:list: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "data-line") {
|
||||
t.Errorf("the binary's stdout did not reach summer's Output: %q", out)
|
||||
}
|
||||
if strings.Contains(out, "log-line") {
|
||||
t.Errorf("the binary's stderr landed in summer's stdout: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppStreams (CR-03): on a terminal the app binary gets summer's own
|
||||
// stdout and stderr; off a terminal its stdout goes through the Output and
|
||||
// its stderr to summer's stderr.
|
||||
func TestAppStreams(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
out := bonfire.NewOutput(strings.NewReader(""), &buf, &buf)
|
||||
if bonfire.IsTerminal(out) {
|
||||
t.Fatal("a buffer console reports a terminal")
|
||||
}
|
||||
stdout, stderr := appStreams(out, true)
|
||||
if stdout != os.Stdout || stderr != os.Stderr {
|
||||
t.Errorf("terminal: streams %v, %v; want os.Stdout, os.Stderr", stdout, stderr)
|
||||
}
|
||||
stdout, stderr = appStreams(out, false)
|
||||
if stdout != io.Writer(out) || stderr != os.Stderr {
|
||||
t.Errorf("not a terminal: streams %v, %v; want the Output, os.Stderr", stdout, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,8 @@ Some column names are never written from a request body, whatever the form decla
|
||||
```go src=modules/cabana/example_form_seams_test.go#MembersController.AdminFieldRelations
|
||||
// AdminFieldRelations binds the form's two relation fields. organisation_id
|
||||
// is a protected column, so the team field would be read-only; the contract
|
||||
// opts in to writing it through this field.
|
||||
// opts in to writing it through this field. A save that moves the member to
|
||||
// another team (or to none) clears team_role, unless it writes a new one.
|
||||
func (MembersController) AdminFieldRelations() []cabana.FieldRelationContract {
|
||||
return []cabana.FieldRelationContract{{
|
||||
Field: "team",
|
||||
@@ -38,6 +39,7 @@ func (MembersController) AdminFieldRelations() []cabana.FieldRelationContract {
|
||||
NewRelated: func() any { return &Team{} },
|
||||
ForeignKey: "organisation_id",
|
||||
WritableForeignKey: true,
|
||||
ClearOnChange: []string{"team_role"},
|
||||
}, {
|
||||
Field: "groups",
|
||||
Kind: "belongsToMany",
|
||||
@@ -51,6 +53,12 @@ func (MembersController) AdminFieldRelations() []cabana.FieldRelationContract {
|
||||
|
||||
The protection itself does not change. A plain field named `organisation_id` is still dropped from a body, and the id submitted through the relation field is still rechecked against the scoped options query, so only a record the controller offers can be set. `WritableForeignKey` on a belongsToMany contract stops the start-up.
|
||||
|
||||
### Clearing dependent columns
|
||||
|
||||
Some columns only make sense for one parent: a member's role in an organisation means nothing in the next organisation. `ClearOnChange` on a belongsTo contract names such columns of the record; a save that changes the field's foreign key, to another record or to none, sets them to NULL, so the role never moves along with the member. The team contract above clears `team_role` this way.
|
||||
|
||||
A save that keeps the key leaves the columns alone, and so does a save that writes a column through its own form field: an administrator who moves a member and picks the new role in the same save keeps that role. Each column must be a nullable column of the record (a pointer field), and neither the foreign key nor the primary key; anything else, or `ClearOnChange` on a belongsToMany contract, stops the start-up. The zero value changes nothing. A hasMany relation manager has the same option on `cabana.RelationContract` (see [Relation kinds](#relation-kinds)).
|
||||
|
||||
### Locked options
|
||||
|
||||
A controller may let an administrator see a choice without letting them change it, for example membership of a group that grants access. It implements `cabana.RelationLockProvider` and returns, per field and per request, a `cabana.RelationLock`: the related ids that are locked for the signed-in administrator, and a message.
|
||||
@@ -132,6 +140,8 @@ func (PostsController) AdminRelationContracts() []cabana.RelationContract {
|
||||
|
||||
A hasMany `ForeignKey` of a pointer type (`*uint`, a nullable column) is needed for `unlink` and for managing the relation before the parent is saved.
|
||||
|
||||
A hasMany contract may also name `ClearOnChange` columns of the related model: nullable columns that a link and an unlink set to NULL together with the `ForeignKey`, deferred links and unlinks included. With an organisation's `members` relation and `ClearOnChange: []string{"organisation_role"}`, a user added to or removed from the organisation never keeps a role from elsewhere. The rules are those of the belongsTo option: a pointer field, neither the `ForeignKey` nor the primary key, and only on a hasMany; the zero value changes nothing.
|
||||
|
||||
### Relation forms
|
||||
|
||||
The relation manager creates and edits related records in a modal through a form of their own. `manage.form` names the form used to create and update a child, `view.form` the read-only preview; a top-level `form` is the fallback of both, as in WinterCMS:
|
||||
@@ -187,7 +197,7 @@ The `manage` panel may declare only `link`. An unknown or duplicate button stops
|
||||
|
||||
What delete, link and unlink do depends on the kind:
|
||||
|
||||
- On a hasMany, `delete` deletes the child through its model, so its hooks and soft delete run. `link` adopts records whose `ForeignKey` is NULL, setting it to the parent's key through the child model; `unlink` sets it back to NULL.
|
||||
- On a hasMany, `delete` deletes the child through its model, so its hooks and soft delete run. `link` adopts records whose `ForeignKey` is NULL, setting it to the parent's key through the child model; `unlink` sets it back to NULL. Both also set the contract's `ClearOnChange` columns to NULL.
|
||||
- On a belongsToMany, `delete` removes this parent's pivot row and then deletes the related record through its model. `link` writes pivot rows and `unlink` deletes them, as before.
|
||||
|
||||
Every child route is scoped to its parent. The parent is loaded through `pact.FormExtendQuery`, and the child must belong to it: on a hasMany by its `ForeignKey`, on a belongsToMany by a pivot row. A child of another parent, or any child of a parent the administrator cannot open, answers 404 `not_found`, never 403, so a request cannot tell a foreign record from a missing one. A delete is all or nothing: when one of the ids is not a child of the parent, nothing is deleted.
|
||||
|
||||
@@ -15,56 +15,92 @@ WinterCMS has one console entry point, `php artisan`. SummerCMS has two programs
|
||||
|
||||
The application binary is what you deploy, so everything that must run in production, such as migrations and workers, is a command of the binary rather than of `summer`.
|
||||
|
||||
Run with no arguments, the binary prints its usage and every command it has, the framework's and those its plugins add; `--help` prints the same:
|
||||
Run with no arguments, the binary prints the list of every command it has, the framework's and those its plugins add, the way `php artisan` does; `--help` and `list` print the same:
|
||||
|
||||
```sh
|
||||
./bin/acme
|
||||
./bin/acme --help
|
||||
./bin/acme list
|
||||
```
|
||||
|
||||
```text
|
||||
SummerCMS acme v1.4.0 (framework v0.3.2)
|
||||
|
||||
Usage:
|
||||
acme [command]
|
||||
bin/acme <command> [options] [arguments]
|
||||
|
||||
Available Commands:
|
||||
admin:create Create an activated backend administrator
|
||||
admin:reset-password Reset a backend administrator password and revoke existing tokens
|
||||
admin:token:create Create a personal API token for a backend administrator
|
||||
admin:token:list List personal API tokens for a backend administrator (or all)
|
||||
admin:token:revoke Revoke a personal API token by id
|
||||
admin:token:scopes List available personal API token scopes
|
||||
deferred:purge Delete expired deferred bindings with their pending uploads and created records
|
||||
greeter:hello Print the configured application name
|
||||
help Help about any command
|
||||
key:generate Print a fresh 32-byte base64 app.key
|
||||
migrate Run plugin migrations in dependency order
|
||||
migrate:rollback Roll back the last migration of a plugin
|
||||
migrate:status Show per-plugin migration history
|
||||
queue:clear Clear all queued jobs, by deleting all pending jobs.
|
||||
queue:work Run background job workers in the foreground
|
||||
route:list List registered HTTP routes
|
||||
schedule:run Run the scheduler in the foreground (or once for system cron)
|
||||
serve Serve the application HTTP API
|
||||
Options:
|
||||
-h, --help Display help for the given command
|
||||
-q, --quiet Do not output any message
|
||||
-v|vv|vvv Increase verbosity
|
||||
--ansi|no-ansi Force (or disable) ANSI output
|
||||
-n, --no-interaction Do not ask any interactive question
|
||||
--env The environment the command runs under
|
||||
|
||||
Flags:
|
||||
-h, --help help for acme
|
||||
|
||||
Use "acme [command] --help" for more information about a command.
|
||||
Available commands:
|
||||
help Display help for a command
|
||||
list List commands
|
||||
migrate Run plugin migrations in dependency order
|
||||
serve Serve the application HTTP API
|
||||
admin
|
||||
admin:create Create an activated backend administrator
|
||||
admin:reset-password Reset a backend administrator password and revoke existing tokens
|
||||
admin:token:create Create a personal API token for a backend administrator
|
||||
migrate
|
||||
migrate:rollback Roll back the last migration of a plugin
|
||||
migrate:status Show per-plugin migration history
|
||||
queue
|
||||
queue:work Run background job workers in the foreground
|
||||
```
|
||||
|
||||
The header names the application, its version and the framework version it was built against (`local` when the framework comes from a local checkout). Commands without a namespace come first, then one block per namespace, the part of the name before the first colon. `summer` with no arguments prints its own list in the same layout.
|
||||
|
||||
## Getting help
|
||||
|
||||
Both programs list their commands with `--help`, and every command accepts `--help` for its arguments and flags:
|
||||
Both programs list their commands with no arguments, with `--help` and with `list`, and every command accepts `--help` for its arguments and flags:
|
||||
|
||||
```sh
|
||||
summer --help
|
||||
summer
|
||||
summer make:model --help
|
||||
summer help migrate
|
||||
./bin/acme --help
|
||||
./bin/acme migrate:rollback --help
|
||||
./bin/acme help migrate:rollback
|
||||
./bin/acme list --all
|
||||
./bin/acme list --format=json
|
||||
```
|
||||
|
||||
A command's help shows its description, usage, arguments, options with their defaults, the global options, the long help and examples. `list --all` includes hidden commands, and `list --format=json` prints every command with its arguments, options and examples for scripts and agents.
|
||||
|
||||
A command that `summer` delegates to the application binary, such as `summer serve`, answers `--help` with the binary's own help, so run it inside an application directory. Asking the help command of `summer` about a delegated command instead prints a short description and its common flags without running the binary.
|
||||
|
||||
### Global options
|
||||
|
||||
Every command of both programs accepts these options, before or after the command name:
|
||||
|
||||
| Option | Effect |
|
||||
|--------|--------|
|
||||
| `-v`, `-vv`, `-vvv` (`--verbose`) | More output: `-v` shows info logs of every component, `-vv` debug logs, `-vvv` also source locations and SQL traces. |
|
||||
| `-q`, `--quiet` | Hides info and success lines, spinners and progress, and shows only warnings and errors in the console log; data a command prints (tokens, keys, JSON) and errors still appear. Implies `-n`. |
|
||||
| `--ansi`, `--no-ansi` | Forces or turns off colors and styling; `--no-ansi` also stops animations. The last one given wins. Without either, colors follow the terminal, `NO_COLOR` and `FORCE_COLOR`. |
|
||||
| `-n`, `--no-interaction` | Asks no questions: prompts take their defaults, and a password prompt reads piped stdin or fails with a hint. |
|
||||
| `--env` | The configuration environment, as `SUMMER_ENV` would set it: `--env staging` or `--env=staging`. |
|
||||
|
||||
The application binary reads these options before it loads `config/` and starts plugins, so `--env` picks the configuration and `-vv` applies from the first log line:
|
||||
|
||||
```sh
|
||||
./bin/acme -vv migrate
|
||||
./bin/acme migrate:status --env staging
|
||||
```
|
||||
|
||||
### Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| `0` | The command succeeded. |
|
||||
| `1` | The command failed; the error is printed on stderr. |
|
||||
| `2` | The command was called wrongly: an unknown command or flag, or a wrong number of arguments. |
|
||||
|
||||
## Commands that summer delegates
|
||||
|
||||
During development you often work from the application directory with `summer` alone. These `summer` commands find the application's `summer.yaml`, build `bin/<binary>` when it does not exist yet, and run the same command of the binary with the same arguments:
|
||||
@@ -98,6 +134,17 @@ summer admin:create --email admin@example.com --superuser
|
||||
|
||||
One leading `--` is ignored, so `summer serve -- --addr 127.0.0.1:8123` behaves the same as `summer serve --addr 127.0.0.1:8123`.
|
||||
|
||||
The global options go to the binary too, wherever you put them: `summer -v migrate --pretend` runs `./bin/acme -v migrate --pretend`, and `summer -q key:generate` prints only the key. `summer help migrate` shows each delegate's examples; the binary's own help, with every flag, is `summer migrate --help`:
|
||||
|
||||
```sh
|
||||
summer -v migrate --pretend
|
||||
summer admin:token:create jane --scope journal.posts --json
|
||||
```
|
||||
|
||||
The binary writes to your terminal directly, so it asks the production question, prints its styled startup summary and animates its spinners as if you had run it yourself. Its error stream stays separate from its output: `summer route:list --json > routes.json` captures only the JSON, while warnings and log lines still reach the terminal on stderr.
|
||||
|
||||
When the binary fails, `summer` exits with the binary's exit code (a usage error stays `2`) and prints nothing more: the binary has already printed its error. A binary killed by a signal gets an error block from `summer` and exit code `1`; the block names the command with the values of secret-looking flags (`--password`, `--api-token=...`) masked as `***`.
|
||||
|
||||
When `summer` receives SIGINT or SIGTERM while the binary runs, it passes the signal on and waits for the binary to shut down, so stopping `summer serve` with Ctrl+C or `kill` frees its port.
|
||||
|
||||
`summer` does not rebuild an existing binary before it delegates. Run `summer build` after you change code, or keep `summer dev` running. Every other runtime command, such as `deferred:purge` or the commands your plugins add, is run on the binary directly.
|
||||
|
||||
@@ -12,14 +12,16 @@ The `summer` tool creates and builds applications and generates the files a plug
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `summer build` | Reads `summer.yaml`, generates `plugins.gen.go` and `main.go`, and builds the binary into `bin/`, passing the manifest's `build.tags` to `go build` as `-tags`. Run it from the application directory or any directory below it. |
|
||||
| `summer dev` | Builds the application as `summer build` does (build tags included), starts the binary, and rebuilds and restarts it whenever a Go or YAML source, `go.mod`, `go.work`, `.env` or `summer.yaml` changes. |
|
||||
| `summer build` | Reads `summer.yaml`, generates `plugins.gen.go` and `main.go`, and builds the binary into `bin/`, passing the manifest's `build.tags` to `go build` as `-tags`. On a terminal a spinner runs while `go build` works; it ends with `Built bin/<binary> in <time>`. Run it from the application directory or any directory below it. |
|
||||
| `summer dev` | Builds the application as `summer build` does (build tags included), runs `bin/<binary> serve`, and rebuilds and restarts it whenever a Go or YAML source, `go.mod`, `go.work`, `.env` or `summer.yaml` changes. A failed build keeps the running server. |
|
||||
|
||||
```sh
|
||||
summer build
|
||||
summer dev
|
||||
```
|
||||
|
||||
`summer dev` passes its global options on to `serve`: `summer dev -v` shows the per-plugin lines of the startup summary and every component's info logs, `summer dev -q` only warnings and errors. Ctrl+C stops `serve` and waits for its shutdown report: `serve` drains the requests in flight for up to 10 seconds, and `summer dev` kills it only if it is still running 15 seconds after the signal (a restart after a rebuild waits the same way). Then `summer dev` prints its own report (rebuilds, failed builds, restarts, uptime) and exits 0.
|
||||
|
||||
Do not edit `main.go` or `plugins.gen.go`: `summer build` rewrites them from the manifest.
|
||||
|
||||
## Creating an application
|
||||
@@ -62,7 +64,7 @@ summer migrate
|
||||
summer serve
|
||||
```
|
||||
|
||||
The next steps also explain the admin. It opens at `/backend` once a plugin registers an admin controller; until then `summer serve` prints `admin: off` with these steps:
|
||||
The next steps also explain the admin. It opens at `/backend` once a plugin registers an admin controller; until then the Admin line of `summer serve`'s startup summary reads `off`, followed by these steps:
|
||||
|
||||
```sh
|
||||
summer make:admin-controller golem15.acme Posts
|
||||
@@ -117,4 +119,17 @@ File names are the snake-case form of the name: `AddPublishedAt` becomes `add_pu
|
||||
|
||||
After writing the files, every `make:` command regenerates the plugin's `registry.gen.go`, which lists the plugin's models, migrations, commands, jobs and admin controllers, and runs `go mod tidy` in the plugin. The capability methods of a scaffolded `plugin.go` return those generated lists. If your `plugin.go` was written by hand and does not call them, the command prints a note naming the accessors to add.
|
||||
|
||||
A command refuses to overwrite an existing file or to declare a name the package already has.
|
||||
Each command prints `Created <path>` for every file it writes. A command refuses to overwrite an existing file or to declare a name the package already has.
|
||||
|
||||
When a `make:` command cannot do its work, the error says why and what to run instead. Outside an application (no `summer.yaml` in the directory or above it) it reads:
|
||||
|
||||
```text
|
||||
ERROR build: no summer.yaml found from /home/jane
|
||||
|
||||
summer make:model runs inside an application: a directory with summer.yaml, or one below it.
|
||||
|
||||
Try:
|
||||
cd <app>; summer make:model acme.blog Post
|
||||
```
|
||||
|
||||
An invalid plugin ID (`Acme.Blog`), a name that is not an exported Go identifier (`post`), an artifact that already exists and a plugin that `summer.yaml` does not list get the same kind of explanation, with a valid command form to try.
|
||||
|
||||
@@ -12,28 +12,39 @@ Every application binary carries the framework's runtime commands. The examples
|
||||
|
||||
| Command | Flags | Purpose |
|
||||
|---------|-------|---------|
|
||||
| `migrate` | none | Runs the framework migrations, then every plugin's migrations in dependency order. |
|
||||
| `migrate:rollback` | `--plugin <id>` | Rolls back the last migration of the plugin; without the flag, of the last activated plugin that has migrations. |
|
||||
| `migrate:status` | none | Prints a table of plugin, history table and applied migration IDs. |
|
||||
| `migrate` | `--pretend`, `--force` | Runs the framework migrations, then every plugin's migrations in dependency order, printing how many each set applied and `Applied N migrations` (or `Nothing to migrate`). If the history cannot be re-read after a successful run, it warns and counts every pending migration as applied. `--pretend` lists the pending migrations as `<plugin>: <id>` and applies nothing. |
|
||||
| `migrate:rollback` | `--plugin <id>`, `--pretend`, `--force` | Rolls back the last migration of the plugin; without the flag, of the last activated plugin that has migrations. Prints `Rolled back <id> of <plugin>`; `--pretend` only names it. |
|
||||
| `migrate:status` | `--json` | Prints a table of the framework's own migration sets and every plugin with migrations, with history table and applied migration IDs. `--json` prints every declared migration of those sets with `plugin`, `table`, `id` and `applied`. |
|
||||
|
||||
```sh
|
||||
./bin/acme migrate --pretend
|
||||
./bin/acme migrate
|
||||
./bin/acme migrate:status
|
||||
./bin/acme migrate:status --json
|
||||
./bin/acme migrate:rollback --plugin acme.blog
|
||||
```
|
||||
|
||||
Each plugin keeps its own migration history table, so rolling back one plugin never touches another. The migrations are listed in [lagoon](../../modules/lagoon/README.md).
|
||||
|
||||
### Confirmation in production
|
||||
|
||||
`migrate`, `migrate:rollback`, `deferred:purge` and `queue:clear` change or delete data. When the environment is `production` and the command runs on an interactive terminal, they ask before doing it; answering no prints a cancellation and exits 1. `--force` skips the question. Off a terminal (a deploy over SSH, a container entrypoint, the scheduler) and with `-n` they never ask and proceed as before, so deploy scripts need no change.
|
||||
|
||||
```sh
|
||||
./bin/acme migrate --force
|
||||
```
|
||||
|
||||
When the database cannot be reached, or `database.dsn` is empty, the error names the fix (`docker compose up -d --wait`, `cp .env.example .env`). Run from a directory below the application root, an application binary finds no `config/` and adds a hint to its error with the command to run instead, for example `cd ../..; bin/acme migrate`.
|
||||
|
||||
## Pending uploads and related records
|
||||
|
||||
An admin form holds the files uploaded and the related records created for a record that is not saved yet as deferred bindings. Saving the record commits them; abandoned ones are removed by `deferred:purge`:
|
||||
|
||||
| Command | Flags | Purpose |
|
||||
|---------|-------|---------|
|
||||
| `deferred:purge` | `--days <n>` | Deletes deferred bindings older than `n` days, the unattached uploads they hold and the records created under deferral, and prints the counts. Records that were only linked are kept. Without the flag, `database.deferred_bindings.purge_days` applies, else 5 days. |
|
||||
| `deferred:purge` | `--days <n>`, `--pretend`, `--force` | Deletes deferred bindings older than `n` days, the unattached uploads they hold and the records created under deferral, and prints the counts. Records that were only linked are kept. Without the flag, `database.deferred_bindings.purge_days` applies, else 5 days. `--pretend` counts the bindings that would go and deletes nothing. |
|
||||
|
||||
```sh
|
||||
./bin/acme deferred:purge
|
||||
./bin/acme deferred:purge --pretend
|
||||
./bin/acme deferred:purge --days=1
|
||||
```
|
||||
|
||||
@@ -43,10 +54,11 @@ The scheduler already runs it every day at 03:00; see [Task scheduling](../plugi
|
||||
|
||||
| Command | Flags | Purpose |
|
||||
|---------|-------|---------|
|
||||
| `key:generate` | none | Prints a fresh base64 32-byte key for `app.key`. It writes nothing and needs no database. |
|
||||
| `key:generate` | `--json` | Prints a fresh base64 32-byte key for `app.key`. It writes nothing and needs no database. With `-q` it prints only the key; `--json` prints `{"key": "..."}`. |
|
||||
|
||||
```sh
|
||||
./bin/acme key:generate
|
||||
KEY=$(./bin/acme key:generate -q)
|
||||
```
|
||||
|
||||
Copy the printed value into `SUMMER_APP__KEY`. When you rotate the key, keep the old one in `app.previous_keys` so existing encrypted columns can still be read.
|
||||
@@ -55,8 +67,8 @@ Copy the printed value into `SUMMER_APP__KEY`. When you rotate the key, keep the
|
||||
|
||||
| Command | Flags | Purpose |
|
||||
|---------|-------|---------|
|
||||
| `serve` | `--addr` (default `:8080`) | Opens the database and uploads bucket, builds the router, binds the address, starts the in-process job worker and serves HTTP until SIGINT or SIGTERM, then shuts down within 10 seconds. After binding it prints the listen address with a browser URL (an unspecified host shows as 127.0.0.1) and an admin status line: the admin URL, or `admin: off` with the steps to add an admin controller. |
|
||||
| `route:list` | none | Builds the router the way `serve` does, without opening the database or listening, and prints every route with its method, pattern, plugin, middleware and raw flag, followed by the admin status line. |
|
||||
| `serve` | `--addr` (default `:8080`) | Opens the database and uploads bucket, builds the router, binds the address, starts the in-process job worker and serves HTTP until SIGINT or SIGTERM. Once it listens it prints a startup summary; on a signal it drains requests and the worker within 10 seconds and prints a shutdown report. |
|
||||
| `route:list` | `--json` | Builds the router the way `serve` does, without opening the database or listening, and prints every route with its method, pattern, plugin, middleware and raw flag, followed by the admin status line. `--json` prints the routes as a JSON array. |
|
||||
|
||||
```sh
|
||||
./bin/acme route:list
|
||||
@@ -67,16 +79,54 @@ The default `--addr` listens on every interface. Pass a loopback address during
|
||||
|
||||
From the application directory, `summer serve --addr 127.0.0.1:8123` passes the flag through to the built binary.
|
||||
|
||||
### Startup summary
|
||||
|
||||
On a terminal `serve` prints a block once the port is bound:
|
||||
|
||||
```text
|
||||
SummerCMS acme v1.4.0 (framework v0.4.0)
|
||||
Environment production
|
||||
URL http://127.0.0.1:8080/
|
||||
Admin http://127.0.0.1:8080/backend
|
||||
Database db.internal:5432/acme
|
||||
Migrations up to date
|
||||
Plugins 3 (acme.user, acme.blog, acme.shop)
|
||||
Queue default, mail, scheduled (worker in serve)
|
||||
Scheduler in serve
|
||||
Mail smtp
|
||||
Cache redis
|
||||
Realtime null
|
||||
Search null
|
||||
```
|
||||
|
||||
The database line shows the host, port and database name, never the user or password. Pending migrations read `3 pending — run bin/acme migrate`. The admin line shows the admin URL, or `off` with the steps to add an admin controller; a warning naming `summer admin:create` follows when the `backend_users` table is empty. `-v` adds one line per plugin with the plugins it requires and its pending migrations, and lets every component's info logs through; `-q` prints no summary, only warnings and errors.
|
||||
|
||||
Off a terminal (systemd, Docker, a redirected log) nothing is printed on stdout: the same facts are logged as one `startup summary` record and one record whose message is exactly `listening on <addr>`, both from the `serve` component, so journald and the daily files keep them and scripts can wait for the second one. On a terminal the console does not repeat the two records after the block; a file channel whose level lets Info through still gets them (the default daily channel keeps only warnings and errors).
|
||||
|
||||
### Shutting down
|
||||
|
||||
Ctrl+C or SIGTERM drains the requests in flight for up to 10 seconds, stops the in-process worker and prints a report (on a terminal) and a `shutdown report` record:
|
||||
|
||||
```text
|
||||
Shutdown report
|
||||
Requests 1520 served, 2 drained, 0 aborted
|
||||
Worker 84 done, 1 failed, 0 handed back to the queue
|
||||
Uptime 3h12m40s
|
||||
Errors none
|
||||
```
|
||||
|
||||
Jobs still running at the deadline are cancelled; River records the cancelled attempt and retries the job later, which the report counts as handed back to the queue. `serve` exits 0 after a clean stop and 1 when draining fails (requests still running at the deadline, or the worker failing to stop).
|
||||
|
||||
## Admin accounts
|
||||
|
||||
| Command | Arguments and flags | Purpose |
|
||||
|---------|---------------------|---------|
|
||||
| `admin:create` | `--email` (required), `--login`, `--role <code>`, `--superuser`, `--password` (deprecated) | Creates an activated backend administrator. `--login` defaults to the lower-cased email. The password is read from a hidden prompt, or from stdin when the input is not a terminal. |
|
||||
| `admin:reset-password` | `<identifier>` (login or email), `--password` (deprecated) | Sets a new password, read like the one of `admin:create`, and revokes every JWT issued before the reset. Personal API tokens are unchanged. |
|
||||
| `admin:token:create` | `<identifier>`, `--name`, `--scope` (repeatable), `--expires` (`30d`, `90d`, `365d`, `never`, or `YYYY-MM-DD`) | Creates a personal API token for the named administrator and prints exactly the plaintext and a newline on stdout. |
|
||||
| `admin:token:list` | `[<identifier>]`, `--revoked` | Lists tokens (hint only, never the hash or plaintext). |
|
||||
| `admin:token:create` | `<identifier>`, `--name`, `--scope` (repeatable), `--expires` (`30d`, `90d`, `365d`, `never`, or `YYYY-MM-DD`), `--json` | Creates a personal API token for the named administrator and prints exactly the plaintext and a newline on stdout, also under `-q`. `--json` prints `id`, `name`, `token`, `scopes` and `expires_at`. |
|
||||
| `admin:token:list` | `[<identifier>]`, `--revoked`, `--json` | Lists tokens (hint only, never the hash or plaintext); `--json` as a JSON array. |
|
||||
| `admin:token:revoke` | `<id>` | Soft-revokes any user's active token by id. |
|
||||
| `admin:token:scopes` | none | Lists framework and plugin scopes available for tokens. |
|
||||
| `admin:token:scopes` | `--json` | Lists framework and plugin scopes available for tokens; `--json` as a JSON array. |
|
||||
|
||||
```sh
|
||||
./bin/acme admin:create --email admin@example.com --superuser
|
||||
@@ -88,15 +138,15 @@ TOKEN=$(./bin/acme admin:token:create admin@example.com --name agent --scope blo
|
||||
|
||||
From the application directory, `summer admin:create --email admin@example.com --superuser` runs the same command on the built binary, prompt included. The four `admin:token:*` commands have matching `summer` delegates.
|
||||
|
||||
When the admin is mounted and the `backend_users` table is empty, `serve` prints a warning at start-up naming `summer admin:create`.
|
||||
When the admin is mounted and the `backend_users` table is empty, `serve` prints a warning after its startup summary naming `summer admin:create`.
|
||||
|
||||
## Queues and the scheduler
|
||||
|
||||
| Command | Arguments and flags | Purpose |
|
||||
|---------|---------------------|---------|
|
||||
| `queue:work` | `--queue <name>`, repeatable | Runs a job worker in the foreground on the named queues (default: every known queue) until SIGINT or SIGTERM. An unknown queue is an error that lists the known ones. |
|
||||
| `queue:clear` | `[queue]` (default `default`) | Deletes the waiting, scheduled and retryable jobs of one queue and prints how many it cleared. Running jobs are never touched. |
|
||||
| `schedule:run` | `--once` | Without `--once`, runs a scheduler-only worker until stopped. With `--once`, runs the entries due in the current minute and exits, for system cron. |
|
||||
| `queue:work` | `--queue <name>`, repeatable | Runs a job worker in the foreground on the named queues (default: every known queue) until SIGINT or SIGTERM, with a startup summary and a shutdown report. An unknown queue is an error that lists the known ones. |
|
||||
| `queue:clear` | `[queue]` (default `default`), `--force` | Deletes the waiting, scheduled and retryable jobs of one queue and prints `Cleared N jobs from <queue>`. Running jobs are never touched. |
|
||||
| `schedule:run` | `--once` | Without `--once`, runs a scheduler-only worker until stopped, with a startup summary and a shutdown report. With `--once`, runs the entries due in the current minute, prints `Ran N due entries (M failed)` and exits, for system cron. |
|
||||
|
||||
```sh
|
||||
./bin/acme queue:work --queue default --queue imports
|
||||
@@ -104,6 +154,8 @@ When the admin is mounted and the `backend_users` table is empty, `serve` prints
|
||||
./bin/acme schedule:run --once
|
||||
```
|
||||
|
||||
`queue:work` prints a startup summary on a terminal: the environment, each queue with its concurrency, the database host and whether the `scheduled` queue runs in this worker. `schedule:run` prints the number of schedule entries and the next one due. Off a terminal they log the same facts as a `startup summary` record (components `queue` and `schedule`). On Ctrl+C or SIGTERM both stop within 10 seconds and print a shutdown report: jobs done, failed and handed back to the queue (jobs still running at the deadline are cancelled and River retries them), the uptime and any stop error. They exit 0 after a clean stop and 1 when the worker fails to stop.
|
||||
|
||||
`serve` runs a job worker in the same process unless `queue.work_in_serve` is `false`; set it to `false` when you run `queue:work` separately. [Task scheduling](../plugins/scheduling.md) explains the scheduler, and [conga](../../modules/conga/README.md) the queue settings.
|
||||
|
||||
## Realtime and push
|
||||
@@ -112,9 +164,9 @@ These commands are not added by the generated `main`. An application that uses C
|
||||
|
||||
| Command | Arguments and flags | Purpose |
|
||||
|---------|---------------------|---------|
|
||||
| `websockets:health` | none | Calls the Centrifugo `info` API and prints the configuration. Exits 1 when the API key is missing or the call fails. The key itself is never printed. |
|
||||
| `websockets:health` | `--json` | Calls the Centrifugo `info` API and prints the configuration. Exits 1 when the API key is missing or the call fails. The key itself is never printed. `--json` prints `healthy`, `api_url`, `enabled`, `api_key_set` and `error` for monitoring. |
|
||||
| `websockets:generate-vapid-keys` | `--update`, `--show-current` | Shows the configured VAPID keys, truncated, then generates a new pair. With `--update` it saves them to the environment's `overrides.yaml`; without it, it prints the variables to set by hand. |
|
||||
| `websockets:test-push` | `<user_id>`, `--show-config` | Lists a user's push subscriptions and, after confirmation, sends one encrypted test notification to each. |
|
||||
| `websockets:test-push` | `<user_id>`, `--show-config` | Lists a user's push subscriptions and, after confirmation (yes by default, so `-n` sends without asking), sends one encrypted test notification to each. |
|
||||
|
||||
```sh
|
||||
./bin/acme websockets:health
|
||||
|
||||
@@ -20,7 +20,7 @@ When you port an existing backend to SummerCMS, its real responses are the contr
|
||||
| `summer parity:broadcasts` | `--flow`, `--target`, `--vars`, `--listen` (default `127.0.0.1:8424`), `--out`, `--name`, `--step`, `--ids`, `--rules`, `--api-key`, `--settle` (default `500ms`), `--pending` | Runs a flow against the reference backend with a fake Centrifugo server and records the realtime publications it sends into a golden file. |
|
||||
| `summer parity:upstream` | `--listen` (default `127.0.0.1:8425`), `--ca-dir`, `--out`, `--mode` (default `script`; or `forward`), `--script`, `--vars` | Runs a loopback HTTPS recording proxy with a locally generated CA. The reference backend sends its vendor API calls through it; each call is answered from the `--script` file (or forwarded once to the vendor in `forward` mode) and written, with credentials masked, into an upstream sidecar (`<fixture>.upstream.yaml`) when the command is interrupted. |
|
||||
|
||||
A typical port records once against the reference backend and replays against the Go backend on every change:
|
||||
Each command's help (`summer help parity:record`) explains its place in this workflow and shows examples. A typical port records once against the reference backend and replays against the Go backend on every change:
|
||||
|
||||
```sh
|
||||
summer parity:record --spec testdata/parity/posts.spec.yaml --target http://127.0.0.1:8000 --output testdata/parity/posts.yaml --vars /tmp/parity/vars.yaml
|
||||
@@ -35,7 +35,7 @@ These docs are Markdown files under `docs/`, plus every module README, built int
|
||||
|---------|-------|---------|
|
||||
| `summer docs:build` | `--root` (default `.`), `--src`, `--out`, `--base-url`, `--site-url`, `--site-label`, `--check` | Checks every page and writes the site to `site/` (or `--out`): HTML pages, a raw `.md` copy of each page, `llms.txt`, `llms-full.txt` and the search index. With `--check` it only reports problems and writes nothing. |
|
||||
| `summer docs:sync` | `--root` (default `.`), `--src` | Rewrites every code block that has a `src=` reference from its source file. |
|
||||
| `summer docs:serve` | `--root` (default `.`), `--src`, `--base-url`, `--site-url`, `--site-label`, `--addr` (default `127.0.0.1:8088`), `--allow-remote` | Builds the site into a temporary directory, serves it and rebuilds when a page, a module or a referenced source changes. A failed rebuild prints its problems and keeps serving the last good build. |
|
||||
| `summer docs:serve` | `--root` (default `.`), `--src`, `--base-url`, `--site-url`, `--site-label`, `--addr` (default `127.0.0.1:8088`), `--allow-remote` | Builds the site into a temporary directory, serves it and rebuilds when a page, a module or a referenced source changes. A failed rebuild prints its problems and keeps serving the last good build. Ctrl+C stops it and exits 0. |
|
||||
|
||||
`docs/site.yaml` accepts two optional keys, `site_url` and `site_label`, that add a link back to the main site to every page header. With `site_url: https://acme.example/` the link reads "acme.example". Without `site_label` the label is the URL's host, or Home when `site_url` is a path such as `/`. `site_url` must be an `http://` or `https://` URL with a host, or a path starting with a single `/`. The `--site-url` and `--site-label` flags override the two keys the way `--base-url` overrides `base_url`.
|
||||
|
||||
@@ -45,6 +45,6 @@ summer docs:sync
|
||||
summer docs:serve
|
||||
```
|
||||
|
||||
`docs:build` fails, and writes nothing, when a page names an identifier that does not exist, links to a missing page or anchor, shows a command that neither `summer` nor an application binary has, or has a `src=` code block that differs from its source. It also fails on a Go code block (including `golang`) without a `src=` reference, a `src=` code block inside a callout, blockquote or list item (`src=` blocks must be top-level), and a `src=` code block in a module README. A `src=` target must be code `go test ./...` compiles and runs, that is a file in the default build, inside a `Test` function or an `Example` with an `// Output:` comment, or a function one of them calls. After you change code that a page shows, run `summer docs:sync` to refresh the copies.
|
||||
`summer help docs:build` and `summer help docs:sync` show examples. `docs:build` fails, and writes nothing, when a page names an identifier that does not exist, links to a missing page or anchor, shows a command that neither `summer` nor an application binary has, or has a `src=` code block that differs from its source. It also fails on a Go code block (including `golang`) without a `src=` reference, a `src=` code block inside a callout, blockquote or list item (`src=` blocks must be top-level), and a `src=` code block in a module README. A `src=` target must be code `go test ./...` compiles and runs, that is a file in the default build, inside a `Test` function or an `Example` with an `// Output:` comment, or a function one of them calls. After you change code that a page shows, run `summer docs:sync` to refresh the copies.
|
||||
|
||||
`docs:serve` listens only on a loopback address unless you pass `--allow-remote`. Use it to preview search, which browsers block when you open the built files directly from disk.
|
||||
|
||||
@@ -16,7 +16,7 @@ A `bonfire.Command` has a name, a description, its positional arguments and flag
|
||||
- Each `bonfire.Arg` is a positional argument with a name, a description and a `Required` marker. The usage line shows required arguments as `<name>` and optional ones as `[name]`.
|
||||
- Each `bonfire.Flag` is a string flag. Set `bonfire.Flag.Bare` for a switch such as `--dry-run` that stores `true` when given alone, and `bonfire.Flag.Repeatable` for a flag that can be given several times.
|
||||
- `bonfire.Command.Run` receives the context, a `bonfire.Input` and a `bonfire.Output`.
|
||||
- `bonfire.Command.Long` is optional extended help shown by the help command; without it the description is shown. Set `bonfire.Command.Passthrough` for a command that forwards to another program: flag parsing is turned off, so every argument after the command name, including `--help`, `-h` and `--`, reaches `bonfire.Input.Args` unchanged. Declared flags then appear in the usage text only, and declared arguments are not count-checked.
|
||||
- `bonfire.Command.Long` is optional extended help, shown under "Help:" by `help <command>` and `<command> --help`. `bonfire.Command.Examples` and `bonfire.Command.Hidden` are described below. Set `bonfire.Command.Passthrough` for a command that forwards to another program: flag parsing is turned off, so every argument after the command name, including `--help`, `-h` and `--`, reaches `bonfire.Input.Args` unchanged. Declared flags then appear in the usage text only, and declared arguments are not count-checked.
|
||||
|
||||
Read arguments with `bonfire.Input.Argument`, scalar and bare flags with `bonfire.Input.Flag`, and repeatable flags with `bonfire.Input.Flags`, which returns the values in the order given:
|
||||
|
||||
@@ -48,7 +48,7 @@ if err := catalog.Call(context.Background(), "blog:publish", args, os.Stdout); e
|
||||
|
||||
## Registering commands
|
||||
|
||||
Return the commands from the plugin's `Commands` method, which implements `pact.HasCommands`. The generated `main` appends every plugin's commands after the framework's runtime commands. Command names are not checked for duplicates, so keep your commands in your plugin's own namespace, such as `blog:`.
|
||||
Return the commands from the plugin's `Commands` method, which implements `pact.HasCommands`. The generated `main` appends every plugin's commands after the framework's runtime commands. Command names must be unique: when two commands share a name, or a command is named `list` or `help`, `bonfire.NewRoot` fails with `bonfire.ErrDuplicateCommand` naming the command, and the binary does not start. Keep your commands in your plugin's own namespace, such as `blog:`. The global options are reserved as well: a flag named `verbose`, `quiet`, `ansi`, `no-ansi`, `no-interaction`, `env` or `help`, or with the shorthand `v`, `q`, `n` or `h`, makes `bonfire.NewRoot` fail with `bonfire.ErrReservedFlag`.
|
||||
|
||||
A scaffolded plugin's `Commands` method returns the generated list of everything in `console/`, so commands created with `summer make:command` are registered without editing `plugin.go`.
|
||||
|
||||
@@ -57,10 +57,177 @@ A scaffolded plugin's `Commands` method returns the generated list of everything
|
||||
`bonfire.Output` is the console your command writes to. Besides `bonfire.Output.Printf` and `bonfire.Output.Println`, it provides:
|
||||
|
||||
- status lines: `bonfire.Output.Info`, `bonfire.Output.Success`, `bonfire.Output.Warning` and `bonfire.Output.Error` (the last one writes to the error stream);
|
||||
- widgets: `bonfire.Output.Table`, `bonfire.Output.Spinner` around a function and `bonfire.Output.Progress` for a progress bar, which fall back to plain lines when the output is not a terminal;
|
||||
- prompts: `bonfire.Output.Ask`, `bonfire.Output.Confirm`, `bonfire.Output.Choice` and `bonfire.Output.Secret`. Prompts return their defaults when input ends, so a command run from cron or a script never hangs.
|
||||
- widgets: `bonfire.Output.Table`, `bonfire.Output.Spinner` around a function and `bonfire.Output.Progress` for a progress bar. On a terminal they are SSU's live views on the charm stack: a spinner, a gradient bar and boxed tables;
|
||||
- prompts: `bonfire.Output.Ask`, `bonfire.Output.Confirm`, `bonfire.Output.Choice` and `bonfire.Output.Secret`. On an interactive terminal they are huh fields and an arrow-key selector; otherwise they read lines from stdin, so a password can be piped in, and return their defaults when input ends, so a command run from cron or a script never hangs.
|
||||
|
||||
Return an error from `Run` to fail the command. The binary prints it and exits with status 1.
|
||||
How much of this appears depends on where the command runs and on the global options:
|
||||
|
||||
| Run | Animation | Decoration | Data | Prompts |
|
||||
|-----|-----------|------------|------|---------|
|
||||
| Terminal | spinner, progress bar, process view | colors, symbols, boxed tables | printed | huh fields and the selector |
|
||||
| Not a terminal | none: `[...] message` and a line per tenth of progress | symbols, no colors | printed, tables tab-separated | read a line from stdin; `Confirm` returns its default |
|
||||
| `-q` | none | info, success and warning lines hidden; errors kept | printed | defaults, as with `-n` |
|
||||
| `--no-ansi` | none | symbols, no colors | printed | plain line prompts |
|
||||
| `-n` | none | as on the terminal | printed | defaults; `Secret` reads piped stdin or fails with a hint |
|
||||
|
||||
Print the data your command exists to produce (a token, a key, JSON) with `bonfire.Output.Println` or `bonfire.Output.Printf`, never as a status line: `-q` keeps that output and drops only the decoration around it. `bonfire.IsQuiet`, `bonfire.VerbosityOf` and `bonfire.GlobalsOf` tell a command which options it runs under.
|
||||
|
||||
Before a destructive step, ask with `bonfire.ConfirmProceed`. It asks only on an interactive terminal; off a terminal, with `-n` or `-q`, and when the scheduler calls the command, it returns true without asking, so deploys over SSH never wait for an answer.
|
||||
|
||||
For a loop over many items, `bonfire.Process` runs a function per item and shows SSU's process view on a terminal, one row per item with its state, or prints one `✓ item` or `✗ item: error` line per item elsewhere; it returns the failures joined. Inside `bonfire.Output.Progress`, `bonfire.Current` names the item being worked on and `bonfire.Failed` records a failed one. Log lines and output printed while a live view runs appear above the view (`bonfire.PrintAbove`). When the context given to `bonfire.Process` is cancelled, the view ends with its last frame and later lines print plainly below it.
|
||||
|
||||
Return an error from `Run` to fail the command. The binary prints it as an error block on stderr and exits with status 1; see [Errors and hints](#errors-and-hints).
|
||||
|
||||
## Examples and hidden commands
|
||||
|
||||
`bonfire.Command.Examples` lists usage examples. Each `bonfire.Example` holds the command line without the binary and a short description; help prints the description as a `#` comment and the line after `bin/<name>` (or `summer` for the tool's own commands), so one example serves both. `bonfire.Command.Hidden` keeps a command that still runs out of the list; `list --all` and `list --format=json` show it:
|
||||
|
||||
```go src=modules/bonfire/example_test.go#ExampleExample
|
||||
export := bonfire.Command{
|
||||
Name: "blog:export",
|
||||
Description: "Export published posts as JSON",
|
||||
Long: "Writes every published post, newest first, to a JSON file.",
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "path", Description: "Output file", Default: "posts.json"},
|
||||
},
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "blog:export --path=backup.json", Description: "Export to another file"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
// Hidden commands run, but list shows them only with --all.
|
||||
cleanup := bonfire.Command{
|
||||
Name: "blog:cleanup",
|
||||
Description: "Remove orphaned uploads",
|
||||
Hidden: true,
|
||||
}
|
||||
|
||||
root, err := bonfire.NewRootIO("acme", []bonfire.Command{export, cleanup}, strings.NewReader(""), os.Stdout, os.Stdout)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
bonfire.Execute(context.Background(), root, []string{"help", "blog:export"})
|
||||
// Output:
|
||||
// Description:
|
||||
// Export published posts as JSON
|
||||
//
|
||||
// Usage:
|
||||
// blog:export [options]
|
||||
//
|
||||
// Options:
|
||||
// --path=PATH Output file [default: "posts.json"]
|
||||
//
|
||||
// Global options:
|
||||
// -h, --help Display help for the given command
|
||||
// -q, --quiet Do not output any message
|
||||
// -v|vv|vvv Increase verbosity
|
||||
// --ansi|no-ansi Force (or disable) ANSI output
|
||||
// -n, --no-interaction Do not ask any interactive question
|
||||
// --env The environment the command runs under
|
||||
//
|
||||
// Help:
|
||||
// Writes every published post, newest first, to a JSON file.
|
||||
//
|
||||
// Examples:
|
||||
// # Export to another file
|
||||
// bin/acme blog:export --path=backup.json
|
||||
```
|
||||
|
||||
`list --format=json` prints every command with its arguments, options (with defaults) and examples as a `bonfire.Listing`, for documentation checks, scripts and agents. `bonfire.Describe` builds the same listing from a command slice.
|
||||
|
||||
### What a finished command has
|
||||
|
||||
The framework's own commands follow these rules, and so should a plugin's:
|
||||
|
||||
- **Description:** one line in the imperative, sentence case, without a trailing period: "Publish a blog post by its slug".
|
||||
- **Long help:** what the command does, when to run it (by hand, from cron, from the scheduler), what each flag changes, and its exit codes.
|
||||
- **Two or three examples:** the common call first, then the flags people reach for.
|
||||
- **A result line:** a one-shot command ends with a line that says what it did, such as `Published hello-world` or `Imported 120 posts (3 skipped)`, and returns an error when it failed, so the exit code is 1.
|
||||
- **Data on stdout:** what a script reads (a token, a key, `--json` output) goes through `Println` or `Printf`, which `-q` does not hide; information and success lines go through `Info` and `Success`, which it does.
|
||||
- **Flags with a meaning people know:** `--json` for output scripts read, `--pretend` or `--dry-run` for a run that changes nothing, `--force` to skip a confirmation. A destructive step asks through `bonfire.ConfirmProceed`, which never asks off an interactive terminal; never give an existing flag a new meaning.
|
||||
- **A named logger:** log through `diary.For("<plugin code>")` from a package variable, not `slog.Default`, so `log.components` can raise or silence the plugin. See [Logging](../services/logging.md).
|
||||
|
||||
## Errors and hints
|
||||
|
||||
When `Run` returns an error, the binary prints a block on stderr: the ERROR badge and the message, then "Run with -v for details.". With `-v` the block lists the wrapped errors under "Caused by:" instead. Messages pass through `sunscreen.Scrub`, so a token inside an error text is not printed, and through `bonfire.Sanitize`, which drops control characters: an error text from a database or a remote service cannot move the cursor, clear the screen or retitle the terminal. Table cells and the item names and errors of `bonfire.Process` are cleaned the same way.
|
||||
|
||||
Wrap an error with `bonfire.WithHint` to explain it and to say what to run next. `{bin}` in the Try text becomes `bin/<name>` or `summer`, so the hint names the right program:
|
||||
|
||||
```go src=modules/bonfire/example_test.go#ExampleWithHint
|
||||
publish := bonfire.Command{
|
||||
Name: "blog:publish",
|
||||
Description: "Publish a draft post",
|
||||
Args: []bonfire.Arg{{Name: "slug", Description: "Post slug", Required: true}},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
slug, _ := in.Argument("slug")
|
||||
return bonfire.WithHint(fmt.Errorf("blog: no draft named %q", slug),
|
||||
"Only drafts can be published; published posts keep their slug.",
|
||||
"{bin} blog:drafts")
|
||||
},
|
||||
}
|
||||
|
||||
root, err := bonfire.NewRootIO("acme", []bonfire.Command{publish}, strings.NewReader(""), os.Stdout, os.Stdout)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
code := bonfire.Execute(context.Background(), root, []string{"blog:publish", "hello-world"})
|
||||
fmt.Println("exit code:", code)
|
||||
// Output:
|
||||
// ERROR blog: no draft named "hello-world"
|
||||
//
|
||||
// Only drafts can be published; published posts keep their slug.
|
||||
//
|
||||
// Try:
|
||||
// bin/acme blog:drafts
|
||||
//
|
||||
// Run with -v for details.
|
||||
//
|
||||
// exit code: 1
|
||||
```
|
||||
|
||||
A command that has already printed its problems, such as a checker that lists every finding, returns `bonfire.Reported(err)`: the binary exits 1 and prints nothing more. A mistake in how the command was called, such as an unknown flag or a missing argument, exits 2 and the block suggests `help <command>`; wrap your own usage checks with `bonfire.Usage` to get the same exit code.
|
||||
|
||||
An application binary also explains the most common mistake: running it from the wrong directory. When a command fails, the working directory has no `config/` directory and a parent directory holds `summer.yaml`, the block says "This command runs from the application root." and suggests the same command from there:
|
||||
|
||||
```text
|
||||
ERROR lagoon: database.dsn is empty (set SUMMER_DATABASE__DSN)
|
||||
|
||||
This command runs from the application root.
|
||||
|
||||
Try:
|
||||
cd ../..; bin/acme migrate
|
||||
```
|
||||
|
||||
The suggested command repeats your arguments, except that the value of a flag whose name contains `password`, `passwd`, `secret`, `token` or `key` is masked as `***` (`--password ***` (also a value that starts with `-`), `--api-token=***`), so a secret typed on the command line does not reach logs that capture stderr. Only the message changes: the binary still reads its configuration from `config/` in the working directory. `summer` is not affected, because its commands find the application root themselves.
|
||||
|
||||
## Documentation coverage
|
||||
|
||||
`bonfire.CheckDocs` reports each command that lacks a description, long help, an example, or a description of one of its arguments or options. It only reports; the framework's own test fails on any problem, and a plugin repository can call it from a test and log the problems:
|
||||
|
||||
```go src=modules/bonfire/example_test.go#ExampleCheckDocs
|
||||
commands := []bonfire.Command{
|
||||
{
|
||||
Name: "blog:publish",
|
||||
Description: "Publish a draft post",
|
||||
Long: "Publishes the draft with the given slug and notifies subscribers.",
|
||||
Args: []bonfire.Arg{{Name: "slug", Description: "Post slug", Required: true}},
|
||||
Examples: []bonfire.Example{{Command: "blog:publish hello-world"}},
|
||||
},
|
||||
{
|
||||
Name: "blog:reindex",
|
||||
Description: "Rebuild the search index",
|
||||
Flags: []bonfire.Flag{{Name: "batch", Default: "500"}},
|
||||
},
|
||||
}
|
||||
for _, problem := range bonfire.CheckDocs(commands) {
|
||||
fmt.Println(problem)
|
||||
}
|
||||
// Output: blog:reindex: missing long help, examples, option --batch description
|
||||
```
|
||||
|
||||
## Calling commands in-process
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ The application binary has the migration commands:
|
||||
|
||||
`migrate:rollback` rolls back the last applied migration of one plugin. Without `--plugin` it picks the last activated plugin that has migrations. To fix a migration you just wrote, roll it back, edit it and run `migrate` again.
|
||||
|
||||
`migrate:status` prints each plugin's history table and applied IDs.
|
||||
`migrate:status` prints the history table and applied IDs of the framework's own sets (under history IDs such as `summercms.attach`) and of each plugin with migrations.
|
||||
|
||||
The same operations are Go functions, which is how tests migrate a fresh database:
|
||||
|
||||
@@ -104,6 +104,8 @@ if err := lagoon.RollbackLast(db, plugins, "acme.blog"); err != nil {
|
||||
|
||||
`lagoon.Migrate`, `lagoon.Status` and `lagoon.RollbackLast` take the activated plugins; a plugin that does not implement `pact.HasMigrations` is skipped.
|
||||
|
||||
`lagoon.Status` returns one row per plugin, a plugin with no migrations included, and leaves out the framework's own sets that `migrate:status` prints.
|
||||
|
||||
## Porting version.yaml
|
||||
|
||||
WinterCMS runs a plugin's update scripts by version number and seeds data from the same files. When you port a plugin:
|
||||
|
||||
@@ -17,7 +17,12 @@ func PublishCommand(withDB func(ctx context.Context, fn func(*gorm.DB) error) er
|
||||
return bonfire.Command{
|
||||
Name: "blog:publish",
|
||||
Description: "Publish a blog post by its slug",
|
||||
Args: []bonfire.Arg{{Name: "slug", Description: "Slug of the post to publish", Required: true}},
|
||||
Long: `Sets published_at on the post with this slug. Publishing a post twice keeps
|
||||
the first publication time. It fails when no post has the slug.`,
|
||||
Args: []bonfire.Arg{{Name: "slug", Description: "Slug of the post to publish", Required: true}},
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "blog:publish hello-world", Description: "Publish the post hello-world"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
slug, _ := in.Argument("slug")
|
||||
if slug == "" {
|
||||
|
||||
@@ -27,6 +27,9 @@ func TestPublishCommandShape(t *testing.T) {
|
||||
if cmd.Description == "" {
|
||||
t.Error("Description is empty")
|
||||
}
|
||||
for _, problem := range bonfire.CheckDocs([]bonfire.Command{cmd}) {
|
||||
t.Error(problem)
|
||||
}
|
||||
|
||||
cmds := []bonfire.Command{cmd}
|
||||
var out bytes.Buffer
|
||||
|
||||
@@ -15,7 +15,7 @@ WinterCMS reads configuration with `Config::get('app.name')` from `config/*.php`
|
||||
1. Plugin defaults, merged with `compass.Config.MergePlugin` when the plugin is activated. A plugin's `config/config.yaml` becomes `<plugin id>.<key>`, so `acme.blog.posts_per_page` is the WinterCMS `acme.blog::posts_per_page`. Any other `config/<name>.yaml` becomes `<plugin id>.<name>.<key>`.
|
||||
2. `config/*.yaml`. Each file is a section named after the file, so `config/app.yaml` provides `app.*`.
|
||||
3. `config/env/<environment>/*.yaml`, the per-environment sections.
|
||||
4. `SUMMER_` environment variables and the `.env` file next to `config/`. `SUMMER_MAIL__DRIVER` sets `mail.driver`: the prefix is removed, `__` separates the path segments and the name is lower-cased. A `.env` value applies only when the real environment does not set the same variable.
|
||||
4. `SUMMER_` environment variables and the `.env` file next to `config/`. `SUMMER_MAIL__DRIVER` sets `mail.driver`: the prefix is removed, `__` separates the path segments and the name is lower-cased. A `.env` value applies only when the real environment does not set the same variable. Values arrive as strings; modules that expect numbers or lists either parse them (`SUMMER_LOG__CHANNELS__DAILY__DAYS=30` and `SUMMER_LOG__CHANNELS__STACK__CHANNELS=console,daily` work for the [logging](logging.md) keys) or document that the key belongs in YAML.
|
||||
5. `config/env/<environment>/overrides.yaml`, written by `compass.Config.Persist`.
|
||||
6. Values set in memory with `compass.Config.Set`.
|
||||
|
||||
|
||||
@@ -1,18 +1,107 @@
|
||||
---
|
||||
title: Logging
|
||||
description: Log through log/slog with sunscreen, the redacting handler every generated main installs, and keep tokens, keys and request bodies out of log records.
|
||||
description: Configure log channels in config/log.yaml, log through diary component loggers and keep credentials out of log records.
|
||||
section: services
|
||||
order: 105
|
||||
---
|
||||
# Logging
|
||||
|
||||
SummerCMS logs through the standard library's `log/slog`. Where a WinterCMS plugin calls `Log::error` and relies on a Monolog tap to scrub credentials, a SummerCMS plugin logs through a `*slog.Logger`, and [sunscreen](../../modules/sunscreen/README.md) does the scrubbing.
|
||||
SummerCMS logs through the standard library's `log/slog`, configured by [diary](../../modules/diary/README.md). Where a WinterCMS application lists channels in `config/logging.php` and calls `Log::error`, a SummerCMS application lists channels in `config/log.yaml` and logs through a component logger from `diary.For`. Every channel redacts credentials through [sunscreen](../../modules/sunscreen/README.md).
|
||||
|
||||
## The redacting default logger
|
||||
The application `main` that `summer build` generates calls `diary.Bootstrap(os.Stderr, diary.Options{})` as the first statement of `run`, so even configuration errors are logged, and `diary.Setup` right after `compass` has loaded `config/`. From then on component loggers, `slog.Default`, the package-level `slog` functions and the standard `log` package all write through the configured channels.
|
||||
|
||||
Every application `main` that `summer build` generates calls `sunscreen.InstallDefault(os.Stderr)` as the first statement of `run`, before the configuration is loaded and before any plugin is activated. From then on `slog.Default`, the package-level `slog` functions and the standard `log` package all write through a text handler wrapped by `sunscreen.Wrap`.
|
||||
## Channels
|
||||
|
||||
The handler rewrites every record before it is written:
|
||||
A channel is a named destination with a driver:
|
||||
|
||||
| Driver | Writes |
|
||||
|--------|--------|
|
||||
| `console` | One line per record to stderr. With `format: auto` (the default) the line is styled when stderr is a terminal (time, level, the component as a fixed-width prefix, the message, `key=value` attributes) and logfmt otherwise, for systemd and Docker; `format: json` writes JSON lines. |
|
||||
| `stderr` | logfmt (the default) or JSON to stderr, never styled. |
|
||||
| `daily` | One file per day, JSON by default or logfmt; see [Daily files](#daily-files). |
|
||||
| `stack` | Every record to each channel listed under `channels`. |
|
||||
| `null` | Nothing. |
|
||||
|
||||
Each channel may set its own `level`; a record reaches a channel when it passes both its component's level and the channel's level.
|
||||
|
||||
While a command shows a live view on the terminal (a spinner, a progress bar or the process view of `bonfire.Process`), styled console lines print above the view through `bonfire.PrintAbove` instead of being written into it, so log records from workers and other goroutines never tear the animation. Without a live view, and on channels that are not styled, lines are written directly as usual.
|
||||
|
||||
## config/log.yaml
|
||||
|
||||
`summer new` writes this file, and an application without it gets the same values as framework defaults:
|
||||
|
||||
```yaml
|
||||
# config/log.yaml
|
||||
default: stack
|
||||
level: info
|
||||
channels:
|
||||
stack:
|
||||
driver: stack
|
||||
channels: [console, daily]
|
||||
console:
|
||||
driver: console
|
||||
format: auto
|
||||
daily:
|
||||
driver: daily
|
||||
path: storage/logs/summer.log
|
||||
days: 14
|
||||
format: json
|
||||
level: warn
|
||||
components:
|
||||
river: warn
|
||||
http: info
|
||||
```
|
||||
|
||||
`default` names the channel every record goes to, `level` is the global minimum, `channels` defines the channels and `components` sets levels per component (the `http: info` line only spells out what `level: info` already gives). It is the shape of Laravel's `config/logging.php` (`default`, `channels` with a `driver` each, `stack` and `daily` drivers, `days` for retention), written in YAML. The framework defaults also define a `stderr` channel (logfmt) and a `null` channel, so `default: stderr` or `default: null` works without defining them.
|
||||
|
||||
Like any key, these can be overridden from the environment with a double underscore between path segments, and string values are accepted where YAML would give a number or a list:
|
||||
|
||||
```sh
|
||||
SUMMER_LOG__CHANNELS__DAILY__DAYS=30
|
||||
SUMMER_LOG__CHANNELS__STACK__CHANNELS=console,daily
|
||||
SUMMER_LOG__COMPONENTS__RIVER=error
|
||||
```
|
||||
|
||||
An override changes one key and keeps every other default. An unknown driver, format or level, a stack that names a missing channel or contains itself, and a daily path outside the application root stop the start-up with an error that names the key, for example `diary: log.channels.daily.days must be a whole number of at least 0`.
|
||||
|
||||
## Component loggers
|
||||
|
||||
Code logs through the logger of its component:
|
||||
|
||||
```go src=modules/diary/example_test.go#ExampleFor
|
||||
// Before diary.Setup runs, records go to the process default logger.
|
||||
// This one drops the time so the output is stable.
|
||||
previous := slog.Default()
|
||||
defer slog.SetDefault(previous)
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
|
||||
if len(groups) == 0 && a.Key == slog.TimeKey {
|
||||
return slog.Attr{}
|
||||
}
|
||||
return a
|
||||
},
|
||||
})))
|
||||
|
||||
// After diary.Setup the same record reaches the configured channels and
|
||||
// passes log.components.blog, else log.level.
|
||||
blogLog.InfoContext(context.Background(), "post published", "id", 7)
|
||||
// Output:
|
||||
// level=INFO msg="post published" component=blog id=7
|
||||
```
|
||||
|
||||
Every record carries the attribute `component=blog`, and passes the component's level: `log.components.blog` when it is set, `log.level` otherwise. The name is the plugin or module code without dots, because configuration keys split on dots (`acme.blog` would nest). Framework modules use their package name (`cabana`, `lagoon`, `conga`), River logs as `river` and GORM as `db`. `diary.SetVerbosity` lowers every component to info (1) or debug (2 and more) at runtime.
|
||||
|
||||
`diary.For` is safe in a package `var` block: before `diary.Setup` runs, its records go to the process default logger, and after it they reach the configured channels. Records logged through `slog.Default` carry no component and use `log.level`.
|
||||
|
||||
## Daily files
|
||||
|
||||
A daily channel with `path: storage/logs/summer.log` writes `storage/logs/summer-2026-10-21.log` today and `storage/logs/summer-2026-10-22.log` after local midnight. A relative path resolves against the application root, the directory that holds `config/`, and may not leave it; an absolute path is used as given. Files are created with mode 0640 in directories created with mode 0750, because logs carry user identifiers, and a file is only created once a record reaches it. With `days: 14` the files of today and the 13 days before are kept and older files of the same name pattern are deleted; other files in the directory are never touched. `days: 0` keeps every file.
|
||||
|
||||
If the directory cannot be created or the file cannot be opened (a read-only container filesystem, missing permissions), the channel logs a warning from component `diary` on the console naming the path and the error, once per day. It then skips records and tries to open the file again one minute later, or at the first record of the next day, whichever comes first, so a passing failure (a full disk, too many open files, a directory removed for a moment during rotation) does not end file logging until the next restart. The start-up never fails because of a log file.
|
||||
|
||||
## Redaction
|
||||
|
||||
Every channel, console, stderr, daily and null alike, is wrapped by `sunscreen.Wrap`, which rewrites every record before it is written:
|
||||
|
||||
- The value of an attribute whose key is one of `sunscreen.RedactedKeys` becomes `[REDACTED]`, at any group depth and whatever the letter case: `api_key`, `apikey`, `authorization`, `bearer`, `password`, `secret`, `token`, `webhook_secret`, `admin_password` and the `openai_api_key`, `anthropic_api_key` and `perplexity_api_key` variants.
|
||||
- The message, string values, error values and the formatted text of other values are passed through `sunscreen.Scrub`, which replaces `Bearer <token>`, `sk-` style API keys and `x-api-key: <value>`.
|
||||
@@ -39,22 +128,25 @@ logger.Info("vendor call failed",
|
||||
// level=INFO msg="vendor call failed" api_key=[REDACTED] request.Authorization=[REDACTED] err="401 for key sk-[REDACTED]"
|
||||
```
|
||||
|
||||
`sunscreen.InstallDefault` remains for programs that install their own default logger instead of diary.
|
||||
|
||||
## Logging from a plugin
|
||||
|
||||
A plugin resolves a `*slog.Logger` from the application with `backpack.Lookup` and falls back to `slog.Default` when none is published, as the framework modules do. Either way the record goes through the redacting handler.
|
||||
A plugin logs through its own component logger, named after its plugin code without dots, instead of the process default logger. The logger is safe to create in a package `var` block, before diary is set up:
|
||||
|
||||
```go src=modules/diary/example_test.go#blogLog
|
||||
// blogLog is the component logger of a blog plugin, created once in a
|
||||
// package var block, before diary is set up.
|
||||
var blogLog = diary.For("blog")
|
||||
```
|
||||
|
||||
Its records carry `component=blog`, follow `log.components.blog` and go through the redacting channels.
|
||||
|
||||
Redaction is a safety net for mistakes, not a way to log secrets on purpose. Log identifiers and outcomes: the record id, the vendor, the status code, how long it took. Never log request arguments, tokens, credentials or request and response bodies, which can carry personal data the patterns do not recognise.
|
||||
|
||||
## Request log
|
||||
|
||||
The router can log every request. It is off by default; turn it on with the `http.access_log` key (environment `SUMMER_HTTP__ACCESS_LOG=true`):
|
||||
|
||||
```yaml
|
||||
# config/http.yaml
|
||||
access_log: true
|
||||
```
|
||||
|
||||
Each request then writes one Info record with the message `http request` once it has been served:
|
||||
The router logs every request. Each request writes one Info record with the message `http request` from the component `http` once it has been served:
|
||||
|
||||
| Attribute | Value |
|
||||
|-----------|-------|
|
||||
@@ -62,9 +154,25 @@ Each request then writes one Info record with the message `http request` once it
|
||||
| `pattern` | The matched route, method and path pattern, for example `GET /posts/{id}`. Empty when no route matched. |
|
||||
| `status` | The response status; a handler that writes without setting one counts as 200. A 5xx is logged as 499 (client closed request) only when the client had already gone away when the status was written and the handler did not panic: the error came from the cancellation, and no client received it. A 5xx written before the client went away keeps its status, and a recovered panic is always 500, so a search for 5xx records finds every real server error. |
|
||||
| `duration_ms` | Time spent serving the request, in whole milliseconds. |
|
||||
| `request_id` | The `X-Request-Id` request header, trimmed and cut to 128 bytes; absent when the header is empty. Set it in your reverse proxy to follow one request across the proxy's and the application's logs. |
|
||||
| `client_gone` | `true` when the client went away before the request was served (its context ended with `context.Canceled`); absent otherwise. |
|
||||
|
||||
The record never carries the URL path, the query string, request or response headers or the client address, because tokens often travel in paths and query strings. That holds for a request that reached no route too: a token route requested with another method gets a 405 from the router, and its trailing-slash variant a 404, before any route handler runs, so the path would carry the token. It goes to the published `*slog.Logger` or `slog.Default`, so the redacting handler still applies. Grep for the empty `pattern` to find requests that reached no route; the client's own log (a browser, a proxy) shows which path it asked for.
|
||||
The record carries the route pattern, never the URL path, the query string, request or response headers or the client address, because tokens travel in paths and query strings: share and invitation links carry them in the path. That holds for a request that reached no route too: a token route requested with another method gets a 405 from the router, and its trailing-slash variant a 404, before any route handler runs, so the path would carry the token. Grep for the empty `pattern` to find requests that reached no route; the client's own log (a browser, a proxy) shows which path it asked for.
|
||||
|
||||
The log is on by default, at Info, so the console shows it and the daily files keep what their level lets through. Silence it with the component level, or turn it off with `http.access_log` (environment `SUMMER_HTTP__ACCESS_LOG=false`):
|
||||
|
||||
```yaml
|
||||
# config/log.yaml
|
||||
components:
|
||||
http: warn
|
||||
```
|
||||
|
||||
```yaml
|
||||
# config/http.yaml
|
||||
access_log: false
|
||||
```
|
||||
|
||||
It goes to the published `*slog.Logger` when a plugin publishes one, else to the `http` component logger, so the redacting handler applies on every channel.
|
||||
|
||||
## Panics
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ Any key can be overridden with an environment variable. Take the dot path, repla
|
||||
|
||||
A `.env` file next to the `config/` directory supplies `KEY=VALUE` lines for variables that are not already set in the real environment. Keep secrets in the environment or in `.env`, never in committed YAML.
|
||||
|
||||
Environment values are strings. Keys that must be numbers, such as `http.body_limits.default_bytes`, belong in a YAML file.
|
||||
Environment values are strings. Keys that must be numbers, such as `http.body_limits.default_bytes`, belong in a YAML file. The `log.*` keys accept strings: `SUMMER_LOG__CHANNELS__DAILY__DAYS=30` keeps daily log files for 30 days, and `SUMMER_LOG__CHANNELS__STACK__CHANNELS=console,daily` sets a list.
|
||||
|
||||
The layers merge in this order, each overriding the ones before it: plugin defaults, `config/*.yaml`, `config/env/<environment>/*.yaml`, `SUMMER_` variables, then runtime overrides that a command saved to `config/env/<environment>/overrides.yaml`.
|
||||
|
||||
@@ -59,6 +59,7 @@ These are the keys most applications set. Each module's reference page lists all
|
||||
| `cache.driver`, `cache.prefix`, `cache.memory.max_entries` | The cache driver (`memory`, the default, or `redis`), the key prefix (default from `app.name`) and the memory driver's size bound (default 10000). | [cooler](../../modules/cooler/README.md) |
|
||||
| `site_cache.purge_url`, `site_cache.purge_token` | The host site's page-cache purge route and its bearer token (at least 32 characters). Set both or neither; http only to a loopback host. Keep the token in `SUMMER_SITE_CACHE__PURGE_TOKEN`. | [cooler](../../modules/cooler/README.md) |
|
||||
| `redis.url` | Redis URL (`redis://`, `rediss://` or `unix://`), required when a Redis cache or rate-limit store is selected. Keep it in `SUMMER_REDIS__URL` when it carries a password. | [cooler](../../modules/cooler/README.md) |
|
||||
| `log.default`, `log.level`, `log.channels.*`, `log.components.*` | Logging: the default channel (`stack` of `console` and `daily`), the global level (`info`), the channels (`driver`, `level`, `format`, `path`, `days`, `channels`) and per-component levels. Daily files go to `storage/logs/` and are kept 14 days. See [Logging](../services/logging.md). | [diary](../../modules/diary/README.md) |
|
||||
| `http.access_log` | Logs one record per request with method, matched route pattern, status and duration. Defaults to `false`. | [surf](../../modules/surf/README.md) |
|
||||
| `http.error_pages.enabled` | HTML error pages for browser navigations (defaults to `true`); request details appear when `app.debug` is true or outside production. | [surf](../../modules/surf/README.md) |
|
||||
| `storage.uploads.bucket_url` | Uploads bucket, `file://`, `mem://` or `s3://` (the last only in a binary built with the `summercms_s3` tag from `build.tags` in `summer.yaml`; credentials from the AWS environment). Required by `serve`. | [lagoon](../../modules/lagoon/README.md) |
|
||||
|
||||
@@ -68,7 +68,7 @@ curl http://127.0.0.1:8080/
|
||||
|
||||
It returns `{"message":"Hello from SummerCMS","plugin":"golem15.demo"}`. Stop the database with `docker compose down`, or `docker compose down -v` to delete its data as well.
|
||||
|
||||
`summer serve` prints the URL to open and the admin status. The admin lives at `http://127.0.0.1:8080/backend` once a plugin registers an admin controller; until then the status line reads `admin: off` and names the steps. When the admin is there, create the first administrator:
|
||||
`summer serve` prints a startup summary with the URL to open and the admin status. The admin lives at `http://127.0.0.1:8080/backend` once a plugin registers an admin controller; until then the summary's Admin line reads `off` and the steps follow it. When the admin is there, create the first administrator:
|
||||
|
||||
```sh
|
||||
summer admin:create --email you@example.com --superuser
|
||||
|
||||
@@ -748,7 +748,12 @@ func PublishCommand(withDB func(ctx context.Context, fn func(*gorm.DB) error) er
|
||||
return bonfire.Command{
|
||||
Name: "blog:publish",
|
||||
Description: "Publish a blog post by its slug",
|
||||
Args: []bonfire.Arg{{Name: "slug", Description: "Slug of the post to publish", Required: true}},
|
||||
Long: `Sets published_at on the post with this slug. Publishing a post twice keeps
|
||||
the first publication time. It fails when no post has the slug.`,
|
||||
Args: []bonfire.Arg{{Name: "slug", Description: "Slug of the post to publish", Required: true}},
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "blog:publish hello-world", Description: "Publish the post hello-world"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
slug, _ := in.Argument("slug")
|
||||
if slug == "" {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user