Files
summercms/.planning/phases/07-user-plugin-and-authentication/07-RESEARCH.md
2026-09-22 12:37:08 +02:00

77 KiB

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:

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

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

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:

// 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:

// 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:

// 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.
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)

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

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

// 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 (RESOLVED)

  1. RESOLVED: 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. RESOLVED: 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. RESOLVED: 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)