Files
summercms/.planning/phases/07-user-plugin-and-authentication/07-04-PLAN.md
Jakub Zych 57745e32a2 docs(07): create phase plan
Six plans for the user plugin and authentication phase:
- 07-01: bouncer JWT lifecycle, password hashing, I18N-02 locale
  override, lagoon.Validate extensions (summercms.go)
- 07-02: User/Throttle schema, core session loop (login/logout/
  fetch/refresh/register) (fonoteka.go)
- 07-03: account management (forgot/reset, activation, update,
  change-password, avatar, mail) (fonoteka.go)
- 07-04: personal API tokens, me/locale, 423-exempt route-table
  proof (fonoteka.go)
- 07-05: parity evidence recording against the isolated PHP
  instance (fonoteka.go)
- 07-06: full unit coverage and validation sign-off (both repos)

Plan count and scope confirmed at the plan-count checkpoint.
2026-09-22 12:21:15 +02:00

23 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
07-user-plugin-and-authentication 04 execute 3
07-02
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/api_token_manager.go
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/api_token_manager_test.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/token_api_controller.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/token_api_controller_test.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_locale_controller.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_locale_controller_test.go
../fonoteka.go/plugins/golem15/fonoteka/routes.go
../fonoteka.go/plugins/golem15/fonoteka/plugin.go
../fonoteka.go/plugins/golem15/fonoteka/routes_isolation_test.go
../fonoteka.go/parity/manifest.yaml
true
AUTH-03
AUTH-04
I18N-02
truths artifacts key_links
A personal API token is minted with a read|write|ai scope ceiling (server-enforced, a client cannot request a scope outside MINTABLE_SCOPES), listed without leaking the secret, and revoked with an owner-scoped no-leak 404 for a cross-user or missing id (AUTH-03)
GET/PUT me/locale is its own jwt.auth-only group (no inv.must-change-password) and persists preferred_locale (AUTH-04, I18N-02)
inv.must-change-password covers exactly the JWT-authed /_fonoteka/api/v1 group's real routes and never me/locale, proven over the real assembled route table (mirrors Phase 6's mutual-exclusivity test)
locale.from-principal runs after jwt.auth and before inv.must-change-password on every /_fonoteka/api/v1 JWT group, so preferred_locale resolves even while the 423 lock is active (I18N-02)
path provides
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/api_token_manager.go MintPersonalToken/RevokeToken, inv_ + base64url(32 random bytes), sha256 at rest, MintableScopes=[read,write,ai]
path provides
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/token_api_controller.go Store (201, plaintext once)/Index (200, secret-free)/Destroy (200 or 404) on the JWT group
path provides
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_locale_controller.go Show/Update on the jwt.auth-only me/locale group
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/routes.go summercms.go/surf LocaleFromPrincipal (registered locale.from-principal) Use("jwt.auth", "locale.from-principal", "inv.must-change-password") and Use("jwt.auth", "locale.from-principal") on me/locale locale.from-principal
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/api_token_manager.go ../fonoteka.go/plugins/golem15/fonoteka/classes ResolveActiveCollection mint binds the new token to the caller's resolved active collection ResolveActiveCollection
Complete the `golem15.fonoteka`-owned half of Phase 7: production minting/list/revoke for personal API tokens (verification already shipped in Phase 6), the `me/locale` read/write endpoints, and the wiring that proves `RequirePasswordChange` covers exactly the right routes while `I18N-02`'s locale override reaches the locked surface too.

Purpose: Phase 6 shipped the inv_token verifier and inv.scope gate but explicitly deferred minting (P6 D-07) and me/locale/tokens stayed pending manifest entries with empty group builders. This plan fills both in on the SAME auth-group structure Phase 6 already proved. Output: api_token_manager.go, token_api_controller.go, me_locale_controller.go, the real me/locale and tokens route groups, and a route-table test proving the 423 lock's exempt set.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md @.planning/phases/07-user-plugin-and-authentication/07-RESEARCH.md @.planning/phases/07-user-plugin-and-authentication/07-PATTERNS.md @.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-CONTEXT.md @.planning/phases/07-user-plugin-and-authentication/07-02-SUMMARY.md ```go // plugins/golem15/fonoteka/models/api_token.go (Phase 5/6, unchanged) type ApiToken struct { ID uint; UserID uint; Name *string; TokenHash string `json:"-"` Scopes lagoon.Jsonable[[]string]; CollectionIDs lagoon.Jsonable[[]uint] ExpiresAt, RevokedAt, LastUsedAt *time.Time; LastUsedIP *string; OAuthClientID *string CreatedAt, UpdatedAt time.Time } func (t ApiToken) HasScope(scope string) bool func (t ApiToken) IsUsable() bool

