docs(07): research user plugin and authentication phase

This commit is contained in:
Jakub Zych
2026-09-22 02:42:14 +02:00
parent 5548b2fab4
commit 0ef3a47d22

View File

@@ -0,0 +1,627 @@
# Phase 7: User plugin and authentication - Research
**Researched:** 2026-09-22
**Domain:** Go port of a WinterCMS/Laravel `php-open-source-saver/jwt-auth` user plugin (registration, login, sliding JWT refresh, blacklist, personal API tokens, forced-password-change lock) onto the existing `bouncer`/`festival`/`surf`/`postcard`/`lagoon` framework primitives shipped in Phases 1-6.
**Confidence:** HIGH on JWT wire-compatibility, route surface, and throttle/blacklist algorithms (all read directly from PHP vendor source and the live `plugins/golem15/user`/`plugins/golem15/fonoteka` source tree). MEDIUM on exact Go-side ergonomics for a few net-new pieces (locale-from-user middleware, `confirmed`/`email` validation tokens) that have no precedent in this codebase yet.
## Summary
The `/_user/api/v1` PHP contract is far larger than the AUTH-01..04 surface (it also carries PIN login, device auth, 2FA, OAuth redirect/callback, and a password-bootstrap-OTP branch) but 07-CONTEXT.md's D-01..D-21 already cut the port down to exactly 15 routes plus 5 fonoteka-owned routes (`tokens` x3, `me/locale` x2), and every one of those decisions checks out against the PHP source read in this session. The hard, load-bearing part of this phase is not the route list — it is reproducing `php-open-source-saver/jwt-auth`'s **sliding-refresh** semantics (an "expired" token is still refreshable inside `refresh_ttl`, checked only against `iat`, never `exp`) and its **blacklist grace period** (a rotated token stays valid for `blacklist_grace_period` seconds so parallel requests don't 401) using `golang-jwt/jwt/v5`, which validates `exp` by default and has no built-in blacklist concept at all. Both are fully reproducible with `jwt.WithoutClaimsValidation()` plus a small Postgres-backed jti store; the exact algorithm is documented below with source citations.
The existing framework already carries most of what this phase needs: `bouncer.Registry`/`NewJWTGuard` for the guard, `festival.Bus.Collect[*T]` (pointer-event + mutated map + `Collected()`) for the `golem15.user.getApiArray` fire-and-collect port, `postcard.Mailer` for the three mail templates, `lagoon/attach` (`File`, `Thumb`) for the avatar upload, and `surf.FixedWindowLimiter`/`BucketProvider` for the `user-api` bucket. Two framework gaps surfaced during this research and should be scoped as small Phase 7 tasks rather than hand-rolled per-handler: `lagoon.Validate` has no `email`/`confirmed`/`different`/`file` rule tokens (only `required|nullable|integer|numeric|between|min|max|in|unique|boolean` exist today), and `bouncer.Principal` has no `PreferredLocale` field, which I18N-02's per-request locale resolution needs without a second DB round-trip per request.
**Primary recommendation:** Extend `bouncer.Principal` with `PreferredLocale string`; extend `lagoon.Validate` with `email`, `confirmed` (compare `field` against `values[field+"_confirmation"]`, no cross-field validator tag needed), and `different:field` tokens (Phase 12 will also want `file`/`mimes` for album photo uploads — do not hand-roll those per-handler either); build JWT mint/verify/refresh/blacklist as new `bouncer` primitives reusing `Verify`'s parsing style but with `jwt.WithoutClaimsValidation()` for the refresh path; and place a new locale-resolution middleware immediately after the auth-group guard (before must-change-password) that overrides `towel.Locale(ctx)` with the resolved principal's `PreferredLocale` when non-empty — this is purely additive since PHP has no equivalent stage.
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked 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.
### Deferred Ideas (OUT OF SCOPE)
- 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.
</user_constraints>
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| AUTH-01 | User plugin port: registration, login, logout, password reset, email verification, and JWT issue/refresh (golang-jwt) with the same claims and cookie behavior the Nuxt app expects | Exact route list confirmed against `routes.php`; exact JWT claims (`iss,iat,exp,nbf,sub,jti,prv`), `prv` hash input, and cookie names (`token`,`auth_token`) confirmed against `php-open-source-saver/jwt-auth` vendor source. Refresh/blacklist algorithm fully documented below with a `golang-jwt/v5` implementation sketch. |
| AUTH-02 | Organizations with roles; organization fields appear on the user payload through a fire-and-collect event so the fonoteka plugin extends the user plugin without editing it | `festival.Bus.Collect[*T]`'s exact pointer+mutated-map idiom confirmed from `examples/hello/plugins/greeter`; PHP's flat-merge shape (`organisation_id`, `organisation_role`, `must_change_password`, `preferred_locale`) confirmed from `fonoteka/Plugin.php:238-253`. |
| AUTH-03 | Personal API tokens with a read\|write\|ai scope ceiling, token CRUD endpoints, and a scope-checking middleware | `ApiTokenManager`/`TokenApiController` PHP source read in full; Go's `bouncer.CredentialGuard`/`InvScope`/`models.ApiToken` already exist from Phase 6 (verification path only) — this phase adds the minting/CRUD controller and reuses the existing `classes.ResolveActiveCollection` helper for the token's collection binding. |
| AUTH-04 | The must-change-password flag locks the authenticated surface with 423 except the locale and password-change routes | `RequirePasswordChange` middleware body confirmed byte-for-byte (`{"error":"Password change required","must_change_password":true}`, 423); `MeLocaleController` confirmed to sit outside that middleware's group. |
| I18N-02 | Locale is resolved per request from the user's persisted preferred_locale with header fallback, and the locale endpoints stay reachable while the must-change-password lock is active | Confirmed the existing `surf` pipeline's `locale` middleware runs *before* the auth-group stage and only ever sees the raw `Accept-Language` header (`towel.WithLocale`) — there is no existing per-user resolution. A new post-auth locale-override stage and a `PreferredLocale` field on `bouncer.Principal` are the concrete, additive fix; documented in Architecture Patterns and Code Examples below. |
</phase_requirements>
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Registration / login / logout / JWT issue | API (`golem15.user` plugin, `/_user/api/v1`) | — | Stateless bearer-token auth; PHP's equivalent lives entirely in the same tier (`ApiController`). |
| JWT verify / sliding refresh / blacklist | Framework (`bouncer`) | API (`golem15.user` owns config, table, routes) | Verification is a cross-plugin primitive (Phase 3/6 already put the guard in `bouncer`); minting/refresh/blacklist extend that same primitive so every future guard (OAuth in Phase 8) can reuse the jti-blacklist Store shape. |
| Personal API tokens (mint/list/revoke, scope ceiling) | API (`golem15.fonoteka` plugin) | Database (`golem15_fonoteka_api_tokens`) | PHP places `TokenApiController`/`ApiTokenManager` in the Fonoteka plugin, not the User plugin — Phase 6 already ported the verification half there; this phase completes it in the same location. |
| Organisation / must_change_password / preferred_locale on the user payload | API (`golem15.fonoteka` listener) via a `golem15.user`-owned event | — | `festival.Collect` fire-and-collect event, owned and typed by `golem15.user`, populated by a `golem15.fonoteka` listener — exactly PHP's `Event::listen('golem15.user.getApiArray', ...)` shape. `golem15.user` must never import `golem15.fonoteka`. |
| must_change_password 423 lock | API (`golem15.fonoteka` middleware, applied only to the JWT-authed `/_fonoteka/api/v1` group) | — | PHP's `RequirePasswordChange` middleware is Fonoteka-owned and never touches `/_user/api/v1` — the change-password/fetch/logout endpoints must always be reachable to clear the lock. |
| Locale resolution (I18N-02) | Framework (`surf` pipeline + `bouncer.Principal`) | API (per-plugin `preferred_locale` column read) | The existing `locale` middleware (raw `Accept-Language` header, pre-auth) is a framework primitive; a new post-auth override reads the already-resolved `bouncer.Principal` rather than re-querying the DB, so it belongs in the same tier as the guard. |
| Avatar upload/remove | API (`golem15.user` handler) | Storage (`lagoon/attach.File` + `gocloud.dev/blob`) | Multipart handling and validation live at the handler; the storage read/write/thumb generation is the existing Phase 5 framework primitive. |
| Failed-login throttle / user_throttle | API (`golem15.user` plugin) | Database (`user_throttle` table) | Winter's `Throttle` model is plugin-owned in PHP (`Golem15\User\Models\Throttle extends ThrottleBase`); the Go port keeps the same ownership. |
| Password hashing | Framework (`bouncer` or a new small `bouncer/password` helper) | — | Shared by login, register, change-password, reset-password — a single hashing/verify/rehash helper avoids four copies of the same bcrypt-cost logic. |
## Standard Stack
### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `github.com/golang-jwt/jwt/v5` | v5.3.1 (already a direct dependency in both `go.mod` files) | JWT parse/verify/sign | Already decided project-wide (STACK.md); `bouncer.Verify`/`bouncer.jwtGuard` already use it for verification. This phase adds signing (`jwt.NewWithClaims`) and a `jwt.WithoutClaimsValidation()` parse path for the sliding-refresh flow. [VERIFIED: go.mod, `go doc`] |
| `golang.org/x/crypto/bcrypt` | already present transitively at v0.55.0 (summercms.go) / v0.54.0 (fonoteka.go); latest tagged release v0.57.0 | Password hashing compatible with PHP's `$2y$` bcrypt hashes | Only bcrypt implementation available to Go (stdlib has none); PHP's `password_hash`/`Hash::make` defaults to bcrypt cost 10, matching `GenerateFromPassword(pw, cost)`/`CompareHashAndPassword`. Promote from indirect to a direct `go.mod` requirement. See Package Legitimacy Audit — `slopcheck` flags it `[SUS]` on a stale heuristic; this is the official Go team's own extended-stdlib module, already load-bearing transitively via `testcontainers-go`/`gocloud.dev`. [VERIFIED: go.sum, `go doc`, module cache inspection] |
### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| `crypto/rand` + `encoding/base64` (stdlib) | Go 1.27 | Personal API token secret generation (`inv_` + base64url(32 random bytes)) | Direct stdlib port of PHP's `random_bytes(32)` + `strtr(base64_encode(...), '+/', '-_')` — no new dependency, matches `ApiTokenManager::mint()` exactly. |
| `crypto/sha256` + `crypto/subtle` (stdlib) | Go 1.27 | Token-at-rest hashing (already used by `TokenGuard`) and constant-time reset/activation code comparison (D-15) | `TokenGuard.AuthenticateCredential` already hashes with `sha256`+`hex`; reuse the same hash for `ApiTokenManager.mint`. `crypto/subtle.ConstantTimeCompare` for the reset/activation code check (currently PHP does a non-constant-time `===`; D-15 upgrades this). |
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| A framework-owned JWT mint/refresh/blacklist primitive in `bouncer` | Put minting/refresh entirely inside the `golem15.user` plugin, `bouncer` stays verify-only | CONTEXT.md leaves this to discretion. Recommendation: put the *mechanism* (mint/refresh/blacklist Store interface) in `bouncer` since Phase 8's OAuth guard will need the identical blacklist/jti shape; the *config and table ownership* stay in `golem15.user`. This avoids Phase 8 re-deriving the same sliding-window algorithm. |
| Extending `lagoon.Validate` with `email`/`confirmed`/`different`/`file` tokens | Hand-roll validation per handler with raw `strings`/`net/mail` checks | Hand-rolling means four different ad hoc implementations of "does this look like an email" / "do these two fields match" across register/update/change-password/reset-password, each a potential parity bug. `go-playground/validator`'s built-in `email` tag and a values-map field-vs-field comparison for `confirmed`/`different` are trivial additions to the existing `validateField` switch. |
**Installation:**
```bash
# summercms.go and fonoteka.go both already require golang-jwt/v5; promote bcrypt to direct:
go get golang.org/x/crypto@latest # bumps the existing indirect x/crypto requirement, makes it direct once bcrypt is imported
go mod tidy
```
**Version verification:**
```bash
go list -m -versions golang.org/x/crypto # latest: v0.57.0 (confirmed via module proxy, 2026-09-22)
go doc golang.org/x/crypto/bcrypt # confirms GenerateFromPassword/CompareHashAndPassword/Cost API, unchanged across the version range in use
```
No other new external packages are required — `golang-jwt/jwt/v5`, `gorm.io/gorm`, `github.com/go-gormigrate/gormigrate/v2`, `github.com/go-playground/validator/v10` are already direct dependencies in both modules.
## Package Legitimacy Audit
| Package | Registry | Age | Downloads | Source Repo | slopcheck | Disposition |
|---------|----------|-----|-----------|-------------|-----------|-------------|
| `golang.org/x/crypto` (bcrypt sub-package) | Go module proxy | Module itself is 10+ years old; the specific v0.57.0 tag is ~13 days old at research time | Not applicable (proxy doesn't report weekly downloads; it's a transitive dependency of `testcontainers-go`, `gocloud.dev`, `golang-jwt` toolchains already in both `go.sum` files) | `github.com/golang/crypto` (Go team's own extended-stdlib repo, `golang.org/x/*` namespace) | `[SUS]` — flagged only for "13 days old" (the release tag date) and "no source repository linked" (proxy metadata gap, not an actual absence of a repo) | **Approved override** — see rationale below |
**Packages removed due to slopcheck `[SLOP]` verdict:** none.
**Packages flagged as suspicious `[SUS]`:** `golang.org/x/crypto` — `slopcheck`'s two signals are both false positives for this specific package: (1) "13 days old" measures the *latest patch tag's* publish date, not the module's age — `golang.org/x/crypto` has shipped continuously since 2011 as part of the official Go project's extended standard library, and is already present *transitively* in both `go.mod` files (v0.55.0 / v0.54.0) via `testcontainers-go` and `gocloud.dev`, so it is already running in this codebase's test suite today; (2) "no source repository linked" reflects a gap in the module proxy's metadata for `golang.org/x/*` vanity import paths, not an actual missing repo — the source is `github.com/golang/crypto`, mirrored from `go.googlesource.com/crypto`, maintained by the Go security team. **Recommendation: the planner should still add one `checkpoint:human-verify` task before promoting it to a direct dependency**, per the graceful-degradation rule for `[SUS]` packages, but the override rationale above should be cited in that checkpoint so the human reviewer isn't starting from zero — do not swap in an alternative bcrypt implementation.
## Architecture Patterns
### System Architecture Diagram
```
Nuxt SPA / fonoteka-mcp
│ Authorization: Bearer <jwt> (or cookies "token"/"auth_token" on jwt.auth routes)
▼
┌────────────────────────── surf pipeline ──────────────────────────┐
│ recover → CORS → locale(header) → [auth-group guard] → │
│ [NEW: locale-from-principal override] → must-change-password → │
│ org-context → rate-limit(named bucket) → handler │
└─────────────────────────────────────────────────────────────────┘
│ │
▼ ▼
golem15.user (/_user/api/v1) golem15.fonoteka (/_fonoteka/api/v1, /api/v1/fonoteka)
- login/register/logout/fetch - tokens CRUD (mint/list/revoke)
- refresh (sliding, via bouncer) - me/locale (GET/PUT)
- change-password/reset/activate - RequirePasswordChange (423)
- avatar upload/remove - InvScope (read|write|ai)
- marketing-consent - TokenGuard (verify, Phase 6)
│ festival.Bus.Collect[*GetApiArrayEvent]("golem15.user.getApiArray")
│ (owned by golem15.user; golem15.fonoteka listens, never the reverse)
▼
users.getApiArray() base payload ──merge──▶ {organisation_id, organisation_role,
(id,name,email,groups,role,...) must_change_password, preferred_locale}
│
▼
bouncer (framework primitives)
- Registry / NewJWTGuard (Phase 3/6, unchanged)
- NEW: Mint/Refresh/Blacklist (jti Store, sliding refresh_ttl window)
- NEW: Principal.PreferredLocale
│
▼
Postgres: users, user_throttle, jwt_blacklist (new), golem15_fonoteka_api_tokens (Phase 5)
```
A reader tracing "SPA calls `POST /_user/api/v1/refresh` with a stale-but-refreshable token" follows: pipeline → `golem15.user` refresh handler → `bouncer.Refresh` (parses with `WithoutClaimsValidation`, checks `iat+refresh_ttl > now` regardless of `exp`, checks jti not already force-blacklisted) → mints new token, blacklists old jti with grace-period `valid_until` → returns `{token}`.
### Recommended Project Structure
```
fonoteka.go/plugins/golem15/user/
├── plugin.go # extend: routes, middleware wiring, event registration
├── controllers/
│ └── api_controller.go # login, logout, fetch, refresh, register, update,
│ # change_password, avatar, marketing_consent,
│ # forgot_password, reset_password, activate(-by-code),
│ # oauth_providers
├── classes/
│ ├── user_lookup.go # existing GormUsers — extend FindByID to fill PreferredLocale
│ ├── registry.go # existing GORM-hook registrar — unchanged
│ ├── throttle.go # NEW: Winter Throttle port (user_throttle table)
│ ├── events.go # NEW: GetApiArrayEvent (festival.Collectable)
│ └── codes.go # NEW: reset/activation code issue+verify with TTL, constant-time compare
├── config/config.yaml # extend: jwt.{ttl,refresh_ttl,blacklist_grace,leeway}, activation/reset TTLs
├── models/
│ ├── user.go # extend far beyond the 4-column stub (see Code Examples)
│ ├── throttle.go # NEW
│ └── registry.go
├── updates/ # NEW appended migrations only — never edit 00_base.go/10_organisations.go
└── lang/{en,pl}/lang.php-equivalent YAML # login/registration/etc. message keys
fonoteka.go/plugins/golem15/fonoteka/
├── controllers/api/
│ ├── token_api_controller.go # NEW: mint/list/revoke (port of TokenApiController)
│ └── me_locale_controller.go # NEW: GET/PUT me/locale (port of MeLocaleController)
├── classes/auth/
│ ├── token_guard.go # existing (Phase 6) — verification path unchanged
│ └── api_token_manager.go # NEW: mint()/revoke() (port of ApiTokenManager)
└── middleware/
└── must_change_password.go # existing (Phase 6) — unchanged, applied to the right group only
summercms.go/bouncer/
├── jwt.go # existing Verify/Middleware/NewJWTGuard — unchanged
├── mint.go # NEW: Mint(secret, claims) (jwt.NewWithClaims + HS256)
├── refresh.go # NEW: Refresh(secret, token, refreshTTL) using WithoutClaimsValidation
├── blacklist.go # NEW: Store interface + Postgres implementation (jti, expires_at, valid_until)
├── password.go # NEW: HashPassword/CheckPassword/NeedsRehash (bcrypt wrapper)
└── context.go # extend: Principal.PreferredLocale string
```
### Pattern 1: Sliding-refresh JWT verification without a blacklist table lookup on the hot path
**What:** Every authenticated request (not just refresh) still uses the existing `bouncer.Verify`/`NewJWTGuard` path (parses with `exp` validation ON, `WithExpirationRequired()`). Only the dedicated `POST refresh` handler uses a second, more permissive parse.
**When to use:** `refresh` handler only — never relax `exp` validation on the general request-auth path, or an expired token would authenticate normal API calls.
**Example:**
```go
// Source: golang-jwt/v5 API confirmed via `go doc`; algorithm confirmed by reading
// php-open-source-saver/jwt-auth's Manager::refresh / PayloadValidator::validateRefresh /
// Claims/IssuedAt::validateRefresh (vendor source, see Sources).
func Refresh(secret string, tokenString string, refreshTTL time.Duration, bl Blacklist) (string, error) {
parser := jwt.NewParser(
jwt.WithValidMethods([]string{"HS256"}),
jwt.WithoutClaimsValidation(), // exp is NEVER checked during refresh (PHP doesn't either)
)
claims := jwt.MapClaims{}
tok, err := parser.ParseWithClaims(tokenString, claims, func(*jwt.Token) (any, error) {
return []byte(secret), nil
})
if err != nil { // signature/structure still verified
return "", fmt.Errorf("Could not refresh token: %w", err)
}
jti, _ := claims["jti"].(string)
iatF, _ := claims["iat"].(float64)
iat := time.Unix(int64(iatF), 0)
if time.Now().After(iat.Add(refreshTTL)) {
return "", errors.New("Token has expired and can no longer be refreshed")
}
if bl.IsBlacklisted(jti) { // forever-blacklisted (logout) OR grace period elapsed
return "", errors.New("The token has been blacklisted")
}
newTok, err := Mint(secret, claims["sub"].(string)) // fresh iat/exp/jti, same sub/prv
if err != nil {
return "", err
}
_ = bl.BlacklistWithGrace(jti, tok) // valid_until = now + blacklist_grace_period
return newTok, nil
}
```
### Pattern 2: `festival.Collect` fire-and-collect event, PHP's `Event::fire(..., false)` equivalent
**What:** A pointer event type carrying a mutable `map[string]any`; every listener (including cross-plugin ones) mutates the SAME map; `Collected()` returns it after each listener runs. This is the confirmed idiom already shipping in `examples/hello/plugins/greeter/plugin.go`.
**When to use:** `golem15.user.getApiArray` — `golem15.user` owns and fires the event; `golem15.fonoteka` (and later `golem15.feedback`) each register a listener that adds their own flat keys.
**Example:**
```go
// Source: summercms.go/festival/bus.go (Collect/Collectable) +
// summercms.go/examples/hello/plugins/greeter/plugin.go (confirmed live idiom)
// golem15.user/classes/events.go
type GetApiArrayEvent struct {
User *models.User
data map[string]any
}
func (e *GetApiArrayEvent) Collected() map[string]any {
if e.data == nil {
e.data = map[string]any{}
}
return e.data
}
// golem15.user controller building the payload:
payload := map[string]any{
"id": u.ID, "name": u.Name, "surname": u.Surname, "email": u.Email,
"is_activated": u.IsActivated, "permissions": []string{}, // see Open Questions
"avatar": avatarURL, "avatar_url": avatarThumbURL, "has_avatar": u.HasAvatar(),
"marketing_consent": u.MarketingConsent, "groups": map[string]string{}, "role": nil,
"has_self_set_password": u.HasSelfSetPassword,
}
extra, _ := app.Events.Collect(ctx, &GetApiArrayEvent{User: u})
for k, v := range extra {
payload[k] = v // later-registered listener wins on key collision, matches PHP array_merge order
}
// golem15.fonoteka/plugin.go Boot(): (owns organisation/must_change_password/preferred_locale)
app.Events.Listen[*userevents.GetApiArrayEvent]("golem15.fonoteka", func(ctx context.Context, e *userevents.GetApiArrayEvent) error {
m := e.Collected()
m["organisation_id"] = e.User.OrganisationID
m["organisation_role"] = e.User.OrganisationRole
m["must_change_password"] = e.User.MustChangePassword
m["preferred_locale"] = e.User.PreferredLocale
return nil
})
```
### Pattern 3: I18N-02 locale resolution — a post-auth override, not a replacement
**What:** The existing pipeline stage `locale` (`surf/router.go:650`) sets `towel.WithLocale(ctx, r.Header.Get("Accept-Language"))` *before* the auth-group guard runs, so it never has access to the user. A second, new stage runs immediately after the auth-group guard and rewrites the context locale using the now-resolved `bouncer.Principal.PreferredLocale` when it is non-empty.
**When to use:** Every group that carries an auth guard (`jwt.auth` groups). Not needed on public/unauthenticated routes (login, register, forgot-password) — those keep the existing header-only resolution, matching PHP (which has no concept of `preferred_locale` before a user is identified).
**Example:**
```go
// Source: confirmed against summercms.go/surf/router.go:650 (existing `locale` middleware)
// and summercms.go/towel/context.go (WithLocale/Locale). No PHP equivalent — additive per
// I18N-02 and CONTEXT.md's explicit discretion note.
func LocaleFromPrincipal(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if p, ok := bouncer.User(r.Context()); ok && p.PreferredLocale != "" {
r = r.WithContext(towel.WithLocale(r.Context(), p.PreferredLocale))
}
next.ServeHTTP(w, r)
})
}
// Registered as a named middleware placed AFTER the auth-group guard and BEFORE
// must-change-password in the group's middleware list, so it runs even while the
// 423 lock is active (it must — me/locale needs it, and it runs before the 423
// check ever gets a chance to short-circuit the chain).
```
**Must-verify at plan time:** confirm this ordering does not shift any byte in an already-recorded Phase 3-6 fixture — since it only ever *replaces* a context value consumed exclusively by `phrasebook.Translator.Get`, and no currently-ported route's response body is rendered through `Lang::get`-equivalent phrasebook calls with a `preferred_locale`-bearing user, this should be a no-op for existing fixtures. New Phase 7 fixtures (validation error bodies on `update`/`register`/`change-password`) are the first place this can be observed and must be asserted with both `pl` and `en` `preferred_locale` values.
### Anti-Patterns to Avoid
- **Registering a literal `OPTIONS {level1?}/{level2?}/{level3?}` catch-all route:** PHP's `Route::options(...)` returning empty 204 is redundant in Go — `surf`'s CORS middleware (`surf/cors.go:96-99`) already short-circuits **every** `OPTIONS` request with `204 No Content` before the request ever reaches route dispatch, for any path inside a CORS-scoped prefix. Just ensure `/_user/api/v1` is included in whatever path-scoped CORS configuration Phase 6 declared (06-03-PLAN.md) — do not hand-write a wildcard OPTIONS handler.
- **Relaxing `exp` validation on the general auth guard:** Only the `refresh` handler's parser may use `jwt.WithoutClaimsValidation()`. `bouncer.Verify`/`NewJWTGuard` (used by every other authenticated route) must keep `WithExpirationRequired()` exactly as Phase 3/6 shipped it.
- **Treating `permissions`/`groups`/`role` as features to build:** These are read-only payload fields backed by tables/relations that don't exist in Go yet (Winter Storm's RBAC `Role`/`UserGroup` models). Per CONTEXT.md discretion, stub these as empty/`null` in the base payload (verify against real fixtures whether Płytarium ever populates them — see Open Questions) rather than porting a permissions system nobody asked for in AUTH-01..04.
- **Storing the personal-token secret anywhere but a sha256 hash:** `ApiTokenManager.mint()` never persists the raw `inv_...` secret; only `token_hash`. The existing Go `TokenGuard` already expects this exact `sha256`+hex encoding — reuse it verbatim in the new mint path so hash format never diverges between mint and verify.
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| bcrypt password hashing | A custom hash/verify routine, or shelling out | `golang.org/x/crypto/bcrypt.GenerateFromPassword` / `CompareHashAndPassword` | Only correct, audited bcrypt implementation for Go; transparently verifies PHP's `$2y$` hashes (the `$2y$`/`$2b$` version-byte distinction does not affect the underlying crypt algorithm for ASCII/UTF-8 passwords under 72 bytes — confirmed by reading `bcrypt.go`'s `decodeVersion`, which does not gate on the minor-version byte). |
| Email format validation | A hand-rolled regex | `go-playground/validator`'s built-in `email` tag, wired into `lagoon.Validate`'s existing `validateField` switch | The validator library is already a direct dependency and already used for every other rule token; adding one more `case "email": tags = append(tags, "email")` is a two-line change vs. an ad hoc regex with its own edge-case bugs. |
| Cross-field "confirmed"/"different" checks | A per-handler `if request["password"] != request["password_confirmation"]` scattered across register/reset-password/change-password | One `case "confirmed"` / `case "different"` branch inside `lagoon.validateField`, comparing `values[field]` against `values[field+"_confirmation"]` (or the named field for `different`) | `lagoon.Validate` already receives the full `values map[string]any` for every field — no `go-playground/validator` struct-level `eqfield` tag is even needed; this is a same-function addition, not a new subsystem. Centralizing it means Phase 12's own confirmed-password flows (if any) reuse the same code. |
| Multipart file-size/mimetype checks for avatar upload | Hand-rolled `http.MaxBytesReader` + manual `Content-Type` sniffing per handler | The Phase 6 D-18 per-group `MaxBytesReader` cap (apply it to this group too) + a small `mimes:jpeg,jpg,png,webp,gif` token added to `lagoon.Validate` (Phase 12 needs the same token for album photo uploads) | Two independent handlers reimplementing "is this a JPEG under 4MB" is exactly the kind of duplicated-and-drifting validation DATA-05/HTTP-09 already guard against for other rule types. |
| JWT blacklist storage | An in-process map or a bespoke SQL table with ad hoc query code per call site | A small `bouncer.BlacklistStore` interface (`Add(jti, expiresAt, validUntil) error`, `IsBlacklisted(jti) (bool, error)`, `Sweep(now) error`) mirroring `surf.Store`'s existing shape (`surf/limiter.go`'s `Store` interface pattern) | Phase 8's OAuth guard will need an identical revocation-list shape for refresh tokens; building one small interface now that a second guard can implement against avoids a second copy-pasted blacklist implementation next phase. |
**Key insight:** Every "don't hand-roll" item above already has a load-bearing precedent *somewhere* in this codebase (validator, MaxBytesReader, a Store-shaped interface) — the risk in this phase specifically is skipping the existing pattern and writing one-off logic directly in a new controller because the existing primitive (`lagoon.Validate`, `surf.Store`) doesn't yet cover the exact rule/shape needed. Extend the primitive; don't bypass it.
## Common Pitfalls
### Pitfall 1: Checking `exp` during refresh
**What goes wrong:** A "refresh" implementation that reuses the standard `bouncer.Verify` parser (which requires `exp` to be in the future) will 401 on exactly the case PHP's sliding refresh is designed for — a token that expired 10 minutes ago but is still within its 2-week `refresh_ttl` window.
**Why it happens:** It looks natural to "verify then refresh," but PHP's `PayloadValidator::validateRefresh()` deliberately skips the `Expiration` claim's validator entirely in the refresh context (confirmed in `Claims/Expiration.php` — no `validateRefresh` override exists, so the base `Claim::validateRefresh` no-op runs) and only checks `IssuedAt::validateRefresh($refreshTTL)`.
**How to avoid:** Use a dedicated parser with `jwt.WithoutClaimsValidation()` for the refresh path only (Pattern 1 above), and manually gate on `iat + refresh_ttl > now`.
**Warning signs:** A parity fixture for "refresh with an expired-but-refreshable token" (D-14's `nuxt-auth` flow implicitly needs this case) returns 401 in Go but 200 in PHP.
### Pitfall 2: Blacklisting immediately instead of with a grace window
**What goes wrong:** If the old token is blacklisted the instant a refresh succeeds (no grace period), any second concurrent request already in flight with the old token (a common pattern: multiple API calls fired in parallel by the SPA, one of which triggers refresh) gets an unexpected 401 even though it was issued milliseconds ago.
**Why it happens:** PHP's `Blacklist::add()` stores `valid_until = now + grace_period` and `has()` only reports "blacklisted" once `valid_until` is in the past (confirmed in `vendor/.../Blacklist.php`) — this is a deliberate design, not an accident, and is easy to miss if you model "blacklist" as a simple boolean set.
**How to avoid:** Blacklist entries need two fields: `expires_at` (storage TTL, for the sweep) and `valid_until` (grace period gate) — `IsBlacklisted(jti)` must check `now > valid_until`, not just row existence. `blacklist_grace: 0` is the framework default but Płytarium's app config sets `10` (seconds) per D-10.
**Warning signs:** Flaky/racy 401s on the Nuxt app immediately after a token rotation, especially under the `token-refresh.client.ts` interceptor pattern that retries a 401 once with a fresh token.
### Pitfall 3: `iss` claim mismatch breaking a byte-diff parity fixture
**What goes wrong:** PHP's `iss` claim is `$this->request->url()` — the *full request URL of the endpoint that minted the token* (e.g. `https://host/_user/api/v1/login` for a login-minted token, `.../refresh` for a refreshed one, `.../register` or `.../activate-by-code` for those paths). If Go hardcodes a single `iss` value (e.g. the app base URL) or a route name, JWT payloads will differ byte-for-byte from PHP's, and any fixture that inspects/decodes the token payload (not just its presence) will fail.
**Why it happens:** This isn't documented anywhere in jwt-auth's own README — it is a side effect of `ClaimFactory::iss()` calling `$this->request->url()` (confirmed by reading `vendor/php-open-source-saver/jwt-auth/src/Claims/Factory.php:122-125`), and is easy to assume is a fixed "issuer name" the way most JWT tutorials use it.
<br>**How to avoid:** Set `iss` to the full request URL (scheme+host+path, no query string — matches Laravel's `Request::url()`) of the *current* handler at mint time, not a constant.
**Warning signs:** A parity diff on any endpoint that returns a token (`login`, `register`, `refresh`, `activate-by-code`) fails only on the `iss` segment of the decoded JWT payload, with everything else matching.
### Pitfall 4: `prv` claim computed from the wrong string
**What goes wrong:** `prv` (lock-subject) is `sha1(get_class($subject))` where `$subject` is the PHP model instance — i.e. `sha1("Golem15\User\Models\User")`. Computing it from `"User"`, `"users"`, or any other string produces a JWT that a real PHP `php-open-source-saver/jwt-auth` client library would reject (though Go's own verifier, if it doesn't check `prv` at all, would silently accept an internally-inconsistent token).
**Why it happens:** The class name includes the full PHP namespace with backslashes; a Go dev following the PHP source superficially might use the short class name.
**How to avoid:** Hardcode the constant `const prvHash = "a867434cbc213adfbe78a02bed7082a6bd99c883"` — [VERIFIED] computed in this research session as `sha1("Golem15\User\Models\User")` (confirmed independently via both a `php -r` one-liner attempt and a Python `hashlib.sha1` cross-check on the exact same byte string, matching).
**Warning signs:** A cutover-era PHP-issued token round-tripped through Go's verifier (if Go chooses to *check* `prv`, which CONTEXT.md doesn't explicitly require but is good practice) fails `prv` comparison, or a Go-minted token inspected by a stray PHP debug script shows a `prv` that doesn't match what PHP itself would have produced.
### Pitfall 5: Winter's failed-login throttle runs *before* credential validation, and keys on `(user_id, ip_address OR NULL)`
**What goes wrong:** A naive port might throttle by IP alone (like the `user-api` rate-limit bucket already does) or check the throttle only after a failed password check, both of which diverge from PHP's actual order and keying.
**Why it happens:** `Winter\Storm\Auth\Manager::validateInternal()` (confirmed in `vendor/winter/storm/src/Auth/Manager.php:403-458`) resolves the throttle row by **login name first** (`findThrottleByLogin` looks up the user by login, throws "user not found" if none exists — meaning an unknown-email login attempt is NOT throttled at all, since there's no user row to key a throttle to), calls `$throttle->check()` (throws if already banned/suspended) **before** attempting credential validation, and only calls `addLoginAttempt()` inside the `catch` block after a real credential mismatch for an existing user. `clearLoginAttempts()` runs on success. The throttle row itself is looked up `WHERE user_id = ? AND (ip_address = ? OR ip_address IS NULL)` — i.e. per-user-per-IP with a NULL-IP fallback row.
**How to avoid:** Look up the user by login attribute first; if no user exists, skip throttle entirely and fall straight to the generic invalid-credentials 401 (matches PHP's `AuthenticationException` → the same 401 body, no enumeration signal either way since the throttle-vs-no-throttle difference is not observable in the response body). If the user exists, find-or-create a `user_throttle` row for `(user_id, ip)`, call the suspend/ban check first, then validate the password, incrementing/clearing attempts as PHP does.
**Warning signs:** A parity fixture for "5 failed logins from IP A, then a 6th from IP B for the same account" behaves differently (PHP's `ip_address = ? OR ip_address IS NULL` fallback row means the *first* throttle row created for a user, if no ip_address was recorded, would apply globally — verify this edge case against a real fixture rather than assuming pure per-(user,ip) isolation).
### Pitfall 6: `lagoon.Validate`'s current rule set can't express register/update/change-password's rules verbatim
**What goes wrong:** PHP's rule strings for these endpoints use `email`, `confirmed`, `different:current_password`, and (for avatar) `file|mimes:...|max:4000` — none of which exist in `lagoon.Validate` today (only `required|nullable|integer|numeric|between|min|max|in|unique|boolean`, confirmed by reading `lagoon/validate.go`'s `validateField` switch, which returns `fmt.Errorf("lagoon: unrecognized validation rule %q", tok)` for anything else). A plan that assumes these tokens already work will fail at `go vet`/first test run, not at review time.
**Why it happens:** `lagoon.Validate` was built for Phase 5's model-column rules (DATA-05's stated vocabulary), which never needed cross-field or format-string rules; the user plugin is the first consumer of ad hoc *request-level* (not model-level) validation.
**How to avoid:** Add `email`, `confirmed`, and `different:field` as small new cases in `validateField` (Standard Stack / Don't Hand-Roll above) before writing the first handler that needs them. Budget this as its own small task, not an inline fix buried in the register-handler task.
**Warning signs:** `go vet ./...` passes but the first `register`/`update`/`change-password` unit test panics or returns a Go error instead of a 422 JSON body.
## Runtime State Inventory
> Not applicable — Phase 7 is additive (new columns, new tables, new routes on an existing 4-column `users` stub), not a rename/refactor/migration of already-shipped runtime state. The one adjacent concern — PHP's JWT blacklist not being migrated at cutover (D-06) — is already an explicit, accepted decision, not something this phase needs to reconcile.
## Code Examples
### JWT minting (login/register/activate-by-code)
```go
// Source: claims confirmed against php-open-source-saver/jwt-auth vendor source
// (Manager::getPayloadFactory, Claims/Factory.php, JWT.php:252 hashSubjectModel).
// golang-jwt/v5 API confirmed via `go doc github.com/golang-jwt/jwt/v5`.
const prvHash = "a867434cbc213adfbe78a02bed7082a6bd99c883" // sha1("Golem15\User\Models\User")
type registeredClaims struct {
jwt.RegisteredClaims
Prv string `json:"prv,omitempty"`
}
func Mint(secret string, sub string, issuerURL string, ttl time.Duration) (string, string, error) {
jti := newJTI() // crypto/rand + hex, any unique format PHP never inspects for equality beyond storage
now := time.Now()
claims := registeredClaims{
RegisteredClaims: jwt.RegisteredClaims{
Issuer: issuerURL, // full request URL of the minting endpoint (Pitfall 3)
Subject: sub,
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
NotBefore: jwt.NewNumericDate(now),
IssuedAt: jwt.NewNumericDate(now),
ID: jti,
},
Prv: prvHash,
}
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := tok.SignedString([]byte(secret))
return signed, jti, err
}
```
### Personal API token mint (AUTH-03, port of `ApiTokenManager::mint`)
```go
// Source: plugins/golem15/fonoteka/classes/auth/ApiTokenManager.php (read in full this session)
const tokenPrefix = "inv_"
func MintPersonalToken(userID uint, name string, scopes []string, expiresAt *time.Time, collectionIDs []uint) (secret string, model *models.ApiToken, err error) {
raw := make([]byte, 32)
if _, err = rand.Read(raw); err != nil {
return "", nil, err
}
secret = tokenPrefix + base64.RawURLEncoding.EncodeToString(raw) // matches PHP's rtrim(strtr(base64_encode(...),'+/','-_'),'=')
sum := sha256.Sum256([]byte(secret))
model = &models.ApiToken{
UserID: userID,
Name: &name,
TokenHash: hex.EncodeToString(sum[:]), // MUST match TokenGuard's existing hash format exactly
Scopes: lagoon.NewJsonable(scopes),
ExpiresAt: expiresAt,
}
if len(collectionIDs) > 0 {
model.CollectionIDs = lagoon.NewJsonable(collectionIDs)
}
return secret, model, nil // caller db.Create(model), return {"token": secret, "meta": serialize(model)} 201
}
```
### Winter Throttle port (D-16, per-`(user_id, ip)` failed-login lock)
```go
// Source: vendor/winter/storm/src/Auth/Manager.php:318-458 (findThrottleByLogin,
// findThrottleByUserId, validateInternal) and Auth/Models/Throttle.php (addLoginAttempt,
// clearLoginAttempts, suspend/ban semantics), both read in full this session.
func CheckAndRecordLogin(ctx context.Context, db *gorm.DB, user *models.User, ip string, ok bool) error {
var t models.Throttle
err := db.WithContext(ctx).
Where("user_id = ? AND (ip_address = ? OR ip_address IS NULL)", user.ID, ip).
First(&t).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
t = models.Throttle{UserID: user.ID, IPAddress: &ip}
db.Create(&t)
}
if t.IsBanned {
return ErrBanned // PHP: AuthException "User [...] has been banned."
}
if t.IsSuspended && time.Now().Before(t.SuspendedAt.Add(15*time.Minute)) {
return ErrSuspended
}
if ok {
return db.Model(&t).Updates(map[string]any{"attempts": 0, "is_suspended": false}).Error
}
attempts := t.Attempts + 1
updates := map[string]any{"attempts": attempts, "last_attempt_at": time.Now()}
if attempts >= 5 { // attemptLimit, config default
updates["is_suspended"] = true
updates["suspended_at"] = time.Now()
}
return db.Model(&t).Updates(updates).Error
}
```
## State of the Art
| Old Approach (Phase 3/6 stub) | Current Approach (Phase 7) | When Changed | Impact |
|--------------|------------------|--------------|--------|
| `bouncer.jwtGuard` only verifies a pinned HS256 token with `exp` required; no minting exists anywhere in Go | `bouncer` gains `Mint`/`Refresh`/`Blacklist` alongside the existing `Verify` | Phase 7 (this phase) | The Phase 3 comment "token minting stays out of Phase 3" (STATE.md) is resolved; `Verify`'s behavior for already-authenticated requests is unchanged. |
| `models/User` is a 4-column stub (`id, email, password, must_change_password`) | Full `User` model with ~25 columns (name, surname, username, avatar via attachOne, activation/reset codes + issued-at, has_self_set_password, organisation_id/role, preferred_locale, marketing_consent, tokens-valid-after timestamp, ip fields) | Phase 7 | All new columns arrive as **appended** migrations per P5 D-03 — `00_base.go` and `10_organisations.go` are never edited. |
| `surf`'s `locale` middleware only ever reads the raw `Accept-Language` header | A second, post-auth stage overrides the context locale from `bouncer.Principal.PreferredLocale` | Phase 7 (I18N-02) | Purely additive; PHP has no equivalent, so there is no "old PHP behavior" to diff against — only new Phase 7 fixtures exercise it. |
**Deprecated/outdated:** None — this phase does not remove or replace any existing Phase 1-6 primitive; it extends `bouncer` and `lagoon.Validate` with net-new capability.
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | `$2y$`-prefixed PHP bcrypt hashes verify correctly against `golang.org/x/crypto/bcrypt.CompareHashAndPassword` with no transformation | Standard Stack / Don't Hand-Roll | Confirmed by reading `bcrypt.go`'s hash decoder (no minor-version gate blocks `'y'`), which is strong evidence, but the claim that `$2y$`'s crypt-algorithm output is byte-identical to `$2b$`/`$2a$` for the same password+salt+cost is training-derived, not verified against a live cross-language test in this session. If wrong: existing PHP users cannot log in with their current password until a forced reset — high user-visible impact. **Recommend a live cross-check**: hash a known password with PHP's `password_hash($pw, PASSWORD_BCRYPT)` (which produces `$2y$`) and confirm Go's `CompareHashAndPassword` accepts it, as an early Phase 7 task/test, before building the rest of login on top of the assumption. |
| A2 | Winter's `Auth\Manager::validateInternal()` throttle path (keyed by `(user_id, ip_address OR NULL)`, checked before credential validation) is the actual code path executed by the SPA's `JWTAuth::attempt()` call on `/login`, not a parallel, unused Backend-only auth path | Common Pitfalls / Code Examples (Throttle) | This was inferred from `'auth' => 'user.auth'` in `config/jwt.php` resolving to the app's bound `auth` singleton (Winter overrides Laravel's `auth` service app-wide), not from stepping through a live request with a debugger. If wrong, the failed-login throttle might not actually apply to the JWT API surface in PHP at all, and D-16's port would be adding stricter behavior than PHP has today (safe direction, but changes AUTH-01's "same behavior" framing) or applying the wrong key shape. **Recommend confirming with one recorded PHP fixture**: 6 rapid failed logins against the same account/IP on the isolated PHP instance, and check whether attempt #6 returns the normal 401 or a "suspended" 401 with a different body — this settles A2 definitively before the throttle port is built. |
| A3 | `getAvatarThumb()`'s underlying `File::getThumb($size, $size, [])` uses Winter's default resize mode (`'auto'`), matching the `mode` value already implemented in `lagoon/attach.File.Thumb` | Architecture Patterns (avatar) | If PHP's actual default mode differs (e.g. `'crop'`), the avatar thumbnail's aspect-ratio/cropping behavior would visually differ from PHP even though the endpoint and payload shape are correct — a UI regression, not a wire-contract break (the field names/URLs are unaffected). Low risk; verify against one recorded avatar-upload fixture's actual thumbnail file if pixel-parity matters, otherwise not blocking. |
| A4 | `permissions: []`, `groups: {}`, `role: null` are the only values a real Płytarium user's `getApiArray()` payload ever produces (no roles/groups are configured in this app), so no port of Winter Storm's RBAC (`Role`, `UserGroup`) is needed | Anti-Patterns / Don't Hand-Roll | If a real user does have a group or role assigned in the live PHP data, a recorded fixture (D-11's `/_user/api/v1` capture pass) will show non-empty values and the "stub as empty" plan collapses — but this will be caught automatically the moment fixtures are recorded (D-11), before any code is written against a wrong assumption, so risk is self-correcting and low. |
## Open Questions
1. **Does PHP's `change-password` endpoint return a fresh JWT alongside the "Password changed" message?**
- What we know: The handler body (read in full, `ApiController.php:412-515`) returns `{'message': 'Password changed', 'user': $user->getApiArray()}` — no `token` key anywhere in that response.
- What's unclear: D-20 asks the researcher to confirm this so the "invalidate all older tokens on password change" mechanism (a per-user "valid after" timestamp) doesn't lock out the very request that just changed the password.
- Recommendation: **Confirmed — PHP does NOT return a fresh token from change-password or reset-password.** The plan must therefore exempt the *presenting* token from the new "valid after" cutoff (set the per-user timestamp to one second before the presenting token's own `iat`, or equivalently special-case "the token used to authenticate this exact change-password call is allowed through even if it predates the new cutoff") so the calling device isn't logged out mid-session, exactly as D-20 anticipates. `reset-password` is unauthenticated (no presenting token to exempt), so its "valid after" write has no such carve-out to make.
2. **What is the correct `mimes` list interaction with `lagoon.Validate`'s planned `file`/`mimes` extension for the avatar endpoint specifically (vs. Phase 12's album photo needs)?**
- What we know: PHP's rule is `avatar => required|file|mimes:jpeg,jpg,png,webp,gif|max:4000` (4000 KB, i.e. ~4MB) — confirmed in `ApiController.php:534-536`.
- What's unclear: Whether the `max:4000` unit convention (KB, Laravel's default for `file`) should be reproduced as a `lagoon.Validate` token or left as a simple, separate `http.MaxBytesReader` cap at the handler/group level (which is also required regardless, per P6 D-18, to bound the multipart body before any parsing happens at all).
- Recommendation: Use `http.MaxBytesReader` (already an established Phase 6 pattern) as the hard byte cap at the group/handler level for DoS protection, and treat `mimes:jpeg,jpg,png,webp,gif` as the only new `lagoon.Validate` token actually needed this phase — don't invent a `max` unit-conversion rule inside `lagoon.Validate` for file sizes when the existing multipart cap mechanism already covers it more cheaply.
3. **Exact PHP behavior for a migrated user row with `has_self_set_password = false` calling `change-password` in Go, where the OTP-bootstrap branch (428/503) is deferred (D-03).**
- What we know: D-03 states this branch is deferred with the social-login flow.
- What's unclear: Whether such a row can exist in the *current* Płytarium production data at all (the column defaults true per the PHP model's `has_self_set_password ?? true` fallback, and CONTEXT.md states "no Go path can create a user with `has_self_set_password = false`").
- Recommendation: Confirmed low-risk — since no Go-created row can have this flag false, and cutover-migrated PHP rows are out of this phase's scope (data migration happens at cutover, Phase 15), Phase 7's Go `change-password` handler can safely assume `has_self_set_password` is always true for any row it creates or touches, and simply 500-guard (fail loud, don't silently treat as self-set) if it ever encounters `false` — matching D-03's explicit "documented as deferred, not silently treated as self-set" instruction.
## Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Postgres | jti blacklist table, `user_throttle`, `users` extensions | ✓ (already required by Phase 3+, `testcontainers-go/modules/postgres` v0.44.0 already a direct dependency in both modules) | 18 (per STACK.md's own migration test matrix reference) | — |
| SMTP / Mailpit | `mail.activate`/`mail.restore`/`mail.reactivate` sends via `postcard.Send` | ✓ (Phase 4 already ships `postcard` with `smtp`/`memory`/`log` drivers and a Mailpit integration-test pattern — `axllent/mailpit:v1.31.1` pinned) | — | `memory` driver for unit tests (already established Phase 4 pattern); no fallback needed for production. |
| Isolated PHP instance (for parity recording) | D-11/D-12 fixture capture against `/_user/api/v1` and `tokens`/`me/locale` | Not probed in this research session (requires the `/media/nvme/dev/golem15/fonoteka` PHP app + its own DB, per Phase 2's `tide` tooling) | — | None — parity recording for this phase's new routes cannot proceed without a working isolated PHP instance; this is an execution-time prerequisite, not a Go dependency. |
| `golang.org/x/crypto` module proxy access | `go get golang.org/x/crypto@latest` | Assumed ✓ (standard Go module proxy, already resolving other dependencies in this environment) | v0.57.0 latest | Pin the already-present transitive version (v0.55.0/v0.54.0) instead of bumping, if network/proxy access is unavailable at implementation time — `bcrypt`'s API has been stable across this entire version range. |
**Missing dependencies with no fallback:**
- A working isolated PHP instance for the Phase 2 `tide` recorder — required before any `/_user/api/v1` fixture can be captured (D-11). This blocks parity verification, not implementation; the Go code can be written and unit-tested against `lagoon`/`bouncer` primitives independently, but the phase cannot be marked verified without it.
**Missing dependencies with fallback:**
- `golang.org/x/crypto` latest tag — fall back to the already-present transitive version if a fresh `go get` isn't possible in the execution environment.
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | Go stdlib `testing`, `testcontainers-go` v0.44.0 (+`modules/postgres`) for real-Postgres integration tests, `stretchr/testify` v1.12.1 for assertions (already direct dependencies in both `go.mod` files) |
| Config file | none — Go's `go test` needs no config file; `testing.Short()` gates testcontainers-backed tests (established convention, confirmed in `lagoon/postgres_test.go`, `postcard/mailpit_test.go`, `plugins/golem15/user/updates/postgres_test.go`) |
| Quick run command | `go test ./... -short` (both `summercms.go` and `fonoteka.go` modules) |
| Full suite command | `go test ./... -race` (Postgres/Mailpit containers included; matches the Phase 1 `scripts/check-phase1.sh`-style pattern of vet+test+race across the workspace) |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| AUTH-01 | Login/register/logout/fetch/refresh return PHP-identical status+body shapes | unit + integration (Postgres) | `go test ./plugins/golem15/user/... -run TestApiController -short` | ❌ Wave 0 |
| AUTH-01 | Sliding refresh accepts an expired-but-within-`refresh_ttl` token; rejects one past it | unit | `go test ./summercms.go/bouncer/... -run TestRefresh` | ❌ Wave 0 |
| AUTH-01 | Blacklist grace period: a just-rotated token stays valid for `blacklist_grace` seconds, then 401s | unit | `go test ./summercms.go/bouncer/... -run TestBlacklistGrace` | ❌ Wave 0 |
| AUTH-02 | `golem15.fonoteka`'s `getApiArray` listener adds `organisation_id/role`, `must_change_password`, `preferred_locale` without `golem15.user` importing `golem15.fonoteka` | unit (compile-time import-direction check + runtime payload assertion) | `go test ./plugins/golem15/... -run TestGetApiArray` | ❌ Wave 0 |
| AUTH-03 | Mint/list/revoke a personal token; scope ceiling rejects `admin`/unknown scopes; `InvScope` middleware 403s an out-of-scope request | unit + integration | `go test ./plugins/golem15/fonoteka/... -run TestTokenApi -short` | ❌ Wave 0 (controller doesn't exist yet; `InvScope`/`TokenGuard` tests already exist from Phase 6) |
| AUTH-04 | 423 on the JWT-authed Fonoteka surface while locked; `me/locale` and change-password remain reachable | integration (full route-table test, mirrors Phase 6's mutual-exclusivity test) | `go test ./... -run TestMustChangePasswordLock -short` | ❌ Wave 0 |
| I18N-02 | `preferred_locale` resolves over `Accept-Language` for an authenticated request, including while locked | unit | `go test ./summercms.go/surf/... -run TestLocaleFromPrincipal` | ❌ Wave 0 |
| QA (parity) | Every new `/_user/api/v1` + `tokens` + `me/locale` route passes the Phase 2 parity harness against the isolated PHP instance | manual-only for recording, then automated replay | `summer parity:replay --manifest fonoteka.go/parity/manifest.yaml` (existing Phase 2 tooling) | ✓ (harness exists; new fixtures do not) |
### Sampling Rate
- **Per task commit:** `go vet ./... && go test ./... -short` (both modules)
- **Per wave merge:** `go test ./... -race` (both modules) + `summer parity:replay` against whatever fixtures exist so far
- **Phase gate:** Full suite green, all D-11 fixtures recorded and replayed, before `/gsd:verify-work`
### Wave 0 Gaps
- [ ] `fonoteka.go/plugins/golem15/user/controllers/api_controller_test.go` — covers AUTH-01
- [ ] `summercms.go/bouncer/mint_test.go`, `refresh_test.go`, `blacklist_test.go` — covers AUTH-01 refresh/blacklist algorithm in isolation from HTTP
- [ ] `fonoteka.go/plugins/golem15/fonoteka/controllers/api/token_api_controller_test.go` — covers AUTH-03
- [ ] `summercms.go/surf/locale_from_principal_test.go` — covers I18N-02
- [ ] `fonoteka.go/parity/fixtures/routes/_user-api-v1-*.yaml` (new, recorded via `tide`) — covers the parity half of every AUTH req
- [ ] Framework install: none — `testcontainers-go`, `testify` already present; only `golang.org/x/crypto` needs `go get` (see Package Legitimacy Audit)
## Security Domain
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---------------|---------|-------------------|
| V2 Authentication | yes | bcrypt (cost from config, default 10), constant-time reset/activation code comparison (`crypto/subtle.ConstantTimeCompare`, D-15), failed-login throttle with suspend/ban (D-16) |
| V3 Session Management | yes | JWT with `exp`/`nbf`/`iat`, sliding refresh bounded by `refresh_ttl`, forever-blacklist on logout, grace-windowed blacklist on rotation, per-user "tokens valid after" cutoff on password change/reset (D-20) |
| V4 Access Control | yes | `InvScope` scope-ceiling middleware (existing, Phase 6) gates personal-token routes by `read\|write\|ai`; owner-scoped queries (`WHERE user_id = ?`) on all token CRUD, matching PHP's no-leak-404 pattern |
| V5 Input Validation | yes | `lagoon.Validate` (extended with `email`/`confirmed`/`different`/`mimes`), `go-playground/validator/v10` underneath |
| V6 Cryptography | yes | `golang.org/x/crypto/bcrypt` (password hashing — never hand-roll), `crypto/rand` (token secrets, jti), `crypto/sha256` (token-at-rest hash), `crypto/subtle` (constant-time code comparison) |
### Known Threat Patterns for this stack
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|----------------------|
| Token replay after logout | Spoofing / Elevation of Privilege | Forever-blacklist on `POST logout` (D-07); every authenticated request does one indexed jti lookup (D-08) |
| Parallel-request 401 storm on token rotation | Denial of Service (self-inflicted) | Blacklist grace period (Pitfall 2) — do not blacklist immediately on refresh |
| Account enumeration via `forgot-password` | Information Disclosure | Enumeration-safe identical 200 body regardless of account existence (D-18), already the PHP behavior — must not regress |
| Credential-stuffing / brute force on `login` | Spoofing | `user-api` rate bucket (120/min, C-04) + per-`(user,ip)` Winter Throttle (5 attempts / 15 min suspend, D-16) — two independent layers, both must be ported, neither substitutes for the other |
| Reset/activation code guessing | Spoofing | Constant-time compare (D-15 hardening over PHP's plain `===`) + new TTL (reset 60 min / activation 72 h, D-15) |
| Personal token privilege escalation via scope confusion | Elevation of Privilege | Server-side `MINTABLE_SCOPES` allow-list (`read,write,ai`) enforced at mint time via `lagoon.Validate`'s `in:` token — a client cannot request a scope outside the ceiling regardless of what it sends |
| Stale/rotated app JWT secret silently downgrading security | Tampering | C-01: empty `JWT_SECRET` fails boot (already enforced since Phase 3, `classes.JWTSecret`) — must remain true after this phase's changes |
| `must_change_password` bypass via a route outside the intended lock group | Elevation of Privilege | `RequirePasswordChange` applied only to the correct group (Phase 6 already scoped this to `/_fonoteka/api/v1`); this phase must verify the lock's exempt-route set still matches D-... exactly and add a route-table assertion test (mirrors Phase 6's mutual-exclusivity test pattern) rather than trusting manual review alone |
## Sources
### Primary (HIGH confidence — read directly this session)
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/user/routes.php` — full route table, all groups, all three rate-limiter definitions
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/ApiController.php` (1524 lines, read in full) — every handler's request/response shape and status code
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/user/classes/TokenExtractor.php`, `JwtServiceProvider.php` — extraction surfaces and cookie parser wiring
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/user/config/jwt.php`, `.env` — TTL/algo/lock_subject/grace_period defaults
- `/media/nvme/dev/golem15/fonoteka/vendor/php-open-source-saver/jwt-auth/src/{Manager.php, Factory.php, Blacklist.php, JWT.php, Claims/{IssuedAt,Expiration,Issuer}.php, Validators/PayloadValidator.php, Providers/JWT/Namshi.php, Providers/Auth/Illuminate.php}` — the entire sliding-refresh/blacklist/iss/prv algorithm, read directly
- `/media/nvme/dev/golem15/fonoteka/vendor/winter/storm/src/Auth/Manager.php`, `Auth/Models/{Throttle,User}.php` — throttle algorithm, activation/reset code semantics (no expiry, confirmed at the cited line range)
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/user/models/User.php` (read in full), `Plugin.php:320-364` (`getApiArray` base payload + event merge)
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/{Plugin.php:210-286, middleware/RequirePasswordChange.php, classes/auth/ApiTokenManager.php, controllers/api/{MeLocaleController,TokenApiController}.php}` — 423 lock, token manager, token CRUD, all read in full
- `git.golem15.com/golem15/summercms` (`summercms.go`) — `bouncer/{jwt,guard,registry,context}.go`, `festival/bus.go`, `postcard/mailer.go`, `wire/response.go`, `surf/{limiter,cors,router}.go`, `towel/context.go`, `lagoon/{validate,attach/{thumb,file}}.go`, `examples/hello/plugins/greeter/plugin.go` — all read directly, confirming exact current API shapes
- `git.golem15.com/golem15/fonoteka` (`fonoteka.go`) — `plugins/golem15/{user,fonoteka}/**` current state (stub plugin, existing token guard/scope middleware, existing models), `go.mod`/`go.work` — read directly
- `go doc github.com/golang-jwt/jwt/v5`, `go doc golang.org/x/crypto/bcrypt` — current API surface for both libraries, run against the actual module cache in this environment
- `sha1("Golem15\User\Models\User")` computed independently in this session (bash `php -r`/Python `hashlib` cross-check) = `a867434cbc213adfbe78a02bed7082a6bd99c883`
### Secondary (MEDIUM confidence)
- `slopcheck install golang.org/x/crypto` output — flagged `[SUS]` on stale/misleading heuristics (module-tag age, proxy metadata gap); overridden with documented rationale in Package Legitimacy Audit.
- `$2y$`/`$2b$` bcrypt cross-compatibility (Assumption A1) — supported by reading `bcrypt.go`'s hash decoder (no minor-version gate) but not verified with a live PHP-hash-vs-Go-verify round trip in this session.
### Tertiary (LOW confidence)
- None — every claim in this document traces to either a direct source read, a `go doc`/module-cache inspection, or an independently-computed hash, and is flagged in the Assumptions Log where verification is incomplete.
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH — only one new package (`golang.org/x/crypto/bcrypt`), already transitively present and API-stable; everything else is already a direct dependency.
- Architecture: HIGH — every pattern (festival.Collect, bouncer.Registry, surf pipeline order, lagoon.Validate's exact current rule set) was read directly from the shipping Phase 1-6 source, not inferred from documentation.
- Pitfalls: HIGH — the six pitfalls all trace to specific PHP vendor source lines read in this session (sliding refresh, blacklist grace, `iss`/`prv` claim construction, throttle keying, current `lagoon.Validate` rule gaps), not general JWT/auth folklore.
**Research date:** 2026-09-22
**Valid until:** 30 days (stable, internal-codebase-driven research; the PHP source it depends on is a fixed reference implementation that doesn't change during this project, and the Go primitives it extends are already merged/shipped code, not a moving external dependency)