---
phase: 19-local-auth-no-oidc-mode
plan: 05
type: execute
wave: 4
depends_on: ["19-01", "19-03"]
files_modified:
  - apps/api/src/auth/devBypass.ts
  - apps/api/scripts/reset-admin.ts
  - apps/pwa/e2e/global-setup.ts
  - apps/pwa/e2e/login.spec.ts
  - .gitea/workflows/ci.yml
autonomous: false
requirements: [AUTH-LOCAL-11, AUTH-LOCAL-16]

must_haves:
  truths:
    - "With DEV_AUTH_BYPASS=true, every request also carries a real local-session cookie for the dev user, so the PWA login gate skips to the app"
    - "The Phase-7/8 harness still reaches the authed PWA without manual login (existing specs unchanged)"
    - "A login-specific spec can clear the local-session cookie and exercise the real /login form against the seeded dev credential"
    - "global-setup seeds a local_credentials row for the dev user (id=1) and truncates it between runs"
    - "CI provides LOCAL_SESSION_SECRET to the harness job and seeds the local_credentials table"
    - "The break-glass CLI creates/resets a local admin by username, runs only outside production, and is excluded from the prod image"
  artifacts:
    - path: "apps/api/scripts/reset-admin.ts"
      provides: "break-glass create/reset local admin CLI (dev-only)"
      min_lines: 30
    - path: "apps/pwa/e2e/login.spec.ts"
      provides: "real-login-form e2e covering the gate + form (AUTH-LOCAL-12/15)"
      min_lines: 25
    - path: "apps/pwa/e2e/global-setup.ts"
      provides: "local_credentials dev seed + truncate"
      contains: "local_credentials"
  key_links:
    - from: "apps/api/src/auth/devBypass.ts"
      to: "apps/api/src/auth/localSession.ts"
      via: "devSessionCookieMiddleware issues a real local-session cookie for DEV_USER (Option C)"
      pattern: "local-session"
    - from: "apps/pwa/e2e/global-setup.ts"
      to: "local_credentials table"
      via: "INSERT ... ON DUPLICATE KEY UPDATE seed for dev user id=1"
      pattern: "local_credentials"
    - from: ".gitea/workflows/ci.yml"
      to: "LOCAL_SESSION_SECRET"
      via: "harness job env + table seed"
      pattern: "LOCAL_SESSION_SECRET"
---

<objective>
Rework the dev-bypass + Phase-7/8 Playwright harness to coexist with the new login UI (Option C: bypass issues a real `local-session` cookie), add the break-glass CLI, seed `local_credentials` for the dev user, add a real-login e2e spec, and update the CI harness job — all while preserving the D-15 dev-only/no-prod-image guarantees.

Purpose: The new login gate would otherwise break the harness, which reaches the authed PWA purely via DEV_AUTH_BYPASS (D-14). Option C is the minimal-change path: the bypass keeps setting `c.get('user')` AND now also issues the same `local-session` cookie the PWA gate expects, so existing specs pass unchanged; a dedicated login spec clears the cookie to test the real form. This is glue + CI + a CLI script (type: execute). D-15 is enforced by the existing IMG-01/02/03 gates plus the `.dockerignore apps/api/scripts/` exclusion added in 19-01.

Output: edited `devBypass.ts`, new `reset-admin.ts`, edited `global-setup.ts`, new `login.spec.ts`, edited `ci.yml`.

Derived REQ-IDs covered: AUTH-LOCAL-11 (break-glass CLI, D-13), AUTH-LOCAL-16 (dev-bypass + harness rework, D-14/D-15).
</objective>

<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md
@.planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md
@.planning/phases/19-local-auth-no-oidc-mode/19-CONTEXT.md
@.planning/phases/19-local-auth-no-oidc-mode/19-01-SUMMARY.md
@.planning/phases/19-local-auth-no-oidc-mode/19-03-SUMMARY.md
</context>

<tasks>

