Phase 19: Local Auth (No-OIDC Mode) Goal: Let an operator run FamilySync entirely on local DB users with no OIDC — username/password accounts and a local login flow that coexists with the Authelia OIDC path — and optionally wire OIDC in later by claiming/linking an existing local user to an OIDC identity. Removes the hard dependency on a deployed Authelia for small/solo self-hosters. Status: Verified ✓ · Threat-secured ✓
This phase generalizes the single bootstrap local user from Phase 12 into a full local-account model with login. It adds scrypt password hashing (PHC-encoded), stateless HS256 local-session cookies with a boot-time LOCAL_SESSION_SECRET guard, a rate-limited/lockout-protected login route with timing-safe username handling, admin member management and self-service password change, an OIDC-account linking flow guarded by a single-use signed nonce and conflict preflight, a standalone /login PWA surface with an auth-mode gate, and a dev-only break-glass admin-reset CLI. Local and OIDC auth can be live simultaneously; a valid local session is honored ahead of the OIDC guard.
UAT (19-UAT.md): Test 1 (login page visual + flow) pass live; Test 2 (admin reset-password) pass live; Test 3 pass live; Test 4 resolved-by-fix (admin reset-password URL mismatch, commit 53da4be); Test 5 → ship. 4 UI-polish findings routed to Phase 17.
Security: 19-SECURITY.md — 28/28 threats closed, threats_open: 0 (26 mitigate + 2 accept, ASVS L1, block-on-high). Every mitigation verified against code with file:line evidence.
Key Decisions
scrypt over argon2/bcrypt, PHC-style encoded (scrypt$N$r$p$salt$hash) — parameter upgrades without DB migration; hashing later made async off the event loop (WR-03).
Stateless HS256 session cookie (local-session, distinct from oidc-auth); fresh JWT issued every login (session-fixation defense); boot refuses to start when LOCAL_SESSION_SECRET is unset/<32 chars (exempt only under DEV_AUTH_BYPASS).
Login lockout keyed on username, not IP (CR-04) — deliberate deviation from the plan's "per-IP" wording: in this single Pangolin-tunnel topology all traffic shares one X-Forwarded-For hop, so IP-keying would let one actor lock out every member; username-keyed with TTL auto-expiry + admin-reset unlock is stronger here.
OIDC-link takeover guards: preflight iss+sub uniqueness conflict (409) before any write, uniq_oidc_identity DB backstop, single-use signed-nonce state (IN-04), and session-match + empty-iss/sub rejection (BL-03).
Local sessions coexist with OIDC: the OIDC guard is wrapped to skip when c.get('user') is already set, rather than a deploy-time mode switch.
Dev/break-glass isolation (D-15): apps/api/scripts/ excluded from the image; reset-admin.ts throws first if NODE_ENV=production; dev seed lives only in e2e/global-setup.ts and CI.
TDD Audit
This project's commits do not emit a gate_status Git trailer (0 of 70 non-merge commits carry one), so every row below normalizes to missing per the audit's strict trailer rule. The RED→GREEN discipline is nonetheless visible in the commit graph: each test: commit lands a failing test immediately before its feat: implementation.
## Summary
**Phase 19: Local Auth (No-OIDC Mode)**
**Goal:** Let an operator run FamilySync entirely on local DB users with no OIDC — username/password accounts and a local login flow that coexists with the Authelia OIDC path — and optionally wire OIDC in later by claiming/linking an existing local user to an OIDC identity. Removes the hard dependency on a deployed Authelia for small/solo self-hosters.
**Status:** Verified ✓ · Threat-secured ✓
This phase generalizes the single bootstrap local user from Phase 12 into a full local-account model with login. It adds scrypt password hashing (PHC-encoded), stateless HS256 local-session cookies with a boot-time `LOCAL_SESSION_SECRET` guard, a rate-limited/lockout-protected login route with timing-safe username handling, admin member management and self-service password change, an OIDC-account linking flow guarded by a single-use signed nonce and conflict preflight, a standalone `/login` PWA surface with an auth-mode gate, and a dev-only break-glass admin-reset CLI. Local and OIDC auth can be live simultaneously; a valid local session is honored ahead of the OIDC guard.
## Changes
### Plan 19-01: Foundation (TDD)
scrypt hash/verify, local-session JWT helpers, `LOCAL_SESSION_SECRET` boot guard, `local_credentials` schema + 0003 migration, `.dockerignore` scripts exclusion (D-15).
**Key files:** `apps/api/src/auth/localCredentials.ts`, `localSession.ts`, `lib/bootGuards.ts`, `db/schema.ts`, `db/migrations/0003_warm_deathstrike.sql`, `.dockerignore`
### Plan 19-02: Backend account management (TDD)
Admin create/reset member, self-change password, `hasLocalCredential`, `linkOidcToUser` helper + `POST /api/me/link-oidc`.
**Key files:** `apps/api/src/auth/linkOidc.ts`, `routes/admin.ts`, `routes/me.ts`
### Plan 19-03: Login route + middleware (TDD)
`localAuthMiddleware`, `GET /api/auth/mode`, `POST /api/auth/local/login` (rate-limit + lockout, timing-safe), logout, `/callback` link branch, OIDC-guard skip-when-user-set, de-Authelia copy.
**Key files:** `apps/api/src/auth/localAuthMiddleware.ts`, `routes/authMode.ts`, `routes/localAuth.ts`, `index.ts`
### Plan 19-04: PWA auth UI
Standalone `/login` route + brand slot, `App.tsx` auth-mode gate, AdminPage LOCAL ACCOUNTS section, SettingsSheet change-password + link-OIDC.
**Key files:** `apps/pwa/src/routes/LoginPage.tsx`, `components/BrandSlot.tsx`, `routes/AdminPage.tsx`, `components/SettingsSheet.tsx`, `api/client.ts`, `App.tsx`
### Plan 19-05: Dev-bypass rework + break-glass + CI
Option C `devSessionCookieMiddleware` (real cookie under bypass), dev-only break-glass `reset-admin.ts`, e2e login spec, CI harness seed.
**Key files:** `apps/api/scripts/reset-admin.ts`, `apps/api/src/auth/devBypass.ts`, `apps/pwa/e2e/login.spec.ts`, `apps/pwa/e2e/global-setup.ts`, `.gitea/workflows/ci.yml`
## Requirements Addressed
AUTH-LOCAL-01 … AUTH-LOCAL-20 (local_credentials schema, scrypt hash/verify, login route, localAuthMiddleware, auth-mode endpoint, logout, admin create-member, admin reset, self-change, OIDC-link, break-glass CLI, LoginPage, admin UI, settings UI, routing gate, dev-bypass/harness rework, hasLocalCredential, de-Authelia copy, rate-limit/lockout, auth unit tests), plus `LOCAL_SESSION_SECRET` env + boot assertion (D-05) and image-hygiene IMG-01/IMG-02.
## Verification
- [x] Automated verification: **passed** — 21/21 must-haves (`19-VERIFICATION.md`). API 446/446, PWA 266/266, e2e desktop 42 passed / 3 skipped, typecheck clean.
- [x] UAT (`19-UAT.md`): Test 1 (login page visual + flow) pass live; Test 2 (admin reset-password) pass live; Test 3 pass live; Test 4 resolved-by-fix (admin reset-password URL mismatch, commit `53da4be`); Test 5 → ship. 4 UI-polish findings routed to Phase 17.
- [x] Security: `19-SECURITY.md` — **28/28 threats closed, threats_open: 0** (26 mitigate + 2 accept, ASVS L1, block-on-high). Every mitigation verified against code with file:line evidence.
## Key Decisions
- **scrypt over argon2/bcrypt**, PHC-style encoded (`scrypt$N$r$p$salt$hash`) — parameter upgrades without DB migration; hashing later made async off the event loop (WR-03).
- **Stateless HS256 session cookie** (`local-session`, distinct from `oidc-auth`); fresh JWT issued every login (session-fixation defense); boot refuses to start when `LOCAL_SESSION_SECRET` is unset/<32 chars (exempt only under `DEV_AUTH_BYPASS`).
- **Login lockout keyed on username, not IP** (CR-04) — deliberate deviation from the plan's "per-IP" wording: in this single Pangolin-tunnel topology all traffic shares one X-Forwarded-For hop, so IP-keying would let one actor lock out every member; username-keyed with TTL auto-expiry + admin-reset unlock is stronger here.
- **OIDC-link takeover guards**: preflight iss+sub uniqueness conflict (409) before any write, `uniq_oidc_identity` DB backstop, single-use signed-nonce state (IN-04), and session-match + empty-iss/sub rejection (BL-03).
- **Local sessions coexist with OIDC**: the OIDC guard is wrapped to skip when `c.get('user')` is already set, rather than a deploy-time mode switch.
- **Dev/break-glass isolation (D-15)**: `apps/api/scripts/` excluded from the image; `reset-admin.ts` throws first if `NODE_ENV=production`; dev seed lives only in `e2e/global-setup.ts` and CI.
## TDD Audit
This project's commits do not emit a `gate_status` Git trailer (0 of 70 non-merge commits carry one), so every row below normalizes to `missing` per the audit's strict trailer rule. The RED→GREEN discipline is nonetheless visible in the commit graph: each `test:` commit lands a failing test immediately before its `feat:` implementation.
| Test commit | Impl commit | gate_status |
|---|---|---|
| `7ece966` test(19-01): failing scrypt hash/verify tests | `85b01b5` feat(19-01): implement hashPassword/verifyPassword | missing |
| `0d8f3fa` test(19-01): failing localSession/boot-guard tests | `7d61148` feat(19-01): localSession helpers + boot guard | missing |
| `b2c7902` test(19-02): failing admin create/reset tests | `6232aa0` feat(19-02): admin create-member/reset | missing |
| `80b5906` test(19-02): failing self-change password tests | `c88f7d4` feat(19-02): self-change password | missing |
| `8ced2d0` test(19-02): failing linkOidcToUser tests | `efb80c8` feat(19-02): linkOidcToUser + /me/link-oidc | missing |
| `ac32bd4` test(19-03): failing middleware + auth-mode tests | `be7a0ae` feat(19-03): localAuthMiddleware + /auth/mode | missing |
| `db66295` test(19-03): failing login + logout tests | `c437f40` feat(19-03): login (rate-limit/lockout) + logout | missing |
| `af0a70c` test(19): UAT completion | — | missing |
Aggregate (informational): 0 skill, 0 fallback, 0 exempt — 8 test-commit rows missing the trailer; 20 `fix:` commits (CR/BL/WR/IN code-review remediations) and the remaining impl commits are not trailer-tagged either.
gate_status: skill=0, fallback=0, exempt=0, missing=70
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>
- 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)
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
- 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 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/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 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)
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
- 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
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>
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>
Lint (eslint --max-warnings 0):
- index.ts: disable no-unsafe-argument on the type-only Context mismatch when
delegating to the OIDC handler inside the local-session skip wrapper
- localAuth.ts: handleLogout is sync (no await) — drop async (require-await)
- devBypass.ts: disable detect-possible-timing-attacks on the public well-known
dev-placeholder string compare (not a secret comparison)
- remove dead code / unused bindings flagged by no-unused-vars: makeTestApp
(localSession.test), makeUnauthContext + BrowserContext import (login.spec),
unused memberId (admin.test), unused txSelectCount counter (me.test)
- localAuthMiddleware.test / me.test: fix unused + reflow-detached
eslint-disable directives
Format: prettier --write across the 20 Phase-19 files that were never formatted.
Secret scan (gitleaks): allowlist two false positives — the synthetic >=32-char
TEST_SECRET in localSession.test.ts, and .planning/ design prose (a generic-api-key
regex hit on "credential atomically, 409-equivalent"). Neither is a real secret.
Verified locally: format:check, lint, typecheck, md:lint, gitleaks (no leaks),
PWA 266/266, API 452/452.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Phase 19: Local Auth (No-OIDC Mode)
Goal: Let an operator run FamilySync entirely on local DB users with no OIDC — username/password accounts and a local login flow that coexists with the Authelia OIDC path — and optionally wire OIDC in later by claiming/linking an existing local user to an OIDC identity. Removes the hard dependency on a deployed Authelia for small/solo self-hosters.
Status: Verified ✓ · Threat-secured ✓
This phase generalizes the single bootstrap local user from Phase 12 into a full local-account model with login. It adds scrypt password hashing (PHC-encoded), stateless HS256 local-session cookies with a boot-time
LOCAL_SESSION_SECRETguard, a rate-limited/lockout-protected login route with timing-safe username handling, admin member management and self-service password change, an OIDC-account linking flow guarded by a single-use signed nonce and conflict preflight, a standalone/loginPWA surface with an auth-mode gate, and a dev-only break-glass admin-reset CLI. Local and OIDC auth can be live simultaneously; a valid local session is honored ahead of the OIDC guard.Changes
Plan 19-01: Foundation (TDD)
scrypt hash/verify, local-session JWT helpers,
LOCAL_SESSION_SECRETboot guard,local_credentialsschema + 0003 migration,.dockerignorescripts exclusion (D-15).Key files:
apps/api/src/auth/localCredentials.ts,localSession.ts,lib/bootGuards.ts,db/schema.ts,db/migrations/0003_warm_deathstrike.sql,.dockerignorePlan 19-02: Backend account management (TDD)
Admin create/reset member, self-change password,
hasLocalCredential,linkOidcToUserhelper +POST /api/me/link-oidc.Key files:
apps/api/src/auth/linkOidc.ts,routes/admin.ts,routes/me.tsPlan 19-03: Login route + middleware (TDD)
localAuthMiddleware,GET /api/auth/mode,POST /api/auth/local/login(rate-limit + lockout, timing-safe), logout,/callbacklink branch, OIDC-guard skip-when-user-set, de-Authelia copy.Key files:
apps/api/src/auth/localAuthMiddleware.ts,routes/authMode.ts,routes/localAuth.ts,index.tsPlan 19-04: PWA auth UI
Standalone
/loginroute + brand slot,App.tsxauth-mode gate, AdminPage LOCAL ACCOUNTS section, SettingsSheet change-password + link-OIDC.Key files:
apps/pwa/src/routes/LoginPage.tsx,components/BrandSlot.tsx,routes/AdminPage.tsx,components/SettingsSheet.tsx,api/client.ts,App.tsxPlan 19-05: Dev-bypass rework + break-glass + CI
Option C
devSessionCookieMiddleware(real cookie under bypass), dev-only break-glassreset-admin.ts, e2e login spec, CI harness seed.Key files:
apps/api/scripts/reset-admin.ts,apps/api/src/auth/devBypass.ts,apps/pwa/e2e/login.spec.ts,apps/pwa/e2e/global-setup.ts,.gitea/workflows/ci.ymlRequirements Addressed
AUTH-LOCAL-01 … AUTH-LOCAL-20 (local_credentials schema, scrypt hash/verify, login route, localAuthMiddleware, auth-mode endpoint, logout, admin create-member, admin reset, self-change, OIDC-link, break-glass CLI, LoginPage, admin UI, settings UI, routing gate, dev-bypass/harness rework, hasLocalCredential, de-Authelia copy, rate-limit/lockout, auth unit tests), plus
LOCAL_SESSION_SECRETenv + boot assertion (D-05) and image-hygiene IMG-01/IMG-02.Verification
19-VERIFICATION.md). API 446/446, PWA 266/266, e2e desktop 42 passed / 3 skipped, typecheck clean.19-UAT.md): Test 1 (login page visual + flow) pass live; Test 2 (admin reset-password) pass live; Test 3 pass live; Test 4 resolved-by-fix (admin reset-password URL mismatch, commit53da4be); Test 5 → ship. 4 UI-polish findings routed to Phase 17.19-SECURITY.md— 28/28 threats closed, threats_open: 0 (26 mitigate + 2 accept, ASVS L1, block-on-high). Every mitigation verified against code with file:line evidence.Key Decisions
scrypt$N$r$p$salt$hash) — parameter upgrades without DB migration; hashing later made async off the event loop (WR-03).local-session, distinct fromoidc-auth); fresh JWT issued every login (session-fixation defense); boot refuses to start whenLOCAL_SESSION_SECRETis unset/<32 chars (exempt only underDEV_AUTH_BYPASS).uniq_oidc_identityDB backstop, single-use signed-nonce state (IN-04), and session-match + empty-iss/sub rejection (BL-03).c.get('user')is already set, rather than a deploy-time mode switch.apps/api/scripts/excluded from the image;reset-admin.tsthrows first ifNODE_ENV=production; dev seed lives only ine2e/global-setup.tsand CI.TDD Audit
This project's commits do not emit a
gate_statusGit trailer (0 of 70 non-merge commits carry one), so every row below normalizes tomissingper the audit's strict trailer rule. The RED→GREEN discipline is nonetheless visible in the commit graph: eachtest:commit lands a failing test immediately before itsfeat:implementation.7ece966test(19-01): failing scrypt hash/verify tests85b01b5feat(19-01): implement hashPassword/verifyPassword0d8f3fatest(19-01): failing localSession/boot-guard tests7d61148feat(19-01): localSession helpers + boot guardb2c7902test(19-02): failing admin create/reset tests6232aa0feat(19-02): admin create-member/reset80b5906test(19-02): failing self-change password testsc88f7d4feat(19-02): self-change password8ced2d0test(19-02): failing linkOidcToUser testsefb80c8feat(19-02): linkOidcToUser + /me/link-oidcac32bd4test(19-03): failing middleware + auth-mode testsbe7a0aefeat(19-03): localAuthMiddleware + /auth/modedb66295test(19-03): failing login + logout testsc437f40feat(19-03): login (rate-limit/lockout) + logoutaf0a70ctest(19): UAT completionAggregate (informational): 0 skill, 0 fallback, 0 exempt — 8 test-commit rows missing the trailer; 20
fix:commits (CR/BL/WR/IN code-review remediations) and the remaining impl commits are not trailer-tagged either.gate_status: skill=0, fallback=0, exempt=0, missing=70
- schema.ts: export localCredentials = mysqlTable('local_credentials', {...}) - user_id FK->users(cascade), username, password_hash, createdAt, updatedAt - UNIQUE(user_id), UNIQUE(username), INDEX(user_id) - 0003_warm_deathstrike.sql: purely additive CREATE TABLE (no ALTER/DROP/TRUNCATE on existing tables) - Applied to dev DB: pnpm --filter @familysync/api db:migrate exits 0 - test/setup.ts: add localCredentials to afterEach delete cleanup (FK-safe ordering) - generate-secrets.mjs: emit LOCAL_SESSION_SECRET (base64 32-byte, >=32 chars, D-05) - .dockerignore: add apps/api/scripts/ exclusion (D-15/IMG-02) — entire break-glass dir excluded- 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)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- localAuthMiddleware: cookie→c.set('user') with Pitfall-1 guard (no-set on no-cookie path) - authMode: GET /api/auth/mode pre-auth endpoint (localEnabled:true, oidcEnabled from env+config) - localAuth: POST /api/auth/local/login (rate-limit + timing-safe), logout routes - index.ts: mount authModeRouter + localAuthRouter pre-auth; localAuthMiddleware after devAuthBypass; OIDC guard wrapped skip-when-user-set