// plugins/golem15/fonoteka/classes/active_collection.go (Phase 3/5) func ResolveActiveCollection(ctx context.Context, gdb *gorm.DB, userID uint) (*models.Collection, error)

// plugins/golem15/fonoteka/classes/auth/token_guard.go (Phase 6, unchanged — sha256+hex hashing convention to reuse verbatim) const tokenPrefix = "inv_"

Current `routes.go` (Phase 6) already declares the two groups this plan fills in, as empty builders:
```go
r.Group("/_fonoteka/api/v1", surf.Use("jwt.auth"), func(g pact.Router) {})                              // jwt_locale — becomes me/locale
r.Group("/_fonoteka/api/v1", surf.Use("jwt.auth", "inv.must-change-password"), func(g pact.Router) { g.Get("/genres", handler) })  // already carries genres; this plan adds tokens

surf.RouteInfo{Method, Pattern, PluginID, Middleware, Raw} and (*Router).Routes() []RouteInfo are read-only route-table snapshots (summercms.go/surf/routetable.go) this plan's Task 3 test walks.

Task 1: ApiTokenManager — mint and revoke ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/api_token_manager.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/api_token_manager_test.go ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard.go (same package — sha256+hex hashing convention MUST match exactly between mint and verify), ../fonoteka.go/plugins/golem15/fonoteka/models/api_token.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/auth/ApiTokenManager.php (full file, already read this session — PREFIX, DEFAULT_SCOPES, MINTABLE_SCOPES, mint()/revoke()/hash()), .planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md (C-02, C-03) - Test: `MintPersonalToken(userID, "my token", nil, nil, nil)` returns a `secret` prefixed `inv_`, base64url (no padding) after the prefix, and a `*models.ApiToken` whose `TokenHash` equals `hex.EncodeToString(sha256.Sum256([]byte(secret)))` — the EXACT format `TokenGuard.AuthenticateCredential` already hashes with (Phase 6); a token minted here must immediately verify through the existing `TokenGuard`. - Test: `MintPersonalToken` with `scopes=nil` persists `DefaultScopes` (`["read"]`); with an explicit `["write","ai"]` persists exactly those. - Test: minting twice never produces the same secret or hash (32 bytes of `crypto/rand` per call). - Test: `RevokeToken` sets `revoked_at` to a non-nil, recent timestamp; a revoked token's `IsUsable()` is `false`. Create `classes/auth/api_token_manager.go` (package `auth`, alongside `token_guard.go`): `const MintablePrefix = "inv_"`, `var DefaultScopes = []string{"read"}`, `var MintableScopes = []string{"read", "write", "ai"}`. `func MintPersonalToken(userID uint, name string, scopes []string, expiresAt *time.Time, collectionIDs []uint) (secret string, model *models.ApiToken, err error)`: generate `secret = MintablePrefix + base64.RawURLEncoding.EncodeToString(raw32BytesFromCryptoRand)` (matches PHP's `rtrim(strtr(base64_encode(...),'+/','-_'),'=')` exactly — `base64.RawURLEncoding` already IS unpadded URL-safe base64, no manual `strtr`/`rtrim` needed in Go). Hash with `sha256.Sum256` + `hex.EncodeToString`, IDENTICAL to `token_guard.go`'s existing hashing (do not reimplement — extract a tiny shared `hashToken(secret string) string` helper in this package if `token_guard.go` doesn't already expose one, and have both call sites use it). Build `&models.ApiToken{UserID: userID, Name: &name, TokenHash: hash, Scopes: lagoon.NewJsonable(scopesOrDefault), ExpiresAt: expiresAt}`; if `collectionIDs` is non-empty, dedupe/sort and set `CollectionIDs`. Caller (Task 2) does `db.Create(model)`. `func RevokeToken(db *gorm.DB, token *models.ApiToken) error`: `db.Model(token).Update("revoked_at", time.Now())`. go vet ./... && go test ./plugins/golem15/fonoteka/classes/auth/... -run TestMintPersonalToken -short - `MintPersonalToken`'s returned secret is prefixed `inv_` and the model's `TokenHash` equals `hex.EncodeToString(sha256.Sum256([]byte(secret)))` - A token minted by `MintPersonalToken` immediately authenticates through the existing `TokenGuard.AuthenticateCredential` - `scopes=nil` persists exactly `["read"]`; two calls never produce the same secret - `RevokeToken` sets a non-nil `revoked_at` and `IsUsable()` becomes false MintPersonalToken's hash format is byte-identical to what TokenGuard verifies (proven by a round-trip test); MINTABLE_SCOPES/DefaultScopes match PHP's constants. Task 2: Token CRUD controller, me/locale controller, route groups, locale wiring ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/token_api_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/token_api_controller_test.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_locale_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_locale_controller_test.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go ../fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go (handler-factory, writeJSON/writeOpaque500, bouncer.User(ctx) — this route lives on the group-level jwt.auth, so auth is already resolved by the time the handler runs, UNLIKE golem15.user's per-handler style), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/TokenApiController.php (full file, already read this session), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/MeLocaleController.php (full file, already read this session), .planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md (C-02, C-03, discretion note on 423-exempt grouping), .planning/phases/07-user-plugin-and-authentication/07-RESEARCH.md (Pattern 3 — I18N-02 middleware placement) - Test (Store, 201): `{"name":"laptop"}` mints a token bound to the caller's resolved active collection, returns `{"token":"inv_...","meta":{"id":...,"name":"laptop","scopes":["read"],"collection_ids":[],"collections":[{"id":...,"name":"..."}],"last_used_at":null,"last_used_ip":null,"expires_at":null,"revoked_at":null,"created_at":"..."}}`, status 201; the response NEVER contains `token_hash`. - Test (Store, 422): a scope outside `read|write|ai` (e.g. `"admin"`) is rejected before any row is persisted; a missing `name` is rejected. - Test (Index, 200): returns `{"data":[],"connected_apps_count":}`, newest first, secret-free. - Test (Destroy): the owner revoking their own token returns 200 `{"data":{"revoked":true}}`, and that token subsequently fails `TokenGuard` verification; a cross-user id or a nonexistent id returns 404 `{"error":"Token not found"}` (owner-scoped `WHERE user_id = ?` — a cross-user id must never distinguish "exists but not yours" from "does not exist"). - Test (me/locale Show): returns `{"preferred_locale": ""}` for the JWT-authed caller. - Test (me/locale Update): `{"locale":"en"}` persists and returns `{"preferred_locale":"en"}`; `{"locale":"de"}` (not in `pl,en`) returns 422 `{"error":"Validation failed","errors":{"locale":[...]}}`. - Test (I18N-02 ordering): a caller with `PreferredLocale="pl"` hitting `me/locale` while ALSO carrying `must_change_password=true` still succeeds (locale.from-principal and the me/locale group itself both run before/without `inv.must-change-password`), proving the lock never blocks locale persistence. Create `controllers/api/token_api_controller.go` (package `api`, new subdirectory under `controllers/`, matching the `controllers/api/` layout PATTERNS.md and the `MeLocaleController.php`/`TokenApiController.php` PHP namespace both already imply): `Store(app)` — validate `name` (`required`, max 255 via `lagoon.Validate`'s existing `max` token), and for each element of an optional `scopes` array, reject any value outside `auth.MintableScopes` (manual loop — `lagoon.Validate` has no wildcard-array rule; shape the 422 body as `{"error":"Validation failed","errors":{"scopes":[...]}}`, adjust the exact key during 07-05 fixture recording if the live PHP uses `scopes.0` instead). Resolve `active, err := classes.ResolveActiveCollection(ctx, tx, user.ID)`; call `auth.MintPersonalToken(user.ID, name, scopes, expiresAt, []uint{active.ID})`, `db.Create(model)`. Serialize via a `serializeToken(t *models.ApiToken) map[string]any` helper (id, name, scopes, collection_ids via `t.CollectionIDs.Get()`, collections resolved by `Collection.Where("id IN ?", ids).Select("id,name")`, last_used_at/last_used_ip/expires_at/revoked_at/created_at as ISO8601 or `null`) — `Store` returns `{"token": secret, "meta": serializeToken(model)}`, 201. `Index(app)`: `ApiToken.Where("user_id = ? AND oauth_client_id IS NULL AND revoked_at IS NULL", user.ID).Order("created_at DESC")`, map each through `serializeToken`; separately count `WHERE user_id = ? AND oauth_client_id IS NOT NULL AND revoked_at IS NULL` for `connected_apps_count`. `{"data": [...], "connected_apps_count": n}`, 200 (empty list serializes `[]`, never `null` — use `wire.Slice`). `Destroy(app)`: `ApiToken.Where("user_id = ?", user.ID).First(&token, id)`; not found → `{"error":"Token not found"}`,404. Found: `auth.RevokeToken(db, &token)` (the OAuth-refresh-token revocation chain PHP's controller runs when `oauth_client_id` is set is Phase 8 scope — no Go code mints an oauth-bound token yet, so this branch is unreachable this phase; do not build `OAuthCodeManager` to handle it, just call `RevokeToken` unconditionally). `{"data":{"revoked":true}}`,200.
Create `controllers/api/me_locale_controller.go`: `Show(app)` — `bouncer.User(r.Context())` (group middleware already resolved it), `{"preferred_locale": user.PreferredLocale}`,200. `Update(app)` — validate `locale` (`required|in:pl,en`) via `lagoon.Validate`; failure → `{"error":"Validation failed","errors":{"locale":[...]}}`,422; success → persist, `{"preferred_locale": locale}`,200.

Extend `routes.go`: replace the empty `jwt_locale` group body with `g.Get("/me/locale", api.Show(p.app)); g.Put("/me/locale", api.Update(p.app))` and its `Use(...)` list with `surf.Use("jwt.auth", "locale.from-principal")` (NO `inv.must-change-password` — this group must stay reachable while locked, per D-01/AUTH-04). Add `g.Post("/tokens", api.Store(p.app)); g.Get("/tokens", api.Index(p.app)); g.Delete("/tokens/{id}", api.Destroy(p.app))` with `Where("id", "[0-9]+")` on the delete route, to the EXISTING genres group whose `Use(...)` becomes `surf.Use("jwt.auth", "locale.from-principal", "inv.must-change-password")` (insert `"locale.from-principal"` as the second entry, right after `"jwt.auth"`, before `"inv.must-change-password"` — this is the concrete I18N-02 placement RESEARCH.md's Pattern 3 specifies).

No `plugin.go` change is needed for `locale.from-principal` itself — it is registered globally by `surf.BuildRouter` in 07-01 under that exact name; this task only REFERENCES it in `Use(...)`.
go vet ./... && go test ./plugins/golem15/fonoteka/... -run 'TestTokenApi|TestMeLocale' -short - `Store` returns 201 with `token` present and `meta` containing no `token_hash` key - `Store` with a scope outside `read|write|ai` is rejected (422) before any row is persisted - `Index` returns `{"data":[...],"connected_apps_count":n}` with `data` serializing `[]` when empty, never `null` - `Destroy` on a cross-user or nonexistent id returns 404 `{"error":"Token not found"}`; on the owner's own token returns 200 `{"data":{"revoked":true}}` - `me/locale` `PUT {"locale":"en"}` returns 200 `{"preferred_locale":"en"}`; `{"locale":"de"}` returns 422 - `surf.RouteInfo` for `GET/PUT /_fonoteka/api/v1/me/locale` lists `jwt.auth` and `locale.from-principal` but NOT `inv.must-change-password` Token mint/list/revoke and me/locale GET/PUT all match the behaviors above; the JWT genres+tokens group and the me/locale group both carry locale.from-principal immediately after jwt.auth; only the genres+tokens group carries inv.must-change-password. Task 3: Route-table 423-exempt assertion and manifest flip ../fonoteka.go/plugins/golem15/fonoteka/routes_isolation_test.go, ../fonoteka.go/parity/manifest.yaml ../fonoteka.go/plugins/golem15/fonoteka/routes_isolation_test.go (TestFullRouteTableAuthGroupMutualExclusivity — the exact BuildRouter-over-real-plugins pattern to extend, not replace), summercms.go/surf/routetable.go, ../fonoteka.go/parity/manifest.yaml (the existing `me/locale`/`tokens` pending entries, lines ~14-52 and ~1611-1667, already read this session), .planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-01-SUMMARY.md (Deviation #2 — the precedent for aligning a stale capture-session fixture body to the real ported handler's output, rather than weakening the assertion) Extend `routes_isolation_test.go` with `TestRequirePasswordChangeExemptSet` (same `BuildRouter`-over-real-plugins pattern as `TestFullRouteTableAuthGroupMutualExclusivity`): walk `rt.Routes()`; for every route whose `Pattern` starts with `/_fonoteka/api/v1` — assert `inv.must-change-password` is present in `Middleware` UNLESS `Pattern == "/_fonoteka/api/v1/me/locale"` (both GET and PUT); assert NO route whose `Pattern` starts with `/_user/api/v1` ever carries `jwt.auth` or `inv.must-change-password` (D-01 — that group has neither, auth is per-handler). Assert both `me/locale` routes DO carry `jwt.auth` and `locale.from-principal`, and the `tokens` routes carry `jwt.auth`, `locale.from-principal`, AND `inv.must-change-password`.
Run the existing recorded fixtures for the five previously-`pending` routes (`GET|PUT /_fonoteka/api/v1/me/locale`, `POST|GET /_fonoteka/api/v1/tokens`, `DELETE /_fonoteka/api/v1/tokens/{id}`) against the new Go handlers via `go test ./parity/... -run TestParityCorpus`. Where a fixture body is a stale capture-session artifact that doesn't match what the CURRENT Go handler correctly produces (e.g. a `POST /tokens` 422 fixture recorded before `name` validation existed, or a `GET /tokens` body shape mismatch), align the FIXTURE to the real handler output — mirroring the 06-01-SUMMARY Deviation #2 precedent exactly (fix the stale fixture, do not weaken the Go response to match a wrong recording). If a fixture instead reveals a genuine Go behavior gap (e.g. missing `collections` key), fix the Go handler, not the fixture. Once every one of the five cases replays green, flip their `status: pending` to `status: ported` in `parity/manifest.yaml` (edit the five existing blocks in place — do not create new manifest entries for routes that already have one). Update `parity_test.go`'s `expectedPortedRoutes` constant from `2` to `7` (genres x2 + tokens x3 + me/locale x2 — wait, that is 2+3+2=7, confirm the exact arithmetic against the manifest's actual current ported count via `grep -c 'status: ported' parity/manifest.yaml` rather than trusting this comment blindly) and `parity_contract_test.go`'s hardcoded inventory assertion (`cov.Recorded != 154 || cov.Ported != 2 || cov.Pending != 152`) to the new ported/pending split — `expectedPHPRoutes` itself stays `154` this plan (the fifteen new `/_user/api/v1` routes are 07-05's addition, not this plan's).
go vet ./... && go test ./plugins/golem15/fonoteka/... -run TestRequirePasswordChangeExemptSet && go test ./parity/... -run TestParityCorpus -short - `TestRequirePasswordChangeExemptSet` walks the real assembled route table and fails if any `/_fonoteka/api/v1` route other than `me/locale` lacks `inv.must-change-password`, or if `me/locale` carries it - The same test fails if any `/_user/api/v1` route carries `jwt.auth` or `inv.must-change-password` - `parity/manifest.yaml`'s five `tokens`/`me/locale` entries all read `status: ported` - `go test ./parity/... -run TestParityCorpus -short` exits 0 with those five cases passing The route-table test proves the 423 lock's exempt set is exactly {me/locale GET, me/locale PUT}; the five tokens/me-locale manifest entries read status: ported and their recorded fixtures replay green against the real handlers.

<threat_model>

Trust Boundaries

Boundary Description
Client → token mint An authenticated but potentially malicious client crosses into scope-ceiling enforcement
Client → token revoke Untrusted numeric id crosses into an owner-scoped lookup

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-07-06 Elevation of Privilege token_api_controller.Store mitigate Server-side MintableScopes allow-list rejects any scope outside read/write/ai regardless of client input — no client-suppliable escape hatch
T-07-08 Elevation of Privilege 423 lock route grouping mitigate TestRequirePasswordChangeExemptSet asserts the exempt set over the REAL assembled route table, not by convention — a future route accidentally added to the wrong group fails this test at boot-equivalent time
IDOR (unlabeled, folded into V4 Access Control) Information Disclosure token_api_controller.Destroy mitigate Owner-scoped WHERE user_id = ? lookup; a cross-user or missing id both produce the identical 404, no enumeration signal

</threat_model>

`go vet ./...` and `go test ./... -short` green in `fonoteka.go`. `go test ./parity/... -run TestParityCorpus` reports the five previously-pending routes as passing/ported, corpus totals otherwise unchanged from 07-02/07-03's state.

<success_criteria> Personal API tokens can be minted, listed and revoked entirely through /_fonoteka/api/v1/tokens; me/locale persists preferred_locale and stays reachable under the 423 lock; the lock's exempt set is proven, not assumed. </success_criteria>

Create `.planning/phases/07-user-plugin-and-authentication/07-04-SUMMARY.md` when done