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.
239 lines
23 KiB
Markdown
239 lines
23 KiB
Markdown
---
|
|
phase: 07-user-plugin-and-authentication
|
|
plan: 04
|
|
type: execute
|
|
wave: 3
|
|
depends_on: ["07-02"]
|
|
files_modified:
|
|
- ../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
|
|
autonomous: true
|
|
requirements: [AUTH-03, AUTH-04, I18N-02]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "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)"
|
|
artifacts:
|
|
- path: "../fonoteka.go/plugins/golem15/fonoteka/classes/auth/api_token_manager.go"
|
|
provides: "MintPersonalToken/RevokeToken, inv_ + base64url(32 random bytes), sha256 at rest, MintableScopes=[read,write,ai]"
|
|
- path: "../fonoteka.go/plugins/golem15/fonoteka/controllers/api/token_api_controller.go"
|
|
provides: "Store (201, plaintext once)/Index (200, secret-free)/Destroy (200 or 404) on the JWT group"
|
|
- path: "../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_locale_controller.go"
|
|
provides: "Show/Update on the jwt.auth-only me/locale group"
|
|
key_links:
|
|
- from: "../fonoteka.go/plugins/golem15/fonoteka/routes.go"
|
|
to: "summercms.go/surf LocaleFromPrincipal (registered locale.from-principal)"
|
|
via: "Use(\"jwt.auth\", \"locale.from-principal\", \"inv.must-change-password\") and Use(\"jwt.auth\", \"locale.from-principal\") on me/locale"
|
|
pattern: "locale.from-principal"
|
|
- from: "../fonoteka.go/plugins/golem15/fonoteka/classes/auth/api_token_manager.go"
|
|
to: "../fonoteka.go/plugins/golem15/fonoteka/classes ResolveActiveCollection"
|
|
via: "mint binds the new token to the caller's resolved active collection"
|
|
pattern: "ResolveActiveCollection"
|
|
---
|
|
|
|
<objective>
|
|
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.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<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
|
|
|
|
<interfaces>
|
|
<!-- Existing Phase 6 shapes this plan extends, not replaces. -->
|
|
```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.
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: ApiTokenManager — mint and revoke</name>
|
|
<files>
|
|
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/api_token_manager.go,
|
|
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/api_token_manager_test.go
|
|
</files>
|
|
<read_first>
|
|
../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)
|
|
</read_first>
|
|
<behavior>
|
|
- 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`.
|
|
</behavior>
|
|
<action>
|
|
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())`.
|
|
</action>
|
|
<verify>
|
|
<automated>go vet ./... && go test ./plugins/golem15/fonoteka/classes/auth/... -run TestMintPersonalToken -short</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `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
|
|
</acceptance_criteria>
|
|
<done>MintPersonalToken's hash format is byte-identical to what TokenGuard verifies (proven by a round-trip test); MINTABLE_SCOPES/DefaultScopes match PHP's constants.</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 2: Token CRUD controller, me/locale controller, route groups, locale wiring</name>
|
|
<files>
|
|
../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
|
|
</files>
|
|
<read_first>
|
|
../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)
|
|
</read_first>
|
|
<behavior>
|
|
- 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":[<active id>],"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":[<meta for each non-revoked, non-oauth-bound token>],"connected_apps_count":<count of oauth_client_id-bound, non-revoked tokens, always 0 this phase since nothing mints one yet>}`, 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": "<value>"}` 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.
|
|
</behavior>
|
|
<action>
|
|
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(...)`.
|
|
</action>
|
|
<verify>
|
|
<automated>go vet ./... && go test ./plugins/golem15/fonoteka/... -run 'TestTokenApi|TestMeLocale' -short</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `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`
|
|
</acceptance_criteria>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 3: Route-table 423-exempt assertion and manifest flip</name>
|
|
<files>../fonoteka.go/plugins/golem15/fonoteka/routes_isolation_test.go, ../fonoteka.go/parity/manifest.yaml</files>
|
|
<read_first>
|
|
../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)
|
|
</read_first>
|
|
<action>
|
|
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).
|
|
</action>
|
|
<verify>
|
|
<automated>go vet ./... && go test ./plugins/golem15/fonoteka/... -run TestRequirePasswordChangeExemptSet && go test ./parity/... -run TestParityCorpus -short</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `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
|
|
</acceptance_criteria>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<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>
|
|
|
|
<verification>
|
|
`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.
|
|
</verification>
|
|
|
|
<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>
|
|
|
|
<output>
|
|
Create `.planning/phases/07-user-plugin-and-authentication/07-04-SUMMARY.md` when done
|
|
</output>
|