<task type="auto">
  <name>Task 1: Option C — devSessionCookieMiddleware issues a real local-session cookie under bypass</name>
  <read_first>
    - apps/api/src/auth/devBypass.ts (DEV_USER shape lines ~30-36; devAuthBypass() env-guard structure lines ~58-76; the production hard-guard is the FIRST check and must stay first)
    - apps/api/tests/auth/devBypass.test.ts (the test that must keep passing)
    - apps/api/src/auth/localSession.ts (issueLocalSessionCookie + getCookie('local-session') — from 19-01)
    - apps/api/src/index.ts (where devAuthBypass() is mounted — the companion middleware mounts just after it; from 19-03 wiring)
    - .planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md §Dev-Bypass Rework (Option C; D-15 compliance) + Pitfall 7
  </read_first>
  <files>apps/api/src/auth/devBypass.ts, apps/api/src/index.ts</files>
  <action>
    In apps/api/src/auth/devBypass.ts add `export function devSessionCookieMiddleware(): MiddlewareHandler`. Keep the production hard-guard as the FIRST check (return no-op when NODE_ENV==='production') and a no-op when DEV_AUTH_BYPASS!=='true' — identical guard order to devAuthBypass so the IMG-01 boot guard / `assertNotDevBypassInProduction` continues to protect it. When active: on each request that does NOT already have a `local-session` cookie (getCookie), call `issueLocalSessionCookie(c, DEV_USER.id)` so the PWA login gate sees a valid session and skips /login. Then next(). devAuthBypass() itself is unchanged (still sets c.get('user')).

    In apps/api/src/index.ts mount `app.use('/api/*', devSessionCookieMiddleware())` immediately AFTER `app.use('/api/*', devAuthBypass())` (it is a no-op outside bypass mode, so it is safe to mount unconditionally like devAuthBypass). Do not change the OIDC-side chain.
  </action>
  <verify>
    <automated>pnpm --filter @familysync/api test tests/auth/devBypass.test.ts && pnpm --filter @familysync/api typecheck</automated>
  </verify>
  <acceptance_criteria>
    - `pnpm --filter @familysync/api test tests/auth/devBypass.test.ts` exits 0 (existing bypass behavior intact)
    - Source assertion: devSessionCookieMiddleware's FIRST conditional is `NODE_ENV === 'production'` returning a no-op (grep the guard order) — D-15
    - Source assertion: `grep -c "issueLocalSessionCookie" apps/api/src/auth/devBypass.ts` >= 1
    - Source assertion: `grep -c "devSessionCookieMiddleware" apps/api/src/index.ts` >= 1 mounted after devAuthBypass
  </acceptance_criteria>
  <done>Under DEV_AUTH_BYPASS, a real local-session cookie is issued for the dev user (production-guarded); existing bypass tests still pass.</done>
</task>

<task type="auto">
  <name>Task 2: Break-glass reset-admin CLI (dev-only)</name>
  <read_first>
    - apps/pwa/e2e/global-setup.ts (mysql2/promise connection lines ~95-101; ON DUPLICATE KEY upsert lines ~125-129; NODE_ENV production guard lines ~34-44 — the "plain Node.js only" inline-hash constraint, Pitfall 11)
    - .planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md §Break-Glass (script contract; tsx run via docker exec) + §Common Pitfalls 11
    - .planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md §apps/api/scripts/reset-admin.ts (DB connection, idempotent upsert, dev-only guard, --arg parsing, inline hashPassword)
    - .dockerignore (confirm apps/api/scripts/ is excluded — added in 19-01; this CLI relies on that exclusion for D-15)
  </read_first>
  <files>apps/api/scripts/reset-admin.ts</files>
  <action>
    Create apps/api/scripts/reset-admin.ts — a standalone script runnable as `docker exec -it familysync-api node --import=tsx/esm scripts/reset-admin.ts --username admin --password '<new>'`. First statement: a dev-only guard that throws when `NODE_ENV === 'production'` (defense-in-depth; the script is also `.dockerignore`d per 19-01, IMG-02). Parse `--username` and `--password` from process.argv (no new deps). Connect via mysql2/promise using DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME env (same defaults as global-setup.ts). Inline a `hashPassword` (copy the 5-line scrypt PHC implementation — cannot import compiled TS from a plain script, Pitfall 11). Upsert: find-or-insert a users row for the username with `is_admin=true, claimed=true`; then INSERT ... ON DUPLICATE KEY UPDATE the local_credentials row (user_id, username, password_hash). Print the resulting user id. Support a `--dry-run` flag that validates args + connection without writing (used by the validation command). Never log the password value.
  </action>
  <verify>
    <automated>cd apps/api && node --import=tsx/esm scripts/reset-admin.ts --dry-run --username smoketest --password ignored; echo "exit=$?"</automated>
  </verify>
  <acceptance_criteria>
    - The `--dry-run` invocation exits 0 and prints no password value (grep the output for the literal 'ignored' → absent)
    - Source assertion: the FIRST executable statement guards `NODE_ENV === 'production'` (throws) — D-13/D-15
    - Source assertion: `grep -c "scryptSync" apps/api/scripts/reset-admin.ts` >= 1 (inline hash, no TS import)
    - Source assertion: `.dockerignore` excludes `apps/api/scripts/` (carried from 19-01) so this file never ships
  </acceptance_criteria>
  <done>reset-admin.ts creates/resets a local admin by username, refuses to run in production, is excluded from the prod image, and supports --dry-run.</done>
