Compare commits

...

26 Commits

Author SHA1 Message Date
2030fd881a fix(16.3-03): expect 32 CSRF-gated admin routes after token API
Account token create/revoke added two state-changing mounts; update the
Phase 10 CSRF walk count so the release gate inventory stays green.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 02:20:55 +02:00
60873a3fea docs(16.3-03): ExampleResolveBackend for plugin token auth
- Add compiled ResolveBackend example with acme.blog cover
- Include src= fence in authentication docs (Bearer-only)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 02:17:43 +02:00
611f98e33b docs(16.3-02): advance STATE and ROADMAP after plan 02
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 02:08:18 +02:00
fd07e2637f docs(16.3-02): complete admin SPA API tokens card plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 02:07:48 +02:00
e17207a00d feat(16.3-02): dialog rules smoke tests and My account API tokens docs
Cover scope *, 422 focus, clipboard copy/fallback, close-without-copy
guard, and document the SPA tokens card on My account.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 02:06:43 +02:00
1c930b6ff7 feat(16.3-02): list states, show-revoked toggle, and revoke via ConfirmDialog
Add skeleton/empty/error coverage, revoked-row handling, ConfirmDialog
overflow wrap for long names, and rebuild boardwalk dist.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 02:04:54 +02:00
5a6a190e7f feat(16.3-02): tracer — API tokens card on My account with show-once create
Wire ApiTokensPanel after the profile form, add the two-step create dialog,
phrase keys, smoke tracer, and rebuilt boardwalk dist.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 02:02:44 +02:00
3f9d3b4f11 docs: sync todos with g15office and map phases to milestone tasks
Tag the 17 open todos with their g15office task IDs, close 7 todos that
are already done or promoted to Phase 16.3, and record the sync rules
and the phase-to-task map in .planning/G15OFFICE.md.
2026-10-11 02:01:50 +02:00
93a3112fb7 docs(16.3-01): advance STATE and ROADMAP after plan 01
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 01:56:26 +02:00
3019ab188b docs(16.3-01): complete framework personal API tokens plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 01:56:03 +02:00
e24e03c47d feat(16.3-01): admin:token CLI, party.Activated, READMEs and docs
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 01:55:07 +02:00
ffdb5d00ca feat(16.3-01): account token API with OpenAPI and phrase keys
- Add list/create/revoke/scopes handlers, validation, and soft revoke
- Regenerate admin OpenAPI and TS types; inventory and conformance cases
- Add server-side api_tokens phrase keys (en/pl)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 01:51:11 +02:00
183583e0e9 feat(16.3-01): interactive-only gates, ResolveBackend, last_used stamp
- Refuse g15_ tokens on account/avatar/refresh/logout; gate field-types and markdown
- Publish cookie-less ResolveBackend; debounce last_used_at once per minute
- Reserve account vendor segment; TestTokenGatesSmoke covers the deny path

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 01:47:52 +02:00
b9273c1da7 feat(16.3-01): tracer — g15_ token scopes through composite backend guard
- Add pact.TokenScope/HasTokenScopes and bouncer.ScopeLimit on Principal
- Append backend_api_tokens migration; Allows evaluates scope before superuser
- Composite guard routes Bearer g15_… to token lookup; smoke tests cover path

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 01:46:32 +02:00
4d107f4ccb docs: capture todo - uploads keep the client extension (stored XSS, g15office bemfi2zs) 2026-10-11 01:35:37 +02:00
c53ae6a39b docs(16.3): create phase plan 2026-10-11 01:05:56 +02:00
1821f92bee docs: capture todo - prompt open tabs to reload when a new version is deployed 2026-10-11 01:05:05 +02:00
602e0412f3 docs: add a Role in the framework section to every module README
Each module README now says where the module sits in the dependency
graph, why it is its own module, and lists use cases for application
developers.
2026-10-11 00:58:17 +02:00
87953f68bf docs: capture exploration — wavepath framework asks as phases 19.2 to 19.6 2026-10-11 00:36:09 +02:00
ca1443e9c8 docs: add a name mnemonic to every module README and the root modules table 2026-10-11 00:26:20 +02:00
5e3c552aa1 docs(phase-16.3): add validation strategy 2026-10-11 00:18:10 +02:00
9907fa94cd docs(16.3): research phase domain 2026-10-11 00:17:32 +02:00
941ae80b86 docs(16.3): UI design contract 2026-10-11 00:05:21 +02:00
a04ba35271 docs(16.2): re-verify after gap closure 16.2-07 and UAT; phase complete
16.2-VERIFICATION.md passed 14/14: the five human items are resolved by the
16.2-07 gate log and the UAT re-run on v0.1.20 (16.2-UAT.md 5/5). covered_files
adds 16.2-07 PLAN/SUMMARY and openapi_conformance_test.go; digest recomputed
with verification.fingerprint. Re-verified by hand because msd-core 1.14.0
re-stales a report after gap closure and its verifier ignores passed UAT.
STATE points at 16.3 (phase.complete picked 11, an executed phase that is
only stale).
2026-10-10 23:39:54 +02:00
ed8210132e test(16.2): UAT complete - 5 passed after the 16.2-07 gap closure 2026-10-10 21:49:59 +02:00
e1703c7489 docs(16.2-07): complete gap closure plan; UAT Tests 2 and 3 pass on v0.1.20
Purge token set on UAT, restart at 2026-10-10T17:57:57Z, smoke, token-match
and purge-ok pass. posts-unchanged, translations-unchanged, category-purged
and category-fresh pass after the human re-saves. Debug sessions for
G-16.2-1 and G-16.2-2 resolved.
2026-10-10 21:05:47 +02:00
132 changed files with 9410 additions and 216 deletions

49
.planning/G15OFFICE.md Normal file
View File

@@ -0,0 +1,49 @@
# g15office sync
Todos and phases in `.planning/` are mirrored in g15office, project `summercms` (see `g15office.json`). Keep the two in sync.
## Todos
- Every todo in `todos/pending/` has a g15office task, and its frontmatter carries the task ID: `g15office: <id>`.
- Capturing a todo creates its g15office task in the same session (type Bug, Feature, Improvement or Task; the description ends with `Local: .planning/todos/pending/<file>`). Write the ID back into the frontmatter.
- Closing a todo (moving it to `todos/done/`) also moves its task to `Live`, or to `Rejected` if it was dropped, with a one-line comment saying where it was done.
- A todo promoted to a phase closes into that phase's milestone task. For example, `backend-admin-api-tokens` became the Phase 16.3 task.
## Phases
Each roadmap phase has one milestone task titled `[Phase N] <name>`, type Task. Post a short comment on it whenever the phase advances (discussed, planned, a plan executed, verified, UAT, shipped), and keep its status in step:
| Phase state | g15office status |
|---|---|
| Not discussed, or discussed but not planned | To do |
| Planned or executing | In progress |
| Executed, waiting on verification/UAT | Testing |
| Complete | Live |
A new phase, inserted or added, gets its task when it is added to `ROADMAP.md`. A todo that a phase will deliver becomes a subtask of that phase's task.
| Phase | Task | Phase | Task |
|---|---|---|---|
| 1 | 1ar7xvcd | 14.2 | fdlwwsfh |
| 2 | 7uqoumsi | 14.2.1 | bf2wsxhz |
| 3 | 0tszvge7 | 14.3 | utmjarsp |
| 4 | qbwhi5u9 | 15 | qqbp8xtm |
| 5 | 5ahymkhp | 16 | n0nuhzca |
| 6 | altu10qm | 16.1 | y7adhmsm |
| 7 | jzdzafce | 16.2 | gwzqpjyi |
| 8 | 9nwkuxzo | 16.3 | 5lcivllc |
| 9 | hrjqlvb8 | 17 | ejoiuzy4 |
| 10 | vgrmjuwl | 18 | 46vnjevj |
| 10.1 | hwr4istb | 18.1 | 2vriy9pw |
| 10.2 | 6nwcght4 | 19 | 6nojs2go |
| 11 | sz63omvi | 19.1 | uakdpkc8 |
| 11.1 | 9crhrf7k | 19.2 | hgol7otp |
| 11.2 | pvwvnvvi | 19.3 | lpybyjdh |
| 12 | odwb6onw | 19.4 | l7t8dbyl |
| 12.1 | c5wcij0a | 19.5 | onunbz3l |
| 12.2 | hkoccgbx | 19.6 | avoroeb4 |
| 13 | nkbr2ybg | 20 | 0rkcboo0 |
| 14 | cwlaryop | | |
| 14.1 | v0j5lwvq | | |
Backlog phase 999.1 (newsletter) has no task until it is promoted into the roadmap.

View File

@@ -34,6 +34,11 @@ Decimal phases appear between their surrounding integers in numeric order.
- [ ] **Phase 18: Payment Gateway** - Port Golem15.PaymentGateway and its Stripe operator, with Horoskopia as the acceptance consumer
- [ ] **Phase 19: Chat and ChatChannels** - Port Golem15.Chat and Golem15.ChatChannels on lighthouse
- [ ] **Phase 19.1: Chat satellites** - Port chatbots, chatvideo, forum, herald and userfriends
- [ ] **Phase 19.2: Chat polish against WavePath clients** - Replay WavePath's chat traffic through `tide` against the ported chat plugins until green
- [ ] **Phase 19.3: Framework transport and infrastructure for WavePath** - Cookie refresh, credentialed CORS, cache and Redis rate limits, S3 option, OpenAPI page, Turnstile
- [ ] **Phase 19.4: OIDC provider on wristband** - Discovery, JWKS with key rotation, id_token, userinfo, consent and pairwise subjects
- [ ] **Phase 19.5: Social login in sm-user-plugin** - Google and Facebook login with the SPA one-time-code handoff
- [ ] **Phase 19.6: Two-factor auth and device flow in sm-user-plugin** - TOTP, WebAuthn, email and recovery codes, trusted devices, device authorization
- [ ] **Phase 20: Płytarium cutover** - Parity harness green, both real clients run unchanged in production
## Phase Details
@@ -999,7 +1004,7 @@ Plans:
**Goal:** Plugins can register their own admin form field types (todo: cabana-richeditor-field-type), and the Journal plugin ships a first-class Blog Editor formwidget for post content — beyond the current markdown field or a plain rich editor — that edits imported HTML posts losslessly.
**Requirements**: TBD
**Depends on:** Phase 16.1
**Plans:** 6/7 plans executed
**Plans:** 7/7 plans complete
Plans:
**Wave 1**
@@ -1017,7 +1022,7 @@ Plans:
- [x] 16.2-06-PLAN.md — Unit tests: resave contract at every layer, strip-and-report and notices, policy cross-check corpus (both policies, identical copies), category strip, full Go and JS coverage, Postgres tests for a Docker host, phase gate scripts/check-phase16.2.sh, validation map
**Wave 5** *(gap closure, blocked on Wave 4 completion)*
- [ ] 16.2-07-PLAN.md — Gap closure G-16.2-1 and G-16.2-2: conformance cases for GET /field-types and the field upload route, Docker-free route inventory in the gate, TestJournalSVGEmbed fix; Journal post/category admin saves purge the host Nuxt route cache after commit through a token-protected vue-journal-layer route (browsers revalidate); full gate incl. postgres, release summercms v0.1.20 and vue-journal-layer v0.3.0, UAT redeploy, re-run of UAT Tests 3 and 2
- [x] 16.2-07-PLAN.md — Gap closure G-16.2-1 and G-16.2-2: conformance cases for GET /field-types and the field upload route, Docker-free route inventory in the gate, TestJournalSVGEmbed fix; Journal post/category admin saves purge the host Nuxt route cache after commit through a token-protected vue-journal-layer route (browsers revalidate); full gate incl. postgres, release summercms v0.1.20 and vue-journal-layer v0.3.0, UAT redeploy, re-run of UAT Tests 3 and 2
### Phase 16.3: Backend admin personal API tokens with scopes (INSERTED)
@@ -1039,10 +1044,20 @@ Plans:
5. Module READMEs and `docs/` cover the token API, scopes and CLI commands.
6. The new code has unit tests, delivered in the phase's last plan.
**Plans:** 0 plans
**Plans:** 2/4 plans executed
Plans:
- [ ] TBD (run /msd-plan-phase 16.3 to break down)
**Wave 1**
- [x] 16.3-01-PLAN.md — Framework (summercms.go): pact.HasTokenScopes, bouncer scope limit, Allows rewrite (scope before the superuser shortcut), backend_api_tokens migration, composite backend guard, interactive-only marker, route gates, published cabana.ResolveBackend, account token API with OpenAPI and inventories, admin:token:* CLI with party.Activated and summer delegates, READMEs and docs
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 16.3-02-PLAN.md — Admin SPA API tokens card on My account per UI-SPEC: list states, two-step create with show-once plaintext and copy, revoke, phrase keys, dist, docs
**Wave 3** *(blocked on Wave 2 completion)*
- [ ] 16.3-03-PLAN.md — Journal: journal and journal:read scopes, token auth through ResolveBackend with byte-identical bodies (reverses Phase 15 D-15), Journal README and the ResolveBackend docs example; release gate (human), summercms tag after v0.1.20, Journal go.mod and host gitlink bumps
**Wave 4** *(blocked on Wave 3 completion)*
- [ ] 16.3-04-PLAN.md — Unit tests: scope rules, route classification, guard, resolver, account API, CLI, migration, SPA backstops, Journal token matrix; Postgres tests for a Docker host; phase gate scripts/check-phase16.3.sh; validation map
### Phase 17: Blog companions
@@ -1114,7 +1129,7 @@ Plans:
**Mode:** mvp
**Depends on:** Phase 11
**Repos:** `sm-chat-plugin` and `sm-chatchannels-plugin` (new, under `git.golem15.com/golem15/`); `summercms.go` only if `lighthouse` is missing a feature
**Port sources:** `/media/nvme/dev/jz/wavepath.org/plugins/golem15/{chat,chatchannels}`. Wavepath is the heaviest user. Check its copy (with `root_id` threading) against the starter's before choosing the source.
**Port sources:** `/media/nvme/dev/jz/wavepath.org/plugins/golem15/{chat,chatchannels}`. Port the Wavepath copy (with `root_id` threading and `thread_summaries`); decided 2026-10-11, see `.planning/notes/wavepath-framework-asks-division.md`.
**Requirements**: TBD
**Success Criteria** (what must be TRUE):
@@ -1148,11 +1163,107 @@ Plans:
Plans:
- [ ] TBD (run $gsd-plan-phase 19.1 to break down)
### Phase 19.2: Chat polish against WavePath clients (INSERTED)
**Goal:** The ported chat and chat satellites carry WavePath's chat traffic exactly as PHP does. The chat routes that WavePath's clients call are recorded with `tide`, replayed against the Go plugins and fixed until green, so that chat, the core feature of WavePath, is not the risk in the WavePath migration.
**Mode:** mvp
**Depends on:** Phase 19, Phase 19.1
**Repos:** `sm-chat-plugin`, `sm-chatchannels-plugin` and the satellite repos; `summercms.go` only for `lighthouse` or `tide` gaps
**Requirements**: TBD
**Success Criteria** (what must be TRUE):
1. A `tide` corpus of the chat, channel, thread, video-room and push routes, recorded from the PHP backend through `vue-wavepath-app`, `wavepath-mcp` and `hubot-wavepath`, replays green against the Go plugins.
2. Realtime events on the `chat`, `video` and `presence:` namespaces match the PHP broadcasts recorded by the `tide` Centrifugo recorder.
3. Behaviour stays neutral to the other consumers of these plugins (QuestStream, Horoskopia, Portal). Framework READMEs and docs still name no application.
4. The new code has unit tests, delivered in the phase's last plan.
**Plans:** 0 plans
Plans:
- [ ] TBD (run /msd-plan-phase 19.2 to break down)
### Phase 19.3: Framework transport and infrastructure for WavePath (INSERTED)
**Goal:** The framework has the transport and infrastructure pieces a multi-client, multi-instance application needs: JWT cookie refresh across subdomains, credentialed CORS, a cache module with a Redis-backed rate-limit store, an S3 blob option, an OpenAPI page and Turnstile verification.
**Mode:** mvp
**Depends on:** Phase 11
**Repos:** `summercms.go`
**Requirements**: TBD
**Success Criteria** (what must be TRUE):
1. `bouncer` refresh issues a new httpOnly `token` cookie whose `Domain`, `SameSite` and `Secure` attributes are configurable to match PHP, and blacklists the old JTI. The 60-minute TTL and 14-day refresh window are config keys, and credentialed CORS for a configured SPA origin is available as middleware.
2. A cache module ships with memory and Redis drivers, and `surf` gains a Redis-backed `Store`, so rate limits hold across instances. The Redis client dependency has a decision note before code lands.
3. An S3 blob driver can be selected through framework config, so the application does not import it. `/api-docs` and `/api-docs.json` can be served. Turnstile verification middleware is available.
4. The touched modules' READMEs and `docs/` pages are updated. The new code has unit tests, delivered in the phase's last plan.
**Plans:** 0 plans
Plans:
- [ ] TBD (run /msd-plan-phase 19.3 to break down)
### Phase 19.4: OIDC provider on wristband (INSERTED)
**Goal:** `wristband` is a full OpenID Connect provider that existing relying parties can switch to without noticing: discovery, JWKS with asymmetric signing and key rotation, `id_token`, userinfo, resumable authorization with account switching, a consent store and pairwise subjects. The existing issuer, signing keys and client secrets carry over.
**Mode:** mvp
**Depends on:** Phase 8, Phase 19.3
**Repos:** `summercms.go`
**Requirements**: TBD
**Success Criteria** (what must be TRUE):
1. A decision note settles whether the full OIDC provider extends the hand-written `wristband` server or puts zitadel/oidc v3 behind it, before code lands.
2. `/.well-known/openid-configuration`, the JWKS endpoint, userinfo and `id_token` in the token response work with RS256 or ES256 keys. Rotation publishes the old and the new key together.
3. The authorization flow supports resuming a request, switching account, the consent list and revocation, and pairwise and public subjects. A scheduled prune job removes expired codes, tokens and requests.
4. An existing issuer, its signing keys and client secrets can be imported so that relying parties keep working. The phase gets a security review.
5. The `wristband` README and `docs/` pages are updated. The new code has unit tests, delivered in the phase's last plan.
**Plans:** 0 plans
Plans:
- [ ] TBD (run /msd-plan-phase 19.4 to break down)
### Phase 19.5: Social login in sm-user-plugin (INSERTED)
**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.
**Mode:** mvp
**Depends on:** Phase 19.3
**Repos:** `sm-user-plugin`; `summercms.go` only if a provider client belongs in the framework
**Requirements**: TBD
**Success Criteria** (what must be TRUE):
1. `/oauth/google` and `/oauth/facebook` and their callbacks keep the PHP paths, because desktop shells hard-code them.
2. SPA mode (`?mode=spa&frontend_callback=…`) redirects with a one-time code that `oauth-complete` exchanges. A new social user who still needs profile completion goes through `oauth-register-complete`.
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:** 0 plans
Plans:
- [ ] TBD (run /msd-plan-phase 19.5 to break down)
### Phase 19.6: Two-factor auth and device flow in sm-user-plugin (INSERTED)
**Goal:** `sm-user-plugin` has the PHP user plugin's second factors and device login: TOTP, WebAuthn, email codes, recovery codes and trusted devices, a challenge step during login, and QR/code device authorization.
**Mode:** mvp
**Depends on:** Phase 19.3
**Repos:** `sm-user-plugin`
**Requirements**: TBD
**Success Criteria** (what must be TRUE):
1. TOTP setup, confirm and disable; WebAuthn registration and login challenges; email codes; recovery codes; and trusted devices all work against the PHP contract. The WebAuthn dependency has a decision note before code lands.
2. Login with a second factor enabled returns the unauthenticated challenge step, rate-limited by the `2fa-verify` bucket.
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:
- [ ] TBD (run /msd-plan-phase 19.6 to break down)
### Phase 20: Płytarium cutover
**Goal**: The parity harness is green on every manifest route and `vue-fonoteka-app` and `fonoteka-mcp` run unchanged against the Go backend in daily use in production — the Płytarium definition of done.
**Mode:** mvp
**Depends on**: Phase 2, Phase 8, Phase 9, Phase 10, Phase 12, Phase 12.1, Phase 13, Phase 14, Phase 14.1, Phase 14.2, Phase 15, Phase 16
**Depends on**: Phase 2, Phase 8, Phase 9, Phase 10, Phase 12, Phase 12.1, Phase 13, Phase 14, Phase 14.1, Phase 14.2, Phase 15, Phase 16, Phases 19 to 19.6 (decided 2026-10-11: the 19.x WavePath readiness work lands before the cutover)
**Repos:** fonoteka.go, summercms.go
**Requirements**: API-09, QA-05
**Success Criteria** (what must be TRUE):
@@ -1167,7 +1278,7 @@ Plans:
## 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 → 20
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.2 → 19.3 → 19.4 → 19.5 → 19.6 → 20
(Phase 2 depends on Phase 1's command kernel; the two are no longer parallel.)
| Phase | Plans Complete | Status | Completed |
@@ -1198,6 +1309,11 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
| 18.1. Payment Gateway Subscriptions | 0/TBD | Not started | - |
| 19. Chat and ChatChannels | 0/TBD | Not started | - |
| 19.1. Chat satellites | 0/TBD | Not started | - |
| 19.2. Chat polish against WavePath clients | 0/TBD | Not started | - |
| 19.3. Framework transport and infrastructure for WavePath | 0/TBD | Not started | - |
| 19.4. OIDC provider on wristband | 0/TBD | Not started | - |
| 19.5. Social login in sm-user-plugin | 0/TBD | Not started | - |
| 19.6. Two-factor auth and device flow in sm-user-plugin | 0/TBD | Not started | - |
| 20. Płytarium cutover | 0/TBD | Not started | - |
## Backlog

View File

@@ -1,19 +1,19 @@
---
msd_state_version: "1.0"
milestone: v1.0
current_phase: "16.2"
current_phase_name: Plugin-provided field types and the Blog Editor formwidget
status: verifying
stopped_at: Phase 16.3 context gathered
last_updated: "2026-10-10T14:21:46.539Z"
last_activity: 2026-10-10
last_activity_desc: Phase 16.2 execution started
state_head: 9652834ca02230d338888344c5bb0d0c0550abed
current_phase: "16.3"
current_phase_name: Backend admin personal API tokens with scopes
status: executing
stopped_at: Completed 16.3-02-PLAN.md
last_updated: "2026-10-11T00:07:52.680Z"
last_activity: 2026-10-11
last_activity_desc: Completed 16.3-02 admin SPA API tokens card
state_head: fd07e2637fce8ca9bd6294ca611e6acc4137ef4a
progress:
total_phases: 35
total_phases: 40
completed_phases: 13
total_plans: 157
completed_plans: 153
total_plans: 162
completed_plans: 156
gsd_state_version: "1.0"
milestone_name: milestone
---
@@ -25,22 +25,22 @@ milestone_name: milestone
See: .planning/PROJECT.md (updated 2026-09-16)
**Core value:** An existing WinterCMS-shaped app can be ported plugin by plugin to a single Go binary without its frontend noticing: the PHP version's API contract is the acceptance test.
**Current focus:** Phase 16.2 — Plugin-provided field types and the Blog Editor formwidget
**Current focus:** Phase 16.3 — Backend admin personal API tokens with scopes
## Current Position
Phase: 16.2 (Plugin-provided field types and the Blog Editor formwidget) — EXECUTING
Plan: 6 of 6
Status: Phase complete — ready for verification
Last activity: 2026-10-10 - Completed quick task 261010-iwu: Blog Editor live preview pane
Phase: 16.3 (Backend admin personal API tokens with scopes) — EXECUTING
Plan: 3 of 4
Status: Ready to execute
Last activity: 2026-10-11 — Completed 16.3-02 (admin SPA API tokens card)
Progress: [██████████] 100%
Progress: [█████░░░░░] 48%
## Performance Metrics
**Velocity:**
- Total plans completed: 65
- Total plans completed: 71
- Average duration: 21 min
- Total execution time: 104 min
@@ -59,6 +59,7 @@ Progress: [██████████] 100%
| 10 | 5 | - | - |
| 10.2 | 2 | - | - |
| 14.1 | 2 | - | - |
| 16.2 | 7 | - | - |
**Recent Trend:**
@@ -208,6 +209,8 @@ Progress: [██████████] 100%
| Phase 16.2 P04 | 50 min | 3 tasks | 15 files |
| Phase 16.2 P05 | 25 min | 3 tasks | 7 files |
| Phase 16.2 P06 | 43min | 3 tasks | - files |
| Phase 16.3 P01 | 95 | 4 tasks | 43 files |
| Phase 16.3 P02 | 9 min | 3 tasks | 17 files |
## Accumulated Context
@@ -234,6 +237,11 @@ Progress: [██████████] 100%
- Phase 16.1 inserted after Phase 16: Journal admin urgent fixes: relation search, list state, post form content and excerpt (found on UAT) (URGENT)
- Phase 16.2 inserted after Phase 16.1: Plugin-provided field types and the Blog Editor formwidget
- Phase 16.3 inserted after Phase 16: Backend admin personal API tokens with user-selected scopes (Journal agent writes as backend author); successor of Apparatus PersonalApiToken (URGENT)
- Phase 19.2 inserted after Phase 19.1: Chat polish against WavePath clients (WavePath framework asks, 2026-10-11)
- Phase 19.3 inserted after Phase 19.2: Framework transport and infrastructure for WavePath (WavePath framework asks, 2026-10-11)
- Phase 19.4 inserted after Phase 19.3: OIDC provider on wristband (WavePath framework asks, 2026-10-11)
- Phase 19.5 inserted after Phase 19.4: Social login in sm-user-plugin (WavePath framework asks, 2026-10-11)
- Phase 19.6 inserted after Phase 19.5: Two-factor auth and device flow in sm-user-plugin (WavePath framework asks, 2026-10-11)
### Decisions
@@ -677,6 +685,11 @@ Recent decisions affecting current work:
- [Phase 16.2]: 16.2-04: vue-journal-layer v0.2.0 published (tag 042b65d0 -> 759e684e, human reply "push"); vue-grzybyfunkcjonalne-app main 465286a pins it; a defect gets v0.2.1
- [Phase 16.2]: 16.2-05: summercms v0.1.18 released (human: release v0.1.18); a later defect gets v0.1.19
- [Phase 16.2]: 16.2-05: UAT Nuxt rollback copy is nuxt/.output.prev; the site-ok log check reads /var/log/supervisor/grzybyfunkcjonalne-go.log from the restart River anchor (supervisorctl tail fails on UAT)
- [Phase 16.3]: D-03 literal: one method-allowed scope must match every required code; ScopeLimit keeps per-scope pattern slices — Locked CONTEXT reading; multi-code lists are stricter than a union
- [Phase 16.3]: CLI uses party.Activated at Run time; cabana.RuntimeCommands(app) signature unchanged (Option A) — User-confirmed Option A so host apps only bump versions
- [Phase 16.3]: ResolveBackend is Bearer-only (cookie-less JWT guard) — Plugin write routes have no CSRF; cookie auth must not drive them
- [Phase 16.3]: 16.3-02: ApiTokensPanel + ApiTokenCreateDialog on /account; plaintext dialog-local only; shared ConfirmDialog for revoke and close-without-copy — UI-SPEC component split
- [Phase 16.3]: 16.3-02: expired from API drives Expired chip; dates use CellValue local YYYY-MM-DD / YYYY-MM-DD HH:mm — Plan decisions; no new date format
### Pending Todos
@@ -749,6 +762,6 @@ Items acknowledged and carried forward from previous milestone close:
## Session Continuity
Last session: 2026-10-10T13:33:02.885Z
Stopped at: Phase 16.3 context gathered
Resume file: .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-CONTEXT.md
Last session: 2026-10-11T00:07:52.680Z
Stopped at: Completed 16.3-02-PLAN.md
Resume file: None

View File

@@ -1,8 +1,8 @@
---
status: diagnosed
status: resolved
trigger: "UAT gap G-16.2-2: Updated content in https://uat.grzybyfunkcjonalne.pl/gfadmin/golem15/journal/categories/2 (\"Te grzyby, szczególnie\" changed into \"Te grzyby, a szczególnie\") and i see the change in backend even after refresh but not on frontend"
created: 2026-10-10T17:20:00+02:00
updated: 2026-10-10T17:32:00+02:00
updated: 2026-10-10T20:05:00+02:00
goal: find_root_cause_only
---
@@ -116,3 +116,7 @@ root_cause: "Not a 16.2 save defect. The save, the DB row and the Journal API al
fix: (not applied — goal find_root_cause_only)
verification: "Observed self-healing without any write: 127.0.0.1:3016/cordyceps returned the old text at 15:24:01 UTC (last-modified 14:59:08) and the new 'Te grzyby, a szczególnie' at 15:25:00 (last-modified 15:24:01)"
files_changed: []
## Resolution
Fixed by plan 16.2-07 (released as summercms v0.1.20 and vue-journal-layer v0.3.0) and confirmed on UAT on 2026-10-10. See 16.2-07-SUMMARY.md.

View File

@@ -1,8 +1,8 @@
---
status: diagnosed
status: resolved
trigger: "UAT gap G-16.2-1: bash scripts/check-phase16.2.sh postgres fails in ./modules/cabana: TestPhase10OpenAPIConformance: openapi_conformance_test.go:418: admin route GET /field-types has no conformance case"
created: 2026-10-10T17:10:00+02:00
updated: 2026-10-10T17:28:00+02:00
updated: 2026-10-10T20:05:00+02:00
goal: find_root_cause_only
---
@@ -150,3 +150,7 @@ suggested_fix_direction: |
- Add two conformCases. GET /field-types: status 200, ref "cabana.FieldTypesResponse", decode into[cabana.FieldTypesResponse](); register a plugin field type on the conform fixture (conformPlugin implements pact.HasFormFieldTypes; element under its widget tag prefix; a script already in conformFS) so the list is non-empty and the ?v= asset URL can be checked with assertPluginAsset. POST .../fields/{field}/upload: status 201, ref "cabana.Envelope-cabana_FieldUploadResponse", decode into[cabana.Envelope[cabana.FieldUploadResponse]](); a gadget fields.yaml field of that alias whose type has Upload, multipart part named "file", sent with requireAjax headers. Mind that a plugin-typed field on the gadget form also runs Decode/Load in the existing create/update/show cases — make them benign or give conformGadget a column for it.
- Fix TestJournalSVGEmbed to assert the intended `type: mlblogeditor` (optionally htmlColumn: content_html) for post content.
- Recurrence guard: split the static inventory check out of the Docker setup (e.g. move `env := newConformEnv(t)` after the coverage loop, or a package-level conformCaseKeys + a Docker-free TestPhase10ConformanceInventory) and add it to FRAMEWORK_RUN in check-phase16.2.sh (and later gates); add openapi_conformance_test.go to the "new admin route" checklist next to phase09Routes and admin_openapi.go.
## Resolution
Fixed by plan 16.2-07 (released as summercms v0.1.20 and vue-journal-layer v0.3.0) and confirmed on UAT on 2026-10-10. See 16.2-07-SUMMARY.md.

View File

@@ -0,0 +1,45 @@
---
title: How the WavePath framework asks divide between SummerCMS and WavePath
date: 2026-10-11
context: /msd-explore on the WavePath hand-off list `/media/nvme/dev/jz/wavepath.org/.planning/notes/summercms-framework-asks-for-wavepath.md`. Seed `SEED-261007-0tb`.
---
# How the WavePath framework asks divide between SummerCMS and WavePath
WavePath's agent wrote a list of what it needs from SummerCMS (section A) and what it will do itself (section B). Section B stays on the WavePath side as its migration milestone. Section A lands here as the 19.x phases, all before the Płytarium cutover (Phase 20).
## Decisions
- **The 19.x block comes before the cutover.** Chat first, then chat polish, then the other framework and user-plugin asks. Phase 20 waits for 19 to 19.6. The WavePath migration milestone opens after Phase 20.
- **Chat is ported from the WavePath copy**, with `root_id` threading and `thread_summaries`. This settles the open chat-source question for Phase 19.
- **The seed's trigger is now "Phase 20 complete".** The WavePath milestone no longer waits for the Horoskopia port.
## Where each ask lands
| Ask | Where |
|---|---|
| A9: chat and chatchannels | Phase 19 |
| A9: chatbots, chatvideo (LiveKit, reconcile job), forum, herald (web push), userfriends | Phase 19.1 |
| Chat parity against WavePath's clients | Phase 19.2 |
| A3 cookie refresh and CORS, A6 cache and Redis `surf.Store`, A8 S3 option, `/api-docs`, Turnstile | Phase 19.3 (`summercms.go`) |
| A1 OIDC provider | Phase 19.4 (`wristband`) |
| A2 social login | Phase 19.5 (`sm-user-plugin`) |
| A4 2FA, A5 device flow | Phase 19.6 (`sm-user-plugin`) |
| A7 `summer import:winter` | Phase 20 (D-02 runner), with the WavePath stress cases below |
## Corrections to the hand-off list
- **The user plugin is already shared.** `sm-user-plugin` (`git.golem15.com/golem15/sm-user-plugin`) is its own repo, checked out by both fonoteka.go and sm-bm-app. Phases 19.5 and 19.6 add to it directly. Both consuming apps must still build after each change.
- **Plugin-registrable token scopes (A8) are dropped for now.** `sm-user-plugin` already mints scoped tokens (`MintAPIToken`, `HasScope`). Its allowed list (`read`, `write`, `ai`) is a static package variable. WavePath's `wave.scope.public:*` scopes belong to its own `wave_token` guard and `golem15_wavepath_api_tokens` table, which section B ports on the WavePath side. Add a `RegisterAPITokenScope` hook only if WavePath confirms that its user-plugin tokens carry custom scopes.
## `import:winter` stress cases from WavePath (for Phase 20)
- The source is MariaDB. That is the same MySQL wire protocol, but check type coercion.
- About 90 tables, including large chat and channel message tables. Batching must handle them.
- APP_KEY-encrypted columns go through the Laravel-decrypt hook.
- Zero-knowledge encryption-key blobs are copied byte for byte and never decoded.
- `system_files` across 16 attaching models, and 9 `system_settings` rows.
## Scope reminder
The WavePath backend is about 350 routes, about 90 tables and 164 migrations across `wavepath` and its shared plugins. The PHP routes are not frozen, so re-count when the milestone opens.

View File

@@ -0,0 +1,129 @@
---
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
plan: 07
subsystem: gap-closure
tags: [gap-closure, conformance, site-cache, purge, nitro-swr, release, uat, d-03, d-19]
gap_closure: true
gap_ids: [G-16.2-1, G-16.2-2]
requires:
- phase: 16.2-06
provides: scripts/check-phase16.2.sh and the Postgres suites whose stage G-16.2-1 found red
- phase: 16.2-05
provides: the UAT deploy procedure and its corrections
provides:
- "Conformance cases for GET /field-types and the field upload route, and a Docker-free route inventory check in the gate's go stage (G-16.2-1)"
- "Journal admin post and category writes purge the host Nuxt route cache after commit through a token-protected loopback route (G-16.2-2)"
- "summercms v0.1.20 and vue-journal-layer v0.3.0, released to UAT with the plugin and host bumps"
- "UAT Tests 2 and 3 re-run on the deployed build: all four on-server checks pass"
affects: [16.2 verify-work, sm-journal-plugin, vue-journal-layer, sm-grzybyfunkcjonalne-app]
actuals:
tasks: 6
executor_sessions: [summercms-go-da (Tasks 1-5 except step 8), summercms-go-16 (Task 5 step 8 onward, Task 6)]
plan_head_before: d0b7fe4
plan_head_after: aba063f
plugin_commits_repo: ../sm-journal-plugin
plugin_head_after: 5eea57f
layer_commits_repo: ../vue-journal-layer
layer_head_after: 25e9bfe
site_app_head_after: 3a920c7
host_head_after: f1d53af
key-files:
created:
- ../sm-journal-plugin/classes/site_cache.go
- ../sm-journal-plugin/classes/site_cache_test.go
- ../sm-journal-plugin/controllers/site_cache.go
- ../vue-journal-layer/app/utils/siteCache.ts
- ../vue-journal-layer/server/api/__journal__/cache/purge.post.ts
- ../vue-journal-layer/testing/purge-check.mjs
modified:
- modules/cabana/openapi_conformance_test.go
- scripts/check-phase16.2.sh
- ../sm-journal-plugin/plugin_test.go
- ../sm-journal-plugin/README.md
- ../vue-journal-layer/README.md
---
# Phase 16.2 Plan 07: Gap closure for G-16.2-1 and G-16.2-2
**The phase gate is green again with the field type routes under conformance, and a Journal admin save now clears the host Nuxt route cache, so an edited category shows on the next view of the site. Released as summercms v0.1.20 and vue-journal-layer v0.3.0 and confirmed on UAT.**
## Task 4 answer
"release" (verbatim).
## Release
| Item | Value |
|------|-------|
| summercms tag | v0.1.20, tag object ad7981f, pushed |
| vue-journal-layer tag | v0.3.0, tag object c09e0d0 on 25e9bfe, pushed |
| summercms.go master | aba063f |
| sm-journal-plugin main | 5eea57f (requires summercms v0.1.20) |
| vue-journal-layer main | 25e9bfe |
| vue-grzybyfunkcjonalne-app main | 3a920c7 (pins vue-journal-layer#v0.3.0) |
| sm-grzybyfunkcjonalne-app master | f1d53af |
| UAT binary sha256 | 8a64d015… (matches the local build) |
| First start | migrate, smoke (98 URLs) and deploy-ok at 2026-10-10T17:34:31Z |
### Purge token (Task 5 step 8) and restart
- The token was generated and written on the server by a script the user approved: `token-written`. It was never printed or sent back over the connection. Both env files stay mode 0600.
- RESTART_UTC: 2026-10-10T17:57:57Z. Both programs RUNNING.
- LOG_ANCHOR: `client_id=alexandria_2026_10_10T17_57_58_009306`. This replaces the first anchor (`client_id=alexandria_2026_10_10T17_34_31_364928`).
- smoke: `smoke: ok (98 urls)`
- token-match: `token-match`
- purge-ok: `200 83 403 401` (an authorized loopback purge removed 83 entries; a POST through nginx gets 403, a loopback POST without a token gets 401)
### Snapshot before Task 6 (equal to the 16.2-05 expected rows)
- posts md5: `1:a026264105f900175b9770e637e5ec5c:a026264105f900175b9770e637e5ec5c`, `64:0e4be845390b1656871513957d471a7c:e785e14e80f6400534e16b4769749d3c`
- translations: `64:en:862fc5b362cc9856e6736a2078dca3ba`
- updated_at: post 1 `2026-10-10 15:05:09.645641+00`, post 64 `2026-10-10 15:05:18.083705+00`
- category 2: `cordyceps:4053158bd1a58eb7f9547c18cb5a1724:2026-10-10 15:00:27.940875+00`
## Task 6: UAT re-run
Human replies: step 1 approved, step 2 approved (no removal notice on the untouched saves of posts 1 and 64), step 3 confirmed (category 2 saved with no removal notice), step 4 confirmed (the edit showed on the first view of /cordyceps, by reload and by the menu link).
Phrase after the edit: "Te grzyby, a szczególnie Cordyceps sinensis i Cordyceps militaris, od wieków zajmują bardzo ważne miejsce" (was "… zajmują ważne miejsce"). The user chose to keep the edit; the word was not changed back.
| Check | Result |
|-------|--------|
| posts-unchanged | pass: content and content_html of posts 1 and 64 are byte-identical, and both saves moved updated_at past RESTART_UTC |
| translations-unchanged | pass |
| category-purged | pass: successful `kind=category slug=cordyceps` purges after the anchor, every purge succeeded, no panic or SQLSTATE lines, category 2 saved after RESTART_UTC |
| category-fresh | pass: /cordyceps Last-Modified 2026-10-10 19:00:35 GMT, after the category's updated_at |
| Phrase on the server-rendered /cordyceps | found 1 time |
Purge lines after the anchor: post `10-faktow-na-temat-cordycepsu` purged=5, post `ekstrakt-z-turkey-tail` purged=0, category `shiitake` purged=0 (twice), category `cordyceps` purged=7, 3, 3. All status=200. The shiitake purges are from other admin activity during the session and do not affect any check.
**Ready for /msd-verify-work:** UAT Test 2 (D-19) and Test 3 (D-03) can be marked pass. Test 1 (the postgres gate stage) is covered by the Task 3 gate log (gate2.log, full gate including postgres).
## Commits
summercms.go: 11be08d (conformance cases), ee62410 (gate stages), aba063f (docs: route checklist, validation rows).
sm-journal-plugin: f3b223f (tracer: purge after a category update), 2a3afb5 (purge on every post and category write), 09e5643 (TestJournalSVGEmbed expects mlblogeditor), 4405fee (full purge coverage), 5eea57f (require v0.1.20).
vue-journal-layer: a03e076 (token-protected purge route), 0a01bfe (browser revalidation, docs), 4f55511 (tests), 03a90e9 (forward the visitor address), 25e9bfe (release v0.3.0).
vue-grzybyfunkcjonalne-app: 5095f95 (docs), 3a920c7 (layer v0.3.0). sm-grzybyfunkcjonalne-app: 152e1ef (deploy docs: purge token), f1d53af (bump plugin and site app).
## Deviations from Plan
1. **[Rule 1 - Bug] Visitor address lost on route-cached renders.** Layer fix 03a90e9 forwards it through `event.context.journalForwardedFor`.
2. **Scratch host build.** It uses a `file:` override instead of `link:`, and TMPDIR moved off /tmp.
3. **TDD RED and GREEN in one commit.** RED for TestPhase10ConformanceInventory was run locally (RED_EVIDENCE_OK), then committed together with GREEN in 11be08d, because go test must stay green at every commit.
4. **Extra helper file.** `controllers/site_cache.go` in sm-journal-plugin, not listed in files_modified.
5. **Layer README additions.** The 16.2-VALIDATION.md rows for Tasks 4 to 6 were left pending by the first executor.
6. **pnpm store.** `pnpm_config_store_dir=/media/nvme/dev/golem15/summercms.io/summercms/.pnpm-store`, because /tmp/.pnpm-store belongs to another user.
7. **Task 5 step 8 blocked by the Secret read guard.** The hook blocks agent commands that name a .env file. The step ran as a server-side script whose command line does not name the file, with the user's explicit approval of each command; the token was never printed.
8. **Executor handover.** summercms-go-da ran Tasks 1 to 5 up to step 8, then stood down when the user lost access to that console. summercms-go-16 ran step 8 onward and Task 6, so RESTART_UTC and LOG_ANCHOR come from the second restart.
## Next
- /msd-verify-work 16.2: mark Tests 2 and 3 pass, then re-run phase verification (VERIFICATION.md is still `human_needed`).
- Fill the 16.2-VALIDATION.md rows for Tasks 4 to 6.

View File

@@ -1,34 +1,31 @@
---
status: diagnosed
status: complete
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
source: [16.2-VERIFICATION.md]
started: 2026-10-10T14:54:46Z
updated: 2026-10-10T15:27:52Z
updated: 2026-10-10T19:15:00Z
---
## Current Test
[testing paused — 1 items outstanding]
[testing complete]
## Tests
### 1. Postgres suites on a Docker host
expected: bash scripts/check-phase16.2.sh postgres passes. This is the end-to-end proof for the CR-01 and WR-04 Load changes and for the category save.
result: issue
reported: "Run by Claude on this Docker host (2026-10-10 16:57): the postgres stage fails in ./modules/cabana: --- FAIL: TestPhase10OpenAPIConformance: openapi_conformance_test.go:418: admin route GET /field-types has no conformance case. The stage stops there, so the Journal admin harness suites (TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML, TestCategoriesAdminContent, TestCategoriesAdminBlogEditor) did not run."
severity: major
result: pass
reported: "Re-run after 16.2-07 (gate2.log, 2026-10-10 18:44): phase16.2 postgres passed — 293 cabana tests and 78 sm-journal-plugin tests; full gate \'Phase 16.2 gate passed\', exit 0. Was: TestPhase10OpenAPIConformance had no case for GET /field-types (G-16.2-1, fixed in 11be08d)."
### 2. D-19 category content on UAT (WINDOWS 18)
expected: Change one word in a category body and save. No removal notice appears, and the site shows the change.
result: issue
reported: "something is wrong. Updated content in https://uat.grzybyfunkcjonalne.pl/gfadmin/golem15/journal/categories/2 (\"Te grzyby, szczególnie\" changed into \"Te grzyby, a szczególnie\") and i see the change in backend even after refresh but not on frontend"
severity: major
result: pass
reported: "Re-run on v0.1.20 with the site cache purge (16.2-07 Task 6): user changed \'zajmują ważne miejsce\' to \'zajmują bardzo ważne miejsce\' on category 2, no removal notice, change visible on the first view of /cordyceps by reload and menu link. category-purged and category-fresh pass; phrase found once in the server-rendered page. Was: Nitro swr cache served the old page (G-16.2-2)."
### 3. Untouched re-save after the next deploy (with the CR-01/WR-04 fixes)
expected: Re-save posts 1 and 64 untouched. The md5 SELECT is unchanged (1: a026264105f900175b9770e637e5ec5c both columns; 64: 0e4be845390b1656871513957d471a7c:e785e14e80f6400534e16b4769749d3c) and updated_at moves.
result: blocked
blocked_by: release-build
reason: "User: posts resaved. Claude checked UAT read-only at 15:05 UTC: md5 of posts 1 and 64 equal the snapshot and updated_at moved (15:05:09 / 15:05:18 UTC). But the UAT binary /srv/grzybyfunkcjonalne/go/bin/grzybyfunkcjonalne dates from 12:27 UTC, before v0.1.19 was tagged, so the CR-01/WR-04 fixes are not deployed yet. Re-run after the v0.1.19 deploy."
result: pass
reported: "Re-run on v0.1.20 (includes CR-01/WR-04), 16.2-07 Task 6: user re-saved posts 1 and 64 untouched, no removal notice. posts-unchanged (md5 equal to the snapshot, updated_at moved past the 17:57:57Z restart) and translations-unchanged pass."
### 4. Preview pane in a real browser (quick 261010-iwu)
expected: 50/50 split; the pane wraps below the editor when the element is narrower than about 720px; a <script> in a raw HTML block stays inert; YouTube thumbnails load; relative /storage images resolve.
@@ -41,17 +38,17 @@ result: pass
## Summary
total: 5
passed: 2
issues: 2
passed: 5
issues: 0
pending: 0
skipped: 0
blocked: 1
blocked: 0
## Gaps
- gap_id: G-16.2-1
truth: "bash scripts/check-phase16.2.sh postgres passes on a Docker host"
status: failed
status: resolved
reason: "Observed by Claude running the postgres stage: TestPhase10OpenAPIConformance fails: admin route GET /field-types (added in 16.2) has no conformance case in modules/cabana/openapi_conformance_test.go; the Journal Postgres suites never ran because the stage aborted"
severity: major
test: 1
@@ -74,7 +71,7 @@ blocked: 1
- gap_id: G-16.2-2
truth: "Changing one word in a category body and saving shows no removal notice, and the site shows the change"
status: failed
status: resolved
reason: "User reported: something is wrong. Updated content in https://uat.grzybyfunkcjonalne.pl/gfadmin/golem15/journal/categories/2 (\"Te grzyby, szczególnie\" changed into \"Te grzyby, a szczególnie\") and i see the change in backend even after refresh but not on frontend"
severity: major
test: 2

View File

@@ -1,8 +1,8 @@
---
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
verified: 2026-10-10T17:05:00Z
status: human_needed
score: 13/14 must-haves verified
verified: 2026-10-10T20:10:00Z
status: passed
score: 14/14 must-haves verified
covered_files:
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-01-PLAN.md
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-01-SUMMARY.md
@@ -16,6 +16,8 @@ covered_files:
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-05-SUMMARY.md
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-06-PLAN.md
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-06-SUMMARY.md
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-07-PLAN.md
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-07-SUMMARY.md
- admin/src/app/fieldTypes.ts
- admin/src/components/form/fields/MLPluginField.vue
- admin/src/components/form/fields/PluginField.vue
@@ -26,42 +28,39 @@ covered_files:
- modules/cabana/field_type.go
- modules/cabana/field_type_upload.go
- modules/cabana/http.go
- modules/cabana/openapi_conformance_test.go
- modules/cabana/relation_field.go
- modules/cabana/tx_context.go
- modules/pact/capabilities.go
- scripts/check-phase16.2.sh
covered_digest: "v2:sha256:9eb467f60a452579c8faedf8900a27909d5234623d4f6760fef9f5691bbf6d75"
behavior_unverified: 1
covered_digest: "v2:sha256:1172372f0148895ed07ee76d9d0d9996ba7497c35d22ad3443e564c7a817e5d4"
behavior_unverified: 0
overrides_applied: 0
behavior_unverified_items:
- truth: "D-19 (amended): Journal category content uses blogeditor and is stripped and reported like post content; the 422 CheckHTML gate is retired"
test: "On a Docker host run `bash scripts/check-phase16.2.sh postgres` (TestCategoriesAdminContent, TestCategoriesAdminBlogEditor); on UAT change one word of a category's content in the Blog Editor and save"
expected: "Both Postgres tests pass; on UAT the save succeeds with no removal notice and the category page on the site shows the change"
why_human: "The code is present and wired (fields.yaml type blogeditor, prepareCategoryWrite no longer refuses, the shared Decode is unit-tested), but the end-to-end category save only runs in the Docker-only harness and the UAT category step was never performed (WINDOWS entry 18)"
human_verification:
re_verification: "2026-10-10, after gap closure plan 16.2-07 (G-16.2-1, G-16.2-2) and human UAT on the v0.1.20 UAT deploy; manual re-verification because msd-core 1.14.0 loops stale/human_needed after gap closure"
human_verification_resolved:
- test: "On a host with Docker, run `bash scripts/check-phase16.2.sh postgres` from summercms.go"
expected: "TestFieldTypeSavePostgres, TestListRelationSearchPostgres, TestPostsFormCompiles, TestPostsAdminCreateSmoke, TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML, TestCategoriesAdminContent, TestCategoriesAdminBlogEditor and TestJournalSVGEmbed all pass (closes WINDOWS 11, 12, 14, 15, 16, 20, 21)"
why_human: "No Docker daemon on this host; the suites compile under go vet but have never run. They are the only end-to-end proof of the CR-01 and WR-04 Load changes, which landed after the UAT acceptance"
result: pass
evidence: "Full gate at ee62410 (gate2.log, 2026-10-10 18:44): phase16.2 postgres passed, 293 cabana and 78 sm-journal-plugin top-level tests; 'Phase 16.2 gate passed', exit 0. No non-planning change since. UAT Test 1"
- test: "D-19 on UAT: change one word of a category's content in the Blog Editor and save"
expected: "The save succeeds with no removal notice; the category page shows the change"
why_human: "WINDOWS entry 18; not among the four UAT steps the human ran"
- test: "After the next deploy (v0.1.19 with the review fixes), save post 1 and post 64 untouched again and re-run the read-only md5 SELECT"
expected: "md5(content), md5(content_html) and the post 64 en translation md5 equal the 16.2-05 snapshot; updated_at moved"
why_human: "The UAT D-03 evidence was taken on the v0.1.18 binary. The review fixes changed Load (CR-01: blank content served as content_html; WR-04: '<'-prefixed values compared with content_html). By reading the code the served value for posts 1 and 64 is unchanged, but the deployed proof predates the change"
- test: "Blog Editor preview pane in a real browser (quick 261010-iwu): toggle Preview in light and dark mode, narrow the window, put a <script> in a raw HTML block, add a YouTube embed and a relative /storage image"
expected: "50/50 split that wraps below about 720px; the script does nothing in the pane; the embed shows a thumbnail card; the relative image resolves"
why_human: "happy-dom cannot enforce iframe sandboxing, CSP or layout"
- test: "Judgment-tier prohibition (plan 02): 'The element renders no iframe and no live raw HTML in the admin'. Confirm the preview pane folded in from quick 261010-iwu is an accepted exception"
expected: "Human accepts that the sandboxed srcdoc iframe (empty sandbox, CSP meta, active content dropped, no src) does not violate the intent, or asks for a change"
why_human: "The preview pane renders the post HTML inside an iframe in the admin, which reads against the literal prohibition text; the LLM judgment is that its intent (no YouTube iframe, no script-capable raw HTML in the admin DOM) still holds"
result: pass
evidence: "16.2-07 Task 6 on v0.1.20: category 2 saved with no removal notice, edit visible on the first view of /cordyceps; category-purged and category-fresh pass. UAT Test 2"
- test: "After the next deploy, save post 1 and post 64 untouched again and re-run the read-only md5 SELECT"
result: pass
evidence: "16.2-07 Task 6 on v0.1.20 (includes CR-01/WR-04): posts-unchanged and translations-unchanged pass, updated_at moved past the 17:57:57Z restart. UAT Test 3"
- test: "Blog Editor preview pane in a real browser"
result: pass
evidence: "UAT Test 4"
- test: "Judgment-tier prohibition: sandboxed preview iframe accepted as an exception"
result: pass
evidence: "Human accepted. UAT Test 5"
---
# Phase 16.2: Plugin-provided field types and the Blog Editor formwidget Verification Report
**Phase Goal:** Plugins can register their own admin form field types (todo: cabana-richeditor-field-type), and the Journal plugin ships a first-class Blog Editor formwidget for post content (beyond the current markdown field or a plain rich editor) that edits imported HTML posts losslessly.
**Verified:** 2026-10-10T17:05:00Z
**Status:** human_needed
**Re-verification:** No, initial verification
**Status:** passed
**Re-verification:** Yes, 2026-10-10 after gap closure 16.2-07 and UAT (see Re-verification below)
ROADMAP lists no success criteria and REQUIREMENTS.md has none for this phase, so the must-haves are the six plans' frontmatter truths, keyed to CONTEXT decisions D-01 to D-19. They are merged into 14 truths below.
@@ -246,3 +245,23 @@ Status is `human_needed`, not `passed`, for three reasons:
_Verified: 2026-10-10T17:05:00Z_
_Verifier: Claude (msd-verifier)_
## Re-verification (2026-10-10, after 16.2-07)
Gap closure plan 16.2-07 closed G-16.2-1 (conformance cases for GET /field-types and the field upload route; a Docker-free route inventory check in the gate) and G-16.2-2 (Journal admin writes purge the host Nuxt route cache). It was released as summercms v0.1.20 and vue-journal-layer v0.3.0 and deployed to UAT.
All five human verification items above are resolved by evidence, recorded in `human_verification_resolved` and in 16.2-UAT.md (status complete, 5/5 pass):
| Item | Evidence |
|------|----------|
| Postgres suites (truth 10 Postgres half, truth 14) | gate2.log at ee62410: `phase16.2 postgres passed`, `Phase 16.2 gate passed` |
| D-19 category save and site visibility (truth 14) | 16.2-07 Task 6: category-purged, category-fresh, phrase found on the server-rendered page |
| Untouched re-save on the build with CR-01/WR-04 (truth 10) | 16.2-07 Task 6: posts-unchanged, translations-unchanged |
| Preview pane in a browser | UAT Test 4 pass |
| Preview iframe exception | UAT Test 5 pass, accepted by the human |
Checks at this re-verification: `go vet ./modules/cabana/ ./modules/pact/` clean; `go test ./modules/pact/` and `TestPhase10ConformanceInventory` pass; no change outside `.planning/` since aba063f. `covered_files` now includes 16.2-07-PLAN.md, 16.2-07-SUMMARY.md and modules/cabana/openapi_conformance_test.go, and `covered_digest` was recomputed with `msd_run query verification.fingerprint`.
Truth 14 (D-19) moves from NEEDS HUMAN to VERIFIED; the score is 14/14 and the status is `passed`.
This report was re-verified by hand, as Phase 9's was, because msd-core 1.14.0 makes a report stale after any gap-closure plan and its verifier ignores passed UAT results. Re-running execute-phase would have reset 16.2-UAT.md to pending.

View File

@@ -0,0 +1,498 @@
---
phase: 16.3-backend-admin-personal-api-tokens-with-scopes
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- modules/pact/capabilities.go
- modules/pact/README.md
- modules/bouncer/context.go
- modules/bouncer/README.md
- modules/party/registry.go
- modules/party/registry_test.go
- modules/party/README.md
- modules/lagoon/backend_admin_migrations.go
- modules/lagoon/backend_admin_migrations_test.go
- modules/lagoon/README.md
- modules/cabana/api_tokens.go
- modules/cabana/token_scopes.go
- modules/cabana/token_guard.go
- modules/cabana/account_tokens.go
- modules/cabana/token_commands.go
- modules/cabana/contracts.go
- modules/cabana/registry.go
- modules/cabana/messages.go
- modules/cabana/http.go
- modules/cabana/auth.go
- modules/cabana/field_type.go
- modules/cabana/field_markdown.go
- modules/cabana/commands.go
- modules/cabana/admin_openapi.go
- modules/cabana/export_test.go
- modules/cabana/security_coverage_test.go
- modules/cabana/openapi_conformance_test.go
- modules/cabana/token_smoke_test.go
- modules/cabana/token_postgres_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
- cmd/summer/runtime.go
- cmd/summer/runtime_test.go
- docs/examples/blog/token_scopes.go
- docs/examples/blog/lang/en/lang.yaml
- docs/backend/users-and-permissions.md
- docs/services/authentication.md
- docs/console/setup-and-maintenance.md
- docs/console/introduction.md
- docs/plugins/registration.md
autonomous: true
requirements: [SC-1, SC-2, SC-3, SC-5]
estimate:
tokens: 170000
raw_tokens: 170000
tasks: 4
confidence: low
must_haves:
truths:
- "D-07 and D-12: a token is g15_ plus 40 lowercase hex characters from 20 crypto/rand bytes; only its sha256 hex (unique column) and its last four characters are stored, in backend_api_tokens, created by migration 202610110001_backend_api_tokens appended to lagoon.BackendAdminMigrations under the summercms.cabana history (backend_user_id FK ON DELETE CASCADE, revoked_by FK ON DELETE SET NULL); TestBackendAdminMetadataColumn is updated in the same commit; Apparatus rows are not migrated."
- "D-13 and SC-1: the cabana backend guard is composite: Authorization Bearer g15_… goes to the token lookup, every other request goes to the unchanged NewBackendJWTGuard path (Bearer JWT, then the admin cookie); a g15_ value is never read from a cookie or the query string and is never handed to the JWT parser; on success the request runs as the token's owner and the *cabana.BackendAPIToken row is the bouncer credential."
- "D-08, D-09 and D-10: a malformed, unknown, expired or revoked token, or one whose owner is deactivated or soft-deleted, answers the existing 401 unauthenticated envelope; the owner is loaded fresh per request through lazyBackendUsers (role, code-declared and user grants), and neither a password reset nor tokens_valid_after invalidates a token."
- "D-02, D-03 and D-04: only stored scopes whose method list includes the request method count; when a known scope exists but none allows the method the guard answers 403 forbidden with details.reason token_scope before any handler runs; a token whose stored scopes are all unknown answers 401; `*` behaves as the owner on every method and `admin:read` as the owner on GET and HEAD."
- "D-14, D-03 and D-05: cabana.Allows evaluates the scope before the superuser and empty-list shortcuts; a scoped (non-full) principal passes only when one method-allowed scope matches every required code and the owner holds a required code inside that scope; a superuser with a scoped token is limited to the scope; an empty requirement list is refused unless the scope is full; JWT principals keep today's behaviour exactly."
- "D-01: plugins register scopes through pact.HasTokenScopes returning pact.TokenScope{Name, Label, Description, Permissions, Methods}; boot fails loudly on a duplicate name (naming both plugins), a reserved name (`*`, admin, admin:…), an invalid name, empty permissions or methods, an unknown or lower-case method, a method list without GET, an unknown permission pattern, an empty label or description, or a label or description phrase key that does not resolve."
- "D-05 and D-06: GET /auth/me answers any valid token; PUT /account, POST and DELETE /account/avatar, the four /account/tokens routes, POST /auth/refresh and POST /auth/logout refuse every token, `*` included, with 403 forbidden details.reason interactive_only; GET /field-types and POST /markdown/preview are gated like Allows with an empty list (only `*`, and `admin:read` on GET, pass) with details.reason token_scope."
- "D-15 (framework half): cabana.ResolveBackend authenticates a request outside the backend middleware from the Authorization Bearer header only (admin JWT or g15_ token, never the admin cookie), applies the method filter and, for a token, requires a method-allowed scope that is full or matches every cover code; it returns cabana.ErrUnauthenticated or cabana.ErrTokenScope and uses the same lazyBackendUsers and compiled registry as the admin guard (code-declared role grants included); cabana.Activate publishes the resolver."
- "D-11: last_used_at is stamped at most once per minute per token (in-process gate plus a conditional UPDATE of last_used_at only); a failed stamp is logged and never fails authentication."
- "D-16, D-17 and D-18 (server half): GET /account/tokens lists the caller's own tokens newest first (revoked ones only with revoked=1), POST /account/tokens answers 201 with the plaintext exactly once and 422 validation_failed on name, expires or scopes (unknown, empty, or granting the caller nothing), normalizes a list containing `*` to [`*`], DELETE /account/tokens/{id} soft-revokes only the caller's own active token (404 otherwise), and GET /account/tokens/scopes lists `*`, admin:read and plugin scopes that overlap the caller's grants, with server-translated labels; all four carry swag annotations, appear in admin/openapi/admin.json and admin/src/api/schema.d.ts, and are in phase09Routes and conformCases."
- "D-19: admin:token:create <login|email> --name --scope (repeatable) [--expires] prints exactly the token and a newline on stdout, applies the D-17 overlap rule to the target user and refuses deactivated or deleted users; admin:token:list [<login|email>] [--revoked], admin:token:revoke <id> and admin:token:scopes work; party.Activate publishes party.Activated so the commands see plugin scopes while cabana.RuntimeCommands(app) keeps its signature; summer forwards the four commands to the app binary."
- "D-20 (framework half): the pact, bouncer, cabana, party and lagoon READMEs and docs/backend/users-and-permissions.md, docs/services/authentication.md, docs/console/setup-and-maintenance.md, docs/console/introduction.md and docs/plugins/registration.md describe the token API, scope registration, the framework scopes, interactive-only routes, ResolveBackend and the CLI with neutral names; TestDocsTree and summer docs:build --check pass."
artifacts:
- path: "modules/pact/capabilities.go"
provides: "TokenScope and HasTokenScopes"
contains: "HasTokenScopes"
- path: "modules/bouncer/context.go"
provides: "ScopeLimit and Principal.Scope"
contains: "ScopeLimit"
- path: "modules/lagoon/backend_admin_migrations.go"
provides: "backend_api_tokens migration"
contains: "202610110001_backend_api_tokens"
- path: "modules/cabana/api_tokens.go"
provides: "BackendAPIToken model, APITokenPrefix, mint, hash, issue, revoke, debounced stamp"
contains: "APITokenPrefix"
- path: "modules/cabana/token_scopes.go"
provides: "scope compile, framework scopes, scopeMatch, allowsUnderScope, overlap rule"
contains: "allowsUnderScope"
- path: "modules/cabana/token_guard.go"
provides: "composite backend guard, interactiveOnly, ResolveBackend, ErrUnauthenticated, ErrTokenScope"
contains: "func ResolveBackend"
- path: "modules/cabana/account_tokens.go"
provides: "the four account token handlers"
contains: "accountTokenCreate"
- path: "modules/cabana/token_commands.go"
provides: "admin:token:create, list, revoke, scopes"
contains: "admin:token:create"
- path: "modules/party/registry.go"
provides: "party.Activated published by Activate"
contains: "Activated"
- path: "docs/backend/users-and-permissions.md"
provides: "API tokens guide"
contains: "HasTokenScopes"
key_links:
- from: "modules/cabana/http.go Activate"
to: "modules/cabana/token_guard.go composite guard"
via: "guards.Register(\"summercms.cabana\", \"backend\", newCompositeGuard(...)) wrapping the unchanged NewBackendJWTGuard"
pattern: "newCompositeGuard"
- from: "modules/cabana/token_guard.go"
to: "bouncer.Principal.Scope"
via: "method-filtered scope limit set on the principal FindByID returned"
pattern: "ScopeLimit"
- from: "modules/cabana/contracts.go Allows"
to: "modules/cabana/token_scopes.go allowsUnderScope"
via: "scope branch evaluated before the IsSuperuser and empty-list shortcuts"
pattern: "allowsUnderScope"
- from: "modules/cabana/http.go Activate"
to: "backpack.App service registry"
via: "app.Publish of the backendResolver read by ResolveBackend per request"
pattern: "backendResolver"
- from: "modules/cabana/token_commands.go"
to: "modules/party/registry.go Activated"
via: "app.Lookup[party.Activated]() at Run time, compiled by compileTokenCatalog"
pattern: "party.Activated"
- from: "modules/cabana/account_tokens.go and token_commands.go"
to: "modules/cabana/api_tokens.go issueAPIToken"
via: "one validation and insert path (name, expiry, scopes, overlap) for the API and the CLI"
pattern: "issueAPIToken"
prohibitions:
- statement: "The plaintext token is never persisted, logged, put in an error detail or returned after the create response; logs carry token_id and admin_id only, never the Authorization value or the hash."
status: unresolved
verification: judgment
- statement: "A g15_ token is never accepted from a cookie or a query string, and ResolveBackend never reads the admin cookie."
status: unresolved
verification: judgment
- statement: "A token never grants more than its owner: no scope branch skips the owner check, and a superuser token is limited by its scope."
status: unresolved
verification: judgment
- statement: "The JWT verification path (audience, blacklist, tokens_valid_after, cookie fallback) is unchanged."
status: unresolved
verification: judgment
- statement: "No new Go or npm dependency; crypto is stdlib only (crypto/rand, crypto/sha256, crypto/subtle)."
status: unresolved
verification: judgment
- statement: "Framework READMEs and docs never name a consuming application; examples use acme and blog."
status: unresolved
verification: judgment
---
## Phase Goal
ROADMAP Goal (not in user-story format): backend users create personal API tokens with scopes picked at creation time, so an agent or integration calls the admin and plugin APIs as that backend user with no more access than the token's scope and the user's own permissions allow. Successor of Apparatus PersonalApiToken; reverses Phase 15 D-15.
This plan's slice (user checkpoint, plan 1 of 4): the whole framework side in summercms.go: the scope capability, the principal scope limit, the `Allows` rewrite, the token table, the composite `backend` guard, the interactive-only marker, the explicit route gates, the published resolver, the account token API with OpenAPI and inventories, the `admin:token:*` CLI with the `summer` delegates, and the module READMEs and docs pages for every API this plan adds (CLAUDE.md: same change).
## Spec-less probe fallback
Visible skip: "spec-less probe fallback skipped: phase has no requirement IDs to probe". No SPEC.md exists. CONTEXT D-01 to D-20, the six ROADMAP success criteria and RESEARCH Patterns 1-7 / Pitfalls 1-12 are the source of truth. Prohibitions above are recalled in-prompt and descriptor-less (flagged-unverified).
## Decisions taken in this plan
Locked-decision readings and Claude's-discretion choices, so executors do not re-decide them:
- **D-03 read literally (honoring the locked text; RESEARCH A2 suggested a per-code union).** A scoped request passes when one method-allowed scope matches every required code and the owner holds a required code inside that scope. For single-code checks (almost every call site) the two readings agree; for a multi-code list the literal reading is stricter. `bouncer.ScopeLimit` therefore keeps one pattern slice per scope, not a union.
- **Scope match table** (`scopeMatch(pattern, required)`, written fresh; `granted()` is not reused for the scope side, RESEARCH Pattern 2):
| required | pattern | result |
|---|---|---|
| exact `a.b.c` | `a.b.c`, `a.b.*`, `a.*` | full |
| exact `a.b.c` | `a.x.*`, `a.b.d` | none |
| wildcard `a.b.*` | `a.b.*`, `a.*` (at least as broad) | full |
| wildcard `a.*` | `a.b.*` or exact `a.b.c` (narrower) | partial via the pattern |
| wildcard `a.b.*` | `a.x.*`, `x.*` | none |
| leading `*.manage` | exact `a.manage` | partial via the pattern |
| leading `*.manage` | any wildcard pattern | none (conservative) |
Owner side: full match needs `ownerHas(required)`, partial match needs `ownerHas(pattern)`, where `ownerHas(x) = IsSuperuser || granted(PermissionGrants, x)`.
- **Overlap rule (D-17):** a plugin scope is offered to (and accepted for) a user when some registered catalog code `c` has a full match in the scope and `ownerHas(c)`. `*` is always offered. `admin:read` is offered when the user is a superuser or holds at least one catalog code (RESEARCH A6).
- **Names (discretion):** `pact.TokenScope`, `pact.HasTokenScopes`; `bouncer.ScopeLimit{Full bool; Scopes [][]string}` and `bouncer.Principal.Scope`; `party.Activated`; `cabana.BackendAPIToken`, `cabana.APITokenPrefix`, `cabana.ResolveBackend`, `cabana.ErrUnauthenticated`, `cabana.ErrTokenScope`; table `backend_api_tokens`; route wrapper `interactiveOnly`; framework scope names `*` and `admin:read` (unexported constants).
- **Storage (discretion):** `scopes` is TEXT holding a JSON array (`lagoon.Jsonable[[]string]`), as the user plugin's token table does. An unknown stored name grants nothing and is listed with `known: false`; a token whose stored scopes are all unknown answers 401 (RESEARCH A4).
- **403 reporting (discretion):** the guard's method refusal and the explicit route gates answer `forbidden` with `details.reason = "token_scope"`; the interactive-only marker answers `forbidden` with `details.reason = "interactive_only"`. Allows-based 403s inside handlers keep today's body (no per-handler change, D-14).
- **Methods must include GET (planner rule for D-05):** every registered scope can then identify itself through `GET /auth/me`, so "always allowed to any valid token" holds without a route exception in the guard.
- **Throttle (discretion):** no new throttle bucket. The format check runs before the indexed lookup, a 160-bit secret cannot be guessed, and the admin API has no per-route throttle today (threat T-16.3-10, accepted).
- **Debounce (discretion):** in-process `sync.Map` gate plus `UPDATE … SET last_used_at = ? WHERE id = ? AND (last_used_at IS NULL OR last_used_at < ?)`.
- **Create contract (RESEARCH open question 3, user-confirmed defaults):** 201; `["*", …]` normalizes to `["*"]`; duplicates removed in order; name trimmed, 1 to 100 runes; `expires` one of `30d`, `90d`, `365d`, `never`, default `90d` when absent.
- **Resolver is Bearer-only (new finding while planning):** the Journal write routes have no CSRF check because they are Bearer-only today; a resolver that also read the `summer_admin` cookie would let a cross-site form drive them. `ResolveBackend` therefore wraps a second, cookie-less `NewBackendJWTGuard`.
- **CLI plugin access (user-confirmed Option A):** `party.Activate` publishes `party.Activated`; the token commands look it up at Run time; `cabana.RuntimeCommands(app)` keeps its signature; host apps only bump versions.
- **Reserved vendor segment (RESEARCH Pitfall 10):** `account` joins `api`, `assets`, `login`, `settings`.
- **OpenAPI regeneration lands here, not in plan 02:** `TestPhase09ContractInventory` reads the committed `admin/openapi/admin.json`, so the routes and the regenerated document must ship in the same change to keep `go test ./...` green; plan 02 consumes the regenerated `schema.d.ts`.
- **Docs example:** scope registration is shown from compiled code, `docs/examples/blog/token_scopes.go` (scopes `blog` and `blog:read`), included by a `src=` fence (RESEARCH Pitfall 12); `plugin.go` is not touched, so the whole-file snippet in `docs/setup/porting-a-plugin.md` stays in sync.
## Interfaces (exact shapes executors build and later plans consume)
- `pact.TokenScope{Name, Label, Description string; Permissions, Methods []string}`; `pact.HasTokenScopes{ TokenScopes() []pact.TokenScope }`.
- `bouncer.ScopeLimit{Full bool; Scopes [][]string}`; `bouncer.Principal` gains `Scope *ScopeLimit` tagged `json:"-"` (nil means unrestricted: a JWT session).
- `party.Activated` is a named `[]party.Plugin` published by `party.Activate` in activation order; read with `app.Lookup[party.Activated]()`.
- `cabana.APITokenPrefix = "g15_"`; `cabana.BackendAPIToken` (columns id, backend_user_id, name, token_hash `json:"-"`, token_hint, scopes, expires_at, last_used_at, revoked_at, revoked_by, created_at, updated_at; `TableName()` is `backend_api_tokens`).
- `cabana.ResolveBackend(app *backpack.App, r *http.Request, cover ...string) (*http.Request, *bouncer.Principal, error)`; sentinel errors `cabana.ErrUnauthenticated`, `cabana.ErrTokenScope` (the internal scope error wraps `ErrTokenScope`, so `errors.Is` works).
- Admin API (under `{backend.uri}/api/v1`, backend guard, all interactive-only, writes also `requireAjax`):
- `GET /account/tokens[?revoked=1]` → 200 `Envelope[[]AdminAPIToken]`
- `POST /account/tokens` body `AdminAPITokenRequest{name, expires, scopes}` → 201 `Envelope[AdminAPITokenCreated]`, 422 details keyed `name`, `expires`, `scopes`
- `DELETE /account/tokens/{id}` → 200 `Envelope[AdminAPIToken]`, 404 `not_found`
- `GET /account/tokens/scopes` → 200 `Envelope[[]AdminAPITokenScope]`
- JSON: `AdminAPITokenScope{name, label, description, read_only, known}`; `AdminAPIToken{id, name, hint (last four characters), scopes []AdminAPITokenScope, created_at, last_used_at|null, expires_at|null, revoked_at|null, expired}`; `AdminAPITokenCreated` = every `AdminAPIToken` field plus `token`.
- swag stubs: `AdminAccountTokens`, `AdminAccountTokenCreate`, `AdminAccountTokenRevoke`, `AdminAccountTokenScopes`.
- Server-side phrase keys (backend::lang.api_tokens.*): `scope_all`, `scope_all_description`, `scope_admin_read`, `scope_admin_read_description`, `name_required`, `name_too_long`, `expires_invalid`, `scopes_required`, `scope_unavailable` (UI-SPEC copy for the six it names; `name_too_long` "The name may have at most 100 characters." / "Nazwa może mieć najwyżej 100 znaków."; `expires_invalid` "Choose a valid expiry." / "Wybierz poprawny termin ważności."). Plan 02 adds the SPA-only keys to the same block.
- CLI: `admin:token:create <identifier> --name <text> --scope <name> [--scope …] [--expires 30d|90d|365d|never|YYYY-MM-DD]` (a date means 00:00 UTC on that day and must be in the future); `admin:token:list [<identifier>] [--revoked]`; `admin:token:revoke <id>`; `admin:token:scopes`.
## Plan sizing note
The user locked four plans and put the whole framework side here, with its READMEs and docs in the same change (CLAUDE.md). Four tasks: a tracer, then three expansions. After revision 1 the estimate is 170k tokens against `workflow.smart_zone_tokens` 100k (about 1.7x; confidence low, uncalibrated, since no completed phase carries actuals). The checker treats this as a warning, never a blocker.
Load reductions (revision 1):
- The context block no longer loads 16.3-RESEARCH.md and 16.3-UI-SPEC.md whole (about 26k tokens together by chars/4). Each task's read_first names the sections it needs, with line ranges, about 12k of reads in total.
- **Seam after Task 2.** Tasks 1-2 are the framework core: scope capability, principal limit, Allows, table, composite guard, gates, resolver and stamp. Tasks 3-4 build the surfaces on top: account API and OpenAPI, CLI, READMEs and docs. Tasks 3 and 4 use Tasks 1-2 only through committed code and the Interfaces section above. Commit Task 2 before starting Task 3. At the start of Task 3, do not re-read the Task 1-2 files whole: list their as-built signatures with `grep -n "^func \|^type \|^var \|^const " modules/cabana/api_tokens.go modules/cabana/token_scopes.go modules/cabana/token_guard.go`, then read only the function bodies Task 3 or 4 changes (`issueAPIToken`, the catalog helpers). Record any name that differs from the Interfaces section for the SUMMARY.
Residual risk, accepted by the user on 2026-10-11 at the plan-phase revision checkpoint (keep four plans, no mid-plan checkpoint):
- One executor context still carries all four tasks. The executor and execute-phase honor only one kind of mid-plan context reset, a checkpoint task. Here that would raise this plan to five tasks, which is the plan checker's blocker threshold. Under this project's config (`human_verify_mode` end-of-phase, `auto_advance` false) it would also stop for a human mid-plan. Moving the docs, the CLI or the API out of this plan would break the user's plan lock and the CLAUDE.md same-change rule.
- Failure mode: quality degrades late, in Task 4 (READMEs and docs pages, 21 files), or the run is interrupted after Task 2's commits.
- Mitigations:
- Tracer-first ordering puts the security-critical core (Tasks 1-2) in the early context.
- Every task commits atomically and has its own verify.
- Task 4's checks are mechanical and catch an unknown identifier, command name or stale snippet: TestDocsTree, docs:build --check and TestDelegateCommandsHelp.
- Plan 03 Task 2 re-checks that every exported identifier this phase added is named in its module README.
- An interrupted run lands in execute-phase's safe_resume_gate (commits present, SUMMARY missing), which stops and offers a manual close-out instead of re-dispatching blindly.
## Source coverage audit (phase-wide)
| Source | ID | Item | Plan | Status |
|---|---|---|---|---|
| GOAL | — | Scoped personal tokens, owner ∩ scope, admin and plugin APIs | 01, 02, 03 | COVERED |
| ROADMAP | SC-1 | backend guard accepts g15_ tokens and the JWT | 01 | COVERED |
| ROADMAP | SC-2 | out-of-scope refused; never beyond owner | 01 (+03 Journal) | COVERED |
| ROADMAP | SC-3 | SPA create/list/revoke, show once; CLI for any user | 01 (API, CLI), 02 (SPA) | COVERED |
| ROADMAP | SC-4 | Journal accepts a Journal-scoped token for writes | 03 | COVERED |
| ROADMAP | SC-5 | READMEs and docs cover token API, scopes, CLI | 01 (framework), 02 (My account), 03 (Journal) | COVERED |
| ROADMAP | SC-6 | unit tests in the last plan | 04 | COVERED |
| CONTEXT | D-01 | pact capability, fail-loud boot | 01 | COVERED |
| CONTEXT | D-02 | read scopes GET/HEAD, 403 before handler | 01 | COVERED |
| CONTEXT | D-03 | several scopes, any one passes, immutable | 01 | COVERED |
| CONTEXT | D-04 | `*`, `admin:read`; Journal `journal`, `journal:read` | 01, 03 | COVERED |
| CONTEXT | D-05 | deny by default; /auth/me always | 01 | COVERED |
| CONTEXT | D-06 | interactive-only marker | 01 | COVERED |
| CONTEXT | D-07 | g15_ + 40 hex, sha256, hint | 01 | COVERED |
| CONTEXT | D-08 | expiry 30/90/365/never, default 90, CLI date | 01, 02 | COVERED |
| CONTEXT | D-09 | soft revoke, hidden by default, no edits | 01, 02 | COVERED |
| CONTEXT | D-10 | owner loaded fresh; reset does not invalidate | 01 | COVERED |
| CONTEXT | D-11 | last_used debounce; list columns | 01, 02 | COVERED |
| CONTEXT | D-12 | framework table in BackendAdminMigrations | 01 | COVERED |
| CONTEXT | D-13 | composite guard, Bearer only, credential | 01 | COVERED |
| CONTEXT | D-14 | scope in principal, Allows intersection | 01 | COVERED |
| CONTEXT | D-15 | resolver; Journal switch; byte-identical bodies | 01, 03 | COVERED |
| CONTEXT | D-16 | SPA panel, own tokens, show once | 02 | COVERED |
| CONTEXT | D-17 | picker overlap rule, server 422 | 01, 02 | COVERED |
| CONTEXT | D-18 | account endpoints, envelope, swag, interactive-only | 01 | COVERED |
| CONTEXT | D-19 | admin:token:* CLI | 01 | COVERED |
| CONTEXT | D-20 | READMEs and docs | 01, 02, 03 | COVERED |
| RESEARCH | Pitfall 1 | superuser shortcut | 01 | COVERED |
| RESEARCH | Pitfall 2 | non-Allows routes gated | 01 | COVERED |
| RESEARCH | Pitfall 3 | ownership branches (Journal) | 03 | COVERED |
| RESEARCH | Pitfall 4 | nil *cabana.Registry in Journal | 01, 03 | COVERED |
| RESEARCH | Pitfall 5 | migration test pins last ID | 01 | COVERED |
| RESEARCH | Pitfalls 6, 7 | CLI scope access, stdout purity | 01 | COVERED |
| RESEARCH | Pitfall 8 | 201 documented | 01 | COVERED |
| RESEARCH | Pitfall 9 | labels translated server-side | 01 | COVERED |
| RESEARCH | Pitfall 10 | reserved `account` segment | 01 | COVERED |
| RESEARCH | Pitfall 11 | Journal throttle keyed by IP (documented, unchanged) | 03 | COVERED |
| RESEARCH | Pitfall 12 | docs snippets from compiled code | 01 | COVERED |
| RESEARCH | Open Q2 | featured-image parity checked against frozen PHP | 03 | COVERED |
| RESEARCH | Open Q5 | framework tag, Journal go.mod, host bumps | 03 | COVERED |
Deferred (not gaps): backend-user management screen and superuser token view, `payment:read`, Fonoteka scopes, checksum token format.
## Artifacts this phase produces (this plan)
- pact: `TokenScope`, `HasTokenScopes`.
- bouncer: `ScopeLimit`, `Principal.Scope`.
- party: `Activated` (published by `Activate`).
- lagoon: migration `202610110001_backend_api_tokens` (table `backend_api_tokens`) in `BackendAdminMigrations`.
- cabana exported: `BackendAPIToken`, `APITokenPrefix`, `ResolveBackend`, `ErrUnauthenticated`, `ErrTokenScope`; OpenAPI types `AdminAPIToken`, `AdminAPITokenScope`, `AdminAPITokenCreated`, `AdminAPITokenRequest`; swag stubs `AdminAccountTokens`, `AdminAccountTokenCreate`, `AdminAccountTokenRevoke`, `AdminAccountTokenScopes`.
- cabana internal: `mintAPIToken`, `hashAPIToken`, `wellFormedAPIToken`, `tokenStore` (interface) and `gormTokenStore`, `compositeGuard` and `newCompositeGuard`, `scopeError`, `tokenScopeCatalog`, `compileTokenScopes`, `compileTokenCatalog`, `scopeMatch`, `allowsUnderScope`, `scopeCoversAll`, `scopeOverlaps`, `validateTokenRequest`, `issueAPIToken`, `parseTokenExpiry`, `revokeAPIToken`, `stampLastUsed`, `interactiveOnly`, `tokenRouteGate`, `backendResolver`; handlers `accountTokensList`, `accountTokenCreate`, `accountTokenRevoke`, `accountTokenScopes`; CLI funcs `adminTokenCreate`, `adminTokenList`, `adminTokenRevoke`, `adminTokenScopes`.
- Routes: `GET`/`POST /account/tokens`, `DELETE /account/tokens/{id}`, `GET /account/tokens/scopes`.
- CLI commands: `admin:token:create`, `admin:token:list`, `admin:token:revoke`, `admin:token:scopes` (app binary) and their `summer` delegates.
- Phrase keys `backend::lang.api_tokens.{scope_all, scope_all_description, scope_admin_read, scope_admin_read_description, name_required, name_too_long, expires_invalid, scopes_required, scope_unavailable}` (en, pl).
- Smoke tests: `TestTokenTracerSmoke`, `TestTokenGatesSmoke` (Docker-free); `TestTokenTracerPostgres`, `TestAccountTokensSmoke`, `TestTokenCommandsSmoke` (Docker host); `TestActivatePublishesPlugins` (party).
- Docs: `docs/examples/blog/token_scopes.go` (`blog`, `blog:read`), new sections in the five docs pages, README updates in five modules.
<objective>
Build the framework side of personal API tokens with scopes (D-01 to D-15, D-17 to D-20 server half): scope registration, the scope-limited principal and Allows, the token table and composite guard, interactive-only and route gates, the published resolver, the account token API with OpenAPI, the CLI, and the docs.
Purpose: every backend-authenticated request can run as a token's owner, limited by the token's scopes, and plugins (plan 03) and the SPA (plan 02) build on these exact names.
Output: the files above, regenerated admin OpenAPI and TS types, smoke tests, updated READMEs and docs pages.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-CONTEXT.md
@.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-PATTERNS.md
16.3-RESEARCH.md and 16.3-UI-SPEC.md are not loaded whole (context budget, see Plan sizing note): each task's read_first names the sections it needs with line ranges; read only those ranges.
Docker: the cabana auth, account, command and conformance suites, the lagoon migration suites and the Journal admin harness use testcontainers Postgres. When `docker info` fails, run the Docker-free verify lines, record every Postgres verify line as NOT RUN in the SUMMARY (they fail rather than skip by design), and plan 03's release gate lists them (16.2 pattern). Another session may edit `modules/` concurrently: stage files by explicit path and check `git status` before each commit. Commits: no co-author tags, one logical change per commit, code and planning docs in separate commits.
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer: a g15_ token with a plugin scope reaches an admin controller as its owner, and an out-of-scope controller refuses it</name>
<reversibility rating="costly">D-07 (rated one-way in CONTEXT, format fixed by the human) becomes irreversible only when issued tokens reach agent configs after the release; that door is gated by the 16.3-03 release checkpoint, which carries the one-way rating. Until then a format change is a code edit across this phase.</reversibility>
<files>modules/pact/capabilities.go, modules/bouncer/context.go, modules/lagoon/backend_admin_migrations.go, modules/lagoon/backend_admin_migrations_test.go, modules/cabana/api_tokens.go, modules/cabana/token_scopes.go, modules/cabana/token_guard.go, modules/cabana/contracts.go, modules/cabana/registry.go, modules/cabana/messages.go, modules/cabana/http.go, modules/cabana/export_test.go, modules/cabana/token_smoke_test.go, modules/cabana/token_postgres_smoke_test.go</files>
<read_first>modules/pact/capabilities.go (Permission and HasPermissions, ~:495-506), modules/bouncer/context.go, modules/bouncer/guard.go, modules/bouncer/registry.go (Middleware :73-113), modules/bouncer/jwt.go (NewBackendJWTGuard :94, Authenticate :106, extractToken :273), modules/cabana/http.go (Activate :73-185, lazyBackendUsers, writeUnauthenticated), modules/cabana/auth.go (BackendUsers.FindByID :40-69, principalFrom, logAuth :374), modules/cabana/contracts.go (Allows :186, granted :203-235, BackendUser), modules/cabana/registry.go (compileContributions :168-288, permissionCode, validatePermissions, permissionExists, rolePermissions), modules/cabana/messages.go (validateMessageKeys :300-330), modules/lagoon/backend_admin_migrations.go, modules/lagoon/backend_admin_migrations_test.go (:516-600, rollbackBackendAdmin :443-452), modules/lagoon/jsonable.go, modules/wristband/crypto.go (:26-31), modules/cabana/export_test.go, modules/cabana/auth_test.go (adminGorm :321, adminHandler :367, insertAdmin, demoPlugin :433-470), ../sm-summercmsio-app/plugins/golem15/user/middleware/api_token.go, ../sm-summercmsio-app/plugins/golem15/user/classes/api_tokens.go, 16.3-RESEARCH.md Patterns 1, 2 and 6 (:216-316, :381-415), Pitfalls 1 and 5 (:460-464, :479-483) and the Code Examples (:505-589: mint and hash, debounced last_used_at, migration entry)</read_first>
<action>Wire one path through every layer: pact registration → boot compile → migration → token row → composite guard → scope-limited principal → Allows → handler.
pact (per D-01): add `TokenScope` (Name, Label, Description, Permissions, Methods) and `HasTokenScopes` next to `Permission`/`HasPermissions`, with one-line doc comments in the same style; Label and Description are phrase keys or literal text.
bouncer (per D-14): add `ScopeLimit` (`Full bool`, `Scopes [][]string`) and the `Scope *ScopeLimit` field on `Principal` tagged `json:"-"`; document that nil means unrestricted and that only a token guard sets it, already filtered by request method. Nothing else in bouncer changes.
lagoon (per D-12): append migration `202610110001_backend_api_tokens` to `BackendAdminMigrations` with the DDL from RESEARCH "Migration entry" (CREATE TABLE IF NOT EXISTS, BIGSERIAL id, `backend_user_id INTEGER NOT NULL REFERENCES backend_users(id) ON DELETE CASCADE`, `token_hash CHAR(64) NOT NULL UNIQUE`, `token_hint TEXT NOT NULL`, `scopes TEXT NOT NULL`, nullable expires_at/last_used_at/revoked_at TIMESTAMPTZ, `revoked_by INTEGER REFERENCES backend_users(id) ON DELETE SET NULL`, created_at/updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), plus an index on backend_user_id; rollback drops the table). Extend the doc comment above `BackendAdminMigrations`. In the same commit fix `TestBackendAdminMetadataColumn` (RESEARCH Pitfall 5): assert the history ends with `202610110001_backend_api_tokens` and contains the metadata ID, and roll back both newest entries before checking that the metadata column is gone; add backend_api_tokens to the table list `TestPhase09MigrationsFreshRollback` checks after a full rollback.
cabana model and crypto (api_tokens.go, per D-07): `APITokenPrefix`, `BackendAPIToken` (gorm tags as the user plugin's model, `Scopes lagoon.Jsonable[[]string]`, `TokenHash` tagged `json:"-"`), `mintAPIToken` (20 bytes from crypto/rand, lowercase hex, sha256 hex, last four characters as hint), `hashAPIToken`, `wellFormedAPIToken` (exactly g15_ plus 40 of 0-9a-f), and a minimal internal `issueAPIToken` that inserts a row for an owner with given scopes and expiry and returns the plaintext once (Task 3 adds name/expiry/overlap validation to this same function). Add a test-only exporter in export_test.go (for example `IssueTestAPIToken`) so external Postgres tests can mint a token before the HTTP API exists.
cabana scopes (token_scopes.go, per D-01, D-03, D-04, D-05, D-14): `tokenScopeCatalog` holding the ordered compiled scopes (framework `*`: every method, full; `admin:read`: GET and HEAD, full; then plugin scopes in plugin activation order) and a by-name map; `compileTokenScopes(reg, plugins)` called in Activate right after `compileContributions`, storing the catalog on the Registry, failing boot with `cabana: …` errors for every rule in the D-01 truth above (name pattern `^[a-z][a-z0-9_-]*(:[a-z][a-z0-9_-]*)?$`, reserved `*`/`admin`/`admin:…`, duplicates naming both plugin IDs, empty lists, methods limited to GET, HEAD, POST, PUT, PATCH, DELETE in upper case and required to contain GET, permissions via `permissionCode(code, true)` and `reg.permissionExists`, non-empty label and description). Extend `validateMessageKeys` (or add a sibling called at the same place in Activate) so a label or description containing `::` must resolve in the published translator. Write `scopeMatch` exactly per the match table in this plan and `allowsUnderScope(p, required)` per the D-03 literal reading: empty list false; otherwise true when some slice in p.Scope.Scopes matches every required code (full or partial) and some required code is owner-held inside it (full match: ownerHas(code); partial: ownerHas(pattern)). Do not reuse `granted()` for the scope side.
Allows (contracts.go, per D-14 and D-05, RESEARCH Pitfall 1): when `principal.Scope != nil && !principal.Scope.Full`, return `allowsUnderScope` before the IsSuperuser and empty-list shortcuts; the full and nil paths keep today's code byte for byte. Update the doc comment.
Composite guard (token_guard.go, per D-13, D-02, D-08 to D-10): `compositeGuard` implements `bouncer.CredentialGuard` and `bouncer.UnauthorizedWriter` (compile-time assertions as in the user plugin's guard). It reads the Authorization header; only a `Bearer ` value starting with `APITokenPrefix` takes the token branch, every other request returns the wrapped JWT guard's `Authenticate` result unchanged. Token branch: refuse a value that is not well formed before touching the database; look the hash up through a small internal `tokenStore` interface (production `gormTokenStore` over the app's `*gorm.DB`: `revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now)`), compare the fetched hash with `crypto/subtle.ConstantTimeCompare`; load the owner through the same `bouncer.UserProvider` the JWT guard uses (`lazyBackendUsers{app, reg}`; nil principal → 401; tokens_valid_after is not consulted); method-filter the stored scopes through the catalog (unknown names ignored; no known scope → 401; known scopes but none allowing r.Method → a `scopeError` wrapping `ErrTokenScope`); set `principal.Scope` (Full when any allowed scope is full, else one pattern slice per allowed scope); return the token row as the credential. `WriteUnauthorized` answers 403 `forbidden` with `details.reason` token_scope for a `scopeError` and `writeUnauthenticated` (401) for anything else; say in a comment that this writer also answers 403. Log failures as `admin.auth` events with outcome and admin_id/token_id when known, never the header, plaintext or hash. Declare `ErrUnauthenticated` and `ErrTokenScope` here.
Activate (http.go): build the existing JWT guard unchanged, wrap it with `newCompositeGuard(jwtGuard, store, users, reg)` and register the composite under `backend` (owner check unchanged).
Tests (smoke only; full coverage is plan 04): `TestTokenTracerSmoke` (package cabana, Docker-free): a catalog with scopes `demo` (acme.demo.*, every method) and `demo:reports` (acme.demo.reports, GET and HEAD), an in-memory `tokenStore`, a stub `UserProvider` returning a developer principal granted acme.demo.access and acme.demo.reports, the composite guard registered in a `bouncer.Registry` and its middleware around a handler that answers 200 when `Allows(principal, ["acme.demo.access"])` and 403 otherwise: a `demo` token GET → 200; a `demo:reports` token GET → 403 (out of scope although the grants allow it); the same token POST → 403 with reason token_scope; a superuser principal with `demo:reports` → 403; a malformed `g15_x` → 401; a Bearer JWT string goes to the stub JWT guard untouched. `TestTokenTracerPostgres` (cabana_test, Docker host): the adminHandler harness with a test plugin that adds permission acme.demo.reports (developer role) and the two scopes; mint through the test exporter for a developer admin; GET the widgets list with a `demo` token → 200, with a `demo:reports` token → 403, POST with `demo:reports` → 403 token_scope; then soft-delete the admin and the `demo` token → 401.</action>
<verify>
<automated>go vet ./modules/pact ./modules/bouncer ./modules/lagoon ./modules/cabana &amp;&amp; go test -count=1 -run '^(TestTokenTracerSmoke|TestPhase09PermissionMatrix|TestPhase09ContractInventory|TestPhase10ConformanceInventory)$' -v ./modules/cabana 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 4</automated>
<fails_when>Non-zero exit: vet fails in one of the four packages, or fewer than four "--- PASS: Test" lines (the tracer smoke failed, or the existing route inventories broke).</fails_when>
<automated>docker info &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go test -count=1 -run '^TestTokenTracerPostgres$' -v ./modules/cabana 2&gt;&amp;1 | grep -- '^--- PASS: TestTokenTracerPostgres' &amp;&amp; go test -count=1 -run '^(TestBackendAdminMetadataColumn|TestPhase09MigrationsFreshRollback)$' -v ./modules/lagoon 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 2</automated>
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN in the SUMMARY), no "--- PASS: TestTokenTracerPostgres" line, or fewer than two passing lagoon migration tests (the migration or the updated last-ID test is wrong).</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "HasTokenScopes interface" modules/pact/capabilities.go` and `grep -n "Scope \*ScopeLimit" modules/bouncer/context.go` each print one line.
- `grep -c "202610110001_backend_api_tokens" modules/lagoon/backend_admin_migrations.go modules/lagoon/backend_admin_migrations_test.go` reports at least 1 for each file.
- `grep -n "allowsUnderScope" modules/cabana/contracts.go` prints a line that comes before the line containing `principal.IsSuperuser || len(required) == 0` (scope first).
- `grep -n "ConstantTimeCompare" modules/cabana/token_guard.go modules/cabana/api_tokens.go` prints at least one line.
- The Docker-free verify passes; the Postgres verify passes on a Docker host or is recorded NOT RUN.
</acceptance_criteria>
<done>A g15_ token minted for a backend user authenticates through the composite backend guard, runs as its owner and is limited by its method-filtered scopes in Allows, with the JWT path unchanged.</done>
</task>
<task type="auto">
<name>Task 2: Deny by default outside Allows: interactive-only marker, explicit route gates, the published ResolveBackend, and the debounced last_used_at stamp</name>
<reversibility rating="costly">D-13/D-14: every backend-authenticated route now passes through the composite guard and scope rules; undoing it touches every route classification.</reversibility>
<files>modules/cabana/token_guard.go, modules/cabana/api_tokens.go, modules/cabana/http.go, modules/cabana/auth.go, modules/cabana/field_type.go, modules/cabana/field_markdown.go, modules/cabana/registry.go, modules/cabana/token_smoke_test.go</files>
<read_first>modules/cabana/token_guard.go and modules/cabana/api_tokens.go (Task 1), modules/cabana/http.go (mount :222-345, Activate guard block), modules/cabana/csrf.go (requireAjax, csrfSafe), modules/cabana/auth.go (refresh :255, logout :295, sessionToken :323, me :338, bearerToken :362, adminBlacklist :395), modules/cabana/field_type.go (fieldTypesList :864), modules/cabana/field_markdown.go (markdownPreview :71), modules/cabana/registry.go (reservedVendorSegments :44, checkReservedSegments), modules/cabana/contracts.go (WriteErrorDetails), ../sm-journal-plugin/controllers/api/auth.go (the current Bearer-only guard the resolver replaces in plan 03), 16.3-RESEARCH.md Patterns 3 and 4 (:317-363), Pitfalls 2, 3, 4 and 10 (:465-478, :496-498), "Debounced last_used_at" (:535-553) and assumption A3 (:603)</read_first>
<action>Make D-05 and D-06 hold on routes that never call Allows, and publish the D-15 resolver.
Interactive-only (per D-06): `interactiveOnly(next)` answers 403 `forbidden` with `details.reason` interactive_only when the request credential is a `*BackendAPIToken`, or when the raw Authorization Bearer value starts with `APITokenPrefix` (the routes outside the guard). In mount wrap PUT /account, POST and DELETE /account/avatar (outside requireAjax, so the refusal comes first) and POST /auth/refresh and POST /auth/logout, so a g15_ Bearer never reaches refresh or logout (no cookie expiry, no blacklist write). Task 3 wraps the four token routes the same way. GET /auth/me stays unwrapped (D-05).
Route gates (per D-05, RESEARCH Pitfall 2 and assumption A3): `tokenRouteGate` refuses a principal whose Scope is non-nil and not Full with 403 `forbidden`, reason token_scope (the same answer as `Allows(principal, nil)` refusing); call it at the top of `fieldTypesList` and `markdownPreview` after the principal check. `/navigation` and `/settings` already filter every item through Allows and stay as they are.
Reserved segment (RESEARCH Pitfall 10): add `account` to `reservedVendorSegments` and to the boot error's reserved list text.
Resolver (per D-15, RESEARCH Pattern 3 and Pitfall 4): in Activate, after the backend guard is registered, build a second composite guard around a cookie-less `NewBackendJWTGuard(secret, users, bl, nil)` (same users, same blacklist, same registry and catalog) and publish it as `*backendResolver` with `app.Publish`, keeping an already-published one when Activate runs twice on one app (as the guard registration does). `ResolveBackend(app, r, cover...)`: no app, no published resolver or no Bearer value → `ErrUnauthenticated`; otherwise authenticate through the resolver's guard; a scope error → `ErrTokenScope`; any other failure → `ErrUnauthenticated`; for a token principal that is not Full, require `scopeCoversAll(principal.Scope, cover)` (some scope slice matches every cover code, full or partial) or return `ErrTokenScope`; on success return the request carrying `bouncer.WithUser` and, for a token, `bouncer.WithCredential`. The resolver never reads the admin cookie (Journal's write routes have no CSRF check). Document in the doc comment that routes registered before Activate must call it per request.
last_used_at (per D-11): `stampLastUsed` as in RESEARCH "Debounced last_used_at" (process-wide sync.Map keyed by token id, one-minute window, UPDATE of last_used_at only with the conditional WHERE), called by the composite guard after a successful token authentication on both the guard and resolver paths; an error is logged with `slog.Default().WarnContext` and token_id and never fails the request.
Smoke test `TestTokenGatesSmoke` (Docker-free, package cabana): with the Task 1 in-memory store and stub users, assert a `*` token gets 403 interactive_only from the wrapped PUT /account handler and from the refresh handler given a raw g15_ Bearer; a `demo` token gets 403 token_scope from fieldTypesList; an `admin:read` GET passes fieldTypesList; ResolveBackend on an app with a published resolver returns ErrTokenScope for a `demo:reports` POST and for cover acme.other.* , ErrUnauthenticated for no Bearer and for a malformed token, and a principal carrying the credential for a `demo` GET with cover acme.demo.*; two authentications within a minute call the store's stamp once.</action>
<verify>
<automated>go vet ./modules/cabana &amp;&amp; go test -count=1 -run '^(TestTokenTracerSmoke|TestTokenGatesSmoke|TestPhase09PermissionMatrix|TestPhase09ContractInventory|TestPhase10ConformanceInventory|TestPhase10CSRF)$' -v ./modules/cabana 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 6</automated>
<fails_when>Non-zero exit or fewer than six "--- PASS: Test" lines: a gate, the interactive-only marker, the resolver or the stamp debounce misbehaves, or a wrapper broke the route inventory or CSRF tests.</fails_when>
<automated>docker info &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go test -count=1 -run '^(TestTokenTracerPostgres|TestAdminAuthLifecycle|TestAdminLogoutRevokesExpiredRefreshableToken)$' -v ./modules/cabana 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 3</automated>
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or the tracer, the JWT login/refresh lifecycle or the logout test regressed after wrapping refresh and logout.</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "interactiveOnly(" modules/cabana/http.go` prints at least five lines (account, two avatar, refresh, logout).
- `grep -n "tokenRouteGate" modules/cabana/field_type.go modules/cabana/field_markdown.go` prints one line per file.
- `grep -n "func ResolveBackend" modules/cabana/token_guard.go` prints one line, and `grep -n "app.Publish" modules/cabana/http.go` shows a publish of the resolver.
- `grep -n '"account"' modules/cabana/registry.go` prints a line in `reservedVendorSegments`.
</acceptance_criteria>
<done>Tokens are refused on every interactive-only route, unscoped routes outside Allows are gated, plugins can resolve a backend principal (JWT or token) with a scope cover check, and token use is stamped at most once a minute.</done>
</task>
<task type="auto">
<name>Task 3: Account token API (list, create, revoke, scopes) with swag annotations, regenerated OpenAPI and TS types, route inventories and server-side phrase keys</name>
<files>modules/cabana/account_tokens.go, modules/cabana/api_tokens.go, modules/cabana/token_scopes.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/security_coverage_test.go, modules/cabana/openapi_conformance_test.go, modules/cabana/token_postgres_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</files>
<read_first>modules/cabana/account.go (accountPrincipal :216, backendMessage :246, accountUpdate :289, refusedAccountKeys :445, writeAccountError :495), modules/cabana/relation_child.go (decodeCappedObject :700), modules/cabana/field_file.go (fieldDetail :873), modules/cabana/crud.go (ValidationError :65, writeCRUDError :737), modules/cabana/http.go (translator :797), modules/cabana/form_schema.go (schemaLocale :361), modules/cabana/admin_openapi.go (header :1-12, AdminMe and AdminAccountUpdate :190-219), modules/cabana/security_coverage_test.go (phase09Routes), modules/cabana/phase09_contract_test.go, modules/cabana/openapi_conformance_test.go (conformCases :230, the /account/avatar cases :515-540, newConformEnv :765), scripts/check-admin-openapi.sh, modules/phrasebook/backend/lang/en/lang.yaml and pl/lang.yaml (myaccount block :234), 16.3-UI-SPEC.md Copywriting Contract (:131-206) and Component Contracts 3-5 (:221-281), 16.3-RESEARCH.md Pattern 5 (:364-380) and Pitfalls 8 and 9 (:490-495); for the Task 1-2 code, the as-built signatures only (see Plan sizing note, seam after Task 2)</read_first>
<action>Implement the D-16 to D-18 server half; the SPA (plan 02) relies on these exact shapes (Interfaces section of this plan).
Validation and issue (api_tokens.go, per D-08, D-17): put the rules in a pure `validateTokenRequest` (no database: catalog, owner principal, raw name, expires and scopes, the clock and a CLI-mode flag in; cleaned values and field-keyed message keys out) and grow `issueAPIToken` into the single path used by the API and the CLI that calls it and then inserts: trim the name and require 1 to 100 runes (`api_tokens.name_required`, `api_tokens.name_too_long`); `parseTokenExpiry` accepts 30d, 90d, 365d and never (absent means 90d) for the API, and additionally YYYY-MM-DD (00:00 UTC, must be after now) for the CLI (`api_tokens.expires_invalid`); scopes: empty → `api_tokens.scopes_required`; a list containing `*` becomes exactly [`*`]; duplicates removed keeping order; each name must exist in the catalog and overlap the owner (`scopeOverlaps`, the D-17 rule in this plan) or the error is `api_tokens.scope_unavailable` with :name. Return field-keyed messages the handler turns into a `ValidationError`. `revokeAPIToken(db, ownerID, id, revokedBy)` updates revoked_at and revoked_by only where id, backend_user_id and revoked_at IS NULL match and reports not found otherwise.
Handlers (account_tokens.go): `accountTokensList`, `accountTokenCreate`, `accountTokenRevoke`, `accountTokenScopes`, each starting with `accountPrincipal`, each using `s.db()` with the slog plus 500 tail of accountUpdate. Create decodes with `decodeCappedObject`, refuses keys other than name, expires and scopes as PUT /account does, answers 201 with the created row plus `token` (the only place the plaintext ever appears). List filters by the principal's id, hides revoked rows unless the query value revoked is 1 or true, orders by created_at DESC then id DESC, and maps each stored scope to `{name, label, description, read_only, known}`: labels and descriptions translated server-side in the request locale (RESEARCH Pitfall 9; `*` and `admin:read` from the api_tokens keys), read_only true when the scope's methods are only GET and HEAD, unknown names returned with known false and label equal to the name. `expired` is computed server-side. Scopes answers the picker list in catalog order filtered by `scopeOverlaps`, `*` always first. Revoke parses {id}, answers 404 `not_found` when nothing was revoked, else 200 with the row.
Mount (http.go): GET and POST /account/tokens, GET /account/tokens/scopes and DELETE /account/tokens/{id} inside the backend group next to the account routes, each wrapped in `interactiveOnly`, writes also in `requireAjax` (per D-06, D-18).
OpenAPI (admin_openapi.go): types `AdminAPIToken`, `AdminAPITokenScope`, `AdminAPITokenCreated`, `AdminAPITokenRequest` and stubs `AdminAccountTokens`, `AdminAccountTokenCreate` (documents @Success 201, RESEARCH Pitfall 8), `AdminAccountTokenRevoke`, `AdminAccountTokenScopes` in the AdminAccountUpdate style, each with @Failure 401, 403 (interactive_only) and 422/404 as they apply and a @Description naming the interactive-only rule. Change the BackendBearer description to say the header carries an admin access token or a personal API token (g15_…), and that account and token routes refuse personal tokens. Run `bash scripts/check-admin-openapi.sh` to regenerate admin/openapi/admin.json and admin/src/api/schema.d.ts and commit both with the routes (TestPhase09ContractInventory reads the committed document).
Inventories: add the four route keys to `phase09Routes` and four `conformCases` (list after a create, create with scopes [`*`] expecting 201, revoke of a token created in the case, scopes) using the env's JWT.
Phrase keys (per UI-SPEC copy): add an `api_tokens:` block next to `myaccount:` in en and pl with the nine server-side keys from the Interfaces section, Polish first as the design language and the UI-SPEC wording where it names the key.
Smoke test `TestAccountTokensSmoke` (Postgres, cabana_test, adminHandler harness): log in a developer admin, GET /account/tokens/scopes includes `*` and `demo`, POST creates a `demo` token (201, token matches g15_ plus 40 hex, no token_hash key in the body), GET lists it with hint equal to the token's last four characters and no token key, the token itself is refused on GET /account/tokens with 403 interactive_only, DELETE revokes it (200) and a second DELETE is 404, and the revoked token now answers 401 on the widgets list.</action>
<verify>
<automated>go vet ./modules/cabana &amp;&amp; go test -count=1 -run '^(TestPhase09PermissionMatrix|TestPhase09ContractInventory|TestPhase10ConformanceInventory|TestTokenTracerSmoke|TestTokenGatesSmoke)$' -v ./modules/cabana 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 5 &amp;&amp; bash scripts/check-admin-openapi.sh --check &amp;&amp; npm --prefix admin run typecheck</automated>
<fails_when>Non-zero exit: fewer than five "--- PASS: Test" lines (a new route is missing from phase09Routes, the committed OpenAPI or conformCases), check-admin-openapi reports admin.json or schema.d.ts stale, or the SPA no longer typechecks against the regenerated types.</fails_when>
<automated>docker info &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go test -count=1 -run '^(TestAccountTokensSmoke|TestPhase10OpenAPIConformance)$' -v ./modules/cabana 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 2</automated>
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), the account token smoke fails (plaintext leaked into a list, a token reached a token route, revoke not soft or not own-only), or a documented response schema does not match a real response.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c '"/account/tokens' admin/openapi/admin.json` reports at least 1, and `grep -n '"201"' admin/openapi/admin.json` includes the create operation.
- `grep -n "account/tokens" modules/cabana/security_coverage_test.go modules/cabana/openapi_conformance_test.go` prints four route lines in each file.
- `grep -c "api_tokens:" modules/phrasebook/backend/lang/en/lang.yaml modules/phrasebook/backend/lang/pl/lang.yaml` reports 1 for each file.
- In modules/cabana/account_tokens.go the only JSON key carrying the plaintext is set in the create handler: `grep -n '"token"' modules/cabana/account_tokens.go modules/cabana/admin_openapi.go` lines all belong to the create path or the AdminAPITokenCreated type.
</acceptance_criteria>
<done>A signed-in admin can list, create (plaintext once), revoke and pick scopes through the documented admin API, and the OpenAPI document, TS types and inventories cover the four routes.</done>
</task>
<task type="auto">
<name>Task 4: admin:token:* CLI with party.Activated, summer delegates, and the framework READMEs and docs pages</name>
<files>modules/party/registry.go, modules/party/registry_test.go, modules/party/README.md, modules/cabana/token_commands.go, modules/cabana/commands.go, modules/cabana/token_scopes.go, modules/cabana/registry.go, modules/cabana/token_postgres_smoke_test.go, modules/cabana/README.md, modules/pact/README.md, modules/bouncer/README.md, modules/lagoon/README.md, cmd/summer/runtime.go, cmd/summer/runtime_test.go, docs/examples/blog/token_scopes.go, docs/examples/blog/lang/en/lang.yaml, docs/backend/users-and-permissions.md, docs/services/authentication.md, docs/console/setup-and-maintenance.md, docs/console/introduction.md, docs/plugins/registration.md</files>
<read_first>modules/party/registry.go (activate, SetPlugins :107), modules/backpack/services.go (Publish duplicate rule), modules/cabana/commands.go (RuntimeCommands :18, adminCreate, adminResetPassword, flagValue :192, withAdminDB :200), modules/cabana/auth.go (findBackendLogin :417), modules/bonfire/command.go (Flag.Repeatable, Input.Flags), modules/bonfire/output.go (Println, Table), modules/bonfire/root.go, cmd/summer/runtime.go (delegateCommands), cmd/summer/runtime_test.go (TestDelegateCommandsHelp count 11), cmd/summer/docs.go (docsCommands), internal/build/build.go (generated main calls cabana.RuntimeCommands(app)), modules/cabana/commands_test.go (TestAdminCreateCommand, commandApp, flagInput), docs/examples/blog/plugin.go and lang/en/lang.yaml, docs/backend/users-and-permissions.md, docs/services/authentication.md, docs/console/setup-and-maintenance.md (Admin accounts), docs/console/introduction.md (delegate table), docs/plugins/registration.md (capability table), modules/pact/README.md, modules/bouncer/README.md, modules/cabana/README.md (Features, Admin API routes, API reference, CLI commands), modules/party/README.md, modules/lagoon/README.md (:129), CLAUDE.md Documentation section, 16.3-RESEARCH.md Pattern 7 (:416-433) and Pitfalls 6, 7 and 12 (:484-489, :502-504)</read_first>
<action>party (user-confirmed Option A, per D-19): declare `Activated` as the ordered `[]Plugin` and publish it with `app.Publish` right after `app.SetPlugins(orderedIDs)` (an error is returned as `party: …`); doc comment says commands read it at Run time. Smoke test `TestActivatePublishesPlugins` in modules/party: after activate, `app.Lookup[Activated]()` returns the plugins in activation order.
Catalog for the CLI (token_scopes.go, registry.go): extract the permission and role-grant loop of `compileContributions` into a helper both paths call, and add `compileTokenCatalog(plugins)` returning a Registry holding only permissions, role grants and the token scope catalog (no controllers, settings or navigation, so plugins with navigation compile).
Commands (token_commands.go; registered by RuntimeCommands, signature unchanged; constructors only capture app so docsCommands keeps working): `admin:token:create` (Arg identifier required; Flags name, scope Repeatable, expires default 90d) resolves the user with `findBackendLogin` (ambiguity rule unchanged), refuses deactivated or soft-deleted users, loads the owner principal with `BackendUsers{DB, Registry: catalog registry}.FindByID` so code-declared roles count, calls `issueAPIToken` (same rules as the API, D-17), and on success writes exactly the token and a newline with `out.Println` and nothing else on stdout (RESEARCH Pitfall 7; errors are returned, never printed). `admin:token:list` (optional identifier, Bare flag revoked) prints a `Table` with id, login, name, scopes, hint as g15_…last4, created, last used, expires and revoked, never the hash or plaintext. `admin:token:revoke` (Arg id) revokes any user's active token with revoked_by NULL; not found is an error, an already revoked token prints that it was already revoked and exits 0. `admin:token:scopes` prints name, label (translated with the app translator when published, otherwise the key), read-only and permissions. When `party.Activated` is not published the commands use the framework scopes only and say so on stderr-safe paths (create and scopes return an error naming the unknown scope as usual). Smoke test `TestTokenCommandsSmoke` (Postgres, cabana_test, commandApp plus a published party.Activated holding the demo test plugin): create prints exactly one line matching g15_ plus 40 hex, list shows the hint and not the token, revoke works, scopes lists `*`, admin:read and demo.
summer (cmd/summer/runtime.go): four delegate rows for admin:token:create (hint: identifier, --name, --scope repeatable, --expires), admin:token:list, admin:token:revoke and admin:token:scopes; update `TestDelegateCommandsHelp` to 15 commands and add hints for admin:token:create.
Docs example: docs/examples/blog/token_scopes.go with the compile-time assertion for `pact.HasTokenScopes` and `TokenScopes()` returning `blog` (acme.blog.*, GET, HEAD, POST, PUT, PATCH, DELETE) and `blog:read` (acme.blog.*, GET, HEAD) with phrase keys acme.blog::lang.scopes.*, and those keys in docs/examples/blog/lang/en/lang.yaml (the example activates through cabana, which now validates them).
READMEs (CLAUDE.md, same change, neutral names): pact (Features line and API reference rows for TokenScope and HasTokenScopes), bouncer (ScopeLimit, Principal.Scope), cabana (Features: composite backend guard, personal API tokens, scopes and framework scopes, interactive-only routes, route gates; Admin API routes: the four account token routes; API reference: BackendAPIToken, APITokenPrefix, ResolveBackend, ErrUnauthenticated, ErrTokenScope and the four OpenAPI types; CLI commands table: the four admin:token commands with a pipe example `TOKEN=$(./bin/acme admin:token:create admin@example.com --name agent --scope blog)`), party (Activated in Features and API reference), lagoon (the BackendAdminMigrations row names backend_api_tokens).
docs/ pages: users-and-permissions.md gets an "API tokens" section (format and storage, scopes and the framework scopes, owner ∩ scope with the multi-code rule, deny by default and the gated routes, interactive-only routes, expiry and revoke, the four account endpoints, a `src=docs/examples/blog/token_scopes.go#Plugin.TokenScopes` fence and the boot rules); authentication.md describes the composite backend guard under "Guards and middleware" and a subsection on accepting admin tokens on plugin routes with ResolveBackend (Bearer only, cover codes, the two sentinel errors); setup-and-maintenance.md adds the four commands to "Admin accounts"; introduction.md adds the four delegate rows; registration.md adds a capability row for `pact.HasTokenScopes`. Run `go run ./cmd/summer docs:sync` if a src= fence changed, then the checks.</action>
<verify>
<automated>go vet ./... &amp;&amp; go test -count=1 ./modules/party ./modules/pact &amp;&amp; go test -count=1 -run '^(TestDocsTree|TestDelegateCommandsHelp|TestDocsCommandsMirrorGeneratedMain)$' -v ./cmd/summer 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 3 &amp;&amp; go run ./cmd/summer docs:build --check &amp;&amp; go test -short -count=1 -run '^(TestPluginActivates|TestRoutesRegistered)$' ./docs/examples/blog</automated>
<fails_when>Non-zero exit: vet fails anywhere, the party or pact package fails (Activated not published), fewer than three passing cmd/summer tests (a docs identifier or command name is unknown, a delegate row or its hint is missing), docs:build --check reports a problem, or the blog example no longer activates (a scope label key is missing or a scope rule is broken).</fails_when>
<automated>docker info &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go test -count=1 -run '^(TestTokenCommandsSmoke|TestAdminCreateCommand)$' -v ./modules/cabana 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 2</automated>
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), create printed anything besides the token line, or the existing admin:create test regressed.</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "func RuntimeCommands(app \*backpack.App) \[\]bonfire.Command" modules/cabana/commands.go` prints one line (signature unchanged).
- `grep -c "admin:token:" cmd/summer/runtime.go` reports 4 and `grep -n "want 15" cmd/summer/runtime_test.go` prints one line.
- `grep -l "HasTokenScopes" modules/pact/README.md docs/backend/users-and-permissions.md docs/plugins/registration.md` lists all three files; `grep -l "ResolveBackend" modules/cabana/README.md docs/services/authentication.md` lists both; `grep -l "ScopeLimit" modules/bouncer/README.md` and `grep -l "Activated" modules/party/README.md` each list their file; `grep -c "admin:token:" modules/cabana/README.md docs/console/setup-and-maintenance.md` reports at least 4 for each.
- `grep -ciE "fonoteka|p[lł][yý]tarium|grzyby" docs/backend/users-and-permissions.md docs/services/authentication.md modules/cabana/README.md` reports 0 for each file.
</acceptance_criteria>
<done>Operators manage any user's tokens from the app binary (and through summer), plugin scopes are visible to the CLI without a host main change, and every new exported identifier, route and command is documented in its module README and the docs pages.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → admin API | Authorization header (JWT or g15_ token) and cookie cross here; untrusted |
| client → plugin routes via ResolveBackend | Bearer value only; plugin routes have no CSRF check |
| plugin code → cabana boot | plugins declare scopes and permissions compiled at boot |
| operator → CLI | runs with database access; may mint for any user |
| database → process | token rows; only hashes stored |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-16.3-01 | Spoofing | compositeGuard token branch | high | mitigate | 160-bit crypto/rand secret; wellFormedAPIToken refuses anything but g15_ plus 40 lowercase hex before the database; lookup by unique sha256 then crypto/subtle compare; expired, revoked and dead-owner tokens are 401 |
| T-16.3-02 | Information Disclosure | backend_api_tokens | medium | mitigate | Only sha256 hex and a four-character hint are stored; TokenHash is json:"-"; list and CLI outputs never carry hash or plaintext |
| T-16.3-03 | Information Disclosure | create response, logs | high | mitigate | Plaintext only in the 201 create body and the CLI stdout line; admin.auth logs carry outcome, admin_id and token_id only, never the Authorization value |
| T-16.3-04 | Elevation of Privilege | cabana.Allows | high | mitigate | Scope branch before the IsSuperuser and empty-list shortcuts; per-scope every-code match plus owner-held code; superuser limited by the scope; smoke tests cover superuser and out-of-scope cases |
| T-16.3-05 | Elevation of Privilege | account, token, refresh, logout routes | high | mitigate | interactiveOnly refuses any token credential or raw g15_ Bearer, `*` included, with 403 interactive_only before requireAjax and the handler |
| T-16.3-06 | Spoofing / Tampering (CSRF) | token transport, ResolveBackend | high | mitigate | Token branch reads only the Authorization Bearer header; cookie values never reach it; ResolveBackend wraps a cookie-less JWT guard so plugin write routes cannot be driven by the admin cookie |
| T-16.3-07 | Elevation of Privilege | owner loading | medium | mitigate | lazyBackendUsers.FindByID per request: deactivated or soft-deleted owners fail with 401; code-declared role grants come from the compiled registry |
| T-16.3-08 | Elevation of Privilege | issueAPIToken (API and CLI) | medium | mitigate | Scope names validated against the catalog and the overlap rule; unknown stored names grant nothing; scopes immutable after creation |
| T-16.3-09 | Denial of Service | last_used_at writes | low | mitigate | Per-token one-minute in-process gate plus conditional UPDATE; stamp failures never fail auth |
| T-16.3-10 | Denial of Service | invalid token floods | low | accept | Format check precedes an indexed unique lookup; 160-bit secrets are not guessable; no new throttle bucket (the admin API has none beyond login) |
| T-16.3-11 | Tampering | scope registration | medium | mitigate | Boot fails on duplicate, reserved (`*`, admin, admin:…) or invalid names, unknown permissions or methods, missing GET, unresolved phrase keys |
| T-16.3-12 | Repudiation | revoke | low | mitigate | Soft revoke keeps revoked_at and revoked_by (NULL for the CLI); denials logged as admin.auth events |
| T-16.3-13 | Elevation of Privilege | routes outside Allows | high | mitigate | tokenRouteGate on /field-types and /markdown/preview; /navigation and /settings filter per item through Allows; plan 04 classifies every mounted route in a test table |
| T-16.3-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package is installed; swag runs pinned at v1.16.6 through the existing scripts/check-admin-openapi.sh; openapi-typescript comes from the committed admin lockfile |
</threat_model>
<verification>
- Task 1: tracer smoke and inventories pass; Postgres tracer and lagoon migration tests pass on a Docker host.
- Task 2: gates smoke, CSRF and inventories pass; JWT lifecycle tests still pass on a Docker host.
- Task 3: inventories, OpenAPI drift gate and SPA typecheck pass; account token smoke and OpenAPI conformance pass on a Docker host.
- Task 4: go vet ./..., party, pact, TestDocsTree, delegate help, docs:build --check and the blog example pass; command smoke passes on a Docker host.
- Every Postgres line not run here is listed NOT RUN in the SUMMARY.
</verification>
<success_criteria>
- SC-1 and SC-2 hold at the framework level: tokens authenticate as their owner and never exceed the owner or the scope, with deny-by-default outside Allows.
- SC-3 server half: account token API and CLI work; SC-5 framework half: READMEs and docs updated in the same plan.
- No new dependency; JWT behaviour unchanged.
</success_criteria>
<output>
Create `.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-01-SUMMARY.md` when done, with the commit list, the exported API as built, any name that differs from this plan, and the Postgres tests run or NOT RUN.
</output>

View File

@@ -0,0 +1,189 @@
---
phase: 16.3-backend-admin-personal-api-tokens-with-scopes
plan: 01
subsystem: auth
tags: [api-tokens, scopes, cabana, bouncer, pact, party, lagoon, admin-cli, openapi, g15]
requires:
- phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
provides: cabana Activate, admin OpenAPI inventories, BackendAdminMigrations, My account routes
provides:
- "pact.TokenScope and pact.HasTokenScopes"
- "bouncer.ScopeLimit and Principal.Scope"
- "backend_api_tokens migration and cabana.BackendAPIToken / APITokenPrefix"
- "composite backend guard, interactiveOnly, tokenRouteGate, ResolveBackend, ErrUnauthenticated, ErrTokenScope"
- "account token API (list/create/revoke/scopes) with OpenAPI and phrase keys"
- "admin:token:* CLI via party.Activated and summer delegates"
- "framework READMEs and docs for tokens, scopes, ResolveBackend and CLI"
affects: [16.3-02, 16.3-03, 16.3-04, sm-journal-plugin]
actuals:
tokens: 40262
tasks: 4
commits: 5
plan_head_before: 4d107f4ccb3ed00bf12b680b07c2e7874b345198
plan_head_after: e24e03c47d84f854b6c66c1b317940492b60f441
tech-stack:
added: []
patterns:
- "Composite backend guard: Bearer g15_ → token branch; else unchanged JWT+cookie path"
- "party.Activated published at Activate for CLI catalog without changing RuntimeCommands(app)"
- "Scope before superuser/empty-list in Allows; interactive_only and token_scope reasons"
key-files:
created:
- modules/cabana/api_tokens.go
- modules/cabana/token_scopes.go
- modules/cabana/token_guard.go
- modules/cabana/account_tokens.go
- modules/cabana/token_commands.go
- modules/cabana/token_smoke_test.go
- modules/cabana/token_postgres_smoke_test.go
- docs/examples/blog/token_scopes.go
modified:
- modules/pact/capabilities.go
- modules/bouncer/context.go
- modules/lagoon/backend_admin_migrations.go
- modules/cabana/contracts.go
- modules/cabana/http.go
- modules/cabana/registry.go
- modules/cabana/admin_openapi.go
- modules/party/registry.go
- cmd/summer/runtime.go
- admin/openapi/admin.json
- admin/src/api/schema.d.ts
- modules/phrasebook/backend/lang/en/lang.yaml
- modules/phrasebook/backend/lang/pl/lang.yaml
- docs/backend/users-and-permissions.md
- docs/services/authentication.md
- docs/console/setup-and-maintenance.md
- docs/console/introduction.md
- docs/plugins/registration.md
key-decisions:
- "D-03 literal: one method-allowed scope must match every required code; ScopeLimit keeps per-scope pattern slices"
- "CLI uses party.Activated at Run time; cabana.RuntimeCommands(app) signature unchanged (Option A)"
- "ResolveBackend is Bearer-only (cookie-less JWT guard) so plugin writes cannot be CSRF-driven by summer_admin"
- "Exported names match the plan Interfaces section; no as-built renames"
patterns-established:
- "Personal API token: g15_+40 hex, sha256+hint stored, plaintext once on create/CLI stdout"
- "Plugin scopes via HasTokenScopes; framework * and admin:read; boot fails loud on bad registration"
requirements-completed: [SC-1, SC-2, SC-3, SC-5]
coverage:
- id: D1
description: "g15_ token authenticates through the composite backend guard as its owner, scoped by Allows"
requirement: SC-1
verification:
- kind: unit
ref: modules/cabana/token_smoke_test.go#TestTokenTracerSmoke
status: pass
- kind: integration
ref: modules/cabana/token_postgres_smoke_test.go#TestTokenTracerPostgres
status: pass
human_judgment: false
- id: D2
description: "Out-of-scope and non-Allows routes refuse tokens; interactive-only routes refuse all personal tokens; ResolveBackend and last_used debounce work"
requirement: SC-2
verification:
- kind: unit
ref: modules/cabana/token_smoke_test.go#TestTokenGatesSmoke
status: pass
human_judgment: false
- id: D3
description: "Account token API list/create/revoke/scopes with OpenAPI, inventories and phrase keys"
requirement: SC-3
verification:
- kind: integration
ref: modules/cabana/token_postgres_smoke_test.go#TestAccountTokensSmoke
status: pass
- kind: other
ref: bash scripts/check-admin-openapi.sh --check
status: pass
human_judgment: false
- id: D4
description: "admin:token:* CLI with party.Activated, summer delegates, READMEs and docs"
requirement: SC-5
verification:
- kind: integration
ref: modules/cabana/token_postgres_smoke_test.go#TestTokenCommandsSmoke
status: pass
- kind: unit
ref: cmd/summer/runtime_test.go#TestDelegateCommandsHelp
status: pass
- kind: other
ref: go run ./cmd/summer docs:build --check
status: pass
human_judgment: false
duration: 95min
completed: 2026-10-11
status: complete
---
# Phase 16.3 Plan 01: Framework personal API tokens Summary
**Scoped g15_ personal API tokens through a composite backend guard, account API, admin:token CLI and docs — owner ∩ scope, interactive-only gates, ResolveBackend for plugins.**
## Performance
- **Duration:** 95 min
- **Started:** 2026-10-10T23:41:00Z
- **Completed:** 2026-10-11T00:00:00Z
- **Tasks:** 4
- **Files modified:** 43
## Accomplishments
- Tracer path: mint → hash store → composite guard → ScopeLimit → Allows (scope before superuser)
- Deny-by-default gates: interactiveOnly, field-types/markdown tokenRouteGate, published ResolveBackend, debounced last_used_at
- Account token API with OpenAPI/TS types, inventories and backend::lang.api_tokens phrase keys
- admin:token:* CLI via party.Activated, summer delegates, blog HasTokenScopes example, five module READMEs and five docs pages
## Task Commits
Each task was committed atomically:
1. **Task 1: Tracer** - `b9273c1` (feat)
2. **Task 2: Gates, ResolveBackend, stamp** - `183583e` (feat)
3. **Task 3: Account token API + OpenAPI** - `ffdb5d0` (feat)
4. **Task 4: CLI, party.Activated, READMEs/docs** - `e24e03c` (feat)
**Plan metadata:** (this commit)
## Exported API as built
Matches the plan Interfaces section: `pact.TokenScope` / `HasTokenScopes`; `bouncer.ScopeLimit` / `Principal.Scope`; `party.Activated`; `cabana.BackendAPIToken`, `APITokenPrefix`, `ResolveBackend`, `ErrUnauthenticated`, `ErrTokenScope`; OpenAPI types and swag stubs; CLI `admin:token:create|list|revoke|scopes`. No name differs from the plan.
## Decisions Made
- Followed locked D-01–D-20 and Option A for CLI plugin access (`party.Activated`).
- `validateTokenRequest` + `validateTokenScopes` helper; `compilePermissionCatalog` shared by `compileContributions` and `compileTokenCatalog`.
## Deviations from Plan
None - plan executed as written.
## Issues Encountered
None
## Postgres verify status
All Docker-host Postgres lines from this plan were **RUN** and passed on this host:
- Task 1: `TestTokenTracerPostgres`, `TestBackendAdminMetadataColumn`, `TestPhase09MigrationsFreshRollback`
- Task 2: `TestTokenTracerPostgres`, `TestAdminAuthLifecycle`, `TestAdminLogoutRevokesExpiredRefreshableToken`
- Task 3: `TestAccountTokensSmoke`, `TestPhase10OpenAPIConformance`
- Task 4: `TestTokenCommandsSmoke`, `TestAdminCreateCommand`
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
Ready for 16.3-02 (admin SPA API tokens card on My account). Framework half of SC-1/SC-2/SC-3/SC-5 is in place for Journal (16.3-03) and unit tests (16.3-04).
---
*Phase: 16.3-backend-admin-personal-api-tokens-with-scopes*
*Completed: 2026-10-11*

View File

@@ -0,0 +1,245 @@
---
phase: 16.3-backend-admin-personal-api-tokens-with-scopes
plan: 02
type: execute
wave: 2
depends_on: ["16.3-01"]
files_modified:
- admin/src/components/account/ApiTokensPanel.vue
- admin/src/components/account/ApiTokenCreateDialog.vue
- admin/src/views/AccountView.vue
- admin/src/components/ui/ConfirmDialog.vue
- admin/tests/account/ApiTokensPanel.test.ts
- admin/tests/views/AccountView.test.ts
- modules/phrasebook/backend/lang/en/lang.yaml
- modules/phrasebook/backend/lang/pl/lang.yaml
- modules/boardwalk/dist
- docs/backend/users-and-permissions.md
- docs/backend/admin-spa.md
autonomous: true
requirements: [SC-3, SC-5]
estimate:
tokens: 95000
raw_tokens: 95000
tasks: 3
confidence: low
must_haves:
truths:
- "D-16: the My account page (/account) shows an API tokens card after the profile and password form and outside it, listing, creating and revoking only the signed-in admin's own tokens; the page's footer Save never sends token data; there is no view of other users' tokens."
- "D-11 and UI-SPEC §3: each row shows the name (wrapped, never truncated), the mono hint g15_…last4 with the aria-label api_tokens.hint, one chip per stored scope in stored order (`*` labelled Full access, an unknown scope as a dashed mono chip with the unknown_scope_hint title), and the meta line created · last used or never used · expires, expired or no expiry (· revoked for revoked rows); rows are newest first; Revoke token shows on active and expired rows and never on revoked rows."
- "UI-SPEC §7: the list shows three skeleton rows with aria-busy while loading, the dashed empty block (api_tokens.empty_title and empty_body) when there are no tokens, and the api_tokens.load_failed alert block (role=alert) with the toggle hidden when the load fails; Create token is disabled only during the first load."
- "D-09: the Show revoked tokens checkbox reloads the list with revoked rows (muted, Revoked chip, no revoke button); its state lives only in the component."
- "D-08 and D-17 (dialog step 1): the create dialog has a name input (maxlength 100), the expiry segmented control 30d, 90d, 365d, never with 90d preselected and a local-date preview line, and a scope picker rendered from GET /account/tokens/scopes in server order with label, mono name, Read-only chip for GET/HEAD-only scopes and description; checking `*` unchecks and disables every other scope, shows api_tokens.scope_all_includes and sends exactly [\"*\"]; no scope is preselected; 422 messages render under the field with aria-invalid and focus moves to the first invalid control; while busy the fieldset, Discard token, close, Esc and overlay are inert and a second submit does nothing; a non-422 failure shows the create_failed toast and keeps the input."
- "D-16 (dialog step 2): a successful create swaps the same dialog to the show-once step: the bg-sel note api_tokens.created_notice, the full plaintext in a read-only mono input that selects on focus, Copy token with a two-second Copied state, and Done, hide token; overlay clicks never close step 2; the list reloads in the background."
- "UI-SPEC §6: Revoke token asks the shared ConfirmDialog (danger, api_tokens.revoke_confirm with the name, confirm label api_tokens.revoke_submit) and runs DELETE /account/tokens/{id} as the confirm action; success reloads the list and shows the revoked toast; 404 shows api_tokens.gone and reloads; other failures show api_tokens.revoke_failed and keep the row; ConfirmDialog's description wraps long names ([overflow-wrap:anywhere])."
- "UI-SPEC copy: every string comes from backend::lang.api_tokens.* (en and pl) or existing keys; myaccount.menu_description mentions API tokens; modules/boardwalk/dist is rebuilt and check-admin-dist.sh passes."
- statement: "Plaintext never retained: after Done, hide token and after an Esc-confirm close, the plaintext is absent from the DOM and from the panel and dialog state, reopening starts at step 1, and no toast text contains the token."
verification: backstop
- statement: "Copy and live-region flow: with a stubbed navigator.clipboard, a successful copy shows the copied state and announces api_tokens.copied_announce in a polite live region; a rejected writeText focuses and selects the input and shows api_tokens.copy_failed (role=alert); a manual copy event on the input counts as copied."
verification: backstop
- statement: "Close-without-copy guard: Esc or the close button on step 2 before any copy opens the danger confirm api_tokens.close_uncopied_confirm and cancel keeps the dialog on step 2; after a copy the dialog closes at once."
verification: backstop
artifacts:
- path: "admin/src/components/account/ApiTokensPanel.vue"
provides: "the API tokens card: list, states, toggle, revoke"
contains: "data-api-tokens"
- path: "admin/src/components/account/ApiTokenCreateDialog.vue"
provides: "two-step create dialog with show-once plaintext"
contains: "data-token-plaintext"
- path: "admin/tests/account/ApiTokensPanel.test.ts"
provides: "panel and dialog smoke tests (plan 04 completes coverage)"
contains: "ApiTokensPanel"
key_links:
- from: "admin/src/views/AccountView.vue"
to: "admin/src/components/account/ApiTokensPanel.vue"
via: "<ApiTokensPanel :confirm=\"confirm\" /> after the form, sharing the view's useConfirm() and its single ConfirmDialog"
pattern: "ApiTokensPanel"
- from: "admin/src/components/account/ApiTokensPanel.vue"
to: "GET/DELETE {api}/account/tokens"
via: "typed openapi-fetch calls from admin/src/api/client.ts using the plan 01 schema.d.ts"
pattern: "/account/tokens"
- from: "admin/src/components/account/ApiTokenCreateDialog.vue"
to: "GET /account/tokens/scopes and POST /account/tokens"
via: "typed api calls; the 201 data.token lives only in the dialog's local state"
pattern: "/account/tokens/scopes"
prohibitions:
- statement: "The plaintext is never written to localStorage, sessionStorage, the URL, router state, the console, a toast or the list rows."
status: unresolved
verification: judgment
- statement: "Token names, scope labels and descriptions render through text interpolation only; no v-html."
status: unresolved
verification: judgment
- statement: "No new npm dependency and no new design token, colour, font, radius or shadow (UI-SPEC)."
status: unresolved
verification: judgment
---
## Phase Goal
ROADMAP Goal (not in user-story format): backend users create personal API tokens with scopes picked at creation time, so an agent or integration calls the admin and plugin APIs as that backend user with no more access than the token's scope and the user's own permissions allow.
This plan's slice (user checkpoint, plan 2 of 4): the admin SPA "API tokens" card on My account per 16.3-UI-SPEC.md, against the account token API and the regenerated TS types plan 01 committed, with the remaining `api_tokens.*` copy, the committed dist and the My account docs.
## Spec-less probe fallback
Visible skip: "spec-less probe fallback skipped: phase has no requirement IDs to probe". The UI-SPEC `## UI Considerations` table is lifted into must_haves: covered rows as truths, the three 🧪 backstop rows as `verification: backstop` truths, dismissed rows omitted. No unresolved rows exist.
## Decisions taken in this plan
- Component split (UI-SPEC planner's choice): `ApiTokensPanel.vue` (card, toolbar, list, revoke) and `ApiTokenCreateDialog.vue` (both steps, owns the plaintext). The panel receives the view's `useConfirm()` instance as a `confirm` prop; the dialog uses the same instance for the close-without-copy confirm (ConfirmDialog renders at z-[90], above the dialog's z-50).
- The hint is rendered as `g15_…` plus the API's `hint` field (last four characters, plan 01 contract). Labels and the known flag come from each list row's scope entries (server-translated), so no client-side scope catalog is needed.
- Dates reuse the list datetime cell's formatting (`CellValue.vue` / `admin/src/app/dateFormat.ts`): created, expires and expired as YYYY-MM-DD, last used and revoked as YYYY-MM-DD HH:mm, local time, tabular-nums. No new date format.
- `expired` from the API decides the Expired chip (server clock), so client clock skew cannot mislabel a row.
- Test location: `admin/tests/account/` (mirrors `src/components/account/`; `tests/components/` does not exist); 16.3-VALIDATION.md is updated to this path.
- OpenAPI types were regenerated in plan 01 (the contract inventory test reads the committed document); this plan only re-checks them with `check-admin-openapi.sh --check`.
## Artifacts this phase produces (this plan)
- `admin/src/components/account/ApiTokensPanel.vue` (data attributes `data-api-tokens`, `data-token-list`, `data-token-row`, `data-token-scopes`, `data-tokens-empty`, `data-tokens-error`, `data-show-revoked`, `data-action="create-token"`, `data-action="revoke-token"`).
- `admin/src/components/account/ApiTokenCreateDialog.vue` (`data-token-dialog`, `data-scope-picker`, `data-scope`, `data-token-notice`, `data-token-plaintext`, `data-action` values `create-token-submit`, `create-token-cancel`, `copy-token`, `hide-token`).
- `admin/tests/account/ApiTokensPanel.test.ts` (smoke: `tracer: list, create, show once, hide`; list states; dialog rules).
- SPA phrase keys `backend::lang.api_tokens.*` from the UI-SPEC Copywriting Contract not added by plan 01 (section, section_comment, create, create_submit, creating, done, empty_title, empty_body, load_failed, scopes_failed, create_failed, revoke_failed, gone, copy_failed, revoke, revoke_named, revoke_confirm, revoke_submit, revoked, close_uncopied_confirm, close_uncopied_submit, create_title, create_description, name, name_placeholder, name_comment, expires, expires_30, expires_90, expires_365, expires_never, expires_on, expires_never_note, scopes, read_only, scope_all_includes, created_title, created_notice, token_label, copy, copied, copied_announce, create_cancel, show_revoked, created_at, last_used, never_used, expires_at, expired_at, no_expiry, revoked_at, state_expired, state_revoked, unknown_scope, unknown_scope_hint, hint) and the changed `myaccount.menu_description`, en and pl.
- One-class change to `ConfirmDialog.vue` (`[overflow-wrap:anywhere]` on the description).
- Rebuilt `modules/boardwalk/dist`.
- My account docs: the API tokens panel in docs/backend/users-and-permissions.md and docs/backend/admin-spa.md.
<objective>
Ship the API tokens card on My account (D-16, D-17, D-08, D-09, D-11) exactly as 16.3-UI-SPEC.md describes: list with all states, two-step create with show-once plaintext and copy, revoke through the shared confirm, and the copy in both languages.
Purpose: SC-3, a backend user manages their own tokens in the admin SPA and sees the plaintext once.
Output: two components, the AccountView wiring, phrase keys, smoke tests, rebuilt dist, docs.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-UI-SPEC.md
@.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-CONTEXT.md
@.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-01-SUMMARY.md
Plan 01 contract (Interfaces section of 16.3-01-PLAN.md): `GET /account/tokens[?revoked=1]` → `AdminAPIToken[]`; `POST /account/tokens` `{name, expires, scopes}` → 201 `AdminAPITokenCreated` (row plus `token`); `DELETE /account/tokens/{id}` → row, 404 not_found; `GET /account/tokens/scopes` → `AdminAPITokenScope[]` (`name`, `label`, `description`, `read_only`, `known`). 422 details keyed `name`, `expires`, `scopes` with server messages. Commits: no co-author tags; stage by explicit path; rebuild and commit `modules/boardwalk/dist` with each SPA change (`npm --prefix admin run build`).
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer: on /account an admin creates a scoped token in the dialog, sees the plaintext once, hides it, and the new row is in the list</name>
<files>admin/src/components/account/ApiTokensPanel.vue, admin/src/components/account/ApiTokenCreateDialog.vue, admin/src/views/AccountView.vue, admin/tests/account/ApiTokensPanel.test.ts, admin/tests/views/AccountView.test.ts, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, modules/boardwalk/dist</files>
<read_first>16.3-UI-SPEC.md (Component Contracts 1-5, Copywriting Contract), admin/src/views/AccountView.vue (imports :2-14, confirm :92, card markup :247-262, form end and ConfirmDialog :395-420), admin/src/components/relation/RelationPickerModal.vue (Dialog shell :3-11 and :355-380), admin/src/components/ui/Button.vue, admin/src/components/ui/confirm.ts, admin/src/components/form/control.ts, admin/src/components/form/formState.ts (fieldErrors :267, focusField :288), admin/src/state/useToasts.ts, admin/src/app/i18n.ts, admin/src/api/client.ts, admin/src/api/schema.d.ts (the /account/tokens operations from plan 01), admin/tests/views/AccountView.test.ts, admin/tests/helpers.ts (mountApp, requestsTo), admin/tests/relation/RelationPickerModal.test.ts, modules/phrasebook/backend/lang/en/lang.yaml and pl/lang.yaml (myaccount and the plan 01 api_tokens block)</read_first>
<action>Build the thinnest complete path through the SPA: AccountView → panel → API list → dialog → POST → show-once step → hide → list.
Per D-16 and UI-SPEC §1: render `<ApiTokensPanel :confirm="confirm" />` in AccountView after the `</form>` and before the existing ConfirmDialog, inside the `gap-5` section, never inside the form. The panel card uses `flex flex-col gap-4 rounded-card border border-border bg-surface p-7 shadow-card` with `data-api-tokens`, the header from UI-SPEC §2 (heading, comment, outline sm Create token button with Plus), and on mount loads `GET /account/tokens` through the typed `api` client; populated rows follow UI-SPEC §3 for name, hint, known-scope chips and the meta line (states and revoke come in Task 2).
Per D-16, D-17, D-08 and UI-SPEC §4-5 (minimal for the tracer): `ApiTokenCreateDialog` uses the RelationPickerModal shell verbatim; step 1 has the name input, the expiry RadioGroup with 90d preselected, and the scope checkboxes from `GET /account/tokens/scopes` (rendered in server order); submit sends `POST /account/tokens` with name, expires and the checked scope names; a 201 switches to step 2 with the notice, the read-only plaintext input and Done, hide token; Done clears the plaintext from local state, closes, and the panel reloads the list. The plaintext lives only in a dialog-local ref; it is cleared on close and in onBeforeUnmount.
Phrase keys: add every SPA key listed in this plan's Artifacts to the `api_tokens:` block in en and pl with the UI-SPEC wording, and change `myaccount.menu_description`. All strings go through `t()`; no literal UI text.
Tests: update AccountView.test.ts so its routes answer `GET {API}/account/tokens` with an empty list (the panel now loads on mount). Create admin/tests/account/ApiTokensPanel.test.ts with the case "tracer: list, create, show once, hide": mount /account with one existing token and two scopes, open the dialog, type a name, check a scope, submit, assert the POST body {name, expires: "90d", scopes: [checked]}, assert the plaintext input shows the returned token, click Done, assert the token string is absent from the whole document and that the list was requested again.
Rebuild `modules/boardwalk/dist` with `npm --prefix admin run build` and commit it with the code.</action>
<verify>
<automated>npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test -- tests/account/ApiTokensPanel.test.ts tests/views/AccountView.test.ts &amp;&amp; bash scripts/check-admin-openapi.sh --check &amp;&amp; bash scripts/check-admin-dist.sh</automated>
<fails_when>Non-zero exit: vue-tsc reports a type error against the plan 01 schema, a vitest case fails (the tracer or an AccountView case broken by the panel's mount request), the committed OpenAPI or TS types drifted, or modules/boardwalk/dist differs from a fresh build ("check-admin-dist: modules/boardwalk/dist is stale").</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "ApiTokensPanel" admin/src/views/AccountView.vue` prints the import and one usage, and the usage line number is greater than the line of `</form>`.
- `grep -n "tracer: list, create, show once, hide" admin/tests/account/ApiTokensPanel.test.ts` prints one line.
- `grep -c "api_tokens:" modules/phrasebook/backend/lang/en/lang.yaml modules/phrasebook/backend/lang/pl/lang.yaml` reports 1 per file and `grep -n "created_notice" modules/phrasebook/backend/lang/pl/lang.yaml` prints one line.
- `grep -rn "localStorage\|sessionStorage\|console\.\|v-html" admin/src/components/account/` prints nothing.
</acceptance_criteria>
<done>An admin can open My account, create a scoped token, read and hide the plaintext once, and see the new token in the list, with dist rebuilt.</done>
</task>
<task type="auto">
<name>Task 2: List states, show-revoked toggle, state and unknown-scope chips, and revoke through the shared confirm</name>
<files>admin/src/components/account/ApiTokensPanel.vue, admin/src/components/ui/ConfirmDialog.vue, admin/tests/account/ApiTokensPanel.test.ts, modules/boardwalk/dist</files>
<read_first>16.3-UI-SPEC.md (Component Contracts 2, 3, 6, 7; UI Considerations rows for the token list, toggle and revoke), admin/src/components/account/ApiTokensPanel.vue (Task 1), admin/src/components/ui/ConfirmDialog.vue (AlertDialogDescription :63), admin/src/components/ui/confirm.ts (ask with a run action), admin/src/components/list/CellValue.vue (datetime cell formatting), admin/src/app/dateFormat.ts, admin/src/state/useToasts.ts, admin/src/views/AccountView.vue (shared confirm)</read_first>
<action>Complete the list side per D-09, D-11 and UI-SPEC §2, §3, §6, §7.
States: first load and toggle reloads show three skeleton rows (h-3 w-40 above h-3 w-64, rounded-[6px] bg-skel, aria-hidden) with aria-busy="true" on the container; Create token is disabled only during the first load. A failed load replaces the list with the alert block (`data-tokens-error`, role=alert, CircleAlert 16px, api_tokens.load_failed) and hides the toggle; Create token stays enabled. No rows (also with the toggle on) shows the dashed empty block (`data-tokens-empty`, KeyRound 20px in a 40px bg-subtle circle, empty_title, empty_body).
Toggle (D-09): after a successful load, the `data-show-revoked` native checkbox with api_tokens.show_revoked reloads with `revoked=1`; its state is component-only.
Rows (D-11): chips per UI-SPEC §3 table: known scope (`bg-subtle text-text`, label, title = mono name, `*` shows api_tokens.scope_all), unknown scope (dashed mono, api_tokens.unknown_scope with the name, title unknown_scope_hint), Expired chip (`bg-danger-soft text-danger`) when `expired`, Revoked chip when `revoked_at` is set; revoked rows render name and meta in text-muted with no line-through. Meta line joined with " · ": created_at, last_used or never_used, expires_at / expired_at / no_expiry, and revoked_at for revoked rows, formatted as this plan's Decisions say. Revoke token (`variant="danger" size="sm"`, Ban icon, aria-label api_tokens.revoke_named) on active and expired rows only; on max-sm it sits under the meta line.
Revoke (UI-SPEC §6): `confirm.ask({message: t(revoke_confirm, {name}), confirmLabel: t(revoke_submit), danger: true}, run)` where run sends `DELETE /account/tokens/{id}`; success → list reload, success toast api_tokens.revoked, focus to Create token; 404 → danger toast api_tokens.gone and reload; other failure → danger toast api_tokens.revoke_failed, row unchanged.
ConfirmDialog: add `[overflow-wrap:anywhere]` to the AlertDialogDescription class (UI-SPEC long-text row); nothing else changes in the shared component.
Smoke cases in ApiTokensPanel.test.ts: "list states" (skeleton then empty block; failed load shows the alert and hides the toggle) and "revoke" (confirm runs DELETE, success toast; a 404 shows api_tokens.gone and reloads; the toggle requests revoked=1 and a revoked row has no revoke button). Rebuild and commit dist.</action>
<verify>
<automated>npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test -- tests/account/ApiTokensPanel.test.ts tests/views/AccountView.test.ts tests/ui/ui.test.ts &amp;&amp; bash scripts/check-admin-dist.sh</automated>
<fails_when>Non-zero exit: a type error, a failing panel, AccountView or shared-UI case (for example the ConfirmDialog class change broke a UI test, or revoke did not use the shared confirm), or dist is stale.</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "overflow-wrap:anywhere" admin/src/components/ui/ConfirmDialog.vue` prints one line.
- `grep -c "data-tokens-empty\|data-tokens-error\|data-show-revoked" admin/src/components/account/ApiTokensPanel.vue` reports at least 3.
- `grep -n "list states\|revoke" admin/tests/account/ApiTokensPanel.test.ts` prints the two new describe or it lines.
</acceptance_criteria>
<done>The token list handles loading, empty, failure, populated and mixed states, revoked rows on demand, and revoke with the shared confirm and correct toasts.</done>
</task>
<task type="auto">
<name>Task 3: Dialog rules: scope picker states and the `*` rule, expiry preview, 422 and busy handling, copy and clipboard fallback, close guards; My account docs</name>
<files>admin/src/components/account/ApiTokenCreateDialog.vue, admin/tests/account/ApiTokensPanel.test.ts, modules/boardwalk/dist, docs/backend/users-and-permissions.md, docs/backend/admin-spa.md</files>
<read_first>16.3-UI-SPEC.md (Component Contracts 4 and 5, UI Considerations rows for the scope picker, create form, create submit, show-once step and clipboard), admin/src/components/account/ApiTokenCreateDialog.vue (Task 1), admin/src/components/form/fields/PermissionEditorField.vue (segmented control classes), admin/src/components/form/fields/CheckboxField.vue (checkbox class), admin/src/views/AccountView.vue (422 error markup and focusField use), docs/backend/users-and-permissions.md (My account section and the API tokens section plan 01 wrote), docs/backend/admin-spa.md (:36)</read_first>
<action>Complete the dialog per D-08, D-16, D-17 and UI-SPEC §4-5.
Step 1: scope picker skeleton (three 56px rows with a 20px square and an h-3 w-40 bar) while the scopes request is pending, submit disabled; a failed request or an empty answer shows the alert block api_tokens.scopes_failed with submit disabled. Rows use the UI-SPEC row classes, label (14/600), mono name, the Read-only chip when `read_only`, and the description. Checking `*` unchecks and disables every other row (`cursor-not-allowed opacity-60`) and shows the Info note api_tokens.scope_all_includes; unchecking `*` re-enables rows without restoring earlier picks; only [`*`] is sent then. Expiry preview under the segmented control: api_tokens.expires_on with today plus 30, 90 or 365 days as local YYYY-MM-DD, or api_tokens.expires_never_note. 422: map `error.details` through `fieldErrors`, render each field's messages like AccountView (CircleAlert 14px, aria-describedby, aria-invalid, `border-danger` on the scope group), and focus the name input, else the first scope checkbox, else the first segment. Busy: fieldset disabled, primary label api_tokens.creating with a spinning LoaderCircle, Discard token and close disabled, Esc and overlay ignored, repeated submit ignored. Non-422 failure: danger toast api_tokens.create_failed, input kept. Closing step 1 any way discards input without a confirm. Focus: name input on open, the card's Create token button on close.
Step 2: overlay pointer-down and interact-outside are prevented; Copy token calls `navigator.clipboard.writeText` and on success shows Check plus api_tokens.copied for two seconds, announces api_tokens.copied_announce in an sr-only aria-live="polite" region and marks the token copied; a missing or rejecting Clipboard API focuses the input with its value selected and shows api_tokens.copy_failed (13px text-danger, role=alert); a `copy` event on the input also marks it copied. Esc and the close button close at once when copied, otherwise ask the shared confirm (`danger`, api_tokens.close_uncopied_confirm, confirm label close_uncopied_submit) and close only on confirm. Focus goes to Copy token on entering step 2. Reopening always starts at step 1 with a cleared plaintext.
Smoke cases: "dialog rules": `*` disables other rows and sends [`*`]; a 422 on scopes renders the server message and focuses the first scope; a stubbed clipboard success shows Copied and a rejection shows copy_failed; Esc before copying opens the confirm and cancel keeps step 2. Plan 04 adds the full matrix.
Docs (D-20, SPA half): in docs/backend/users-and-permissions.md "My account" describe the API tokens card (own tokens only, create with name, expiry and scopes, show once and copy, revoke, show revoked) and link to the API tokens section; in docs/backend/admin-spa.md extend the My account sentence. Rebuild and commit dist.</action>
<verify>
<automated>npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test &amp;&amp; bash scripts/check-admin-dist.sh &amp;&amp; go test -count=1 ./cmd/summer -run TestDocsTree</automated>
<fails_when>Non-zero exit: a type error, any vitest case in the full admin suite fails (including the dialog rules smoke), dist is stale, or TestDocsTree reports an unknown identifier, broken link or anchor in the edited docs pages.</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "dialog rules" admin/tests/account/ApiTokensPanel.test.ts` prints one line.
- `grep -c "aria-live=\"polite\"\|pointer-down-outside\|scope_all_includes\|copy_failed" admin/src/components/account/ApiTokenCreateDialog.vue` reports at least 4.
- `grep -n "API tokens" docs/backend/users-and-permissions.md docs/backend/admin-spa.md` prints at least one line per file.
</acceptance_criteria>
<done>The create dialog follows every UI-SPEC rule for scopes, expiry, errors, busy, copy and closing, the full admin suite passes, dist is current and the My account docs describe the panel.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| server → SPA | the 201 create response carries the plaintext once |
| SPA → browser storage, clipboard | the plaintext must stay in component memory; the clipboard is user-initiated |
| server data → DOM | token names and plugin scope labels are rendered as text |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-16.3-14 | Information Disclosure | ApiTokenCreateDialog plaintext | high | mitigate | Plaintext only in a dialog-local ref, cleared on Done, on confirmed close and in onBeforeUnmount; never in toasts, storage, URL, router state, console or list rows; Task 1 acceptance greps the component folder for storage, console and v-html; plan 04 backstop tests assert DOM and state are clean |
| T-16.3-15 | Information Disclosure | show-once step | low | accept | The full token is visible once by design (UI-SPEC §5) so the user can verify it; overlay clicks cannot close the step and closing before copying asks first |
| T-16.3-16 | Tampering (XSS) | token names, scope labels and descriptions | medium | mitigate | Rendered with Vue text interpolation only (no v-html); plugin labels arrive as server-translated plain strings |
| T-16.3-17 | Elevation of Privilege | scope picker | low | mitigate | The SPA renders only what GET /account/tokens/scopes returns; the server re-validates names and the overlap rule on create (plan 01, D-17) |
| T-16.3-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package is installed; reka-ui, @lucide/vue and the build tools come from the committed admin lockfile |
</threat_model>
<verification>
- Task 1: typecheck, tracer and AccountView tests, OpenAPI and dist drift gates pass.
- Task 2: panel, AccountView and shared UI tests and the dist gate pass.
- Task 3: full admin vitest suite, dist gate and TestDocsTree pass.
</verification>
<success_criteria>
- SC-3 (SPA half): a backend user creates, lists and revokes their own tokens on /account, and the plaintext is shown once.
- Every UI-SPEC state is implemented; the three backstops have smoke coverage here and full tests in plan 04.
</success_criteria>
<output>
Create `.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-02-SUMMARY.md` when done, with the commit list, the component props and emitted events, and the smoke test names.
</output>

View File

@@ -0,0 +1,190 @@
---
phase: 16.3-backend-admin-personal-api-tokens-with-scopes
plan: 02
subsystem: ui
tags: [admin-spa, api-tokens, account, vue, reka-ui, phrasebook, show-once]
requires:
- phase: 16.3-backend-admin-personal-api-tokens-with-scopes
provides: account token API, OpenAPI/TS types, plan 01 api_tokens phrase seeds
provides:
- "ApiTokensPanel on /account after the profile form"
- "ApiTokenCreateDialog two-step create with show-once plaintext"
- "List states, show-revoked toggle, revoke via shared ConfirmDialog"
- "SPA api_tokens.* phrase keys (en/pl) and myaccount.menu_description"
- "Rebuilt modules/boardwalk/dist and My account docs"
affects: [16.3-03, 16.3-04]
actuals:
tokens: 17683
tasks: 3
commits: 4
plan_head_before: 93a3112fb7a9c9d181607b473fde1f051eea778b
plan_head_after: e17207a
tech-stack:
added: []
patterns:
- "Account panel shares AccountView useConfirm(); ConfirmDialog stays at z-[90]"
- "Plaintext only in dialog-local ref; cleared on Done, confirmed close, onBeforeUnmount"
key-files:
created:
- admin/src/components/account/ApiTokensPanel.vue
- admin/src/components/account/ApiTokenCreateDialog.vue
- admin/tests/account/ApiTokensPanel.test.ts
modified:
- admin/src/views/AccountView.vue
- admin/src/components/ui/ConfirmDialog.vue
- admin/src/api/types.ts
- admin/tests/views/AccountView.test.ts
- admin/tests/fixtures/lang.json
- modules/phrasebook/backend/lang/en/lang.yaml
- modules/phrasebook/backend/lang/pl/lang.yaml
- modules/boardwalk/dist
- docs/backend/users-and-permissions.md
- docs/backend/admin-spa.md
key-decisions:
- "Panel/dialog split per plan: ApiTokensPanel owns list/revoke; ApiTokenCreateDialog owns plaintext"
- "Hint renders as g15_… plus API hint (last4); scope chips use server label/known"
- "expired from API drives Expired chip (server clock)"
patterns-established:
- "My account cards outside the profile form share one ConfirmDialog"
- "Show-once secrets stay in dialog state only — never toasts, storage, URL or list rows"
requirements-completed: [SC-3, SC-5]
coverage:
- id: D1
description: "My account API tokens card lists, creates (show-once) and hides plaintext"
requirement: SC-3
verification:
- kind: unit
ref: admin/tests/account/ApiTokensPanel.test.ts#tracer: list, create, show once, hide
status: pass
human_judgment: false
- id: D2
description: "List loading/empty/error, show-revoked toggle, revoke via ConfirmDialog"
requirement: SC-3
verification:
- kind: unit
ref: admin/tests/account/ApiTokensPanel.test.ts#list states
status: pass
- kind: unit
ref: admin/tests/account/ApiTokensPanel.test.ts#revoke
status: pass
human_judgment: false
- id: D3
description: "Create dialog scope *, 422 focus, clipboard copy/fallback, close-without-copy"
requirement: SC-3
verification:
- kind: unit
ref: admin/tests/account/ApiTokensPanel.test.ts#dialog rules
status: pass
human_judgment: false
- id: D4
description: "My account docs describe the API tokens SPA card"
requirement: SC-5
verification:
- kind: other
ref: go test -count=1 ./cmd/summer -run TestDocsTree
status: pass
human_judgment: false
duration: 9min
completed: 2026-10-11
status: complete
---
# Phase 16.3 Plan 02: Admin SPA API tokens card Summary
**My account API tokens card with list states, two-step show-once create/copy, revoke via shared ConfirmDialog, phrase keys, dist and docs.**
## Performance
- **Duration:** 9 min
- **Started:** 2026-10-10T23:57:24Z
- **Completed:** 2026-10-11T00:06:49Z
- **Tasks:** 3
- **Files modified:** 17
## Accomplishments
- `ApiTokensPanel` after the profile form on `/account`; Create token, list, show-revoked, revoke
- `ApiTokenCreateDialog`: name/expiry/scopes → show-once plaintext, copy with clipboard fallback, close-without-copy confirm
- Full `backend::lang.api_tokens.*` en/pl copy, Polish lang fixture, rebuilt `modules/boardwalk/dist`, My account docs
## Task Commits
Each task was committed atomically:
1. **Task 1: Tracer — list, create, show once, hide** - `5a6a190` (feat)
2. **Task 2: List states, show-revoked, revoke, ConfirmDialog wrap** - `1c930b6` (feat)
3. **Task 3: Dialog rules smoke tests and My account docs** - `e17207a` (feat)
**Plan metadata:** (this commit)
## Component contracts
| Component | Props | Emits |
|-----------|-------|-------|
| `ApiTokensPanel` | `confirm: ReturnType<typeof useConfirm>` | — |
| `ApiTokenCreateDialog` | `open`, `confirm` | `update:open`, `created`, `closed` |
## Smoke test names
- `tracer: list, create, show once, hide`
- `list states` (skeleton→empty; load failure)
- `revoke` (DELETE confirm; 404 gone; show-revoked)
- `dialog rules` (`*` posts `["*"]`; 422 scopes focus; clipboard; close-without-copy)
## Files Created/Modified
- `admin/src/components/account/ApiTokensPanel.vue` — card, list states, revoke
- `admin/src/components/account/ApiTokenCreateDialog.vue` — two-step create dialog
- `admin/src/views/AccountView.vue` — panel after `</form>`, shared confirm
- `admin/src/components/ui/ConfirmDialog.vue` — `[overflow-wrap:anywhere]` on description
- `admin/tests/account/ApiTokensPanel.test.ts` — tracer, list, revoke, dialog rules
- `modules/phrasebook/backend/lang/{en,pl}/lang.yaml` — SPA api_tokens keys
- `docs/backend/users-and-permissions.md`, `docs/backend/admin-spa.md` — My account tokens card
## Decisions Made
- Followed plan/UI-SPEC component split and date formatting (CellValue shapes).
- `expired` from the API drives the Expired chip; client clock is not used for that label.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Polish lang fixture for new phrase keys**
- **Found during:** Task 1 (AccountView / panel tests use `langFixture` Polish bundle)
- **Issue:** Plan listed en/pl YAML only; without `admin/tests/fixtures/lang.json` keys, SPA strings fall back to key paths and assertions on Polish copy fail.
- **Fix:** Injected all `backend::lang.api_tokens.*` Polish strings and updated `myaccount.menu_description` in the fixture.
- **Files modified:** `admin/tests/fixtures/lang.json`
- **Verification:** AccountView and ApiTokensPanel tests pass with Polish copy
- **Committed in:** `5a6a190` (Task 1)
---
**Total deviations:** 1 auto-fixed (1 blocking)
**Impact on plan:** Required for green admin tests; no scope creep.
## Issues Encountered
None
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
SPA half of SC-3 is in place. Ready for 16.3-03 (Journal scopes + ResolveBackend writes) and 16.3-04 (full unit/backstop matrix).
## Self-Check: PASSED
- Key files exist on disk
- Commits grepped for 16.3-02: 3 task commits
- Task acceptance greps and plan verification commands re-run green (typecheck, full admin vitest 1228, check-admin-dist, TestDocsTree)
---
*Phase: 16.3-backend-admin-personal-api-tokens-with-scopes*
*Completed: 2026-10-11*

View File

@@ -0,0 +1,286 @@
---
phase: 16.3-backend-admin-personal-api-tokens-with-scopes
plan: 03
type: execute
wave: 3
depends_on: ["16.3-01", "16.3-02"]
files_modified:
- ../sm-journal-plugin/token_scopes.go
- ../sm-journal-plugin/lang/en/lang.yaml
- ../sm-journal-plugin/lang/pl/lang.yaml
- ../sm-journal-plugin/controllers/api/auth.go
- ../sm-journal-plugin/controllers/api/auth_test.go
- ../sm-journal-plugin/journal_api_token_smoke_test.go
- ../sm-journal-plugin/README.md
- ../sm-journal-plugin/go.mod
- modules/cabana/example_tokens_test.go
- docs/services/authentication.md
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal
autonomous: false
requirements: [SC-2, SC-4, SC-5]
estimate:
tokens: 75000
raw_tokens: 75000
tasks: 4
confidence: low
must_haves:
truths:
- "D-04 and D-01 (Journal): the Journal plugin implements pact.HasTokenScopes with `journal` (golem15.journal.*, GET, HEAD, POST, PUT, PATCH, DELETE) and `journal:read` (golem15.journal.*, GET, HEAD), labels and descriptions as golem15.journal::lang.scopes.* phrase keys present in lang/en and lang/pl; cabana boot accepts them."
- "SC-4 and D-15: POST, PUT and DELETE /_journal/api/v1/posts, the featured-image routes and POST /_journal/api/v1/media/upload accept a Bearer g15_ token whose scope covers golem15.journal.* (journal or `*`), running as the token's owner with the owner's Journal permissions; the backend JWT keeps working exactly as before."
- "D-15 byte-identical bodies: a missing, invalid, expired or revoked token answers 401 {\"error\":\"Authentication required\"}; a journal:read token on a write route and a token whose scopes do not cover golem15.journal.* answer 403 {\"error\":\"Insufficient permissions\"}; every other status and body is unchanged."
- "D-15 optional auth: GET /_journal/api/v1/posts and /posts/{slug} with a valid journal or journal:read token unlock drafts exactly where the owner may see them; a token that fails or does not cover golem15.journal.* is treated as anonymous (no 401 on public reads)."
- "RESEARCH Pitfall 3: the Journal routes resolve their principal only through cabana.ResolveBackend with cover golem15.journal.*, so a token with an unrelated scope never reaches the ownership branches (Post.CanEdit, canViewDraft, featured images)."
- "RESEARCH Pitfall 4 (behaviour change, documented): the Journal API now honors code-declared role grants (pact.Permission.Roles) because it uses the admin guard's compiled registry instead of a nil one."
- "RESEARCH open question 2 resolved against the frozen PHP tree: PostApiController::uploadFeaturedImage and deleteFeaturedImage check only $post->canEdit($user), like update and destroy; the Go featured-image handlers keep that parity and gain no access_posts check; the token side is closed by the resolver's cover."
- "D-20 (Journal and remaining docs): the Journal README documents the two scopes, token auth on writes and optional-auth reads, the reversal of Phase 15 D-15, the code-declared-role change and that the journal-api throttle still keys by client IP; docs/services/authentication.md shows ResolveBackend from a compiled example (ExampleResolveBackend) with neutral names; TestDocsTree and summer docs:build --check pass."
- "Release (RESEARCH open question 5, user checkpoint): after a human approves at a blocking checkpoint, an annotated summercms tag (the next free v0.1.x after v0.1.20) containing plans 01 and 02 is on origin; sm-journal-plugin main requires that tag and is pushed; the sm-grzybyfunkcjonalne-app journal gitlink points at the pushed Journal head and is pushed; no pushed tag is moved."
artifacts:
- path: "../sm-journal-plugin/token_scopes.go"
provides: "journal and journal:read scopes"
contains: "TokenScopes"
- path: "../sm-journal-plugin/controllers/api/auth.go"
provides: "requireBackendPrincipal and optionalBackendPrincipal on cabana.ResolveBackend with cover golem15.journal.*"
contains: "cabana.ResolveBackend"
- path: "../sm-journal-plugin/journal_api_token_smoke_test.go"
provides: "Postgres tracer and expansion smoke for token writes"
contains: "TestJournalTokenTracer"
- path: "modules/cabana/example_tokens_test.go"
provides: "compiled ResolveBackend example for the docs"
contains: "ExampleResolveBackend"
key_links:
- from: "../sm-journal-plugin/controllers/api/auth.go requireBackendPrincipal"
to: "summercms modules/cabana/token_guard.go ResolveBackend"
via: "per-request lookup of the resolver cabana.Activate published (Journal routes register before Activate)"
pattern: "ResolveBackend"
- from: "../sm-journal-plugin/token_scopes.go"
to: "cabana compileTokenScopes at boot"
via: "pact.HasTokenScopes on the Journal plugin"
pattern: "HasTokenScopes"
- from: "summercms annotated tag"
to: "../sm-journal-plugin/go.mod require"
via: "go mod edit -require after the human-approved push"
pattern: "git.golem15.com/golem15/summercms v0.1."
prohibitions:
- statement: "Journal error bodies and status codes stay byte-identical to the PHP contract; response shapes are not improved during the port (CLAUDE.md rule 6)."
status: unresolved
verification: judgment
- statement: "The Journal API never authenticates from the admin cookie; its write routes stay Bearer-only."
status: unresolved
verification: judgment
- statement: "No tag, branch or gitlink is pushed before the human approves at the release checkpoint; no pushed tag is moved or deleted; no credential is requested, printed or committed."
status: unresolved
verification: judgment
- statement: "The PHP Journal and Apparatus originals are not changed; Apparatus token rows are not migrated."
status: unresolved
verification: judgment
---
## Phase Goal
ROADMAP Goal (not in user-story format): backend users create personal API tokens with scopes picked at creation time, so an agent or integration calls the admin and plugin APIs as that backend user with no more access than the token's scope and the user's own permissions allow. Reverses Phase 15 D-15.
This plan's slice (user checkpoint, plan 3 of 4): the Journal plugin registers `journal` and `journal:read` and accepts tokens through the published cabana resolver (D-15), the remaining docs (Journal README, a compiled ResolveBackend example), and the release: summercms tag after v0.1.20, the Journal `go.mod` bump and the host gitlink bump, behind a blocking human checkpoint.
## Spec-less probe fallback
Visible skip: "spec-less probe fallback skipped: phase has no requirement IDs to probe". Prohibitions above are recalled in-prompt and descriptor-less (flagged-unverified).
## Decisions taken in this plan
- **Featured images (RESEARCH open question 2), checked while planning:** the frozen PHP tree (`/media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/controllers/api/PostApiController.php` uploadFeaturedImage :416-430 and deleteFeaturedImage :459-470) authorizes only through `$post->canEdit($user)`, exactly like update and destroy, under the `token.auth` group in routes.php. The Go handlers already match; this plan adds no `access_posts` check there (API parity is the acceptance test). A `journal:read` token is refused by the method filter, and a token without Journal coverage by the resolver's cover check.
- **Resolver errors to Journal bodies:** `cabana.ErrTokenScope` → 403 `errInsufficientPerms`; anything else → 401 `errAuthenticationRequired`; on optional-auth reads any error means anonymous.
- **Cover:** every Journal call passes the cover `golem15.journal.*` (RESEARCH Pattern 3).
- **Throttle (RESEARCH Pitfall 11):** the `journal-api` bucket still keys by client IP because it runs before the handler resolves the principal; documented, not changed.
- **Hosts:** sm-grzybyfunkcjonalne-app is the host that ships the Journal plugin to UAT and gets the journal gitlink bump (16.2 pattern; its framework require is nominal because it builds from `../summercms.go` through a replace). sm-summercmsio-app also carries a Journal gitlink (f5cd5b1, pre-16.2); bumping it would also pull in Phase 16.2, so the release checkpoint asks whether to bump it too (default: no). A UAT redeploy is not part of this plan; the release sheet names it as a follow-up (DEPLOY.md; `migrate` creates backend_api_tokens).
- **Tag:** the highest `v0.1.*` plus one (v0.1.21 unless someone tagged first), annotated, created only after the checkpoint.
## Artifacts this phase produces (this plan)
- `../sm-journal-plugin/token_scopes.go`: `(*Plugin).TokenScopes` with scopes `journal` and `journal:read`, compile-time assertion for `pact.HasTokenScopes`.
- Phrase keys `golem15.journal::lang.scopes.{journal, journal_description, journal_read, journal_read_description}` (en, pl).
- `../sm-journal-plugin/controllers/api/auth.go`: `requireBackendPrincipal` and `optionalBackendPrincipal` on `cabana.ResolveBackend`; the plugin's own guard constructor and its blacklist wiring removed; new helper `resolveError` mapping resolver errors to the PHP bodies.
- Tests: `TestResolveErrorMapping` (controllers/api, Docker-free); `TestJournalTokenTracer`, `TestJournalTokenExpansion` (root, Postgres).
- `modules/cabana/example_tokens_test.go`: `ExampleResolveBackend` (compile-only), included in docs/services/authentication.md.
- Journal README sections on scopes and token auth.
- Release: summercms annotated tag `v0.1.21` (or next free), Journal `go.mod` require of it, host journal gitlink bump; session values `RELEASE_TAG` and the pushed heads in the SUMMARY.
<objective>
Let an agent manage Journal posts as a backend author with a Journal-scoped token (SC-4), keep the Journal contract byte-identical, finish the docs (SC-5), and release the framework, the plugin and the host bump behind a human gate.
Purpose: the phase's main use case (CONTEXT Specific Ideas) works end to end against the released framework.
Output: Journal scope registration and resolver switch, smoke tests, README and docs, a pushed tag and bumps.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-CONTEXT.md
@.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-RESEARCH.md
@.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-01-SUMMARY.md
@.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-02-SUMMARY.md
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-07-SUMMARY.md
Repositories: ../sm-journal-plugin builds against ../summercms.go through its go.work replace, so the Journal code works before the tag; its go.mod require is nominal and is bumped after the tag. Release facts at planning time: summercms latest tag v0.1.20 (aba063f); sm-journal-plugin main 5eea57f (requires v0.1.20); sm-grzybyfunkcjonalne-app master f1d53af with journal gitlink 5eea57f. Docker: the Journal root package runs a testcontainers Postgres in TestMain; Docker-free runs use `-short` or the classes and controllers packages. Commits: no co-author tags, stage by explicit path in each repository, tests and code in separate commits where the plan says so.
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer: a journal-scoped token created through the admin API creates a Journal post as its owner</name>
<files>../sm-journal-plugin/token_scopes.go, ../sm-journal-plugin/lang/en/lang.yaml, ../sm-journal-plugin/lang/pl/lang.yaml, ../sm-journal-plugin/controllers/api/auth.go, ../sm-journal-plugin/controllers/api/auth_test.go, ../sm-journal-plugin/journal_api_token_smoke_test.go</files>
<read_first>../sm-journal-plugin/controllers/api/auth.go (whole file), ../sm-journal-plugin/controllers/api/posts.go (Index :30, Show :119, Store :193, Update :238, Destroy :291, UploadFeaturedImage :323, DeleteFeaturedImage :391), ../sm-journal-plugin/controllers/api/media.go (UploadMedia :18), ../sm-journal-plugin/controllers/api/posts_helpers.go (canViewDraft :33), ../sm-journal-plugin/admin_permissions.go, ../sm-journal-plugin/plugin.go (capability assertions :25-33, Buckets), ../sm-journal-plugin/routes.go, ../sm-journal-plugin/lang/en/lang.yaml and pl/lang.yaml, ../sm-journal-plugin/admin_harness_test.go (TestMain :53, newPostsEnv, bootJournal, e.admin :241, e.call, adminAPI), ../sm-journal-plugin/journal_api_writes_test.go, summercms modules/cabana/token_guard.go (ResolveBackend, ErrUnauthenticated, ErrTokenScope) and 16.3-01-SUMMARY.md</read_first>
<action>Per D-04, D-01 and D-15, wire one Journal write through the framework token path.
Scopes: token_scopes.go declares the compile-time assertion for `pact.HasTokenScopes` and `TokenScopes()` returning `journal` (Permissions golem15.journal.*, Methods GET, HEAD, POST, PUT, PATCH, DELETE) and `journal:read` (golem15.journal.*, GET, HEAD), Label and Description as golem15.journal::lang.scopes.journal, scopes.journal_description, scopes.journal_read and scopes.journal_read_description. Add a `scopes:` block to lang/en and lang/pl (EN: "Journal" / "Create, edit, publish and delete posts as you, within your Journal permissions."; "Journal, read-only" / "Read posts, including drafts you may see, without changing anything."; PL equivalents).
Resolver switch (reverses Phase 15 D-15): in controllers/api/auth.go replace the plugin's own guard constructor (auth.go :58-81) and its blacklist and registry lookups with `cabana.ResolveBackend(app, r, "golem15.journal.*")`, looked up per request. `requireBackendPrincipal` keeps its signature and writes the existing bodies through a new `resolveError(w, err)`: `errors.Is(err, cabana.ErrTokenScope)` → `writeAPIError(w, 403, errInsufficientPerms)`, anything else → `writeAuthRequired`. `optionalBackendPrincipal` keeps the hasBearer short-cut for anonymous requests and treats every resolver error as anonymous. Remove imports and constants that become unused. No handler in posts.go or media.go changes.
Tests: `TestResolveErrorMapping` (controllers/api/auth_test.go, Docker-free): ErrTokenScope (also wrapped) → 403 with body exactly {"error":"Insufficient permissions"}, ErrUnauthenticated and a generic error → 401 {"error":"Authentication required"}. `TestJournalTokenTracer` (root package, Postgres): `newPostsEnv`, an admin with golem15.journal.access_posts via `e.admin`, POST `adminAPI("/account/tokens")` with that JWT and {name, scopes:["journal"]} → 201 and a g15_ token; POST /_journal/api/v1/posts with Bearer token and a minimal valid body → 201 and the stored post's user_id equals the admin's id.</action>
<verify>
<automated>go -C ../sm-journal-plugin vet ./... &amp;&amp; go -C ../sm-journal-plugin test -count=1 -run '^TestResolveErrorMapping$' -v ./controllers/api/ 2&gt;&amp;1 | grep -- '^--- PASS: TestResolveErrorMapping' &amp;&amp; go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/...</automated>
<fails_when>Non-zero exit: vet fails (an unused import or constant left behind), no "--- PASS: TestResolveErrorMapping" line (a resolver error maps to the wrong status or body), or a Journal classes or controllers test regressed.</fails_when>
<automated>docker info &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go -C ../sm-journal-plugin test -count=1 -run '^TestJournalTokenTracer$' -v . 2&gt;&amp;1 | grep -- '^--- PASS: TestJournalTokenTracer'</automated>
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN in the SUMMARY), or the journal token could not be created through the admin API, the Journal write was refused, or the post's author is not the token's owner.</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "func (\*Plugin) TokenScopes\|func (p \*Plugin) TokenScopes" ../sm-journal-plugin/token_scopes.go` prints one line and `grep -c "journal_read_description" ../sm-journal-plugin/lang/en/lang.yaml ../sm-journal-plugin/lang/pl/lang.yaml` reports 1 per file.
- `grep -c "cabana.ResolveBackend" ../sm-journal-plugin/controllers/api/auth.go` reports at least 2.
- `grep -v '^\s*//' ../sm-journal-plugin/controllers/api/auth.go | grep -c "NewBackendJWTGuard\|NewPostgresBlacklist"` reports 0.
- `git -C ../sm-journal-plugin diff --stat 5eea57f -- controllers/api/posts.go controllers/api/media.go` prints nothing (5eea57f is the pre-phase Journal head; no handler edits).
</acceptance_criteria>
<done>A journal-scoped token minted on the admin API creates a Journal post as its owner, and the resolver errors map to the PHP bodies.</done>
</task>
<task type="auto">
<name>Task 2: Journal token matrix (read scope, unrelated scope, optional-auth drafts, featured images, code-declared roles), Journal README, and the compiled ResolveBackend docs example</name>
<files>../sm-journal-plugin/journal_api_token_smoke_test.go, ../sm-journal-plugin/README.md, modules/cabana/example_tokens_test.go, docs/services/authentication.md</files>
<read_first>../sm-journal-plugin/journal_api_token_smoke_test.go (Task 1), ../sm-journal-plugin/journal_api_writes_test.go and journal_api_matrix_test.go (status and body assertions), ../sm-journal-plugin/admin_permissions.go (Roles developer), ../sm-journal-plugin/README.md (Features :19-28, Usage :30-150, API reference :231, Testing :303), /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/controllers/api/PostApiController.php (:313-330, :395-430, :459-470) and routes.php (:38-65), modules/cabana/example_test.go and example_controller_test.go (compile-only example style), docs/services/authentication.md (the plan 01 section on admin tokens on plugin routes), internal/docsite/snippet.go (src= rules)</read_first>
<action>Pin the rest of D-15 and RESEARCH Pitfalls 3 and 4 with one Postgres smoke, then document.
`TestJournalTokenExpansion` (root package, Postgres, same harness): with tokens created through the admin API for an editor that has golem15.journal.access_posts but not access_other_posts: a `journal:read` token POST /posts → 403 {"error":"Insufficient permissions"}; a `journal:read` token GET /posts/{slug} of the editor's own draft → 200 while an anonymous GET → 404; a token for a scope that does not cover golem15.journal.* (register a second test scope or use the framework `admin:read` on a POST) on POST /posts → 403 and on GET /posts behaves as anonymous; a `journal` token uploads and deletes a featured image on the editor's own post (canEdit parity, no access_posts requirement added) and gets 403 on another author's post; a revoked token → 401 {"error":"Authentication required"}; an admin whose Journal access comes only from the code-declared developer role (no role or user grant rows) creates a post with a JWT (Pitfall 4 behaviour change). Compare bodies byte for byte with the existing constants.
Journal README (D-20): Features line for writes ("a cabana backend JWT or a personal admin API token, g15_…, whose scope covers the Journal"); Usage: the two scopes (`journal`, `journal:read`), a token example request, optional-auth reads unlocking drafts, the error bodies, the reversal of Phase 15 D-15, the code-declared role change, the featured-image parity note and the IP-keyed journal-api throttle; API reference row for `journal.Plugin.TokenScopes`; Testing: the new tests.
Framework docs (D-20, remaining): `modules/cabana/example_tokens_test.go` with a compile-only `ExampleResolveBackend` showing a plugin handler (acme names) that calls `cabana.ResolveBackend(app, r, "acme.blog.*")` and maps `cabana.ErrTokenScope` to 403 and other errors to 401; include it in docs/services/authentication.md with a `src=modules/cabana/example_tokens_test.go#ExampleResolveBackend` fence next to the prose plan 01 wrote, and say the resolver reads the Bearer header only. Then check that every exported identifier this phase added is named in its module README (pact TokenScope, HasTokenScopes; bouncer ScopeLimit; party Activated; cabana BackendAPIToken, APITokenPrefix, ResolveBackend, ErrUnauthenticated, ErrTokenScope) and every admin:token command in the cabana README and docs/console/setup-and-maintenance.md; fix any gap in the same commit.</action>
<verify>
<automated>go vet ./modules/cabana &amp;&amp; go test -count=1 -run '^ExampleResolveBackend$|^TestDocsTree$' ./modules/cabana ./cmd/summer &amp;&amp; go run ./cmd/summer docs:build --check &amp;&amp; go -C ../sm-journal-plugin vet ./...</automated>
<fails_when>Non-zero exit: the example does not compile, TestDocsTree or docs:build --check reports an unknown identifier, a stale src= fence or a consuming-application name, or the Journal tests fail vet.</fails_when>
<automated>docker info &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go -C ../sm-journal-plugin test -count=1 -run '^(TestJournalTokenTracer|TestJournalTokenExpansion|TestJournalWriteUnauthenticated|TestJournalFeaturedImageUnauthenticated)$' -v . 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 4</automated>
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than four passing tests: a read or unrelated scope reached a write, a draft leaked or stayed hidden, a featured-image route changed its PHP behaviour, an existing unauthenticated contract test changed, or a body differs from the PHP constants.</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "journal:read" ../sm-journal-plugin/README.md` prints at least one line and `grep -n "Phase 15" ../sm-journal-plugin/README.md` names the reversal.
- `grep -n "src=modules/cabana/example_tokens_test.go#ExampleResolveBackend" docs/services/authentication.md` prints one line.
- For each of TokenScope, HasTokenScopes, ScopeLimit, Activated, BackendAPIToken, APITokenPrefix, ResolveBackend, ErrUnauthenticated, ErrTokenScope, `grep -l` over modules/pact/README.md, modules/bouncer/README.md, modules/party/README.md and modules/cabana/README.md finds at least one file.
</acceptance_criteria>
<done>Read-only, unrelated and revoked tokens are refused with the PHP bodies, drafts unlock as the owner may see them, featured images keep PHP parity, and the Journal README and framework docs cover the token integration.</done>
</task>
<task type="checkpoint:decision" gate="blocking-human">
<name>Task 3: Release gate: local checks green, tag chosen, one-way surfaces listed, a human approves the tag and pushes</name>
<read_first>.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-07-SUMMARY.md (how the last release ran), 16.3-01-SUMMARY.md, 16.3-02-SUMMARY.md and this plan's Task 1-2 SUMMARY notes (commits per repository, Postgres tests NOT RUN), 16.3-REVIEW.md and 16.3-SECURITY.md when present, ../sm-journal-plugin/go.mod, ../sm-grzybyfunkcjonalne-app/.gitmodules</read_first>
<action>Do everything automatable, print a release sheet, then stop.
Concurrency: in summercms.go `git status --porcelain -- modules admin docs scripts cmd` prints nothing, `git fetch origin --tags`, `git log --oneline HEAD..origin/master` prints nothing; the same clean and not-behind checks for ../sm-journal-plugin (main) and ../sm-grzybyfunkcjonalne-app (master). If an origin has commits this checkout lacks, stop and report; never merge or rebase on your own.
Tag: if `git tag --contains` on the first plan 01 commit lists a tag, propose reusing it; otherwise propose the highest of `git tag -l 'v0.1.*' --sort=-v:refname` plus one (v0.1.21 if nobody tagged after v0.1.20) and confirm with `git ls-remote --tags origin` that it does not exist.
Run the gate (verify command). Release sheet: the proposed tag; the one-way surfaces the tag publishes (pact.TokenScope and pact.HasTokenScopes; bouncer.ScopeLimit and Principal.Scope; party.Activated; cabana.BackendAPIToken, APITokenPrefix, ResolveBackend, ErrUnauthenticated, ErrTokenScope; the four account token routes; the g15_ format from D-07; table backend_api_tokens and migration 202610110001_backend_api_tokens); commits to push per repository; the Postgres suites recorded NOT RUN in plans 01 to 03; open high findings from 16.3-REVIEW.md or 16.3-SECURITY.md if they exist (an open high finding allows only docker-first or hold, per security_block_on high); whether sm-summercmsio-app's Journal gitlink should also move (it would pull in Phase 16.2; default no); and that a UAT redeploy is a separate follow-up. Stop for the human.</action>
<decision>Push summercms master and the proposed annotated tag, push the Journal go.mod bump, and push the host journal gitlink bump now?</decision>
<context>A pushed tag is read by other repositories' go.mod and the Go module proxy and publishes the pact scope capability and the cabana resolver; it cannot be moved, only superseded. Once a host deploys it, issued g15_ tokens live in agent configs (D-07 one-way). The unit-test plan (04) runs after this release, as in 16.2 (lean rule 3); its tests ship with the next tag.</context>
<options>
<option id="release-now">
<name>Release now with the proposed tag</name>
<pros>Hosts and the Journal plugin get scoped tokens immediately; the main use case is usable</pros>
<cons>Postgres suites recorded NOT RUN run later on a Docker host; a defect then needs the next tag</cons>
</option>
<option id="docker-first">
<name>Run the Docker-host suites first, then release</name>
<pros>The cabana token, account, command and conformance suites, the lagoon migration tests and the Journal token tests all run before the tag exists</pros>
<cons>Needs a Docker-enabled machine and delays the release</cons>
</option>
<option id="hold">
<name>Hold the release</name>
<pros>Nothing leaves this machine</pros>
<cons>Journal stays JWT-only for hosts; agents cannot use tokens yet</cons>
</option>
</options>
<resume-signal>Reply "release vX" (release-now), "docker passed, release vX" (docker-first, after the human ran the suites), optionally adding "and summercmsio" to also bump sm-summercmsio-app, or "hold".</resume-signal>
<verify>
<automated>go vet ./... &amp;&amp; go -C ../sm-journal-plugin vet ./... &amp;&amp; go test -count=1 -run '^(TestTokenTracerSmoke|TestTokenGatesSmoke|TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestPhase10ConformanceInventory|TestPhase10CSRF)$' -v ./modules/cabana 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 6 &amp;&amp; go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/... &amp;&amp; go test -count=1 ./cmd/summer -run 'TestDocsTree|TestDelegateCommandsHelp' &amp;&amp; go run ./cmd/summer docs:build --check &amp;&amp; npm --prefix admin test &amp;&amp; bash scripts/check-admin-dist.sh &amp;&amp; bash scripts/check-admin-openapi.sh --check &amp;&amp; echo release-gate-ok</automated>
<fails_when>Non-zero exit or "release-gate-ok" absent: vet fails in either repository, fewer than six "--- PASS: Test" lines for the cabana token and inventory set, a Journal classes or controllers test fails, a docs check fails, an admin vitest case fails, or a dist or OpenAPI drift gate reports stale output.</fails_when>
</verify>
<acceptance_criteria>
- The gate printed release-gate-ok and the three repositories were clean and not behind origin.
- The SUMMARY records the release sheet (tag, one-way surfaces, commits per repository, suites NOT RUN, review findings, the sm-summercmsio-app question) and the human's reply verbatim.
</acceptance_criteria>
<done>A human chose release-now or docker-first with a named tag, or hold (the plan stops).</done>
</task>
<task type="auto">
<name>Task 4: Release: annotated summercms tag, Journal go.mod bump, host journal gitlink bump, all pushed</name>
<reversibility rating="one-way">A pushed annotated summercms tag publishes pact.HasTokenScopes and cabana.ResolveBackend to other repositories' go.mod and the Go module proxy (only supersedable), and lets hosts issue g15_ tokens in the D-07 format that then live in agent configs.</reversibility>
<precondition>Task 3 was answered "release vX" or "docker passed, release vX"; RELEASE_TAG is that tag in the session; `git ls-remote --tags origin "refs/tags/$RELEASE_TAG"` prints nothing unless Task 3 recorded a reused tag.</precondition>
<files>../sm-journal-plugin/go.mod, ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal</files>
<read_first>this plan's Task 3 release sheet in the SUMMARY, ../sm-journal-plugin/go.mod, ../sm-journal-plugin/go.work, ../sm-grzybyfunkcjonalne-app/.gitmodules, .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-07-SUMMARY.md (Release table)</read_first>
<action>Framework: unless Task 3 recorded a reused tag, re-run `git log --oneline HEAD..origin/master` (must print nothing), create the annotated tag on summercms.go master HEAD with message "$RELEASE_TAG: personal admin API tokens with scopes (pact.HasTokenScopes, cabana.ResolveBackend, admin:token commands, My account API tokens panel)", then push master and the tag.
sm-journal-plugin: `go -C ../sm-journal-plugin mod edit -require=git.golem15.com/golem15/summercms@$RELEASE_TAG` (the go.work replace stays, so the require is nominal); `go -C ../sm-journal-plugin vet ./...`; commit go.mod alone as "chore(deps): require summercms $RELEASE_TAG"; push main (this publishes Tasks 1 and 2).
Host ../sm-grzybyfunkcjonalne-app: in plugins/golem15/journal fetch origin and check out the sm-journal-plugin main just pushed; commit the gitlink alone as "chore: bump journal plugin (personal admin API tokens)" and push master. Only when the human's reply included "and summercmsio", do the same for ../sm-summercmsio-app/plugins/golem15/journal and report its build result. Record every pushed head and the tag object in the SUMMARY.</action>
<verify>
<automated>test -n "$(git ls-remote --tags origin "refs/tags/$RELEASE_TAG")" &amp;&amp; test "$(git -C ../sm-journal-plugin rev-parse main)" = "$(git -C ../sm-journal-plugin rev-parse origin/main)" &amp;&amp; grep -q "git.golem15.com/golem15/summercms $RELEASE_TAG" ../sm-journal-plugin/go.mod &amp;&amp; test "$(git -C ../sm-grzybyfunkcjonalne-app ls-files -s plugins/golem15/journal | cut -d' ' -f2)" = "$(git -C ../sm-journal-plugin rev-parse main)" &amp;&amp; test "$(git -C ../sm-grzybyfunkcjonalne-app rev-parse master)" = "$(git -C ../sm-grzybyfunkcjonalne-app rev-parse origin/master)" &amp;&amp; echo release-ok</automated>
<fails_when>Non-zero exit or "release-ok" absent: the tag is not on origin, Journal main is not pushed, go.mod does not require the tag, the host gitlink does not point at the pushed Journal head, or the host master is not pushed.</fails_when>
</verify>
<acceptance_criteria>
- `git ls-remote --tags origin` lists the release tag (or the SUMMARY names the reused tag).
- The verify prints release-ok.
- The SUMMARY records the tag, every pushed head and the commit messages; no credential appears in it.
</acceptance_criteria>
<done>The framework tag, the Journal plugin requiring it and the host bump are on origin.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| agent → Journal API | Bearer JWT or g15_ token; untrusted |
| Journal handlers → ownership checks | CanEdit and canViewDraft trust the principal the resolver returns |
| executor → git origin | tags and branches are read by other repositories |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-16.3-18 | Elevation of Privilege | Journal ownership branches (CanEdit, canViewDraft, featured images) | high | mitigate | Every Journal principal comes from cabana.ResolveBackend with cover golem15.journal.*; a token without Journal coverage gets 403 on writes and is anonymous on reads; TestJournalTokenExpansion pins it |
| T-16.3-19 | Elevation of Privilege | journal:read on write routes | high | mitigate | Method filter in the resolver refuses non-GET/HEAD before the handler; 403 Insufficient permissions |
| T-16.3-20 | Spoofing / Tampering (CSRF) | Journal write routes | high | mitigate | The resolver reads only the Bearer header (plan 01), so the admin cookie cannot authenticate a Journal write; Journal keeps its Bearer-only contract |
| T-16.3-21 | Information Disclosure | Journal error bodies | low | mitigate | Only the existing PHP constants are written; resolver error text never reaches the client |
| T-16.3-22 | Tampering / Repudiation | tag and pushes | high | mitigate | Clean, not-behind trees and an absent tag checked at a blocking-human checkpoint; one-way surfaces and NOT RUN suites listed for the human; heads recorded in the SUMMARY; no pushed tag moved |
| T-16.3-23 | Elevation of Privilege | code-declared role grants on the Journal API | low | accept | Intended fix (the admin API already honors them); documented in the Journal README as a behaviour change |
| T-16.3-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package is installed; go mod edit changes only the nominal summercms require; builds use committed go.sum and lockfiles |
</threat_model>
<verification>
- Task 1: Journal vet, resolver mapping test, classes and controllers tests; the Postgres tracer on a Docker host.
- Task 2: example compiles, docs checks pass; the Journal token matrix on a Docker host.
- Task 3: release-gate-ok and a human decision.
- Task 4: release-ok.
</verification>
<success_criteria>
- SC-4: an agent manages Journal posts with a journal-scoped token as a backend author.
- SC-2 for plugin routes: read, unrelated and revoked tokens are refused with the PHP bodies.
- SC-5: Journal README and the compiled ResolveBackend example are in place.
- The release is pushed only after human approval.
</success_criteria>
<output>
Create `.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-03-SUMMARY.md` when done, with the commits per repository, the release sheet, the human's reply, the tag and pushed heads, and the Postgres tests run or NOT RUN.
</output>

View File

@@ -0,0 +1,238 @@
---
phase: 16.3-backend-admin-personal-api-tokens-with-scopes
plan: 04
type: execute
wave: 4
depends_on: ["16.3-01", "16.3-02", "16.3-03"]
files_modified:
- modules/bouncer/context_test.go
- modules/party/registry_test.go
- modules/lagoon/backend_admin_migrations_test.go
- modules/cabana/token_scopes_test.go
- modules/cabana/api_tokens_test.go
- modules/cabana/token_guard_test.go
- modules/cabana/token_routes_test.go
- modules/cabana/token_postgres_test.go
- modules/cabana/account_tokens_test.go
- modules/cabana/token_commands_test.go
- admin/tests/account/ApiTokensPanel.test.ts
- admin/tests/account/ApiTokenCreateDialog.test.ts
- ../sm-journal-plugin/token_scopes_test.go
- ../sm-journal-plugin/journal_api_tokens_test.go
- scripts/check-phase16.3.sh
- .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-VALIDATION.md
autonomous: true
requirements: [SC-6, SC-1, SC-2, SC-3, SC-4]
estimate:
tokens: 140000
raw_tokens: 140000
tasks: 3
confidence: low
must_haves:
truths:
- "CLAUDE.md lean rule 3 and SC-6: every piece of Phase 16.3 code has unit tests in this last plan: bouncer ScopeLimit, party Activated, the lagoon migration, cabana scope match, Allows, compile, overlap, mint, expiry, request validation, the composite guard, the debounce, ResolveBackend, interactive-only, the route gates and their classification, the account token API, the CLI, the SPA panel and dialog, and the Journal scopes and token matrix."
- "D-14, D-03 and D-05 pinned by table: TestAllowsScope covers JWT principals unchanged, full scopes (`*` every method, admin:read on GET/HEAD), a plugin scope with owner grant, without owner grant, superuser with a scoped token, empty requirement list, multi-code lists under the literal per-scope reading (one scope must match every code), two scopes each matching part of a list, and wildcard requirements against broader, equal and narrower patterns; TestScopeMatch covers every row of the plan 01 match table."
- "D-05 and D-06 for every route: TestTokenRouteGates walks every entry of phase09Routes and asserts its class (public, open to any token: GET /auth/me, interactive-only, explicitly gated, gated through Allows by protect, navigation or settings) and the answer a `*` token, an admin:read token and a plugin-scoped token get; adding a route without a class fails the test."
- "D-13, D-07 to D-11 pinned on the guard: well-formedness (length, prefix, upper-case hex, non-hex), prefix routing (JWT and cookie untouched, g15_ in a cookie or a query string never authenticates), unknown, expired, revoked, deactivated and soft-deleted owners → 401, all-unknown scopes → 401, method refusal → 403 token_scope, the credential is the token row, a failed stamp does not fail auth, and the stamp runs at most once per minute; on Postgres a password reset and a tokens_valid_after cutoff leave the token working."
- "D-16 to D-19 pinned: the account token API (list own newest first, revoked filter, scope entries with known false for unknown names, 201 with plaintext once and never again, 422 for every validation rule, `*` normalization, own-only soft revoke, scopes picker overlap) and the CLI (stdout of admin:token:create is exactly the token and a newline, overlap refusal, deactivated or deleted targets refused, list never prints hash or plaintext, revoke of another user's token, scopes listing with and without party.Activated) are tested on Postgres; request validation and expiry parsing are tested without a database."
- "D-15 and SC-4 pinned on Postgres for the Journal: every write route (store, update, destroy, both featured-image routes, media upload) with journal, `*`, journal:read, admin:read, an unrelated scope, revoked and malformed tokens, with byte-identical bodies; optional-auth reads; the JWT path unchanged; the publish permission still enforced under a token."
- "UI-SPEC backstops: the plaintext is absent from the DOM, component state and toasts after Done and after an Esc-confirm close and reopening starts at step 1; the copy flow announces and the clipboard failure selects and alerts; Esc before copying asks and cancel keeps step 2."
- "scripts/check-phase16.3.sh runs every runnable suite fail-closed (stages go, admin, docs, postgres; --self-test proves the detectors fail on planted output; --all ends with 'Phase 16.3 gate passed' and says NOT RUN postgres loudly without Docker); 16.3-VALIDATION.md has every row filled and nyquist_compliant true."
artifacts:
- path: "modules/cabana/token_scopes_test.go"
provides: "scope match, Allows, compile and overlap tables"
contains: "TestAllowsScope"
- path: "modules/cabana/token_routes_test.go"
provides: "route classification and interactive-only tests"
contains: "TestTokenRouteGates"
- path: "modules/cabana/account_tokens_test.go"
provides: "Postgres account token API tests"
contains: "TestAccountTokens"
- path: "../sm-journal-plugin/journal_api_tokens_test.go"
provides: "Postgres Journal token matrix"
contains: "TestJournalTokens"
- path: "scripts/check-phase16.3.sh"
provides: "phase gate"
contains: "Phase 16.3 gate passed"
key_links:
- from: "modules/cabana/token_routes_test.go"
to: "modules/cabana/security_coverage_test.go phase09Routes"
via: "every inventoried route must have a token class"
pattern: "phase09Routes"
- from: "scripts/check-phase16.3.sh"
to: "every suite named in 16.3-VALIDATION.md"
via: "fail-closed stages and required test name lists"
pattern: "check-phase16.3"
prohibitions:
- statement: "No test touches a shared database, a server, git origin or the network: pure tests use stubs and in-memory stores; Postgres tests use throwaway testcontainers databases."
status: unresolved
verification: judgment
- statement: "Tests are not weakened to pass: existing assertions in the cabana, lagoon, bouncer, party and Journal suites stay; production code changes only in separate fix(...) commits with their own test, named in the SUMMARY."
status: unresolved
verification: judgment
- statement: "No test prints or logs a real token outside the test process; fixtures never contain credentials beyond test secrets already used by the suites."
status: unresolved
verification: judgment
---
## Phase Goal
ROADMAP Goal (not in user-story format): backend users create personal API tokens with scopes picked at creation time, so an agent or integration calls the admin and plugin APIs as that backend user with no more access than the token's scope and the user's own permissions allow.
This plan's slice (CLAUDE.md lean rule 3, user checkpoint plan 4 of 4): full unit coverage of the phase across summercms.go (Go and admin SPA) and sm-journal-plugin, the Postgres tests for a Docker host, the phase gate `scripts/check-phase16.3.sh` following the 16.2 gate pattern, and the completed validation map. Tests only; any defect found is fixed in a separate `fix(...)` commit with its own test.
## Spec-less probe fallback
Visible skip: "spec-less probe fallback skipped: phase has no requirement IDs to probe". Prohibitions above are recalled in-prompt and descriptor-less (flagged-unverified).
## Artifacts this phase produces (this plan)
- Go tests, Docker-free: `TestPrincipalScopeNotSerialized` (bouncer); `TestActivatePublishesPlugins` extended (party); `TestScopeMatch`, `TestAllowsScope`, `TestTokenScopeCompile`, `TestScopeOverlap`, `TestScopeCoversAll` (cabana token_scopes_test.go); `TestAPITokenMint`, `TestParseTokenExpiry`, `TestValidateTokenRequest` (api_tokens_test.go); `TestBackendTokenGuard`, `TestTokenLastUsedDebounce`, `TestResolveBackend` (token_guard_test.go); `TestTokenRouteGates`, `TestInteractiveOnly` (token_routes_test.go); `TestTokenCommandsRegistered` (token_commands_test.go); `TestJournalTokenScopes` (Journal root, `-short`).
- Go tests, Postgres (Docker host): `TestBackendAPITokensMigration` (lagoon); `TestBackendTokenPostgres` (token_postgres_test.go); `TestAccountTokens` (account_tokens_test.go); `TestTokenCommands` (token_commands_test.go); `TestJournalTokens` (Journal root).
- Vitest: extended `admin/tests/account/ApiTokensPanel.test.ts`, new `admin/tests/account/ApiTokenCreateDialog.test.ts`.
- `scripts/check-phase16.3.sh` (stages `go`, `admin`, `docs`, `postgres`; flags `--self-test`, `--all`; env `PHASE163_ROOT`, `JOURNAL`, `PHASE163_REQUIRE_POSTGRES`).
- 16.3-VALIDATION.md completed.
<objective>
Bring full unit coverage to Phase 16.3 (lean rule 3), add the Postgres tests for a Docker host, write the fail-closed phase gate and complete the validation map.
Purpose: every security rule the phase ships (owner ∩ scope, deny by default, interactive-only, show-once plaintext, Journal coverage) is pinned by a test that fails when it regresses.
Output: the test files above, scripts/check-phase16.3.sh, an updated 16.3-VALIDATION.md.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-VALIDATION.md
@.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-01-SUMMARY.md
@.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-02-SUMMARY.md
@.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-03-SUMMARY.md
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-06-PLAN.md
Without Docker run Go tests with explicit `-run` sets (and `-short` in packages whose TestMain starts Postgres: modules/lagoon, the Journal root package); the testcontainers tests are written to fail rather than skip without a database and are listed for the Docker host. Helpers to reuse: the in-memory token store and stub users from `TestTokenTracerSmoke`, `captureRouter` and `phase09Routes` (security_coverage_test.go), `adminGorm`, `adminHandler`, `insertAdmin`, `commandApp`, `flagInput`, `commandByName` (cabana_test), `newPostsEnv`, `e.admin`, `e.call`, `adminAPI` (Journal harness), `mountApp`, `requestsTo` (admin tests/helpers.ts). Commits: tests only, `test(16.3-04): …`, no co-author tags, stage by explicit path in each repository; VALIDATION.md in its own docs commit.
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer: the deny-by-default contract pinned at every layer (scope rules, guard, every route, SPA plaintext, Journal coverage)</name>
<files>modules/cabana/token_scopes_test.go, modules/cabana/token_routes_test.go, admin/tests/account/ApiTokenCreateDialog.test.ts, ../sm-journal-plugin/token_scopes_test.go, modules/cabana/token_postgres_test.go</files>
<read_first>modules/cabana/token_scopes.go, modules/cabana/contracts.go (Allows, granted), modules/cabana/token_guard.go, modules/cabana/http.go (mount), modules/cabana/security_coverage_test.go (phase09Routes, captureRouter, TestPhase09PermissionMatrix), modules/cabana/token_smoke_test.go (in-memory store, stub users), 16.3-01-PLAN.md (Decisions: match table, D-03 literal reading, overlap rule), admin/src/components/account/ApiTokenCreateDialog.vue, admin/tests/account/ApiTokensPanel.test.ts, ../sm-journal-plugin/token_scopes.go, ../sm-journal-plugin/lang/en/lang.yaml and pl/lang.yaml, modules/cabana/auth_test.go (adminGorm, adminHandler, insertAdmin)</read_first>
<action>Write one test per layer for the phase's central promise (SC-2: never beyond the owner, never outside the scope).
Scope rules (token_scopes_test.go): `TestScopeMatch` with one case per row of the plan 01 match table (full, partial, none, leading wildcard); `TestAllowsScope` table per the D-14/D-03/D-05 truth of this plan, including the literal multi-code reading and a superuser holding a plugin scope.
Every route (token_routes_test.go, per D-05 and D-06): `TestTokenRouteGates` builds a class map for every phase09Routes key (public; open to any token; interactive-only; explicit gate; Allows through protect, navigation or settings) and fails for any inventoried route without a class; for each class it drives the real handler wrapper chain from `service.mount` with principals for a `*` token, an admin:read token on GET, and a plugin-scoped token, and asserts the status and details.reason (interactive_only or token_scope) the class requires.
SPA (ApiTokenCreateDialog.test.ts, UI-SPEC backstop "plaintext never retained"): after Done, hide token and after an Esc-then-confirm close, the token string is absent from document.body.innerHTML, the dialog exposes no plaintext in its state, no toast text contains it, and reopening shows step 1.
Journal (token_scopes_test.go, Docker-free with -short): `TestJournalTokenScopes` asserts the two scopes, their permissions and methods, and that every label and description key exists in lang/en and lang/pl.
Postgres (token_postgres_test.go, Docker host): `TestBackendTokenPostgres` covers the D-10 owner lifecycle through the real admin API (deactivate → 401, reactivate → 200, soft-delete → 401), a password reset through admin:reset-password and a tokens_valid_after cutoff leaving the token valid, revoked and expired rows → 401, last_used_at set once within a minute for two requests.</action>
<verify>
<automated>go vet ./modules/cabana &amp;&amp; go test -count=1 -run '^(TestScopeMatch|TestAllowsScope|TestTokenRouteGates)$' -v ./modules/cabana 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 3 &amp;&amp; npm --prefix admin test -- tests/account/ApiTokenCreateDialog.test.ts &amp;&amp; go -C ../sm-journal-plugin test -short -count=1 -run '^TestJournalTokenScopes$' -v . 2&gt;&amp;1 | grep -- '^--- PASS: TestJournalTokenScopes'</automated>
<fails_when>Non-zero exit: fewer than three passing cabana tests (a scope rule regressed or a mounted route has no class or the wrong answer), the plaintext backstop fails, or the Journal scope test fails (missing phrase key or changed scope).</fails_when>
<automated>docker info &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go test -count=1 -run '^TestBackendTokenPostgres$' -v ./modules/cabana 2&gt;&amp;1 | grep -- '^--- PASS: TestBackendTokenPostgres'</automated>
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or an owner lifecycle, reset, cutoff, revoke, expiry or stamp expectation fails.</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "func TestTokenRouteGates" modules/cabana/token_routes_test.go` prints one line and the test references `phase09Routes`.
- `grep -c "func Test" modules/cabana/token_scopes_test.go` reports at least 2 at this point.
- The three Docker-free checks pass; the Postgres test passes on a Docker host or is recorded NOT RUN.
</acceptance_criteria>
<done>The scope rules, every route's token behaviour, the SPA plaintext lifetime and the Journal scope registration are pinned, with the owner lifecycle on Postgres.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Full Go coverage: bouncer, party, lagoon migration, cabana compile, overlap, mint, expiry, validation, guard, debounce, resolver, interactive-only, account API, CLI; Journal token matrix</name>
<files>modules/bouncer/context_test.go, modules/party/registry_test.go, modules/lagoon/backend_admin_migrations_test.go, modules/cabana/token_scopes_test.go, modules/cabana/api_tokens_test.go, modules/cabana/token_guard_test.go, modules/cabana/token_routes_test.go, modules/cabana/account_tokens_test.go, modules/cabana/token_commands_test.go, ../sm-journal-plugin/journal_api_tokens_test.go</files>
<read_first>modules/bouncer/context.go and context_test.go, modules/party/registry.go and registry_test.go, modules/lagoon/backend_admin_migrations.go and backend_admin_migrations_test.go, modules/cabana/api_tokens.go, modules/cabana/token_scopes.go, modules/cabana/token_guard.go, modules/cabana/account_tokens.go, modules/cabana/token_commands.go, modules/cabana/registry.go (compileTokenCatalog), modules/cabana/commands_test.go (TestAdminCreateCommand, commandApp, flagInput), modules/cabana/auth_test.go (adminHandler, insertAdmin), modules/cabana/openapi_conformance_test.go, ../sm-journal-plugin/journal_api_token_smoke_test.go, ../sm-journal-plugin/journal_api_writes_test.go, ../sm-journal-plugin/admin_harness_test.go</read_first>
<behavior>
- bouncer: a Principal with a Scope marshals to JSON without any scope field; a nil Scope is the zero value.
- party: Activated is published in activation (dependency) order; a second Activate on the same app returns an error.
- lagoon (Postgres): the migration creates backend_api_tokens with a unique token_hash, the backend_user_id FK cascades on a hard user delete and revoked_by is set NULL when the revoker is deleted; rollback drops the table and leaves backend_users; a fresh full rollback and re-migrate works.
- compile: every boot rule fails with its message (bad name, reserved `*`/admin/admin:x, duplicate naming both plugins, empty permissions, empty methods, lower-case or unknown method, missing GET, unknown permission, empty label, unresolved phrase key) and valid plugin scopes follow the framework scopes in activation order.
- overlap and cover: scopeOverlaps true only with a catalog code the owner holds under a full match; `*` always; admin:read for superusers or any catalog grant; scopeCoversAll for full, partial and none.
- mint and expiry: 1000 minted tokens are well formed, distinct, hash to 64 hex and hint to the last four; parseTokenExpiry for 30d, 90d, 365d, never, absent (90d), a future date (00:00 UTC), today or a past date and garbage, in API and CLI mode.
- validateTokenRequest: blank and over-100-rune names, invalid expiry, empty scopes, unknown scope, non-overlapping scope, `*` normalization, duplicate removal in order.
- guard (stubs): the guard matrix in this plan's truths; the stamp error path logs and returns the principal; the debounce gate skips a second stamp within a minute and allows one after.
- ResolveBackend: no app, no resolver, no Bearer, cookie-only JWT (refused, Bearer only), Bearer JWT (cover ignored), token with full scope, token with matching and non-matching cover, method refusal; the returned request carries the user and, for a token, the credential.
- interactive-only: token credential and raw g15_ Bearer refused with reason interactive_only on each wrapped route; a JWT session passes.
- account API (Postgres): list ordering, revoked filter, scope entries (known label, `*` label, unknown with known false, read_only), 201 shape with plaintext only there, every 422 rule with the server message text, `*` normalization stored, revoke own (200), other user's (404), already revoked (404), scopes picker filtered per user (developer vs a user without Journal-like grants vs superuser).
- CLI (Postgres): create stdout is exactly one line g15_ plus 40 hex with nothing else, ambiguous identifier refused, deactivated and soft-deleted targets refused, non-overlapping scope refused, --expires date accepted; list without and with identifier and --revoked shows hints and never hash or plaintext; revoke another user's token (revoked_by NULL), unknown id errors, already revoked exits 0; scopes with and without a published party.Activated. Docker-free TestTokenCommandsRegistered: four commands, scope flag Repeatable, RuntimeCommands constructs with an empty app.
- Journal (Postgres): TestJournalTokens: every write route × {journal, `*`, journal:read, admin:read, unrelated scope, revoked, malformed} with exact bodies; published=true under a journal token without access_publish → 403 "You do not have permission to publish posts"; optional-auth Index and Show with each token; the JWT path unchanged.
</behavior>
<action>Write the tests in the behavior block (per D-01 to D-19), table-driven where cases share a shape, extending the plan 01 and plan 03 smoke files' fixtures instead of duplicating them. Docker-free tests use stubs, in-memory stores and httptest; Postgres tests reuse adminGorm/adminHandler/commandApp and the Journal harness and fail rather than skip without Docker. Do not change production code; a defect gets its own fix(...) commit with a test, recorded in the SUMMARY.</action>
<verify>
<automated>go vet ./modules/bouncer ./modules/party ./modules/lagoon ./modules/cabana &amp;&amp; go test -count=1 ./modules/party &amp;&amp; go test -count=1 -run '^TestPrincipalScopeNotSerialized$' -v ./modules/bouncer 2&gt;&amp;1 | grep -- '^--- PASS: TestPrincipalScopeNotSerialized' &amp;&amp; go test -count=1 -run '^(TestScopeMatch|TestAllowsScope|TestTokenScopeCompile|TestScopeOverlap|TestScopeCoversAll|TestAPITokenMint|TestParseTokenExpiry|TestValidateTokenRequest|TestBackendTokenGuard|TestTokenLastUsedDebounce|TestResolveBackend|TestTokenRouteGates|TestInteractiveOnly|TestTokenCommandsRegistered|TestTokenTracerSmoke|TestTokenGatesSmoke)$' -v ./modules/cabana 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 16</automated>
<fails_when>Non-zero exit: vet fails, the party package fails, the bouncer serialization test is missing or fails, or fewer than sixteen "--- PASS: Test" lines for the named cabana Docker-free tests (one missing or failing).</fails_when>
<automated>docker info &gt;/dev/null 2&gt;&amp;1 &amp;&amp; go test -count=1 -run '^(TestBackendAPITokensMigration|TestBackendAdminMetadataColumn|TestPhase09MigrationsFreshRollback)$' -v ./modules/lagoon 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 3 &amp;&amp; go test -count=1 -run '^(TestAccountTokens|TestTokenCommands|TestBackendTokenPostgres|TestPhase10OpenAPIConformance)$' -v ./modules/cabana 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 4 &amp;&amp; go -C ../sm-journal-plugin test -count=1 -run '^(TestJournalTokens|TestJournalTokenTracer|TestJournalTokenExpansion)$' -v . 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 3</automated>
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or a lagoon migration, account API, CLI, Postgres guard, OpenAPI conformance or Journal token test fails or is missing.</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "func TestAccountTokens(t" modules/cabana/account_tokens_test.go`, `grep -n "func TestTokenCommands(t" modules/cabana/token_commands_test.go`, `grep -n "func TestBackendAPITokensMigration" modules/lagoon/backend_admin_migrations_test.go` and `grep -n "func TestJournalTokens(" ../sm-journal-plugin/journal_api_tokens_test.go` each print one line, and those files pass go vet in their repositories.
- For each repository, `git diff --stat "$TASK2_START"..HEAD` (TASK2_START recorded in the SUMMARY before this task) lists only *_test.go files, except files of separate fix(...) commits the SUMMARY names.
</acceptance_criteria>
<done>Every Go behaviour of the phase has a passing unit test, and the database-backed paths have Postgres tests for the Docker host.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Full SPA coverage, the phase gate script, and the validation map</name>
<files>admin/tests/account/ApiTokensPanel.test.ts, admin/tests/account/ApiTokenCreateDialog.test.ts, scripts/check-phase16.3.sh, .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-VALIDATION.md</files>
<read_first>admin/src/components/account/ApiTokensPanel.vue, admin/src/components/account/ApiTokenCreateDialog.vue, admin/tests/account/ApiTokensPanel.test.ts, admin/tests/helpers.ts, 16.3-UI-SPEC.md (Component Contracts 2-7 and UI Considerations), scripts/check-phase16.2.sh (structure, detectors, --self-test, --all, NOT RUN postgres), .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-VALIDATION.md</read_first>
<behavior>
- Panel: skeleton with aria-busy on first load and on toggle; empty block (also with the toggle on); load failure alert hides the toggle and keeps Create enabled; row anatomy (name, hint aria-label, chips: known label with title, `*` Full access, unknown dashed with hint title, Expired, Revoked), meta line formats and order, revoke button visibility per state; revoke success toast and focus, 404 gone toast and reload, other failure keeps the row; ConfirmDialog message wraps.
- Dialog step 1: focus on open; scopes skeleton and submit disabled while pending; scopes failure and empty answer show the alert; `*` checked disables and unchecks others, shows the note and sends only [`*`]; unchecking restores enabled rows without picks; no scope preselected; 90d preselected and the preview date for 30, 90, 365 and the never note; 422 for name, expires and scopes renders messages with aria-invalid and focuses the first invalid control; busy disables fieldset, buttons, Esc and overlay and ignores a second submit; non-422 failure toast keeps input; any close discards input; focus returns to Create token.
- Dialog step 2 (backstops): copy success state for two seconds and the polite announcement; rejected or missing clipboard selects the input and shows the alert; manual copy event counts; overlay never closes; Esc and close before copy ask and cancel keeps step 2, confirm closes; after copy they close at once; Done closes without confirm; plaintext absent afterwards; focus on Copy token on entering step 2.
- Gate: stages go (vet in both repositories; the Docker-free framework, cmd/summer and Journal tests by required name, Journal root with -short), admin (typecheck and full vitest with required case names), docs (TestDocsTree, summer docs:build --check, check-admin-openapi.sh --check, check-admin-dist.sh), postgres (Docker host: lagoon, cabana and Journal suites by required name); fail-closed detectors for failures, skips, zero tests, "no tests to run" and missing names; --self-test plants each failure; --all ends with "Phase 16.3 gate passed" and prints NOT RUN postgres without Docker unless PHASE163_REQUIRE_POSTGRES=1.
</behavior>
<action>Write the vitest cases in the behavior block (per D-08, D-09, D-11, D-16, D-17 and the UI-SPEC) in the two admin/tests/account files with stubbed fetch routes and a stubbed navigator.clipboard. Write scripts/check-phase16.3.sh in the check-phase16.2.sh style (set -euo pipefail, unset FORCE_COLOR, ROOT from the script path, overridable JOURNAL, required-name lists per stage covering every test this phase added, the same go-json and vitest-json detectors, --self-test, --all, NOT RUN postgres). Update 16.3-VALIDATION.md: replace each planned row's status with the real result, fix test file paths that differ from the plan, tick Wave 0, set wave_0_complete and nyquist_compliant to true, keep the Manual-Only rows (real-browser clipboard; Postgres suites on a Docker host when NOT RUN). Commit the tests as test(16.3-04), the script as "test(16.3): phase gate", and VALIDATION.md separately as docs(16.3).</action>
<verify>
<automated>npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test &amp;&amp; bash scripts/check-phase16.3.sh --self-test &amp;&amp; bash scripts/check-phase16.3.sh --all 2&gt;&amp;1 | tail -1 | grep -qx 'Phase 16.3 gate passed'</automated>
<fails_when>Non-zero exit: a type error or failing vitest case, a detector self-test that does not fail on planted output, or a --all run whose last line is not exactly "Phase 16.3 gate passed" (a stage failed or a required test is missing).</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "Phase 16.3 gate passed" scripts/check-phase16.3.sh` prints at least one line and the --all run ends with that line.
- `grep -n "nyquist_compliant: true" .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-VALIDATION.md` prints one line and `grep -c "TBD" .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-VALIDATION.md` reports 0.
- The full admin suite passes.
</acceptance_criteria>
<done>The SPA is fully tested against the UI-SPEC, the phase gate passes here with the Docker suites named, and the validation map is complete.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| tests → databases | Postgres tests start throwaway containers; nothing else is dialled |
| tests → network | no test reaches a server, a registry or git origin |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-16.3-24 | Repudiation (false green) | phase gate | medium | mitigate | Fail-closed detectors for failures, skips, zero tests, "no tests to run" and missing required names; --self-test plants each; Docker suites are named and reported NOT RUN instead of silently skipped |
| T-16.3-25 | Tampering (security regression) | Allows, guard, route gates, plaintext lifetime | high | mitigate | Table tests for the scope rules, a classification test that fails for any unclassified route, guard and resolver matrices, the Journal token matrix and the SPA plaintext backstop all run in the gate; production changes only through separate fix commits |
| T-16.3-26 | Tampering | test isolation | low | mitigate | Stubs, in-memory stores, httptest and happy-dom for unit tests; testcontainers for Postgres; no shared database or network |
| T-16.3-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package is installed; every runner uses the committed lockfiles and go.sum |
</threat_model>
<verification>
- Task 1: scope rules, route classification, SPA plaintext backstop and Journal scopes pass; the Postgres guard test on a Docker host.
- Task 2: the named Docker-free Go tests pass; the Postgres suites on a Docker host.
- Task 3: full admin suite, gate self-test and --all pass; VALIDATION.md complete.
- Before /msd-verify-work, on a Docker host: `bash scripts/check-phase16.3.sh postgres`.
</verification>
<success_criteria>
- SC-6: full unit coverage of the phase's code in the last plan, Postgres tests ready for a Docker host, a passing fail-closed phase gate and a complete validation map.
</success_criteria>
<output>
Create `.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-04-SUMMARY.md` when done, with test counts per repository, any fix(...) commits, and the Docker-host command list.
</output>

View File

@@ -0,0 +1,468 @@
# Phase 16.3: Backend admin personal API tokens with scopes - Pattern Map
**Mapped:** 2026-10-11
**Files analyzed:** 22 (new + modified, across summercms.go and ../sm-journal-plugin)
**Analogs found:** 21 / 22
All analog paths are git-tracked source (user plugin analogs verified with `git ls-files` inside the `sm-user-plugin` submodule of `../sm-summercmsio-app`).
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| `modules/pact/capabilities.go` (+ `TokenScope`, `HasTokenScopes`) | contract/interface | config | `modules/pact/capabilities.go` `Permission`/`HasPermissions` (~:495-506) | exact |
| `modules/bouncer/context.go` (+ `ScopeLimit`, `Principal.Scope`) | model (context value) | request-response | existing `Principal` fields `Backend`/`IsSuperuser`/`PermissionGrants` (`json:"-"`) | exact |
| `modules/lagoon/backend_admin_migrations.go` (+ token table) | migration | batch/DDL | same file, entry `202610090001_backend_users_metadata` (:121-132) | exact |
| `modules/lagoon/backend_admin_migrations_test.go` (fix last-ID test) | test | — | `TestBackendAdminMetadataColumn` (:561-575) | exact |
| `modules/cabana/api_tokens.go` (model, mint, hash, revoke, debounce) | model/service | CRUD | `sm-user-plugin/classes/api_tokens.go` + `models/api_token.go` | exact |
| `modules/cabana/token_scopes.go` (compile, cover, overlap) | service | transform | `modules/cabana/registry.go` `compileContributions` (:169-200), `validatePermissions` (:301-308) | role-match |
| `modules/cabana/token_guard.go` (composite guard, resolver) | middleware/guard | request-response | `sm-user-plugin/middleware/api_token.go` `APITokenGuard` | exact |
| `modules/cabana/contracts.go` (`Allows` rewrite) | utility | transform | itself (:186-235) | exact |
| `modules/cabana/csrf.go` or `token_guard.go` (`interactiveOnly`) | middleware (route wrapper) | request-response | `requireAjax` (csrf.go:22-30) | exact |
| `modules/cabana/account_tokens.go` (4 endpoints) | controller | CRUD | `modules/cabana/account.go` (`accountUpdate` :289, `accountPrincipal` :216, `writeAccountError` :495) | exact |
| `modules/cabana/http.go` (guard registration :145-160, mount :243-260, Publish resolver) | config/route | request-response | itself | exact |
| `modules/cabana/admin_openapi.go` (swag stubs) | config (docs) | — | `AdminAccountUpdate` / `AdminMe` stubs (:190-218) | exact |
| `modules/cabana/commands.go` (`admin:token:*`) | command | CRUD | `admin:create` / `admin:reset-password` (:18-50, :52-104) | exact |
| `modules/party/registry.go` (publish activated plugins, Option A) | config | — | `app.SetPlugins(orderedIDs)` (:107) + `app.Publish(guards)` in cabana http.go:139 | role-match |
| `cmd/summer/runtime.go` (delegate rows) | config | — | rows for `admin:create`, `admin:reset-password` (:37-38) | exact |
| `modules/phrasebook/backend/lang/{en,pl}/lang.yaml` (`api_tokens:` block) | config (i18n) | — | existing `myaccount:` block | exact |
| `admin/src/components/account/ApiTokensPanel.vue` (+ `ApiTokenCreateDialog.vue`) | component | request-response | `admin/src/views/AccountView.vue` + `admin/src/components/relation/RelationPickerModal.vue` | role-match |
| `admin/src/views/AccountView.vue` (render panel) | component | — | itself (:247 section, :411 ConfirmDialog) | exact |
| `admin/src/components/ui/ConfirmDialog.vue` (`[overflow-wrap:anywhere]`) | component | — | itself | exact |
| `../sm-journal-plugin/token_scopes.go` | config (capability impl) | — | `../sm-journal-plugin/admin_permissions.go` | exact |
| `../sm-journal-plugin/controllers/api/auth.go` (resolver switch) | middleware helper | request-response | itself (:58-111) | exact |
| Tests: `modules/cabana/token_*_test.go`, `account_tokens_test.go`, `commands_test.go`, `admin/tests/components/ApiTokensPanel.test.ts`, journal `journal_api_tokens_test.go`, `scripts/check-phase16.3.sh` | test | — | `commands_test.go` (`TestAdminCreateCommand` :24), `auth_test.go` `adminHandler` (:367), `admin/tests/views/AccountView.test.ts`, `admin/tests/relation/RelationPickerModal.test.ts`, `scripts/check-phase16.2.sh` | exact |
| docs pages / READMEs (cabana, bouncer, pact, party, journal) | docs | — | existing module READMEs; `docs/services/authentication.md`, `docs/console/introduction.md` | role-match |
## Pattern Assignments
### `modules/cabana/token_guard.go` (guard, request-response)
**Analog:** `../sm-summercmsio-app/plugins/golem15/user/middleware/api_token.go` (tracked in sm-user-plugin submodule)
Interface assertions + struct (lines 21-33):
```go
type APITokenGuard struct {
App *backpack.App
DB func() *gorm.DB
Prefix string
ClientIP func(*http.Request) string
}
var (
_ bouncer.CredentialGuard = (*APITokenGuard)(nil)
_ bouncer.UnauthorizedWriter = (*APITokenGuard)(nil)
)
```
Core lookup (lines 35-80) — copy shape, but: route by `g15_` prefix and delegate non-token requests to the wrapped `NewBackendJWTGuard.Authenticate(r)`; validate `^g15_[0-9a-f]{40}$` before DB; add `revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now())`; owner via `lazyBackendUsers.FindByID`; **do not fail auth when the last_used write fails** (analog does — anti-pattern, research §Anti-Patterns):
```go
secret := BearerToken(r)
if secret == "" || !strings.HasPrefix(secret, prefix) {
return nil, nil, errInvalidAPIToken
}
...
db = db.WithContext(r.Context())
var token models.APIToken
result := db.Where("token_hash = ?", classes.HashAPIToken(secret)).Limit(1).Find(&token)
now := time.Now()
if result.Error != nil || result.RowsAffected == 0 || !token.IsUsableAt(now) {
return nil, nil, errInvalidAPIToken
}
principal, err := classes.PrincipalByID(r.Context(), db, token.UserID)
...
return principal, &token, nil
```
WriteUnauthorized (lines 84-86) — cabana version switches on `errors.As(err, *ScopeError)` → 403 `WriteErrorDetails(..., "forbidden", ..., {"reason":"token_scope"})`, else existing `writeUnauthenticated` 401 envelope:
```go
func (g *APITokenGuard) WriteUnauthorized(w http.ResponseWriter, _ error) {
wire.WriteJSON(w, http.StatusUnauthorized, map[string]any{"error": true, "message": "Invalid token."})
}
```
Bearer extraction (lines 119-129):
```go
func BearerToken(r *http.Request) string {
if r == nil { return "" }
token, ok := strings.CutPrefix(strings.TrimSpace(r.Header.Get("Authorization")), "Bearer ")
if !ok { return "" }
return strings.TrimSpace(token)
}
```
(cabana already has `bouncer/jwt.go` `bearerToken` :314 — export it or keep a local copy.)
Credential consumption pattern for `interactiveOnly` (analog `Scope` middleware, lines 89-102):
```go
credential, _ := bouncer.Credential(r.Context())
token, ok := credential.(*models.APIToken)
if !ok || token == nil || !token.HasScope(scope) { ... 403 ... }
```
**Registration point** — `modules/cabana/http.go` lines 145-160 (replace `guard` with the composite wrapping it):
```go
bl := adminBlacklist(app)
users := lazyBackendUsers{app: app, reg: reg}
guard := bouncer.NewBackendJWTGuard(secret, users, bl, writeUnauthenticated, AdminCookieName)
if owner, taken := guards.Owner("backend"); !taken {
if err := guards.Register("summercms.cabana", "backend", guard); err != nil {
return nil, fmt.Errorf("cabana: backend guard: %w", err)
}
} else if owner != "summercms.cabana" { ... }
```
Publish the resolver next to `app.Publish(guards)` (http.go ~:139) — same idiom.
---
### `modules/cabana/api_tokens.go` (model/service, CRUD)
**Analogs:** `sm-user-plugin/classes/api_tokens.go`, `sm-user-plugin/models/api_token.go`
Model (models/api_token.go :11-27) — copy tag style; table `backend_api_tokens`, field `BackendUserID`, add `TokenHint`, `RevokedBy *uint`; `Scopes lagoon.Jsonable[[]string]`:
```go
type APIToken struct {
ID uint `gorm:"column:id;primaryKey"`
UserID uint `gorm:"column:user_id"`
Name *string `gorm:"column:name"`
TokenHash string `gorm:"column:token_hash" json:"-"`
Scopes lagoon.Jsonable[[]string] `gorm:"column:scopes"`
ExpiresAt *time.Time `gorm:"column:expires_at"`
RevokedAt *time.Time `gorm:"column:revoked_at"`
LastUsedAt *time.Time `gorm:"column:last_used_at"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
func (APIToken) TableName() string { return "user_api_tokens" }
```
Mint / hash / revoke (classes/api_tokens.go :47-112) — keep name trim + dedupe scopes; change secret to `g15_` + `hex(20 bytes)` (RESEARCH "Mint and hash"), name max 100:
```go
name = strings.TrimSpace(name)
...
seenScopes := make(map[string]bool, len(scopes)) // dedupe, keep order
...
token := &models.APIToken{
UserID: userID, Name: &name, TokenHash: HashAPIToken(secret),
Scopes: lagoon.Jsonable[[]string]{Data: scopes, Valid: true}, ExpiresAt: expiresAt,
}
func HashAPIToken(secret string) string {
sum := sha256.Sum256([]byte(secret))
return hex.EncodeToString(sum[:])
}
func RevokeAPIToken(db *gorm.DB, token *models.APIToken) error {
now := time.Now()
if err := db.Model(token).Update("revoked_at", now).Error; err != nil { return err }
token.RevokedAt = &now
return nil
}
```
Constant-time compare: `modules/wristband/crypto.go:26-31`. Debounced stamp: RESEARCH "Debounced last_used_at" (sync.Map + conditional UPDATE, log on error via `slog.Default().WarnContext`).
---
### `modules/pact/capabilities.go` (+ `TokenScope`, `HasTokenScopes`)
**Analog:** same file (~:495-506):
```go
// Permission is one registerPermissions() entry.
type Permission struct {
Code string
Tab string
Label string
Roles []string
}
// HasPermissions is implemented by plugins that declare backend permissions.
type HasPermissions interface {
Permissions() []Permission
}
```
Add `TokenScope{Name, Label, Description, Permissions, Methods}` + `HasTokenScopes{ TokenScopes() []TokenScope }` with the same one-line doc-comment style; optional `TokenReadMethods = []string{"GET","HEAD"}`.
---
### `modules/cabana/token_scopes.go` (compile + cover, transform)
**Analog:** `modules/cabana/registry.go` `compileContributions` (:169-200) — capability type-assert loop, fail-loud duplicate:
```go
for _, plugin := range plugins {
if plugin == nil { continue }
if src, ok := plugin.(pact.HasPermissions); ok && src != nil {
for _, permission := range src.Permissions() {
if !permissionCode(permission.Code, false) {
return fmt.Errorf("cabana: plugin %s registered invalid permission %q", plugin.ID(), permission.Code)
}
if _, exists := reg.permissions[permission.Code]; exists {
return fmt.Errorf("cabana: duplicate permission %s", permission.Code)
}
...
```
Validation reuse (:301-308):
```go
func (r *Registry) validatePermissions(owner string, permissions []string) error {
for _, code := range permissions {
if !permissionCode(code, true) || !r.permissionExists(code) {
return fmt.Errorf("cabana: %s references unknown permission %s", owner, code)
}
}
return nil
}
```
Phrase-key existence: follow `validateMessageKeys` (messages.go:322-326), called from http.go ~:131 when a `*phrasebook.Translator` is published. Write `scopeCovers` fresh — do NOT reuse `granted()` (contracts.go:203-235), see RESEARCH Pattern 2.
---
### `modules/cabana/contracts.go` (`Allows`)
**Analog:** itself (:186-199); target shape in RESEARCH Pattern 2 (scope evaluated before the `IsSuperuser` short-circuit).
---
### `interactiveOnly` route wrapper
**Analog:** `modules/cabana/csrf.go:22-30`:
```go
func requireAjax(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !csrfSafe(r) {
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
return
}
next(w, r)
}
}
```
Apply in `mount` (http.go :243-249) composed as `interactiveOnly(requireAjax(s.accountUpdate))`:
```go
r.GroupRaw(api, []string{"backend"}, func(g pact.Router) {
g.Get("/auth/me", s.me)
g.Put("/account", requireAjax(s.accountUpdate))
g.Post("/account/avatar", requireAjax(s.avatarUpload))
g.Delete("/account/avatar", requireAjax(s.avatarRemove))
g.Get("/navigation", s.navigation)
g.Get("/field-types", s.fieldTypesList) // needs explicit token gate (Pitfall 2)
g.Post("/markdown/preview", requireAjax(s.markdownPreview)) // same
```
`/auth/refresh` and `/auth/logout` are mounted outside the guard (:228-236) — add explicit `g15_` refusal + tests.
---
### `modules/cabana/account_tokens.go` (controller, CRUD)
**Analog:** `modules/cabana/account.go`
Principal (:216-224):
```go
func accountPrincipal(w http.ResponseWriter, r *http.Request) *bouncer.Principal {
principal, ok := bouncer.User(r.Context())
if !ok || principal == nil || !principal.Backend || principal.ID == 0 {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
return nil
}
return principal
}
```
Handler skeleton (:289-310): principal → `s.decodeCappedObject(w, r)` → `writeRelationError` on decode → `s.translator()` → `&ValidationError{Details: ...}` via `writeCRUDError` → `s.db()` with slog + 500:
```go
func (s *service) accountUpdate(w http.ResponseWriter, r *http.Request) {
principal := accountPrincipal(w, r)
if principal == nil { return }
body, err := s.decodeCappedObject(w, r)
if err != nil { writeRelationError(w, err); return }
tr := s.translator()
if details := refusedAccountKeys(r.Context(), tr, body); len(details) > 0 {
writeCRUDError(w, &ValidationError{Details: details})
return
}
db, err := s.db()
if err != nil {
slog.Default().ErrorContext(r.Context(), "cabana: account update without a database", "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
```
Messages: `backendMessage(ctx, tr, key, fallback)` (:246) for `api_tokens.name_required` etc.; `fieldDetail("scopes", msg)` for single-field 422. Error tail: copy `writeAccountError` (:495-507).
---
### `modules/cabana/admin_openapi.go` (swag stubs)
**Analog:** `AdminAccountUpdate` (:204-219) / `AdminMe` (:190-202):
```go
// AdminAccountUpdate documents PUT /account.
//
// @Summary Update my account
// @Description ...
// @Tags admin
// @Accept json
// @Produce json
// @Security BackendBearer
// @Param body body AdminAccountRequest true "Profile and optional password change"
// @Success 200 {object} Envelope[AdminProfile]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /account [put]
func AdminAccountUpdate() {}
```
Also update the `BackendBearer` description at :10 (`Send "Bearer {access_token}"`) to mention `g15_` tokens. Create must document 201 (Pitfall 8). Regenerate via `scripts/check-admin-openapi.sh`; add routes to `phase09Routes` (security_coverage_test.go:34), `conformCases()` (openapi_conformance_test.go).
---
### `modules/lagoon/backend_admin_migrations.go` (migration)
**Analog:** last entry (:121-132):
```go
{
// WinterCMS ...: a nullable jsonable column. It is TEXT, not JSONB ...
ID: "202610090001_backend_users_metadata",
Migrate: func(tx *gorm.DB) error {
return tx.Exec(`ALTER TABLE backend_users ADD COLUMN IF NOT EXISTS metadata TEXT`).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec(`ALTER TABLE backend_users DROP COLUMN IF EXISTS metadata`).Error
},
},
```
Multi-statement `stmts := []string{...}` loop: first entry (:23-...). Full DDL in RESEARCH "Migration entry". Update the package doc comment above `BackendAdminMigrations` (:15-21) to mention the token table. Fix `TestBackendAdminMetadataColumn` (test :561-575) in the same commit.
---
### `modules/cabana/commands.go` (`admin:token:*`)
**Analog:** same file :18-50 (command table) and :52-104 (`adminCreate`):
```go
{
Name: "admin:reset-password",
Description: "Reset a backend administrator password and revoke existing tokens",
Args: []bonfire.Arg{{Name: "identifier", Description: "Login or email", Required: true}},
Flags: []bonfire.Flag{{Name: "password", Description: "..."}},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
return adminResetPassword(ctx, app, in, out)
},
},
```
Body idiom: `flagValue(in, "...")`, `in.Argument("identifier")`, `withAdminDB(ctx, app, func(gdb *gorm.DB) error { return gdb.WithContext(ctx).Transaction(...) })`, errors prefixed `"cabana: ..."`. `--scope` uses `bonfire.Flag{Repeatable: true}` (bonfire/command.go:42-48). **Divergence:** `admin:token:create` must print only `out.Println(token)` — never `out.Success` (stdout == stderr in generated mains, Pitfall 7). Identifier resolution: reuse `findBackendLogin`. Plugin list: look up the party-published slice at Run time (Option A).
Tests: copy `TestAdminCreateCommand` (commands_test.go:24) harness.
---
### `../sm-journal-plugin/token_scopes.go`
**Analog:** `../sm-journal-plugin/admin_permissions.go` (:1-25):
```go
package journal
import "git.golem15.com/golem15/summercms/modules/pact"
const journalPermissionTab = "golem15.journal::lang.journal.tab"
// Permissions is the plugin's Winter registerPermissions() catalog. ...
func (*Plugin) Permissions() []pact.Permission {
return []pact.Permission{
{
Code: "golem15.journal.manage_settings",
Tab: journalPermissionTab,
Label: "golem15.journal::lang.journal.manage_settings",
Roles: []string{"developer"},
},
```
`func (*Plugin) TokenScopes() []pact.TokenScope` returning `journal` and `journal:read` (`golem15.journal.*`), labels under `golem15.journal::lang.scopes.*` in `lang/{en,pl}/lang.yaml`.
---
### `../sm-journal-plugin/controllers/api/auth.go` (resolver switch)
**Analog:** itself. Replace `newBackendGuard` (:58-81, the nil-`*cabana.Registry` bug, Pitfall 4) with the cabana resolver looked up per request; keep bodies byte-identical:
```go
func writeAuthRequired(w http.ResponseWriter, _ error) {
writeAPIError(w, http.StatusUnauthorized, errAuthenticationRequired)
}
func optionalBackendPrincipal(app *backpack.App, r *http.Request) *http.Request {
if r == nil || !hasBearer(r) { return r }
pr, err := newBackendGuard(app, nil).Authenticate(r)
if err != nil || pr == nil { return r }
return r.WithContext(bouncer.WithUser(r.Context(), pr))
}
func requireBackendPrincipal(app *backpack.App, w http.ResponseWriter, r *http.Request) (*http.Request, *bouncer.Principal, bool) {
pr, err := newBackendGuard(app, writeAuthRequired).Authenticate(r)
if err != nil || pr == nil {
writeAuthRequired(w, err)
return r, nil, false
}
r = r.WithContext(bouncer.WithUser(r.Context(), pr))
return r, pr, true
}
```
Map `ErrTokenScope` → `writeAPIError(w, 403, errInsufficientPerms)`; pass cover `"golem15.journal.*"`. Same for posts.go Index/Show (:32, :121) and featured-image handlers (:325, :393).
---
### `admin/src/components/account/ApiTokensPanel.vue` (+ `ApiTokenCreateDialog.vue`)
**Analogs:** `admin/src/views/AccountView.vue`, `admin/src/components/relation/RelationPickerModal.vue`
Imports (AccountView.vue :2-14):
```ts
import { computed, nextTick, ref } from 'vue'
import { CircleAlert, ImageUp, Trash2 } from '@lucide/vue'
import { api } from '../api/client'
import type { AdminAccountRequest } from '../api/types'
import { t } from '../app/i18n'
import { controlClass } from '../components/form/control'
import { fieldErrors, focusField } from '../components/form/formState'
import Button from '../components/ui/Button.vue'
import { useConfirm } from '../components/ui/confirm'
import { showToast } from '../state/useToasts'
```
API + 422 handling (AccountView.vue :219-233):
```ts
const result = await api.PUT('/account', { body: payload() })
...
errors.value = fieldErrors(result.error?.error.details)
...
focusField(ID_PREFIX, first)
```
Card class: AccountView.vue :255 `flex flex-col gap-3 rounded-card border border-border bg-surface p-7 shadow-card` (UI-SPEC uses `gap-4`).
Shared confirm: AccountView.vue :92 `const confirm = useConfirm()`, `confirm.ask({...}, run)` (confirm.ts:28), single `<ConfirmDialog>` at :411-419 — pass `confirm` to the panel as a prop.
Dialog shell (RelationPickerModal.vue :3-11): `DialogRoot, DialogPortal, DialogOverlay, DialogContent, DialogTitle, DialogDescription, DialogClose` from `reka-ui`; classes verbatim per UI-SPEC §4. Segmented control classes: `PermissionEditorField.vue`. Date format: reuse the `CellValue.vue` datetime formatter.
AccountView.vue edit: render `<ApiTokensPanel :confirm="confirm" />` after the `<form>` (ends ~:409), before `<ConfirmDialog>`.
Tests: `admin/tests/views/AccountView.test.ts` and `admin/tests/relation/RelationPickerModal.test.ts` (note: `admin/tests/components/` does not exist yet — either create it or place the test under `admin/tests/views/` / a new `admin/tests/account/`).
## Shared Patterns
### Admin error envelope
**Source:** `modules/cabana` `WriteError(w, status, code, msg)`, `WriteErrorDetails`, `writeCRUDError(w, &ValidationError{Details: ...})`, `fieldDetail`.
**Apply to:** token guard 401/403, interactiveOnly, account token endpoints, route gates on `/field-types` and `/markdown/preview`.
### Principal retrieval
**Source:** `bouncer.User(r.Context())`, `bouncer.Credential(r.Context())`; `accountPrincipal` (account.go:216).
**Apply to:** all account token handlers, `interactiveOnly`.
### Fail-loud capability compile
**Source:** `compileContributions` (registry.go:169), errors `fmt.Errorf("cabana: ...")`.
**Apply to:** scope registration.
### Logging
`slog.Default().ErrorContext/WarnContext(ctx, "cabana: ...", "key", val)` — never log plaintext or Authorization.
### Documentation
Every exported identifier/CLI command change updates the module README and `docs/` (TestDocsTree). Add `admin:token:*` to `cmd/summer/runtime.go` delegate table (:37-38) and `docs/console/introduction.md`.
## No Analog Found
| File | Role | Data Flow | Reason |
|---|---|---|---|
| `scopeCovers` wildcard-vs-wildcard logic (in `token_scopes.go`) | utility | transform | `granted()` has different semantics; write fresh with table tests (RESEARCH Pattern 2) |
## Metadata
**Analog search scope:** `modules/{cabana,bouncer,pact,lagoon,party,bonfire}`, `admin/src`, `admin/tests`, `cmd/summer`, `../sm-journal-plugin`, `../sm-summercmsio-app/plugins/golem15/user`
**Files scanned:** ~20
**Pattern extraction date:** 2026-10-11

View File

@@ -0,0 +1,726 @@
# Phase 16.3: Backend admin personal API tokens with scopes - Research
**Researched:** 2026-10-11
**Domain:** Go admin authentication (bouncer/cabana guard), permission/scope intersection, framework migration, admin SPA (Vue 3), bonfire CLI, Journal plugin wiring
**Confidence:** HIGH (every finding below is read from this repo and its sibling repos this session; no new external packages)
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
#### Scope model
- **D-01:** Scopes are named and registered by plugins through a new pact capability (working name `pact.HasTokenScopes`). Each scope has a name, a label and description (phrasebook keys), a list of permission patterns (Winter `.*` wildcard semantics, Phase 9 D-03) and a list of allowed HTTP methods. The naming convention is `<plugin>` for full access and `<plugin>:read` for read-only, e.g. `journal` → `golem15.journal.*` with all methods. A duplicate scope name fails boot loudly. — **Reversibility:** costly — plugins implement the capability and stored tokens reference scope names.
- **D-02:** Read-only is enforced by the scope's method list. A `:read` scope allows only `GET`/`HEAD`, and the framework refuses any other method with 403 before the handler runs. Plugins do not change their handlers for this. A POST that only reads (search) is not reachable with a read scope; that is accepted.
- **D-03:** A token holds several scopes. A request passes if any one scope allows it, meaning its method list includes the request method and its patterns match every required permission. Scopes are immutable after creation.
- **D-04:** Framework scopes:
- `*` means full access: all methods, and it behaves exactly like the owner.
- `admin:read` means system-wide read-only: `GET`/`HEAD` on everything the owner can see.
Journal registers `journal` and `journal:read`. Payment (`payment:read`) and Fonoteka scopes come with their own phases through the same API.
- **D-05:** Deny by default. A token reaches a route only when a scope covers it. When `cabana.Allows` is called with an empty permission list under a token, only `*` (and `admin:read` for GET/HEAD) passes; other scoped tokens are refused. `GET /auth/me` is always allowed to any valid token, so an agent can see who it is.
- **D-06:** An explicit "interactive only" marker covers account changes (`PUT /account`, password, email, avatar), token management (create, list, revoke) and logout/refresh. These routes refuse every token, including `*`, and need an admin JWT session. A leaked token cannot take over the account or mint new tokens. — **Reversibility:** reversible.
#### Token lifecycle
- **D-07:** The format keeps the Apparatus shape: `g15_` + 40 lowercase hex chars (20 random bytes from `crypto/rand`, 160 bits). Only the sha256 hex is stored, in a unique indexed column, along with the last 4 characters as a display hint (`g15_…a1b2`). There is no checksum suffix. — **Reversibility:** one-way — issued tokens live in agent and integration configs.
- **D-08:** Expiry is optional. The SPA picker offers 30 / 90 / 365 days / never, defaulting to 90 days. The CLI accepts `--expires=30d|90d|365d|never` or a date, also defaulting to 90 days. Expired tokens return 401.
- **D-09:** Revoking is a soft revoke. It sets `revoked_at` and `revoked_by` and keeps the row for audit. Revoked tokens return 401 and are hidden from the default list, with an option to show them. Name, scopes and expiry cannot be edited: to change one, create a new token and revoke the old one.
- **D-10:** The owner is loaded fresh on every request through the existing `BackendUsers.FindByID` path. Deactivating (`is_activated=false`) or soft-deleting the owner makes all their tokens fail with 401. A password reset and the JWT `tokens_valid_after` cutoff do **not** invalidate tokens, so routine password changes don't break agents.
- **D-11:** `last_used_at` is stamped on use, at most once per minute per token, so every request isn't a write. Lists show name, scopes, hint, `created_at`, `last_used_at`, `expires_at`, and revoked state.
- **D-12:** The table is framework-owned. It is appended to `BackendAdminMigrations` (history id `summercms.cabana`) as a raw-SQL migration with a dated ID. Columns are roughly: id, backend_user_id (FK), name, token_hash (unique), token_hint, scopes (JSON array of names), expires_at, last_used_at, revoked_at, revoked_by, timestamps. Apparatus `golem15_apparatus_personal_api_tokens` rows are **not** migrated.
#### Guard and Journal wiring
- **D-13:** The cabana `backend` guard becomes composite. `Authorization: Bearer g15_…` goes to the token lookup; everything else (Bearer JWT or the admin cookie) goes to the existing `NewBackendJWTGuard` path unchanged. Tokens are accepted only in the Bearer header, never from a cookie or query string. The token row is returned as the bouncer credential (`CredentialGuard`). — **Reversibility:** costly — every backend-authenticated route goes through it.
- **D-14:** The permission side of the scope check lives in the principal. A token-authenticated `Principal` carries its scopes' permission patterns and methods, and `cabana.Allows` requires **both** an owner grant and a matching scope pattern. Every existing check (admin CRUD `RequiredPermissions`, actions, Journal's direct `Allows` calls) is restricted with no per-route annotation. A superuser holding a scoped token is still limited to the scope. The method check runs once, in the guard or middleware. — **Reversibility:** costly — `Allows` semantics change for every caller.
- **D-15:** Cabana exports a principal resolver, working name `cabana.BackendPrincipal(r)` or similar, that runs the composite guard plus the scope and method checks for routes that don't sit under the `backend` middleware. Journal's `controllers/api/auth.go` uses it instead of building its own `NewBackendJWTGuard`. Journal's error bodies and status codes stay byte-identical (`{"error":"Authentication required"}` 401, its existing 403). The public read routes stay optional-auth, so a valid token with `journal` or `journal:read` also unlocks drafts where the owner may see them. This reverses Phase 15 D-15. — **Reversibility:** reversible.
#### Admin SPA and CLI
- **D-16:** The SPA panel is an "API tokens" section on `/account` (`AccountView.vue`). It lists, creates and revokes the current user's own tokens only. There is no superuser view of other users' tokens; superusers use the CLI. The plaintext is shown once after creation with a copy button and a clear "you won't see this again" notice, and is never retrievable afterwards.
- **D-17:** The scope picker lists registered scopes with label and description. It hides scopes that grant the current user nothing (no overlap between the scope's patterns and their grants). `*` is always shown, labelled as full access ("everything you can do"). The server re-validates scope names and the overlap rule on create (422 on an unknown or empty scope).
- **D-18:** Account token endpoints sit under the admin API (Phase 9 D-09/D-10 envelope), for example `GET/POST /_admin/api/v1/account/tokens`, `DELETE /account/tokens/{id}` (revoke) and `GET /account/tokens/scopes`. They carry swag annotations so the OpenAPI document and the SPA TS types cover them, and they are interactive-only (D-06).
- **D-19:** CLI commands in cabana, beside `admin:create`:
- `admin:token:create <login|email> --name=… --scope=journal [--scope=payment:read] [--expires=90d|never]` prints only the plaintext token to stdout so it can be piped.
- `admin:token:list [<login|email>] [--revoked]`
- `admin:token:revoke <id>`
- `admin:token:scopes` lists registered scopes.
The CLI may create tokens for any backend user and applies the same overlap validation as D-17.
- **D-20:** Module READMEs (cabana, bouncer if its API changes, pact for the new capability) and the `docs/` pages cover the token API, the scope registration capability, the framework scopes and the CLI commands, per the project documentation rules. The Journal README documents its scopes and token auth.
### Claude's Discretion
- Exact names: the pact capability, the scope struct, the principal resolver, the table (`backend_api_tokens` or similar), and the interactive-only marker.
- How scopes are stored (JSON array vs `text[]`), and what an unknown scope name does at request time after a plugin drops it. Recommended: it grants nothing and is shown as unknown in lists.
- How the 403 for a scope refusal is reported in the framework envelope (e.g. `details.reason = "token_scope"`).
- Whether token requests get their own throttle bucket; reuse an existing throttle shape if cheap.
- How `last_used_at` is debounced (in-memory per process is fine).
### Deferred Ideas (OUT OF SCOPE)
- Backend-user management screen, including a superuser view and revoke of other users' tokens: its own phase.
- `payment:read` scope: registered by the Payment Gateway phase (18.x) through D-01.
- Fonoteka scopes: when a Fonoteka integration needs token access.
- Secret-scanner-friendly checksum token format: only if a scanner partnership is pursued.
</user_constraints>
<phase_requirements>
## Phase Requirements
No requirement IDs are mapped (ROADMAP: "Requirements: TBD"). The success criteria are the coverage source.
| ID | Description | Research Support |
|----|-------------|------------------|
| SC-1 | `backend` guard accepts a valid, unexpired, unrevoked `g15_` token as well as the backend JWT; request runs as the token's user | Composite guard (Pattern 1), registration point `http.go:145-160`, `Registry.Middleware` credential handling `registry.go:83-99` |
| SC-2 | Out-of-scope requests refused even when the role allows; token never exceeds owner | `Allows` rewrite (Pattern 2), method filter in guard (Pattern 1), unchecked-route audit (Pitfall 2), owner-branch bypass in Journal (Pitfall 3) |
| SC-3 | Create/list/revoke own tokens in SPA, plaintext shown once; CLI for any user | Account token API (Pattern 5), interactive-only marker (Pattern 4), SPA pipeline, CLI (Pattern 7, Pitfall 6/7) |
| SC-4 | Journal accepts a Journal-scoped token for writes | Resolver (Pattern 3), Journal call sites, `*cabana.Registry` never published (Pitfall 4) |
| SC-5 | READMEs and `docs/` cover token API, scopes, CLI | Docs tooling section, `docsCommands` and `delegateCommands` tables |
| SC-6 | Unit tests in the last plan | Validation Architecture |
</phase_requirements>
## Project Constraints (from CLAUDE.md)
- Lean planning: fewer, larger plans; **present the plan count with one-line scopes and wait for confirmation before writing PLAN.md files**.
- Unit tests are always the **last plan** of the phase; earlier plans may carry smoke tests only.
- Standard library first (`net/http`, `encoding/json`, `crypto/*`). A dependency is added only when a research doc or phase decision names it. This phase needs **no new Go or npm dependency**.
- Compiled plugins; no runtime plugin loading.
- `go vet` and `go test ./...` green at every commit.
- Commits: no co-author tags; one logical change per commit; planning docs and code in separate commits.
- Any change to a `modules/` package's exported API, config keys, CLI commands or dependencies updates that module's `README.md` **and** affected `docs/` pages in the same change. Framework READMEs never name a consuming application (say "host application"; examples use `blog`/`acme`).
- Every identifier named in a README/docs page must exist; `go test ./cmd/summer -run TestDocsTree` and `summer docs:build --check` verify identifiers, links, anchors, `src=` snippets, command names and consuming-application names. Config keys are not checked automatically.
- Core plugin contracts (user, blog, pages, payment) must be preserved.
- Two repos are written in this phase: `summercms.go` (framework) and `../sm-journal-plugin`; host apps only bump versions.
## Summary
The framework already has every seam this phase needs, and none of them needs a new library. `bouncer.Registry.Middleware` already prefers a `CredentialGuard` and stores the returned credential on the context; `bouncer.UnauthorizedWriter` receives the guard's error, so one composite guard can answer 401 for bad tokens and 403 for a method refusal from the same hook. Cabana registers its own `backend` guard at one point (`http.go:145-160`), so wrapping it is a local change. All permission enforcement in cabana funnels through `cabana.Allows` (no handler reads `IsSuperuser` or `PermissionGrants` directly), which makes D-14's "restrict in `Allows`" approach sound — provided the principal carries a method-filtered scope limit and `Allows` stops short-circuiting on `IsSuperuser` for scoped principals.
Four findings change the plan's shape. (1) `*cabana.Registry` is never published to the app, so Journal's `newBackendGuard` always builds `BackendUsers{Registry: nil}` and silently drops code-declared role grants; the D-15 resolver must be published by `cabana.Activate` and fixes this as a side effect. (2) Several cabana routes (`/field-types`, `/markdown/preview`) and two Journal handlers (`UploadFeaturedImage`, `DeleteFeaturedImage`, plus draft visibility via `canViewDraft`) authorize by ownership or authentication only, never through `Allows`; under a scoped token they would be reachable by an unrelated scope, so deny-by-default needs explicit gates there. (3) `cabana.RuntimeCommands(app)` takes no plugin list and is called from generated `main.go` files in every host app, so the CLI cannot see plugin scopes without either a signature change (forcing regenerated mains) or a published plugin list. (4) Appending to `BackendAdminMigrations` breaks `TestBackendAdminMetadataColumn`, which pins the last migration ID.
**Primary recommendation:** Build a cabana-owned composite `backend` guard that routes `Bearer g15_…` to a sha256 lookup, loads the owner through the existing `lazyBackendUsers`, method-filters the token's scopes into a `bouncer.Principal` scope limit, and publishes a resolver for Journal; rewrite `Allows` as "owner grant AND scope cover, per code"; gate every non-`Allows` route explicitly; keep all crypto in stdlib.
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Token minting, hashing, lookup | API / Backend (cabana) | Database (token table) | Secret generation and hashing never leave the server; only sha256 is persisted |
| Scope registry (plugin-declared) | API / Backend (cabana compile at boot, pact interface) | — | Same compile path as `HasPermissions`; fail-loud at boot |
| Scope ∩ permission enforcement | API / Backend (`cabana.Allows`, guard method filter) | — | Handlers already call `Allows`; method filter needs request context |
| Interactive-only refusal | API / Backend (route wrapper) | — | Must run before handler, per route |
| Journal token acceptance | API / Backend (cabana resolver used by Journal handlers) | — | Journal routes are not under the `backend` middleware |
| Token panel (list/create/revoke, show once) | Browser / Client (admin SPA) | API (account token endpoints) | Plaintext lives only in component state; server owns validation |
| Scope label translation | API / Backend | Browser | Plugin phrase keys are outside the `backend::lang.` bundle the SPA loads |
| CLI token management | API / Backend process (bonfire command in app binary) | Database | Runs without HTTP; needs plugin scope catalog |
| Token table schema | Database / Storage | — | Framework-owned migration under `summercms.cabana` history |
## Standard Stack
### Core (all already in the repo — nothing to install)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `crypto/rand` (stdlib) | Go 1.27.0 | 20 random bytes per token | CSPRNG; D-07 |
| `crypto/sha256` + `encoding/hex` (stdlib) | Go 1.27.0 | Stored token hash | Same transform as Apparatus `hash('sha256', …)` and the user plugin's `HashAPIToken` |
| `crypto/subtle` (stdlib) | Go 1.27.0 | Constant-time compare after the row is fetched | Pattern already used in `wristband/crypto.go` |
| `gorm.io/gorm` | v1.31.2 (pinned in go.mod) | Token model queries | Project standard |
| `github.com/go-gormigrate/gormigrate/v2` | already pinned | Migration entry in `BackendAdminMigrations` | Project standard |
| `lagoon.Jsonable[[]string]` | in-repo | `scopes` TEXT column as JSON array | Same shape as user plugin `user_api_tokens.scopes` |
| `bonfire` (cobra wrapper) | in-repo | `admin:token:*` commands | `Flag.Repeatable` supports `--scope` repeated |
| swaggo/swag | v1.16.6 (run via `go run` in `scripts/check-admin-openapi.sh`) | OpenAPI for the account token endpoints | Project standard |
| openapi-typescript / openapi-fetch | 7.13.0 / 0.17.0 (admin/package.json) | SPA types and typed calls | Project standard |
| reka-ui, @lucide/vue | 2.9.10 / 1.17.0 | Dialog, RadioGroup, icons | UI-SPEC locked |
[VERIFIED: summercms.go/admin/package.json; go version → `go version go1.27.0-X:nodwarf5 linux/amd64`]
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| plain sha256 | HMAC-SHA256 with a server pepper | Protects against a DB-only leak being used for offline guessing, but 160-bit random secrets cannot be guessed anyway, and a pepper rotation would invalidate every token (D-07 is one-way). Not recommended. |
| bcrypt/argon2 of token | — | Wrong tool: slow hashes defend low-entropy passwords; they also prevent an indexed equality lookup. Do not use. |
| `scopes` as `text[]` | Postgres array | Needs a custom GORM type or `pq.StringArray`; TEXT JSON matches existing Winter-shaped jsonable columns. Use TEXT JSON. |
**Installation:** none.
## Package Legitimacy Audit
This phase installs no external packages (Go or npm). Every library used is already pinned in `go.mod` or `admin/package.json`.
| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition |
|---------|----------|-----|-----------|-------------|---------|-------------|
| (none added) | — | — | — | — | — | — |
**Packages removed due to [SLOP] verdict:** none
**Packages flagged as suspicious [SUS]:** none
## Architecture Patterns
### System Architecture Diagram
```
Authorization: Bearer <x> | summer_admin cookie
|
+--------------------------+---------------------------+
| |
admin API route (backend mw) Journal route (/_journal/api/v1, no backend mw)
| |
v v
bouncer.Registry.Middleware("backend") cabana resolver (published by Activate)
| |
+-------------------> composite guard <----------------+
|
x starts with "g15_"? |
+-------- yes -----------+---------- no ---------+
v v
format check (g15_ + 40 hex) existing NewBackendJWTGuard path
sha256 -> SELECT token row (bearer JWT or cookie, aud=backend,
(not revoked, not expired) blacklist, tokens_valid_after)
| |
owner = lazyBackendUsers.FindByID (activated, not soft-deleted, role+user grants)
| |
method filter: scopes whose Methods include r.Method |
none left -> 403 (WriteUnauthorized sees *ScopeError) |
| |
principal.Scope = {Full | Patterns} ; debounced last_used |
+---------------------> principal (+ token credential) on ctx
|
route wrapper: interactiveOnly? -- token credential --> 403
|
handler: cabana.Allows(principal, required)
owner grant(code) AND scope covers(code)
|
200 / 403 (envelope or Journal body)
```
### Recommended Project Structure
```
modules/
├── pact/capabilities.go # + TokenScope struct, HasTokenScopes interface, method helpers
├── bouncer/context.go # + Principal scope-limit field (json:"-")
├── bouncer/jwt.go # optionally export BearerToken (or keep cabana's own copy)
├── lagoon/backend_admin_migrations.go # + dated backend_api_tokens migration
└── cabana/
├── api_tokens.go # model, mint, hash, hint, lookup, revoke, debounce
├── token_scopes.go # compile scopes (framework + plugins), cover/overlap rules
├── token_guard.go # composite CredentialGuard + UnauthorizedWriter, resolver
├── account_tokens.go # GET/POST /account/tokens, DELETE /account/tokens/{id}, GET /account/tokens/scopes
├── contracts.go # Allows rewrite
├── commands.go # admin:token:create|list|revoke|scopes
└── admin_openapi.go # swag stubs + types for the four endpoints
admin/src/components/account/ApiTokensPanel.vue (+ ApiTokenCreateDialog.vue)
../sm-journal-plugin/
├── token_scopes.go # journal, journal:read
├── controllers/api/auth.go # switch to cabana resolver
└── lang/{en,pl}/lang.yaml # scope labels/descriptions
```
### Pattern 1: Composite `backend` guard (D-13)
**What:** One type implementing `bouncer.CredentialGuard` and `bouncer.UnauthorizedWriter`, registered by cabana under `backend` in place of the bare JWT guard.
Current registration [VERIFIED: modules/cabana/http.go:145-160]:
```go
guard := bouncer.NewBackendJWTGuard(secret, users, bl, writeUnauthenticated, AdminCookieName)
...
if owner, taken := guards.Owner("backend"); !taken {
if err := guards.Register("summercms.cabana", "backend", guard); err != nil {
```
The bouncer interfaces [VERIFIED: modules/bouncer/guard.go:10-25]:
```go
type CredentialGuard interface {
AuthenticateCredential(r *http.Request) (*Principal, any, error)
}
type UnauthorizedWriter interface {
WriteUnauthorized(w http.ResponseWriter, err error)
}
```
Middleware behaviour [VERIFIED: modules/bouncer/registry.go:83-99]: on `err != nil || principal == nil`, if the guard implements `UnauthorizedWriter` it calls `wtr.WriteUnauthorized(w, err)` and stops; on success it stores `WithUser` and, when `cred != nil`, `WithCredential`.
**How:** `AuthenticateCredential(r)`:
1. Read `Authorization`; if `strings.CutPrefix(h, "Bearer ")` yields a value starting with `g15_` → token branch. Otherwise delegate to the wrapped JWT guard's `Authenticate(r)` and return `(p, nil, err)`.
2. Token branch: reject anything not matching `^g15_[0-9a-f]{40}$` **before** touching the DB (401). Hash, `SELECT … WHERE token_hash = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now())`, constant-time compare the fetched hash.
3. Owner via the same `lazyBackendUsers.FindByID` the JWT guard uses (D-10: activation + soft-delete honored; `tokens_valid_after` not consulted).
4. Method filter → if no stored scope (known, registered) permits `r.Method`, return a typed `*ScopeError` so `WriteUnauthorized` answers **403** `forbidden` with `details.reason="token_scope"`; any other error answers the existing 401 `unauthenticated` envelope.
5. Set the principal's scope limit, stamp `last_used_at` (debounced, never fails auth), return `(principal, *BackendAPIToken, nil)`.
`WriteUnauthorized` receives `err`, so the 401/403 split needs no bouncer change. Name it clearly in a comment, because "unauthorized writer" writing 403 is surprising.
### Pattern 2: `Allows` as owner ∩ scope, per code (D-14, D-05)
Current [VERIFIED: modules/cabana/contracts.go:186-199]:
```go
func Allows(principal *bouncer.Principal, required []string) bool {
if principal == nil {
return false
}
if principal.IsSuperuser || len(required) == 0 {
return true
}
for _, code := range required {
if granted(principal.PermissionGrants, code) {
return true
}
}
return false
}
```
Current `Principal` [VERIFIED: modules/bouncer/context.go:14-22]:
```go
type Principal struct {
ID uint
MustChangePassword bool
PreferredLocale string
TokensValidAfter time.Time
Backend bool `json:"-"`
IsSuperuser bool `json:"-"`
PermissionGrants map[string]bool `json:"-"`
}
```
**Recommended shape** (names are discretion):
```go
// bouncer: nil means unrestricted (JWT session). Set only by a token guard,
// already filtered to the scopes whose methods include the request method.
type ScopeLimit struct {
Full bool // "*" (any method) or "admin:read" (on GET/HEAD)
Patterns []string // union of the remaining scopes' permission patterns
}
// Principal gains: Scope *ScopeLimit `json:"-"`
// cabana
func Allows(p *bouncer.Principal, required []string) bool {
if p == nil {
return false
}
if p.Scope != nil && !p.Scope.Full {
if len(required) == 0 {
return false // D-05
}
for _, code := range required {
if ownerHas(p, code) && scopeCovers(p.Scope.Patterns, code) {
return true
}
}
return false
}
// unchanged JWT / full-scope path
}
```
`ownerHas` = `p.IsSuperuser || granted(p.PermissionGrants, code)` (superuser is still limited by the scope, D-14).
**Why the union of patterns is correct:** `Allows` already has any-of semantics (Winter `hasAnyAccess`). With per-code evaluation, "some method-allowed scope covers code" equals "the union of method-allowed patterns covers code". D-03's "patterns match every required permission" reads as the single-code case; per-code any-of never grants more than either side. Flag for the planner (Assumptions A2).
**Do not reuse `granted()` verbatim for scope cover.** `granted` (contracts.go:203-235) matches a wildcard *requirement* only against concrete keys with a longer prefix and skips wildcard keys shorter than 2 chars, so `scopeCovers(["golem15.*"], "golem15.journal.*")` returns false and pattern `*` never matches. Write `scopeCovers` with explicit cases (pattern wildcard covers code; pattern wildcard prefix ⊇ required wildcard prefix) and table tests.
### Pattern 3: Published resolver for routes outside the `backend` middleware (D-15)
Journal today builds its own guard [VERIFIED: ../sm-journal-plugin/controllers/api/auth.go:58-81]:
```go
reg, _ = app.Lookup[*cabana.Registry]()
...
return bouncer.NewBackendJWTGuard(secret, cabana.BackendUsers{DB: gdb, Registry: reg}, bl, write)
```
`cabana.Activate` publishes only the guard registry [VERIFIED: modules/cabana/http.go:136-142 — `if err := app.Publish(guards); err != nil {`]; no `Publish` of `*Registry` exists anywhere in `modules/cabana` (grep this session), so `reg` is always nil and `rolePermissions` returns an empty map for nil (registry.go:368-372). Code-declared role grants (`Roles: []string{"developer"}` in Journal's catalog) are therefore missing on Journal's API today.
**Recommendation:** `Activate` publishes an exported resolver value (e.g. `*cabana.BackendAuth`) holding the composite guard and the compiled scope catalog. Export a function such as:
```go
// ResolveBackend authenticates r like the backend guard and requires, for a
// token, that a method-allowed scope covers at least one of cover.
func ResolveBackend(app *backpack.App, r *http.Request, cover ...string) (*http.Request, *bouncer.Principal, error)
```
with sentinel errors (`ErrUnauthenticated`, `ErrTokenScope`) so Journal maps them to its own byte-identical bodies (`{"error":"Authentication required"}` 401, `{"error":"Insufficient permissions"}` 403). Plugin routes are registered **before** `cabana.Activate` runs (surf router.go:540-548), so the resolver must be looked up per request, not captured at `Routes()` time. Journal's test harness uses `surf.Assemble` (admin_harness_test.go:228), so the published resolver is present in tests.
The `cover` argument closes the ownership bypass (Pitfall 3): Journal passes `"golem15.journal.*"` on every call; an unrelated scope (e.g. a future `payment`) never resolves a principal for Journal routes.
### Pattern 4: Interactive-only marker (D-06)
Route wrapper next to `requireAjax`, applied in `mount`:
```go
func interactiveOnly(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if cred, _ := bouncer.Credential(r.Context()); isAPIToken(cred) {
WriteErrorDetails(w, http.StatusForbidden, "forbidden", msgForbidden,
map[string]any{"reason": "interactive_only"})
return
}
next(w, r)
}
}
```
Current account routes [VERIFIED: modules/cabana/http.go:243-248]:
```go
r.GroupRaw(api, []string{"backend"}, func(g pact.Router) {
g.Get("/auth/me", s.me)
g.Put("/account", requireAjax(s.accountUpdate))
g.Post("/account/avatar", requireAjax(s.avatarUpload))
g.Delete("/account/avatar", requireAjax(s.avatarRemove))
```
`/auth/refresh` and `/auth/logout` are mounted outside the guard (http.go:229-238) and read `sessionToken(r)` (Bearer first). A `g15_` Bearer fails JWT verification there and already yields 401; add explicit tests so this stays true, and consider an explicit `g15_` refusal so the intent is visible.
CSRF note [VERIFIED: modules/cabana/csrf.go `csrfSafe`]: any non-empty Bearer satisfies `requireAjax`, so token clients do not need `X-Requested-With`. Keep that.
### Pattern 5: Account token API (D-16..D-18)
Endpoints under `{prefix}/api/v1` inside the `backend` group, all wrapped in `interactiveOnly` (and `requireAjax` for writes):
- `GET /account/tokens?revoked=1` → own tokens, newest first. Each row: id, name, hint, scopes as `[{name, label, read_only, known}]`, created_at, last_used_at, expires_at, revoked_at. Server translates labels (UI-SPEC §3 requires a label + known flag per scope).
- `POST /account/tokens` `{name, expires: "30d"|"90d"|"365d"|"never", scopes: [...]}` → 201 (or 200 — see Pitfall 8) with the row plus `token` plaintext once. 422 `validation_failed` details keyed `name`, `expires`, `scopes` (UI-SPEC copy: `api_tokens.name_required`, `scopes_required`, `scope_unavailable`). Trim name, 1..100 chars. `["*", "journal"]` → normalize to `["*"]` or 422 (choose one; UI-SPEC sends only `["*"]`).
- `DELETE /account/tokens/{id}` → revoke own token only (owner check in the WHERE clause, 404 otherwise); sets `revoked_at`, `revoked_by = principal.ID`.
- `GET /account/tokens/scopes` → picker list in server order: `*`, `admin:read`, then plugin scopes; hidden when no overlap (D-17). `*` always.
Overlap rule (D-17), recommended: a scope overlaps the user iff some **registered permission code** `c` satisfies `ownerHas(c) && scopeCovers(scope.Patterns, c)`; `*` always; `admin:read` iff superuser or any grant. Using the catalog avoids wildcard-vs-wildcard reasoning on user grants.
Inventories that must list every new route (or tests fail):
- `phase09Routes` [VERIFIED: modules/cabana/security_coverage_test.go:34] — mount/guard matrix (`TestPhase09PermissionMatrix`).
- OpenAPI path presence and 200/201 response (`TestPhase09ContractInventory`, phase09_contract_test.go:15-80).
- `conformCases()` vs `cabana.AdminAPIRoutes()` (`TestPhase10ConformanceInventory`, openapi_conformance_test.go:134).
Swag stubs live in `admin_openapi.go` as exported no-op functions (e.g. `func AdminAccountUpdate() {}`); new ones follow that style and are therefore exported identifiers the docs checker can see. Update the `BackendBearer` security description, which currently says `Send "Bearer {access_token}"` (admin_openapi.go:10), to mention `g15_` tokens.
### Pattern 6: Scope capability and compile (D-01, D-04)
`HasPermissions` precedent [VERIFIED: modules/pact/capabilities.go:495-506]:
```go
type Permission struct {
Code string
Tab string
Label string
Roles []string
}
type HasPermissions interface {
Permissions() []Permission
}
```
Compile precedent: `compileContributions` (registry.go:168-200) iterates plugins, validates, rejects duplicates with `fmt.Errorf("cabana: duplicate permission %s", …)`, then `validatePermissions(owner, codes)` (registry.go:301-308) checks `permissionCode(code, true)` and `permissionExists`.
Recommended capability:
```go
type TokenScope struct {
Name string // "journal", "journal:read"
Label string // phrase key, e.g. "golem15.journal::lang.scopes.journal"
Description string // phrase key
Permissions []string // non-empty; Winter patterns, validated against the catalog
Methods []string // non-empty; e.g. pact.TokenReadMethods = {"GET","HEAD"}
}
type HasTokenScopes interface{ TokenScopes() []TokenScope }
```
Boot validation (fail loud): name syntax `^[a-z][a-z0-9_-]*(:[a-z][a-z0-9_-]*)?$`; reserved `*`, `admin`, `admin:*`; duplicates across plugins; empty permissions or methods; unknown HTTP method; permissions via `validatePermissions`; label/description containing `::` must exist in the translator (same rule as action labels in `validateMessageKeys`, messages.go:322-326).
Journal registers [catalog VERIFIED: ../sm-journal-plugin/admin_permissions.go — codes `golem15.journal.manage_settings`, `golem15.journal.access_posts`, `golem15.journal.access_categories`, `golem15.journal.access_other_posts`, `golem15.journal.access_import_export`, `golem15.journal.access_publish`, `golem15.journal.access_tags`]:
- `journal` → `golem15.journal.*`, all methods
- `journal:read` → `golem15.journal.*`, GET/HEAD
Unknown stored scope names (plugin removed): grant nothing, render as unknown (UI-SPEC §3). Tokens whose scopes are all unknown authenticate but cannot pass any method filter → 403; consider 401 instead so agents see a dead token (discretion).
### Pattern 7: CLI (D-19)
Current signature [VERIFIED: modules/cabana/commands.go:18 — `func RuntimeCommands(app *backpack.App) []bonfire.Command {`]; generated host `main.go` calls it [VERIFIED: ../sm-grzybyfunkcjonalne-app/main.go:44 — `commands = append(commands, cabana.RuntimeCommands(app)...)`, file header `// Code generated by summer build. DO NOT EDIT.`]; same line in `fonoteka.go/main.go`, `sm-summercmsio-app/main.go`, `sm-bm-app/main.go`, `internal/build/build.go:120`, `examples/hello/main.go:44`, `cmd/summer/docs.go:156`.
`party.Activate` stores only plugin IDs on the app (`app.SetPlugins(orderedIDs)`, registry.go:107); the plugin objects are not published.
Options:
- **A (recommended, version-bump-only for hosts):** `party.Activate` publishes the ordered plugin slice under a named type (e.g. `party.Activated`); cabana's token commands look it up at `Run` time and compile the scope + permission catalog. `RuntimeCommands(app)` signature unchanged. Cost: a party README/API addition.
- **B:** `RuntimeCommands(app *backpack.App, plugins ...party.Plugin)` (source compatible) plus a generator change. Old generated mains compile but pass no plugins, so `--scope=journal` would be "unknown" until each host re-runs `summer build`. Contradicts "host applications only for the version bump."
bonfire facts:
- Repeatable flags exist [VERIFIED: modules/bonfire/command.go:42-48 — `Repeatable bool`, read through `Input.Flags`]; cobra StringSlice also splits `--scope=a,b`.
- **All Output methods except `Error` write to stdout, and `NewRoot` sets `errW = out`** [VERIFIED: modules/bonfire/root.go:12-14 — `return NewRootIO(name, commands, os.Stdin, out, out)`; output.go `Success`/`Warning`/`Info` call `c.Println`]. To keep `admin:token:create` pipe-clean, print exactly `out.Println(token)` and nothing else on success; return errors (the generated main prints them to stderr).
`summer` forwards app commands through a fixed table [VERIFIED: cmd/summer/runtime.go:37-38 rows for `admin:create`, `admin:reset-password`]. Add `admin:token:*` rows if `summer admin:token:create …` should work from the app directory, and the `docs/console/introduction.md` delegate table with them. `docsCommands` (docs.go:146-163) already picks up anything `cabana.RuntimeCommands(app)` returns.
Identifier resolution: reuse the `findBackendLogin` ambiguity rule (login OR lower(email), exactly one row). Refuse to mint for a deactivated or soft-deleted user (the token would 401 anyway). `revoked_by` is NULL for CLI revokes.
### Anti-Patterns to Avoid
- **Checking scope in per-route middleware annotations:** permission checks live in handlers (D-14); annotations would miss actions, relations and settings.
- **Letting the token branch fall through to the JWT parser:** a `g15_` value must never be handed to `VerifyClaimsAudience`; route by prefix first.
- **Accepting `g15_` from the `summer_admin` cookie or a query string:** D-13 forbids it; the JWT path's cookie fallback must stay JWT-only.
- **Failing auth when the `last_used_at` write fails:** the user plugin's guard does this (api_token.go returns `errInvalidAPIToken` on update error); cabana should log and continue.
- **Returning the plaintext anywhere after creation:** not in list rows, logs, slog attributes, or error details.
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Random secret | math/rand, time-seeded IDs | `crypto/rand.Read` | CSPRNG required for bearer secrets |
| Hash compare | `==` on secrets | `crypto/subtle.ConstantTimeCompare` on fixed-length hex | Removes a timing side channel |
| JWT path | any change to JWT verification | wrap existing `NewBackendJWTGuard` unchanged | D-13; keeps audience, blacklist, cutoff behaviour |
| Owner loading | a second user query | `lazyBackendUsers.FindByID` / `BackendUsers.FindByID` | Already merges role, code-declared and user grants (auth.go:40-69) |
| Validation messages | ad-hoc strings | `ValidationError{Details: …}`, `lagoon`/`backendMessage` helpers as in account.go | Keeps the D-10 envelope and translations |
| Plaintext display/copy | custom modal | Reka Dialog shell copied from `RelationPickerModal.vue`, shared `ConfirmDialog` | UI-SPEC binding |
**Key insight:** every security-relevant primitive here is stdlib or already in cabana; the work is wiring and making the deny-by-default rule hold on routes that never called `Allows`.
## Runtime State Inventory
Not a rename/refactor phase. One related check: Apparatus `golem15_apparatus_personal_api_tokens` rows exist only in the PHP deployment and are explicitly **not migrated** (D-12). Nothing in the Go apps stores `g15_` tokens today.
## Common Pitfalls
### Pitfall 1: Superuser short-circuit survives the rewrite
**What goes wrong:** `IsSuperuser || len(required) == 0` returns true before scope logic, so a superuser's `journal` token administers everything.
**How to avoid:** Evaluate the scope limit first; superuser only replaces the owner-grant side. Table-test superuser × each scope.
**Warning signs:** a test for "superuser + journal token on a non-journal controller" passes with 200.
### Pitfall 2: Routes that never call `Allows`
**What goes wrong:** D-05 deny-by-default only holds where `Allows` runs. Handlers that check only `principal.Backend`: `/auth/me` (intended open), `/navigation`, `/settings` (both filtered per item via `Allows`, so a scoped token sees only covered items — acceptable), `/field-types` (field_type.go:864-866) and `/markdown/preview` (field_markdown.go:71-73) — no permission gate at all.
**How to avoid:** Add an explicit gate equivalent to `Allows(principal, nil)` on `/field-types` and `/markdown/preview` (only `*`, and `admin:read` on GET, pass). Document the classification of every route in `mount` in a test table.
**Warning signs:** a `journal:read` token gets 200 from `/field-types`.
### Pitfall 3: Ownership branches bypass `Allows`
**What goes wrong:** Journal `UploadFeaturedImage`/`DeleteFeaturedImage` check only `post.CanEdit(pr)` (posts.go:323-341, 391-405; no `requireAccessPosts`), and `CanEdit` returns true for the post's author regardless of permissions (models/post.go:161-169). `canViewDraft` likewise passes the owner (posts_helpers.go:33-41). Any token whose scope permits the method would reach them for the owner's own posts.
**How to avoid:** The resolver's `cover` argument (Pattern 3) refuses tokens whose scopes do not cover `golem15.journal.*`. Separately decide whether featured-image routes should also call `requireAccessPosts` (PHP parity check needed — the CONTEXT flags it as a possible pre-existing gap).
**Warning signs:** a non-Journal scoped token uploads a featured image.
### Pitfall 4: `*cabana.Registry` lookup is always nil
**What goes wrong:** Journal's guard drops code-declared role grants today; if the resolver copies that pattern, tokens inherit the bug.
**How to avoid:** The resolver uses the same `lazyBackendUsers{app, reg}` instance the admin guard uses. Add a Journal test where the role grant comes only from `Permission.Roles` (code-declared) and the API write succeeds. Note this as a behaviour change in the Journal README/changelog (users with code-declared grants gain access they lacked through the Journal API).
### Pitfall 5: Migration tests pin the last ID
**What goes wrong:** `TestBackendAdminMetadataColumn` asserts the last `BackendAdminMigrations` ID and the last history row equal `202610090001_backend_users_metadata` and then `RollbackLast()`s expecting the metadata column to disappear [VERIFIED: modules/lagoon/backend_admin_migrations_test.go:561-575].
**How to avoid:** Update that test (roll back two, or assert by ID) in the same commit as the migration. `TestPhase09MigrationsFreshRollback` rolls back every entry newest-first (rollbackBackendAdmin, :445-452), which drops the token table before `backend_users` — keep the FK so that order works.
Schema facts for the FK [VERIFIED: modules/lagoon/backend_admin_migrations.go:36-37 — `CREATE TABLE IF NOT EXISTS backend_users (` / `id SERIAL PRIMARY KEY,`]: use `backend_user_id INTEGER NOT NULL REFERENCES backend_users(id) ON DELETE CASCADE`, `revoked_by INTEGER NULL REFERENCES backend_users(id) ON DELETE SET NULL`. Use `CREATE TABLE IF NOT EXISTS` and `CREATE … INDEX IF NOT EXISTS` to match the re-runnable style.
### Pitfall 6: CLI cannot see plugin scopes
See Pattern 7. Without a plugin list, `admin:token:scopes` shows only `*` and `admin:read`, and `--scope=journal` is rejected.
### Pitfall 7: CLI stdout pollution
`out.Success`/`Warning`/`Info` go to stdout, and in generated mains stderr is stdout too. Any courtesy line breaks `TOKEN=$(./bin/app admin:token:create …)`. Test that stdout is exactly `g15_<40 hex>\n`.
### Pitfall 8: OpenAPI inventory demands 200 or 201
`TestPhase09ContractInventory` fails a route with neither 200 nor 201 documented. Document 201 for create if you return 201; the SPA openapi-fetch client handles both.
### Pitfall 9: Scope labels are not in the SPA bundle
The public bundle serves only `backend::lang.` keys [VERIFIED: modules/cabana/lang.go:8 — `const adminLangPrefix = "backend::lang."`]. Plugin scope labels must be translated server-side in `/account/tokens/scopes` and in the list rows; framework scope copy (`api_tokens.*`) goes in `modules/phrasebook/backend/lang/{en,pl}/lang.yaml`.
### Pitfall 10: Route shadowing under the admin API
`GET /account/tokens/scopes` (three literal segments) is more specific than `GET /{vendor}/{plugin}/{controller}`, so ServeMux accepts both, but a plugin with vendor `account`, plugin `tokens`, controller `scopes` would be shadowed. `reservedVendorSegments` holds only `api`, `assets`, `login`, `settings` [VERIFIED: modules/cabana/registry.go:44]. Consider adding `account` (low risk).
### Pitfall 11: Journal throttle key runs before auth
`journal-api` bucket key reads `bouncer.User(r.Context())` (plugin.go:118-123) before any guard has run, so it always keys by IP. Agents behind one IP share 120/min. Pre-existing; leave unless cheap, but document.
### Pitfall 12: Docs snippets must compile
Docs pages use `src=modules/bouncer/example_test.go#ExampleNewJWTGuard`-style snippets checked by `TestDocsTree`. New code samples in docs should come from `Example*` tests, and every identifier named must exist.
## Code Examples
### Mint and hash (stdlib only)
```go
// Source: D-07; transform matches Apparatus PersonalApiToken::generateToken/hashToken
const tokenPrefix = "g15_"
func mintToken() (plain, hash, hint string, err error) {
var b [20]byte
if _, err = rand.Read(b[:]); err != nil {
return "", "", "", err
}
plain = tokenPrefix + hex.EncodeToString(b[:]) // 4 + 40 chars, lowercase
sum := sha256.Sum256([]byte(plain))
return plain, hex.EncodeToString(sum[:]), plain[len(plain)-4:], nil
}
func wellFormed(v string) bool {
if len(v) != 44 || !strings.HasPrefix(v, tokenPrefix) {
return false
}
for _, c := range v[4:] {
if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
return false
}
}
return true
}
```
### Debounced last_used_at
```go
// In-memory gate (per process) + conditional UPDATE (safe across processes).
var lastStamp sync.Map // token id -> time.Time
func stamp(ctx context.Context, db *gorm.DB, id uint, now time.Time) {
if v, ok := lastStamp.Load(id); ok && now.Sub(v.(time.Time)) < time.Minute {
return
}
lastStamp.Store(id, now)
err := db.WithContext(ctx).Exec(`UPDATE backend_api_tokens SET last_used_at = ?
WHERE id = ? AND (last_used_at IS NULL OR last_used_at < ?)`, now, id, now.Add(-time.Minute)).Error
if err != nil {
slog.Default().WarnContext(ctx, "cabana: token last_used_at stamp failed", "token_id", id, "error", err)
}
}
```
Use `UPDATE … SET last_used_at` only (not `updated_at`).
### Migration entry
```go
// Appended to lagoon.BackendAdminMigrations; table name is discretion.
{
ID: "202610110001_backend_api_tokens",
Migrate: func(tx *gorm.DB) error {
for _, stmt := range []string{
`CREATE TABLE IF NOT EXISTS backend_api_tokens (
id BIGSERIAL PRIMARY KEY,
backend_user_id INTEGER NOT NULL REFERENCES backend_users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
token_hash CHAR(64) NOT NULL UNIQUE,
token_hint TEXT NOT NULL,
scopes TEXT NOT NULL,
expires_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
revoked_by INTEGER REFERENCES backend_users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS backend_api_tokens_backend_user_id_index ON backend_api_tokens (backend_user_id)`,
} {
if err := tx.Exec(stmt).Error; err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec(`DROP TABLE IF EXISTS backend_api_tokens`).Error
},
},
```
The `UNIQUE` constraint creates the lookup index; no separate hash index is needed.
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Apparatus `TokenAuthenticate` (no scopes, full owner access, stamp every request) | Scoped tokens ∩ owner permissions, method-limited read scopes, debounced stamp | This phase | Agents get least privilege |
| Journal writes: backend JWT only (Phase 15 D-15) | Composite resolver: JWT or `journal`-scoped token | This phase | Reversal recorded in Journal README |
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | Publishing the activated plugin slice from `party.Activate` (Option A) is acceptable as a framework API addition | Pattern 7 | If rejected, Option B forces every host to regenerate `main.go` |
| A2 | Per-code any-of evaluation (owner AND scope per code) is the intended reading of D-03 | Pattern 2 | If "every required permission" was meant literally for multi-code checks, some multi-permission actions would need all codes covered |
| A3 | `/field-types` and `/markdown/preview` should be gated to `*`/`admin:read` under tokens | Pitfall 2 | A `journal` agent that wants markdown preview would be refused |
| A4 | A token whose stored scopes are all unknown should 401 rather than 403 | Pattern 6 | Minor UX difference for agents |
| A5 | Featured-image routes' missing `requireAccessPosts` may be PHP parity, not a bug | Pitfall 3 | Adding it could change Journal's contract for authors without `access_posts` |
| A6 | `admin:read` is offered in the picker only when the user is superuser or has at least one grant | Pattern 5 | Picker differs slightly from user expectation |
## Open Questions (RESOLVED)
1. **CLI plugin access (Option A vs B).** Recommendation: A. Ask the user at the plan-count checkpoint since it touches `party`'s exported API. — RESOLVED: Option A, user-confirmed: `party.Activate` publishes `party.Activated` and the token commands read it at Run time, with `cabana.RuntimeCommands(app)` unchanged (16.3-01 Decisions, Task 4).
2. **Featured-image permission gap.** Check the frozen PHP `routes.php`/controller for `access_posts` on featured images before adding it; the resolver's cover check closes the token-scope side either way. — RESOLVED: the frozen PHP `PostApiController` checks only `$post->canEdit($user)` on both featured-image actions, so no `access_posts` check is added (parity), and the resolver cover `golem15.journal.*` closes the token side (16.3-03 Decisions, Task 2 `TestJournalTokenExpansion`).
3. **Create response status (200 vs 201).** Either passes the inventory if documented; 201 matches Journal's own create; the admin API mostly uses 200. Recommendation: 201. — RESOLVED: 201, user-locked, documented as `@Success 201` in the swag stub (16.3-01 Decisions "Create contract", Task 3).
4. **Mixed `*` + other scopes on create.** Normalize to `["*"]` (simplest for clients) or 422. Recommendation: normalize. — RESOLVED: normalize to `["*"]`, user-locked, in the shared `validateTokenRequest`/`issueAPIToken` path used by the API and the CLI (16.3-01 Task 3).
5. **Version release.** The Journal change requires a new framework tag (current latest `v0.1.20`) and a Journal `go.mod` bump; host apps then bump the journal submodule and framework version. Confirm the release step is in a plan. — RESOLVED: 16.3-03 Task 3 (blocking-human release gate) and Task 4 (annotated tag after `v0.1.20`, Journal `go.mod` require, `sm-grzybyfunkcjonalne-app` journal gitlink bump; the `sm-summercmsio-app` bump is asked at the gate).
## Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Go | all Go code | ✓ | go1.27.0 | — |
| Node | admin SPA build/tests | ✓ | v22.23.2 (engines `>=22.6`) | — |
| npm | admin deps | ✓ | 12.0.2 | — |
| Docker daemon | testcontainers Postgres suites (cabana auth/account harness `adminHandler`, lagoon migration tests, Journal admin harness) | ✗ in this sandbox (`permission denied while trying to connect to the docker API at unix:///var/run/docker.sock`) | client 29.7.2 | Run Postgres stages on a Docker host; Docker-free unit tests (Allows, scope cover, mint/format, CLI arg parsing, inventories) run anywhere |
**Missing dependencies with no fallback:** none for planning; executors need a Docker host for the Postgres stages (as in every earlier phase gate).
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | Go `testing` (+ testcontainers-go Postgres for DB suites); vitest 3.2.7 + @vue/test-utils + happy-dom for the SPA |
| Config file | `admin/vitest.config.ts` (`include: ['tests/**/*.test.ts']`, setup `tests/setup.ts`) |
| Quick run command | `go test ./modules/cabana ./modules/bouncer ./modules/pact -run 'Token|Scope|Allows' -count=1` |
| Full suite command | `go vet ./... && go test ./... && npm --prefix admin test && (cd ../sm-journal-plugin && go vet ./... && go test ./...)` |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| SC-1 | `g15_` Bearer authenticates; JWT and cookie unchanged; malformed/expired/revoked/deactivated/soft-deleted → 401; cookie or query `g15_` ignored | unit + Postgres | `go test ./modules/cabana -run TestBackendToken -count=1` | ❌ Wave 0 |
| SC-2 | `Allows` matrix: scope × owner × superuser × empty list × method; non-`Allows` route gates; interactive-only routes refuse `*` | unit (Docker-free) | `go test ./modules/cabana -run 'TestAllowsScope|TestTokenRouteGates|TestInteractiveOnly' -count=1` | ❌ Wave 0 |
| SC-2 | scope cover incl. wildcard-vs-wildcard, overlap rule | unit | `go test ./modules/cabana -run TestScopeCover -count=1` | ❌ Wave 0 |
| SC-2 | boot fails on duplicate/reserved/invalid scope, unknown permission, missing phrase key | unit | `go test ./modules/cabana -run TestTokenScopeCompile -count=1` | ❌ Wave 0 |
| SC-3 | account token API: list/create/revoke/scopes, 422 details, own-only revoke, plaintext once | Postgres | `go test ./modules/cabana -run TestAccountTokens -count=1` | ❌ Wave 0 |
| SC-3 | route inventories + OpenAPI | unit | `go test ./modules/cabana -run 'TestPhase09PermissionMatrix|TestPhase09ContractInventory|TestPhase10ConformanceInventory' -count=1` and `bash scripts/check-admin-openapi.sh --check` | ✅ (update) |
| SC-3 | CLI: create stdout exactly the token; list; revoke; scopes; overlap 422-equivalent errors | Postgres | `go test ./modules/cabana -run TestTokenCommands -count=1` | ❌ Wave 0 |
| SC-3 | SPA panel: states, show-once, clipboard fallback, close-without-copy, plaintext cleared | vitest | `npm --prefix admin test -- tests/components/ApiTokensPanel.test.ts` | ❌ Wave 0 |
| SC-3 | migration up/down, last-ID test updated | Postgres | `go test ./modules/lagoon -run TestBackendAdmin -count=1` | ✅ (update) |
| SC-4 | Journal: `journal` token writes; `journal:read` POST → 403 Journal body; unrelated scope → 403/anon; JWT path unchanged; code-declared role grant now honored | Postgres | `(cd ../sm-journal-plugin && go test . -run TestJournalToken -count=1)` | ❌ Wave 0 |
| SC-5 | docs identifiers, commands, snippets | unit | `go test ./cmd/summer -run TestDocsTree -count=1` | ✅ |
| SC-5 | SPA dist drift | script | `bash scripts/check-admin-dist.sh` | ✅ |
### Sampling Rate
- **Per task commit:** quick run command plus `go vet ./...`.
- **Per wave merge:** full suite; Postgres suites on a Docker host.
- **Phase gate:** a `scripts/check-phase16.3.sh` following the 16.2 gate's stage pattern (go, admin, docs, postgres; fail on skips or zero tests), green before `/msd-verify-work`.
### Wave 0 Gaps
- [ ] `modules/cabana/token_*_test.go` — Allows/scope cover/compile/guard/inventory classification (Docker-free)
- [ ] `modules/cabana/account_tokens_test.go` — API via the existing `adminHandler(t, gdb, mutate)` harness (auth_test.go:367)
- [ ] `modules/cabana/commands_test.go` additions — token commands (stdout purity)
- [ ] `admin/tests/components/ApiTokensPanel.test.ts` — UI-SPEC backstops (plaintext never retained, clipboard flow, close guard)
- [ ] `../sm-journal-plugin/journal_api_tokens_test.go` — via `newPostsEnv`
- [ ] `scripts/check-phase16.3.sh`
## Security Domain
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | yes | 160-bit CSPRNG secret, sha256 at rest, format check before lookup, expiry, soft revoke, owner activation check |
| V3 Session Management | yes | Tokens are not sessions: interactive-only routes (account, tokens, logout/refresh) refuse them; JWT cutoff unaffected (D-10) |
| V4 Access Control | yes | Owner ∩ scope in `Allows`; method filter in guard; resolver cover check; superuser limited by scope |
| V5 Input Validation | yes | Name trim/length, expiry enum or date, scope names against the compiled catalog (422) |
| V6 Cryptography | yes | stdlib `crypto/rand`, `crypto/sha256`, `crypto/subtle`; nothing hand-rolled |
| V7 Error Handling and Logging | yes | Never log plaintext or Authorization; log token id and admin id on denial via `logAuth` |
### Known Threat Patterns for this stack
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Leaked token mints more tokens or changes password | Elevation of privilege | Interactive-only marker on account and token routes (D-06) |
| Scoped token reaches unscoped route | Elevation of privilege | Deny-by-default in `Allows`; explicit gates on non-`Allows` routes; resolver cover |
| Superuser token escapes scope | Elevation of privilege | Scope evaluated before the superuser shortcut |
| Token via cookie/query (CSRF, referrer leak) | Spoofing / Info disclosure | Bearer header only (D-13) |
| Timing oracle on hash compare | Info disclosure | Indexed equality on sha256 + constant-time compare after fetch |
| Plaintext persisted in browser | Info disclosure | Component-local state only, cleared on close/unmount (UI-SPEC §5) |
| DB dump reveals usable tokens | Info disclosure | Only sha256 stored; hint is 4 chars |
| Write amplification (stamp per request) | DoS | Per-minute debounce |
| Dead owner keeps access | Elevation of privilege | Fresh `FindByID` per request (D-10) |
## Sources
### Primary (HIGH confidence — read this session)
- `modules/bouncer/{guard.go,context.go,registry.go,jwt.go}` — guard interfaces, Principal, middleware failure path, bearer extraction
- `modules/cabana/{http.go,auth.go,contracts.go,account.go,csrf.go,commands.go,registry.go,lang.go,admin_openapi.go,field_type.go,field_markdown.go,messages.go}` — registration, mount, Allows/granted, account handlers, CSRF, CLI, scope-compile precedent
- `modules/cabana/{security_coverage_test.go,phase09_contract_test.go,openapi_conformance_test.go,export_test.go}` — route inventories
- `modules/lagoon/{backend_admin_migrations.go,backend_admin_migrations_test.go,migrations.go,jsonable.go}`
- `modules/pact/capabilities.go`, `modules/party/registry.go`, `modules/bonfire/{command.go,output.go,root.go}`, `modules/surf/router.go`, `modules/wristband/crypto.go`
- `cmd/summer/{runtime.go,docs.go}`, `internal/build/build.go`, `docs/services/authentication.md`, `docs/console/{introduction.md,setup-and-maintenance.md}`
- `scripts/{check-admin-openapi.sh,check-admin-dist.sh,check-phase16.2.sh}`, `admin/{package.json,vitest.config.ts,src/api/client.ts}`
- `../sm-journal-plugin/{controllers/api/auth.go,controllers/api/posts.go,controllers/api/posts_helpers.go,models/post.go,routes.go,admin_permissions.go,plugin.go,admin_harness_test.go,go.mod,go.work}`
- `../sm-summercmsio-app/plugins/golem15/user/{middleware/api_token.go,classes/api_tokens.go,models/api_token.go,updates/202610040001_create_api_tokens.go}`
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/apparatus/{models/PersonalApiToken.php,middleware/TokenAuthenticate.php}`
- Host `main.go` files in `sm-grzybyfunkcjonalne-app`, `fonoteka.go` (generated, call `cabana.RuntimeCommands(app)`)
### Secondary / Tertiary
- None. No web sources were needed: the phase adds no external dependency and every decision is in-repo.
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH — stdlib plus already-pinned modules; nothing new.
- Architecture: HIGH — every seam read in source; the open choices (CLI plugin access, multi-code reading) are flagged.
- Pitfalls: HIGH — each one traced to a file and line this session.
**Research date:** 2026-10-11
**Valid until:** 2026-11-10 (in-repo findings; re-check if cabana auth or Journal auth changes first)
### Suggested plan split (for the plan-count checkpoint; CLAUDE.md rule 2)
1. Framework core: pact `HasTokenScopes`, scope compile + framework scopes, `bouncer.Principal` scope limit, `Allows` rewrite, token table migration (+ fixed migration test), composite guard, interactive-only marker, route gates, published resolver; smoke tests.
2. Account token API + swag/OpenAPI regen + route inventories, CLI `admin:token:*` (+ plugin-list access), `summer` delegate rows; smoke tests.
3. Admin SPA token panel per UI-SPEC, lang keys, `npm run build` dist commit.
4. Journal: scopes + lang, resolver switch, README; framework READMEs (cabana, bouncer, pact, party if changed) and `docs/` pages; framework tag and Journal `go.mod` bump.
5. Unit tests (last plan): full coverage across both repos, `scripts/check-phase16.3.sh`.

View File

@@ -0,0 +1,367 @@
---
phase: "16.3"
slug: "backend-admin-personal-api-tokens-with-scopes"
status: approved
reviewed_at: "2026-10-11"
shadcn_initialized: false
preset: none
created: "2026-10-10"
---
# Phase 16.3 — UI Design Contract
> Visual and interaction contract for frontend phases. Generated by msd-ui-researcher, verified by msd-ui-checker.
This phase adds one surface to the existing admin SPA (`admin/`): an **API tokens** card on the My account page (`AccountView.vue`, route `/account`). The card lists the signed-in user's own tokens, creates a token in a two-step dialog (form, then show-once plaintext), and revokes tokens (D-16). There is no other screen: no superuser view and no backend-user management screen (deferred). The CLI (`admin:token:*`, D-19) has no visual contract beyond its stdout rule in CONTEXT.md.
The SPA already 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` and built on Reka UI primitives. **This contract adds no new tokens, colours, fonts, radii or shadows.** Every value below reuses an existing token or copies a class string from an existing component (`AccountView.vue`, `RelationPickerModal.vue`, `ConfirmDialog.vue`, `PermissionEditorField.vue`, `CheckboxField.vue`, `Button.vue`, `control.ts`).
Sources: 16.3-CONTEXT.md D-06, D-08, D-09, D-11, D-16, D-17, D-18 (locked); Phase 12.2 UI-SPEC (approved house conventions for dialogs, chips, alert blocks, skeletons and copy); the codebase scan listed above; `modules/phrasebook/backend/lang/{en,pl}/lang.yaml` (`myaccount.*`, `form.*`).
---
## 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, same ruling as Phases 10.1, 12.1, 12.2) |
| Preset | not applicable |
| Component library | Reka UI 2.9.10 (headless primitives, the Vue port of Radix) plus the local components in `admin/src/components/` |
| Icon library | `@lucide/vue` 1.17.0 (named imports only; 16px in buttons and inline notices, 14px in error and note lines, 20px in the empty-state circle) |
| Font | DM Sans 400/600/700 (self-hosted `@fontsource/dm-sans`); DM Mono 400 (`font-mono`) for the token plaintext, the token hint and scope names; `tabular-nums` on every date and time |
---
## 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-10.
Local components enumerated by `find admin/src/components -name '*.vue' | wc -l` — 51 components — summercms-admin@unversioned (private workspace package with no `version` field in `admin/package.json`; pinned by repo commit a04ba35) — 2026-10-10.
Icon presence verified by `node --input-type=module -e "import * as l from '@lucide/vue'; ..."` (run in `admin/`) against @lucide/vue@1.17.0 on 2026-10-10: `Plus`, `KeyRound`, `Copy`, `Check`, `Ban`, `TriangleAlert`, `CircleAlert`, `Info`, `LoaderCircle`, `X` all exported.
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 |
|-----------|-------------|-------|
| DialogRoot, DialogPortal, DialogOverlay, DialogContent, DialogTitle, DialogDescription, DialogClose | `reka-ui` | The create-token dialog (both steps). Same parts and classes as `RelationPickerModal.vue`. Verified exported. |
| RadioGroupRoot, RadioGroupItem | `reka-ui` | The expiry segmented control. Same segmented classes as `PermissionEditorField.vue`. Verified exported. |
| AlertDialog* via ConfirmDialog | `admin/src/components/ui/ConfirmDialog.vue` + `useConfirm` from `confirm.ts` | Revoke confirmation and the "close without copying" confirmation. `AccountView` already mounts one `ConfirmDialog` bound to `useConfirm()`; reuse that instance. Do not build a second confirm. |
| Button | `admin/src/components/ui/Button.vue` | Every text button. Variants `primary` / `outline` / `ghost` / `danger`, sizes `md` (42px) / `sm` (38px). |
| `controlClass` | `admin/src/components/form/control.ts` | Border, background and padding of the name input and the plaintext input. |
| `fieldErrors`, `focusField` | `admin/src/components/form/formState.ts` | Map a 422 `error.details` to field errors and focus the first invalid field (same as `AccountView.onSave`). |
| `showToast` | `admin/src/state/useToasts.ts` | `success` toast after revoke; `danger` toasts for failures. Never carries the plaintext. |
| `t`, `tc` | `admin/src/app/i18n.ts` | All copy. No literal UI strings in the component. |
| `api` (openapi-fetch client) | `admin/src/api/client.ts` | Typed calls to the D-18 endpoints after `npm run gen:api` regenerates `schema.d.ts`; keeps the 401 refresh-and-replay. |
| Native checkbox (`size-5 shrink-0 rounded-[6px] accent-primary`) | class string from `CheckboxField.vue` | Scope picker rows and the "Show revoked tokens" toggle. Not a field-registry control: used directly. |
New code this phase creates (planner names the file): one component, suggested `admin/src/components/account/ApiTokensPanel.vue` (card, list, revoke), with the dialog either inline or in a sibling `ApiTokenCreateDialog.vue`. `AccountView.vue` renders the panel after the profile/password `<form>` and passes it the shared `confirm` from `useConfirm()`.
---
## Spacing Scale
Declared values for new layout in this phase (multiples of 4):
| Token | Value | Tailwind | Usage |
|-------|-------|----------|-------|
| xs | 4px | `gap-1` | Label-to-mono-name gap in a scope row; icon-to-text inside chips |
| sm | 8px | `gap-2` | Scope chip gap, toggle checkbox-to-label gap, row line gap, plaintext input to copy-row gap, empty-state stack gap |
| md | 16px | `gap-4`, `px-4` | Card inner stack gap, row horizontal padding, row column gap, dialog section gap, card header gap |
| lg | 24px | `p-6`, `px-6` | Dialog padding; empty-state horizontal padding |
| xl | 32px | `py-8` | Empty-state vertical padding |
| 2xl | 48px | — | Not used by new components |
| 3xl | 64px | — | Not used by new components |
**Locked user decision (2026-10-11):** these inherited Direction C values are accepted for this phase; any new layout not copied from an existing component uses the 4/8/16/24/32 scale. The user explicitly accepted the 18, 6, 10, 14, 2, 20, 28 and 12px values below (`gap-[18px]`, `gap-1.5`, `gap-2.5`, `py-3.5`, `px-[18px] py-3.5`, `mt-0.5` / `gap-0.5`, `gap-5`, `p-7`, `gap-3` / `py-3`) as locked exceptions. Re-verification treats them as locked, not as defects.
Exceptions (inherited from Direction C, `AccountView.vue` and `ConfirmDialog.vue`, binding, not new):
- card padding 28px (`p-7`) and page stack gap 20px (`gap-5`), copied from the existing account cards;
- field stack gap 18px (`gap-[18px]`) inside the dialog form, copied from the account `fieldset`;
- label-to-control gap 6px (`gap-1.5`);
- footer button gap 10px (`gap-2.5`), copied from `ConfirmDialog`;
- row vertical padding 14px (`py-3.5`), copied from the `CheckboxField` toggle card;
- alert block padding 14×18 (`px-[18px] py-3.5`), copied from the 12.2 alert block;
- segmented control inner padding 4px (`p-1`) and segment height 34px (`h-pager`), copied from `PermissionEditorField`;
- scope-row checkbox-to-text gap and dialog header gap 12px (`gap-3`), the same exception 12.2 approved for picker option rows and dialog headers; scope-row vertical padding 12px (`py-3`), copied from the `PermissionEditorField` option rows;
- 2px nudges (`mt-0.5` on the scope checkbox and notice icon, `gap-0.5` between error lines), copied from the existing error-line markup. No other off-scale spacing is introduced.
Fixed component dimensions (sizes, not spacing): 44px inputs (`h-input`), 42px / 38px buttons, 34px dialog close button, 40px empty-state icon circle, 24px chips (`h-6`), 20px checkboxes, 560px dialog max width (`max-w-[560px]`), 56px minimum scope-row height (`min-h-[56px]`), 12px skeleton bars with `rounded-[6px]`.
---
## Typography
New components use exactly four sizes. All inherit DM Sans and the 14px/1.5 base from `html` in `main.css`.
| Role | Size | Weight | Line Height | Used by |
|------|------|--------|-------------|---------|
| Body | 14px | 400 | 1.5 | Token name input, toggle label, plaintext input (DM Mono), dialog descriptions, segment labels when checked |
| Label | 14px | 600 | 1.5 | Field labels (Name, Expiration, Scopes), token name in a row, scope label in a picker row, empty-state heading, button text (existing `Button`) |
| Meta | 13px | 400 | 1.5 | Card comment, row meta line (dates), token hint (DM Mono), scope mono name in the picker, scope description, expiry preview line, error lines, copy status line, unchecked segment labels |
| Section heading | 18px | 700 | 1.5 | Card heading "API tokens": the existing `AccountView` card heading class `text-[18px] font-bold`, copied verbatim |
| Dialog title | 20px | 700 | 1.2 (`tracking-[-0.01em]`) | Dialog titles: the existing `DialogTitle` class from `RelationPickerModal.vue`, copied verbatim |
**Locked user decision (2026-10-11):** 700 heading weight inherited from Direction C is accepted for this phase; new content uses 400 and 600 only. The 700 weight appears only on the card heading (`text-[18px] font-bold`) and the dialog title (`DialogTitle` class from `RelationPickerModal.vue`), both copied verbatim. Re-verification treats this as a locked system-level exception, not a defect.
Weights: new content uses 400 and 600 only. **700 is the inherited, heading-only weight** (card heading and dialog title, both copied verbatim). Chips use 13px/600, the same as the approved 12.2 "Unsaved" chip; no 12px text is introduced. Every date and time uses `tabular-nums`. DM Mono appears only on the plaintext token, the `g15_…a1b2` hint and scope names (`journal:read`, `*`).
---
## Color
All colours are existing CSS variables; light / dark values from `main.css`. Hard-coded hex values are forbidden in new components.
| Role | Value (light / dark) | Usage |
|------|----------------------|-------|
| Dominant (60%) | `bg` #f4f6f9 / #111726, `surface` #ffffff / #182033 | Page background; the card, the dialog, inputs, list rows |
| Secondary (30%) | `subtle` #f3f5f8 / #1f283d, `border` #e6e9ef / #29334b, `border-strong` #d2d8e2 / #3a4661, `muted` #566175 / #a9b3c6, `skel` #eceff4 / #263049 | Read-only plaintext input fill, segmented control track, scope chips, empty-state circle, row dividers, dashed empty-state border, meta text, skeleton bars |
| Accent (10%) | `primary` #22304d / #fcd34d (action colour); `sel` #fdf3cf / #3a3622; `ring` rgba(252,196,40,.55) / rgba(252,211,77,.45) | See reserved list below |
| Destructive | `danger` #c62828 / #f58a8a, `danger-soft` #fdf0f0 / #3a1d24 | "Revoke token" row buttons (danger outline), the revoke confirm button, the "Expired" chip, invalid input borders, error lines, alert blocks |
Accent reserved for (exhaustive for this phase):
1. `primary` fill: the single primary button of each dialog step, "Create token" (step 1) and "Done, hide token" (step 2). The page's existing footer Save stays the page's only other primary.
2. `primary` checkbox tint (`accent-primary`): checked scope checkboxes and the "Show revoked tokens" checkbox.
3. `sel` tint: the show-once notice block in step 2, and nothing else.
4. `ring` (3px): focus-visible on every new focusable element (base style), never removed.
Accent is never used for: the "Create token" button on the card (outline), scope chips, token names, row hover, the checked expiry segment (uses `bg-surface shadow-tab`), icons, or links.
---
## Copywriting Contract
All copy is keyed in `modules/phrasebook/backend/lang/{en,pl}/lang.yaml` under a new `api_tokens:` block (`backend::lang.api_tokens.*`), next to `myaccount:`. Placeholders use `:name` syntax. Polish is the design language; English values follow. Plugin scope labels and descriptions come from the plugin's own phrasebook keys (D-01) and are rendered as delivered by the server.
### Primary CTAs and key states
| Element | Copy (EN / PL) |
|---------|----------------|
| Card heading | `api_tokens.section` "API tokens" / "Tokeny API" |
| Card comment | `api_tokens.section_comment` "Tokens let agents and integrations call the admin API as you, limited to the scopes you choose. Treat a token like a password." / "Tokeny pozwalają agentom i integracjom wywoływać API panelu w Twoim imieniu, w granicach wybranych zakresów. Traktuj token jak hasło." |
| Card button (opens dialog) | `api_tokens.create` "Create token" / "Utwórz token" (`Plus` icon) |
| Primary CTA, dialog step 1 | `api_tokens.create_submit` "Create token" / "Utwórz token"; while busy `api_tokens.creating` "Creating…" / "Tworzenie…" |
| Primary CTA, dialog step 2 | `api_tokens.done` "Done, hide token" / "Gotowe, ukryj token" |
| Empty state heading | `api_tokens.empty_title` "No API tokens yet" / "Nie masz jeszcze tokenów API" |
| Empty state body | `api_tokens.empty_body` "Create a token to let an agent or integration call the admin API as you." / "Utwórz token, aby agent lub integracja mogły wywoływać API panelu w Twoim imieniu." (the card's "Create token" button is the next step) |
| Error state (list load failed) | `api_tokens.load_failed` "The tokens could not be loaded. Refresh the page to try again." / "Nie udało się wczytać tokenów. Odśwież stronę, aby spróbować ponownie." |
| Error state (scopes load failed, dialog) | `api_tokens.scopes_failed` "The scopes could not be loaded. Close this window and try again." / "Nie udało się wczytać zakresów. Zamknij to okno i spróbuj ponownie." |
| Error state (create, non-422) | `api_tokens.create_failed` "The token could not be created. Please try again." / "Nie udało się utworzyć tokenu. Spróbuj ponownie." (danger toast; dialog stays open with input kept) |
| Error state (create 422, name) | `api_tokens.name_required` "Enter a name for the token." / "Podaj nazwę tokenu." (server message, rendered verbatim) |
| Error state (create 422, scopes empty) | `api_tokens.scopes_required` "Choose at least one scope." / "Wybierz co najmniej jeden zakres." (server message) |
| Error state (create 422, scope refused) | `api_tokens.scope_unavailable` "The scope :name is not available to you." / "Zakres :name nie jest dla Ciebie dostępny." (server message, D-17 overlap rule) |
| Error state (revoke, non-404) | `api_tokens.revoke_failed` "The token could not be revoked. Please try again." / "Nie udało się unieważnić tokenu. Spróbuj ponownie." (danger toast) |
| Error state (revoke 404) | `api_tokens.gone` "This token no longer exists. The list has been refreshed." / "Ten token już nie istnieje. Lista została odświeżona." (danger toast, list reloads) |
| Error state (clipboard refused) | `api_tokens.copy_failed` "Could not copy automatically. The token is selected: press Ctrl+C (Cmd+C on a Mac) to copy it." / "Nie udało się skopiować automatycznie. Token jest zaznaczony: naciśnij Ctrl+C (Cmd+C na Macu), aby go skopiować." |
### Destructive actions
| Action | Confirmation approach | Copy (EN / PL) |
|--------|----------------------|----------------|
| Revoke a token (row button) | ConfirmDialog, `danger`, with the revoke request run as the confirm action (`confirm.ask(request, run)`), so the confirm button shows the busy spinner until it settles | Row button: `api_tokens.revoke` "Revoke token" / "Unieważnij token", aria-label `api_tokens.revoke_named` "Revoke token: :name" / "Unieważnij token: :name". Confirm message: `api_tokens.revoke_confirm` "Revoke the token “:name”? Apps and agents using it lose access at once. This cannot be undone." / "Unieważnić token „:name”? Aplikacje i agenci, którzy go używają, natychmiast stracą dostęp. Tej operacji nie można cofnąć." Confirm button: `api_tokens.revoke_submit` "Revoke token" / "Unieważnij token". Success toast: `api_tokens.revoked` "Token revoked" / "Unieważniono token". |
| Close step 2 before copying (Esc or the close button, while the token was never copied) | ConfirmDialog, `danger`, rendered above the token dialog | `api_tokens.close_uncopied_confirm` "Close without copying? You won't be able to see this token again." / "Zamknąć bez kopiowania? Tego tokenu nie będzie można już zobaczyć." Confirm button: `api_tokens.close_uncopied_submit` "Close and hide token" / "Zamknij i ukryj token". Cancel keeps the dialog open on step 2. |
| Close step 1 with input typed | No dialog: nothing is stored yet and the form is cheap to refill | — |
### Create dialog copy
| Element | Copy (EN / PL) |
|---------|----------------|
| Step 1 title | `api_tokens.create_title` "New API token" / "Nowy token API" |
| Step 1 description | `api_tokens.create_description` "Pick a name, an expiry and the scopes this token may use. It never gets more access than your own account." / "Wybierz nazwę, termin ważności i zakresy tokenu. Token nigdy nie ma szerszego dostępu niż Twoje konto." |
| Name label / placeholder / comment | `api_tokens.name` "Name" / "Nazwa"; `api_tokens.name_placeholder` "e.g. Blog agent" / "np. Agent bloga"; `api_tokens.name_comment` "Helps you recognise the token in the list." / "Pomoże rozpoznać token na liście." |
| Expiration label | `api_tokens.expires` "Expiration" / "Ważność" |
| Expiration segments (D-08) | `api_tokens.expires_30` "30 days" / "30 dni"; `api_tokens.expires_90` "90 days" / "90 dni"; `api_tokens.expires_365` "365 days" / "365 dni"; `api_tokens.expires_never` "Never" / "Bez terminu" |
| Expiry preview (dated choice) | `api_tokens.expires_on` "Expires on :date." / "Wygasa :date." |
| Expiry preview (never) | `api_tokens.expires_never_note` "This token never expires. Revoke it when you no longer need it." / "Ten token nie wygasa. Unieważnij go, gdy przestanie być potrzebny." |
| Scopes label | `api_tokens.scopes` "Scopes" / "Zakresy" |
| Framework scope `*` (D-04, D-17) | `api_tokens.scope_all` "Full access" / "Pełny dostęp"; `api_tokens.scope_all_description` "Everything you can do in the admin. Your account settings and tokens still need a signed-in session." / "Wszystko, co możesz zrobić w panelu. Ustawienia konta i tokeny nadal wymagają zalogowania." |
| Framework scope `admin:read` (D-04) | `api_tokens.scope_admin_read` "Read-only, whole admin" / "Tylko odczyt, cały panel"; `api_tokens.scope_admin_read_description` "View everything you can see in the admin, without making changes." / "Podgląd wszystkiego, co widzisz w panelu, bez wprowadzania zmian." |
| Read-only chip (scope whose methods are only GET/HEAD) | `api_tokens.read_only` "Read-only" / "Tylko odczyt" |
| Full-access note (when `*` is checked) | `api_tokens.scope_all_includes` "Full access already includes every other scope." / "Pełny dostęp obejmuje wszystkie pozostałe zakresy." |
| Step 2 title | `api_tokens.created_title` "Copy your new token" / "Skopiuj nowy token" |
| Step 2 notice | `api_tokens.created_notice` "This is the only time the token is shown. Copy it now and store it somewhere safe. If you lose it, revoke it and create a new one." / "Token jest widoczny tylko teraz. Skopiuj go i przechowuj w bezpiecznym miejscu. Jeśli go zgubisz, unieważnij go i utwórz nowy." |
| Plaintext input label (sr-only) | `api_tokens.token_label` "New token" / "Nowy token" |
| Copy button / copied state | `api_tokens.copy` "Copy token" / "Kopiuj token" (`Copy` icon); after success `api_tokens.copied` "Copied" / "Skopiowano" (`Check` icon) for 2 s |
| Copy announcement (polite live region) | `api_tokens.copied_announce` "Token copied to the clipboard." / "Skopiowano token do schowka." |
| Dialog dismiss button (step 1) | `api_tokens.create_cancel` "Discard token" / "Odrzuć token" (new key; closes the dialog and discards the typed input; the generic `form.cancel` is not used here) |
| Dialog close button aria | existing `form.close` |
### List copy
| Element | Copy (EN / PL) |
|---------|----------------|
| Show-revoked toggle | `api_tokens.show_revoked` "Show revoked tokens" / "Pokaż unieważnione tokeny" |
| Meta: created | `api_tokens.created_at` "Created :date" / "Utworzono :date" |
| Meta: last used / never | `api_tokens.last_used` "Last used :date" / "Ostatnio użyto :date"; `api_tokens.never_used` "Never used" / "Nieużywany" |
| Meta: expiry | `api_tokens.expires_at` "Expires :date" / "Wygasa :date"; `api_tokens.expired_at` "Expired :date" / "Wygasł :date"; `api_tokens.no_expiry` "No expiry" / "Bez terminu ważności" |
| Meta: revoked | `api_tokens.revoked_at` "Revoked :date" / "Unieważniono :date" |
| State chips | `api_tokens.state_expired` "Expired" / "Wygasły"; `api_tokens.state_revoked` "Revoked" / "Unieważniony" |
| Unknown scope chip and tooltip | `api_tokens.unknown_scope` ":name (unknown)" / ":name (nieznany)"; `title` `api_tokens.unknown_scope_hint` "This scope is no longer registered and grants nothing." / "Ten zakres nie jest już zarejestrowany i nic nie przyznaje." |
| Hint (aria-label of the mono hint) | `api_tokens.hint` "Token ending in :last4" / "Token kończący się na :last4" |
### Changed existing key
`myaccount.menu_description` becomes "Edit your name, email, subtitle and password, and manage your API tokens." / "Zmień imię, nazwisko, e-mail, podtytuł i hasło oraz zarządzaj tokenami API."
---
## Component Contracts
### 1. Placement on `/account`
- The API tokens card renders **after** the profile/password `<form>` and **outside** it, as a sibling inside the existing `section` stack (`gap-5`). It is never inside the `<form>`: the fixed footer "Save" saves only the profile and never touches tokens, and every token action is immediate.
- The card uses the existing account card class verbatim: `flex flex-col gap-4 rounded-card border border-border bg-surface p-7 shadow-card`, with `data-api-tokens`.
- The `ConfirmDialog` already mounted by `AccountView` serves revoke and close-without-copying. The panel receives the view's `useConfirm()` instance (prop or provide/inject; planner's choice).
### 2. Card header and toolbar
- Header: `flex flex-wrap items-start justify-between gap-4`. Left column `flex min-w-0 flex-col gap-1`: `h2` `api_tokens.section` (18/700) and `p` `api_tokens.section_comment` (13px muted, `max-w-[640px]`). Right: `Button variant="outline" size="sm" :icon="Plus"` with `api_tokens.create`, `data-action="create-token"`. Disabled while the list is in its first load.
- Visual anchor: the 18/700 heading is read first, then the row names. The card has no primary fill, so it never competes with the page's footer Save.
- Toolbar row (only after a successful load): `label.flex.items-center.gap-2` holding the native checkbox (`size-5 shrink-0 rounded-[6px] accent-primary`, `data-show-revoked`) and `api_tokens.show_revoked` (14/400). Toggling reloads the list with revoked rows included (D-09); the toggle state lives in the component only (not in the URL, not persisted).
### 3. Token list
- Container: `flex flex-col overflow-hidden rounded-inner border border-border`, `role="list"`, `data-token-list`. Rows are ordered newest first (`created_at` desc). No pagination: one user's tokens are a short list; the server returns them all.
- Row (`role="listitem"`, `data-token-row="<id>"`): `flex items-start justify-between gap-4 border-t border-border px-4 py-3.5 first:border-t-0 max-sm:flex-col`.
- Left column `flex min-w-0 flex-1 flex-col gap-2`:
- Line 1, `flex flex-wrap items-center gap-2`: the token name (14/600, `[overflow-wrap:anywhere]`, never truncated, since the name is how the user tells tokens apart); the hint `g15_…a1b2` (13px `font-mono text-muted`, `aria-label` `api_tokens.hint`); the state chip when the token is expired or revoked.
- Line 2, `flex flex-wrap gap-2`, `data-token-scopes`: one chip per stored scope, in stored order.
- Line 3, meta: 13px muted `tabular-nums`, items joined with " · ": created, last used (or never used), then expiry (expires / expired / no expiry), and for revoked rows the revoked date.
- Right: `Button variant="danger" size="sm" :icon="Ban"` with `api_tokens.revoke` "Revoke token", aria-label `api_tokens.revoke_named`, `data-action="revoke-token"`. Shown on active and expired rows, hidden on revoked rows. On `max-sm` it sits under the meta line (`self-start`).
- Chips (all `inline-flex h-6 items-center rounded-pill px-2.5 text-[13px] font-semibold whitespace-nowrap`):
| Chip | Extra classes | Text |
|------|---------------|------|
| Known scope | `bg-subtle text-text` | the scope label (`*` shows `api_tokens.scope_all`); `title` = the scope's mono name |
| Unknown scope (no longer registered, D-12 discretion) | `border border-dashed border-border-strong text-muted font-mono` | `api_tokens.unknown_scope` with the raw name; `title` `api_tokens.unknown_scope_hint` |
| State: expired | `bg-danger-soft text-danger` | `api_tokens.state_expired` |
| State: revoked | `border border-border-strong text-muted` | `api_tokens.state_revoked` |
- Revoked rows: name and meta use `text-muted`; no line-through (the chip states it in text, WCAG 1.4.1).
- Dates: created, expires and expired show `YYYY-MM-DD`; last used and revoked show `YYYY-MM-DD HH:mm`, both in the browser's local time, the same shapes as the list `datetime` cell in `CellValue.vue` (reuse or extract that formatter; do not add a new date format).
- Labels for list chips come from the same scope catalog the server uses (the list payload carries a label and a known flag per scope, or the panel maps names through a catalog that includes every registered scope, not only the ones the picker shows). A name with no label is rendered as unknown. The API shape is the planner's call; the rendering rule is fixed here.
### 4. Create dialog, step 1 (form)
- Shell: the `RelationPickerModal` shell, verbatim: `DialogOverlay` `fixed inset-0 z-40 bg-overlay` with `data-dialog-overlay`; `DialogContent` `fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100vh-32px)] w-[calc(100%-32px)] max-w-[560px] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 rounded-modal bg-surface p-6 text-text shadow-pop` with `data-dialog-content` and `data-token-dialog`, using the existing 200 ms fade/scale.
- Header `flex items-start gap-3`: `DialogTitle` `api_tokens.create_title` (20/700 class copied) and `DialogDescription` `api_tokens.create_description` (14px muted) in a `flex min-w-0 flex-1 flex-col gap-1` column; the 34px `DialogClose` button copied from the picker (`X` 16px, aria `form.close`).
- Body: `<form novalidate>` with `fieldset` (`disabled` while busy) holding a `flex min-h-0 flex-col gap-[18px] overflow-y-auto` stack:
1. **Name.** `label` (14/600) + `input type="text"` `h-input` with `controlClass(invalid)`, `maxlength="100"`, `autocomplete="off"`, placeholder `api_tokens.name_placeholder`, comment line `api_tokens.name_comment` (13px muted). The server enforces the same 100-character limit and trims the value.
2. **Expiration.** A `span` label (14/600) naming a Reka `RadioGroupRoot` (`orientation="horizontal"`, `aria-labelledby`) styled as the PermissionEditor segmented control: root `inline-flex flex-wrap gap-1 rounded-inner border border-border bg-subtle p-1`; items `inline-flex h-pager items-center rounded-tab px-4 text-[13px] whitespace-nowrap text-muted transition-colors duration-150 ease-out hover:text-text data-[state=checked]:bg-surface data-[state=checked]:text-text data-[state=checked]:shadow-tab data-[state=checked]:text-[14px] data-[state=checked]:font-semibold`. Values `30d`, `90d`, `365d`, `never` in that order; **default `90d`** (D-08). Under it, a 13px muted `tabular-nums` preview line: `api_tokens.expires_on` with today plus N days as `YYYY-MM-DD` (local), or `api_tokens.expires_never_note` for never. The server computes the stored instant; the preview is informational.
3. **Scopes.** A `span` label (14/600) naming a `role="group"` container `overflow-hidden rounded-inner border` (`border-danger` when the scopes field has an error, else `border-border`), `data-scope-picker`. One row per scope from `GET …/account/tokens/scopes`, in server order (`*` first, then `admin:read`, then plugin scopes). The server already hides scopes that grant the user nothing (D-17); the SPA renders exactly what it receives.
- Row: `label.flex.min-h-[56px].cursor-pointer.items-start.gap-3.border-t.border-border.px-4.py-3.first:border-t-0`, `data-scope="<name>"`. Left: native checkbox `mt-0.5 size-5 shrink-0 rounded-[6px] accent-primary`. Right `flex min-w-0 flex-col`: line 1 `flex flex-wrap items-center gap-1` with the scope label (14/600), the mono name (13px `font-mono text-muted`), and the "Read-only" chip (`bg-subtle text-muted`, chip classes above) when the scope's methods are only GET/HEAD; line 2 the description (13px muted, `[overflow-wrap:anywhere]`).
- Rows are 12px gap (`gap-3`), the same exception 12.2 approved for picker option rows.
- `*` checked: every other row's checkbox is unchecked and `disabled` (row `cursor-not-allowed opacity-60`), and a note shows under the group: `flex items-center gap-2 text-[13px] text-muted`, `Info` 14px, `api_tokens.scope_all_includes`. Only `["*"]` is sent. Unchecking `*` re-enables the rows (previous picks are not restored).
- No scope pre-selected on open.
- Field errors render under each field exactly like `AccountView`: `flex flex-col gap-0.5 text-[13px] text-danger`, one `p.flex.items-center.gap-1.5` per message with `CircleAlert` 14px, linked by `aria-describedby`, the control gets `aria-invalid="true"`. The scopes error sits under the group.
- Footer `flex gap-2.5`: two equal-width buttons (`flex-1`), `Button variant="outline"` `api_tokens.create_cancel` "Discard token" (closes and discards input, `data-action="create-token-cancel"`) and `Button variant="primary" type="submit"` `api_tokens.create_submit`, `data-action="create-token-submit"`. Enter in the name input submits.
- Behaviour:
- On open, the scopes request runs; while it is pending the scopes group shows 3 skeleton rows (`min-h-[56px]` rows holding a 20px `rounded-[6px] bg-skel` square and a `h-3 w-40 rounded-[6px] bg-skel` bar) and the submit is disabled.
- Scopes load failure: the group is replaced by the alert block (`rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger`, `role="alert"`) with `api_tokens.scopes_failed`; submit stays disabled; "Discard token" closes.
- The SPA does no client-side validation beyond `maxlength`; empty name and no scope go to the server and come back as a 422, rendered as above, with focus on the first invalid control (name input, else the first scope checkbox, else the first segment).
- Busy: `fieldset` disabled, the primary label is `api_tokens.creating` with `LoaderCircle` 16px `animate-spin`, "Discard token" and the close button are disabled, Esc and overlay click are ignored. A second submit while busy is a no-op.
- Non-422 failure: `api_tokens.create_failed` danger toast; the dialog stays on step 1 with input kept.
- Closing step 1 any way (Esc, overlay, close button, "Discard token") discards input with no confirm.
- Focus: on open, the name input; on close, back to the card's "Create token" button.
### 5. Create dialog, step 2 (show once)
- Success swaps the dialog content in place (same `DialogContent`, same width); the list reloads in the background so the new row is present when the dialog closes.
- Header: `DialogTitle` `api_tokens.created_title`; the close button stays.
- Notice (the visual anchor of the step): `flex items-start gap-2 rounded-inner bg-sel px-4 py-3.5 text-text`, `role="note"`, `data-token-notice`: `TriangleAlert` 16px (`mt-0.5 shrink-0`, `aria-hidden`) and `api_tokens.created_notice` (14/400). This is also the `DialogDescription`.
- Plaintext: a `label` (`sr-only`) `api_tokens.token_label` and a full-width read-only `input` with `controlClass(false)` plus `h-input bg-subtle font-mono text-[14px]`, `readonly`, `autocomplete="off"`, `spellcheck="false"`, `data-token-plaintext`. Focus or click selects the whole value. The full token is visible, not masked: it is shown once and the user must be able to read and verify it.
- Copy row, 8px below: `flex flex-wrap items-center gap-2`: `Button variant="outline" size="sm" :icon="Copy"` `api_tokens.copy`, `data-action="copy-token"`, then a 13px status line.
- Copy uses `navigator.clipboard.writeText`. On success the button shows `Check` and `api_tokens.copied` for 2 s, a `sr-only` `aria-live="polite"` region announces `api_tokens.copied_announce`, and the dialog records that the token was copied.
- When the Clipboard API is missing or rejects: the input is focused with its value selected, and the status line shows `api_tokens.copy_failed` in 13px `text-danger` with `CircleAlert` 14px (`role="alert"`). A manual copy (Ctrl+C or the context menu) on the input also counts as copied (`copy` event on the input).
- Footer: one `Button variant="primary"` `api_tokens.done`, right-aligned (`flex justify-end`), `data-action="hide-token"`. It closes without a confirm.
- Closing rules:
- Overlay click never closes step 2 (`@pointer-down-outside` and `@interact-outside` prevented).
- Esc and the close button: when the token was copied, close at once; otherwise ask `api_tokens.close_uncopied_confirm` (ConfirmDialog, `danger`) and close only on confirm.
- Focus: on entering step 2, the "Copy token" button; on close, the card's "Create token" button.
- Plaintext handling (security, binding): the plaintext lives only in the dialog component's local state; it is set from the create response and cleared on close (and on unmount). It is never written to a toast, the console, `localStorage`/`sessionStorage`, the URL, the router state or the list rows. Reopening the dialog always starts at step 1.
### 6. Revoke
- The row's "Revoke token" button calls `confirm.ask({ message: t('api_tokens.revoke_confirm', { name }), confirmLabel: t('api_tokens.revoke_submit'), danger: true }, run)`, where `run` sends `DELETE …/account/tokens/{id}`.
- Success: the list reloads, `api_tokens.revoked` success toast. With "Show revoked tokens" off the row leaves the list; with it on the row turns into a revoked row. Focus moves to the card's "Create token" button.
- 404: `api_tokens.gone` danger toast, list reloads. Other failure: `api_tokens.revoke_failed` danger toast, the row stays as it was.
### 7. List states
- **Loading (first load and toggle reload):** the list container shows 3 skeleton rows with the row padding: a `h-3 w-40` bar above a `h-3 w-64` bar, both `rounded-[6px] bg-skel`, `aria-hidden`, and the container gets `aria-busy="true"`.
- **Load failure:** the alert block (`rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger`, `role="alert"`, `data-tokens-error`) with `CircleAlert` 16px and `api_tokens.load_failed` replaces the list; the toolbar toggle is hidden; the "Create token" button stays enabled (creating does not depend on the list).
- **Empty:** in place of the list, `flex flex-col items-center gap-2 rounded-inner border border-dashed border-border-strong px-6 py-8 text-center`, `data-tokens-empty`: a 40px `bg-subtle` circle with `KeyRound` 20px muted, `api_tokens.empty_title` (14/600), `api_tokens.empty_body` (13px muted, `max-w-[420px]`). The same block shows when "Show revoked tokens" is on and the user has no tokens at all.
### 8. Interactive-only and auth
- The SPA always calls the token endpoints with the admin JWT session, so the interactive-only refusal (D-06) never surfaces in the UI. A 401 goes through the existing client refresh-and-replay and, failing that, the existing sign-in redirect. No token-specific auth UI.
- The panel does not show the scope picker's hidden scopes, the owner's permissions, or any permission pattern: scope labels and descriptions are the only explanation of access.
---
## UI Considerations
Applicable state considerations resolved (ui-consideration-probe, run post-verification 2026-10-11: 7 elements — token list, show-revoked toggle, create form, expiry control, scope picker, show-once step, revoke action — 48 element×state pairs, kinds confirmed by the user). Every pair is covered, backstopped or dismissed with a reason; 0 unresolved. Rows group pairs that share a resolution. Copy for empty and error rows lives in the Copywriting Contract.
| Category | Element(s) | Status | Resolution / Reason |
|----------|------------|--------|---------------------|
| empty | Token list | ✅ covered | With no tokens, the dashed empty block shows `api_tokens.empty_title` and `api_tokens.empty_body`; the card's "Create token" button stays the single next step |
| loading | Token list | ✅ covered | First load and the show-revoked reload show 3 skeleton rows with `aria-busy="true"`; the "Create token" button is disabled only during the first load |
| error | Token list | ✅ covered | A failed load replaces the list with the `api_tokens.load_failed` alert block (`role="alert"`) and hides the toggle |
| populated | Token list | ✅ covered | Each row shows name, mono hint, scope chips, the meta line (created, last used or never used, expiry) and a "Revoke token" button; newest first |
| partial | Token list (mixed states) | ✅ covered | Active, expired and revoked rows coexist; expired rows carry the danger-soft "Expired" chip and keep "Revoke token"; revoked rows are muted with the "Revoked" chip and no "Revoke token" button |
| zero-one-many | Token list | ✅ covered | Zero: empty block; one or more: rows separated by `border-t`, no pagination, page scroll only |
| overflow | Token list | ✅ covered | Rows stack vertically and the page scrolls; scope chips wrap with `flex-wrap`; on `max-sm` the "Revoke token" button moves under the meta line |
| long-text | Token name, scope labels | ✅ covered | Names (up to 100 characters) wrap with `[overflow-wrap:anywhere]`, never truncate; scope descriptions wrap the same way; chips never truncate and wrap to new lines |
| partial | Scope chips (unknown scope) | ✅ covered | A stored scope name that no plugin registers any more renders as a dashed muted mono chip `api_tokens.unknown_scope` with the `unknown_scope_hint` title |
| empty | Show-revoked toggle on, no tokens | ✅ covered | Same empty block as above |
| loading | Show-revoked toggle | ✅ covered | Toggling reloads with the list skeleton; the checkbox stays interactive |
| loading | Scope picker | ✅ covered | 3 skeleton scope rows while `GET …/scopes` is pending; submit disabled |
| error | Scope picker | ✅ covered | Load failure shows the `api_tokens.scopes_failed` alert block; submit stays disabled; "Discard token" closes |
| empty | Scope picker | ✅ covered | Cannot be empty: `*` is always offered (D-17). If the server ever returns none, the scopes-failed alert block is shown |
| partial | Scope picker with `*` | ✅ covered | Checking `*` unchecks and disables every other row and shows `api_tokens.scope_all_includes`; only `["*"]` is sent |
| overflow / long-text | Scope picker (many plugin scopes) | ✅ covered | The dialog body scrolls inside `max-h-[calc(100vh-32px)]` while header and footer stay fixed |
| error | Create form (422) | ✅ covered | Server messages render under the name input, the expiry group or the scopes group with `aria-invalid`; focus moves to the first invalid control; the dialog stays on step 1 |
| error | Create form (other failure) | ✅ covered | `api_tokens.create_failed` danger toast; input kept |
| loading | Create submit | ✅ covered | Fieldset disabled, primary shows `api_tokens.creating` with a spinner, "Discard token"/close/Esc/overlay ignored, double submit is a no-op |
| populated | Show-once step | ✅ covered | `bg-sel` notice, full plaintext in a read-only mono input, Copy button with copied state, single "Done, hide token" primary |
| error | Clipboard refused or unavailable | ✅ covered | The input is focused and selected, and `api_tokens.copy_failed` shows in a `role="alert"` line; a manual copy event counts as copied |
| partial | Show-once step closed before copying | ✅ covered | Overlay click is ignored; Esc and the close button ask `api_tokens.close_uncopied_confirm` first; "Done, hide token" closes without a confirm |
| long-text | Plaintext token | ✅ covered | 44 characters of DM Mono 14px fit the full-width input at the 560px dialog width; on narrow screens the input scrolls horizontally and a select-all copy still takes the whole value |
| loading / error | Revoke | ✅ covered | The ConfirmDialog confirm button shows the busy spinner while the request runs; 404 shows `api_tokens.gone` and reloads; other failures show `api_tokens.revoke_failed` and keep the row |
| populated | Plaintext never retained | 🧪 backstop | An SPA unit test asserts that after "Done, hide token" (and after Esc-confirm) the plaintext is absent from the DOM and from the panel state, that reopening starts at step 1, and that no toast text contains the token |
| populated | Copy and live-region flow | 🧪 backstop | An SPA unit test with a stubbed `navigator.clipboard` asserts the copied state, the polite announcement, and that a rejected `writeText` selects the input and shows `api_tokens.copy_failed` |
| partial | Close-without-copy guard | 🧪 backstop | An SPA unit test asserts that Esc on step 2 before copying opens the danger confirm and keeps the dialog on cancel, and that it closes directly after a copy |
| zero-one-many | Scope chips per row | ✅ covered | One chip per stored scope (a token holds one or more, D-03); `*` shows the "Full access" label |
| loading / error / overflow | Expiry segmented control | ➖ dismissed | Four static options with no async data; labels are short and the group wraps (`flex-wrap`) on narrow screens |
| empty / loading / error | Page placement | ➖ dismissed | The card is independent of the profile form: profile load, save and 422 states are unchanged, and the footer Save never includes token data |
| long-text | Revoke confirm message | ✅ covered | `api_tokens.revoke_confirm` interpolates the token name (up to 100 characters, possibly with no spaces). `ConfirmDialog.vue`'s `AlertDialogDescription` currently has no wrap rule, so this phase adds `[overflow-wrap:anywhere]` to it (a one-class change to the shared component, harmless for existing messages) |
| long-text | Create form name input | ✅ covered | The single-line `h-input` scrolls horizontally like every other text input; `maxlength="100"` caps the value, and the server trims and enforces the same limit |
| empty / partial | Create form | ✅ covered | An unfilled form or a partial one (name without scope, scope without name) is submitted as-is; the server's 422 errors render under each field as above. Expiry is never empty (default `90d`) |
| empty / populated / partial / overflow / zero-one-many / long-text | Show-revoked toggle | ➖ dismissed | A single native checkbox with a short static label; its only states are checked/unchecked and the list reload it triggers (covered under Token list); a list load failure hides it |
| empty / partial / long-text | Expiry segmented control | ➖ dismissed | Always has a value (default `90d`), four fixed short labels; the preview line is one short date string |
| empty / loading / overflow | Show-once step | ➖ dismissed | The step exists only after a successful create response, so it is never empty or loading (create loading is under Create submit); its content is fixed-size (notice, one input, one button row) inside the scrolling dialog body |
| empty / populated / partial / overflow / zero-one-many | Revoke action | ➖ dismissed | A per-row button acting on exactly one token; its states are idle, confirming, busy, success and failure (covered under Revoke and the list rows) |
---
## 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` and Tailwind already installed at the versions above) | not applicable |
---
## Checker Sign-Off
- [x] Dimension 1 Copywriting: PASS
- [x] Dimension 2 Visuals: PASS
- [x] Dimension 3 Color: PASS
- [x] Dimension 4 Typography: FLAG (inherited 700 heading weight, locked user decision 2026-10-11)
- [x] Dimension 5 Spacing: FLAG (inherited Direction C values, locked user decision 2026-10-11)
- [x] Dimension 6 Registry Safety: PASS
- [x] Dimension 7 Inventory Provenance: PASS
**Approval:** approved 2026-10-11

View File

@@ -0,0 +1,94 @@
---
phase: "16.3"
slug: "backend-admin-personal-api-tokens-with-scopes"
# 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-11"
---
# Phase 16.3 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Framework** | Go `testing` (+ testcontainers-go Postgres for DB suites); vitest + @vue/test-utils + happy-dom for the admin SPA |
| **Config file** | `admin/vitest.config.ts` |
| **Quick run command** | `go test ./modules/cabana ./modules/bouncer ./modules/pact -run 'Token|Scope|Allows' -count=1` |
| **Full suite command** | `go vet ./... && go test ./... && npm --prefix admin test && (cd ../sm-journal-plugin && go vet ./... && go test ./...)` |
| **Estimated runtime** | ~180 seconds (Postgres suites need a Docker host) |
---
## Sampling Rate
- **After every task commit:** Run the quick run command plus `go vet ./...`
- **After every plan wave:** Run the full suite command (Postgres suites on a Docker host)
- **Before `/msd-verify-work`:** `scripts/check-phase16.3.sh` must be green (no skips, no zero-test stages)
- **Max feedback latency:** 180 seconds
---
## Per-Task Verification Map
Filled in by the planner from the RESEARCH.md test map and the four PLAN.md files. "Docker" commands need a reachable Docker daemon (testcontainers Postgres); without one they are recorded NOT RUN, never skipped. Plan 04 replaces each status with the real result.
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| 16.3-01-T1 | 01 | 1 | SC-1, SC-2 | T-16.3-01, T-16.3-02, T-16.3-04, T-16.3-07, T-16.3-11 | g15_ token runs as its owner; scope evaluated before the superuser shortcut; method refusal 403 token_scope; malformed tokens refused before the database | unit + Docker | `go test -count=1 -run '^(TestTokenTracerSmoke\|TestPhase09PermissionMatrix\|TestPhase09ContractInventory\|TestPhase10ConformanceInventory)$' ./modules/cabana`; Docker: `go test -count=1 -run '^TestTokenTracerPostgres$' ./modules/cabana` and `go test -count=1 -run '^(TestBackendAdminMetadataColumn\|TestPhase09MigrationsFreshRollback)$' ./modules/lagoon` | ❌ W0 | ⬜ pending |
| 16.3-01-T2 | 01 | 1 | SC-2 | T-16.3-05, T-16.3-06, T-16.3-09, T-16.3-13 | interactive-only routes refuse every token; /field-types and /markdown/preview gated; ResolveBackend Bearer-only with cover; stamp debounced and non-fatal | unit + Docker | `go test -count=1 -run '^(TestTokenTracerSmoke\|TestTokenGatesSmoke\|TestPhase09PermissionMatrix\|TestPhase09ContractInventory\|TestPhase10ConformanceInventory\|TestPhase10CSRF)$' ./modules/cabana`; Docker: `go test -count=1 -run '^(TestTokenTracerPostgres\|TestAdminAuthLifecycle\|TestAdminLogoutRevokesExpiredRefreshableToken)$' ./modules/cabana` | ❌ W0 | ⬜ pending |
| 16.3-01-T3 | 01 | 1 | SC-3 | T-16.3-03, T-16.3-08 | plaintext only in the 201 create body; own-only soft revoke; 422 on unknown, empty or non-overlapping scopes | unit + script + Docker | `go test -count=1 -run '^(TestPhase09PermissionMatrix\|TestPhase09ContractInventory\|TestPhase10ConformanceInventory\|TestTokenTracerSmoke\|TestTokenGatesSmoke)$' ./modules/cabana && bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck`; Docker: `go test -count=1 -run '^(TestAccountTokensSmoke\|TestPhase10OpenAPIConformance)$' ./modules/cabana` | ❌ W0 | ⬜ pending |
| 16.3-01-T4 | 01 | 1 | SC-3, SC-5 | T-16.3-03, T-16.3-08 | admin:token:create prints only the token; CLI applies the overlap rule; docs name no consuming application | unit + Docker | `go vet ./... && go test -count=1 ./modules/party ./modules/pact && go test -count=1 -run '^(TestDocsTree\|TestDelegateCommandsHelp\|TestDocsCommandsMirrorGeneratedMain)$' ./cmd/summer && go run ./cmd/summer docs:build --check && go test -short -count=1 -run '^(TestPluginActivates\|TestRoutesRegistered)$' ./docs/examples/blog`; Docker: `go test -count=1 -run '^(TestTokenCommandsSmoke\|TestAdminCreateCommand)$' ./modules/cabana` | ❌ W0 | ⬜ pending |
| 16.3-02-T1 | 02 | 2 | SC-3 | T-16.3-14, T-16.3-16 | plaintext shown once and cleared on Done; no storage, console or v-html in the account components | vitest + script | `npm --prefix admin run typecheck && npm --prefix admin test -- tests/account/ApiTokensPanel.test.ts tests/views/AccountView.test.ts && bash scripts/check-admin-openapi.sh --check && bash scripts/check-admin-dist.sh` | ❌ W0 | ⬜ pending |
| 16.3-02-T2 | 02 | 2 | SC-3 | T-16.3-16 | revoke through the shared confirm; names rendered as text | vitest + script | `npm --prefix admin run typecheck && npm --prefix admin test -- tests/account/ApiTokensPanel.test.ts tests/views/AccountView.test.ts tests/ui/ui.test.ts && bash scripts/check-admin-dist.sh` | ❌ W0 | ⬜ pending |
| 16.3-02-T3 | 02 | 2 | SC-3, SC-5 | T-16.3-14, T-16.3-15, T-16.3-17 | `*` sends only ["*"]; close-without-copy guard; clipboard fallback; server-filtered scopes only | vitest + docs | `npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-admin-dist.sh && go test -count=1 ./cmd/summer -run TestDocsTree` | ❌ W0 | ⬜ pending |
| 16.3-03-T1 | 03 | 3 | SC-4 | T-16.3-18, T-16.3-20, T-16.3-21 | Journal writes accept a journal-scoped token as the owner; resolver errors map to the PHP bodies | unit + Docker | `go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 -run '^TestResolveErrorMapping$' ./controllers/api/ && go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/...`; Docker: `go -C ../sm-journal-plugin test -count=1 -run '^TestJournalTokenTracer$' .` | ❌ W0 | ⬜ pending |
| 16.3-03-T2 | 03 | 3 | SC-2, SC-4, SC-5 | T-16.3-18, T-16.3-19, T-16.3-23 | journal:read and unrelated scopes refused on writes; drafts only as the owner may see; featured-image parity | docs + Docker | `go test -count=1 -run '^ExampleResolveBackend$\|^TestDocsTree$' ./modules/cabana ./cmd/summer && go run ./cmd/summer docs:build --check`; Docker: `go -C ../sm-journal-plugin test -count=1 -run '^(TestJournalTokenTracer\|TestJournalTokenExpansion\|TestJournalWriteUnauthenticated\|TestJournalFeaturedImageUnauthenticated)$' .` | ❌ W0 | ⬜ pending |
| 16.3-03-T3 | 03 | 3 | SC-4 | T-16.3-22 | human approves the tag with the one-way surfaces and NOT RUN suites listed | gate + checkpoint | release gate command in 16.3-03-PLAN.md Task 3 (`… && echo release-gate-ok`) | ✅ | ⬜ pending |
| 16.3-03-T4 | 03 | 3 | SC-4 | T-16.3-22 | tag, Journal require and host gitlink pushed only after approval | git checks | release-ok command in 16.3-03-PLAN.md Task 4 | ✅ | ⬜ pending |
| 16.3-04-T1 | 04 | 4 | SC-6, SC-2 | T-16.3-25 | scope rules, every route's token class, SPA plaintext backstop, Journal scope keys | unit + vitest + Docker | `go test -count=1 -run '^(TestScopeMatch\|TestAllowsScope\|TestTokenRouteGates)$' ./modules/cabana && npm --prefix admin test -- tests/account/ApiTokenCreateDialog.test.ts && go -C ../sm-journal-plugin test -short -count=1 -run '^TestJournalTokenScopes$' .`; Docker: `go test -count=1 -run '^TestBackendTokenPostgres$' ./modules/cabana` | ❌ W0 | ⬜ pending |
| 16.3-04-T2 | 04 | 4 | SC-6, SC-1..SC-4 | T-16.3-25, T-16.3-26 | full Go coverage incl. migration, API, CLI and Journal matrix | unit + Docker | the sixteen-name Docker-free cabana set in 16.3-04-PLAN.md Task 2; Docker: lagoon, cabana and Journal sets in the same task | ❌ W0 | ⬜ pending |
| 16.3-04-T3 | 04 | 4 | SC-6 | T-16.3-24 | gate fails closed on skips, zero tests and missing names | vitest + gate | `npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-phase16.3.sh --self-test && bash scripts/check-phase16.3.sh --all` | ❌ W0 | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
---
## Wave 0 Requirements
- [ ] `modules/cabana/token_smoke_test.go`, `token_postgres_smoke_test.go` — plan 01 smoke (tracer, gates, account API, commands)
- [ ] `modules/cabana/token_scopes_test.go`, `api_tokens_test.go`, `token_guard_test.go`, `token_routes_test.go` — Allows / scope match / compile / overlap / mint / expiry / validation / guard / resolver / route classification (Docker-free, plan 04)
- [ ] `modules/cabana/token_postgres_test.go`, `account_tokens_test.go`, `token_commands_test.go` — Postgres guard, account token API, token commands with stdout purity (plan 04)
- [ ] `modules/lagoon/backend_admin_migrations_test.go` — `TestBackendAPITokensMigration` and the updated last-ID test
- [ ] `admin/tests/account/ApiTokensPanel.test.ts`, `admin/tests/account/ApiTokenCreateDialog.test.ts` — UI-SPEC states and backstops (path chosen in plan 02; `tests/components/` does not exist)
- [ ] `../sm-journal-plugin/controllers/api/auth_test.go`, `journal_api_token_smoke_test.go`, `token_scopes_test.go`, `journal_api_tokens_test.go` — Journal mapping, scopes and token matrix
- [ ] `scripts/check-phase16.3.sh` — phase gate
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| Clipboard copy of the show-once token in a real browser | SC-3 | Clipboard API permissions differ from happy-dom | Create a token in the admin SPA, copy it, use it as `Authorization: Bearer` against a Journal write route |
| Postgres suites when an executor had no Docker daemon | SC-1..SC-4 | testcontainers needs Docker | On a Docker host: `bash scripts/check-phase16.3.sh postgres` (lagoon, cabana and Journal token suites by name) |
---
## 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

View File

@@ -0,0 +1 @@
No external API integration: this phase builds the framework's own admin token API and wires the in-repo Journal plugin to it; no third-party service, SDK or remote API is called.

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -4,6 +4,11 @@
- **Does PaymentGateway need `winter/location`?** It is installed in 19 projects. Check whether PaymentGateway shipping addresses, the shop or user profiles read its countries and states. If they do, Phase 18 needs a Go equivalent (a framework module or an `sm-location-plugin`). Phase: 18.
- **Does Horoskopia define the full Phase 18 acceptance contract?** Which `vue-horoskopia-app` requests reach PaymentGateway or subscription code? Do they cover the order and refund paths, or only subscriptions? Are other order flows tested only through hand-written host tests? Phase: 18.
- **Which chat source should Phase 19 port?** Wavepath's chat has `root_id` threading and `thread_summaries`. Compare it with the starter's `wn-chat-plugin` and the other 16 copies, and pick the newest as the 1:1 source. Phase: 19.
- ~~**Which chat source should Phase 19 port?**~~ **Settled 2026-10-11:** port the Wavepath copy, with `root_id` threading and `thread_summaries`. See `.planning/notes/wavepath-framework-asks-division.md`. Phase: 19.
- **Does any Płytarium route need userfriends?** PHP fonoteka has `golem15/userfriends`, but fonoteka.go has no port, and Phase 14 reached 175/175 routes without one. Confirm before porting it for Wavepath in Phase 19.1, so there is one shared copy. Phase: 19.1.
- **How should Herald's Web Push be done in Go?** Herald uses `minishlink/web-push`. Choose a Go Web Push/VAPID library (or a stdlib implementation) and record it in a decision note before Phase 19.1 code lands. Phase: 19.1.
## From the WavePath framework asks exploration (2026-10-11)
- **Should the full OIDC provider extend the hand-written `wristband` server, or put zitadel/oidc v3 behind it?** Phase 8 hand-wrote `wristband` on the standard library (`.planning/notes/oauth2-wristband-direct-port.md`). Phase 19.4 adds discovery, JWKS with RS256/ES256 rotation, `id_token`, userinfo, consent and pairwise subjects. That is protocol and security code where a maintained library is preferred, and zitadel/oidc v3 is already named in the stack. Settle it in a decision note before code lands. Phase: 19.4.
- **Do WavePath's user-plugin tokens carry custom scopes?** If they do, `sm-user-plugin` needs a `RegisterAPITokenScope` hook in place of the static `AllowedAPITokenScopes`. Ask the WavePath side. Phase: 19.3 or the WavePath milestone.

View File

@@ -3,7 +3,7 @@ id: SEED-261007-0tb
status: dormant
planted: 2026-10-07
planted_during: Phase 16 (context gathered)
trigger_when: The Horoskopia port (SEED-261007-j2b) is complete, and Phases 19 and 19.1 (chat and the chat satellites) are done.
trigger_when: Phase 20 (Płytarium cutover) is complete. Phases 19 to 19.6 already deliver the framework and shared-plugin work WavePath needs.
scope: Large
---
@@ -15,27 +15,30 @@ Wavepath (`/media/nvme/dev/jz/wavepath.org`) is the largest Golem15 app and the
## When to Surface
**Trigger:** after the Horoskopia port, with Phases 19 and 19.1 done. This stretch is about preparing the shared plugins, not migrating apps, and a stabilization phase after Phase 20 may come first.
**Trigger:** once Phase 20 is complete. Phases 19 to 19.6 come first and cover WavePath's framework asks (chat, chat polish, transport and infrastructure, OIDC, social login, 2FA and device flow). The milestone no longer waits for the Horoskopia port (changed 2026-10-11).
## Scope Estimate
**Large:** a milestone, probably larger than Płytarium:
- **App plugin:** `wavepath` has 30.6k PHP LOC and **255 routes**, compared with Płytarium's 175. Expect 2–3 API phases plus a cutover phase.
- **App plugin:** `wavepath` has 30.6k PHP LOC and **255 routes**, compared with Płytarium's 175. The whole backend surface the clients use is about **350 routes** (wavepath 255, user 46, journal 11, oauthserver 10, chatvideo 6, userfriends 5, feedback 3, and a few small ones), with about 90 tables and 164 migrations. PHP routes are not frozen, so re-count when the milestone opens. Expect 3–4 API phases plus a cutover phase.
- **Database:** WavePath moves from MariaDB to Postgres as part of the port, through `summer import:winter`. This replaces WavePath's planned Laravel 12 upgrade.
- **Clients:** `vue-wavepath-app`, `wavepath-mcp`, `tauri-wavepath-desktop`, `electron-wavepath` and `hubot-wavepath`. All of them define the parity contract.
- **Required shared plugins:**
- Ported: user.
- Phase 19: chat, chatchannels.
- Phase 19.1: chatbots, chatvideo, forum, herald, userfriends.
- Already covered by framework `lighthouse`: websockets.
- **OAuth:** `oauthserver` (5.5k LOC, 10 routes, OIDC) sits on the framework's `wristband` package. OIDC parts (id_token, userinfo) may be new to `wristband`.
- **OAuth:** `oauthserver` (5.5k LOC, 10 routes, OIDC) sits on the framework's `wristband` package. Phase 19.4 makes `wristband` a full OIDC provider.
- **Division of work:** WavePath's agent listed what it needs from SummerCMS and what it does itself. See `/media/nvme/dev/jz/wavepath.org/.planning/notes/summercms-framework-asks-for-wavepath.md` and `.planning/notes/wavepath-framework-asks-division.md`.
- **Also mounted:** feedback (ported), journal (ported), sitemap and faq (Phase 17), and paymentgateway/pgstripe. Wavepath's own code never references PaymentGateway, so check whether it can be dropped.
## Breadcrumbs
- `.planning/notes/core-plugin-port-inventory.md`
- `.planning/notes/oauth2-wristband-direct-port.md`
- `.planning/ROADMAP.md`: Phases 19 and 19.1
- `.planning/notes/wavepath-framework-asks-division.md`
- `.planning/ROADMAP.md`: Phases 19 to 19.6 and 20
- `.planning/research/questions.md`: chat source version, Herald Web Push dependency
## Notes

View File

@@ -133,6 +133,31 @@
"name": "Chat satellites",
"status": "pending"
},
{
"number": "19.2",
"name": "Chat polish against WavePath clients",
"status": "pending"
},
{
"number": "19.3",
"name": "Framework transport and infrastructure for WavePath",
"status": "pending"
},
{
"number": "19.4",
"name": "OIDC provider on wristband",
"status": "pending"
},
{
"number": "19.5",
"name": "Social login in sm-user-plugin",
"status": "pending"
},
{
"number": "19.6",
"name": "Two-factor auth and device flow in sm-user-plugin",
"status": "pending"
},
{
"number": "20",
"name": "Płytarium cutover",
@@ -140,9 +165,9 @@
}
],
"next": {
"command": "/msd:progress --next",
"label": "Advance to the next step (verify)",
"reason": "Phase 16.2 of 35 · ready to verify"
"command": "/msd-progress --next",
"label": "Advance to the next step",
"reason": "Phase 16.3 of 40 · executing"
},
"updated_at": "2026-10-10T14:21:45.523Z"
"updated_at": "2026-10-11T00:07:52.680Z"
}

View File

@@ -0,0 +1,6 @@
# Todos
- `pending/`: open todos. Each one has a matching g15office task, and its frontmatter carries the task ID as `g15office: <id>`.
- `done/`: closed todos, each ending with a `## Closed <date>` note.
**Keep todos synchronized with g15office** (project `summercms`). Creating, closing or promoting a todo updates its g15office task in the same session. The rules and the phase → task map are in [`../G15OFFICE.md`](../G15OFFICE.md).

View File

@@ -3,6 +3,9 @@ title: Backend admin personal API tokens (Phase 16.3)
date: 2026-09-28
priority: high
area: summercms.go admin auth
g15office: 5lcivllc
status: done
closed: 2026-10-11
---
Apparatus provides personal API tokens for backend admins (`PersonalApiToken` model, `TokenAuthenticate` + `ForceJsonResponse` middleware, token create/revoke on the backend user form). Nothing in Płytarium calls it, so it is deferred past v1. The Phase 9 `backend` guard already accepts `Authorization: Bearer` for CLI and tests.
@@ -10,3 +13,7 @@ Apparatus provides personal API tokens for backend admins (`PersonalApiToken` mo
Revisit when a Golem15 project needs scripted access to the admin API. See `.planning/notes/apparatus-dissolved-into-framework.md`.
**2026-10-10:** Promoted to Phase 16.3 (scoped tokens, Journal agent use case). No migration of Apparatus tokens.
## Closed 2026-10-11
Promoted to Phase 16.3 on 2026-10-10. The g15office task is now the Phase 16.3 milestone task.

View File

@@ -9,6 +9,8 @@ files:
- modules/cabana/extension.go
- modules/pact
- admin/src/components/form/registry.ts
status: done
closed: 2026-10-11
---
## Problem
@@ -30,3 +32,7 @@ Add a capability interface in `pact` (for example `HasFormFieldTypes`) through w
- Update the cabana README and `docs/`, the admin OpenAPI and the generated types, and commit `dist/`, all in the same change.
Decide in a framework phase (or a decimal phase before the first plugin that needs it). Phase 18's `money` field stays a core cabana type (18-CONTEXT); this todo covers the plugins that follow.
## Closed 2026-10-11
Done in Phase 16.2: `pact.FormFieldType` plus cabana's field-type registry (`modules/cabana/field_type.go`).

View File

@@ -6,6 +6,8 @@ severity: minor
files:
- modules/cabana/form_schema.go
- ../sm-journal-plugin/models/category/fields.yaml
status: done
closed: 2026-10-11
---
## Problem
@@ -15,3 +17,7 @@ D-21 asks for a rich content field on Journal categories. cabana has no WYSIWYG
## Solution
Add a `richeditor` field type (and an `mlricheditor` variant if a translatable rich field is needed) to the cabana form schema (`modules/cabana/form_schema.go`) and to the admin SPA. Keep the server-side gate on save: the Journal plugin's `classes.CheckHTML` runs in the Categories controller's `FormBeforeCreate` and `FormBeforeUpdate`, and the new field type must not bypass it. Then switch the Journal category `content` field in `models/category/fields.yaml` from `textarea` to `richeditor`.
## Closed 2026-10-11
Done in Phase 16.2: the Journal `blogeditor`/`mlblogeditor` plugin field type edits category and post HTML content.

View File

@@ -3,6 +3,8 @@ title: Keep imported HTML post bodies intact when a Journal post is re-saved in
date: 2026-10-09
priority: high
area: sm-journal-plugin
status: done
closed: 2026-10-11
---
Phase 16 RESEARCH Pitfall 2. The PHP journal export carries the pl post `content` as **HTML** (61 of 63 published posts on grzybyfunkcjonalne.pl). `journal:import-posts` stores it as is and the pl `content_html` is correct, so imported posts render fine. But an admin save of such a post runs `FormatHTML(content)`: goldmark without `html.WithUnsafe()` replaces raw HTML with `<!-- raw HTML omitted -->`, so the post body is wiped (probe in 16-RESEARCH.md: `pl HTML content -> out="<!-- raw HTML omitted -->\n"`).
@@ -16,3 +18,7 @@ Fix in `sm-journal-plugin`, one of:
- add a content format flag on the post so HTML-sourced posts are edited as HTML.
Whatever the fix, it must keep the XSS gate (`classes.CheckHTML`) on every write path and keep the PHP API contract of `content`/`content_html` unchanged. Add a test that re-saves an imported HTML post and asserts `content_html` is unchanged.
## Closed 2026-10-11
Done in Phase 16.2 (D-03): `FormatHTML` passes raw HTML through (`html.WithUnsafe()`), covered by `TestFormatHTMLKeepsRawHTML` and the posts admin smoke test.

View File

@@ -3,6 +3,8 @@ title: Nest framework packages under modules/ — same beach names
date: 2026-09-28
priority: high
area: summercms.go
status: done
closed: 2026-10-11
---
Mechanical layout change only. Do not rename packages.
@@ -18,3 +20,7 @@ Import paths become `git.golem15.com/golem15/summercms/modules/<name>`. Rewrite
Do this before Phase 11 adds more root packages. Do not remap onto Winter `system` / `backend` / `cms`.
After the move is green, run the sibling todo that writes a short `README.md` per module.
## Closed 2026-10-11
Done in Phase 10.2: every framework package lives under `modules/<name>/`.

View File

@@ -3,6 +3,8 @@ title: Short README.md per modules/* after the nest
date: 2026-09-28
priority: medium
area: summercms.go/modules
status: done
closed: 2026-10-11
---
Blocked on `.planning/todos/pending/nest-framework-packages-under-modules.md`.
@@ -12,3 +14,7 @@ After the packages live under `modules/`, spawn a GSD explore / researcher pass
Each file: one paragraph on what the package is for, who imports it, and one example entry point (`Package.Type` or file). No architecture essays. No copy-paste of planning docs.
The top-level `summercms.go/README.md` should then link the directory instead of explaining every beach name inline.
## Closed 2026-10-11
Done: all 24 `modules/*` packages ship a README (Phase 10.2, later the Role and mnemonic passes).

View File

@@ -3,6 +3,8 @@ title: Rewrite summercms.go README — onboard, recreate login, honest cutover
date: 2026-09-28
priority: high
area: summercms.go/README.md
status: done
closed: 2026-10-11
---
Replace `README.md`. It currently says pre-alpha / nothing runs. That is false.
@@ -16,3 +18,7 @@ The long story lives here, not in `fonoteka.go`. Include:
- After the `modules/` nest lands, point at `modules/<name>/README.md` instead of listing beach-named packages at the repo root.
Do not invent a production runbook. Operator procedure stays in the PHP `docs/deploy/plytarium.com.md` until Phase 15.
## Closed 2026-10-11
Done: the root README now covers key concepts, requirements, quick start and links to every module README.

View File

@@ -5,6 +5,7 @@ area: docs
severity: minor
files:
- docs/architecture/performance-and-scaling.md
g15office: gy0u2z5c
---
## Problem

View File

@@ -7,6 +7,7 @@ files:
- ../sm-translate-plugin/admin_settings.go
- modules/pact/capabilities.go (SettingsItem.Controller)
- ../examples/golem15-wintercms-starter/plugins/golem15/translate/Plugin.php (registerSettings: messages)
g15office: ewlzc7we
---
## Problem

View File

@@ -0,0 +1,31 @@
---
created: 2026-10-10T23:04:41.663Z
title: Prompt open admin and site tabs to reload when a new version is deployed
area: framework
severity: minor
files:
- modules/boardwalk/boardwalk.go:110-133
- admin/src/app/fieldTypes.ts
- admin/src/components/form/registry.ts
g15office: ltcvqext
---
## Problem
A browser tab opened before a deploy keeps running the old admin SPA bundle until the user reloads it by hand. On uat.grzybyfunkcjonalne.pl (2026-10-11) the post form showed "Unsupported field type: mlblogeditor" instead of the Blog Editor. The deployed binary (sha256 8a64d01…) and its embedded `modules/boardwalk/dist` were current. The old bundle ran against the new backend: it got a schema field type it didn't know and fell back to UnsupportedField. A reload fixed it.
`index.html` is already served with `Cache-Control: no-store` (boardwalk.go:132), and hashed assets are long-lived. So HTTP caching isn't the gap. The gap is a long-lived open tab that never learns a new version exists. The SPA refreshes its JWT silently, so a tab can stay open for days.
The same applies to:
- the Nuxt site apps (grzybyfunkcjonalne, journal layer), where Nitro and client chunks change per deploy;
- lazy chunks: after a deploy an old tab asking for a removed hashed chunk gets a 404 or a chunk-load error;
- plugin field-type scripts, which are already versioned with `?v=` hashes but load only once, after sign-in.
## Solution
TBD. Ideas:
- Expose a build version. Candidates: a response header on every admin API reply (e.g. `X-Summer-Build`, from the boardwalk dist hash plus the binary build info), or a small `/version` endpoint.
- The SPA records the version it booted with. It compares it on API responses and on `visibilitychange`/focus, or on an interval. On a mismatch it shows a non-blocking banner, "A new version is available — reload", and doesn't auto-reload, so unsaved form edits aren't lost. An auto-reload is acceptable only when no form is dirty.
- Catch dynamic-import/chunk-load failures (`vite:preloadError`) and reload once, guarded against loops by a sessionStorage flag.
- Nuxt apps: use the built-in `app:manifest:update` / `checkOutdatedBuildInterval` (Nuxt app manifest) and the same banner pattern.
- Unit-test the version comparison and the reload-once guard. Document the header or endpoint in the boardwalk README and docs.

View File

@@ -0,0 +1,32 @@
---
created: 2026-10-11T00:00:00Z
title: Uploads keep the client file extension, so a valid image can be served as HTML (stored XSS)
priority: high
area: lagoon/attach, fonoteka.go uploads
severity: major
g15office: bemfi2zs
files:
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collection_media_controller.go (storeUpload)
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_files.go (StorePublicFile)
- modules/lagoon/attach
---
## Problem
Found in the Phase 12 code review (WR-01 in `.planning/phases/12-p-ytarium-api-collections-and-albums/12-REVIEW.md`).
Public uploads keep the extension the client sent. The web server serves `storage/app/uploads/public` and picks the Content-Type from the extension; the blob's `ContentType` metadata does not reach it. A PNG with `<script>` in a `tEXt` chunk passes both upload checks (`image|mimes:...` sniffs the content, `IsAllowedImage` decodes it). Uploaded as `x.html`, it is stored as `<hex>.html`, its URL comes back in the 201 response and every listing, and a browser renders it as HTML.
WinterCMS's `File::getDiskName` behaves the same way (inherited, not introduced), but any household member or write-scoped token can exploit it. Still present as of 2026-10-11: `storeUpload` appends `filepath.Ext(upload.Filename)` to the disk name.
## Solution
- Derive the stored extension from the sniffed MIME type (`image/png` → `png`, and so on) in one shared helper, preferably in `lagoon/attach` so every plugin gets it. Keep the client name only in `file_name`.
- The URL changes only for uploads whose client extension disagrees with their content; the parity corpus does not record that case.
- Defence in depth: `X-Content-Type-Options: nosniff` and a file-type allow-list on the web server's uploads location, documented in the deploy docs.
- Shares lines with the CR-01 fix in the application project (odd extensions breaking every listing).
## Acceptance
- A PNG uploaded as `x.html` is stored with a `.png` disk name and its URL ends in `.png`.
- A regression test covers that upload.

View File

@@ -3,6 +3,7 @@ title: Reject duplicate console command names in bonfire.NewRoot
date: 2026-09-30
priority: low
area: summercms.go bonfire
g15office: u4rtpcl1
---
`bonfire.NewRoot` validates each command name's form (`namespace:verb`, or one of the bare framework names) but does not check for duplicates. The generated application `main` appends plugin commands after the framework's runtime commands, so a plugin that declares a command with the same name as a framework command, or as another plugin's command, builds and starts without an error, and which one runs is decided by Cobra.

View File

@@ -3,6 +3,7 @@ title: Port the FAQ plugin as sm-faq-plugin so FAQs become editable
date: 2026-10-07
priority: medium
area: sm-faq-plugin
g15office: ylgfanlk
---
Phase 16 ships the grzybyfunkcjonalne.pl FAQ (`g15_faq` slug `what-mushrooms-can-give-you`) as static i18n/markdown content in the Nuxt app. Later blogs need editors to manage FAQs, so port `Golem15.Faq` 1:1 as its own `sm-faq-plugin` repo (see `.planning/notes/core-plugins-own-repos.md`): models, admin screens and a public read API by slug.

View File

@@ -6,6 +6,7 @@ severity: minor
files:
- ../sm-journal-plugin/controllers/api/authors.go
- modules/lagoon/backend_admin_migrations.go
g15office: esrp4qtx
---
## Problem

View File

@@ -3,6 +3,7 @@ title: lagoon README registers an after-commit GORM callback that can sort after
date: 2026-09-30
priority: high
area: summercms.go lagoon
g15office: fqfoclxq
---
The `lagoon.OnDatabase` example in `modules/lagoon/README.md` registers its create callback with `After("gorm:after_create")` and calls `lagoon.AfterCommit` from it. GORM sorts that callback after `lagoon:after_commit` (itself `After("gorm:commit_or_rollback_transaction")`), so the work is buffered after the buffer was flushed and never runs, with no error or log. A docs test reproduced it: the same callback registered with `After("gorm:create").Before("gorm:commit_or_rollback_transaction")` runs as expected.

View File

@@ -3,6 +3,7 @@ title: Port MailBlocker and the block-mail checkbox with the mailing work
date: 2026-10-04
priority: medium
area: sm-user-plugin mail
g15office: 9cxuwfzy
---
The PHP user form has a "block all mail to this user" checkbox (`block_mail`, update context) backed by `Golem15\User\Models\MailBlocker`. Phase 12.1 left it out (12.1-CONTEXT D-21): it needs its own table and a check in every mail send path, which is a mail feature rather than an admin screen.

View File

@@ -3,6 +3,7 @@ title: Port the menu plugin as sm-menu-plugin so blog menus become editable
date: 2026-10-07
priority: medium
area: sm-menu-plugin
g15office: rfd6l4u4
---
Phase 16 hardcodes the grzybyfunkcjonalne.pl main menu as typed config in the Nuxt app, replacing the `g15_menu` component. Later blogs need editors to manage menus again, so port `Golem15.Menu` 1:1 as its own `sm-menu-plugin` repo (see `.planning/notes/core-plugins-own-repos.md`): models, admin screens and a public read API that the shared `vue-journal-layer` (or a sibling layer) can consume.

View File

@@ -3,6 +3,7 @@ title: Convert module README Go code blocks to compiled src= references
date: 2026-09-30
priority: medium
area: summercms.go/modules/*/README.md, internal/docsite
g15office: gplauwlb
---
Phase 11.1 (D-18) publishes every module README as an `api/<name>` docs page, but its Go code blocks (about 41 across 22 READMEs) are only identifier-checked, not compiled. Move each to an `Example*` or region in the module's `example_test.go` and reference it with `src=`, then drop the README exemption in `internal/docsite` `check_policy.go` so ROADMAP 11.1 SC4 can be widened back to "every Go example in the docs".

View File

@@ -3,6 +3,7 @@ title: Refresh fonoteka.go README — short status plus migrate/serve/login
date: 2026-09-28
priority: high
area: ../fonoteka.go/README.md
g15office: is8whvje
---
Keep this file a short run card. Do not duplicate the framework story.

View File

@@ -3,6 +3,7 @@ title: make:admin-controller output does not boot and names the model after the
date: 2026-09-30
priority: medium
area: summercms.go internal/build
g15office: evdzmic4
---
`summer make:admin-controller acme.blog Posts` writes a controller and YAML that compile but are not a working admin screen:

View File

@@ -3,6 +3,7 @@ title: Scaffolded files carry a DO NOT EDIT header, and make:command cannot reac
date: 2026-09-30
priority: medium
area: summercms.go internal/build
g15office: hqyet7qh
---
Two scaffolder conventions that the porting walkthrough had to work around:

View File

@@ -3,6 +3,7 @@ title: make:model and make:migration in the same second produce migrations that
date: 2026-09-30
priority: high
area: summercms.go internal/build
g15office: xp2bt2rl
---
`nextMigrationFile` in `internal/build/artifact.go` names a migration `<UTC second>_<slug>` and only moves to the next second when a file with the same full name already exists. Two migrations with different slugs created in the same second share one timestamp, and `refreshRegistry` sorts them by file name. Running `summer make:model acme.news Item` and `summer make:migration acme.news AddFlag` back to back (a probe in a copy of examples/hello, 2026-09-30) wrote `20260930213644_add_flag.go` and `20260930213644_create_acme_news_items.go`, and `registry.gen.go` listed `AddFlag` before `CreateItems`. gormigrate runs the set in slice order, so the ALTER TABLE migration would run before the CREATE TABLE one and fail.

View File

@@ -3,6 +3,7 @@ title: Port the sitemap plugin 1:1 as sm-sitemap-plugin for the next blog projec
date: 2026-10-03
priority: medium
area: sm-sitemap-plugin
g15office: krx9geua
---
Phase 14 dropped sitemap for Płytarium, because Nuxt builds its own sitemap with `@nuxtjs/seo` and fonoteka registers no menu item types. The next port after Płytarium is a blog project (grzybyfunkcjonalne.pl or golem15.com), and it needs `Golem15.Sitemap` 1:1.

View File

@@ -3,6 +3,7 @@ title: Neutral default resource URL in wristband.DefaultOptions
date: 2026-09-30
priority: medium
area: summercms.go wristband
g15office: iarcg5dk
---
`wristband.DefaultOptions()` ships a `Resource` default (the RFC 8707 resource indicator) whose URL names a consuming application, and the comments around it name that application too: the `Resource` field comment and the default in `modules/wristband/server.go`, the package comment at the top of `server.go`, and comments in `stores.go` and `client_issue.go`.

View File

@@ -89,32 +89,32 @@ The `migrate`, `migrate:status`, `migrate:rollback`, `serve` and admin commands
## Modules
| Module | Purpose |
|--------|---------|
| [backpack](modules/backpack/README.md) | Per-instance application container that holds the configuration, a typed service registry, the event bus and the set of activated plugins. |
| [beachcomber](modules/beachcomber/README.md) | Search index sync for GORM models: after-commit upserts and deletes through a pluggable engine, gated by an application kill-switch. |
| [boardwalk](modules/boardwalk/README.md) | HTTP handler that serves the embedded admin SPA build under a configurable path prefix. |
| [bonfire](modules/bonfire/README.md) | Declarative console commands for the `summer` tool and application binaries, adapted to Cobra with typed input, prompts and styled output. |
| [bouncer](modules/bouncer/README.md) | Authentication for SummerCMS: HS256 JWT minting, verification and refresh, request guards, a revoked-token blacklist and bcrypt password helpers. |
| [cabana](modules/cabana/README.md) | Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filter and relation definitions at boot and serves them as a JSON admin API next to the embedded admin SPA. |
| [compass](modules/compass/README.md) | Layered YAML configuration with per-environment directories, `SUMMER_` environment overrides, embedded plugin defaults and dot-path access. |
| [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. |
| [festival](modules/festival/README.md) | Typed, synchronous event bus with listener priorities, payload collection and stop-when-handled dispatch. |
| [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. |
| [flare](modules/flare/README.md) | Web Push delivery with VAPID (RFC 8292) and aes128gcm payload encryption (RFC 8291) behind a small Pusher interface. |
| [lagoon](modules/lagoon/README.md) | Postgres data layer: the shared GORM connection, per-plugin migrations, model helpers and file attachments. |
| [lighthouse](modules/lighthouse/README.md) | Transport-neutral realtime: a publisher interface with pluggable drivers, subscribe-time channel authorization, and model broadcasts enqueued in the write transaction. |
| [pact](modules/pact/README.md) | Capability interfaces that compiled plugins implement to contribute routes, config, migrations, middleware, commands, admin screens, translations, mail templates and jobs. |
| [party](modules/party/README.md) | Compiled plugin registry that orders plugins by their dependencies and runs their Register and Boot lifecycle. |
| [phrasebook](modules/phrasebook/README.md) | Namespaced translation catalogs loaded from plugin YAML, with locale fallback, placeholder interpolation and CLDR pluralization. |
| [postcard](modules/postcard/README.md) | Transactional mail from plugin-owned Markdown templates and layouts, delivered through a memory, log or SMTP driver. |
| [sunrise](modules/sunrise/README.md) | Bootstrap script that installs the summer CLI and creates a new SummerCMS application, or updates an existing one, in one command, embedded so it is versioned with the framework. |
| [sunscreen](modules/sunscreen/README.md) | Credential-redacting slog handler that keeps API keys, tokens and passwords out of application logs. |
| [surf](modules/surf/README.md) | HTTP routing for SummerCMS: collects plugin routes and named middleware into a `net/http` ServeMux with constraints, rate limiting, body limits, CORS and panic recovery, and provides the `serve` and `route:list` commands. |
| [tide](modules/tide/README.md) | HTTP parity toolkit that records request and response fixtures from a reference backend, replays them against a new one and reports normalized differences. |
| [towel](modules/towel/README.md) | Request-scoped actor, organization, collection and locale values carried through `context.Context`. |
| [wire](modules/wire/README.md) | JSON response helpers and value types that keep API bodies byte-compatible with a PHP (WinterCMS/Laravel) backend. |
| [wristband](modules/wristband/README.md) | An OAuth authorization server for MCP clients: RFC 8414 metadata, RFC 7591 dynamic client registration, the authorization-code flow with PKCE, consent operations and refresh-token rotation over application-supplied storage. |
| Module | Purpose | Name mnemonic |
|--------|---------|---------------|
| [backpack](modules/backpack/README.md) | Per-instance application container that holds the configuration, a typed service registry, the event bus and the set of activated plugins. | The one bag you pack for the beach; it carries everything the application needs: configuration, services, events and plugins. |
| [beachcomber](modules/beachcomber/README.md) | Search index sync for GORM models: after-commit upserts and deletes through a pluggable engine, gated by an application kill-switch. | A beachcomber searches the shore for finds; this module keeps the search index in step so content can be found. |
| [boardwalk](modules/boardwalk/README.md) | HTTP handler that serves the embedded admin SPA build under a configurable path prefix. | The walkway along the beach front; it is the path admins take into the admin SPA. |
| [bonfire](modules/bonfire/README.md) | Declarative console commands for the `summer` tool and application binaries, adapted to Cobra with typed input, prompts and styled output. | The evening fire where everyone gathers; the command line is where you sit down and fire off commands. |
| [bouncer](modules/bouncer/README.md) | Authentication for SummerCMS: HS256 JWT minting, verification and refresh, request guards, a revoked-token blacklist and bcrypt password helpers. | Checks IDs at the beach club door; it authenticates requests and turns away the ones without a valid token. |
| [cabana](modules/cabana/README.md) | Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filter and relation definitions at boot and serves them as a JSON admin API next to the embedded admin SPA. | The beach hut where the staff run the beach; it is the admin backend. |
| [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. |
| [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. |
| [lagoon](modules/lagoon/README.md) | Postgres data layer: the shared GORM connection, per-plugin migrations, model helpers and file attachments. | Calm, deep water where everything settles; it is the database layer where data is stored. |
| [lighthouse](modules/lighthouse/README.md) | Transport-neutral realtime: a publisher interface with pluggable drivers, subscribe-time channel authorization, and model broadcasts enqueued in the write transaction. | Beams a signal out to everyone watching; it broadcasts realtime messages to subscribers. |
| [pact](modules/pact/README.md) | Capability interfaces that compiled plugins implement to contribute routes, config, migrations, middleware, commands, admin screens, translations, mail templates and jobs. | An agreement both sides keep; it holds the interfaces a plugin agrees to implement. |
| [party](modules/party/README.md) | Compiled plugin registry that orders plugins by their dependencies and runs their Register and Boot lifecycle. | The guest list and the order people arrive in; it registers plugins and boots them in dependency order. |
| [phrasebook](modules/phrasebook/README.md) | Namespaced translation catalogs loaded from plugin YAML, with locale fallback, placeholder interpolation and CLDR pluralization. | What you pack to speak the local language on holiday; it holds the translations. |
| [postcard](modules/postcard/README.md) | Transactional mail from plugin-owned Markdown templates and layouts, delivered through a memory, log or SMTP driver. | What you send home in summer; it delivers the application's mail. |
| [sunrise](modules/sunrise/README.md) | Bootstrap script that installs the summer CLI and creates a new SummerCMS application, or updates an existing one, in one command, embedded so it is versioned with the framework. | The start of a summer day; it starts a new application, or updates one, in one command. |
| [sunscreen](modules/sunscreen/README.md) | Credential-redacting slog handler that keeps API keys, tokens and passwords out of application logs. | Protects you from exposure; it keeps secrets out of the logs. |
| [surf](modules/surf/README.md) | HTTP routing for SummerCMS: collects plugin routes and named middleware into a `net/http` ServeMux with constraints, rate limiting, body limits, CORS and panic recovery, and provides the `serve` and `route:list` commands. | Catching the incoming waves; it routes incoming HTTP requests to their handlers. |
| [tide](modules/tide/README.md) | HTTP parity toolkit that records request and response fixtures from a reference backend, replays them against a new one and reports normalized differences. | The tide goes out and comes back, and you compare the waterline; it records traffic from one backend, replays it against another and compares. |
| [towel](modules/towel/README.md) | Request-scoped actor, organization, collection and locale values carried through `context.Context`. | You carry it everywhere and it marks your spot; it carries the actor, organization and locale through each request. |
| [wire](modules/wire/README.md) | JSON response helpers and value types that keep API bodies byte-compatible with a PHP (WinterCMS/Laravel) backend. | What goes over the wire; it keeps response bodies byte-for-byte the same as the PHP backend's. |
| [wristband](modules/wristband/README.md) | An OAuth authorization server for MCP clients: RFC 8414 metadata, RFC 7591 dynamic client registration, the authorization-code flow with PKCE, consent operations and refresh-token rotation over application-supplied storage. | The festival wristband that lets you in for the event; it issues OAuth access for MCP clients. |
## Using SummerCMS in an application

View File

@@ -1,6 +1,151 @@
{
"components": {
"schemas": {
"cabana.AdminAPIToken": {
"properties": {
"created_at": {
"type": "string"
},
"expired": {
"type": "boolean"
},
"expires_at": {
"type": "string"
},
"hint": {
"type": "string"
},
"id": {
"type": "integer"
},
"last_used_at": {
"type": "string"
},
"name": {
"type": "string"
},
"revoked_at": {
"type": "string"
},
"scopes": {
"items": {
"$ref": "#/components/schemas/cabana.AdminAPITokenScope"
},
"type": "array"
}
},
"required": [
"created_at",
"expired",
"expires_at",
"hint",
"id",
"last_used_at",
"name",
"revoked_at",
"scopes"
],
"type": "object"
},
"cabana.AdminAPITokenCreated": {
"properties": {
"created_at": {
"type": "string"
},
"expired": {
"type": "boolean"
},
"expires_at": {
"type": "string"
},
"hint": {
"type": "string"
},
"id": {
"type": "integer"
},
"last_used_at": {
"type": "string"
},
"name": {
"type": "string"
},
"revoked_at": {
"type": "string"
},
"scopes": {
"items": {
"$ref": "#/components/schemas/cabana.AdminAPITokenScope"
},
"type": "array"
},
"token": {
"type": "string"
}
},
"required": [
"created_at",
"expired",
"expires_at",
"hint",
"id",
"last_used_at",
"name",
"revoked_at",
"scopes",
"token"
],
"type": "object"
},
"cabana.AdminAPITokenRequest": {
"properties": {
"expires": {
"type": "string"
},
"name": {
"type": "string"
},
"scopes": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"expires",
"name",
"scopes"
],
"type": "object"
},
"cabana.AdminAPITokenScope": {
"properties": {
"description": {
"type": "string"
},
"known": {
"type": "boolean"
},
"label": {
"type": "string"
},
"name": {
"type": "string"
},
"read_only": {
"type": "boolean"
}
},
"required": [
"description",
"known",
"label",
"name",
"read_only"
],
"type": "object"
},
"cabana.AdminAccountRequest": {
"properties": {
"current_password": {
@@ -322,6 +467,42 @@
],
"type": "object"
},
"cabana.Envelope-array_cabana_AdminAPIToken": {
"properties": {
"data": {
"items": {
"$ref": "#/components/schemas/cabana.AdminAPIToken"
},
"type": "array"
},
"meta": {
"$ref": "#/components/schemas/cabana.SuccessMeta"
}
},
"required": [
"data",
"meta"
],
"type": "object"
},
"cabana.Envelope-array_cabana_AdminAPITokenScope": {
"properties": {
"data": {
"items": {
"$ref": "#/components/schemas/cabana.AdminAPITokenScope"
},
"type": "array"
},
"meta": {
"$ref": "#/components/schemas/cabana.SuccessMeta"
}
},
"required": [
"data",
"meta"
],
"type": "object"
},
"cabana.Envelope-array_cabana_FileItem": {
"properties": {
"data": {
@@ -394,6 +575,36 @@
],
"type": "object"
},
"cabana.Envelope-cabana_AdminAPIToken": {
"properties": {
"data": {
"$ref": "#/components/schemas/cabana.AdminAPIToken"
},
"meta": {
"$ref": "#/components/schemas/cabana.SuccessMeta"
}
},
"required": [
"data",
"meta"
],
"type": "object"
},
"cabana.Envelope-cabana_AdminAPITokenCreated": {
"properties": {
"data": {
"$ref": "#/components/schemas/cabana.AdminAPITokenCreated"
},
"meta": {
"$ref": "#/components/schemas/cabana.SuccessMeta"
}
},
"required": [
"data",
"meta"
],
"type": "object"
},
"cabana.Envelope-cabana_AdminActionResult": {
"properties": {
"data": {
@@ -2176,7 +2387,7 @@
},
"securitySchemes": {
"BackendBearer": {
"description": "Backend admin bearer token. Send \"Bearer {access_token}\".",
"description": "Backend admin access token or personal API token (g15_…). Send \"Bearer {token}\". Account and token management routes refuse personal API tokens.",
"in": "header",
"name": "Authorization",
"type": "apiKey"
@@ -2193,7 +2404,7 @@
"paths": {
"/account": {
"put": {
"description": "Saves the signed-in admin's own first name, last name, email and subtitle; the row is the token subject, never a request value. Only the AdminAccountRequest keys are accepted: any other key (login, role_id, is_superuser, permissions, is_activated, id) is a 422 on that key and nothing is written. The email must be valid, 6 to 255 characters, and not equal (ignoring case) the email or login of another admin. A non-empty password needs current_password, at least 4 characters, at most 72 bytes and an equal password_confirmation; it ends every other session, and a cookie session receives a fresh cookie.",
"description": "Saves the signed-in admin's own first name, last name, email and subtitle; the row is the token subject, never a request value. Only the AdminAccountRequest keys are accepted: any other key (login, role_id, is_superuser, permissions, is_activated, id) is a 422 on that key and nothing is written. The email must be valid, 6 to 255 characters, and not equal (ignoring case) the email or login of another admin. A non-empty password needs current_password, at least 4 characters, at most 72 bytes and an equal password_confirmation; it ends every other session, and a cookie session receives a fresh cookie. Personal API tokens are refused (403 interactive_only).",
"requestBody": {
"content": {
"application/json": {
@@ -2398,6 +2609,240 @@
]
}
},
"/account/tokens": {
"get": {
"description": "Lists the signed-in admin's personal API tokens, newest first. Revoked tokens are hidden unless revoked=1. Personal API tokens are refused (403 interactive_only).",
"parameters": [
{
"description": "Include revoked tokens when 1 or true",
"in": "query",
"name": "revoked",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.Envelope-array_cabana_AdminAPIToken"
}
}
},
"description": "OK"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Forbidden"
}
},
"security": [
{
"BackendBearer": []
}
],
"summary": "List my API tokens",
"tags": [
"admin"
]
},
"post": {
"description": "Creates a personal API token for the signed-in admin. The plaintext token is returned exactly once in the 201 body. Personal API tokens are refused (403 interactive_only).",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.AdminAPITokenRequest"
}
}
},
"description": "Name, expiry and scopes",
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.Envelope-cabana_AdminAPITokenCreated"
}
}
},
"description": "Created"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Forbidden"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unprocessable Entity"
}
},
"security": [
{
"BackendBearer": []
}
],
"summary": "Create an API token",
"tags": [
"admin"
]
}
},
"/account/tokens/scopes": {
"get": {
"description": "Lists framework and plugin scopes the signed-in admin may pick when creating a token. Personal API tokens are refused (403 interactive_only).",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.Envelope-array_cabana_AdminAPITokenScope"
}
}
},
"description": "OK"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Forbidden"
}
},
"security": [
{
"BackendBearer": []
}
],
"summary": "List available API token scopes",
"tags": [
"admin"
]
}
},
"/account/tokens/{id}": {
"delete": {
"description": "Soft-revokes one of the signed-in admin's active personal API tokens. Personal API tokens are refused (403 interactive_only).",
"parameters": [
{
"description": "Token id",
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.Envelope-cabana_AdminAPIToken"
}
}
},
"description": "OK"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Forbidden"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Not Found"
}
},
"security": [
{
"BackendBearer": []
}
],
"summary": "Revoke an API token",
"tags": [
"admin"
]
}
},
"/auth/login": {
"post": {
"parameters": [

View File

@@ -14,7 +14,7 @@ export interface paths {
get?: never;
/**
* Update my account
* @description Saves the signed-in admin's own first name, last name, email and subtitle; the row is the token subject, never a request value. Only the AdminAccountRequest keys are accepted: any other key (login, role_id, is_superuser, permissions, is_activated, id) is a 422 on that key and nothing is written. The email must be valid, 6 to 255 characters, and not equal (ignoring case) the email or login of another admin. A non-empty password needs current_password, at least 4 characters, at most 72 bytes and an equal password_confirmation; it ends every other session, and a cookie session receives a fresh cookie.
* @description Saves the signed-in admin's own first name, last name, email and subtitle; the row is the token subject, never a request value. Only the AdminAccountRequest keys are accepted: any other key (login, role_id, is_superuser, permissions, is_activated, id) is a 422 on that key and nothing is written. The email must be valid, 6 to 255 characters, and not equal (ignoring case) the email or login of another admin. A non-empty password needs current_password, at least 4 characters, at most 72 bytes and an equal password_confirmation; it ends every other session, and a cookie session receives a fresh cookie. Personal API tokens are refused (403 interactive_only).
*/
put: {
parameters: {
@@ -210,6 +210,247 @@ export interface paths {
patch?: never;
trace?: never;
};
"/account/tokens": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List my API tokens
* @description Lists the signed-in admin's personal API tokens, newest first. Revoked tokens are hidden unless revoked=1. Personal API tokens are refused (403 interactive_only).
*/
get: {
parameters: {
query?: {
/** @description Include revoked tokens when 1 or true */
revoked?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.Envelope-array_cabana_AdminAPIToken"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
put?: never;
/**
* Create an API token
* @description Creates a personal API token for the signed-in admin. The plaintext token is returned exactly once in the 201 body. Personal API tokens are refused (403 interactive_only).
*/
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** @description Name, expiry and scopes */
requestBody: {
content: {
"application/json": components["schemas"]["cabana.AdminAPITokenRequest"];
};
};
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.Envelope-cabana_AdminAPITokenCreated"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/account/tokens/scopes": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List available API token scopes
* @description Lists framework and plugin scopes the signed-in admin may pick when creating a token. Personal API tokens are refused (403 interactive_only).
*/
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.Envelope-array_cabana_AdminAPITokenScope"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/account/tokens/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/**
* Revoke an API token
* @description Soft-revokes one of the signed-in admin's active personal API tokens. Personal API tokens are refused (403 interactive_only).
*/
delete: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Token id */
id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.Envelope-cabana_AdminAPIToken"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/auth/login": {
parameters: {
query?: never;
@@ -4669,6 +4910,41 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
schemas: {
"cabana.AdminAPIToken": {
created_at: string;
expired: boolean;
expires_at: string;
hint: string;
id: number;
last_used_at: string;
name: string;
revoked_at: string;
scopes: components["schemas"]["cabana.AdminAPITokenScope"][];
};
"cabana.AdminAPITokenCreated": {
created_at: string;
expired: boolean;
expires_at: string;
hint: string;
id: number;
last_used_at: string;
name: string;
revoked_at: string;
scopes: components["schemas"]["cabana.AdminAPITokenScope"][];
token: string;
};
"cabana.AdminAPITokenRequest": {
expires: string;
name: string;
scopes: string[];
};
"cabana.AdminAPITokenScope": {
description: string;
known: boolean;
label: string;
name: string;
read_only: boolean;
};
"cabana.AdminAccountRequest": {
current_password?: string;
email: string;
@@ -4774,6 +5050,14 @@ export interface components {
scripts: string[];
styles: string[];
};
"cabana.Envelope-array_cabana_AdminAPIToken": {
data: components["schemas"]["cabana.AdminAPIToken"][];
meta: components["schemas"]["cabana.SuccessMeta"];
};
"cabana.Envelope-array_cabana_AdminAPITokenScope": {
data: components["schemas"]["cabana.AdminAPITokenScope"][];
meta: components["schemas"]["cabana.SuccessMeta"];
};
"cabana.Envelope-array_cabana_FileItem": {
data: components["schemas"]["cabana.FileItem"][];
meta: components["schemas"]["cabana.SuccessMeta"];
@@ -4790,6 +5074,14 @@ export interface components {
data: components["schemas"]["cabana.SettingsEntry"][];
meta: components["schemas"]["cabana.SuccessMeta"];
};
"cabana.Envelope-cabana_AdminAPIToken": {
data: components["schemas"]["cabana.AdminAPIToken"];
meta: components["schemas"]["cabana.SuccessMeta"];
};
"cabana.Envelope-cabana_AdminAPITokenCreated": {
data: components["schemas"]["cabana.AdminAPITokenCreated"];
meta: components["schemas"]["cabana.SuccessMeta"];
};
"cabana.Envelope-cabana_AdminActionResult": {
data: components["schemas"]["cabana.AdminActionResult"];
meta: components["schemas"]["cabana.SuccessMeta"];

View File

@@ -15,6 +15,14 @@ export type AdminProfile = Schemas['cabana.AdminProfile']
export type AdminAvatar = Schemas['cabana.AdminAvatar']
/** Body of PUT /account: the only keys My account may change. */
export type AdminAccountRequest = Schemas['cabana.AdminAccountRequest']
/** One personal API token row on My account (list and revoke). */
export type AdminAPIToken = Schemas['cabana.AdminAPIToken']
/** Create response: the list row plus the plaintext shown once. */
export type AdminAPITokenCreated = Schemas['cabana.AdminAPITokenCreated']
/** Body of POST /account/tokens. */
export type AdminAPITokenRequest = Schemas['cabana.AdminAPITokenRequest']
/** One scope offered by GET /account/tokens/scopes or stored on a token. */
export type AdminAPITokenScope = Schemas['cabana.AdminAPITokenScope']
export type NavigationEntry = Schemas['cabana.NavigationEntry']
export type SettingsEntry = Schemas['cabana.SettingsEntry']
export type SettingsResult = Schemas['cabana.SettingsResult']

View File

@@ -0,0 +1,614 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import {
DialogContent,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
RadioGroupItem,
RadioGroupRoot,
} from 'reka-ui'
import { Check, CircleAlert, Copy, Info, LoaderCircle, TriangleAlert, X } from '@lucide/vue'
import { api } from '../../api/client'
import type { AdminAPITokenScope } from '../../api/types'
import { t } from '../../app/i18n'
import { controlClass } from '../form/control'
import { fieldErrors, focusField } from '../form/formState'
import Button from '../ui/Button.vue'
import { useConfirm } from '../ui/confirm'
import { showToast } from '../../state/useToasts'
// Two-step create dialog (16.3 UI-SPEC §4-5): form, then show-once plaintext.
// The plaintext lives only in a local ref and is cleared on close and unmount.
const props = defineProps<{
open: boolean
confirm: ReturnType<typeof useConfirm>
}>()
const emit = defineEmits<{
'update:open': [open: boolean]
created: []
closed: []
}>()
const ID_PREFIX = 'api-token'
const EXPIRY_OPTIONS = [
{ value: '30d', label: 'backend::lang.api_tokens.expires_30' },
{ value: '90d', label: 'backend::lang.api_tokens.expires_90' },
{ value: '365d', label: 'backend::lang.api_tokens.expires_365' },
{ value: 'never', label: 'backend::lang.api_tokens.expires_never' },
] as const
const step = ref<1 | 2>(1)
const name = ref('')
const expires = ref('90d')
const checked = ref<Set<string>>(new Set())
const scopes = ref<AdminAPITokenScope[]>([])
const scopesLoading = ref(false)
const scopesFailed = ref(false)
const busy = ref(false)
const errors = ref<Record<string, string[]>>({})
const plaintext = ref('')
const copied = ref(false)
const copyFailed = ref(false)
const copyFlash = ref(false)
const announce = ref('')
let copyTimer: ReturnType<typeof setTimeout> | null = null
const nameInput = ref<HTMLInputElement | null>(null)
const plaintextInput = ref<HTMLInputElement | null>(null)
const copyButton = ref<HTMLElement | null>(null)
const starChecked = computed(() => checked.value.has('*'))
const canSubmit = computed(() => !scopesLoading.value && !scopesFailed.value && scopes.value.length > 0 && !busy.value)
function pad(value: number): string {
return String(value).padStart(2, '0')
}
function localDatePlus(days: number): string {
const date = new Date()
date.setDate(date.getDate() + days)
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
const expiryPreview = computed(() => {
if (expires.value === 'never') {
return t('backend::lang.api_tokens.expires_never_note')
}
const days = expires.value === '30d' ? 30 : expires.value === '365d' ? 365 : 90
return t('backend::lang.api_tokens.expires_on', { date: localDatePlus(days) })
})
function clearPlaintext(): void {
plaintext.value = ''
copied.value = false
copyFailed.value = false
copyFlash.value = false
announce.value = ''
if (copyTimer !== null) {
clearTimeout(copyTimer)
copyTimer = null
}
}
function resetForm(): void {
step.value = 1
name.value = ''
expires.value = '90d'
checked.value = new Set()
errors.value = {}
busy.value = false
scopesFailed.value = false
clearPlaintext()
}
function setOpen(open: boolean): void {
emit('update:open', open)
}
async function loadScopes(): Promise<void> {
scopesLoading.value = true
scopesFailed.value = false
scopes.value = []
try {
const result = await api.GET('/account/tokens/scopes')
if (result.data) {
const list = result.data.data ?? []
scopes.value = list
scopesFailed.value = list.length === 0
return
}
scopesFailed.value = true
} catch {
scopesFailed.value = true
} finally {
scopesLoading.value = false
}
}
watch(
() => props.open,
(open) => {
if (open) {
resetForm()
void loadScopes()
} else {
clearPlaintext()
step.value = 1
}
},
)
function toggleScope(scopeName: string, on: boolean): void {
const next = new Set(checked.value)
if (scopeName === '*') {
if (on) {
next.clear()
next.add('*')
} else {
next.delete('*')
}
} else if (!starChecked.value) {
if (on) {
next.add(scopeName)
} else {
next.delete(scopeName)
}
}
checked.value = next
if (errors.value.scopes) {
const nextErrors = { ...errors.value }
delete nextErrors.scopes
errors.value = nextErrors
}
}
function scopeDisabled(scopeName: string): boolean {
return starChecked.value && scopeName !== '*'
}
function selectedScopes(): string[] {
if (starChecked.value) {
return ['*']
}
return scopes.value.map((scope) => scope.name).filter((scopeName) => checked.value.has(scopeName))
}
function nameDescribedBy(): string | undefined {
const ids = [
`${ID_PREFIX}-name-comment`,
errors.value.name ? `${ID_PREFIX}-name-error` : '',
].filter(Boolean)
return ids.length ? ids.join(' ') : undefined
}
async function focusFirstInvalid(): Promise<void> {
await nextTick()
if (errors.value.name) {
focusField(ID_PREFIX, 'name')
return
}
if (errors.value.scopes) {
const first = scopes.value.find((scope) => !scopeDisabled(scope.name))
const el = first
? document.querySelector<HTMLInputElement>(`[data-scope="${CSS.escape(first.name)}"] input[type="checkbox"]`)
: null
el?.focus()
return
}
if (errors.value.expires) {
document.querySelector<HTMLElement>('[data-expiry-segment]')?.focus()
}
}
async function onSubmit(): Promise<void> {
if (busy.value || !canSubmit.value || step.value !== 1) {
return
}
busy.value = true
errors.value = {}
try {
const result = await api.POST('/account/tokens', {
body: { name: name.value, expires: expires.value, scopes: selectedScopes() },
})
if (result.data) {
plaintext.value = result.data.data.token
step.value = 2
emit('created')
await nextTick()
copyButton.value?.querySelector('button')?.focus() ?? copyButton.value?.focus()
return
}
if (result.response.status === 422) {
errors.value = fieldErrors(result.error?.error.details)
await focusFirstInvalid()
return
}
showToast(t('backend::lang.api_tokens.create_failed'), 'danger')
} catch {
showToast(t('backend::lang.api_tokens.create_failed'), 'danger')
} finally {
busy.value = false
}
}
function markCopied(): void {
copied.value = true
copyFailed.value = false
copyFlash.value = true
announce.value = t('backend::lang.api_tokens.copied_announce')
if (copyTimer !== null) {
clearTimeout(copyTimer)
}
copyTimer = setTimeout(() => {
copyFlash.value = false
copyTimer = null
}, 2000)
}
async function onCopy(): Promise<void> {
const value = plaintext.value
if (!value) {
return
}
try {
if (!navigator.clipboard?.writeText) {
throw new Error('clipboard unavailable')
}
await navigator.clipboard.writeText(value)
markCopied()
} catch {
copyFailed.value = true
await nextTick()
plaintextInput.value?.focus()
plaintextInput.value?.select()
}
}
function onPlaintextCopy(): void {
markCopied()
}
function onPlaintextFocus(event: FocusEvent): void {
;(event.target as HTMLInputElement).select()
}
async function closeDialog(): Promise<void> {
clearPlaintext()
setOpen(false)
}
async function requestClose(): Promise<void> {
if (busy.value) {
return
}
if (step.value === 2 && plaintext.value && !copied.value) {
const ok = await props.confirm.ask({
message: t('backend::lang.api_tokens.close_uncopied_confirm'),
confirmLabel: t('backend::lang.api_tokens.close_uncopied_submit'),
danger: true,
})
if (!ok) {
return
}
}
await closeDialog()
}
function onOpenChange(open: boolean): void {
if (!open) {
void requestClose()
return
}
setOpen(true)
}
function onOpenAutoFocus(event: Event): void {
event.preventDefault()
if (step.value === 2) {
copyButton.value?.querySelector('button')?.focus() ?? copyButton.value?.focus()
return
}
nameInput.value?.focus()
}
function onCloseAutoFocus(event: Event): void {
event.preventDefault()
emit('closed')
}
function onPointerOutside(event: Event): void {
if (step.value === 2 || busy.value) {
event.preventDefault()
}
}
function onEscapeKey(event: Event): void {
if (busy.value) {
event.preventDefault()
return
}
if (step.value === 2) {
event.preventDefault()
void requestClose()
}
}
function onHide(): void {
void closeDialog()
}
onBeforeUnmount(() => {
clearPlaintext()
})
</script>
<template>
<DialogRoot :open="open" @update:open="onOpenChange">
<DialogPortal>
<DialogOverlay data-dialog-overlay class="fixed inset-0 z-40 bg-overlay" />
<DialogContent
data-token-dialog
data-dialog-content
aria-modal="true"
class="fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100vh-32px)] w-[calc(100%-32px)] max-w-[560px] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 rounded-modal bg-surface p-6 text-text shadow-pop"
@open-auto-focus="onOpenAutoFocus"
@close-auto-focus="onCloseAutoFocus"
@pointer-down-outside="onPointerOutside"
@interact-outside="onPointerOutside"
@escape-key-down="onEscapeKey"
>
<div class="flex items-start gap-3">
<div class="flex min-w-0 flex-1 flex-col gap-1">
<DialogTitle class="text-[20px] font-bold tracking-[-0.01em]">{{
step === 1
? t('backend::lang.api_tokens.create_title')
: t('backend::lang.api_tokens.created_title')
}}</DialogTitle>
<DialogDescription v-if="step === 1" class="text-muted">{{
t('backend::lang.api_tokens.create_description')
}}</DialogDescription>
<DialogDescription
v-else
data-token-notice
role="note"
class="flex items-start gap-2 rounded-inner bg-sel px-4 py-3.5 text-text"
>
<TriangleAlert :size="16" class="mt-0.5 shrink-0" aria-hidden="true" />
<span>{{ t('backend::lang.api_tokens.created_notice') }}</span>
</DialogDescription>
</div>
<button
type="button"
data-action="close"
:disabled="busy"
:aria-label="t('backend::lang.form.close')"
class="flex size-[34px] shrink-0 items-center justify-center rounded-tab bg-subtle text-muted hover:bg-hover hover:text-text disabled:cursor-not-allowed disabled:opacity-60"
@click="requestClose"
>
<X :size="16" aria-hidden="true" />
</button>
</div>
<form v-if="step === 1" novalidate class="flex min-h-0 flex-col gap-4" @submit.prevent="onSubmit">
<fieldset :disabled="busy" class="flex min-h-0 flex-col gap-[18px] overflow-y-auto">
<div class="flex min-w-0 flex-col gap-1.5" data-field="name">
<label :for="`${ID_PREFIX}-name`" class="font-semibold">{{
t('backend::lang.api_tokens.name')
}}</label>
<input
:id="`${ID_PREFIX}-name`"
ref="nameInput"
v-model="name"
name="name"
type="text"
maxlength="100"
autocomplete="off"
:placeholder="t('backend::lang.api_tokens.name_placeholder')"
:aria-invalid="errors.name ? 'true' : undefined"
:aria-describedby="nameDescribedBy()"
:class="controlClass(!!errors.name)"
class="h-input"
/>
<p :id="`${ID_PREFIX}-name-comment`" class="text-[13px] text-muted">
{{ t('backend::lang.api_tokens.name_comment') }}
</p>
<div
v-if="errors.name"
:id="`${ID_PREFIX}-name-error`"
class="flex flex-col gap-0.5 text-[13px] text-danger"
>
<p v-for="(text, index) in errors.name" :key="index" class="flex items-center gap-1.5">
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
<span>{{ text }}</span>
</p>
</div>
</div>
<div class="flex min-w-0 flex-col gap-1.5" data-field="expires">
<span id="api-token-expires-label" class="font-semibold">{{
t('backend::lang.api_tokens.expires')
}}</span>
<RadioGroupRoot
v-model="expires"
orientation="horizontal"
aria-labelledby="api-token-expires-label"
class="inline-flex flex-wrap gap-1 rounded-inner border border-border bg-subtle p-1"
>
<RadioGroupItem
v-for="option in EXPIRY_OPTIONS"
:key="option.value"
:value="option.value"
data-expiry-segment
class="inline-flex h-pager items-center rounded-tab px-4 text-[13px] whitespace-nowrap text-muted transition-colors duration-150 ease-out hover:text-text data-[state=checked]:bg-surface data-[state=checked]:text-text data-[state=checked]:shadow-tab data-[state=checked]:text-[14px] data-[state=checked]:font-semibold"
>
{{ t(option.label) }}
</RadioGroupItem>
</RadioGroupRoot>
<p class="text-[13px] text-muted tabular-nums">{{ expiryPreview }}</p>
</div>
<div class="flex min-w-0 flex-col gap-1.5" data-field="scopes">
<span id="api-token-scopes-label" class="font-semibold">{{
t('backend::lang.api_tokens.scopes')
}}</span>
<div
v-if="scopesLoading"
role="group"
aria-labelledby="api-token-scopes-label"
data-scope-picker
class="overflow-hidden rounded-inner border border-border"
>
<div
v-for="index in 3"
:key="index"
class="flex min-h-[56px] items-center gap-3 border-t border-border px-4 py-3 first:border-t-0"
aria-hidden="true"
>
<span class="size-5 shrink-0 rounded-[6px] bg-skel" />
<span class="h-3 w-40 rounded-[6px] bg-skel" />
</div>
</div>
<div
v-else-if="scopesFailed"
role="alert"
class="flex items-start gap-2 rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger"
>
<CircleAlert :size="16" class="mt-0.5 shrink-0" aria-hidden="true" />
<span>{{ t('backend::lang.api_tokens.scopes_failed') }}</span>
</div>
<div
v-else
role="group"
aria-labelledby="api-token-scopes-label"
data-scope-picker
:aria-invalid="errors.scopes ? 'true' : undefined"
:class="errors.scopes ? 'border-danger' : 'border-border'"
class="overflow-hidden rounded-inner border"
>
<label
v-for="scope in scopes"
:key="scope.name"
:data-scope="scope.name"
:class="scopeDisabled(scope.name) ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'"
class="flex min-h-[56px] items-start gap-3 border-t border-border px-4 py-3 first:border-t-0"
>
<input
type="checkbox"
class="mt-0.5 size-5 shrink-0 rounded-[6px] accent-primary"
:checked="checked.has(scope.name)"
:disabled="scopeDisabled(scope.name)"
@change="toggleScope(scope.name, ($event.target as HTMLInputElement).checked)"
/>
<span class="flex min-w-0 flex-col">
<span class="flex flex-wrap items-center gap-1">
<span class="font-semibold">{{ scope.label }}</span>
<span class="font-mono text-[13px] text-muted">{{ scope.name }}</span>
<span
v-if="scope.read_only"
class="inline-flex h-6 items-center rounded-pill bg-subtle px-2.5 text-[13px] font-semibold whitespace-nowrap text-muted"
>{{ t('backend::lang.api_tokens.read_only') }}</span
>
</span>
<span class="text-[13px] text-muted [overflow-wrap:anywhere]">{{ scope.description }}</span>
</span>
</label>
</div>
<p
v-if="starChecked"
class="flex items-center gap-2 text-[13px] text-muted"
>
<Info :size="14" class="shrink-0" aria-hidden="true" />
<span>{{ t('backend::lang.api_tokens.scope_all_includes') }}</span>
</p>
<div
v-if="errors.scopes"
:id="`${ID_PREFIX}-scopes-error`"
class="flex flex-col gap-0.5 text-[13px] text-danger"
>
<p v-for="(text, index) in errors.scopes" :key="index" class="flex items-center gap-1.5">
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
<span>{{ text }}</span>
</p>
</div>
</div>
</fieldset>
<div class="flex gap-2.5">
<Button
variant="outline"
class="flex-1"
data-action="create-token-cancel"
:disabled="busy"
@click="requestClose"
>
{{ t('backend::lang.api_tokens.create_cancel') }}
</Button>
<Button
variant="primary"
type="submit"
class="flex-1"
data-action="create-token-submit"
:disabled="!canSubmit"
>
<LoaderCircle v-if="busy" :size="16" class="animate-spin" aria-hidden="true" />
{{ busy ? t('backend::lang.api_tokens.creating') : t('backend::lang.api_tokens.create_submit') }}
</Button>
</div>
</form>
<div v-else class="flex min-h-0 flex-col gap-4">
<div class="flex min-w-0 flex-col gap-2">
<label :for="`${ID_PREFIX}-plaintext`" class="sr-only">{{
t('backend::lang.api_tokens.token_label')
}}</label>
<input
:id="`${ID_PREFIX}-plaintext`"
ref="plaintextInput"
:value="plaintext"
type="text"
readonly
autocomplete="off"
spellcheck="false"
data-token-plaintext
:class="controlClass(false)"
class="h-input bg-subtle font-mono text-[14px]"
@focus="onPlaintextFocus"
@click="onPlaintextFocus"
@copy="onPlaintextCopy"
/>
<div class="flex flex-wrap items-center gap-2">
<span ref="copyButton">
<Button
variant="outline"
size="sm"
:icon="copyFlash ? Check : Copy"
data-action="copy-token"
@click="onCopy"
>
{{ copyFlash ? t('backend::lang.api_tokens.copied') : t('backend::lang.api_tokens.copy') }}
</Button>
</span>
<p
v-if="copyFailed"
role="alert"
class="flex items-center gap-1.5 text-[13px] text-danger"
>
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
<span>{{ t('backend::lang.api_tokens.copy_failed') }}</span>
</p>
<span class="sr-only" aria-live="polite">{{ announce }}</span>
</div>
</div>
<div class="flex justify-end">
<Button variant="primary" data-action="hide-token" @click="onHide">
{{ t('backend::lang.api_tokens.done') }}
</Button>
</div>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>

View File

@@ -0,0 +1,312 @@
<script setup lang="ts">
import { nextTick, onMounted, ref } from 'vue'
import { Ban, CircleAlert, KeyRound, Plus } from '@lucide/vue'
import { api } from '../../api/client'
import type { AdminAPIToken } from '../../api/types'
import { t } from '../../app/i18n'
import Button from '../ui/Button.vue'
import { useConfirm } from '../ui/confirm'
import { showToast } from '../../state/useToasts'
import ApiTokenCreateDialog from './ApiTokenCreateDialog.vue'
// My account API tokens card (16.3 UI-SPEC): list, create dialog, revoke.
// Shares AccountView's useConfirm() so ConfirmDialog stays at z-[90].
const props = defineProps<{
confirm: ReturnType<typeof useConfirm>
}>()
const tokens = ref<AdminAPIToken[]>([])
const loading = ref(true)
const firstLoad = ref(true)
const loadFailed = ref(false)
const showRevoked = ref(false)
const createOpen = ref(false)
const createButton = ref<HTMLElement | null>(null)
function pad(value: number): string {
return String(value).padStart(2, '0')
}
function present(value: string | null | undefined): value is string {
return typeof value === 'string' && value !== ''
}
/** YYYY-MM-DD in local time (list date cells). */
function formatDate(value: string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value
}
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
/** YYYY-MM-DD HH:mm in local time (list datetime cells). */
function formatDateTime(value: string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value
}
return `${formatDate(value)} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
function metaLine(token: AdminAPIToken): string {
const parts = [t('backend::lang.api_tokens.created_at', { date: formatDate(token.created_at) })]
if (present(token.last_used_at)) {
parts.push(t('backend::lang.api_tokens.last_used', { date: formatDateTime(token.last_used_at) }))
} else {
parts.push(t('backend::lang.api_tokens.never_used'))
}
if (token.expired && present(token.expires_at)) {
parts.push(t('backend::lang.api_tokens.expired_at', { date: formatDate(token.expires_at) }))
} else if (present(token.expires_at)) {
parts.push(t('backend::lang.api_tokens.expires_at', { date: formatDate(token.expires_at) }))
} else {
parts.push(t('backend::lang.api_tokens.no_expiry'))
}
if (present(token.revoked_at)) {
parts.push(t('backend::lang.api_tokens.revoked_at', { date: formatDateTime(token.revoked_at) }))
}
return parts.join(' · ')
}
function isRevoked(token: AdminAPIToken): boolean {
return present(token.revoked_at)
}
async function loadTokens(): Promise<void> {
loading.value = true
loadFailed.value = false
try {
const result = await api.GET('/account/tokens', {
params: { query: showRevoked.value ? { revoked: '1' } : {} },
})
if (result.data) {
tokens.value = result.data.data ?? []
return
}
loadFailed.value = true
tokens.value = []
} catch {
loadFailed.value = true
tokens.value = []
} finally {
loading.value = false
firstLoad.value = false
}
}
function onShowRevokedChange(event: Event): void {
showRevoked.value = (event.target as HTMLInputElement).checked
void loadTokens()
}
function openCreate(): void {
createOpen.value = true
}
async function onDialogClosed(): Promise<void> {
await nextTick()
createButton.value?.querySelector('button')?.focus() ?? createButton.value?.focus()
}
async function onCreated(): Promise<void> {
await loadTokens()
}
async function onRevoke(token: AdminAPIToken): Promise<void> {
const ok = await props.confirm.ask(
{
message: t('backend::lang.api_tokens.revoke_confirm', { name: token.name }),
confirmLabel: t('backend::lang.api_tokens.revoke_submit'),
danger: true,
},
async () => {
const result = await api.DELETE('/account/tokens/{id}', {
params: { path: { id: token.id } },
})
if (result.data) {
await loadTokens()
showToast(t('backend::lang.api_tokens.revoked'))
await nextTick()
createButton.value?.querySelector('button')?.focus() ?? createButton.value?.focus()
return
}
if (result.response.status === 404) {
showToast(t('backend::lang.api_tokens.gone'), 'danger')
await loadTokens()
return
}
showToast(t('backend::lang.api_tokens.revoke_failed'), 'danger')
},
)
void ok
}
onMounted(() => {
void loadTokens()
})
</script>
<template>
<div
data-api-tokens
class="flex flex-col gap-4 rounded-card border border-border bg-surface p-7 shadow-card"
>
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="flex min-w-0 flex-col gap-1">
<h2 class="text-[18px] font-bold">{{ t('backend::lang.api_tokens.section') }}</h2>
<p class="max-w-[640px] text-[13px] text-muted">{{ t('backend::lang.api_tokens.section_comment') }}</p>
</div>
<span ref="createButton">
<Button
variant="outline"
size="sm"
:icon="Plus"
data-action="create-token"
:disabled="firstLoad && loading"
@click="openCreate"
>
{{ t('backend::lang.api_tokens.create') }}
</Button>
</span>
</div>
<label
v-if="!loadFailed && !firstLoad"
class="flex items-center gap-2"
>
<input
type="checkbox"
data-show-revoked
class="size-5 shrink-0 rounded-[6px] accent-primary"
:checked="showRevoked"
@change="onShowRevokedChange"
/>
<span>{{ t('backend::lang.api_tokens.show_revoked') }}</span>
</label>
<div
v-if="loading"
role="list"
data-token-list
aria-busy="true"
class="flex flex-col overflow-hidden rounded-inner border border-border"
>
<div
v-for="index in 3"
:key="index"
role="listitem"
class="flex flex-col gap-2 border-t border-border px-4 py-3.5 first:border-t-0"
aria-hidden="true"
>
<span class="h-3 w-40 rounded-[6px] bg-skel" />
<span class="h-3 w-64 rounded-[6px] bg-skel" />
</div>
</div>
<div
v-else-if="loadFailed"
role="alert"
data-tokens-error
class="flex items-start gap-2 rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger"
>
<CircleAlert :size="16" class="mt-0.5 shrink-0" aria-hidden="true" />
<span>{{ t('backend::lang.api_tokens.load_failed') }}</span>
</div>
<div
v-else-if="tokens.length === 0"
data-tokens-empty
class="flex flex-col items-center gap-2 rounded-inner border border-dashed border-border-strong px-6 py-8 text-center"
>
<span class="flex size-10 items-center justify-center rounded-full bg-subtle text-muted">
<KeyRound :size="20" aria-hidden="true" />
</span>
<p class="font-semibold">{{ t('backend::lang.api_tokens.empty_title') }}</p>
<p class="max-w-[420px] text-[13px] text-muted">{{ t('backend::lang.api_tokens.empty_body') }}</p>
</div>
<div
v-else
role="list"
data-token-list
class="flex flex-col overflow-hidden rounded-inner border border-border"
>
<div
v-for="token in tokens"
:key="token.id"
role="listitem"
:data-token-row="token.id"
class="flex items-start justify-between gap-4 border-t border-border px-4 py-3.5 first:border-t-0 max-sm:flex-col"
>
<div class="flex min-w-0 flex-1 flex-col gap-2">
<div class="flex flex-wrap items-center gap-2">
<span
:class="isRevoked(token) ? 'text-muted' : 'text-text'"
class="font-semibold [overflow-wrap:anywhere]"
>{{ token.name }}</span
>
<span
class="font-mono text-[13px] text-muted"
:aria-label="t('backend::lang.api_tokens.hint', { last4: token.hint })"
>g15_…{{ token.hint }}</span
>
<span
v-if="token.expired && !isRevoked(token)"
class="inline-flex h-6 items-center rounded-pill bg-danger-soft px-2.5 text-[13px] font-semibold whitespace-nowrap text-danger"
>{{ t('backend::lang.api_tokens.state_expired') }}</span
>
<span
v-if="isRevoked(token)"
class="inline-flex h-6 items-center rounded-pill border border-border-strong px-2.5 text-[13px] font-semibold whitespace-nowrap text-muted"
>{{ t('backend::lang.api_tokens.state_revoked') }}</span
>
</div>
<div data-token-scopes class="flex flex-wrap gap-2">
<span
v-for="scope in token.scopes"
:key="scope.name"
:title="scope.known ? scope.name : t('backend::lang.api_tokens.unknown_scope_hint')"
:class="
scope.known
? 'bg-subtle text-text'
: 'border border-dashed border-border-strong font-mono text-muted'
"
class="inline-flex h-6 items-center rounded-pill px-2.5 text-[13px] font-semibold whitespace-nowrap"
>
{{
scope.known
? scope.name === '*'
? t('backend::lang.api_tokens.scope_all')
: scope.label
: t('backend::lang.api_tokens.unknown_scope', { name: scope.name })
}}
</span>
</div>
<p :class="isRevoked(token) ? 'text-muted' : 'text-muted'" class="text-[13px] tabular-nums">
{{ metaLine(token) }}
</p>
</div>
<Button
v-if="!isRevoked(token)"
variant="danger"
size="sm"
:icon="Ban"
data-action="revoke-token"
class="max-sm:self-start"
:aria-label="t('backend::lang.api_tokens.revoke_named', { name: token.name })"
@click="onRevoke(token)"
>
{{ t('backend::lang.api_tokens.revoke') }}
</Button>
</div>
</div>
<ApiTokenCreateDialog
v-model:open="createOpen"
:confirm="confirm"
@created="onCreated"
@closed="onDialogClosed"
/>
</div>
</template>

View File

@@ -60,7 +60,7 @@ function onConfirm(): void {
<AlertDialogTitle class="text-[20px] font-bold tracking-[-0.01em]">
{{ title ?? t('backend::lang.form.confirm') }}
</AlertDialogTitle>
<AlertDialogDescription class="text-muted">{{ message }}</AlertDialogDescription>
<AlertDialogDescription class="text-muted [overflow-wrap:anywhere]">{{ message }}</AlertDialogDescription>
<div class="flex gap-2.5">
<AlertDialogCancel
data-action="cancel"

View File

@@ -7,6 +7,7 @@ import { t } from '../app/i18n'
import FormErrorBanner from '../components/form/FormErrorBanner.vue'
import { controlClass } from '../components/form/control'
import { fieldErrors, focusField } from '../components/form/formState'
import ApiTokensPanel from '../components/account/ApiTokensPanel.vue'
import Button from '../components/ui/Button.vue'
import ConfirmDialog from '../components/ui/ConfirmDialog.vue'
import { useConfirm } from '../components/ui/confirm'
@@ -408,6 +409,8 @@ async function onSave(): Promise<void> {
</fieldset>
</form>
<ApiTokensPanel :confirm="confirm" />
<ConfirmDialog
:open="confirm.request.value !== null"
:message="confirm.request.value?.message ?? ''"

View File

@@ -0,0 +1,327 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
import { toasts } from '../../src/state/useToasts'
import { API, keydown, mountApp, queryOf, requestsTo, resetState, type Reply, type Route } from '../helpers'
const TOKENS = `${API}/account/tokens`
const SCOPES = `${API}/account/tokens/scopes`
const TOKEN_ID = `${API}/account/tokens/11`
const envelope = (data: unknown, status = 200) => ({ status, body: { data, meta: {} } })
const existingToken = {
id: 11,
name: 'Blog agent',
hint: 'a1b2',
scopes: [
{
name: 'admin:read',
label: 'Read-only, whole admin',
description: 'View everything',
read_only: true,
known: true,
},
],
created_at: '2026-01-10T12:00:00Z',
last_used_at: '',
expires_at: '2026-04-10T12:00:00Z',
revoked_at: '',
expired: false,
}
const revokedToken = {
...existingToken,
id: 13,
name: 'Old token',
hint: 'dead',
revoked_at: '2026-02-01T08:30:00Z',
}
const createdToken = {
...existingToken,
id: 12,
name: 'CI runner',
hint: 'c3d4',
token: 'g15_0123456789abcdef0123456789abcdef01234567',
}
const scopes = [
{
name: '*',
label: 'Full access',
description: 'Everything',
read_only: false,
known: true,
},
{
name: 'admin:read',
label: 'Read-only, whole admin',
description: 'View everything',
read_only: true,
known: true,
},
]
function deferred(): { route: () => Promise<Reply>; release: (reply: Reply) => void } {
let release!: (reply: Reply) => void
const pending = new Promise<Reply>((resolve) => {
release = resolve
})
return { route: () => pending, release }
}
function tokenRoutes(overrides: Record<string, Route> = {}, list: unknown[] = [existingToken]): Record<string, Route> {
let listed = list
return {
[`GET ${TOKENS}`]: () => envelope(listed),
[`GET ${SCOPES}`]: envelope(scopes),
[`POST ${TOKENS}`]: () => {
listed = [createdToken, ...listed]
return envelope(createdToken, 201)
},
...overrides,
}
}
async function openAccount(routes: Record<string, Route> = {}, list: unknown[] = [existingToken]) {
return mountApp('/account', tokenRoutes(routes, list), { attach: true })
}
const panel = () => document.body.querySelector<HTMLElement>('[data-api-tokens]')
const dialog = () => document.body.querySelector<HTMLElement>('[data-token-dialog]')
const confirmDialog = () => document.body.querySelector<HTMLElement>('[data-confirm-dialog]')
async function click(selector: string, root: ParentNode = document.body): Promise<void> {
const el = root.querySelector<HTMLElement>(selector)
el?.click()
await flushPromises()
}
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
vi.unstubAllGlobals()
})
enableAutoUnmount(afterEach)
describe('ApiTokensPanel', () => {
it('tracer: list, create, show once, hide', async () => {
const { calls } = await openAccount()
await flushPromises()
expect(panel()).toBeTruthy()
expect(panel()!.querySelector('[data-token-row="11"]')).toBeTruthy()
await click('[data-action="create-token"]', panel()!)
expect(dialog()).toBeTruthy()
const nameInput = dialog()!.querySelector<HTMLInputElement>('#api-token-name')!
await nameInput.focus()
nameInput.value = 'CI runner'
nameInput.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
const scope = dialog()!.querySelector<HTMLInputElement>('[data-scope="admin:read"] input')!
scope.checked = true
scope.dispatchEvent(new Event('change', { bubbles: true }))
await flushPromises()
const getsBeforeCreate = requestsTo(calls, 'GET', TOKENS).length
await click('[data-action="create-token-submit"]', dialog()!)
const [post] = requestsTo(calls, 'POST', TOKENS)
expect(post).toBeTruthy()
expect(await post!.clone().json()).toEqual({
name: 'CI runner',
expires: '90d',
scopes: ['admin:read'],
})
const plaintext = dialog()!.querySelector<HTMLInputElement>('[data-token-plaintext]')!
expect(plaintext.value).toBe(createdToken.token)
expect(requestsTo(calls, 'GET', TOKENS).length).toBeGreaterThan(getsBeforeCreate)
await click('[data-action="hide-token"]', dialog()!)
expect(dialog()).toBeNull()
expect(document.body.textContent).not.toContain(createdToken.token)
})
describe('list states', () => {
it('shows skeleton then the empty block', async () => {
const pending = deferred()
const { wrapper } = await mountApp(
'/account',
tokenRoutes({ [`GET ${TOKENS}`]: pending.route }, []),
{ attach: true },
)
expect(panel()!.querySelector('[data-token-list]')!.getAttribute('aria-busy')).toBe('true')
expect(panel()!.querySelectorAll('[aria-hidden="true"]').length).toBeGreaterThanOrEqual(3)
expect(panel()!.querySelector<HTMLButtonElement>('[data-action="create-token"]')!.disabled).toBe(true)
pending.release(envelope([]))
await flushPromises()
expect(panel()!.querySelector('[data-tokens-empty]')).toBeTruthy()
expect(panel()!.querySelector('[data-action="create-token"]')!.hasAttribute('disabled')).toBe(false)
wrapper.unmount()
})
it('shows the alert and hides the toggle when the load fails', async () => {
await openAccount({
[`GET ${TOKENS}`]: { status: 500, body: { error: { code: 'x', message: 'fail', details: {} } } },
})
expect(panel()!.querySelector('[data-tokens-error]')!.getAttribute('role')).toBe('alert')
expect(panel()!.querySelector('[data-show-revoked]')).toBeNull()
expect(panel()!.querySelector<HTMLButtonElement>('[data-action="create-token"]')!.disabled).toBe(false)
})
})
describe('revoke', () => {
it('runs DELETE through the shared confirm and toasts success', async () => {
const { calls } = await openAccount({
[`DELETE ${TOKEN_ID}`]: envelope({ ...existingToken, revoked_at: '2026-03-01T10:00:00Z' }),
})
await click('[data-action="revoke-token"]', panel()!)
expect(confirmDialog()).toBeTruthy()
await click('[data-action="confirm"]', confirmDialog()!)
expect(requestsTo(calls, 'DELETE', TOKEN_ID)).toHaveLength(1)
expect(toasts.value[0]).toMatchObject({ text: 'Unieważniono token', tone: 'success' })
})
it('toasts gone on 404 and reloads', async () => {
let listed: unknown[] = [existingToken]
const { calls } = await openAccount({
[`GET ${TOKENS}`]: () => envelope(listed),
[`DELETE ${TOKEN_ID}`]: () => {
listed = []
return { status: 404, body: { error: { code: 'not_found', message: 'gone', details: {} } } }
},
})
const getsBefore = requestsTo(calls, 'GET', TOKENS).length
await click('[data-action="revoke-token"]', panel()!)
await click('[data-action="confirm"]', confirmDialog()!)
expect(toasts.value[0]).toMatchObject({
text: 'Ten token już nie istnieje. Lista została odświeżona.',
tone: 'danger',
})
expect(requestsTo(calls, 'GET', TOKENS).length).toBeGreaterThan(getsBefore)
expect(panel()!.querySelector('[data-tokens-empty]')).toBeTruthy()
})
it('requests revoked=1 and hides revoke on revoked rows', async () => {
const { calls } = await openAccount({
[`GET ${TOKENS}`]: (request) =>
envelope(queryOf(request).get('revoked') === '1' ? [existingToken, revokedToken] : [existingToken]),
})
const toggle = panel()!.querySelector<HTMLInputElement>('[data-show-revoked]')!
toggle.checked = true
toggle.dispatchEvent(new Event('change', { bubbles: true }))
await flushPromises()
const withRevoked = requestsTo(calls, 'GET', TOKENS).filter((request) => queryOf(request).get('revoked') === '1')
expect(withRevoked.length).toBeGreaterThan(0)
expect(panel()!.querySelector('[data-token-row="13"]')).toBeTruthy()
expect(panel()!.querySelector('[data-token-row="13"] [data-action="revoke-token"]')).toBeNull()
expect(panel()!.querySelector('[data-token-row="11"] [data-action="revoke-token"]')).toBeTruthy()
})
})
describe('dialog rules', () => {
async function openCreateDialog(routes: Record<string, Route> = {}) {
const mounted = await openAccount(routes)
await click('[data-action="create-token"]', panel()!)
await flushPromises()
return mounted
}
async function fillName(value: string): Promise<void> {
const nameInput = dialog()!.querySelector<HTMLInputElement>('#api-token-name')!
nameInput.value = value
nameInput.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
}
it('disables other scopes when * is checked and posts only ["*"]', async () => {
const { calls } = await openCreateDialog()
await fillName('Full')
const star = dialog()!.querySelector<HTMLInputElement>('[data-scope="*"] input')!
star.checked = true
star.dispatchEvent(new Event('change', { bubbles: true }))
await flushPromises()
expect(dialog()!.querySelector<HTMLInputElement>('[data-scope="admin:read"] input')!.disabled).toBe(true)
expect(dialog()!.textContent).toContain('Pełny dostęp obejmuje wszystkie pozostałe zakresy.')
await click('[data-action="create-token-submit"]', dialog()!)
const [post] = requestsTo(calls, 'POST', TOKENS)
expect(await post!.clone().json()).toEqual({ name: 'Full', expires: '90d', scopes: ['*'] })
})
it('renders a 422 on scopes and focuses the first scope checkbox', async () => {
await openCreateDialog({
[`POST ${TOKENS}`]: {
status: 422,
body: {
error: {
code: 'validation_failed',
message: 'Validation failed',
details: { scopes: ['Wybierz co najmniej jeden zakres.'] },
},
},
},
})
await fillName('No scope')
await click('[data-action="create-token-submit"]', dialog()!)
expect(dialog()!.textContent).toContain('Wybierz co najmniej jeden zakres.')
expect(document.activeElement).toBe(dialog()!.querySelector('[data-scope="*"] input'))
})
it('shows Copied on clipboard success and copy_failed on rejection', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
await openCreateDialog()
await fillName('Copy me')
const scope = dialog()!.querySelector<HTMLInputElement>('[data-scope="admin:read"] input')!
scope.checked = true
scope.dispatchEvent(new Event('change', { bubbles: true }))
await flushPromises()
await click('[data-action="create-token-submit"]', dialog()!)
await click('[data-action="copy-token"]', dialog()!)
expect(writeText).toHaveBeenCalledWith(createdToken.token)
expect(dialog()!.querySelector('[data-action="copy-token"]')!.textContent).toContain('Skopiowano')
expect(dialog()!.querySelector('[aria-live="polite"]')!.textContent).toContain(
'Skopiowano token do schowka.',
)
writeText.mockRejectedValueOnce(new Error('denied'))
await click('[data-action="copy-token"]', dialog()!)
expect(dialog()!.querySelector('[role="alert"]')!.textContent).toContain(
'Nie udało się skopiować automatycznie',
)
expect(document.activeElement).toBe(dialog()!.querySelector('[data-token-plaintext]'))
})
it('asks before closing step 2 without a copy and keeps the step on cancel', async () => {
await openCreateDialog()
await fillName('Guard')
const scope = dialog()!.querySelector<HTMLInputElement>('[data-scope="admin:read"] input')!
scope.checked = true
scope.dispatchEvent(new Event('change', { bubbles: true }))
await flushPromises()
await click('[data-action="create-token-submit"]', dialog()!)
expect(dialog()!.querySelector('[data-token-plaintext]')).toBeTruthy()
keydown(dialog()!, 'Escape')
await flushPromises()
// Reka Escape may be handled as escape-key-down; fall back to the close button.
if (!confirmDialog()) {
await click('[data-action="close"]', dialog()!)
}
expect(confirmDialog()).toBeTruthy()
expect(confirmDialog()!.textContent).toContain('Zamknąć bez kopiowania')
await click('[data-action="cancel"]', confirmDialog()!)
expect(dialog()!.querySelector<HTMLInputElement>('[data-token-plaintext]')!.value).toBe(
createdToken.token,
)
})
})
})

View File

@@ -1,5 +1,200 @@
{
"data": {
"backend::lang.api_tokens.close_uncopied_confirm": {
"other": "Zamknąć bez kopiowania? Tego tokenu nie będzie można już zobaczyć."
},
"backend::lang.api_tokens.close_uncopied_submit": {
"other": "Zamknij i ukryj token"
},
"backend::lang.api_tokens.copied": {
"other": "Skopiowano"
},
"backend::lang.api_tokens.copied_announce": {
"other": "Skopiowano token do schowka."
},
"backend::lang.api_tokens.copy": {
"other": "Kopiuj token"
},
"backend::lang.api_tokens.copy_failed": {
"other": "Nie udało się skopiować automatycznie. Token jest zaznaczony: naciśnij Ctrl+C (Cmd+C na Macu), aby go skopiować."
},
"backend::lang.api_tokens.create": {
"other": "Utwórz token"
},
"backend::lang.api_tokens.create_cancel": {
"other": "Odrzuć token"
},
"backend::lang.api_tokens.create_description": {
"other": "Wybierz nazwę, termin ważności i zakresy tokenu. Token nigdy nie ma szerszego dostępu niż Twoje konto."
},
"backend::lang.api_tokens.create_failed": {
"other": "Nie udało się utworzyć tokenu. Spróbuj ponownie."
},
"backend::lang.api_tokens.create_submit": {
"other": "Utwórz token"
},
"backend::lang.api_tokens.create_title": {
"other": "Nowy token API"
},
"backend::lang.api_tokens.created_at": {
"other": "Utworzono :date"
},
"backend::lang.api_tokens.created_notice": {
"other": "Token jest widoczny tylko teraz. Skopiuj go i przechowuj w bezpiecznym miejscu. Jeśli go zgubisz, unieważnij go i utwórz nowy."
},
"backend::lang.api_tokens.created_title": {
"other": "Skopiuj nowy token"
},
"backend::lang.api_tokens.creating": {
"other": "Tworzenie…"
},
"backend::lang.api_tokens.done": {
"other": "Gotowe, ukryj token"
},
"backend::lang.api_tokens.empty_body": {
"other": "Utwórz token, aby agent lub integracja mogły wywoływać API panelu w Twoim imieniu."
},
"backend::lang.api_tokens.empty_title": {
"other": "Nie masz jeszcze tokenów API"
},
"backend::lang.api_tokens.expired_at": {
"other": "Wygasł :date"
},
"backend::lang.api_tokens.expires": {
"other": "Ważność"
},
"backend::lang.api_tokens.expires_30": {
"other": "30 dni"
},
"backend::lang.api_tokens.expires_365": {
"other": "365 dni"
},
"backend::lang.api_tokens.expires_90": {
"other": "90 dni"
},
"backend::lang.api_tokens.expires_at": {
"other": "Wygasa :date"
},
"backend::lang.api_tokens.expires_invalid": {
"other": "Wybierz poprawny termin ważności."
},
"backend::lang.api_tokens.expires_never": {
"other": "Bez terminu"
},
"backend::lang.api_tokens.expires_never_note": {
"other": "Ten token nie wygasa. Unieważnij go, gdy przestanie być potrzebny."
},
"backend::lang.api_tokens.expires_on": {
"other": "Wygasa :date."
},
"backend::lang.api_tokens.gone": {
"other": "Ten token już nie istnieje. Lista została odświeżona."
},
"backend::lang.api_tokens.hint": {
"other": "Token kończący się na :last4"
},
"backend::lang.api_tokens.last_used": {
"other": "Ostatnio użyto :date"
},
"backend::lang.api_tokens.load_failed": {
"other": "Nie udało się wczytać tokenów. Odśwież stronę, aby spróbować ponownie."
},
"backend::lang.api_tokens.name": {
"other": "Nazwa"
},
"backend::lang.api_tokens.name_comment": {
"other": "Pomoże rozpoznać token na liście."
},
"backend::lang.api_tokens.name_placeholder": {
"other": "np. Agent bloga"
},
"backend::lang.api_tokens.name_required": {
"other": "Podaj nazwę tokenu."
},
"backend::lang.api_tokens.name_too_long": {
"other": "Nazwa może mieć najwyżej 100 znaków."
},
"backend::lang.api_tokens.never_used": {
"other": "Nieużywany"
},
"backend::lang.api_tokens.no_expiry": {
"other": "Bez terminu ważności"
},
"backend::lang.api_tokens.read_only": {
"other": "Tylko odczyt"
},
"backend::lang.api_tokens.revoke": {
"other": "Unieważnij token"
},
"backend::lang.api_tokens.revoke_confirm": {
"other": "Unieważnić token „:name”? Aplikacje i agenci, którzy go używają, natychmiast stracą dostęp. Tej operacji nie można cofnąć."
},
"backend::lang.api_tokens.revoke_failed": {
"other": "Nie udało się unieważnić tokenu. Spróbuj ponownie."
},
"backend::lang.api_tokens.revoke_named": {
"other": "Unieważnij token: :name"
},
"backend::lang.api_tokens.revoke_submit": {
"other": "Unieważnij token"
},
"backend::lang.api_tokens.revoked": {
"other": "Unieważniono token"
},
"backend::lang.api_tokens.revoked_at": {
"other": "Unieważniono :date"
},
"backend::lang.api_tokens.scope_admin_read": {
"other": "Tylko odczyt, cały panel"
},
"backend::lang.api_tokens.scope_admin_read_description": {
"other": "Podgląd wszystkiego, co widzisz w panelu, bez wprowadzania zmian."
},
"backend::lang.api_tokens.scope_all": {
"other": "Pełny dostęp"
},
"backend::lang.api_tokens.scope_all_description": {
"other": "Wszystko, co możesz zrobić w panelu. Ustawienia konta i tokeny nadal wymagają zalogowania."
},
"backend::lang.api_tokens.scope_all_includes": {
"other": "Pełny dostęp obejmuje wszystkie pozostałe zakresy."
},
"backend::lang.api_tokens.scope_unavailable": {
"other": "Zakres :name nie jest dla Ciebie dostępny."
},
"backend::lang.api_tokens.scopes": {
"other": "Zakresy"
},
"backend::lang.api_tokens.scopes_failed": {
"other": "Nie udało się wczytać zakresów. Zamknij to okno i spróbuj ponownie."
},
"backend::lang.api_tokens.scopes_required": {
"other": "Wybierz co najmniej jeden zakres."
},
"backend::lang.api_tokens.section": {
"other": "Tokeny API"
},
"backend::lang.api_tokens.section_comment": {
"other": "Tokeny pozwalają agentom i integracjom wywoływać API panelu w Twoim imieniu, w granicach wybranych zakresów. Traktuj token jak hasło."
},
"backend::lang.api_tokens.show_revoked": {
"other": "Pokaż unieważnione tokeny"
},
"backend::lang.api_tokens.state_expired": {
"other": "Wygasły"
},
"backend::lang.api_tokens.state_revoked": {
"other": "Unieważniony"
},
"backend::lang.api_tokens.token_label": {
"other": "Nowy token"
},
"backend::lang.api_tokens.unknown_scope": {
"other": ":name (nieznany)"
},
"backend::lang.api_tokens.unknown_scope_hint": {
"other": "Ten zakres nie jest już zarejestrowany i nic nie przyznaje."
},
"backend::lang.auth.invalid": {
"other": "Nieprawidłowe dane logowania"
},
@@ -328,7 +523,7 @@
"other": "Login"
},
"backend::lang.myaccount.menu_description": {
"other": "Zmień imię, nazwisko, e-mail, podtytuł i hasło."
"other": "Zmień imię, nazwisko, e-mail, podtytuł i hasło oraz zarządzaj tokenami API."
},
"backend::lang.myaccount.menu_label": {
"other": "Moje konto"

View File

@@ -25,7 +25,15 @@ const invalid = (details: Record<string, string[]>) => ({
})
async function openAccount(user: Record<string, unknown> = signedIn, routes: Record<string, Route> = {}) {
return mountApp('/account', { [`GET ${API}/auth/me`]: envelope(user), ...routes }, { attach: true })
return mountApp(
'/account',
{
[`GET ${API}/auth/me`]: envelope(user),
[`GET ${API}/account/tokens`]: envelope([]),
...routes,
},
{ attach: true },
)
}
function input(wrapper: VueWrapper, name: string) {

View File

@@ -36,6 +36,10 @@ func delegateCommands() []bonfire.Command {
{"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", ""},
}
cmds := make([]bonfire.Command, 0, len(rows))
for _, r := range rows {

View File

@@ -79,15 +79,16 @@ func TestDelegateCommandsPassArgs(t *testing.T) {
func TestDelegateCommandsHelp(t *testing.T) {
hints := map[string][]string{
"migrate:rollback": {"--plugin"},
"queue:work": {"--queue"},
"schedule:run": {"--once"},
"serve": {"--addr"},
"admin:create": {"--email", "--superuser"},
"migrate:rollback": {"--plugin"},
"queue:work": {"--queue"},
"schedule:run": {"--once"},
"serve": {"--addr"},
"admin:create": {"--email", "--superuser"},
"admin:token:create": {"--name", "--scope", "--expires"},
}
cmds := delegateCommands()
if len(cmds) != 11 {
t.Fatalf("delegateCommands() has %d commands, want 11", len(cmds))
if len(cmds) != 15 {
t.Fatalf("delegateCommands() has %d commands, want 15", len(cmds))
}
got := captureAppRunner(t)
for _, c := range cmds {

View File

@@ -33,7 +33,7 @@ A list row that carries a state (deleted, negative or disabled) shows a text bad
A form whose schema carries `preview` has a read-only record screen at `<controller>/<id>/preview`, next to the list (`<controller>`), the create form (`<controller>/create`) and the update form (`<controller>/<id>`). It renders the fields whose context allows `preview` as text, the status hint partial above them and, in its footer, the record actions the record response offers before the one edit button. Opening that route for a form without a preview goes to the update form. See [Preview screen](forms.md#preview-screen).
The user menu in the top bar has a My account item that opens `<prefix>/account`. That screen edits the signed-in administrator's own first name, last name, email and subtitle, uploads and removes their avatar, shows the login read-only, and changes the password when a new one is typed together with the current one. Server validation errors show on their fields, and a successful save updates the name and the avatar in the top bar without a reload. See [My account](users-and-permissions.md#my-account).
The user menu in the top bar has a My account item that opens `<prefix>/account`. That screen edits the signed-in administrator's own first name, last name, email and subtitle, uploads and removes their avatar, shows the login read-only, changes the password when a new one is typed together with the current one, and manages personal API tokens (list, create with a show-once plaintext, revoke, show revoked). Server validation errors show on their fields, and a successful save updates the name and the avatar in the top bar without a reload. See [My account](users-and-permissions.md#my-account) and [API tokens](users-and-permissions.md#api-tokens).
## Types from OpenAPI

View File

@@ -66,7 +66,7 @@ func (p *BlogPlugin) Navigation() []pact.NavigationItem {
}
```
A controller's `pact.AdminPermissioned.RequiredPermissions` are checked before any schema is served or query runs, and navigation and settings entries are filtered by the permissions they name, so an administrator sees only what they may open: a main menu item the administrator may not open is dropped even when one of its side-menu entries would pass, and an allowed item that links to a controller the administrator cannot open links to its first openable side-menu entry instead. `cabana.Allows` is the check and follows Winter's `hasAnyAccess`: superusers pass, an administrator needs any one of the listed codes, and an empty requirement list allows any signed-in administrator. Wildcards match on both sides: a grant ending in `.*` covers every code with that prefix, and a required code such as `acme.blog.*` is met by any grant under `acme.blog.`. The last lines of the activation example on [Admin controllers](admin-controllers.md) show it.
A controller's `pact.AdminPermissioned.RequiredPermissions` are checked before any schema is served or query runs, and navigation and settings entries are filtered by the permissions they name, so an administrator sees only what they may open: a main menu item the administrator may not open is dropped even when one of its side-menu entries would pass, and an allowed item that links to a controller the administrator cannot open links to its first openable side-menu entry instead. `cabana.Allows` is the check and follows Winter's `hasAnyAccess` for JWT sessions: superusers pass, an administrator needs any one of the listed codes, and an empty requirement list allows any signed-in administrator. Wildcards match on both sides: a grant ending in `.*` covers every code with that prefix, and a required code such as `acme.blog.*` is met by any grant under `acme.blog.`. When the request carries a personal API token with a non-full scope, the scope is checked first (see [API tokens](#api-tokens)). The last lines of the activation example on [Admin controllers](admin-controllers.md) show it.
An administrator's grants are the role's `permissions` merged with the administrator's own `backend_users.permissions`, the way WinterCMS merges them: the administrator's value for a code replaces the role's, and only `1` grants. A `-1` (or `0`) on the administrator therefore removes a permission the role grants, so rows copied from a WinterCMS database keep their denies. As in WinterCMS the merge compares codes exactly, so denying `acme.blog.access_posts` does not take it back from a role that grants `acme.blog.*`.
@@ -74,28 +74,87 @@ Actions registered through `pact.HasAdminActions`, bulk actions (`pact.HasAdminB
A permission denial and a refusal are different answers with the same status. A denial comes from the framework: the administrator lacks a permission code, and the 403 carries the framework's fixed text. A refusal comes from controller code that returns a `cabana.ForbiddenError`: the administrator may run the route, but this change is not allowed for this record, and the 403 carries the plugin's translated message and, optionally, messages per field. See [Refusing a write](admin-controllers.md#refusing-a-write).
## API tokens
Administrators can mint personal API tokens for agents and integrations. A token authenticates as its owner through the admin `backend` guard when the `Authorization` header carries `Bearer g15_…` (forty lowercase hex characters after the `g15_` prefix). Only the sha256 hash and the last four characters (the hint) are stored in `backend_api_tokens`; the plaintext appears once, in the create response or on the CLI stdout line.
### Scopes
Each token holds one or more scopes chosen at creation. The framework registers two scopes:
| Scope | Methods | Effect |
|-------|---------|--------|
| `*` | every method | Acts as the owner with no further scope limit. |
| `admin:read` | GET, HEAD | Acts as the owner on read methods only. |
Plugins declare more scopes with `pact.HasTokenScopes`. Boot fails on a duplicate name, a reserved name (`*`, `admin`, `admin:…`), an invalid name, empty permissions or methods, an unknown method, a method list without GET, an unknown permission pattern, or a label or description phrase key that does not resolve:
```go src=docs/examples/blog/token_scopes.go#Plugin.TokenScopes
// TokenScopes registers personal API token scopes for the blog plugin.
func (p *Plugin) TokenScopes() []pact.TokenScope {
all := []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"}
return []pact.TokenScope{
{
Name: "blog",
Label: "acme.blog::lang.scopes.blog",
Description: "acme.blog::lang.scopes.blog_description",
Permissions: []string{"acme.blog.*"},
Methods: all,
},
{
Name: "blog:read",
Label: "acme.blog::lang.scopes.blog_read",
Description: "acme.blog::lang.scopes.blog_read_description",
Permissions: []string{"acme.blog.*"},
Methods: []string{"GET", "HEAD"},
},
}
}
```
A scoped request passes only when one method-allowed scope matches every required permission code and the owner holds a required code inside that scope. A superuser with a scoped token is limited to that scope. An empty requirement list is refused unless the scope is full (`*` or `admin:read` on GET/HEAD). Routes that never call `cabana.Allows` (`GET /field-types`, `POST /markdown/preview`) apply the same empty-list rule. Account profile, avatar, token management, refresh and logout refuse every personal token (`details.reason` `interactive_only`), including `*`. `GET /auth/me` accepts any valid token.
### Expiry and revoke
Expiry is `30d`, `90d` (default), `365d` or `never`. Soft revoke sets `revoked_at`; revoked tokens are hidden from the default list. A password reset does not invalidate personal API tokens.
### Account endpoints
Under `<prefix>/api/v1`, interactive sessions only:
| Method and path | Purpose |
|-----------------|---------|
| `GET /account/tokens` | List the caller's tokens (newest first; `?revoked=1` includes revoked). |
| `POST /account/tokens` | Create a token; answers 201 with the plaintext once. |
| `DELETE /account/tokens/{id}` | Soft-revoke one of the caller's own active tokens. |
| `GET /account/tokens/scopes` | Scopes the caller may assign. |
Operators manage any user's tokens with `admin:token:create`, `admin:token:list`, `admin:token:revoke` and `admin:token:scopes` on the application binary (see [Setup and maintenance](../console/setup-and-maintenance.md#admin-accounts)). Plugin routes that accept an admin Bearer token call `cabana.ResolveBackend` (see [Authentication](../services/authentication.md)).
## My account
Every signed-in administrator can edit their own profile on the My account screen, which the SPA opens at `<prefix>/account` from the user menu. It saves through `PUT <prefix>/api/v1/account`, the Go form of WinterCMS's `Users::myaccount`. The route always edits the row of the token's administrator; no id is read from the path or the body.
Every signed-in administrator can edit their own profile on the My account screen, which the SPA opens at `<prefix>/account` from the user menu. It saves through `PUT <prefix>/api/v1/account`, the Go form of WinterCMS's `Users::myaccount`. The route always edits the row of the token's administrator; no id is read from the path or the body. Personal API tokens are refused on this route and on the avatar routes.
- **Editable:** first name, last name, email and subtitle, plus the password. The body may hold only `first_name`, `last_name`, `email`, `subtitle`, `current_password`, `password` and `password_confirmation` (`cabana.AdminAccountRequest`). Any other key, such as `login`, `role_id`, `is_superuser`, `permissions` or `is_activated`, answers 422 on that key and nothing is written. The login is shown but cannot be changed here, and role, superuser flag, permissions and activation stay with the operator commands below.
- **Email:** required, a valid address and 6 to 255 characters long. It must not equal, ignoring case, the email or login of another administrator, deleted administrators included, because a sign-in identifier that matches two administrators locks both out. Your own email in a different case is accepted. Changing the email does not ask for the password, as in WinterCMS: the framework sends no password-reset emails, so the email is not a recovery channel.
- **Password:** leave it blank to keep the current one. A new password needs `current_password`, at least 4 characters, at most 72 bytes (bcrypt's limit) and an equal `password_confirmation`. A wrong current password answers 422 on `current_password` and is written to the authentication log. A changed password is hashed at `admin.password.bcrypt_cost` and ends every other session: tokens issued before the change are refused. The browser that made the change gets a fresh session cookie and stays signed in; an API client that used a Bearer token has to sign in again.
- **Subtitle:** a short line such as a role or position. It is stored where WinterCMS keeps it, under the `subtitle` key of the jsonable `backend_users.metadata` column, so rows copied from WinterCMS keep their subtitles and other metadata keys survive a save. Code reads it with `cabana.BackendUser.Subtitle`, which treats NULL, `[]` and malformed metadata as no subtitle. `GET <prefix>/api/v1/auth/me` returns it as `subtitle`.
- **Avatar:** `POST <prefix>/api/v1/account/avatar` takes one multipart part named `file_data`. Only jpg, jpeg, png, gif and webp images that pass the image check are accepted, up to `cabana.AvatarMaxBytes` (2 MiB) and within `http.body_limits.upload_bytes`; anything else answers 422 on `avatar`, and a body over the cap answers 413. The file is stored under a name the server generates, never the uploaded file name. An upload replaces the previous avatar: the old row is deleted with the new one's save and its files right after. `DELETE <prefix>/api/v1/account/avatar` removes the avatar and answers 200 also when there is none. The avatar is a public attachOne in `system_files` with attachment type `Backend\Models\User` (`cabana.BackendUserMorphType`) and field `avatar` (`cabana.BackendUserAvatarField`), exactly as WinterCMS stores it, so avatar rows copied from WinterCMS work as they are. `GET .../auth/me` returns its `url` and a 250x250 crop `thumb_url`, and the top bar shows it in place of the initials.
- **API tokens:** a card below the profile form (never inside it — the page Save does not touch tokens) lists, creates and revokes only the signed-in administrator's own tokens. Creating asks for a name, an expiry (`30d`, `90d`, `365d` or `never`) and scopes from `GET /account/tokens/scopes`; the plaintext is shown once in the dialog for copying, then cleared. Revoke asks for confirmation. A "Show revoked tokens" toggle reloads the list with revoked rows. See [API tokens](#api-tokens) for the wire contract and scopes.
Plugins that show an administrator, such as an author box, read the subtitle with `cabana.BackendUser.Subtitle` and the picture with `cabana.BackendUserAvatar`, which returns the avatar file (or nil) for an administrator id.
## Managing administrators
The application binary has two commands for operators:
The application binary has commands for operators:
```sh
./bin/acme admin:create --email admin@example.com --superuser
./bin/acme admin:reset-password admin@example.com
TOKEN=$(./bin/acme admin:token:create admin@example.com --name agent --scope blog)
```
Both commands ask for the password at a prompt that does not echo it. In a script, pipe it on stdin so it never appears in the process list or the shell history:
`admin:create` and `admin:reset-password` ask for the password at a prompt that does not echo it. In a script, pipe it on stdin so it never appears in the process list or the shell history:
```sh
printf '%s\n' "$ADMIN_PASSWORD" | ./bin/acme admin:create --email admin@example.com --superuser
@@ -103,4 +162,4 @@ printf '%s\n' "$ADMIN_PASSWORD" | ./bin/acme admin:create --email admin@example.
`--password` is still accepted but deprecated: the command prints a warning, because the value is visible to other users in the process list and stays in the shell history.
`admin:create` creates an activated administrator; `--login` defaults to the lower-cased email and `--role <code>` assigns a role. It refuses a login or email that matches another administrator's login or email in either field, because a sign-in identifier that matches two administrators is answered like a wrong password. `admin:reset-password` takes a login or an email, sets the password and revokes every token issued before the reset. Passwords are hashed with bcrypt at `admin.password.bcrypt_cost`, so hashes copied from a WinterCMS database keep working.
`admin:create` creates an activated administrator; `--login` defaults to the lower-cased email and `--role <code>` assigns a role. It refuses a login or email that matches another administrator's login or email in either field, because a sign-in identifier that matches two administrators is answered like a wrong password. `admin:reset-password` takes a login or an email, sets the password and revokes every JWT issued before the reset (personal API tokens stay valid). Passwords are hashed with bcrypt at `admin.password.bcrypt_cost`, so hashes copied from a WinterCMS database keep working. The four `admin:token:*` commands are listed under [Admin accounts](../console/setup-and-maintenance.md#admin-accounts).

View File

@@ -45,6 +45,10 @@ During development you often work from the application directory with `summer` a
| `summer key:generate` | `./bin/acme key:generate` |
| `summer admin:create` | `./bin/acme admin:create` |
| `summer admin:reset-password` | `./bin/acme admin:reset-password` |
| `summer admin:token:create` | `./bin/acme admin:token:create` |
| `summer admin:token:list` | `./bin/acme admin:token:list` |
| `summer admin:token:revoke` | `./bin/acme admin:token:revoke` |
| `summer admin:token:scopes` | `./bin/acme admin:token:scopes` |
`summer` parses no flags of these commands: every flag and argument, including `--help`, goes to the binary unchanged, and the binary's input is your terminal, so the password prompts of `admin:create` and `admin:reset-password` work as usual:

View File

@@ -72,16 +72,21 @@ From the application directory, `summer serve --addr 127.0.0.1:8123` passes the
| 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 token issued before the reset. |
| `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:revoke` | `<id>` | Soft-revokes any user's active token by id. |
| `admin:token:scopes` | none | Lists framework and plugin scopes available for tokens. |
```sh
./bin/acme admin:create --email admin@example.com --superuser
./bin/acme admin:reset-password admin@example.com
TOKEN=$(./bin/acme admin:token:create admin@example.com --name agent --scope blog)
```
Both commands prompt for the password without echoing it. In a script, pipe it on stdin, for example `printf '%s\n' "$ADMIN_PASSWORD" | ./bin/acme admin:create --email admin@example.com --superuser`. The `--password` flag still works but is deprecated and prints a warning, because the value stays in your shell history and is visible in the process list. The admin is described in [cabana](../../modules/cabana/README.md).
`admin:create` and `admin:reset-password` prompt for the password without echoing it. In a script, pipe it on stdin, for example `printf '%s\n' "$ADMIN_PASSWORD" | ./bin/acme admin:create --email admin@example.com --superuser`. The `--password` flag still works but is deprecated and prints a warning, because the value stays in your shell history and is visible in the process list. The admin and personal API tokens are described in [cabana](../../modules/cabana/README.md) and [Users and permissions](../backend/users-and-permissions.md#api-tokens).
From the application directory, `summer admin:create --email admin@example.com --superuser` runs the same command on the built binary, prompt included.
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`.

View File

@@ -3,6 +3,11 @@ plugin:
description: A simple blog.
permissions:
access_posts: Manage blog posts
scopes:
blog: Blog
blog_description: Full access to blog admin APIs.
blog_read: Blog (read)
blog_read_description: Read-only access to blog admin APIs.
posts:
title: Posts
post: Post

View File

@@ -0,0 +1,26 @@
package blog
import "git.golem15.com/golem15/summercms/modules/pact"
var _ pact.HasTokenScopes = (*Plugin)(nil)
// TokenScopes registers personal API token scopes for the blog plugin.
func (p *Plugin) TokenScopes() []pact.TokenScope {
all := []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"}
return []pact.TokenScope{
{
Name: "blog",
Label: "acme.blog::lang.scopes.blog",
Description: "acme.blog::lang.scopes.blog_description",
Permissions: []string{"acme.blog.*"},
Methods: all,
},
{
Name: "blog:read",
Label: "acme.blog::lang.scopes.blog_read",
Description: "acme.blog::lang.scopes.blog_read_description",
Permissions: []string{"acme.blog.*"},
Methods: []string{"GET", "HEAD"},
},
}
}

View File

@@ -82,6 +82,7 @@ A WinterCMS plugin overrides `register*` methods of `PluginBase`. A SummerCMS pl
| `pact.HasLang`, `pact.HasLangOverrides` | Translations, and overrides of other namespaces. |
| `pact.HasMailTemplates` | Mail templates and layouts. |
| `pact.HasPermissions`, `pact.HasNavigation`, `pact.HasSettings` | Backend permissions, navigation and settings screens. |
| `pact.HasTokenScopes` | Personal API token scopes (`pact.TokenScope`) compiled at admin boot. |
| `pact.HasAdminControllers` | Admin controllers built from `fields.yaml` and `columns.yaml`. |
| `pact.HasModels` | The plugin's GORM models. No framework package reads it yet. |

View File

@@ -62,6 +62,8 @@ Logging out is blacklisting the token's `jti`. Setting a user's `TokensValidAfte
A guard implements `bouncer.Guard`: it turns a request into a principal or an error. `bouncer.NewJWTGuard` is the frontend guard. It reads the bearer token, then any cookie names you give it, verifies the token, checks the blacklist and the user's cutoff, and loads the user through your `bouncer.UserProvider`. On failure it answers 401 with `{"error":true,"message":...}`. `bouncer.NewBackendJWTGuard` is the same for the admin audience.
The admin API's `backend` guard is composite: an `Authorization: Bearer` value that starts with `g15_` is looked up as a personal API token (see [API tokens](../backend/users-and-permissions.md#api-tokens)); every other request uses the unchanged JWT path (Bearer JWT, then the admin cookie). A `g15_` value is never read from a cookie or the query string.
Register guards in a `bouncer.Registry` under a name, and turn one into middleware with `bouncer.Registry.Middleware`. The middleware stores the principal on the context, where handlers read it with `bouncer.User`:
```go src=modules/bouncer/example_test.go#ExampleNewJWTGuard
@@ -103,6 +105,37 @@ To protect routes, return that middleware from `pact.HasMiddleware` under a name
A guard that resolves more than a user, such as an API token record, implements `bouncer.CredentialGuard`; the middleware stores that record too, and handlers read it with `bouncer.Credential`.
### Accepting admin tokens on plugin routes
Plugin routes that should accept an admin JWT or a personal API token call `cabana.ResolveBackend`. It reads only the Authorization Bearer header (never the admin cookie), applies the token's method filter, and for a scoped token requires a method-allowed scope that is full or matches every cover code you pass. On success it returns the request with `bouncer.WithUser` (and `bouncer.WithCredential` for a token). Failures are `cabana.ErrUnauthenticated` or `cabana.ErrTokenScope`. Routes registered before `cabana.Activate` must call it per request, because the resolver is published at activation.
```go src=modules/cabana/example_tokens_test.go#ExampleResolveBackend
handler := func(app *backpack.App, w http.ResponseWriter, r *http.Request) {
_, _, err := cabana.ResolveBackend(app, r, "acme.blog.*")
if err != nil {
if errors.Is(err, cabana.ErrTokenScope) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
fmt.Fprint(w, `{"error":"Insufficient permissions"}`)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprint(w, `{"error":"Authentication required"}`)
return
}
w.WriteHeader(http.StatusOK)
}
rec := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/_blog/api/v1/posts", nil)
r.Header.Set("Authorization", "Bearer unused")
handler(nil, rec, r)
fmt.Println(rec.Code, rec.Body.String())
// Output:
// 401 {"error":"Authentication required"}
```
## Passwords
`bouncer.HashPassword` hashes with bcrypt at the cost you give it, `bouncer.CheckPassword` compares in constant time, and `bouncer.NeedsRehash` reports a hash made below your configured cost. WinterCMS stores bcrypt hashes too, so existing passwords keep working:

View File

@@ -251,6 +251,11 @@ plugin:
description: A simple blog.
permissions:
access_posts: Manage blog posts
scopes:
blog: Blog
blog_description: Full access to blog admin APIs.
blog_read: Blog (read)
blog_read_description: Read-only access to blog admin APIs.
posts:
title: Posts
post: Post

View File

@@ -4,10 +4,22 @@ Per-instance application container that holds the configuration, a typed service
`import "git.golem15.com/golem15/summercms/modules/backpack"`
**Name:** The one bag you pack for the beach; it carries everything the application needs: configuration, services, events and plugins.
## Overview
`backpack` plays the role of the Laravel service container that WinterCMS plugins reach through `App::make` and singleton bindings, without any process-global state: every `backpack.App` is independent, so tests and multiple instances in one process do not interfere. The generated `main` of an application loads configuration with [compass](../compass/README.md), creates the container with `backpack.New`, and hands it to [party](../party/README.md), which passes it to every plugin's Register and Boot. `backpack` deliberately does not import `party`, which keeps the dependency graph acyclic.
## Role in the framework
The root of the framework. It depends only on `compass` (configuration) and `festival` (events), and almost every other module (`lagoon`, `conga`, `cabana`, `surf`, `party`, `phrasebook`, `postcard`, `lighthouse`, `flare`, `beachcomber`) receives the application through it. It is the Go counterpart of Laravel's service container: one value handed to every plugin, with typed lookups instead of string keys. It stays its own module, separate from `party`, so the plugin registry can depend on it without an import cycle.
Use cases:
- Reach shared services from a plugin (the database connection, the blob bucket, the translator) without globals.
- Publish a plugin's own service once at Register time so other plugins can look it up by type.
- Read configuration and fire or listen to events from anywhere that holds the application.
## Features
- `backpack.New` wires a container around a loaded `*compass.Config`: `backpack.App.Config`, a fresh service registry in `backpack.App.Services` and a new [festival](../festival/README.md) bus in `backpack.App.Events`.

View File

@@ -6,6 +6,8 @@ Search index sync for GORM models: after-commit upserts and deletes through a pl
`import _ "git.golem15.com/golem15/summercms/modules/beachcomber/typesense"`
**Name:** A beachcomber searches the shore for finds; this module keeps the search index in step so content can be found.
## Overview
beachcomber is the SummerCMS counterpart of Laravel Scout as WinterCMS applications use it with `queue=false`. A model opts in by implementing `beachcomber.Searchable`. After a create, update or delete of such a model commits, the row is reloaded by primary key and its document is upserted into the engine's index, or removed from it. Sync runs inline in the writing goroutine, bounded by the engine's request timeout, and is never fatal: a failure is logged and the write stays committed.
@@ -14,6 +16,16 @@ The package itself knows no search server. An engine package registers itself fr
`beachcomber.From` builds the app-scoped `beachcomber.Service` on first use and publishes it on the app. It installs the sync GORM callbacks through `lagoon.OnDatabase`, so they reach the production handle even though plugins boot before `serve` publishes the database. The application installs a `beachcomber.Gate`, its kill-switch, with `beachcomber.Service.SetGate`.
## Role in the framework
A leaf service module on top of `lagoon` and `backpack`; nothing in the framework imports it, so an application that has no search pulls none of it in. The engine is behind an interface, with a Typesense driver in the typesense subpackage, so the sync logic does not depend on one search vendor.
Use cases:
- Keep a search index in step with a model such as a blog post: writes after commit, deletes on delete.
- Switch indexing off in development or tests through the application kill-switch.
- Build reindex tooling on the on-demand sync and the drop and ensure index helpers.
## Features
- Engine selection by `search.driver`: `null` (the default) or a registered engine such as `typesense`. An unknown name is a boot error that lists the registered engines. Third-party engines register with `beachcomber.RegisterEngine` and a `beachcomber.EngineFactory`; a duplicate name panics at init.

View File

@@ -4,10 +4,22 @@ HTTP handler that serves the embedded admin SPA build under a configurable path
`import "git.golem15.com/golem15/summercms/modules/boardwalk"`
**Name:** The walkway along the beach front; it is the path admins take into the admin SPA.
## Overview
`boardwalk` embeds the compiled admin SPA (`dist/`, produced by `npm --prefix admin run build`) into the binary and serves it. The build is path-agnostic: `index.html` carries a placeholder token that the handler replaces once, at construction, with the prefix the admin is mounted under, so one build works at any backend URI. [cabana](../cabana/README.md) mounts it when it activates the admin routes. It stands in for the server-rendered backend layouts of WinterCMS, which the Go port replaces with a single-page app.
## Role in the framework
A small leaf with no framework dependencies; `cabana` is its only importer. It exists on its own because it owns the generated admin SPA bundle in dist/, which is rebuilt and drift-checked separately from Go code, and because its handler is easy to test against an in-memory file system. Application code normally does not import it: `cabana` mounts it under the backend prefix.
Use cases:
- Serve the admin SPA from the application binary with no separate frontend deployment.
- Move the admin to another path by changing the backend URI; the bundle is rewritten for the prefix at boot.
- Serve as the pattern to copy when a plugin embeds its own static site (the embedding and cache headers, not the admin-only security headers).
## Features
- Serves the embedded build under any prefix, rewriting relative asset URLs and the admin base meta in `index.html` for that prefix (`boardwalk.RewriteIndex`).

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -7,8 +7,8 @@
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<link rel="icon" href="./favicon.ico" sizes="48x48" />
<title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-Bqh6wnN4.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DhIJo93I.css">
<script type="module" crossorigin src="./assets/index-wuKo5sc0.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-d9w3cFkl.css">
</head>
<body>
<div id="app"></div>

View File

@@ -4,10 +4,22 @@ Declarative console commands for the `summer` tool and application binaries, ada
`import "git.golem15.com/golem15/summercms/modules/bonfire"`
**Name:** The evening fire where everyone gathers; the command line is where you sit down and fire off commands.
## Overview
bonfire is the console layer of SummerCMS. Plugins and framework modules describe commands as plain `bonfire.Command` values (name, flags, arguments and a run function), and `bonfire.NewRoot` turns a slice of them into a Cobra root command. Commands never touch Cobra directly: they read arguments through `bonfire.Input` and write through `bonfire.Output`, which also provides tables, spinners, progress bars and interactive prompts. It is the counterpart of WinterCMS's artisan console commands (`registerConsoleCommand` and Laravel's `Illuminate\Console\Command` output helpers).
## Role in the framework
A dependency-free base layer for console commands. `pact` uses it so plugins can contribute commands, and `lagoon`, `conga`, `cabana`, `lighthouse`, `flare` and `surf` build their own commands (migrate, workers, serve, admin tools) on it. Keeping it separate means every module describes commands the same way without depending on the CLI binary.
Use cases:
- Add a plugin command such as blog:import with typed arguments and options.
- Ask for confirmation or input interactively and print styled tables and progress.
- Ship operational commands (backfills, health checks) inside the same binary as the application.
## Features
- Command values with a description, positional arguments (`bonfire.Arg`) and string flags (`bonfire.Flag`), collected from plugins or the tool itself.

View File

@@ -4,10 +4,22 @@ Authentication for SummerCMS: HS256 JWT minting, verification and refresh, reque
`import "git.golem15.com/golem15/summercms/modules/bouncer"`
**Name:** Checks IDs at the beach club door; it authenticates requests and turns away the ones without a valid token.
## Overview
bouncer decides who is making a request. Guards (`bouncer.Guard`, `bouncer.CredentialGuard`) turn an `*http.Request` into a `bouncer.Principal`; a `bouncer.Registry` holds named guards that plugins register and turns each one into HTTP middleware that stores the principal on the request context. The JWT side issues and checks HS256 tokens for two audiences, frontend users (`bouncer.AudienceUser`) and admin users (`bouncer.AudienceBackend`), with a refresh flow and a jti blacklist compatible with tokens issued by the PHP jwt-auth library. It is the counterpart of WinterCMS's Auth and BackendAuth facades and the JWT auth layer used by API plugins.
## Role in the framework
A dependency-free security primitive. `surf` uses it for request guards, `cabana` for admin sign-in, and `conga` and `lighthouse` for authenticated workers and realtime tokens. It is separate so authentication code can be reviewed and tested on its own, without the HTTP or admin layers around it.
Use cases:
- Issue and refresh JWTs for a single-page or mobile frontend.
- Protect API routes with a guard and read the authenticated user downstream.
- Revoke tokens on sign-out and hash and check passwords with bcrypt.
## Features
- Token minting with `bouncer.Mint` (frontend audience) and `bouncer.MintAudience` (any audience), each returning the signed token and its random jti.
@@ -17,6 +29,7 @@ bouncer decides who is making a request. Guards (`bouncer.Guard`, `bouncer.Crede
- Named guard registry: `bouncer.Registry.Register` accepts any `bouncer.Guard` or `bouncer.CredentialGuard`; `bouncer.Registry.Middleware` derives middleware that stores the principal (and credential, if any) on the context. Guards that do not implement `bouncer.UnauthorizedWriter` let unauthenticated requests through so later middleware can decide.
- Standalone bearer middleware: `bouncer.Middleware`.
- Context helpers: `bouncer.WithUser` and `bouncer.User` for the principal, `bouncer.WithCredential` and `bouncer.Credential` for the credential behind it (for example an API token record).
- Scope limits for personal API tokens: `bouncer.ScopeLimit` on `bouncer.Principal.Scope` (nil means unrestricted, as for a JWT session). A token guard sets it after filtering the token's scopes by the request method; `Full` means the token acts as the owner on every method (`*`) or on GET/HEAD (`admin:read`).
- Blacklist stores behind `bouncer.BlacklistStore`: `bouncer.MemoryBlacklist` for tests and `bouncer.PostgresBlacklist` for production, which works on a caller-supplied table with `jti`, `expires_at` and `valid_until` columns and rejects unsafe table names.
- Passwords: `bouncer.HashPassword`, `bouncer.CheckPassword` and `bouncer.NeedsRehash` (bcrypt, cost chosen by the caller).
@@ -70,7 +83,9 @@ func Login(secret string) (string, error) {
| Identifier | Description |
|------------|-------------|
| `bouncer.Principal` | The authenticated identity: user ID, locale override, token cutoff and admin permission grants. |
| `bouncer.Principal` | The authenticated identity: user ID, locale override, token cutoff, admin permission grants and optional `Scope`. |
| `bouncer.ScopeLimit` | A personal API token's method-filtered scope: `Full` or one permission-pattern slice per allowed scope. |
| `bouncer.Principal.Scope` | Set only for personal API token principals; nil for JWT sessions. |
| `bouncer.Guard` | Resolves the `bouncer.Principal` for a request. |
| `bouncer.CredentialGuard` | Resolves the principal and its underlying credential in one pass. |
| `bouncer.UnauthorizedWriter` | Optional guard interface for writing its own 401 response. |

View File

@@ -7,10 +7,22 @@ import (
type userKey struct{}
// ScopeLimit restricts a principal to the permission patterns of its personal
// API token. Nil means unrestricted (a JWT session). Only a token guard sets
// Scope, already filtered to scopes whose methods include the request method.
// Full is true for framework scopes that act as the owner on every allowed
// method ("*") or on GET/HEAD ("admin:read"). Otherwise Scopes holds one
// pattern slice per method-allowed scope (D-03 literal reading).
type ScopeLimit struct {
Full bool
Scopes [][]string
}
// Principal is the authenticated identity stored on the request context.
// PreferredLocale empty means no override. TokensValidAfter zero means no cutoff.
// IsSuperuser and PermissionGrants are set only for backend-admin principals.
// A grant ending in ".*" matches permission codes by prefix.
// Scope is set only for personal API token principals.
type Principal struct {
ID uint
MustChangePassword bool
@@ -19,6 +31,7 @@ type Principal struct {
Backend bool `json:"-"`
IsSuperuser bool `json:"-"`
PermissionGrants map[string]bool `json:"-"`
Scope *ScopeLimit `json:"-"`
}
// WithUser stores the verified principal on ctx.

View File

@@ -4,10 +4,22 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
`import "git.golem15.com/golem15/summercms/modules/cabana"`
**Name:** The beach hut where the staff run the beach; it is the admin backend.
## Overview
`cabana` is the SummerCMS counterpart of WinterCMS's backend: controllers with the List, Form and Relation behaviors, their `config_list.yaml`, `config_form.yaml`, `config_filter.yaml` and `config_relation.yaml` files, the model `columns.yaml` and `fields.yaml`, backend users, roles and permissions, settings models and backend navigation. Plugins declare admin controllers through the [pact](../pact/README.md) capability interfaces and embed their YAML; `cabana.Activate` compiles all of it once at boot, fails fast on any schema error, and returns the admin routes that [surf](../surf/README.md) mounts under the admin prefix (`backend.uri`, default `/backend`). The JSON API lives under `<prefix>/api/v1`, and every other path under the prefix serves the admin SPA from [boardwalk](../boardwalk/README.md).
## Role in the framework
The largest module and the admin backend of the framework. It sits on top of `backpack`, `lagoon`, `bouncer`, `party`, `pact`, `phrasebook`, `towel` and `bonfire`, and it mounts `boardwalk` to serve the admin SPA. `surf` wires its routes in. It mirrors the WinterCMS backend: YAML-driven lists, forms, filters and relations compiled once at boot.
Use cases:
- Give a model such as a blog post a full admin screen from fields.yaml and columns.yaml, with no Vue code.
- Add relation managers, filters, bulk actions and settings pages declaratively.
- Add custom form widgets and controller actions that run behind the admin's CSRF and permission checks.
## Features
- Boot-time schema compilation: `cabana.CompileList` and `cabana.CompileForm` read a controller's YAML from the plugin's embedded tree, check that `modelClass` matches the controller's model name, and cache a locale-neutral schema. Each request gets a translated copy (`cabana.ListSchema.Localize`, `cabana.FormSchema.Localize`, `cabana.RelationSchema.Localize`) through [phrasebook](../phrasebook/README.md), with CLDR plural forms for the SPA's messages.
@@ -32,12 +44,13 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
- File uploads: a `type: fileupload` field in `fields.yaml` edits an attachOne or attachMany relation the record model declares through `attach.HasRelations` (its `AttachRelations` method) next to `attach.Owner`. The field accepts WinterCMS's `mode` (`image` or `file`), `fileTypes`, `mimeTypes`, `maxFilesize` (megabytes), `maxFiles` (attachMany only), `imageWidth`, `imageHeight`, `thumbOptions` (only `mode`: `auto`, `exact`, `crop` or `fit`), `useCaption` and `prompt`; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds `http.body_limits.upload_bytes` fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the `X-Session-Key` header (`cabana.SessionKeyHeader`) with every file call and with the save, the server keeps the pending work in `deferred_bindings` against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A retry of the same upload may send `X-Upload-Id` so the server returns the already stored file. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB and answers 413 `payload_too_large` past it; the size, type and image checks run on the server (through `attach.Store`) and answer 422 on the field. A file list (`cabana.FileItem`) carries `url` and `thumb_url` only for a public relation.
- Refusals: a lifecycle hook, a relation hook or a bulk, record, toolbar or widget action that returns a `cabana.ForbiddenError` is answered 403 `forbidden` with the error's `Message` and `Details` (a field name to a list of messages), both translated in the request locale; an empty `Message` stays empty. The surrounding transaction is rolled back. Every other error that is not a `cabana.ValidationError` stays the opaque 500, logged on the server.
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code.
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it).
- Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a JWT guard registered in [bouncer](../bouncer/README.md) as `backend` (a guard another plugin already registered under that name fails `cabana.Activate`), login throttling, token refresh and revocation, and two transports. API clients use a Bearer token; the SPA sends `X-Requested-With: XMLHttpRequest` and receives the token in the HttpOnly, SameSite=Strict cookie named by `cabana.AdminCookieName`. Cookie-authenticated requests that change state must carry that header, which blocks cross-site request forgery.
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics for JWT sessions: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it). When the principal carries a non-full `bouncer.ScopeLimit` (a personal API token), the scope is checked first: one method-allowed scope must match every required code and the owner must hold a required code inside that scope; a superuser with a scoped token is limited to the scope, and an empty requirement list is refused unless the scope is full.
- Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a composite `backend` guard (a guard another plugin already registered under that name fails `cabana.Activate`) that accepts an `Authorization: Bearer` personal API token (`cabana.APITokenPrefix` / `g15_…`) or, otherwise, the unchanged admin JWT path (Bearer JWT, then the admin cookie). Personal tokens never come from a cookie or the query string. Login throttling, token refresh and revocation, and the SPA cookie transport are unchanged. Cookie-authenticated requests that change state must carry `X-Requested-With: XMLHttpRequest`, which blocks cross-site request forgery.
- Personal API tokens: administrators mint scoped tokens stored in `backend_api_tokens` (`cabana.BackendAPIToken`); only the sha256 hash and a four-character hint are kept. Framework scopes are `*` (full access as the owner on every method) and `admin:read` (as the owner on GET and HEAD); plugins add scopes through `pact.HasTokenScopes`. Interactive-only routes (account profile, avatar, token management, refresh and logout) refuse every personal token with `details.reason` `interactive_only`. Routes outside `cabana.Allows` (`GET /field-types`, `POST /markdown/preview`) are gated so only a full scope, or `admin:read` on GET, passes. Plugin routes resolve a backend principal with `cabana.ResolveBackend` (Bearer only; never the admin cookie).
- My account: the signed-in administrator opens `/account` from the user menu and edits their own first name, last name, email and subtitle, and changes their password with the current one (PUT `/account`, body `cabana.AdminAccountRequest`). The row is always the token subject, never a request value, and any key outside the request type (login, role, superuser flag, permissions, activation) is a 422 on that key with nothing written. The subtitle is stored where WinterCMS keeps it, under `subtitle` in the jsonable `backend_users.metadata` column (`cabana.BackendUser.Metadata`), and is read with `cabana.BackendUser.Subtitle`, which treats NULL, `[]` and malformed metadata as an empty subtitle; saving it keeps every other metadata key. A password change ends every other session through `tokens_valid_after` and gives a cookie session a fresh cookie. The same screen uploads and removes the administrator's avatar (POST and DELETE `/account/avatar`): a public attachOne in `system_files` under WinterCMS's morph name (`cabana.BackendUserMorphType`, field `cabana.BackendUserAvatarField`; `cabana.BackendUser` implements `attach.Owner` and `attach.HasRelations`), so avatar rows copied from WinterCMS are picked up as they are. Only jpg, jpeg, png, gif and webp images that pass the image check, of at most `cabana.AvatarMaxBytes` (2 MiB), are stored, under a server-generated name. Plugins read the picture with `cabana.BackendUserAvatar`.
- A consistent JSON envelope for every response: `cabana.WriteData`, `cabana.WriteError` and `cabana.WriteErrorDetails`, typed for documentation as `cabana.Envelope`, `cabana.ListEnvelope`, `cabana.RecordEnvelope` and `cabana.ErrorEnvelope`. A body that cannot be encoded is logged and answered with the generic 500 envelope, never a success status with a truncated body.
- OpenAPI documentation: `cabana.AdminList`, `cabana.AdminCreate` and the other `Admin*` functions have empty bodies and exist only to carry the swag annotations of each admin route.
- Operator commands for creating administrators and resetting their passwords (see CLI commands).
- Operator commands for creating administrators, resetting their passwords and managing personal API tokens (see CLI commands).
### Admin API routes
@@ -45,11 +58,14 @@ All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.cont
| Method and path | Purpose |
|-----------------|---------|
| POST `/auth/login`, POST `/auth/refresh` | Sign in (throttled) and refresh a token. Public. |
| POST `/auth/login`, POST `/auth/refresh` | Sign in (throttled) and refresh a token. Public. Refresh refuses personal API tokens (`interactive_only`). |
| GET `/lang` | The `backend::lang` string bundle for the request locale. Public, so the login screen can load it. |
| POST `/auth/logout`, GET `/auth/me` | Revoke the current token, also when its access lifetime has expired but its refresh window is open, and clear the session cookie; return the signed-in administrator (`cabana.AdminProfile`, with the `subtitle` and, when there is one, the `avatar` URLs as a `cabana.AdminAvatar`). |
| PUT `/account` | Save the signed-in administrator's own first name, last name, email and subtitle and, with `current_password`, a new password (`cabana.AdminAccountRequest`); answers the `cabana.AdminProfile`. Other keys are a 422. The email must be valid, 6 to 255 characters and not equal, ignoring case, another administrator's email or login. A password needs at least 4 characters, at most 72 bytes and an equal `password_confirmation`. Needs any backend session. |
| POST and DELETE `/account/avatar` | Upload the signed-in administrator's avatar (one multipart `file_data` part: a jpg, jpeg, png, gif or webp image of at most 2 MiB; anything else is a 422 on `avatar`, a body over the cap a 413) replacing the previous one, or remove it (also 200 when there is none). Both answer the `cabana.AdminProfile`. Needs any backend session. |
| POST `/auth/logout`, GET `/auth/me` | Revoke the current JWT, also when its access lifetime has expired but its refresh window is open, and clear the session cookie; return the signed-in administrator (`cabana.AdminProfile`, with the `subtitle` and, when there is one, the `avatar` URLs as a `cabana.AdminAvatar`). Logout refuses personal API tokens; `/auth/me` accepts any valid token. |
| PUT `/account` | Save the signed-in administrator's own first name, last name, email and subtitle and, with `current_password`, a new password (`cabana.AdminAccountRequest`); answers the `cabana.AdminProfile`. Other keys are a 422. The email must be valid, 6 to 255 characters and not equal, ignoring case, another administrator's email or login. A password needs at least 4 characters, at most 72 bytes and an equal `password_confirmation`. Interactive session only (personal API tokens are refused). |
| POST and DELETE `/account/avatar` | Upload the signed-in administrator's avatar (one multipart `file_data` part: a jpg, jpeg, png, gif or webp image of at most 2 MiB; anything else is a 422 on `avatar`, a body over the cap a 413) replacing the previous one, or remove it (also 200 when there is none). Both answer the `cabana.AdminProfile`. Interactive session only. |
| GET `/account/tokens`, POST `/account/tokens` | List the caller's personal API tokens (newest first; revoked only with `?revoked=1`) and create one (`cabana.AdminAPITokenRequest`: `name`, `expires`, `scopes`). Create answers 201 with `cabana.AdminAPITokenCreated` (plaintext `token` once). Interactive session only. |
| DELETE `/account/tokens/{id}` | Soft-revoke one of the caller's own active tokens (`cabana.AdminAPIToken`); 404 otherwise. Interactive session only. |
| GET `/account/tokens/scopes` | Scopes the caller may assign (`cabana.AdminAPITokenScope`), including `*`, `admin:read` and overlapping plugin scopes. Interactive session only. |
| GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open; a settings entry's `controller` names the admin controller a link entry opens and is empty for a singleton form. |
| GET `/field-types` | The plugin field types (`pact.HasFormFieldTypes`) sorted by name, each with its `element`, `translatable` flag and the versioned URLs of its `scripts` and `styles` (`cabana.FieldTypesResponse`); an empty list when no plugin registers one. The SPA loads them after sign-in. Needs any backend session. |
| POST `/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload` | Store one multipart part named `file` through the `Upload` of the field's plugin field type and answer 201 `{url}` (`cabana.FieldUploadResponse`). `{id}` 0 is the create form and passes no record; any other id loads the record through the form scope (404 outside it). 403 when the controller does not declare the save the upload belongs to (create for `{id}` 0, which needs the list's `create` toolbar button, update otherwise), as the save routes answer. 404 for a field without a plugin type with `Upload` or whose context does not allow the operation, 413 over `http.body_limits.upload_bytes`, 422 on the field for a malformed body or a `pact.FormFieldError`. The body is read and checked whole before `Upload` runs, so a refused request stores nothing. |
@@ -193,7 +209,12 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.Activate` | Compiles every plugin's admin controllers, settings, navigation and permissions and returns the admin `cabana.Routes`, or nil when there are no controllers. |
| `cabana.Routes` | Guard middleware, mount function and normalized prefix of the admin API and SPA. |
| `cabana.AdminPrefix` | Reads and validates `backend.uri`; `cabana.DefaultAdminPrefix` is the fallback. |
| `cabana.RuntimeCommands` | Returns the `admin:create` and `admin:reset-password` commands. |
| `cabana.RuntimeCommands` | Returns the `admin:create`, `admin:reset-password` and `admin:token:*` commands. |
| `cabana.BackendAPIToken` | GORM model of `backend_api_tokens` (hash and hint only; plaintext is never stored). |
| `cabana.APITokenPrefix` | The `g15_` prefix of a personal API token plaintext. |
| `cabana.ResolveBackend` | Authenticates a request outside the admin middleware from the Authorization Bearer header only (admin JWT or `g15_` token, never the admin cookie), with an optional scope cover check. |
| `cabana.ErrUnauthenticated` / `cabana.ErrTokenScope` | Sentinel errors from `cabana.ResolveBackend`. |
| `cabana.AdminAPIToken` / `cabana.AdminAPITokenScope` / `cabana.AdminAPITokenCreated` / `cabana.AdminAPITokenRequest` | JSON shapes of the account token API. |
| `cabana.CompileList` / `cabana.CompileForm` | Compile a controller's list and form YAML into cached schemas. |
| `cabana.CompileFormWithTypes` | `cabana.CompileForm` that also accepts the plugin field types of one plugin (`[]pact.FormFieldType`), with the boot checks of their aliases, option keys and `CheckOptions`. |
| `cabana.FieldTypeAssets` / `cabana.FieldTypesResponse` | One entry of GET `/field-types` (`name`, `element`, `translatable`, `scripts`, `styles`) and the route's answer. |
@@ -285,20 +306,25 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `http.body_limits.upload_bytes` | none | Read from the HTTP configuration: caps the body of a file upload (together with the field's `maxFilesize` plus 64 KiB), and no fileupload field may declare a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds it. Without it the cap is the field's limit, or 128 MiB. |
| `http.body_limits.default_bytes` | none | Read from the HTTP configuration: caps the JSON bodies of create, update, settings, relation link/unlink, bulk delete, file caption and reorder, and relation child routes (1 MiB when not set; 413 `payload_too_large` past it). |
The backend user, role and token blacklist tables (`backend_users`, `backend_user_roles`, `backend_jwt_blacklist`) are created by `lagoon.BackendAdminMigrations`, which the `migrate` command runs.
The backend user, role, token blacklist and personal API token tables (`backend_users`, `backend_user_roles`, `backend_jwt_blacklist`, `backend_api_tokens`) are created by `lagoon.BackendAdminMigrations`, which the `migrate` command runs.
## CLI commands
Both commands are added to every application binary by the generated `main` and open the database themselves when the application has not.
These commands are added to every application binary by the generated `main` and open the database themselves when the application has not.
| Command | Arguments and flags | Effect |
|---------|---------------------|--------|
| `admin:create` | `--email` (required), `--login` (defaults to the lower-cased email), `--role <code>`, `--superuser`, `--password` (deprecated) | Creates an activated backend administrator. The password is read from a hidden prompt, or from stdin when it 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 token issued before the reset. |
| `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:revoke` | `<id>` | Soft-revokes any user's active token by id. |
| `admin:token:scopes` | none | Lists framework and plugin scopes available for tokens. |
```sh
./bin/acme admin:create --email admin@example.com --superuser
printf '%s\n' "$ADMIN_PASSWORD" | ./bin/acme admin:reset-password admin@example.com
TOKEN=$(./bin/acme admin:token:create admin@example.com --name agent --scope blog)
```
`--password` still works, but it prints a deprecation warning: a value on the command line is visible in the process list and stays in the shell history.

View File

@@ -0,0 +1,306 @@
package cabana
import (
"context"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/phrasebook"
)
// AdminAPITokenScope is one scope on a token or in the picker list.
type AdminAPITokenScope struct {
Name string `json:"name"`
Label string `json:"label"`
Description string `json:"description"`
ReadOnly bool `json:"read_only"`
Known bool `json:"known"`
}
// AdminAPIToken is one personal API token row in list/revoke responses.
type AdminAPIToken struct {
ID uint `json:"id"`
Name string `json:"name"`
Hint string `json:"hint"`
Scopes []AdminAPITokenScope `json:"scopes"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at"`
ExpiresAt *time.Time `json:"expires_at"`
RevokedAt *time.Time `json:"revoked_at"`
Expired bool `json:"expired"`
}
// AdminAPITokenCreated is the create response: every AdminAPIToken field plus
// the plaintext token (shown exactly once).
type AdminAPITokenCreated struct {
AdminAPIToken
Token string `json:"token"`
}
// AdminAPITokenRequest is the POST /account/tokens body.
type AdminAPITokenRequest struct {
Name string `json:"name"`
Expires string `json:"expires"`
Scopes []string `json:"scopes"`
}
var allowedTokenCreateKeys = map[string]bool{"name": true, "expires": true, "scopes": true}
func (s *service) accountTokensList(w http.ResponseWriter, r *http.Request) {
principal := accountPrincipal(w, r)
if principal == nil {
return
}
db, err := s.db()
if err != nil {
slog.Default().ErrorContext(r.Context(), "cabana: account tokens list without a database", "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
showRevoked := false
switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("revoked"))) {
case "1", "true":
showRevoked = true
}
q := db.WithContext(r.Context()).Where("backend_user_id = ?", principal.ID)
if !showRevoked {
q = q.Where("revoked_at IS NULL")
}
var rows []BackendAPIToken
if err := q.Order("created_at DESC").Order("id DESC").Find(&rows).Error; err != nil {
slog.Default().ErrorContext(r.Context(), "cabana: account tokens list", "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
tr := s.translator()
now := time.Now().UTC()
out := make([]AdminAPIToken, 0, len(rows))
for i := range rows {
out = append(out, mapAdminAPIToken(r.Context(), tr, s.reg, &rows[i], now))
}
WriteData(w, http.StatusOK, out, nil)
}
func (s *service) accountTokenCreate(w http.ResponseWriter, r *http.Request) {
principal := accountPrincipal(w, r)
if principal == nil {
return
}
body, err := s.decodeCappedObject(w, r)
if err != nil {
writeRelationError(w, err)
return
}
tr := s.translator()
if details := refusedTokenCreateKeys(r.Context(), tr, body); len(details) > 0 {
writeCRUDError(w, &ValidationError{Details: details})
return
}
name, _ := body["name"].(string)
expires, _ := body["expires"].(string)
var scopes []string
switch v := body["scopes"].(type) {
case []any:
for _, item := range v {
if s, ok := item.(string); ok {
scopes = append(scopes, s)
}
}
case []string:
scopes = v
}
db, err := s.db()
if err != nil {
slog.Default().ErrorContext(r.Context(), "cabana: account token create without a database", "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
now := time.Now().UTC()
plain, row, details, err := issueAPIToken(db, s.reg, principal, name, expires, scopes, now, false)
if err != nil {
slog.Default().ErrorContext(r.Context(), "cabana: account token create", "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
if details != nil {
writeCRUDError(w, &ValidationError{Details: translateTokenDetails(r.Context(), tr, details)})
return
}
created := AdminAPITokenCreated{
AdminAPIToken: mapAdminAPIToken(r.Context(), tr, s.reg, row, now),
Token: plain,
}
WriteData(w, http.StatusCreated, created, nil)
}
func (s *service) accountTokenRevoke(w http.ResponseWriter, r *http.Request) {
principal := accountPrincipal(w, r)
if principal == nil {
return
}
id, err := strconv.ParseUint(r.PathValue("id"), 10, 64)
if err != nil || id == 0 {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return
}
db, err := s.db()
if err != nil {
slog.Default().ErrorContext(r.Context(), "cabana: account token revoke without a database", "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
revokedBy := principal.ID
row, notFound, err := revokeAPIToken(db, principal.ID, uint(id), &revokedBy)
if err != nil {
slog.Default().ErrorContext(r.Context(), "cabana: account token revoke", "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
if notFound {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return
}
WriteData(w, http.StatusOK, mapAdminAPIToken(r.Context(), s.translator(), s.reg, row, time.Now().UTC()), nil)
}
func (s *service) accountTokenScopes(w http.ResponseWriter, r *http.Request) {
principal := accountPrincipal(w, r)
if principal == nil {
return
}
tr := s.translator()
var out []AdminAPITokenScope
if s.reg != nil && s.reg.tokenScopes != nil {
for _, scope := range s.reg.tokenScopes.ordered {
if !scopeOverlaps(s.reg, principal, scope.Name) {
continue
}
out = append(out, mapCompiledScope(r.Context(), tr, scope))
}
}
WriteData(w, http.StatusOK, out, nil)
}
func refusedTokenCreateKeys(ctx context.Context, tr *phrasebook.Translator, body map[string]any) map[string]any {
details := map[string]any{}
for key := range body {
if !allowedTokenCreateKeys[key] {
details[key] = []string{backendMessage(ctx, tr, "myaccount.field_not_editable", "This field cannot be changed.")}
}
}
return details
}
func translateTokenDetails(ctx context.Context, tr *phrasebook.Translator, details map[string]any) map[string]any {
out := map[string]any{}
for field, raw := range details {
keys, _ := raw.([]string)
msgs := make([]string, 0, len(keys))
for _, key := range keys {
if name, ok := strings.CutPrefix(key, "api_tokens.scope_unavailable|"); ok {
msgs = append(msgs, backendMessageParams(ctx, tr, "api_tokens.scope_unavailable", "The scope :name is not available to you.", map[string]string{"name": name}))
continue
}
switch key {
case "api_tokens.name_required":
msgs = append(msgs, backendMessage(ctx, tr, key, "Enter a name for the token."))
case "api_tokens.name_too_long":
msgs = append(msgs, backendMessage(ctx, tr, key, "The name may have at most 100 characters."))
case "api_tokens.expires_invalid":
msgs = append(msgs, backendMessage(ctx, tr, key, "Choose a valid expiry."))
case "api_tokens.scopes_required":
msgs = append(msgs, backendMessage(ctx, tr, key, "Choose at least one scope."))
default:
msgs = append(msgs, backendMessage(ctx, tr, key, key))
}
}
out[field] = msgs
}
return out
}
func backendMessageParams(ctx context.Context, tr *phrasebook.Translator, key, fallback string, params map[string]string) string {
full := "backend::lang." + key
if tr != nil && tr.Has(full) {
if s := tr.Get(ctx, full, params); s != "" && s != full {
return s
}
}
msg := fallback
for k, v := range params {
msg = strings.ReplaceAll(msg, ":"+k, v)
}
return msg
}
func mapAdminAPIToken(ctx context.Context, tr *phrasebook.Translator, reg *Registry, row *BackendAPIToken, now time.Time) AdminAPIToken {
stored := []string{}
if row.Scopes.Valid {
stored = row.Scopes.Get()
}
scopes := make([]AdminAPITokenScope, 0, len(stored))
for _, name := range stored {
scopes = append(scopes, mapStoredScope(ctx, tr, reg, name))
}
expired := row.ExpiresAt != nil && !row.ExpiresAt.After(now) && row.RevokedAt == nil
return AdminAPIToken{
ID: row.ID,
Name: row.Name,
Hint: row.TokenHint,
Scopes: scopes,
CreatedAt: row.CreatedAt,
LastUsedAt: row.LastUsedAt,
ExpiresAt: row.ExpiresAt,
RevokedAt: row.RevokedAt,
Expired: expired,
}
}
func mapStoredScope(ctx context.Context, tr *phrasebook.Translator, reg *Registry, name string) AdminAPITokenScope {
if reg != nil && reg.tokenScopes != nil {
if scope, ok := reg.tokenScopes.byName[name]; ok {
return mapCompiledScope(ctx, tr, scope)
}
}
return AdminAPITokenScope{Name: name, Label: name, Description: "", ReadOnly: false, Known: false}
}
func mapCompiledScope(ctx context.Context, tr *phrasebook.Translator, scope *compiledTokenScope) AdminAPITokenScope {
label := scope.Label
desc := scope.Description
if strings.Contains(label, "::") {
label = resolveScopePhrase(ctx, tr, label, label)
}
if strings.Contains(desc, "::") {
desc = resolveScopePhrase(ctx, tr, desc, desc)
}
readOnly := true
for m := range scope.Methods {
if m != "GET" && m != "HEAD" {
readOnly = false
break
}
}
if scope.Full && scope.Name == scopeAll {
readOnly = false
}
return AdminAPITokenScope{
Name: scope.Name,
Label: label,
Description: desc,
ReadOnly: readOnly,
Known: true,
}
}
func resolveScopePhrase(ctx context.Context, tr *phrasebook.Translator, key, fallback string) string {
if tr != nil && tr.Has(key) {
if s := tr.Get(ctx, key, nil); s != "" && s != key {
return s
}
}
return fallback
}

View File

@@ -7,7 +7,7 @@ package cabana
// @securityDefinitions.apikey BackendBearer
// @in header
// @name Authorization
// @description Backend admin bearer token. Send "Bearer {access_token}".
// @description Backend admin access token or personal API token (g15_…). Send "Bearer {token}". Account and token management routes refuse personal API tokens.
// Admin API annotations. scripts/check-admin-openapi.sh reads them with swag
// to produce admin/openapi/admin.json, the document the SPA's TypeScript types
@@ -204,7 +204,7 @@ func AdminMe() {}
// AdminAccountUpdate documents PUT /account.
//
// @Summary Update my account
// @Description Saves the signed-in admin's own first name, last name, email and subtitle; the row is the token subject, never a request value. Only the AdminAccountRequest keys are accepted: any other key (login, role_id, is_superuser, permissions, is_activated, id) is a 422 on that key and nothing is written. The email must be valid, 6 to 255 characters, and not equal (ignoring case) the email or login of another admin. A non-empty password needs current_password, at least 4 characters, at most 72 bytes and an equal password_confirmation; it ends every other session, and a cookie session receives a fresh cookie.
// @Description Saves the signed-in admin's own first name, last name, email and subtitle; the row is the token subject, never a request value. Only the AdminAccountRequest keys are accepted: any other key (login, role_id, is_superuser, permissions, is_activated, id) is a 422 on that key and nothing is written. The email must be valid, 6 to 255 characters, and not equal (ignoring case) the email or login of another admin. A non-empty password needs current_password, at least 4 characters, at most 72 bytes and an equal password_confirmation; it ends every other session, and a cookie session receives a fresh cookie. Personal API tokens are refused (403 interactive_only).
// @Tags admin
// @Accept json
// @Produce json
@@ -218,6 +218,64 @@ func AdminMe() {}
// @Router /account [put]
func AdminAccountUpdate() {}
// AdminAccountTokens documents GET /account/tokens.
//
// @Summary List my API tokens
// @Description Lists the signed-in admin's personal API tokens, newest first. Revoked tokens are hidden unless revoked=1. Personal API tokens are refused (403 interactive_only).
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param revoked query string false "Include revoked tokens when 1 or true"
// @Success 200 {object} Envelope[[]AdminAPIToken]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Router /account/tokens [get]
func AdminAccountTokens() {}
// AdminAccountTokenCreate documents POST /account/tokens.
//
// @Summary Create an API token
// @Description Creates a personal API token for the signed-in admin. The plaintext token is returned exactly once in the 201 body. Personal API tokens are refused (403 interactive_only).
// @Tags admin
// @Accept json
// @Produce json
// @Security BackendBearer
// @Param body body AdminAPITokenRequest true "Name, expiry and scopes"
// @Success 201 {object} Envelope[AdminAPITokenCreated]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /account/tokens [post]
func AdminAccountTokenCreate() {}
// AdminAccountTokenRevoke documents DELETE /account/tokens/{id}.
//
// @Summary Revoke an API token
// @Description Soft-revokes one of the signed-in admin's active personal API tokens. Personal API tokens are refused (403 interactive_only).
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param id path int true "Token id"
// @Success 200 {object} Envelope[AdminAPIToken]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /account/tokens/{id} [delete]
func AdminAccountTokenRevoke() {}
// AdminAccountTokenScopes documents GET /account/tokens/scopes.
//
// @Summary List available API token scopes
// @Description Lists framework and plugin scopes the signed-in admin may pick when creating a token. Personal API tokens are refused (403 interactive_only).
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Success 200 {object} Envelope[[]AdminAPITokenScope]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Router /account/tokens/scopes [get]
func AdminAccountTokenScopes() {}
// AdminAccountAvatarUpload documents POST /account/avatar.
//
// @Summary Upload my avatar

View File

@@ -0,0 +1,290 @@
package cabana
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/lagoon"
"gorm.io/gorm"
)
// APITokenPrefix is the fixed plaintext prefix of every personal admin API token.
const APITokenPrefix = "g15_"
// BackendAPIToken is one personal admin API token row. Only the sha256 hex of
// the plaintext and a four-character hint are stored; the plaintext is never
// persisted.
type BackendAPIToken struct {
ID uint `gorm:"column:id;primaryKey" json:"id"`
BackendUserID uint `gorm:"column:backend_user_id" json:"backend_user_id"`
Name string `gorm:"column:name" json:"name"`
TokenHash string `gorm:"column:token_hash" json:"-"`
TokenHint string `gorm:"column:token_hint" json:"hint"`
Scopes lagoon.Jsonable[[]string] `gorm:"column:scopes" json:"scopes"`
ExpiresAt *time.Time `gorm:"column:expires_at" json:"expires_at"`
LastUsedAt *time.Time `gorm:"column:last_used_at" json:"last_used_at"`
RevokedAt *time.Time `gorm:"column:revoked_at" json:"revoked_at"`
RevokedBy *uint `gorm:"column:revoked_by" json:"revoked_by"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
// TableName is the framework-owned personal API token table.
func (BackendAPIToken) TableName() string { return "backend_api_tokens" }
func mintAPIToken() (plain, hash, hint string, err error) {
var b [20]byte
if _, err = rand.Read(b[:]); err != nil {
return "", "", "", err
}
plain = APITokenPrefix + hex.EncodeToString(b[:])
hash = hashAPIToken(plain)
hint = plain[len(plain)-4:]
return plain, hash, hint, nil
}
func hashAPIToken(plain string) string {
sum := sha256.Sum256([]byte(plain))
return hex.EncodeToString(sum[:])
}
func wellFormedAPIToken(v string) bool {
if len(v) != len(APITokenPrefix)+40 || !strings.HasPrefix(v, APITokenPrefix) {
return false
}
for _, c := range v[len(APITokenPrefix):] {
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
return false
}
}
return true
}
// cleanedTokenRequest is the validated create payload.
type cleanedTokenRequest struct {
Name string
ExpiresAt *time.Time
Scopes []string
}
// validateTokenRequest applies D-08/D-17 name, expiry and scope rules with no
// database access. details maps field names to message keys under
// backend::lang (for example api_tokens.name_required). cli enables YYYY-MM-DD
// expiry dates (00:00 UTC, must be after now).
func validateTokenRequest(reg *Registry, owner *bouncer.Principal, name, expires string, scopes []string, now time.Time, cli bool) (cleanedTokenRequest, map[string]any) {
details := map[string]any{}
name = strings.TrimSpace(name)
if name == "" {
details["name"] = []string{"api_tokens.name_required"}
} else if len([]rune(name)) > 100 {
details["name"] = []string{"api_tokens.name_too_long"}
}
expiresAt, expKey := parseTokenExpiry(expires, now, cli)
if expKey != "" {
details["expires"] = []string{expKey}
}
cleanedScopes, scopeDetails := validateTokenScopes(reg, owner, scopes)
for k, v := range scopeDetails {
details[k] = v
}
if len(details) > 0 {
return cleanedTokenRequest{}, details
}
return cleanedTokenRequest{Name: name, ExpiresAt: expiresAt, Scopes: cleanedScopes}, nil
}
func parseTokenExpiry(expires string, now time.Time, cli bool) (*time.Time, string) {
expires = strings.TrimSpace(expires)
if expires == "" {
expires = "90d"
}
switch expires {
case "30d":
t := now.UTC().Add(30 * 24 * time.Hour)
return &t, ""
case "90d":
t := now.UTC().Add(90 * 24 * time.Hour)
return &t, ""
case "365d":
t := now.UTC().Add(365 * 24 * time.Hour)
return &t, ""
case "never":
return nil, ""
}
if cli {
if t, err := time.Parse("2006-01-02", expires); err == nil {
t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
if !t.After(now.UTC()) {
return nil, "api_tokens.expires_invalid"
}
return &t, ""
}
}
return nil, "api_tokens.expires_invalid"
}
func validateTokenScopes(reg *Registry, owner *bouncer.Principal, scopes []string) ([]string, map[string]any) {
details := map[string]any{}
if len(scopes) == 0 {
details["scopes"] = []string{"api_tokens.scopes_required"}
return nil, details
}
for _, s := range scopes {
if s == scopeAll {
return []string{scopeAll}, nil
}
}
seen := map[string]bool{}
out := make([]string, 0, len(scopes))
for _, name := range scopes {
name = strings.TrimSpace(name)
if name == "" || seen[name] {
continue
}
seen[name] = true
if reg == nil || reg.tokenScopes == nil || reg.tokenScopes.byName[name] == nil || !scopeOverlaps(reg, owner, name) {
details["scopes"] = []string{"api_tokens.scope_unavailable|" + name}
return nil, details
}
out = append(out, name)
}
if len(out) == 0 {
details["scopes"] = []string{"api_tokens.scopes_required"}
return nil, details
}
return out, nil
}
// issueAPIToken validates and inserts a token for the owner. On validation
// failure details is non-nil and plain/row are zero. The plaintext is returned
// exactly once on success.
func issueAPIToken(db *gorm.DB, reg *Registry, owner *bouncer.Principal, name, expires string, scopes []string, now time.Time, cli bool) (plain string, row *BackendAPIToken, details map[string]any, err error) {
if owner == nil || owner.ID == 0 {
return "", nil, nil, fmt.Errorf("cabana: issue API token: missing owner")
}
cleaned, details := validateTokenRequest(reg, owner, name, expires, scopes, now, cli)
if details != nil {
return "", nil, details, nil
}
plain, row, err = insertAPIToken(db, owner.ID, cleaned.Name, cleaned.Scopes, cleaned.ExpiresAt)
return plain, row, nil, err
}
// insertAPIToken mints and persists a row without validation (tests and the
// validated issueAPIToken path).
func insertAPIToken(db *gorm.DB, ownerID uint, name string, scopes []string, expiresAt *time.Time) (plain string, row *BackendAPIToken, err error) {
if db == nil || ownerID == 0 {
return "", nil, fmt.Errorf("cabana: issue API token: missing database or owner")
}
if name == "" {
name = "token"
}
if len(scopes) == 0 {
return "", nil, fmt.Errorf("cabana: issue API token: scopes required")
}
plain, hash, hint, err := mintAPIToken()
if err != nil {
return "", nil, err
}
row = &BackendAPIToken{
BackendUserID: ownerID,
Name: name,
TokenHash: hash,
TokenHint: hint,
Scopes: lagoon.Jsonable[[]string]{Data: scopes, Valid: true},
ExpiresAt: expiresAt,
}
if err := db.Create(row).Error; err != nil {
return "", nil, err
}
return plain, row, nil
}
// revokeAPIToken soft-revokes the owner's active token. notFound is true when
// no matching active row was updated.
func revokeAPIToken(db *gorm.DB, ownerID, id uint, revokedBy *uint) (row *BackendAPIToken, notFound bool, err error) {
if db == nil || ownerID == 0 || id == 0 {
return nil, true, nil
}
now := time.Now().UTC()
res := db.Model(&BackendAPIToken{}).
Where("id = ? AND backend_user_id = ? AND revoked_at IS NULL", id, ownerID).
Updates(map[string]any{"revoked_at": now, "revoked_by": revokedBy})
if res.Error != nil {
return nil, false, res.Error
}
if res.RowsAffected == 0 {
return nil, true, nil
}
var out BackendAPIToken
if err := db.First(&out, id).Error; err != nil {
return nil, false, err
}
return &out, false, nil
}
// tokenStore looks up usable personal API tokens by hash and stamps last_used_at.
type tokenStore interface {
FindUsableByHash(ctx context.Context, hash string, now time.Time) (*BackendAPIToken, error)
StampLastUsed(ctx context.Context, id uint, now time.Time) error
}
type gormTokenStore struct {
db *gorm.DB
}
func (s gormTokenStore) FindUsableByHash(ctx context.Context, hash string, now time.Time) (*BackendAPIToken, error) {
if s.db == nil {
return nil, fmt.Errorf("cabana: token store has no database")
}
var row BackendAPIToken
err := s.db.WithContext(ctx).
Where("token_hash = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)", hash, now).
Limit(1).
Find(&row).Error
if err != nil {
return nil, err
}
if row.ID == 0 {
return nil, nil
}
return &row, nil
}
func (s gormTokenStore) StampLastUsed(ctx context.Context, id uint, now time.Time) error {
if s.db == nil || id == 0 {
return nil
}
return s.db.WithContext(ctx).Exec(
`UPDATE backend_api_tokens SET last_used_at = ?
WHERE id = ? AND (last_used_at IS NULL OR last_used_at < ?)`,
now, id, now.Add(-time.Minute),
).Error
}
// lastStamp gates last_used_at writes to at most once per minute per token id
// in this process; the SQL UPDATE is additionally conditional.
var lastStamp sync.Map // uint -> time.Time
func stampLastUsed(ctx context.Context, store tokenStore, id uint, now time.Time) {
if store == nil || id == 0 {
return
}
if v, ok := lastStamp.Load(id); ok {
if now.Sub(v.(time.Time)) < time.Minute {
return
}
}
lastStamp.Store(id, now)
if err := store.StampLastUsed(ctx, id, now); err != nil {
slog.Default().WarnContext(ctx, "cabana: token last_used_at stamp failed", "token_id", id, "error", err)
}
}

View File

@@ -16,7 +16,7 @@ import (
// RuntimeCommands returns the operator commands for backend administrators.
func RuntimeCommands(app *backpack.App) []bonfire.Command {
return []bonfire.Command{
cmds := []bonfire.Command{
{
Name: "admin:create",
Description: "Create an activated backend administrator",
@@ -48,6 +48,7 @@ func RuntimeCommands(app *backpack.App) []bonfire.Command {
},
},
}
return append(cmds, tokenRuntimeCommands(app)...)
}
func adminCreate(ctx context.Context, app *backpack.App, in bonfire.Input, out bonfire.Output) error {

View File

@@ -176,8 +176,9 @@ func commandApp(t *testing.T, gdb *gorm.DB) *backpack.App {
}
type flagInput struct {
args []string
flags map[string]string
args []string
flags map[string]string
multiFlags map[string][]string
}
func (f flagInput) Args() []string { return f.args }
@@ -186,6 +187,9 @@ func (f flagInput) Argument(name string) (string, bool) {
if name == "identifier" && len(f.args) > 0 {
return f.args[0], true
}
if name == "id" && len(f.args) > 0 {
return f.args[0], true
}
return "", false
}
@@ -194,7 +198,14 @@ func (f flagInput) Flag(name string) (string, bool) {
return value, ok && value != ""
}
func (f flagInput) Flags(string) []string { return nil }
func (f flagInput) Flags(name string) []string {
if f.multiFlags != nil {
if values, ok := f.multiFlags[name]; ok {
return values
}
}
return nil
}
// TestAdminCreateRejectsCrossFieldCollision pins WR-11: admin:create refuses a
// login that equals another admin's email and an email that equals another

View File

@@ -152,6 +152,9 @@ type Registry struct {
// fieldTypes are the plugin field types of this boot
// (pact.HasFormFieldTypes); nil when no plugin registers one.
fieldTypes *fieldTypeRegistry
// tokenScopes is the compiled personal API token catalog (* , admin:read,
// then plugin scopes in activation order).
tokenScopes *tokenScopeCatalog
}
// Get returns the compiled controller for a D-09 id (vendor.plugin.controller).
@@ -178,15 +181,21 @@ func (r *Registry) Setting(code string) (*CompiledSetting, bool) {
}
// Allows reports whether principal satisfies any of the required permission
// codes, the way Winter's hasAnyAccess does. A nil principal fails. Superusers
// pass. An empty requirement list allows any authenticated principal. Both
// sides may use a wildcard: a grant ending in ".*" covers every code with that
// prefix, and a required code ending in ".*" (or starting with "*") is met by
// any granted code that matches it.
// codes, the way Winter's hasAnyAccess does. A nil principal fails. When the
// principal carries a non-full personal API token scope limit, the scope is
// evaluated first (owner ∩ scope); a superuser with a scoped token is still
// limited by the scope, and an empty requirement list is refused. Otherwise
// superusers pass and an empty requirement list allows any authenticated
// principal. Both sides may use a wildcard: a grant ending in ".*" covers
// every code with that prefix, and a required code ending in ".*" (or
// starting with "*") is met by any granted code that matches it.
func Allows(principal *bouncer.Principal, required []string) bool {
if principal == nil {
return false
}
if principal.Scope != nil && !principal.Scope.Full {
return allowsUnderScope(principal, required)
}
if principal.IsSuperuser || len(required) == 0 {
return true
}

View File

@@ -0,0 +1,41 @@
package cabana_test
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
)
// ExampleResolveBackend shows a plugin handler that accepts an admin JWT or
// personal API token through cabana.ResolveBackend. The resolver reads only
// the Authorization Bearer header (never the admin cookie).
func ExampleResolveBackend() {
handler := func(app *backpack.App, w http.ResponseWriter, r *http.Request) {
_, _, err := cabana.ResolveBackend(app, r, "acme.blog.*")
if err != nil {
if errors.Is(err, cabana.ErrTokenScope) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
fmt.Fprint(w, `{"error":"Insufficient permissions"}`)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprint(w, `{"error":"Authentication required"}`)
return
}
w.WriteHeader(http.StatusOK)
}
rec := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/_blog/api/v1/posts", nil)
r.Header.Set("Authorization", "Bearer unused")
handler(nil, rec, r)
fmt.Println(rec.Code, rec.Body.String())
// Output:
// 401 {"error":"Authentication required"}
}

View File

@@ -1,5 +1,11 @@
package cabana
import (
"time"
"gorm.io/gorm"
)
// AdminAPIRoutes exposes the admin API route inventory (method plus
// prefix-relative path) to the external conformance test.
func AdminAPIRoutes() []string {
@@ -11,3 +17,11 @@ func AdminAPIRoutes() []string {
}
return out
}
// IssueTestAPIToken mints a personal API token for ownerID with the given
// scopes and optional expiry. Used by external Postgres smoke tests before
// the HTTP create route exists.
func IssueTestAPIToken(db *gorm.DB, ownerID uint, scopes []string, expiresAt *time.Time) (plain string, err error) {
plain, _, err = insertAPIToken(db, ownerID, "test", scopes, expiresAt)
return plain, err
}

View File

@@ -74,6 +74,9 @@ func (s *service) markdownPreview(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
return
}
if !tokenRouteGate(principal, w) {
return
}
var body AdminMarkdownPreviewRequest
if err := s.decodeStrictBody(w, r, &body); err != nil {
writeCRUDError(w, err)

View File

@@ -867,6 +867,9 @@ func (s *service) fieldTypesList(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
return
}
if !tokenRouteGate(principal, w) {
return
}
WriteData(w, http.StatusOK, s.fieldTypeAssets(), nil)
}

View File

@@ -125,6 +125,9 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
if err := compileContributions(reg, plugins); err != nil {
return nil, err
}
if err := compileTokenScopes(reg, plugins); err != nil {
return nil, err
}
if app == nil {
return nil, errors.New("cabana: app is nil")
}
@@ -132,6 +135,9 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
if err := validateMessageKeys(reg, tr); err != nil {
return nil, err
}
if err := validateTokenScopeMessageKeys(reg, tr); err != nil {
return nil, err
}
}
guards, ok := app.Lookup[*bouncer.Registry]()
if !ok || guards == nil {
@@ -142,7 +148,12 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
}
bl := adminBlacklist(app)
users := lazyBackendUsers{app: app, reg: reg}
guard := bouncer.NewBackendJWTGuard(secret, users, bl, writeUnauthenticated, AdminCookieName)
jwtGuard := bouncer.NewBackendJWTGuard(secret, users, bl, writeUnauthenticated, AdminCookieName)
var store tokenStore
if gdb != nil {
store = gormTokenStore{db: gdb}
}
guard := newCompositeGuard(jwtGuard, store, users, reg)
// The admin API is only as strong as this guard (audience, secret, user
// provider), so cabana always registers its own and never mounts the API
// behind a guard another plugin already put under the name "backend": a
@@ -156,6 +167,14 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
} else if owner != "summercms.cabana" {
return nil, fmt.Errorf("cabana: backend guard: guard %q is already registered by %s; the admin API needs its own audience-checking guard under that name", "backend", owner)
}
// Cookie-less resolver for plugin routes (Bearer JWT or g15_ token only).
if _, published := app.Lookup[*backendResolver](); !published {
cookieLessJWT := bouncer.NewBackendJWTGuard(secret, users, bl, writeUnauthenticated)
resolverGuard := newCompositeGuard(cookieLessJWT, store, users, reg)
if err := app.Publish(&backendResolver{guard: resolverGuard}); err != nil {
return nil, fmt.Errorf("cabana: backend resolver: %w", err)
}
}
mw, err := guards.Middleware("backend")
if err != nil {
return nil, err
@@ -230,11 +249,11 @@ func (s *service) mount(r pact.Router) {
// Login is exempt from the CSRF header: without it the response is a
// Bearer body and no cookie is set, so a cross-site post gains nothing.
g.Post("/login", s.login, throttle)
g.Post("/refresh", requireAjax(s.refresh))
g.Post("/refresh", interactiveOnly(requireAjax(s.refresh)))
// Logout reads and verifies the token itself (see service.logout), so
// it is mounted outside the backend guard, which rejects an expired
// access token that is still refreshable.
g.Post("/logout", requireAjax(s.logout))
g.Post("/logout", interactiveOnly(requireAjax(s.logout)))
})
// The string bundle is public: the login screen needs it before auth.
r.GroupRaw(api, nil, func(g pact.Router) {
@@ -243,9 +262,14 @@ func (s *service) mount(r pact.Router) {
r.GroupRaw(api, []string{"backend"}, func(g pact.Router) {
g.Get("/auth/me", s.me)
// My account: the signed-in admin edits their own profile.
g.Put("/account", requireAjax(s.accountUpdate))
g.Post("/account/avatar", requireAjax(s.avatarUpload))
g.Delete("/account/avatar", requireAjax(s.avatarRemove))
// interactiveOnly refuses personal API tokens before requireAjax.
g.Put("/account", interactiveOnly(requireAjax(s.accountUpdate)))
g.Post("/account/avatar", interactiveOnly(requireAjax(s.avatarUpload)))
g.Delete("/account/avatar", interactiveOnly(requireAjax(s.avatarRemove)))
g.Get("/account/tokens", interactiveOnly(s.accountTokensList))
g.Post("/account/tokens", interactiveOnly(requireAjax(s.accountTokenCreate)))
g.Get("/account/tokens/scopes", interactiveOnly(s.accountTokenScopes))
g.Delete("/account/tokens/{id}", interactiveOnly(requireAjax(s.accountTokenRevoke)))
g.Get("/navigation", s.navigation)
// Plugin field types (pact.HasFormFieldTypes): the SPA loads their
// scripts and styles once after sign-in.

View File

@@ -540,6 +540,27 @@ func conformCases() []conformCase {
}
return rec
}, into[cabana.Envelope[cabana.AdminProfile]](), nil},
{"POST /account/tokens", 201, "cabana.Envelope-cabana_AdminAPITokenCreated", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
rec := e.send(t, http.MethodPost, "/account/tokens", map[string]any{"name": "conform", "expires": "90d", "scopes": []string{"*"}}, true)
var body cabana.Envelope[cabana.AdminAPITokenCreated]
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.Token == "" || body.Data.ID == 0 {
t.Fatalf("token create body = %s (%v)", rec.Body.String(), err)
}
e.tokenID = body.Data.ID
return rec
}, into[cabana.Envelope[cabana.AdminAPITokenCreated]](), nil},
{"GET /account/tokens", 200, "cabana.Envelope-array_cabana_AdminAPIToken", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/account/tokens", nil, true)
}, into[cabana.Envelope[[]cabana.AdminAPIToken]](), nil},
{"GET /account/tokens/scopes", 200, "cabana.Envelope-array_cabana_AdminAPITokenScope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/account/tokens/scopes", nil, true)
}, into[cabana.Envelope[[]cabana.AdminAPITokenScope]](), nil},
{"DELETE /account/tokens/{id}", 200, "cabana.Envelope-cabana_AdminAPIToken", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
if e.tokenID == 0 {
t.Fatal("token id missing from create case")
}
return e.send(t, http.MethodDelete, fmt.Sprintf("/account/tokens/%d", e.tokenID), nil, true)
}, into[cabana.Envelope[cabana.AdminAPIToken]](), nil},
{"POST /auth/logout", 200, "cabana.Envelope-cabana_AdminLogoutData", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, "/auth/logout", nil, true)
}, into[cabana.Envelope[cabana.AdminLogoutData]](), nil},
@@ -603,6 +624,7 @@ type conformEnv struct {
partID uint
partFileID uint
childKey string
tokenID uint
}
// sendWith sends a raw body with extra headers through the assembled router.

View File

@@ -66,14 +66,15 @@ func TestPhase10CSRF(t *testing.T) {
}
})
}
// refresh, logout, account update, avatar upload and remove, markdown preview, settings put, create, bulk-delete, bulk action,
// widget action, toolbar action, update, delete, record action, link,
// unlink, file
// upload, file reorder, file caption, file remove, relation child
// create, update and delete, pivot update, child file upload, reorder,
// caption and remove, plugin field type upload
if unsafe != 30 {
t.Fatalf("walked %d state-changing routes, want 30: %v", unsafe, router.order)
// refresh, logout, account update, avatar upload and remove, account
// token create and revoke, markdown preview, settings put, create,
// bulk-delete, bulk action, widget action, toolbar action, update,
// delete, record action, link, unlink, file upload, file reorder, file
// caption, file remove, relation child create, update and delete, pivot
// update, child file upload, reorder, caption and remove, plugin field
// type upload
if unsafe != 32 {
t.Fatalf("walked %d state-changing routes, want 32: %v", unsafe, router.order)
}
loginHandler := router.handlers[login]

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