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_tokenguard,inv.scopeand the 423 middleware live ingolem15.fonoteka; token CRUD andme/localego 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 ofApiTokenManager(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|aiwith default['read'], validatedin:read,write,ai. Thescope_ceilingcolumn/logic on OAuth clients is Phase 8. - C-04: The user plugin declares its
user-apibucket (120/min, key user id else IP) through the Phase 6 limiter API.pin-loginand2fa-verifybuckets are not declared (their routes are not ported). - C-05: Mail through
postcard.Sendwith WinterCMS-shape templates; the caller picks-ensiblings frompreferred_locale(P4 D-08/20). Models followFillable/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 middlewarethrottle:user-api, nojwt.auth; auth is per-handler as inApiController::authorize()):OPTIONScatch-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};usermode →{message:'Activation email sent'};adminmode → empty{}200.allow_registrationand the 3-per-IP-per-60-min register throttle (created_ip_addresscount) are ported; client IP comes from the Phase 6 trusted-proxy function. - D-03: Social login: only
GET oauth-providersis 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-completeand the password-bootstrap OTP branch of change-password (428/503) are deferred. Because no Go path can create a user withhas_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): payloadavatar,avatar_url(128 thumb) andhas_avatarare real. This is the port's first HTTP multipart endpoint and sets the pattern Phase 12 reuses (per-groupMaxBytesReaderupload 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 thetwo_factor_requiredshape.
JWT lifecycle
- D-06: Tokens are wire-compatible with PHP's
php-open-source-saver/jwt-authso users stay logged in across cutover: HS256 with the sameJWT_SECRET, claimsiss, 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 theissvalue 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 itsexp— accepted. - D-07: Refresh is PHP's sliding access token, no separate refresh token:
POST refreshwith a token that may be expired but is withinrefresh_ttlof its originaliatreturns{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_periodsemantics 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/v1handlers:Authorization: Beareronly.jwt.authroutes: Bearer, then thetokenandauth_tokencookies (plain, not encrypted). Dropped on both surfaces:?jwt_token=, bodyjwt_token, jwt-auth's?token=query, input-source and route-param parsers. Verified: neithervue-fonoteka-appnorfonoteka-mcpsends 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 writesauth_tokenitself. - 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) — andfonoteka.go's config sets Płytarium's real values (1440, 43200, 10 s). Same framework/app split asapp.locale(P4 D-05).
Parity evidence
- D-11: The
/_user/api/v1routes are absent from the 154-route manifest. They are recorded against the isolated PHP instance with the Phase 2tidetooling (capture rules, private 0600 vars store, no live JWT in git) and added tofonoteka.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) andme/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_codefrom 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'smemorydriver. - 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 besidenuxt-browse: register → fetch → update → change-password → refresh → logout → reuse of the logged-out token is refused; plus amust_change_passworduser hitting 423 on the authenticated surface, thenme/localesucceeding, 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.expireis 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_throttletable: per user + IP counters,attemptLimit5 /suspensionTime15 min from config, suspended and banned states with PHP's error bodies,use_throttlesetting honoured. Durable across restarts and gives Phase 9 a real model for ban/unban. Theuser-apibucket sits in front as in PHP. - D-17: Login restores a soft-deleted user and sends
mail.reactivate, as PHP'safterLogin()does. Guest conversion is dropped:is_guestrows cannot log in, and register with an existing email gets the normal unique-email 422. - D-18: Mail is sent inline through
postcard.Sendbehind 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.activatewas 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_length8). - 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
jwtguard; a token withiatbefore 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 itsiat) so the calling device is not logged out mid-session. - D-21:
must_change_passwordhas 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
jwtguard and the 423 gate so it resolvespreferred_locale→Accept-Language→app.localeeven 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
festivalevent type forgetApiArray(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
/_usergroup never carries the middleware;me/localeis its ownjwt.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_requiredlogin 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.registerconsumers (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}.
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:
// 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'sRoute::options(...)returning empty 204 is redundant in Go —surf's CORS middleware (surf/cors.go:96-99) already short-circuits everyOPTIONSrequest with204 No Contentbefore the request ever reaches route dispatch, for any path inside a CORS-scoped prefix. Just ensure/_user/api/v1is included in whatever path-scoped CORS configuration Phase 6 declared (06-03-PLAN.md) — do not hand-write a wildcard OPTIONS handler. - Relaxing
expvalidation on the general auth guard: Only therefreshhandler's parser may usejwt.WithoutClaimsValidation().bouncer.Verify/NewJWTGuard(used by every other authenticated route) must keepWithExpirationRequired()exactly as Phase 3/6 shipped it. - Treating
permissions/groups/roleas features to build: These are read-only payload fields backed by tables/relations that don't exist in Go yet (Winter Storm's RBACRole/UserGroupmodels). Per CONTEXT.md discretion, stub these as empty/nullin 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 rawinv_...secret; onlytoken_hash. The existing GoTokenGuardalready expects this exactsha256+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
usersstub), 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)
-
RESOLVED: Does PHP's
change-passwordendpoint 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()}— notokenkey 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-passwordis unauthenticated (no presenting token to exempt), so its "valid after" write has no such carve-out to make.
- What we know: The handler body (read in full,
-
RESOLVED: What is the correct
mimeslist interaction withlagoon.Validate's plannedfile/mimesextension 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 inApiController.php:534-536. - What's unclear: Whether the
max:4000unit convention (KB, Laravel's default forfile) should be reproduced as alagoon.Validatetoken or left as a simple, separatehttp.MaxBytesReadercap 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 treatmimes:jpeg,jpg,png,webp,gifas the only newlagoon.Validatetoken actually needed this phase — don't invent amaxunit-conversion rule insidelagoon.Validatefor file sizes when the existing multipart cap mechanism already covers it more cheaply.
- What we know: PHP's rule is
-
RESOLVED: Exact PHP behavior for a migrated user row with
has_self_set_password = falsecallingchange-passwordin 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 ?? truefallback, and CONTEXT.md states "no Go path can create a user withhas_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-passwordhandler can safely assumehas_self_set_passwordis 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 encountersfalse— 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
tiderecorder — required before any/_user/api/v1fixture can be captured (D-11). This blocks parity verification, not implementation; the Go code can be written and unit-tested againstlagoon/bouncerprimitives independently, but the phase cannot be marked verified without it.
Missing dependencies with fallback:
golang.org/x/cryptolatest tag — fall back to the already-present transitive version if a freshgo getisn'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:replayagainst 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-01summercms.go/bouncer/mint_test.go,refresh_test.go,blacklist_test.go— covers AUTH-01 refresh/blacklist algorithm in isolation from HTTPfonoteka.go/plugins/golem15/fonoteka/controllers/api/token_api_controller_test.go— covers AUTH-03summercms.go/surf/locale_from_principal_test.go— covers I18N-02fonoteka.go/parity/fixtures/routes/_user-api-v1-*.yaml(new, recorded viatide) — covers the parity half of every AUTH req- Framework install: none —
testcontainers-go,testifyalready present; onlygolang.org/x/cryptoneedsgo 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(getApiArraybase 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 fullgit.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 shapesgit.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 directlygo 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 environmentsha1("Golem15\User\Models\User")computed independently in this session (bashphp -r/Pythonhashlibcross-check) =a867434cbc213adfbe78a02bed7082a6bd99c883
Secondary (MEDIUM confidence)
slopcheck install golang.org/x/cryptooutput — 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 readingbcrypt.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/prvclaim construction, throttle keying, currentlagoon.Validaterule 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)