docs(07): capture phase context

This commit is contained in:
Jakub Zych
2026-09-22 00:24:21 +02:00
parent 2fe5c1e920
commit 1979c45083
2 changed files with 321 additions and 0 deletions

View File

@@ -0,0 +1,167 @@
# Phase 7: User plugin and authentication - Context
**Gathered:** 2026-09-22
**Status:** Ready for planning
<domain>
## Phase Boundary
The stub `golem15.user` Go plugin (JWT guard + 4-column `User`) becomes a real port of the PHP user plugin's SPA contract: register, login, logout, sliding JWT refresh, forgot/reset password, activation, profile update, change-password, avatar upload/remove, marketing consent and `oauth-providers`, with token minting, a jti blacklist and failed-login throttling. The user payload is extended through a fire-and-collect event (`golem15.user.getApiArray`) that `golem15.fonoteka` populates with organisation fields, `must_change_password` and `preferred_locale`. In `golem15.fonoteka` (PHP ownership, P6 D-07/08): personal-token CRUD (`/_fonoteka/api/v1/tokens`), `GET|PUT me/locale`, and the 423 lock with its exempt groups. Locale resolves per request from `preferred_locale` with header fallback (I18N-02).
Repos: `fonoteka.go` (plugins, fixtures, manifest) and `summercms.go` (any `bouncer`/`surf`/`tide` seams the port needs). Security-load-bearing: the security-review agent is applied.
Not in this phase: OAuth2.1 authorization server and the `oauth` guard (Phase 8), backend admin users (Phase 9), River-queued mail (Phase 11), everything listed under Deferred.
</domain>
<decisions>
## Implementation Decisions
### Carried forward (locked earlier, restated for the planner)
- **C-01:** The Phase 3/6 JWT verifier, its 401 bodies and the 423 body `{"error":"Password change required","must_change_password":true}` stay byte-identical (P6 D-10). Empty JWT secret fails boot (P3 D-11).
- **C-02:** `inv_token` guard, `inv.scope` and the 423 middleware live in `golem15.fonoteka`; token CRUD and `me/locale` go there too, exactly where PHP has them. Token CRUD is JWT-group only (a token can never mint a token). Phase 6's verifier is kept; Phase 7 adds production minting through a port of `ApiTokenManager` (`inv_` + base64url(32 random bytes), sha256 at rest, plaintext returned once in the 201).
- **C-03:** "Scope ceiling" for personal tokens means PHP's `MINTABLE_SCOPES = read|write|ai` with default `['read']`, validated `in:read,write,ai`. The `scope_ceiling` column/logic on OAuth clients is Phase 8.
- **C-04:** The user plugin declares its `user-api` bucket (120/min, key user id else IP) through the Phase 6 limiter API. `pin-login` and `2fa-verify` buckets are not declared (their routes are not ported).
- **C-05:** Mail through `postcard.Send` with WinterCMS-shape templates; the caller picks `-en` siblings from `preferred_locale` (P4 D-08/20). Models follow `Fillable/Hidden/Rules` (P5 D-05..09); shipped migrations are never edited, new columns arrive as appended migrations (P5 D-03).
### Route surface
- **D-01:** Ported on `/_user/api/v1` (group middleware `throttle:user-api`, no `jwt.auth`; auth is per-handler as in `ApiController::authorize()`): `OPTIONS` catch-all (204), `POST login`, `POST logout`, `GET fetch`, `POST refresh`, `POST register`, `POST forgot-password`, `POST reset-password`, `POST activate`, `POST activate-by-code`, `POST update`, `POST change-password`, `POST avatar`, `POST avatar/remove`, `POST marketing-consent`, `GET oauth-providers`. forgot/reset/activate have no Nuxt caller today but are required by roadmap success criterion 1.
- **D-02:** Register ports all three activation-mode branches as PHP returns them: auto/not-required → `{token,user}`; `user` mode → `{message:'Activation email sent'}`; `admin` mode → empty `{}` 200. `allow_registration` and the 3-per-IP-per-60-min register throttle (`created_ip_address` count) are ported; client IP comes from the Phase 6 trusted-proxy function.
- **D-03:** Social login: only `GET oauth-providers` is ported, returning the configured provider list (empty list when none configured) so the Nuxt login page renders. `/oauth/{provider}` redirect + callback, `oauth-complete`, `oauth-register-complete` and the password-bootstrap OTP branch of change-password (428/503) are deferred. Because no Go path can create a user with `has_self_set_password = false`, change-password ports the normal branch only; researcher states what happens for a migrated row that has the flag false (recommended: documented as deferred with the social flow, not silently treated as self-set).
- **D-04:** Avatar upload and remove are ported now on the Phase 5 attachment machinery (`system_files`, `Thumb`): payload `avatar`, `avatar_url` (128 thumb) and `has_avatar` are real. This is the port's first HTTP multipart endpoint and sets the pattern Phase 12 reuses (per-group `MaxBytesReader` upload cap from P6 D-18).
- **D-05:** Not ported, no 501 shells (P6 D-15 rule), columns stay in the schema: PIN login (3 routes), device auth (5), all 2FA challenge and management routes (17+5), `GET /api/user/batch`, `GET /_user/activate/{id}`. Login never returns the `two_factor_required` shape.
### JWT lifecycle
- **D-06:** Tokens are wire-compatible with PHP's `php-open-source-saver/jwt-auth` so users stay logged in across cutover: HS256 with the same `JWT_SECRET`, claims `iss, iat, exp, nbf, sub, jti, prv` (`prv` = the lock-subject hash of the PHP model class, reproduced as a constant; researcher confirms the exact hash input and the `iss` value PHP emits per endpoint), `sub` = user id, no custom claims, leeway 0. PHP-issued tokens must verify and refresh in Go. The PHP blacklist is not migrated; a token logged out in PHP shortly before cutover may revive until its `exp` — accepted.
- **D-07:** Refresh is PHP's sliding access token, no separate refresh token: `POST refresh` with a token that may be expired but is within `refresh_ttl` of its original `iat` returns `{token}` and blacklists the old jti; any failure is 401 `{error:'Could not refresh token', msg}`. Logout blacklists the presented token forever (and returns PHP's body). `blacklist_grace_period` semantics are ported (a just-rotated token stays usable for the grace window so parallel requests don't 401).
- **D-08:** The jti blacklist is a Postgres table (jti, expires_at), behind a small Store interface like the limiter's, with an expiry sweep. One indexed lookup per authenticated request is accepted; no cache layer this phase.
- **D-09:** Token extraction — deliberate, documented contract deviation per the Golem15 Stack security upgrade (no tokens in access logs): tokens are never read from the URL or body. `/_user/api/v1` handlers: `Authorization: Bearer` only. `jwt.auth` routes: Bearer, then the `token` and `auth_token` cookies (plain, not encrypted). Dropped on both surfaces: `?jwt_token=`, body `jwt_token`, jwt-auth's `?token=` query, input-source and route-param parsers. Verified: neither `vue-fonoteka-app` nor `fonoteka-mcp` sends any of them. The Bearer/cookie split itself is kept because it is used across Golem15 projects. The server never sets a cookie — the Nuxt app writes `auth_token` itself.
- **D-10:** TTLs are plugin config keys with the library defaults — `golem15.user.jwt.{ttl: 60, refresh_ttl: 20160, blacklist_grace: 0, leeway: 0}` (minutes / seconds as in PHP) — and `fonoteka.go`'s config sets Płytarium's real values (1440, 43200, 10 s). Same framework/app split as `app.locale` (P4 D-05).
### Parity evidence
- **D-11:** The `/_user/api/v1` routes are absent from the 154-route manifest. They are recorded against the isolated PHP instance with the Phase 2 `tide` tooling (capture rules, private 0600 vars store, no live JWT in git) and added to `fonoteka.go/parity/manifest.yaml`, going through the same pending → ported gate. The manifest total grows past 154; wording that treats 154 as fixed is corrected at plan time. `tokens` (3) and `me/locale` (2) entries already exist and flip to ported. Fixtures that exercise dropped extraction sources (D-09) are not recorded.
- **D-12:** Mail-dependent two-step flows (forgot → reset, register → activate-by-code) get their code through an app-owned tide seed/capture hook that reads `reset_password_code` / `activation_code` from the users row after step one and stores it as a `{{var}}`; it works the same against PHP (MariaDB) and Go (Postgres). Mail content is asserted separately through postcard's `memory` driver.
- **D-13:** Every distinct status + body per route is recorded, because PHP's error envelopes differ per endpoint (`{error: string}`, `{error:true,message}`, `{error,errors}`): bad credentials, unactivated, suspended/banned, 422 validation, registration closed, register throttle, bad/expired refresh, wrong old password, etc. Envelopes are reproduced per endpoint, not unified.
- **D-14:** A new recorded Nuxt client flow (`nuxt-auth`) sits beside `nuxt-browse`: register → fetch → update → change-password → refresh → logout → reuse of the logged-out token is refused; plus a `must_change_password` user hitting 423 on the authenticated surface, then `me/locale` succeeding, then change-password clearing the lock.
### Security deltas (wire-invisible hardening over exact parity)
- **D-15:** Reset and activation codes — verified in source that PHP codes never expire (`vendor/winter/storm/src/Auth/Models/User.php:258-292`, plain `===`, no timestamp column; `config/auth.php passwords.expire` is unused by this flow). Go keeps the `"{id}!{code}"` format and the same columns (PHP-issued codes keep working, schema diff stays explainable), compares in constant time, and adds a config TTL (defaults: reset 60 min, activation 72 h) tracked in an appended issued-at timestamp column. An expired code returns exactly the wrong-code response. Assumption stated to the user and not objected to: a code with a null issued-at (issued by PHP before cutover) counts as issued at cutover and gets one full TTL.
- **D-16:** Failed-login throttling is a port of Winter's Throttle onto the `user_throttle` table: per user + IP counters, `attemptLimit` 5 / `suspensionTime` 15 min from config, suspended and banned states with PHP's error bodies, `use_throttle` setting honoured. Durable across restarts and gives Phase 9 a real model for ban/unban. The `user-api` bucket sits in front as in PHP.
- **D-17:** Login restores a soft-deleted user and sends `mail.reactivate`, as PHP's `afterLogin()` does. Guest conversion is dropped: `is_guest` rows cannot log in, and register with an existing email gets the normal unique-email 422.
- **D-18:** Mail is sent inline through `postcard.Send` behind a small seam that Phase 11 swaps for a River job. forgot-password always returns its enumeration-safe 200 `{message:'If that email exists, a reset link has been sent.'}` and logs a send failure instead of surfacing it. `mail.activate` was already synchronous in PHP.
- **D-19:** Passwords: bcrypt with cost from config (default 10 = PHP); existing `$2y$` hashes verify unchanged; on successful login a hash with a lower cost than configured is rehashed silently. Validation rules stay PHP's (`between:8,255|confirmed`, `min_password_length` 8).
- **D-20:** Password change and password reset invalidate every previously issued JWT of that user: a per-user "tokens valid after" timestamp (appended column) checked by the `jwt` guard; a token with `iat` before it gets the normal 401. Response bodies of change-password / reset-password stay byte-identical. Researcher confirms whether PHP's change-password returns a fresh token; if it does not, the presenting token is kept alive (exempt jti or timestamp set just before its `iat`) so the calling device is not logged out mid-session.
- **D-21:** `must_change_password` has no PHP code path that sets it to true. Go adds a console command in the user plugin (scaffolded the Phase 4 way, e.g. `user:require-password-change <email>`) so the locked state is reachable without SQL; the admin form field comes with the Phase 9 user controller. change-password clears the flag, as in PHP.
### Claude's Discretion
- I18N-02 placement: where the per-request locale stage sits relative to the `jwt` guard and the 423 gate so it resolves `preferred_locale` → `Accept-Language` → `app.locale` even while locked; PHP has no such middleware, so this is additive and must not change any recorded body.
- Package homes: whether minting/blacklist primitives live in `bouncer` (generic, app-agnostic) with the user plugin owning config, tables and routes; Store interface shape; sweep interval.
- The `festival` event type for `getApiArray` (owned by the user plugin; fonoteka imports user, never the reverse) and the merge order of collected fields.
- Payload fields backed by tables with no Go source yet (`permissions`, `groups`, `role`, `is_onboarded`): port the minimal read models needed to reproduce the recorded payload exactly; no management endpoints.
- 423-exempt grouping: reproduce PHP's structure (the `/_user` group never carries the middleware; `me/locale` is its own `jwt.auth`-only group) and assert it over the Phase 6 route table.
- Exact names of new columns, config keys not named above, and the console command.
- Plan count and split, subject to the plan-count checkpoint, "unit tests are the last plan" and the security-review agent.
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Planning docs (paths relative to `summercms.go/`)
- `.planning/ROADMAP.md` §Phase 7 — goal, success criteria, repos
- `.planning/REQUIREMENTS.md` — AUTH-01..04, I18N-02
- `.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-CONTEXT.md` — guard registry, `inv_token`/`inv.scope`, limiter buckets, trusted-proxy client IP, raw groups, response conventions
- `.planning/phases/05-data-layer-full-fidelity/05-CONTEXT.md` — Fillable/Hidden/Rules, append-only migrations, schema diff, attachments and thumbs
- `.planning/phases/04-cli-scaffolding-i18n-and-mail/04-CONTEXT.md` — mail template shape, caller-picked locale suffix, phrasebook, plugin directory layout, `make:*`
- `.planning/notes/plugin-layout-winter-directories.md` — where controllers/classes/middleware/console go
- `.planning/research/PITFALLS.md`, `.planning/research/STACK.md` — golang-jwt usage, dependency rules
### PHP contract — user plugin (absolute, `/media/nvme/dev/golem15/fonoteka/`)
- `plugins/golem15/user/routes.php` — groups, middleware, the three named limiters (lines 8-18), route list
- `plugins/golem15/user/controllers/ApiController.php` — every handler, bodies and status codes (`authorize()` :1412, login :42, logout :104, fetch :128, refresh :149, activate :180/:208, register :251, update :364, change-password :412, avatar :526/:561, marketing-consent :595, forgot :638, reset :679, oauth-providers :1066, `makeResetUrl` :1501)
- `plugins/golem15/user/Plugin.php` :329-358 — base user payload and the `golem15.user.getApiArray` merge; :233-246 mail templates
- `plugins/golem15/user/classes/TokenExtractor.php`, `classes/JwtServiceProvider.php` :44-52, `middleware/JwtAuthenticate.php` — the two extraction surfaces (see D-09 for what is dropped)
- `plugins/golem15/user/classes/AuthManager.php` — logout = session + `JWTAuth::invalidate(true)`, `withTrashed`, guest handling (dropped)
- `plugins/golem15/user/models/User.php` — fillable/hidden/rules, `afterLogin` restore (:497-517), `isRegisterThrottled` (:616-630), `getJWTCustomClaims` (:775)
- `plugins/golem15/user/models/Throttle.php`, `models/Organisation.php`, `updates/v3.2.0/*`, `updates/v3.2.1/*`, `updates/v2.3.7/*`
- `plugins/golem15/user/config/jwt.php`, root `config/jwt.php`, `.env` (`JWT_TTL=1440`, `JWT_REFRESH_TTL=43200`, grace 10 s), root `config/auth.php` (guards, throttle 5/15), `config/hashing.php`, `plugins/golem15/user/config/config.php`. Do NOT treat `plugins/golem15/user/config/auth.php` as authoritative (shadow copy).
- `vendor/winter/storm/src/Auth/Models/User.php` :209-292 — activation/reset code semantics (no expiry); Storm `Throttle` and `Manager::authenticate()`
- `plugins/golem15/user/views/mail/{activate,restore,reactivate}.htm`
- `plugins/golem15/user/tests/unit/controllers/*`, `tests/security/AuthenticationTest.php`
### PHP contract — fonoteka plugin side
- `plugins/golem15/fonoteka/Plugin.php` :240-252 (getApiArray listener), :218-221 and :284 (middleware aliases), :182 (`golem15.user.register` listener — invitation/provisioning is Phase 12; the event must exist)
- `plugins/golem15/feedback/Plugin.php` :38-43 — second getApiArray listener (`feedback_widget_hidden`), ported with the feedback plugin
- `plugins/golem15/fonoteka/middleware/RequirePasswordChange.php`, `routes.php` :61-67 (locale group), :282-284 (tokens)
- `plugins/golem15/fonoteka/controllers/api/TokenApiController.php`, `MeLocaleController.php`, `MeTokenController.php`, `classes/auth/ApiTokenManager.php`, `models/ApiToken.php`, `classes/OrgAccess.php`
- `plugins/golem15/fonoteka/tests/security/TokenSurfaceIsolationTest.php`
### Client contract
- `/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/stores/auth.ts`, `app/utils/tokenRefresh.ts`, `app/plugins/token-refresh.client.ts`, `app/plugins/api.ts` (401 → refresh retry), `app/plugins/preferred-locale.client.ts`, `app/middleware/force-password-change.global.ts`, `shared/types/fonoteka.ts` :317-319
### Go code to extend
- `fonoteka.go/plugins/golem15/user/` (plugin.go, models/, classes/user_lookup.go, config/config.yaml, updates/)
- `fonoteka.go/plugins/golem15/fonoteka/middleware/must_change_password.go`, `middleware/token_scope.go`, `classes/auth/token_guard.go`
- `fonoteka.go/parity/manifest.yaml`, `parity/capture-rules.yaml`, `parity/fixtures/`
- `summercms.go/bouncer/` (jwt.go, guard.go, registry.go), `festival/bus.go` (`Collect`), `postcard/`, `phrasebook/`, `surf/` (limiter, route table), `tide/`
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `bouncer.NewJWTGuard` + named guard registry, `bouncer.User(ctx)` and the credential accessor — minting, blacklist and the "valid after" check attach here without changing 401 bodies.
- `festival.Bus.Collect[T]` — merges listener maps, later wins; the literal port of `Event::fire('golem15.user.getApiArray', …, false)` + `array_merge`.
- `postcard.Mailer` with `memory`/`log`/`smtp` drivers — mail assertions in unit tests, Mailpit integration test pattern.
- Phase 5 `system_files` attachments, `Thumb(w,h,mode)`, `lagoon.Fill`, `Rules()` translation, `lagoon.Encrypted`.
- Phase 6 `surf.Limiter` named buckets + inline `throttle:N,M`, trusted-proxy client IP, route table + `route:list`, JSON writer / Carbon time / nullable bool types.
- `tide` record/replay, capture rules, vars store, seed hooks (app-owned seam from Phase 2).
- `fonoteka` plugin's `MustChangePassword` middleware and `inv.scope` already ship with exact bodies.
### Established Patterns
- Routes written line by line from `routes.php` with PHP middleware names (P3 D-14, P6 D-05); groups are builders; only real handlers are mounted.
- Handlers build DTOs explicitly; nothing rewrites responses after the handler (P6 D-17).
- `models/` is a leaf package; services in `classes/`; GORM callbacks registered from `classes/` or `plugin.go` (P4 D-11).
- Secrets and keys fail boot when missing; tests use fixed test-only values.
- Framework never imports the app; anything Płytarium-specific (TTLs, locale list `pl,en`) lives in `fonoteka.go` config or call sites.
### Integration Points
- `golem15.user` plugin: new routes group, controllers, services, migrations (appended), console command, mail templates, lang files, limiter bucket, event types.
- `golem15.fonoteka` plugin: `Requires` user; listens to `getApiArray`; adds tokens CRUD, `me/locale` group, token manager.
- `bouncer`: mint/refresh/blacklist seam and extractor configuration (two surfaces).
- Parity: manifest growth, new capture rules for JWTs in user responses, DB-reading seed hook, `nuxt-auth` flow.
</code_context>
<specifics>
## Specific Ideas
- "jwt_token GET param should be deprecated by now per Golem15 Stack security upgrade, we don't want tokens in access logs. But we need to port the split for Bearer / Cookies as it's used through all projects." — this copy of the PHP plugin predates that upgrade; the Go port follows the upgraded rule.
- The user expected PHP reset codes to expire; source says they do not in this codebase. If another Golem15 project carries a newer user plugin with expiry, point the researcher at it and port its TTL values instead of the defaults in D-15.
- Presence will be built in a future milestone; the legacy CMS theme flows are legacy and are not carried over.
</specifics>
<deferred>
## Deferred Ideas
- Presence system including `GET /api/user/batch` — future milestone.
- Full social login: `/oauth/{provider}` redirect + callback, `oauth-complete`, `oauth-register-complete`, password-bootstrap OTP (428/503) and its mails — own phase/backlog; needs an OAuth-client dependency decision.
- PIN login, device auth and all 2FA routes (incl. the `two_factor_required` login branch) — not in v1.
- Admin-invite signed activation link (`/_user/activate/{id}`, `mail.invite`) — legacy; redesign with the Phase 9 admin if needed.
- Guest users and guest → user conversion — revisit for keios.eu.
- Account deletion / GDPR export lifecycle and `user:process-scheduled-deletions` — no HTTP route exposes them today.
- Queued mail via River — Phase 11 swaps the D-18 seam.
- Migrating the PHP JWT blacklist at cutover — explicitly not done (D-06).
- Admin form field for `must_change_password`, ban/unban UI — Phase 9.
- `golem15.user.register` consumers (invitation token, `CollectionProvisioner`) — Phase 12; `feedback_widget_hidden` — feedback plugin phase.
</deferred>
---
*Phase: 7-User plugin and authentication*
*Context gathered: 2026-09-22*

View File

@@ -0,0 +1,154 @@
# Phase 7: User plugin and authentication - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
**Date:** 2026-09-22
**Phase:** 7-User plugin and authentication
**Areas discussed:** Route surface cut, JWT lifecycle, Parity evidence, Security vs parity
---
## Route surface cut
### Core routes
| Option | Description | Selected |
|--------|-------------|----------|
| Nuxt-used + criterion 1 | login, logout, fetch, refresh, register, update, change-password, marketing-consent + forgot/reset/activate/activate-by-code | ✓ |
| Nuxt-used only | Drop forgot/reset/activate; conflicts with criterion 1 | |
| Everything live in routes.php | Also `api/user/batch` and `/_user/activate/{id}` | |
**User's choice:** Asked first which endpoints option 1 skips versus option 3. Answer given: exactly two — `GET /api/user/batch` (presence, no caller) and `GET /_user/activate/{id}` (signed-URL legacy theme flow). Then: "OK option 1 then, we'll build presence in future milestones, legacy cms theme flow is legacy."
### Social login
| Option | Description | Selected |
|--------|-------------|----------|
| oauth-providers only, rest deferred | Login page renders; provider flow deferred | ✓ |
| Port the full social flow now | Redirect, callback, complete endpoints, bootstrap OTP | |
| Defer all of it | oauth-providers 404s | |
### Avatar
| Option | Description | Selected |
|--------|-------------|----------|
| Port both now | Multipart upload on Phase 5 attachments + Thumb(128) | ✓ |
| Payload fields only | Endpoints wait for Phase 12 | |
### Dead routes (PIN, device, 2FA)
| Option | Description | Selected |
|--------|-------------|----------|
| Skip entirely, document | No 501 shells; login never returns two_factor_required | ✓ |
| Skip, but port the 2FA login branch | Enrolled accounts not downgraded at cutover | |
---
## JWT lifecycle
### Blacklist storage
| Option | Description | Selected |
|--------|-------------|----------|
| Postgres table | Survives restarts, works across replicas | ✓ |
| In-process map | Restart resurrects logged-out tokens | |
| Postgres + in-process read cache | More moving parts | |
### Cutover compatibility
| Option | Description | Selected |
|--------|-------------|----------|
| Yes, wire-compatible tokens | Same secret and claims; PHP blacklist not migrated | ✓ |
| Compatible claims, but no promise | Cutover may force re-login | |
| Yes, and migrate the PHP blacklist | Phase 15 import | |
### Extraction
| Option | Description | Selected |
|--------|-------------|----------|
| Port the split exactly | Incl. `?jwt_token=` and jwt-auth default parsers | |
| One permissive extractor everywhere | Cookies on /_user too | |
| Header + cookies only, everywhere (follow-up) | Drop all URL/body/route-param sources | ✓ |
| Drop URL sources, keep POST-body jwt_token (follow-up) | | |
| Drop, but answer explicitly with 400 (follow-up) | | |
**User's choice:** Free text — "jwt_token GET param should be depreciated by now per Golem15 Stack security upgrade, we don't want tokens in access logs. But we need to port the split for Bearer / Cookies as it's used trough all projects."
**Notes:** Verified neither the Nuxt app nor fonoteka-mcp sends `jwt_token`/`?token=`; PHP's `$request->get('jwt_token')` also reads the POST body. Follow-up confirmed "Header + cookies only, everywhere".
### TTLs
| Option | Description | Selected |
|--------|-------------|----------|
| Config keys, PHP-env defaults in the app | Library defaults in plugin, Płytarium values in fonoteka.go | ✓ |
| Hardcode Płytarium's values in the plugin | | |
---
## Parity evidence
| Question | Options | Selected |
|----------|---------|----------|
| How is the /_user contract proven? | Record new tide fixtures + extend manifest / Hand-written contract tests / Both | Record new tide fixtures, extend manifest |
| Mail-dependent codes | Seed hook reads code from DB / Capture from mail sink / Pre-seeded fixed codes | Seed hook reads the code from the DB |
| Error-path coverage | Every distinct status + body per route / Happy path + one failure | Every distinct status + body per route |
| Nuxt client flow | Add a nuxt-auth flow fixture / Route fixtures are enough | Add a nuxt-auth flow fixture |
---
## Security vs parity
### Reset/activation codes
| Option | Description | Selected |
|--------|-------------|----------|
| Add expiry, keep format and columns | TTL via added timestamp column, constant-time compare, same wire bodies | ✓ |
| Exact PHP behaviour | No expiry | |
| Expiry + hash at rest | Breaks PHP-issued codes and the DB-reading seed hook | |
**User's choice:** "Is your research correct? You sure PHP codes do not expire? Option 1 tho, quite sure php codes do expire"
**Notes:** Re-verified in source: Winter Storm `User.php:258-292` is a plain `===` with no timestamp, the plugin does not override it, the users table has no expiry column, and `passwords.expire=60` belongs to the unused Laravel broker. Things that do expire: bootstrap OTP (30 min), signed invite URL (72 h). Possible that another Golem15 project has a newer plugin version. Assumption stated: null issued-at codes get one full TTL from cutover; not objected to.
### Failed-login throttle
| Option | Description | Selected |
|--------|-------------|----------|
| Port Winter Throttle onto user_throttle | Durable, ban/suspend states, PHP bodies | ✓ |
| Limiter bucket only | In-process, no ban state | |
### Login/register quirks
| Option | Description | Selected |
|--------|-------------|----------|
| Port restore-on-login, drop guest conversion | | ✓ |
| Port both exactly | | |
| Drop both | | |
### Mail + hashing
| Option | Description | Selected |
|--------|-------------|----------|
| Send inline now; bcrypt from config, rehash on login | Seam for River in Phase 11 | ✓ |
| Inline mail; bcrypt 10 fixed, no rehash | | |
| Goroutine fire-and-forget mail | | |
### Revoke tokens on password change/reset
| Option | Description | Selected |
|--------|-------------|----------|
| Invalidate all older tokens | Per-user "valid after" timestamp checked by the jwt guard | ✓ |
| Exact PHP behaviour | Old tokens live up to 30 days | |
| Invalidate on reset only | | |
### Setting must_change_password
| Option | Description | Selected |
|--------|-------------|----------|
| CLI command now, admin field in Phase 9 | | ✓ |
| Tests and seed hooks only | | |
---
## Claude's Discretion
- Locale-resolution stage placement for I18N-02
- Package homes for minting/blacklist, Store interface, sweep interval
- `getApiArray` event type and merge order
- Minimal read models for payload fields backed by unported tables (permissions, groups, role)
- 423-exempt grouping asserted over the route table
- Column/config/command names; plan count and split
## Deferred Ideas
- Presence (`api/user/batch`) — future milestone
- Full social login flow and password-bootstrap OTP
- PIN login, device auth, 2FA
- Signed admin-invite activation link (legacy)
- Guest conversion (keios.eu), account deletion/GDPR routes
- River-queued mail (Phase 11), PHP blacklist migration (not done), admin lock/ban UI (Phase 9)