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