21 KiB
Phase 7: User plugin and authentication - Context
Gathered: 2026-09-22 Status: Ready for planning
## Phase BoundaryThe stub golem15.user Go plugin (JWT guard + 4-column User) becomes a real port of the PHP user plugin's SPA contract: register, login, logout, sliding JWT refresh, forgot/reset password, activation, profile update, change-password, avatar upload/remove, marketing consent and oauth-providers, with token minting, a jti blacklist and failed-login throttling. The user payload is extended through a fire-and-collect event (golem15.user.getApiArray) that golem15.fonoteka populates with organisation fields, must_change_password and preferred_locale. In golem15.fonoteka (PHP ownership, P6 D-07/08): personal-token CRUD (/_fonoteka/api/v1/tokens), GET|PUT me/locale, and the 423 lock with its exempt groups. Locale resolves per request from preferred_locale with header fallback (I18N-02).
Repos: fonoteka.go (plugins, fixtures, manifest) and summercms.go (any bouncer/surf/tide seams the port needs). Security-load-bearing: the security-review agent is applied.
Not in this phase: OAuth2.1 authorization server and the oauth guard (Phase 8), backend admin users (Phase 9), River-queued mail (Phase 11), everything listed under Deferred.
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.
<canonical_refs>
Canonical References
Downstream agents MUST read these before planning or implementing.
Planning docs (paths relative to summercms.go/)
.planning/ROADMAP.md§Phase 7 — goal, success criteria, repos.planning/REQUIREMENTS.md— AUTH-01..04, I18N-02.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-CONTEXT.md— guard registry,inv_token/inv.scope, limiter buckets, trusted-proxy client IP, raw groups, response conventions.planning/phases/05-data-layer-full-fidelity/05-CONTEXT.md— Fillable/Hidden/Rules, append-only migrations, schema diff, attachments and thumbs.planning/phases/04-cli-scaffolding-i18n-and-mail/04-CONTEXT.md— mail template shape, caller-picked locale suffix, phrasebook, plugin directory layout,make:*.planning/notes/plugin-layout-winter-directories.md— where controllers/classes/middleware/console go.planning/research/PITFALLS.md,.planning/research/STACK.md— golang-jwt usage, dependency rules
PHP contract — user plugin (absolute, /media/nvme/dev/golem15/fonoteka/)
plugins/golem15/user/routes.php— groups, middleware, the three named limiters (lines 8-18), route listplugins/golem15/user/controllers/ApiController.php— every handler, bodies and status codes (authorize():1412, login :42, logout :104, fetch :128, refresh :149, activate :180/:208, register :251, update :364, change-password :412, avatar :526/:561, marketing-consent :595, forgot :638, reset :679, oauth-providers :1066,makeResetUrl:1501)plugins/golem15/user/Plugin.php:329-358 — base user payload and thegolem15.user.getApiArraymerge; :233-246 mail templatesplugins/golem15/user/classes/TokenExtractor.php,classes/JwtServiceProvider.php:44-52,middleware/JwtAuthenticate.php— the two extraction surfaces (see D-09 for what is dropped)plugins/golem15/user/classes/AuthManager.php— logout = session +JWTAuth::invalidate(true),withTrashed, guest handling (dropped)plugins/golem15/user/models/User.php— fillable/hidden/rules,afterLoginrestore (:497-517),isRegisterThrottled(:616-630),getJWTCustomClaims(:775)plugins/golem15/user/models/Throttle.php,models/Organisation.php,updates/v3.2.0/*,updates/v3.2.1/*,updates/v2.3.7/*plugins/golem15/user/config/jwt.php, rootconfig/jwt.php,.env(JWT_TTL=1440,JWT_REFRESH_TTL=43200, grace 10 s), rootconfig/auth.php(guards, throttle 5/15),config/hashing.php,plugins/golem15/user/config/config.php. Do NOT treatplugins/golem15/user/config/auth.phpas authoritative (shadow copy).vendor/winter/storm/src/Auth/Models/User.php:209-292 — activation/reset code semantics (no expiry); StormThrottleandManager::authenticate()plugins/golem15/user/views/mail/{activate,restore,reactivate}.htmplugins/golem15/user/tests/unit/controllers/*,tests/security/AuthenticationTest.php
PHP contract — fonoteka plugin side
plugins/golem15/fonoteka/Plugin.php:240-252 (getApiArray listener), :218-221 and :284 (middleware aliases), :182 (golem15.user.registerlistener — invitation/provisioning is Phase 12; the event must exist)plugins/golem15/feedback/Plugin.php:38-43 — second getApiArray listener (feedback_widget_hidden), ported with the feedback pluginplugins/golem15/fonoteka/middleware/RequirePasswordChange.php,routes.php:61-67 (locale group), :282-284 (tokens)plugins/golem15/fonoteka/controllers/api/TokenApiController.php,MeLocaleController.php,MeTokenController.php,classes/auth/ApiTokenManager.php,models/ApiToken.php,classes/OrgAccess.phpplugins/golem15/fonoteka/tests/security/TokenSurfaceIsolationTest.php
Client contract
/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/stores/auth.ts,app/utils/tokenRefresh.ts,app/plugins/token-refresh.client.ts,app/plugins/api.ts(401 → refresh retry),app/plugins/preferred-locale.client.ts,app/middleware/force-password-change.global.ts,shared/types/fonoteka.ts:317-319
Go code to extend
fonoteka.go/plugins/golem15/user/(plugin.go, models/, classes/user_lookup.go, config/config.yaml, updates/)fonoteka.go/plugins/golem15/fonoteka/middleware/must_change_password.go,middleware/token_scope.go,classes/auth/token_guard.gofonoteka.go/parity/manifest.yaml,parity/capture-rules.yaml,parity/fixtures/summercms.go/bouncer/(jwt.go, guard.go, registry.go),festival/bus.go(Collect),postcard/,phrasebook/,surf/(limiter, route table),tide/
</canonical_refs>
<code_context>
Existing Code Insights
Reusable Assets
bouncer.NewJWTGuard+ named guard registry,bouncer.User(ctx)and the credential accessor — minting, blacklist and the "valid after" check attach here without changing 401 bodies.festival.Bus.Collect[T]— merges listener maps, later wins; the literal port ofEvent::fire('golem15.user.getApiArray', …, false)+array_merge.postcard.Mailerwithmemory/log/smtpdrivers — mail assertions in unit tests, Mailpit integration test pattern.- Phase 5
system_filesattachments,Thumb(w,h,mode),lagoon.Fill,Rules()translation,lagoon.Encrypted. - Phase 6
surf.Limiternamed buckets + inlinethrottle:N,M, trusted-proxy client IP, route table +route:list, JSON writer / Carbon time / nullable bool types. tiderecord/replay, capture rules, vars store, seed hooks (app-owned seam from Phase 2).fonotekaplugin'sMustChangePasswordmiddleware andinv.scopealready ship with exact bodies.
Established Patterns
- Routes written line by line from
routes.phpwith PHP middleware names (P3 D-14, P6 D-05); groups are builders; only real handlers are mounted. - Handlers build DTOs explicitly; nothing rewrites responses after the handler (P6 D-17).
models/is a leaf package; services inclasses/; GORM callbacks registered fromclasses/orplugin.go(P4 D-11).- Secrets and keys fail boot when missing; tests use fixed test-only values.
- Framework never imports the app; anything Płytarium-specific (TTLs, locale list
pl,en) lives infonoteka.goconfig or call sites.
Integration Points
golem15.userplugin: new routes group, controllers, services, migrations (appended), console command, mail templates, lang files, limiter bucket, event types.golem15.fonotekaplugin:Requiresuser; listens togetApiArray; adds tokens CRUD,me/localegroup, token manager.bouncer: mint/refresh/blacklist seam and extractor configuration (two surfaces).- Parity: manifest growth, new capture rules for JWTs in user responses, DB-reading seed hook,
nuxt-authflow.
</code_context>
## Specific Ideas- "jwt_token GET param should be deprecated by now per Golem15 Stack security upgrade, we don't want tokens in access logs. But we need to port the split for Bearer / Cookies as it's used through all projects." — this copy of the PHP plugin predates that upgrade; the Go port follows the upgraded rule.
- The user expected PHP reset codes to expire; source says they do not in this codebase. If another Golem15 project carries a newer user plugin with expiry, point the researcher at it and port its TTL values instead of the defaults in D-15.
- Presence will be built in a future milestone; the legacy CMS theme flows are legacy and are not carried over.
- 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.
Phase: 7-User plugin and authentication Context gathered: 2026-09-22