VERIFICATION.md found a cross-layer URL mismatch: fetchAdminResetPassword
POSTed to /api/admin/members/:id/reset-password but the API registers the
route as /api/admin/members/:id/password (admin.ts), so the Admin reset sheet
404'd on every submit. Confirmed live: old path -> 404, correct path -> 400
(route reached). Unit tests missed it because API tests hit the real path
directly and PWA tests mock the fetcher — no test crossed both layers.
Fix the client URL and add a URL-contract regression test that pins the exact
path (asserts fetch is called with /api/admin/members/:id/password).
PWA 266/266 (+1), typecheck clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two issues surfaced only when plans 19-04 (login UI) and 19-05 (Option C
bypass + login.spec) were merged together and run against the real stack —
neither executor could catch them in isolation:
1. LOCAL_SESSION_SECRET was added to the CI harness (ci.yml) but not to the
local dev stack (docker-compose.dev.yml). Without it the real-login success
path (POST /api/auth/local/login) 503s when signing the session cookie, so
the e2e round-trip failed. Add the same fixed dev-only value to the dev
compose override (dev-only target; never a production secret).
2. login.spec test 1 assumed clearing the local-session cookie yields a
logged-out state, but under the always-on DEV_AUTH_BYPASS devAuthBypass()
injects DEV_USER into /api/me regardless of any cookie — a logged-out state
is architecturally unreachable in this bypass-only harness. Reframe the test
to drive /login directly (validating the real-browser render of all brand +
form surfaces) and move the unauthenticated root->/login redirect-gate
coverage to a unit test in App.test.tsx where meQuery.isError is controllable.
Result: API 446/446, PWA 265/265 (+2 gate tests), e2e desktop 42 passed / 3
skipped (all login specs green).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Add devSessionCookieMiddleware() to devBypass.ts (production hard-guard FIRST)
- Issues local-session JWT cookie for DEV_USER when no cookie present under bypass
- Pure no-op when NODE_ENV=production, DEV_AUTH_BYPASS!=true, or secret not set
- Mount devSessionCookieMiddleware() after devAuthBypass() in index.ts
- Existing devBypass tests: 3/3 pass; typecheck: exit 0
apps/api/src/auth/linkOidc.ts (new):
- OidcLinkConflictError: thrown when iss+sub already belongs to a different user
- linkOidcToUser(userId, iss, sub): preflight SELECT for conflict, then db.transaction
(UPDATE users SET oidc_iss/sub/claimed + DELETE local_credentials); atomic, no email (D-10)
- 88 lines; no email in source (D-10/T-19-08 assertion passes)
apps/api/src/routes/me.ts:
- POST /api/me/link-oidc: resolveUserId (401 if null), sign state JWT
({ linkUserId, nonce, iat, exp } HS256 with LOCAL_SESSION_SECRET, 10-min window)
- Returns { signedState, authorizationUrl } — 19-03 /callback reads linkUserId from state
- authorizationUrl constructed from OIDC env vars when configured, null otherwise
- T-19-09: per-request nonce in state prevents CSRF/replay
RED phase for Task 3:
- Test 1: linkOidcToUser updates users.oidc_iss/sub and deletes local_credentials
- Test 2: linkOidcToUser throws OidcLinkConflictError on conflict, no local_cred deletion
- Test 3: POST /api/me/link-oidc returns initiation payload (state / authorizationUrl)
- POST /api/me/password: verifyPassword(current) gate before hashPassword(new) update
- 401 on wrong current password, 404 if no local_credentials row, 200 on success
- meNoEchoHook on /password route (T-19-06, never echo submitted password)
- resolveAdminAndSetupStatus extended with hasLocalCredential (AUTH-LOCAL-17)
- GET /api/me response includes hasLocalCredential alongside isAdmin/needsProviderSetup
RED phase for Task 2:
- Test 1: POST /api/me/password correct current → 200, new hash verifies newPassword
- Test 2: wrong currentPassword → 401, UPDATE not called (hash unchanged)
- Test 3: no local_credentials row → 404
- Test 4 (GET /api/me): hasLocalCredential:true/false based on local_credentials existence
- POST /api/admin/members: atomic tx (users + local_credentials), 409 on dup username
- POST /api/admin/members/:id/password: admin reset (no current-pwd required), 404 if no local cred
- GET /api/admin/members: LEFT JOIN local_credentials, hasLocalCredential in each member row
- noEchoHook on both POST routes (T-19-06); requireAdmin via router.use('*') remains first statement
- Dup-entry detection via error message string match (Drizzle wraps mysql2 ER_DUP_ENTRY)
RED phase for Task 1:
- Test 1: POST /api/admin/members creates users row + local_credentials, hash verifies
- Test 2: duplicate username returns 409, transaction rolled back (no orphaned user row)
- Test 3: admin reset password updates hash, old password no longer verifies
- Test 4: non-admin gets 403 on both POST /members and POST /members/:id/password
- Test 5: GET /api/admin/members returns hasLocalCredential:true/false per local cred existence
- node:crypto scrypt (N=16384, r=8, p=1, 32-byte output) — zero new dependencies (D-08)
- 16-byte random salt per hash; PHC-encoded format: scrypt$N$r$p$salt_b64url$hash_b64url
- timingSafeEqual for constant-time comparison (prevents timing oracle attacks, T-19-01)
- verifyPassword returns false on any error (never throws); passwords never logged
- All 5 unit tests pass (round-trip, wrong-pw, unique-salt, malformed-hash, PHC-shape)
Covers password hashing (node:crypto scrypt), JWT session cookies
(hono/utils/jwt), middleware ordering, local_credentials schema,
OIDC-link flow, dev-bypass rework (option C), and break-glass CLI.
Resolves all five open questions from CONTEXT.md.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>