Files
summercms/.planning/phases/07-user-plugin-and-authentication/07-07-PLAN.md

46 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
07-user-plugin-and-authentication 07 execute 6
07-06
../fonoteka.go/parity/php_parity.sh
../fonoteka.go/parity/schema_diff_test.go
../fonoteka.go/parity/manifest.yaml
../fonoteka.go/parity/parity_test.go
../fonoteka.go/parity/parity_contract_test.go
../fonoteka.go/parity/user_api_seed_test.go
../fonoteka.go/parity/nuxt_flow_test.go
../fonoteka.go/parity/fixtures/routes/GET___user_api_v1_fetch_user-api__reused.yaml
../fonoteka.go/parity/fixtures/nuxt/nuxt-auth.yaml
../fonoteka.go/plugins/golem15/user/controllers/api_controller.go
../fonoteka.go/plugins/golem15/user/controllers/winter_error_page.html
../fonoteka.go/plugins/golem15/user/classes/codes.go
../fonoteka.go/plugins/golem15/user/classes/codes_test.go
../fonoteka.go/plugins/golem15/user/account_test.go
false
AUTH-01
truths artifacts key_links
All 15 /_user/api/v1 routes and both nuxt flows (nuxt-auth, nuxt-auth-lock) replay green against the Go backend
Fetch after logout is 401 in both the re-recorded PHP corpus (file-cache backed, matching production CACHE_DRIVER/blacklist_enabled behavior) and Go, with byte-identical bodies
An authenticated activate or activate-by-code call against an already-activated user reproduces PHP's production Winter error page byte-for-byte in Go (500, text/html), matching the real throw path in Winter's User::attemptActivation, not a blanket 'wrong code' rule
No route replay depends on the unported /_fonoteka/api/v1/onboarding/* endpoints -- the 15 user-api routes and the new nuxt-flow test seed their own state directly against Postgres
go vet and go test ./... -race are green in both summercms.go and fonoteka.go, including updated activation and corpus-count tests
path provides
../fonoteka.go/parity/manifest.yaml 15 user-api route entries flipped from status: pending to status: ported, each with seed_hook: user-api, only after every one of its cases replays green
path provides
../fonoteka.go/plugins/golem15/user/controllers/winter_error_page.html byte-exact copy of the recorded Winter production error page, embedded and reused by Activate and ActivateByCode
path provides
../fonoteka.go/plugins/golem15/user/classes/codes.go IsAlreadyActivated helper distinguishing PHP's uncaught already-active throw from the ordinary wrong/expired code path
path provides
../fonoteka.go/parity/user_api_seed_test.go seedUserAPI: a direct-DB (not HTTP-onboarding) seed hook that makes Alice login-ready for the 15 user-api routes
path provides
../fonoteka.go/parity/nuxt_flow_test.go an in-process replay gate for fixtures/nuxt/nuxt-auth.yaml and nuxt-auth-lock.yaml against the real Go backend, mirroring TestParityCorpus's replayPortedRoute primitives
from to via pattern
../fonoteka.go/plugins/golem15/user/controllers/api_controller.go ../fonoteka.go/plugins/golem15/user/controllers/winter_error_page.html go:embed + writeWinterErrorPage winterErrorPage
from to via pattern
../fonoteka.go/parity/manifest.yaml ../fonoteka.go/parity/parity_test.go expectedPortedRoutes coverage assertion expectedPortedRoutes
from to via pattern
../fonoteka.go/parity/php_parity.sh ../fonoteka.go/parity/fixtures/routes/GET___user_api_v1_fetch_user-api__reused.yaml CACHE_DRIVER=file makes PHP's jwt-auth blacklist persist across requests, matching production blacklist_enabled=true CACHE_DRIVER
from to via pattern
../fonoteka.go/parity/manifest.yaml ../fonoteka.go/parity/user_api_seed_test.go seed_hook: user-api replaces the broken m.Seed HTTP-onboarding fallback seed_hook: user-api
Close the single critical gap from 07-VERIFICATION.md: the 15 recorded `/_user/api/v1` routes and the `nuxt-auth`/`nuxt-auth-lock` client flows are `pending` and have never been replayed against the Go backend (`TestParityCorpus` is 7 ported, 162 pending, 0 failing). Make every one of them replay green and flip them to `ported`, without weakening any recorded PHP contract.

This is a single plan per the plan-count checkpoint decision: handler fixes, fixture corrections (via re-recording, never hand-editing), the green replay, the manifest flips, and the unit-test updates for the changed behavior all land here, with the test-update task ordered last. This exceeds the usual 2-3-task guidance for a plan because the user explicitly locked "one plan" for this gap closure at the plan-count checkpoint; splitting further was rejected.

Three decisions are locked and MUST NOT be re-litigated by the executor:

  1. Fetch-after-logout stays 401 in Go. Production PHP DOES blacklist on logout (AuthManager::logout calls JWTAuth::invalidate(true), blacklist_enabled defaults true in config/jwt.php:227, production .env has CACHE_DRIVER=file). The parity harness previously ran the isolated PHP with CACHE_DRIVER=array, under which Laravel's built-in dev server does not persist cached state (including the jwt-auth blacklist) across separate requests, so the corpus recorded 200 after logout. That recording is a harness artifact, not the contract — CONTEXT.md D-07 and D-14 already lock Go's 401. The fix is to switch the isolated PHP instance to a persistent cache driver and RE-RECORD the affected fixtures, not to change the Go handler.
  2. Wrong-code activate is not simply "return 200." Winter's User::attemptActivation (vendor/winter/storm/src/Auth/Models/User.php:223-238) only returns false for a wrong/expired code on a NOT-yet-activated user (harmless, no exception). It THROWS Exception('User is already active!') uncaught whenever the target user IS ALREADY ACTIVATED, regardless of whether the presented code is right or wrong — and neither ApiController::activate() nor ApiController::activateByCode() catches a plain \Exception around that call, so the throw reaches Laravel's global handler and renders the generic production error page (500, text/html) under APP_DEBUG=false. The two recorded fixtures (POST .../activate with {{jwt:alice}}, POST .../activate-by-code with 1!nope) both hit this path because Alice is already activated. The fix in Go is therefore keyed on "is this user already activated", not on "is the code wrong."
  3. Seeding for these 15 routes and both nuxt flows must not go through /_fonoteka/api/v1/onboarding/*. That endpoint group is entirely unimplemented in Go (plugins/golem15/fonoteka/routes.go:33 registers it with an empty closure — no handler is mounted), and its own manifest entries are themselves still status: pending with a recorded 409-for-an-empty-body case, not the 200 a real org-creation call needs. The manifest's global seed: block (spec/fixture: seed/bootstrap.yaml) POSTs to that unported endpoint, so any route relying on it (via an empty seed_hook) would fail before its own case ever runs. Every currently-ported route avoids this by declaring its own seed_hook (genres), which seeds via a direct GORM upsert instead of HTTP. This plan gives the 15 user-api routes and the new nuxt-flow test the same kind of direct-DB seeding — never the m.Seed HTTP path.

Purpose: prove the PHP contract is actually the acceptance test for the user plugin, not just a design intent — AUTH-01 stays blocked until this replay is green. Output: a green TestParityCorpus (22 ported / 147 pending / 0 failing), a green nuxt-flow replay, corrected fixtures, corrected Go handlers, and updated unit tests.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md @.planning/phases/07-user-plugin-and-authentication/07-VERIFICATION.md @.planning/phases/07-user-plugin-and-authentication/07-REVIEW.md @.planning/phases/07-user-plugin-and-authentication/07-05-SUMMARY.md @.planning/phases/07-user-plugin-and-authentication/07-06-SUMMARY.md tide's fixture/flow loader (summercms.go/tide) is the exact byte source for the embedded Winter error page and the primitives the new nuxt-flow test reuses: - func tide.LoadFlow(path string) (Flow, error) - type Flow struct { Steps []Step; ... } - type Step struct { Response Response; ... } - type Response struct { Body Body } -- Body is a string type holding verbatim bytes - func tide.OpenStore(path string) (*Store, error) -- "" means memory-only - func tide.ReplayFlow(ctx context.Context, flow Flow, cfg ReplayConfig) (Result, error) -- Result{OK bool, Steps []StepResult}; ReplayFlow returns a non-nil *tide.MismatchError whenever any step's diff is non-empty, so checking the returned err (exactly as replayPortedRoute already does) is sufficient -- no separate result.OK check is needed.

WARNING confirmed this session: the manifest's global seed: block (spec/fixture: seed/bootstrap.yaml) is NOT a usable seeding path for the 15 user-api routes or the new nuxt-flow test. That flow's first two steps call GET /_fonoteka/api/v1/onboarding/status and POST /_fonoteka/api/v1/onboarding/bootstrap, and plugins/golem15/fonoteka/routes.go:33 registers the onboarding group with an EMPTY closure (r.Group(..., func(g pact.Router) {})) -- no handler is mounted there at all. The manifest's own onboarding entries (manifest.yaml lines 2103-2139) are themselves still status: pending, and their recorded bootstrap case is a 409 for an empty {} body, not the 200 a real org-creation body like bootstrap.yaml's would produce. replayPortedRoute only falls into this m.Seed HTTP-replay branch when route.SeedHook == ""; giving a route its own seed_hook skips that branch entirely. Every one of the 7 currently-ported routes already avoids this trap via seed_hook: genres (a direct GORM upsert, see genres_seed_test.go) -- Task 2 gives the 15 user-api routes and the nuxt-flow test the equivalent, for the same reason.

summercms.go/tide/normalize.go's isIDKey/maskLeaf: any JSON key literally named id, or ending in _id (and not _at), is masked to the literal string "<id>" on BOTH sides before normalizeJSON diffs a JSON body. This means the literal "id":5/"id":6 values recorded in nuxt-auth.yaml/nuxt-auth-lock.yaml do NOT need to be reproduced exactly by Go's replay -- only that a valid JSON integer is present there, and that every OTHER field (email, must_change_password, is_activated, etc.) is correct.

../fonoteka.go/parity/db_capture.go (already reviewed this session, package main): captureUserCode(ctx, store *tide.Store, key, email, column string, read codeReader) error and postgresCodeReader(db *sql.DB) codeReader already exist and are unit-tested by db_capture_test.go, but neither is wired into TestParityCorpus's replay path yet -- that wiring is part of this plan's Task 2.

../fonoteka.go/parity/parity_test.go (already reviewed this session): parityDB(t) *sql.DB returns ONE shared testcontainers Postgres connection for the entire TestParityCorpus run (not reset between route subtests) -- so a user created by an earlier route's replay (e.g. register's user-mode case creating "Ada") is still present in the database when a later route's replay runs (e.g. activate-by-code), because for _, route := range m.Routes executes t.Run subtests sequentially in manifest file order and register (manifest line ~3053) already precedes activate-by-code (line ~3129) and forgot-password (line ~3081) already precedes reset-password (line ~3097). replayPortedRoute's tide.Store is fresh (memory-only) PER ROUTE, so {{code:activate}}/{{code:reset}} must be re-populated via a live DB read (postgresCodeReader), not carried over from an earlier route's captured vars.

../fonoteka.go/parity/synthetic_test.go seedHooks map[string]seedHookFunc (already reviewed): the existing pattern for name-keyed setup functions invoked before a route's cases replay -- Task 2's new "user-api" hook and the new DB-capture wiring for activate-by-code/reset-password both follow this same shape (an addition to this map, or an equivalent keyed helper alongside it), not a new mechanism.

Task 1: Switch the isolated PHP instance to a persistent cache and re-record the logout-affected fixtures ../fonoteka.go/parity/php_parity.sh, ../fonoteka.go/parity/fixtures/routes/GET___user_api_v1_fetch_user-api__reused.yaml, ../fonoteka.go/parity/fixtures/nuxt/nuxt-auth.yaml, ../fonoteka.go/parity/schema_diff_test.go ../fonoteka.go/parity/php_parity.sh (full file, 75 lines, already read this session -- export_env sets CACHE_DRIVER=array, refuse_db guards the isolated SQLite DB path only, not any cache path); /media/nvme/dev/golem15/fonoteka/config/cache.php (line 21: 'default' => env('CACHE_DRIVER', 'file'); line 52: 'path' => storage_path('framework/cache') -- NOT env-overridable; do not edit this file, it is PHP source of truth); /media/nvme/dev/golem15/fonoteka/storage/framework/cache/.gitignore (already confirmed this session: blanket * / !.gitignore, so file-cache entries never enter git regardless of path); /media/nvme/dev/golem15/fonoteka/config/jwt.php (line 227: 'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', true)); ../fonoteka.go/parity/schema_diff_test.go (lines 16-31, already read this session: allowedDiffs["jwt_blacklist"] comment currently claims "PHP JWT logout does not blacklist; fetch after logout stays 200 in the recorded corpus" -- this is the wrong claim to correct); ../fonoteka.go/parity/fixtures/routes/GET___user_api_v1_fetch_user-api__reused.yaml (full file, already read this session: currently records 200 with Alice's user payload); ../fonoteka.go/parity/fixtures/nuxt/nuxt-auth.yaml (last ~30 lines, already read this session: the reuse step currently records 200 with the Nuxt user's payload after logout); ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go lines 119-165 (Logout/Fetch) and line 1038-1040 (writeUnauthorized) -- Go's existing 401 body is {"error":true,"message":"Unauthorized"}, matching PHP ApiController::fetch()'s catch (AuthenticationException|TokenBlacklistedException $e) branch (ApiController.php lines 133-142) -- this is the body the re-recorded PHP fixture is expected to now match byte-for-byte; do not invent a different Go body to match a different PHP shape. In php_parity.sh's export_env, change "export CACHE_DRIVER=array" to "export CACHE_DRIVER=file" (Laravel's own config default is already file; set it explicitly so the isolated instance's intent is not silent). Add "php artisan cache:clear" to the reset subcommand, after winter:up, so cache entries left over from a developer's own manual runs of this SAME $PHP_ROOT checkout never bleed into a fresh recording session (file-cache entries persist across separate php artisan serve invocations, unlike the previous array driver, whose values reset with every separate PHP process the built-in dev server spawns per request). Confirm via "git status --porcelain storage/framework/cache" in $PHP_ROOT before and after this task that no cache file is ever staged (the directory's own .gitignore already blocks it, but visually confirm -- this directory lives outside $PARITY_ROOT, inside the shared $PHP_ROOT checkout, unlike the SQLite DB).
Run "../fonoteka.go/parity/php_parity.sh reset" (now clears the persistent cache too), then "../fonoteka.go/parity/php_parity.sh serve &" so it stays up for the rest of this task.

Re-record, using "summer parity:record" against http://127.0.0.1:8423 exactly as 07-05 did (same --rules=capture-rules.yaml --vars=&lt;private path&gt; --manifest=manifest.yaml --fixtures=fixtures/routes --spec=&lt;one-off case YAML&gt;): the GET /_user/api/v1/fetch "reused" case (login, then logout, then fetch with the same bearer) and the nuxt-auth.yaml flow's final "reuse" step (its logout step already exists in the flow; only the final reuse step's recorded response needs updating -- re-record the whole flow rather than hand-editing one step's body, so any other step affected by the cache-driver switch is caught too). Confirm the newly recorded status is 401 with body {"error":true,"message":"Unauthorized"} (PHP's TokenBlacklistedException catch branch) -- if the recorded body differs from this, that is real: write PHP's actual body into the fixture and note the discrepancy in this plan's SUMMARY, do not force this expected shape.

Grep the FULL corpus (not just the two files above) for any other fixture whose recorded body could plausibly depend on Laravel's Cache facade (rate-limit/throttle counters backed by cache rather than a DB table, any Cache::remember-wrapped query) -- the T-06-09 note recorded that CACHE_DRIVER=array previously made certain 429 bodies unrecordable entirely, which is direct evidence the cache driver affects more than just the blacklist. Run "go run ./parity/check_corpus.go --require-recorded --require-clients --check-secrets" and "go test ./parity/... -run 'TestParityCorpus|TestParityContract' -short" to confirm nothing else regresses at the recorded level (full replay against Go happens in Task 2 once Task 1's fixtures are in). Treat any unexpected drift in an ALREADY-ported fixture (the 7 routes currently status: ported) as a blocker to investigate and report in the SUMMARY, not to silently re-record.

Correct the wrong comment in schema_diff_test.go's allowedDiffs["jwt_blacklist"] entry (currently: "PHP JWT logout does not blacklist; fetch after logout stays 200 in the recorded corpus") to state the corrected finding: PHP DOES blacklist on logout in production (blacklist_enabled defaults true, AuthManager::logout calls JWTAuth::invalidate(true)); the previous 200-after-logout recording was a CACHE_DRIVER=array harness artifact from the isolated PHP dev server not persisting cache across requests, now corrected by re-recording under CACHE_DRIVER=file. jwt_blacklist remains a Go-only EXTRA TABLE relative to the frozen pre-Phase-7 PHP schema snapshot (the snapshot predates the user plugin entirely, per the existing 07-06 finding) -- only the reasoning comment is wrong, not the allow-list entry itself.
curl -sf -X POST http://127.0.0.1:8423/_user/api/v1/login -H 'Content-Type: application/json' -d '{"email":"alice@parity.test","password":"parity-alice-pass"}' | grep -q '"token"' Confirm the isolated PHP instance is running with CACHE_DRIVER=file against the parity-only SQLite database (not the developer's own), that storage/framework/cache under $PHP_ROOT never gets staged in git, and that the re-recorded fetch-after-logout fixtures now show 401 with PHP's real blacklisted-token body (or, if the recorded body differs from the expected shape, that the actual recorded body has been used instead and the discrepancy is flagged). Type "ready" once the isolated PHP instance is confirmed running under the persistent cache driver and the re-recorded fixtures show a 401 blacklist body. - php_parity.sh's export_env sets CACHE_DRIVER=file, and its reset subcommand runs "php artisan cache:clear" after winter:up - "git status --porcelain storage/framework/cache" in $PHP_ROOT (/media/nvme/dev/golem15/fonoteka) returns empty both before and after recording - GET___user_api_v1_fetch_user-api__reused.yaml and the final reuse step of fixtures/nuxt/nuxt-auth.yaml now record status 401 (not 200), with whatever body PHP actually returns, named explicitly in the SUMMARY - schema_diff_test.go's jwt_blacklist comment no longer claims "PHP JWT logout does not blacklist" - "go run ./parity/check_corpus.go --require-recorded --require-clients --check-secrets" passes with no secret leaks in the re-recorded files - Any drift discovered in an already-ported fixture during the corpus re-check is either explained as expected (and why) or flagged as a blocker in the SUMMARY -- never silently re-recorded The isolated PHP instance persists its cache across requests; the two logout-affected fixtures now record PHP's real (blacklisting) fetch-after-logout behavior; the wrong "PHP does not blacklist" comment is corrected; no secret is committed; no drift in the existing 7 ported routes is left unexplained. Task 2: Fix the already-activated quirk, seed the 15 routes and both nuxt flows without the unported onboarding endpoint, and close every remaining diff ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go, ../fonoteka.go/plugins/golem15/user/controllers/winter_error_page.html, ../fonoteka.go/plugins/golem15/user/classes/codes.go, ../fonoteka.go/parity/parity_test.go, ../fonoteka.go/parity/user_api_seed_test.go, ../fonoteka.go/parity/nuxt_flow_test.go, ../fonoteka.go/parity/manifest.yaml ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go lines 305-385 (Activate, ActivateByCode) and lines 744-774 (authenticate -- already loads a fresh *models.User row, so Activate needs no extra DB read) and lines 902-912 (splitCode); ../fonoteka.go/plugins/golem15/user/classes/codes.go lines 115-165 (VerifyActivationCode, takeUser -- takeUser(ctx, db, id, unscoped bool); unscoped=false matches PHP's default-scoped UserModel::find()); /media/nvme/dev/golem15/fonoteka/vendor/winter/storm/src/Auth/Models/User.php lines 223-238 (attemptActivation -- throws only when $this->is_activated is already true; returns false for a wrong/expired code on a not-yet-activated user); /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/ApiController.php lines 180-245 (activate, activateByCode -- neither wraps the attemptActivation call in a catch for a plain \Exception, so the throw reaches Laravel's global handler); ../fonoteka.go/parity/fixtures/routes/POST___user_api_v1_activate_user-api.yaml and POST___user_api_v1_activate-by-code_user-api__invalid.yaml (full files, already read this session and confirmed byte-identical HTML bodies apart from the request; both use an already-activated user -- Alice, and Alice via user id 1 respectively); ../fonoteka.go/parity/fixtures/routes/POST___user_api_v1_activate-by-code_user-api.yaml (full file, already read this session: the SUCCESS case, using a distinct not-yet-activated user "Ada" / activate@parity.test, id 3, with {{code:activate}} substituted into the request); summercms.go/tide/diff.go lines 1-27 (compareBodies: non-JSON content types like text/html compare via diffBytes, which requires an EXACT byte match including any trailing newline -- there is no normalizer hook for non-JSON bodies, so the embedded HTML must be copied verbatim from the fixture, never hand-retyped); summercms.go/tide/normalize.go lines 112-121 (isIDKey -- confirms "id" and "*_id" JSON keys are masked to "" before comparison; see the WARNING/id-masking note in <interfaces>); ../fonoteka.go/parity/db_capture.go (full file, 124 lines, already read this session: captureUserCode(ctx, store, key, email, column string, read codeReader) error and postgresCodeReader(db *sql.DB) codeReader already exist and are unit-tested but are not yet called from TestParityCorpus's replay path); ../fonoteka.go/parity/parity_test.go lines 1-256 (full replay harness, already read this session: seedHooks map, invokeSeedHook, replayPortedRoute's per-route tide.Store and shared-across-subtests parityDB(t); the m.Seed HTTP-onboarding fallback branch this task must NOT use for these routes); ../fonoteka.go/parity/genres_seed_test.go (full file, 173 lines, already read this session: seedGenres, upsertParityAlice -- upsertParityAlice creates Alice with Password:"" and never sets IsActivated, since it only exists to mint a bypass JWT for read-only genre routes; the new user-api seed hook must upgrade whatever row this leaves behind with a real password hash and IsActivated:true, not assume Alice is already login-ready; mintTestJWT is the existing test-JWT-minting helper to reuse for jwt:alice); ../fonoteka.go/parity/genre_smoke_test.go line 142 (an existing raw-SQL "INSERT INTO users (email, password, must_change_password) VALUES (...)" precedent for directly seeding a user row inside a parity test -- that one leaves password empty; the new lock-user insert needs a real bcrypt hash since it goes through the real Login handler, so use gdb.Create(&models.User{...}) with bouncer.HashPassword instead of raw SQL); ../fonoteka.go/plugins/golem15/fonoteka/routes.go line 33 (onboarding group registered with an empty closure -- confirms no handler is mounted, see the WARNING in <interfaces>); ../fonoteka.go/parity/manifest.yaml lines 2103-2139 (the onboarding/status and onboarding/bootstrap entries, both still status: pending; the bootstrap case is recorded for an EMPTY {} body at 409, not a real org-creation body) and lines 2980-3236 (the 15 auth_group: user-api entries, all currently status: pending with no seed_hook, and their exact id: strings, needed verbatim for the manifest flip and for parity_contract_test.go's allow-list in Task 3); ../fonoteka.go/parity/fixtures/nuxt/nuxt-auth.yaml (full file -- its own first step registers its own "nuxt@parity.test" user through the real Register handler; it needs no pre-seeded identity) and ../fonoteka.go/parity/fixtures/nuxt/nuxt-auth-lock.yaml (full file, already read this session: its first step logs in "lock@parity.test"/"parity-alice-pass" expecting must_change_password:true and is_activated:true -- a user this flow itself never creates) -- neither flow is currently replayed by any Go test (confirmed this session: no go file references nuxt-auth, nuxt-auth-lock, or even the pre-existing working nuxt-browse.yaml anywhere in the repo). In classes/codes.go, add an exported IsAlreadyActivated(ctx context.Context, db *gorm.DB, userID uint) (bool, error) that calls the existing unexported takeUser(ctx, db, userID, false) (scoped, matching PHP's UserModel::find()) and returns (user.IsActivated, nil), or (false, nil) when the user does not exist, or (false, err) on a real DB error. Do not change VerifyActivationCode's existing signature or behavior -- it stays exactly as-is for the not-yet-activated path.
Create controllers/winter_error_page.html containing the EXACT recorded HTML body bytes from POST___user_api_v1_activate_user-api.yaml's single step (confirmed byte-identical to POST___user_api_v1_activate-by-code_user-api__invalid.yaml's body). Extract these bytes programmatically -- write a short throwaway program that calls tide.LoadFlow on that fixture path and writes flow.Steps[0].Response.Body verbatim to winter_error_page.html, then delete the throwaway program; do not hand-copy the HTML out of the YAML file, since diffBytes requires an exact match including the trailing newline and manual transcription risks an off-by-one whitespace diff.

In api_controller.go, add "embed" to the import block, embed the new file (a go:embed directive for winter_error_page.html into an unexported []byte), and add a writeWinterErrorPage(w http.ResponseWriter) helper that sets Content-Type: text/html; charset=UTF-8, sets Cache-Control: no-cache, private (matching the recorded header), writes status 500, and writes the embedded bytes verbatim.

In Activate: after authenticate resolves user (already a fresh DB row per authenticate's own Where("id = ?", id).Take), check user.IsActivated directly -- if true, call writeWinterErrorPage and return before touching readFields/VerifyActivationCode at all (PHP's throw happens before any response body is built). If false, the existing code path (discard VerifyActivationCode's result, re-read, return 200 with the payload) is already correct for a not-yet-activated user with a wrong code and needs no further change.

In ActivateByCode: after splitCode succeeds, call classes.IsAlreadyActivated(codeContext(r.Context(), app), gdb, id) before calling VerifyActivationCode. On a real error, writeOpaque500. If already activated, call writeWinterErrorPage and return. Otherwise proceed exactly as today (VerifyActivationCode returns 422 on failure, mints a token and returns 200 on success).

Give the 15 user-api routes their own seed_hook instead of the broken m.Seed HTTP-onboarding fallback (see the WARNING in &lt;interfaces&gt;). Add ../fonoteka.go/parity/user_api_seed_test.go with a new seedUserAPI(ctx context.Context, db *sql.DB, store *tide.Store) error that mirrors seedGenres: call the existing upsertParityAlice(ctx, gdb) (already in the same package via genres_seed_test.go, so Alice keeps whatever id she already has from the earlier genres-hooked routes' replay), then, only if her Password field is still empty (the state upsertParityAlice leaves her in when she is freshly created), hash "parity-alice-pass" via bouncer.HashPassword(10, ...) and update both Password and IsActivated:true on her row directly via gdb (matching what the fetch/login/activate fixtures need: a real bcrypt-verifiable password and an already-activated account) -- skip the hash/update entirely if a later call finds her already in this state, so repeated invocations across the 15 routes stay cheap and idempotent. Mint and store.Set("jwt:alice", ...) via the existing mintTestJWT helper exactly as seedGenres does, since several of the 15 routes' fixtures use Authorization: Bearer {{jwt:alice}} directly. Register seedUserAPI in parity_test.go's seedHooks map under the key "user-api", and add seed_hook: user-api to all 15 manifest.yaml entries under auth_group: user-api.

Wire the D-12 two-step DB-capture into the replay path for the activate-by-code and reset-password routes: extend parity_test.go (or a small new helper alongside replayPortedRoute) with a route-ID-keyed map that, for "POST /_user/api/v1/activate-by-code user-api", calls captureUserCode(ctx, store, "code:activate", "activate@parity.test", "activation_code", postgresCodeReader(db)) before that route's cases replay (after its own seed_hook: user-api has already run), and for "POST /_user/api/v1/reset-password user-api" calls the equivalent for "code:reset"/"reset_password_code" against whichever identity that case's own recorded fixture implies (determine this from POST___user_api_v1_reset-password_user-api.yaml's known values and, if the specific email is not otherwise recoverable, register and forgot-password a fresh identity as part of this same setup step, mirroring how register's user-mode case creates Ada for activate-by-code). Confirm via the shared, sequential parityDB(t) connection (manifest order already has register before activate-by-code, and forgot-password before reset-password, so the prerequisite row already exists in Postgres by the time each capture runs) that this captures a real, freshly-issued code rather than a stale one.

Add ../fonoteka.go/parity/nuxt_flow_test.go with a new TestUserAPINuxtFlows (skipped under -short, matching parityDB's existing convention) that opens the same parityDB(t) / applyPluginMigrations / httptest.NewServer(newTarget(t, db)) triple replayPortedRoute already uses. Do NOT replay m.Seed's bootstrap.yaml (it needs the unported onboarding endpoint). fixtures/nuxt/nuxt-auth.yaml needs no pre-seeding beyond the migrations already applied -- its own first step registers its own "nuxt@parity.test" user through the real Register handler. Before replaying fixtures/nuxt/nuxt-auth-lock.yaml, insert its missing prerequisite directly: gdb.Create(&amp;models.User{Email: "lock@parity.test", Password: &lt;bouncer.HashPassword(10, "parity-alice-pass")&gt;, IsActivated: true, MustChangePassword: true}) (mirroring genre_smoke_test.go line 142's raw-SQL precedent, but through GORM so a real bcrypt hash can be set). The recorded fixture bodies' literal "id":5/"id":6 values do not need to be reproduced exactly -- tide's normalizeJSON masks every "id"/"*_id" key to "<id>" before comparing (see &lt;interfaces&gt;), so whichever auto-increment id Postgres actually assigns to the Nuxt user and the lock user is fine as long as the OTHER fields (email, must_change_password, is_activated) match. Call tide.ReplayFlow separately against fixtures/nuxt/nuxt-auth.yaml and (after inserting the lock user) fixtures/nuxt/nuxt-auth-lock.yaml, failing the test on any returned error (ReplayFlow already returns a non-nil *tide.MismatchError on any diff, matching the same err-only check replayPortedRoute already uses).

Run "go test ./parity/... -run 'TestParityCorpus|TestUserAPINuxtFlows|TestDBCapture'" WITHOUT -short (needs testcontainers Postgres) with the 15 user-api routes still marked status: pending first, to sanity-check the new seed hook and nuxt-flow test compile and run cleanly before flipping any route status. Then, working route by route, flip ONE route's manifest.yaml entry from status: pending to status: ported, re-run the same test command, and read the failure diff for any case that does not pass. Fix the Go handler behavior to match the recorded PHP body -- NEVER edit a fixture to make Go pass (fixtures only change via Task 1's re-recording pattern). Repeat until all 15 routes and both nuxt flows are green. Known-correct-already (per 07-05-SUMMARY, confirm rather than re-derive): every login/fetch/register/update success payload contains feedback_widget_hidden:false; register disabled/throttled already returns the opaque {"error":"Internal server error"},500 via writeSafe500; the 6th rapid failed login (A2) is byte-identical to the 1st.
go test ./parity/... -run "TestParityCorpus|TestUserAPINuxtFlows" -v 2>&1 | tail -80 - controllers/winter_error_page.html exists and its bytes were extracted via tide.LoadFlow, not hand-typed - api_controller.go's Activate returns 500 text/html (the embedded page) when the authenticated user is already activated, and 200 with the unchanged payload when not yet activated regardless of code correctness - api_controller.go's ActivateByCode returns 500 text/html when the targeted user is already activated, 422 when not-yet-activated with a wrong/expired code, and 200+token+user on a correct code for a not-yet-activated user - classes/codes.go exports IsAlreadyActivated with the signature (ctx, db, userID) (bool, error) - user_api_seed_test.go exists, is registered in seedHooks under the key "user-api", and all 15 manifest.yaml entries under auth_group: user-api carry seed_hook: user-api -- none relies on m.Seed's onboarding-based HTTP bootstrap - parity/nuxt_flow_test.go exists, is skipped under -short, inserts lock@parity.test directly (real bcrypt hash, is_activated true, must_change_password true) before replaying nuxt-auth-lock.yaml, and replays both fixtures/nuxt/nuxt-auth.yaml and fixtures/nuxt/nuxt-auth-lock.yaml against the real Go backend without touching the onboarding endpoints - "grep -A1 'auth_group: user-api' ../fonoteka.go/parity/manifest.yaml | grep -c 'status: ported'" equals 15 and the same query for "status: pending" equals 0 - "go test ./parity/... -run TestParityCorpus -v" (without -short) reports zero failing cases for any user-api route or the two nuxt flows - no fixture file's response body was edited by hand during this task; every fixture change traces back to Task 1's re-recording All 15 /_user/api/v1 routes are status: ported in manifest.yaml with seed_hook: user-api and every case replaying green; both nuxt flows replay green via the new nuxt_flow_test.go without touching the unported onboarding endpoints; the already-activated quirk is reproduced byte-for-byte in Go for both activation handlers; the two-step D-12 flows resolve their codes via a live Postgres read during replay. Task 3: Update unit tests and corpus-count assertions, then run the full gate in both modules ../fonoteka.go/plugins/golem15/user/account_test.go, ../fonoteka.go/plugins/golem15/user/classes/codes_test.go, ../fonoteka.go/parity/parity_test.go, ../fonoteka.go/parity/parity_contract_test.go ../fonoteka.go/plugins/golem15/user/account_test.go lines 81-131 (TestActivate, TestActivateByCode -- TestActivate's own not-yet-activated user via loginToken stays correct and needs no change; TestActivateByCode's final "bad" sub-case at lines 127-130 asserts the OLD behavior: it reuses user id 1, the SAME user activated earlier in the same test at lines 98-101, so by the time "bad" runs that user IS already activated and PHP's real behavior is the 500 HTML page, not the current test's expected 422 "This activation link is invalid or has expired"); ../fonoteka.go/plugins/golem15/user/classes/codes_test.go lines 87-98 (TestCodes's own VerifyActivationCode call uses a fresh, never-activated "pending" user and is unaffected by this plan -- confirm rather than re-derive, and add a companion assertion for the new IsAlreadyActivated helper instead of changing this existing flow); ../fonoteka.go/plugins/golem15/user/session_test.go lines 359-398 (loginToken, insertUser, postJSON, bearer -- the exact test helper signatures to reuse for the new already-activated sub-test); ../fonoteka.go/parity/parity_test.go lines 23-24 (expectedPHPRoutes = 169, expectedPortedRoutes = 7 -- only expectedPortedRoutes changes, to 22; the total stays 169) and lines 90-149 (TestParityCorpus's "coverage" subtest, which asserts cov.Ported/cov.Passing/cov.Pending against these two constants); ../fonoteka.go/parity/parity_contract_test.go lines 59-95 (TestParityContract's ported-route switch allow-list at lines 64-70, its route.SeedHook != "genres" check at line 72-73 -- which currently applies unconditionally to every ported route and must be widened so it accepts "genres" for the 7 pre-existing fonoteka routes AND "user-api" for the 15 new ones, the exact hook names Task 2 assigns -- and the "honest-counts" hardcoded 169/7/162 at lines 92-95, which becomes 169/22/147); ../fonoteka.go/parity/check_corpus.go line 17 (expectedRouteCount = 169 -- confirm this stays unchanged; it counts total recorded routes, not ported ones). In account_test.go's TestActivateByCode, change the final "bad" sub-case's assertion: the user with id 1 in that test IS already activated by that point (activated earlier in the same test via the successful activate-by-code call), so PHP's real behavior is the 500 HTML error page, not a 422. Assert bad.Code == http.StatusInternalServerError, that bad's Content-Type header is "text/html; charset=UTF-8", and that the body contains a distinctive marker from the embedded page (for example the string "Błąd strony") rather than duplicating the exact byte comparison the parity replay already performs.
Add a new sub-test (or extend TestActivate) proving the already-activated branch: insert a user via insertUser, mark it activated directly via gdb (Model(user).Update("is_activated", true)), obtain a bearer token for it via Login, then call Activate with any code and assert the same 500/text/html/"Błąd strony" shape as above. Keep the existing not-yet-activated sub-case in TestActivate unchanged -- it is still correct.

In codes_test.go's TestCodes (or a new dedicated test in the same file), add coverage for IsAlreadyActivated: false for a freshly created, not-yet-activated user; true after that same user is marked is_activated; false for a nonexistent user id, with no error.

In parity_test.go, change expectedPortedRoutes from 7 to 22.

In parity_contract_test.go, add all 15 user-api route IDs to the switch-case allow-list at lines 64-70 (the exact "id:" strings from manifest.yaml, e.g. "POST /_user/api/v1/login user-api" through "GET /_user/api/v1/oauth-providers user-api"). Change the route.SeedHook != "genres" check so it accepts route.SeedHook == "genres" for the 7 pre-existing fonoteka routes and route.SeedHook == "user-api" for the 15 user-api routes (for example switch on route.AuthGroup, or on which arm of the route-ID switch matched, rather than comparing against one hardcoded string) -- the 15 user-api routes carry seed_hook: user-api per Task 2, not "genres", and must not fail this assertion. Change the "honest-counts" sub-test's hardcoded values from Recorded 169 / Ported 7 / Pending 162 to Recorded 169 / Ported 22 / Pending 147.

Run "go vet ./..." and "go test ./... -race" in fonoteka.go (including ./plugins/golem15/user/..., ./plugins/golem15/fonoteka/..., and ./parity/... without -short) and in summercms.go. Run "go run ./parity/check_corpus.go --require-recorded --require-clients --check-secrets" one final time. Write the SUMMARY, explicitly carrying forward the decision that PHP DOES blacklist in production and the previous "PHP does not blacklist" note in STATE.md/prior SUMMARYs was a harness artifact of CACHE_DRIVER=array, now corrected.
cd ../fonoteka.go && go vet ./... && go test ./... -race - TestActivateByCode's "bad" sub-case asserts 500 / text/html / "Błąd strony", not 422 - a new test proves Activate also returns the 500 HTML page for an already-activated user with a bearer token - IsAlreadyActivated has direct unit coverage for the true, false, and not-found cases - parity_test.go's expectedPortedRoutes reads 22 - parity_contract_test.go's ported-route switch lists all 22 route IDs (7 existing plus 15 user-api), its SeedHook check accepts "genres" for the 7 and "user-api" for the 15 without failing either, and its honest-counts sub-test reads Recorded 169 / Ported 22 / Pending 147 - "go vet ./... && go test ./... -race" exits 0 in both summercms.go and fonoteka.go, including nested plugin packages and ./parity/... run without -short - "go run ./parity/check_corpus.go --require-recorded --require-clients --check-secrets" passes - 07-07-SUMMARY.md exists and carries forward the corrected "PHP does blacklist in production" finding Every unit test reflects the real PHP behavior this plan discovered; the corpus-count assertions in both parity test files match the new 22-ported/147-pending reality; go vet and go test -race are green in both modules; the SUMMARY documents the corrected blacklist finding for future phases to read.

<threat_model>

Trust Boundaries

Boundary Description
Parity recorder → isolated PHP (persistent cache) Re-recording now persists PHP-side cache/session state across requests inside the shared $PHP_ROOT checkout, not just the isolated SQLite DB under $PARITY_ROOT
Parity recorder → committed fixtures Recorded bodies (including the newly blacklisted-token 401 body) cross from a private, secret-bearing capture session into a public, committed artifact
Go production error page → HTTP client The embedded Winter error page is served verbatim to any caller who triggers the already-activated path; it must never gain a stack trace or internal path beyond what PHP itself renders under APP_DEBUG=false
Parity test seeding → Postgres The new direct-DB seed hooks (seedUserAPI, the lock-user insert) write real bcrypt-hashed credentials into the shared test Postgres instance; those credentials must never be logged or leak into a committed fixture

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-07-20 Information Disclosure Re-recorded fixtures (Task 1) mitigate Same Phase 2 capture-rule masking plus a repeat of the JWT/inv_-shape grep gate from 07-05 over every file this plan re-records or newly records
T-07-21 Tampering CACHE_DRIVER=file writing into the shared $PHP_ROOT checkout mitigate storage/framework/cache/.gitignore already blanket-ignores all cache files (confirmed this session); php artisan cache:clear added to php_parity.sh reset prevents stale cross-session state from leaking into a recording; git status --porcelain storage/framework/cache is checked before and after
T-07-22 Information Disclosure winter_error_page.html (embedded 500 body) mitigate Bytes are copied verbatim from a fixture recorded under APP_DEBUG=false (already confirmed to contain no stack trace or file path); the executor must not re-record any activation fixture under a debug-enabled PHP instance
T-07-23 Repudiation Logout blacklist enforcement accept (no new risk) No change to bouncer's blacklist logic; TestSessionSequence and bouncer/phase07_coverage_test.go continue to assert 401 after logout unchanged by this plan
T-07-24 Tampering Manifest status flips (pending → ported) mitigate Task 2's acceptance criteria require every case of a route to replay green before its manifest entry flips; TestParityCorpus's existing ported-mismatch/ported-mutated-response subtests continue to catch a falsely-flipped route
T-07-25 Information Disclosure Test-only credentials minted by seedUserAPI/the lock-user insert accept These are hardcoded, well-known, non-production test passwords (parity-alice-pass) already used and accepted throughout the existing Phase 2/7 parity corpus; they exist only inside an ephemeral testcontainers Postgres instance, never in a committed fixture body verbatim as a raw credential

</threat_model>

`go test ./parity/... -run 'TestParityCorpus|TestUserAPINuxtFlows|TestParityContract'` (without `-short`) is green with 22 ported / 147 pending / 0 failing and both nuxt flows passing; `go vet ./... && go test ./... -race` is green in both `summercms.go` and `fonoteka.go`; `go run ./parity/check_corpus.go --require-recorded --require-clients --check-secrets` passes.

<success_criteria> All 15 /_user/api/v1 routes and both nuxt-auth/nuxt-auth-lock client flows are status: ported in parity/manifest.yaml and replay byte-for-byte green against the Go backend, seeded entirely through direct-DB hooks rather than the unported onboarding endpoint; AUTH-01 is unblocked; no PHP contract was weakened to make Go pass. </success_criteria>

Create `.planning/phases/07-user-plugin-and-authentication/07-07-SUMMARY.md` when done