docs(16.3): research phase domain

This commit is contained in:
2026-10-11 00:17:32 +02:00
parent 941ae80b86
commit 9907fa94cd

View File

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