From c53ae6a39bb529015ec1c951f22ffa6e770c7178 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sun, 11 Oct 2026 01:05:56 +0200 Subject: [PATCH] docs(16.3): create phase plan --- .planning/ROADMAP.md | 14 +- .planning/STATE.md | 28 +- .../16.3-01-PLAN.md | 498 ++++++++++++++++++ .../16.3-02-PLAN.md | 245 +++++++++ .../16.3-03-PLAN.md | 286 ++++++++++ .../16.3-04-PLAN.md | 238 +++++++++ .../16.3-PATTERNS.md | 468 ++++++++++++++++ .../16.3-RESEARCH.md | 12 +- .../16.3-VALIDATION.md | 29 +- .../COVERAGE.md | 1 + .planning/state.json | 31 +- 11 files changed, 1820 insertions(+), 30 deletions(-) create mode 100644 .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-01-PLAN.md create mode 100644 .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-02-PLAN.md create mode 100644 .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-03-PLAN.md create mode 100644 .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-04-PLAN.md create mode 100644 .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-PATTERNS.md create mode 100644 .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/COVERAGE.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 6257fa4..c2c2c93 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -1044,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:** 4 plans Plans: -- [ ] TBD (run /msd-plan-phase 16.3 to break down) +**Wave 1** +- [ ] 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)* +- [ ] 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 diff --git a/.planning/STATE.md b/.planning/STATE.md index ba4a6f1..7d6b3c1 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,18 +3,17 @@ msd_state_version: "1.0" milestone: v1.0 current_phase: "16.3" current_phase_name: Backend admin personal API tokens with scopes -status: planning -stopped_at: "Phase 16.2 complete (re-verified after 16.2-07, UAT 5/5); Phase 16.3 ready to plan" -last_updated: "2026-10-10T21:39:28.413Z" +status: executing +stopped_at: Phase 16.3 UI-SPEC approved +last_updated: "2026-10-10T23:05:48.336Z" last_activity: 2026-10-10 last_activity_desc: Phase 16.2 complete, next Phase 16.3 -state_head: ed8210132ed77af6a5abdf65949357d4b5b14f8c +state_head: 1821f92bee51a8cb6d9db54eaff098c9f7e7f8ab progress: - total_phases: 35 + total_phases: 40 completed_phases: 13 - total_plans: 158 + total_plans: 162 completed_plans: 154 - percent: 48 gsd_state_version: "1.0" milestone_name: milestone --- @@ -30,9 +29,9 @@ See: .planning/PROJECT.md (updated 2026-09-16) ## Current Position -Phase: 16.3 (Backend admin personal API tokens with scopes) — READY TO PLAN +Phase: 16.3 (Backend admin personal API tokens with scopes) — READY TO EXECUTE Plan: Not started -Status: Ready to plan +Status: Ready to execute Last activity: 2026-10-10 — Phase 16.2 complete, next Phase 16.3 Progress: [█████░░░░░] 48% @@ -236,6 +235,11 @@ Progress: [█████░░░░░] 48% - 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 @@ -751,6 +755,6 @@ Items acknowledged and carried forward from previous milestone close: ## Session Continuity -Last session: 2026-10-10T19:05:40.225Z -Stopped at: Phase 16.2 complete, ready to plan Phase 11 -Resume file: None +Last session: 2026-10-10T22:05:21.929Z +Stopped at: Phase 16.3 UI-SPEC approved +Resume file: .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-UI-SPEC.md diff --git a/.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-01-PLAN.md b/.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-01-PLAN.md new file mode 100644 index 0000000..d322705 --- /dev/null +++ b/.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-01-PLAN.md @@ -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 --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 [] [--revoked], admin:token:revoke 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 --name --scope [--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 [] [--revoked]`; `admin:token:revoke `; `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. + + +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. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.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-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. + + + + + + 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 + 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. + 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 + 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) + 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. + + go vet ./modules/pact ./modules/bouncer ./modules/lagoon ./modules/cabana && go test -count=1 -run '^(TestTokenTracerSmoke|TestPhase09PermissionMatrix|TestPhase09ContractInventory|TestPhase10ConformanceInventory)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 4 + 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). + docker info >/dev/null 2>&1 && go test -count=1 -run '^TestTokenTracerPostgres$' -v ./modules/cabana 2>&1 | grep -- '^--- PASS: TestTokenTracerPostgres' && go test -count=1 -run '^(TestBackendAdminMetadataColumn|TestPhase09MigrationsFreshRollback)$' -v ./modules/lagoon 2>&1 | grep -c '^--- PASS: Test' | grep -qx 2 + 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). + + + - `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. + + 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. + + + + Task 2: Deny by default outside Allows: interactive-only marker, explicit route gates, the published ResolveBackend, and the debounced last_used_at stamp + D-13/D-14: every backend-authenticated route now passes through the composite guard and scope rules; undoing it touches every route classification. + 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 + 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) + 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. + + go vet ./modules/cabana && go test -count=1 -run '^(TestTokenTracerSmoke|TestTokenGatesSmoke|TestPhase09PermissionMatrix|TestPhase09ContractInventory|TestPhase10ConformanceInventory|TestPhase10CSRF)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 6 + 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. + docker info >/dev/null 2>&1 && go test -count=1 -run '^(TestTokenTracerPostgres|TestAdminAuthLifecycle|TestAdminLogoutRevokesExpiredRefreshableToken)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3 + 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. + + + - `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`. + + 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. + + + + Task 3: Account token API (list, create, revoke, scopes) with swag annotations, regenerated OpenAPI and TS types, route inventories and server-side phrase keys + 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 + 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) + 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. + + go vet ./modules/cabana && go test -count=1 -run '^(TestPhase09PermissionMatrix|TestPhase09ContractInventory|TestPhase10ConformanceInventory|TestTokenTracerSmoke|TestTokenGatesSmoke)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 5 && bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck + 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. + docker info >/dev/null 2>&1 && go test -count=1 -run '^(TestAccountTokensSmoke|TestPhase10OpenAPIConformance)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 2 + 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. + + + - `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. + + 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. + + + + Task 4: admin:token:* CLI with party.Activated, summer delegates, and the framework READMEs and docs pages + 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 + 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) + 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. + + go vet ./... && go test -count=1 ./modules/party ./modules/pact && go test -count=1 -run '^(TestDocsTree|TestDelegateCommandsHelp|TestDocsCommandsMirrorGeneratedMain)$' -v ./cmd/summer 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3 && go run ./cmd/summer docs:build --check && go test -short -count=1 -run '^(TestPluginActivates|TestRoutesRegistered)$' ./docs/examples/blog + 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). + docker info >/dev/null 2>&1 && go test -count=1 -run '^(TestTokenCommandsSmoke|TestAdminCreateCommand)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 2 + Non-zero exit: no Docker daemon (record NOT RUN), create printed anything besides the token line, or the existing admin:create test regressed. + + + - `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. + + 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. + + + + + +## 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 | + + + +- 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. + + + +- 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. + + + +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. + diff --git a/.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-02-PLAN.md b/.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-02-PLAN.md new file mode 100644 index 0000000..28372f6 --- /dev/null +++ b/.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-02-PLAN.md @@ -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: " 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. + + +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. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@.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`). + + + + + + 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 + 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 + 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) + 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 `` in AccountView after the `` 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. + + 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 + 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"). + + + - `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 ``. + - `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. + + 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. + + + + Task 2: List states, show-revoked toggle, state and unknown-scope chips, and revoke through the shared confirm + admin/src/components/account/ApiTokensPanel.vue, admin/src/components/ui/ConfirmDialog.vue, admin/tests/account/ApiTokensPanel.test.ts, modules/boardwalk/dist + 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) + 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. + + 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 + 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. + + + - `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. + + The token list handles loading, empty, failure, populated and mixed states, revoked rows on demand, and revoke with the shared confirm and correct toasts. + + + + 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 + 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 + 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) + 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. + + npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-admin-dist.sh && go test -count=1 ./cmd/summer -run TestDocsTree + 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. + + + - `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. + + 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. + + + + + +## 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 | + + + +- 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. + + + +- 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. + + + +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. + diff --git a/.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-03-PLAN.md b/.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-03-PLAN.md new file mode 100644 index 0000000..289f5af --- /dev/null +++ b/.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-03-PLAN.md @@ -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. + + +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. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.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-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. + + + + + + Task 1: Tracer: a journal-scoped token created through the admin API creates a Journal post as its owner + ../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/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 + 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. + + go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 -run '^TestResolveErrorMapping$' -v ./controllers/api/ 2>&1 | grep -- '^--- PASS: TestResolveErrorMapping' && go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/... + 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. + docker info >/dev/null 2>&1 && go -C ../sm-journal-plugin test -count=1 -run '^TestJournalTokenTracer$' -v . 2>&1 | grep -- '^--- PASS: TestJournalTokenTracer' + 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. + + + - `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). + + 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. + + + + 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 + ../sm-journal-plugin/journal_api_token_smoke_test.go, ../sm-journal-plugin/README.md, modules/cabana/example_tokens_test.go, docs/services/authentication.md + ../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) + 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. + + go vet ./modules/cabana && go test -count=1 -run '^ExampleResolveBackend$|^TestDocsTree$' ./modules/cabana ./cmd/summer && go run ./cmd/summer docs:build --check && go -C ../sm-journal-plugin vet ./... + 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. + docker info >/dev/null 2>&1 && go -C ../sm-journal-plugin test -count=1 -run '^(TestJournalTokenTracer|TestJournalTokenExpansion|TestJournalWriteUnauthenticated|TestJournalFeaturedImageUnauthenticated)$' -v . 2>&1 | grep -c '^--- PASS: Test' | grep -qx 4 + 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. + + + - `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. + + 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. + + + + Task 3: Release gate: local checks green, tag chosen, one-way surfaces listed, a human approves the tag and pushes + .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 + 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. + Push summercms master and the proposed annotated tag, push the Journal go.mod bump, and push the host journal gitlink bump now? + 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. + + + + + + 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". + + go vet ./... && go -C ../sm-journal-plugin vet ./... && go test -count=1 -run '^(TestTokenTracerSmoke|TestTokenGatesSmoke|TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestPhase10ConformanceInventory|TestPhase10CSRF)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 6 && go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/... && go test -count=1 ./cmd/summer -run 'TestDocsTree|TestDelegateCommandsHelp' && go run ./cmd/summer docs:build --check && npm --prefix admin test && bash scripts/check-admin-dist.sh && bash scripts/check-admin-openapi.sh --check && echo release-gate-ok + 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. + + + - 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. + + A human chose release-now or docker-first with a named tag, or hold (the plan stops). + + + + Task 4: Release: annotated summercms tag, Journal go.mod bump, host journal gitlink bump, all pushed + 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. + 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. + ../sm-journal-plugin/go.mod, ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal + 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) + 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. + + test -n "$(git ls-remote --tags origin "refs/tags/$RELEASE_TAG")" && test "$(git -C ../sm-journal-plugin rev-parse main)" = "$(git -C ../sm-journal-plugin rev-parse origin/main)" && grep -q "git.golem15.com/golem15/summercms $RELEASE_TAG" ../sm-journal-plugin/go.mod && test "$(git -C ../sm-grzybyfunkcjonalne-app ls-files -s plugins/golem15/journal | cut -d' ' -f2)" = "$(git -C ../sm-journal-plugin rev-parse main)" && test "$(git -C ../sm-grzybyfunkcjonalne-app rev-parse master)" = "$(git -C ../sm-grzybyfunkcjonalne-app rev-parse origin/master)" && echo release-ok + 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. + + + - `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. + + The framework tag, the Journal plugin requiring it and the host bump are on origin. + + + + + +## 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 | + + + +- 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. + + + +- 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. + + + +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. + diff --git a/.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-04-PLAN.md b/.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-04-PLAN.md new file mode 100644 index 0000000..0abcc2b --- /dev/null +++ b/.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-04-PLAN.md @@ -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. + + +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. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@.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. + + + + + + Task 1: Tracer: the deny-by-default contract pinned at every layer (scope rules, guard, every route, SPA plaintext, Journal coverage) + 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 + 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) + 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. + + go vet ./modules/cabana && go test -count=1 -run '^(TestScopeMatch|TestAllowsScope|TestTokenRouteGates)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3 && npm --prefix admin test -- tests/account/ApiTokenCreateDialog.test.ts && go -C ../sm-journal-plugin test -short -count=1 -run '^TestJournalTokenScopes$' -v . 2>&1 | grep -- '^--- PASS: TestJournalTokenScopes' + 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). + docker info >/dev/null 2>&1 && go test -count=1 -run '^TestBackendTokenPostgres$' -v ./modules/cabana 2>&1 | grep -- '^--- PASS: TestBackendTokenPostgres' + Non-zero exit: no Docker daemon (record NOT RUN), or an owner lifecycle, reset, cutoff, revoke, expiry or stamp expectation fails. + + + - `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. + + 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. + + + + 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 + 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 + 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 + + - 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. + + 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. + + go vet ./modules/bouncer ./modules/party ./modules/lagoon ./modules/cabana && go test -count=1 ./modules/party && go test -count=1 -run '^TestPrincipalScopeNotSerialized$' -v ./modules/bouncer 2>&1 | grep -- '^--- PASS: TestPrincipalScopeNotSerialized' && 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>&1 | grep -c '^--- PASS: Test' | grep -qx 16 + 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). + docker info >/dev/null 2>&1 && go test -count=1 -run '^(TestBackendAPITokensMigration|TestBackendAdminMetadataColumn|TestPhase09MigrationsFreshRollback)$' -v ./modules/lagoon 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3 && go test -count=1 -run '^(TestAccountTokens|TestTokenCommands|TestBackendTokenPostgres|TestPhase10OpenAPIConformance)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 4 && go -C ../sm-journal-plugin test -count=1 -run '^(TestJournalTokens|TestJournalTokenTracer|TestJournalTokenExpansion)$' -v . 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3 + 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. + + + - `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. + + Every Go behaviour of the phase has a passing unit test, and the database-backed paths have Postgres tests for the Docker host. + + + + Task 3: Full SPA coverage, the phase gate script, and the validation map + 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 + 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 + + - 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. + + 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). + + npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-phase16.3.sh --self-test && bash scripts/check-phase16.3.sh --all 2>&1 | tail -1 | grep -qx 'Phase 16.3 gate passed' + 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). + + + - `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. + + 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. + + + + + +## 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 | + + + +- 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`. + + + +- 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. + + + +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. + diff --git a/.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-PATTERNS.md b/.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-PATTERNS.md new file mode 100644 index 0000000..a66f06c --- /dev/null +++ b/.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-PATTERNS.md @@ -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 `` 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 `` after the `
` (ends ~:409), before ``. + +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 diff --git a/.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-RESEARCH.md b/.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-RESEARCH.md index 3a975fb..a89d1f8 100644 --- a/.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-RESEARCH.md +++ b/.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-RESEARCH.md @@ -605,13 +605,13 @@ The `UNIQUE` constraint creates the lookup index; no separate hash index is need | 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 +## 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. -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. -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. -4. **Mixed `*` + other scopes on create.** Normalize to `["*"]` (simplest for clients) or 422. Recommendation: normalize. -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. +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 diff --git a/.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-VALIDATION.md b/.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-VALIDATION.md index 7aba582..e548eee 100644 --- a/.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-VALIDATION.md +++ b/.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-VALIDATION.md @@ -38,11 +38,24 @@ created: "2026-10-11" ## Per-Task Verification Map -Filled in by the planner from the RESEARCH.md test map (SC-1 to SC-5). Rows are added per task once PLAN.md files exist. +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-TBD | TBD | TBD | SC-1..SC-6 | T-16.3-* | see RESEARCH.md Security Domain | unit / Postgres / vitest | see RESEARCH.md Validation Architecture | ❌ W0 | ⬜ pending | +| 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* @@ -50,11 +63,12 @@ Filled in by the planner from the RESEARCH.md test map (SC-1 to SC-5). Rows are ## Wave 0 Requirements -- [ ] `modules/cabana/token_*_test.go` — Allows / scope cover / compile / guard / inventory classification (Docker-free) -- [ ] `modules/cabana/account_tokens_test.go` — account token API via the `adminHandler` harness -- [ ] `modules/cabana/commands_test.go` additions — token commands (stdout purity) -- [ ] `admin/tests/components/ApiTokensPanel.test.ts` — UI-SPEC backstops -- [ ] `../sm-journal-plugin/journal_api_tokens_test.go` — Journal token writes via `newPostsEnv` +- [ ] `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 --- @@ -64,6 +78,7 @@ Filled in by the planner from the RESEARCH.md test map (SC-1 to SC-5). Rows are | 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) | --- diff --git a/.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/COVERAGE.md b/.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/COVERAGE.md new file mode 100644 index 0000000..98ab72f --- /dev/null +++ b/.planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/COVERAGE.md @@ -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. diff --git a/.planning/state.json b/.planning/state.json index b2f9808..18ada2e 100644 --- a/.planning/state.json +++ b/.planning/state.json @@ -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", @@ -141,8 +166,8 @@ ], "next": { "command": "/msd:progress --next", - "label": "Advance to the next step (plan phase 11)", - "reason": "Phase 11 of 35 — needs a plan" + "label": "Advance to the next step", + "reason": "Phase 16.3 of 40 · executing" }, - "updated_at": "2026-10-10T21:38:57.992Z" + "updated_at": "2026-10-10T23:05:48.398Z" }