# 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)