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.
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 |
|
|
true |
|
|
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.
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>