</task>

<task type="auto">
  <name>Task 3: global-setup local_credentials seed + login.spec.ts + CI harness job env</name>
  <read_first>
    - apps/pwa/e2e/global-setup.ts (TRUNCATE block lines ~106-109; users seed lines ~125-129; member_credentials seed lines ~143-147; the inline-hash constraint Pitfall 11)
    - apps/pwa/e2e/layout.spec.ts + apps/pwa/e2e/calendar.spec.ts (spec structure, device-profile usage, DEV_AUTH_BYPASS auth-reached precondition, serviceWorkers block)
    - apps/pwa/playwright.config.ts (iphone/pixel/desktop projects; baseURL; webServer)
    - .gitea/workflows/ci.yml (the harness job: DEV_AUTH_BYPASS env, dev-stack bring-up, MariaDB seed step)
    - .planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md §Dev-Bypass Rework (global-setup change + CI env) + §PWA Routing Gate
    - .planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md Surfaces 1-10 (selectors/copy the spec asserts: id="login-username", "Sign in", error copy)
  </read_first>
  <files>apps/pwa/e2e/global-setup.ts, apps/pwa/e2e/login.spec.ts, .gitea/workflows/ci.yml</files>
  <action>
    In apps/pwa/e2e/global-setup.ts: add `local_credentials` to the TRUNCATE set; inline a `hashPasswordInline(password)` (scrypt PHC, Pitfall 11 — global-setup is plain Node.js); after the existing users seed, `INSERT INTO local_credentials (user_id, username, password_hash) VALUES (1, 'devuser', ?) ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash)` with `hashPasswordInline('devpass')`. The existing NODE_ENV/ DEV_AUTH_BYPASS guards already cover the new seed.

    Create apps/pwa/e2e/login.spec.ts: in a context that clears the `local-session` cookie (so the bypass-issued cookie does not auto-skip the gate), assert: (1) navigating to the app redirects to /login and the brand slot + username/password form render (id="login-username", "Sign in"); (2) a wrong password shows the single "Incorrect username or password." message; (3) logging in as devuser/devpass navigates into the app. Follow the existing spec structure (device profiles, serviceWorkers block, no-SW-controller precondition). Keep the other specs (layout/calendar/lists) reaching the app via the bypass-issued cookie unchanged.

    In .gitea/workflows/ci.yml harness job: add `LOCAL_SESSION_SECRET` to the job env (a fixed dev value >=32 chars, e.g. a documented `dev-secret-change-me-0000000000000000` length-padded) so devSessionCookieMiddleware and global-setup's hash work; if the CI step seeds tables directly, add the `local_credentials` seed there too (mirroring the member_credentials seed). The harness still runs with DEV_AUTH_BYPASS=true; LOCAL_SESSION_SECRET stays dev-only (never in the published image — IMG gates).
  </action>
  <verify>
    <automated>pnpm --filter @familysync/pwa test:e2e --grep "login"</automated>
  </verify>
  <acceptance_criteria>
    - `pnpm --filter @familysync/pwa test:e2e --grep "login"` passes (real-login-form spec green on at least the desktop/chromium profile)
    - Source assertion: `grep -c "local_credentials" apps/pwa/e2e/global-setup.ts` >= 2 (TRUNCATE + INSERT)
    - Source assertion: `grep -c "LOCAL_SESSION_SECRET" .gitea/workflows/ci.yml` >= 1 in the harness job
    - Behavior: the existing layout/calendar/lists specs still reach the authed app (run `pnpm --filter @familysync/pwa test:e2e` — full harness green)
  </acceptance_criteria>
  <done>global-setup seeds + truncates local_credentials; a real-login e2e spec passes; existing harness specs still reach the app via the bypass cookie; CI harness job has LOCAL_SESSION_SECRET + the seed.</done>
