--- phase: 07-user-plugin-and-authentication plan: 05 type: execute wave: 4 depends_on: ["07-03", "07-04"] files_modified: - ../fonoteka.go/parity/manifest.yaml - ../fonoteka.go/parity/capture-rules.yaml - ../fonoteka.go/parity/parity_test.go - ../fonoteka.go/parity/check_corpus.go - ../fonoteka.go/parity/db_capture.go - ../fonoteka.go/parity/db_capture_test.go - ../fonoteka.go/parity/fixtures/routes/POST___user_api_v1_login_user-api.yaml - ../fonoteka.go/parity/fixtures/routes/POST___user_api_v1_logout_user-api.yaml - ../fonoteka.go/parity/fixtures/routes/GET___user_api_v1_fetch_user-api.yaml - ../fonoteka.go/parity/fixtures/routes/POST___user_api_v1_refresh_user-api.yaml - ../fonoteka.go/parity/fixtures/routes/POST___user_api_v1_register_user-api.yaml - ../fonoteka.go/parity/fixtures/routes/POST___user_api_v1_forgot-password_user-api.yaml - ../fonoteka.go/parity/fixtures/routes/POST___user_api_v1_reset-password_user-api.yaml - ../fonoteka.go/parity/fixtures/routes/POST___user_api_v1_activate_user-api.yaml - ../fonoteka.go/parity/fixtures/routes/POST___user_api_v1_activate-by-code_user-api.yaml - ../fonoteka.go/parity/fixtures/routes/POST___user_api_v1_update_user-api.yaml - ../fonoteka.go/parity/fixtures/routes/POST___user_api_v1_change-password_user-api.yaml - ../fonoteka.go/parity/fixtures/routes/POST___user_api_v1_avatar_user-api.yaml - ../fonoteka.go/parity/fixtures/routes/POST___user_api_v1_avatar_remove_user-api.yaml - ../fonoteka.go/parity/fixtures/routes/POST___user_api_v1_marketing-consent_user-api.yaml - ../fonoteka.go/parity/fixtures/routes/GET___user_api_v1_oauth-providers_user-api.yaml - ../fonoteka.go/parity/fixtures/nuxt/nuxt-auth.yaml autonomous: false requirements: [AUTH-01, AUTH-02, AUTH-03, AUTH-04] must_haves: truths: - "Every one of the 15 /_user/api/v1 routes named in D-01 is recorded against the isolated PHP instance and replays green against the Go backend" - "The recorded corpus includes the A2 fixture (6 rapid failed logins) settling whether Winter's throttle actually gates the JWT login path, and the D-12 code-carrying two-step flows (forgot->reset, register->activate-by-code) using a real code read from the target's own database, not a hardcoded value" - "No live JWT, inv_ token or database credential is committed to git; the private vars store stays mode 0600 and untracked" - "A new nuxt-auth client flow fixture exercises register -> fetch -> update -> change-password -> refresh -> logout -> refused-reuse, plus a must_change_password user reaching 423 then clearing it via change-password after a successful me/locale call (D-14)" - "The 15 new /_user/api/v1 routes are added to fonoteka.go/parity/manifest.yaml (absent from the original 154-route fonoteka manifest), the fixed-154-total wording in parity_test.go/check_corpus.go is corrected to the new total, and no fixture is recorded for a dropped extraction source (query-string/body jwt_token, per D-09), per D-11" artifacts: - path: "../fonoteka.go/parity/fixtures/nuxt/nuxt-auth.yaml" provides: "the D-14 recorded client flow" - path: "../fonoteka.go/parity/manifest.yaml" provides: "15 new /_user/api/v1 entries plus every D-13 distinct-body case, status: ported once green" key_links: - from: "../fonoteka.go/parity/db_capture.go" to: "../fonoteka.go/parity/capture-rules.yaml two-step flows" via: "reads reset_password_code/activation_code from the target's own users row after step one, writes it into the shared tide.Store as a {{var}}" pattern: "reset_password_code|activation_code" --- Record real parity evidence for every route 07-02/07-03/07-04 shipped: the 15 `/_user/api/v1` routes (new manifest entries, D-11) plus the `nuxt-auth` client flow (D-14), against the isolated PHP instance via the Phase 2 `tide` tooling, then replay them green against the Go backend. This plan is the acceptance test for AUTH-01..04 — until it is green, "the PHP contract is the acceptance test" is only a design intent for this phase, not a proven fact. Purpose: prove byte-for-byte parity on the newly-ported surface, including the two hardest-to-fake cases: a real Winter throttle confirmation (Assumption A2) and code-carrying two-step mail flows (D-12) that need a real database read, not a stubbed value. Output: 15 new `manifest.yaml` entries plus their fixtures, an extended `capture-rules.yaml`, a DB-reading capture helper, and `nuxt-auth.yaml`. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.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-RESEARCH.md @.planning/phases/07-user-plugin-and-authentication/07-VALIDATION.md @.planning/phases/07-user-plugin-and-authentication/07-02-SUMMARY.md @.planning/phases/07-user-plugin-and-authentication/07-03-SUMMARY.md @.planning/phases/07-user-plugin-and-authentication/07-04-SUMMARY.md ```go func tide.OpenStore(path string) (*Store, error) // loads/creates a private 0600 YAML map; empty path = memory-only func (s *Store) Get(name string) (string, bool) func (s *Store) Set(name, value string) func (s *Store) Save() error // writes YAML, mode 0600 func (s *Store) Expand(text string) (string, error) // {{name}} substitution used by request specs ``` `summer parity:record`/`parity:replay` CLI flags (summercms.go/cmd/summer/parity.go, already read this session): `--spec/--target/--output/--rules/--vars/--manifest/--fixtures/--update/--next-batch(max 15)/--resume/--allow-incomplete/--require-recorded`. `../fonoteka.go/parity/php_parity.sh {reset|serve|artisan}` (already read this session): `reset` wipes and recreates the isolated SQLite DB and runs `winter:up`; `serve` runs the isolated PHP app at `127.0.0.1:8423` with `APP_DEBUG=false`; `artisan ` runs any artisan command (e.g. `tinker`) against the SAME isolated DB — useful for a one-off read without a Go SQLite driver. Task 1: Start the isolated PHP instance ../fonoteka.go/parity/php_parity.sh ../fonoteka.go/parity/php_parity.sh (reset/serve/artisan subcommands and the refuse_db guard, already read this session) Run `../fonoteka.go/parity/php_parity.sh reset` (wipes the parity-only SQLite DB, runs `winter:up`). Run `../fonoteka.go/parity/php_parity.sh serve &` (or in a separate terminal) so it stays up for the rest of this plan. Confirm this is the ISOLATED parity database (`realpath $PARITY_ROOT`), never the developer's own `storage/database.sqlite` -- `php_parity.sh`'s own `refuse_db` guard already enforces this, but visually confirm the printed DB path before proceeding. curl -sf http://127.0.0.1:8423/_user/api/v1/oauth-providers Confirm the isolated PHP instance is running against the parity-only SQLite database, not the developer's own database, before any recording begins. Type "ready" once the isolated PHP instance is reachable at 127.0.0.1:8423. - `curl -sf http://127.0.0.1:8423/_user/api/v1/oauth-providers` returns HTTP 200 with a JSON body - The printed `DB_DATABASE` path from `php_parity.sh` lives under `$PARITY_ROOT` and is named `*parity*`, never the developer's own database The isolated PHP instance is reachable at 127.0.0.1:8423 and backed by the parity-only SQLite database. Task 2: Record the 15 /_user/api/v1 routes and the nuxt-auth flow ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/capture-rules.yaml, ../fonoteka.go/parity/db_capture.go, ../fonoteka.go/parity/db_capture_test.go, ../fonoteka.go/parity/fixtures/routes/POST___user_api_v1_*.yaml, ../fonoteka.go/parity/fixtures/routes/GET___user_api_v1_*.yaml, ../fonoteka.go/parity/fixtures/nuxt/nuxt-auth.yaml ../fonoteka.go/parity/capture-rules.yaml (existing `login` capture rule to extend), ../fonoteka.go/parity/manifest.yaml (existing entry block shape — `id, method, path, auth_group, status, identities, cases[].fixture/request/response`), ../fonoteka.go/parity/fixtures/routes/GET__api_v1_fonoteka_genres_personal_token.yaml (single-route fixture shape), ../fonoteka.go/parity/fixtures/nuxt/nuxt-browse.yaml (first ~50 lines only — the client-flow fixture step shape and `{{jwt:alice}}`/`{{id:*}}` placeholder syntax), summercms.go/tide/variables.go (Store — full file, the exported API this task's DB-capture helper writes through), .planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md (D-01, D-02, D-06 through D-21 — every response body this task must record matches what 07-02/07-03 already implemented), .planning/phases/07-user-plugin-and-authentication/07-VALIDATION.md (Manual-Only Verifications table — A1/A2 confirmation instructions) Extend `capture-rules.yaml` with capture blocks for `register`, `refresh`, and `activate-by-code` (each mints a token, Pitfall 3): `{method: POST, path: /_user/api/v1/register, capture: [{from: response.json, path: $.token, as: jwt:newuser, category: jwt}]}` and equivalents for the other two — mirror the existing `login` rule's shape exactly. Create `db_capture.go` (package `main`, alongside the existing `parity_test.go` — or a small standalone `go run`-able file, whichever fits the existing package layout better once read): a helper that, given a target's connection info (the isolated PHP's SQLite path from `$PARITY_ROOT`, or the Go replay target's Postgres DSN) and an email, reads `reset_password_code` or `activation_code` from the `users` row and calls `tide.OpenStore(varsPath)` → `Set("code:reset", value)` or `Set("code:activate", value)` → `Save()`. For the PHP/SQLite side, shell out to `../fonoteka.go/parity/php_parity.sh artisan tinker --execute="echo \Golem15\User\Models\User::where('email','')->value('reset_password_code');"` (avoids adding a new Go SQLite driver dependency for parity-only tooling) and capture stdout. For the Go/Postgres replay side, use the existing `*sql.DB`/DSN the replay harness already opens — a direct `SELECT reset_password_code FROM users WHERE email = $1`. This is the D-12 "app-owned tide seed/capture hook" — it lives in `fonoteka.go/parity`, not in the framework-owned `tide` package, exactly because it knows the Płytarium `users` table shape. Record, for EACH of the 15 routes (`login, logout, fetch, refresh, register, forgot-password, reset-password, activate, activate-by-code, update, change-password, avatar, avatar/remove, marketing-consent, oauth-providers`), every distinct status+body PHP actually returns (D-13 — do not assume the bodies drafted during planning are exact; RECORD the real ones and treat any drift as authoritative over the plan text): success case, validation-failure (422) case where applicable, and the specific failure modes named in 07-CONTEXT.md (bad credentials, suspended/banned via the throttle, registration disabled/throttled, bad/expired refresh, wrong current password, expired/invalid reset or activation code). Every recorded `user` payload (login/fetch/register/update success bodies) MUST include the literal `feedback_widget_hidden: false` key -- if a recorded fixture is missing it, that is a signal the base payload construction in 07-02 needs a follow-up, not that the fixture should be trimmed to match. Give two DELIBERATE PHP-quirk reproductions their own named recording pass, since they are easy to silently "fix" during recording if the operator assumes the plan text is wrong instead of PHP: (1) `register()` with `allow_registration=false` or the register throttle tripped, under the pinned `APP_DEBUG=false` -- confirm the recorded body is `{"error":"Internal server error"}`,500, NOT the literal "Registrations are currently disabled."/"Registration is throttled..." text (07-02 Task 3's `SafeExceptionResponse` finding); (2) `POST activate` (authenticated) with a WRONG code -- confirm the recorded body is still 200 `{"user":{...}}` with `is_activated` unchanged, NOT a 422/401 (07-03 Task 1's missing-`if`-guard finding). For both, the RECORDED PHP body is authoritative: if either one comes back looking different from what 07-02/07-03 assumed, that is a real finding to write into this plan's SUMMARY and file as a gap-closure item against the plan that implemented it -- do not silently adjust the fixture to match the plan text's expectation. Use `summer parity:record --spec= --target=http://127.0.0.1:8423 --rules=capture-rules.yaml --vars= --manifest=manifest.yaml --fixtures=fixtures/routes --next-batch=15 --resume=true` for the bulk of the batch (the whole 15-route surface fits in one `--next-batch=15` call, matching the established 15-route resume workflow), then hand-author additional YAML specs for the extra per-route failure cases and record those individually with `--spec`. Record the A2 confirmation case explicitly: 6 rapid `POST /_user/api/v1/login` calls with a wrong password for the SAME seeded account, asserting whether the 6th attempt's body differs from attempts 1-5 (settling Assumption A2 — if PHP's `JWTAuth::attempt()` does NOT actually reach Winter's throttle for this route, the 6th attempt's body will be identical to the first; if it does, confirm whatever the actual PHP body is and make sure the Go implementation from 07-02 matches it, filing a gap-closure note in this plan's SUMMARY if a code change is needed). Record the two D-12 two-step flows using `db_capture.go`'s helper between steps: `forgot-password` (step 1) → read `reset_password_code` via the helper → `reset-password` (step 2) with `{{code:reset}}` in its request body. `register` in `user`-activation mode (step 1) → read `activation_code` → `activate-by-code` (step 2) with `{{code:activate}}`. Author `fixtures/nuxt/nuxt-auth.yaml` per D-14: `register → fetch → update → change-password → refresh → logout → (reuse of the logged-out token refused)`, PLUS a second scenario in the same flow (or a second flow file if that's cleaner given the existing `nuxt-browse.yaml` convention — check how multi-scenario flows are structured there first): a `must_change_password` user hits 423 on an authenticated `/_fonoteka/api/v1` route, then successfully calls `me/locale`, then clears the lock via `change-password`. Use `{{jwt:newuser}}`/`{{id:*}}` placeholders exactly per `nuxt-browse.yaml`'s established syntax. Add all 15 new manifest entries (they do not exist yet, unlike `tokens`/`me/locale` which 07-04 already flipped) with `auth_group: user-api` (a new auth-group label distinct from `jwt`/`personal_token`/`public` — the `/_user/api/v1` group has no `jwt.auth`, matching D-01), one `cases[]` entry per distinct status+body recorded. Set `status: ported` only for routes whose EVERY recorded case replays green against the Go backend this session — anything that doesn't reach full green stays `pending` (never claim ported-but-failing, matching the established "pending never equals passing" rule). Update `parity_test.go`'s `expectedPHPRoutes` from `154` to `169` (154 + 15) and `check_corpus.go`'s `expectedRouteCount` the same way; update `parity_contract_test.go`'s hardcoded inventory numbers to the real recorded/ported/pending split this session produces (read the actual numbers off the corpus run, do not guess). Leave `ROADMAP.md`/`REQUIREMENTS.md`'s "All 154 routes" phrasing (API-09, QA-05, the Phase 15 goal) UNCHANGED — that figure is specifically the Płytarium `fonoteka` plugin's route surface for the cutover criterion, a stable definition unaffected by `golem15.user`'s own, separately-tracked routes sharing the same manifest FILE. go test ./parity/... -run 'TestParityCorpus|TestDBCapture' -short - `parity/manifest.yaml` contains exactly 15 new entries with `auth_group: user-api`, one per D-01 route - Every one of the 15 entries has at least one recorded `cases[]` fixture; routes with a documented failure mode (bad credentials, throttled, disabled registration, expired code) have that case recorded too - The A2 fixture (6 rapid failed logins) exists and its 6th-attempt body is explicitly compared against attempt 1 in this plan's SUMMARY - `fixtures/nuxt/nuxt-auth.yaml` exists and its steps cover register, fetch, update, change-password, refresh, logout, and a refused token-reuse step - `parity_test.go`'s `expectedPHPRoutes` reads `169` - The recorded `register` disabled/throttled fixture body is `{"error":"Internal server error"}`,500 under `APP_DEBUG=false`, and the recorded authenticated `activate`-with-wrong-code fixture body is 200 `{"user":{...}}` with `is_activated` unchanged -- both named explicitly in this plan's SUMMARY as confirmed, not assumed - Every recorded login/fetch/register/update success fixture's `user` payload contains the literal key `feedback_widget_hidden: false` 15 new manifest entries exist with D-13-complete case coverage; the A2 and D-12 cases are recorded and settled; nuxt-auth.yaml exists and records the full D-14 sequence; both named PHP-quirk reproductions (register disabled/throttled degrading to 500, authenticated activate's no-op-on-wrong-code) are confirmed against the real recorded PHP body. Task 3: Verify no secrets leaked and the corpus replays green ../fonoteka.go/parity/fixtures/routes, ../fonoteka.go/parity/fixtures/nuxt/nuxt-auth.yaml, ../fonoteka.go/parity/manifest.yaml the fixture files Task 2 recorded, ../fonoteka.go/parity/manifest.yaml Confirm via `git status` over `fonoteka.go/parity/` that the private vars store path is NOT staged (it should sit outside the repo or be gitignored, mode 0600, per the established Phase 2 convention). Grep the new fixtures for live JWT/`inv_` token shapes (a capture leak `tide`'s masking should already prevent, verified directly here since these are brand-new files). Run the corpus and replay commands below. Spot-check 2-3 fixture bodies against this plan's D-13 expectations (e.g. the login-failure body, the change-password wrong-current-password body) to confirm the recorded PHP behavior matches what 07-02/07-03 implemented -- flag any drift for a gap-closure note rather than silently accepting a mismatch. Specifically re-confirm Task 2's two named PHP-quirk recordings here as part of the sign-off: the register disabled/throttled fixture reads `{"error":"Internal server error"}`,500 (not the literal disabled/throttled text), and the authenticated activate-with-wrong-code fixture reads 200 `{"user":{...}}` (not a 4xx) -- both are deliberate reproductions of PHP quirks read directly from source in 07-02/07-03, not planning guesses, so the recorded body is authoritative if either differs from what is written here. ! grep -rE "eyJ[A-Za-z0-9_-]+[.][A-Za-z0-9_-]+[.][A-Za-z0-9_-]+|inv_[A-Za-z0-9]{8,}" ../fonoteka.go/parity/fixtures/routes/*user_api_v1* ../fonoteka.go/parity/fixtures/nuxt/nuxt-auth.yaml && go test ./parity/... -run TestParityCorpus -v Confirm no live secret is committed and the corpus replays green before approving this plan, or list the specific fixtures/bodies that need a gap-closure follow-up. Type "approved" once no live secret is committed and the corpus replays green, or list the specific fixtures/bodies that need a gap-closure follow-up. - The JWT/`inv_`-shape grep over the new fixtures returns zero matches - `git status` does not list the private vars store path - `go test ./parity/... -run TestParityCorpus -v` reports zero failing cases among the newly ported routes - The two named PHP-quirk fixtures (register disabled/throttled -> 500 opaque body; authenticated activate-with-wrong-code -> 200 unchanged) are explicitly re-confirmed in this task's sign-off, with any drift noted as a gap-closure item rather than silently accepted No live JWT/inv_ token shape is present in any committed fixture; the private vars store is untracked and 0600; TestParityCorpus and summer parity:replay are green for every newly ported route; both named PHP-quirk reproductions are confirmed against the real recorded PHP body. ## Trust Boundaries | Boundary | Description | |----------|--------------| | Parity recorder → isolated PHP | The recorder captures real HTTP traffic, including credential and token material, that must never reach git | | Parity recorder → committed fixtures | Recorded bodies cross from a private, secret-bearing capture session into a public, committed artifact | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|------------------| | T-07-10 | Information Disclosure | Recorded fixtures | mitigate | Capture-rule masking (existing Phase 2 mechanism) plus this plan's explicit human-verify grep pass for JWT/`inv_` shapes before commit; the private vars store stays 0600 and untracked | | T-07-14 | Information Disclosure | DB-capture helper (`db_capture.go`) | mitigate | Reads only the single `reset_password_code`/`activation_code` column needed, via a parameterized query (Postgres side) or a fixed `tinker` one-liner with no user-controlled SQL (PHP side) — never a general-purpose DB shell | `go test ./parity/... -run TestParityCorpus -v` is green with the new routes reflected in the coverage summary; `summer parity:replay` reports zero failing cases across the whole manifest (existing 154-route fonoteka surface unaffected, new 15-route user surface green or honestly pending). Every `/_user/api/v1` route and the `nuxt-auth` flow are recorded against real PHP and replay green against the Go backend, with A2 and D-12 settled empirically rather than assumed. Create `.planning/phases/07-user-plugin-and-authentication/07-05-SUMMARY.md` when done