</task>

<task type="checkpoint:human-verify" gate="blocking">
  <name>Task 4: Verify full harness + CI green and D-15 image boundary intact</name>
  <action>Run the full harness locally + push for the CI run, then pause for human confirmation that all specs and the CI harness job are green and no dev artifact ships. Blocking checkpoint — no code change; the executor presents results and waits for approval.</action>
  <what-built>The reworked dev-bypass (Option C) and CI harness. The full Playwright harness (both the new login spec and the unchanged layout/calendar/lists specs) is the automated proof. This checkpoint confirms the CI run is green end-to-end and that no dev artifact leaks into the published image — the D-15 boundary that the IMG-01/02/03 gates and the new .dockerignore exclusion enforce.</what-built>
  <how-to-verify>
    1. Run `pnpm --filter @familysync/pwa test:e2e` locally (host dev stack, DEV_AUTH_BYPASS=true, LOCAL_SESSION_SECRET set) → confirm all specs pass, including login.spec.ts and the unchanged layout/calendar/lists specs.
    2. Push the branch and confirm the Gitea CI harness job is green (it brings up the dev stack with LOCAL_SESSION_SECRET + seeds local_credentials).
    3. Confirm D-15: `.dockerignore` excludes `apps/api/scripts/` (reset-admin.ts) and `apps/pwa/e2e/` (the dev seed); the published image contains no local_credentials dev seed and no reset-admin script. Spot-check the publish.yml image-hygiene assertion still passes.
  </how-to-verify>
  <resume-signal>Type "approved" if the full harness + CI are green and no dev artifact ships, or describe the failure.</resume-signal>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| dev env → published image | the D-15 boundary: dev seed, dev session secret, break-glass script must never ship |
| CI runner → dev stack | DEV_AUTH_BYPASS + LOCAL_SESSION_SECRET are dev-only CI values, never production secrets |

## STRIDE Threat Register (ASVS L1, block on high)

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-19-23 | Elevation of Privilege | dev local_credentials seed in prod image | mitigate | seed lives only in global-setup.ts (apps/pwa/e2e/ — .dockerignore'd) and the CI step; never in a migration or startup code (RESEARCH Pitfall 7) |
| T-19-24 | Elevation of Privilege | devSessionCookieMiddleware active in prod | mitigate | production hard-guard is the FIRST check; assertNotDevBypassInProduction (IMG-01) blocks DEV_AUTH_BYPASS in prod |
| T-19-25 | Tampering | break-glass script shipped in image | mitigate | apps/api/scripts/ excluded in .dockerignore (19-01, IMG-02); NODE_ENV=production guard in the script |
| T-19-26 | Information Disclosure | break-glass password in logs | mitigate | reset-admin never logs the password value; --dry-run validates without writing |
| T-19-SC | Tampering | npm installs | mitigate | zero new packages this plan |
</threat_model>

<verification>
- `pnpm --filter @familysync/pwa test:e2e` full harness green (login + existing specs)
- `pnpm --filter @familysync/api test` green (devBypass test intact)
- Human checkpoint confirms CI green + D-15 boundary intact (no dev artifact in the image)
</verification>

<success_criteria>
- AUTH-LOCAL-16: harness reaches the authed PWA via the bypass-issued local-session cookie; a login spec tests the real form; CI updated
- AUTH-LOCAL-11: break-glass CLI creates/resets a local admin, dev-only, image-excluded
- D-15: no dev seed, dev secret, or break-glass script ships in the published image
</success_criteria>

<artifacts_produced>
## Artifacts this phase produces (Plan 05)
- Middleware: `devSessionCookieMiddleware` (apps/api/src/auth/devBypass.ts) — Option C
- Script: `apps/api/scripts/reset-admin.ts` (break-glass CLI, dev-only, .dockerignore'd)
- e2e: `apps/pwa/e2e/login.spec.ts` (real-login-form spec)
- global-setup.ts: local_credentials dev seed (devuser/devpass) + TRUNCATE
- ci.yml: LOCAL_SESSION_SECRET in the harness job + local_credentials seed
</artifacts_produced>

<output>
Create `.planning/phases/19-local-auth-no-oidc-mode/19-05-SUMMARY.md` when done
</output>
