diff --git a/.planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md b/.planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md
new file mode 100644
index 0000000..5a99cf1
--- /dev/null
+++ b/.planning/phases/19-local-auth-no-oidc-mode/19-RESEARCH.md
@@ -0,0 +1,1161 @@
+# Phase 19: Local Auth (No-OIDC Mode) - Research
+
+**Researched:** 2026-06-17
+**Domain:** Authentication — local username/password credentials, stateless JWT session cookies, Hono middleware ordering, Drizzle schema migration
+**Confidence:** HIGH
+
+---
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+
+- **D-01:** Local auth is the **default and always available**. OIDC is **opt-in/additive**, never a replacement for the local path at the system level.
+- **D-02:** OIDC is configured from the **admin UI** (extends the Phase-12 config that already lands in `app_config`: `oidc_issuer`, `oidc_client_id`, `app_external_url`). When OIDC is configured, **both methods are offered and the user chooses at login** (local username/password OR "Login with OIDC").
+- **D-03:** This must **not break the existing live OIDC deployment**. The two current household members already authenticate via Authelia (`oidc_iss`/`oidc_sub` set, `claimed=true`); they continue as OIDC users. Local auth is layered on additively.
+- **D-04:** A **new local login UI (username + password) must be built in the PWA** — none exists today. The PWA currently boots straight into the authed app (OIDC redirect) or via dev-bypass; there is no login form.
+- **D-05:** Local logins are backed by a **stateless signed httpOnly JWT cookie** carrying `userId`, validated by a **new local-auth middleware that sets `c.get('user')`** the same way `auth/devBypass.ts` does — so every downstream route resolves the user unchanged. **No DB sessions table**. Tradeoff accepted: a password change cannot retroactively invalidate other live sessions; logout = clear cookie.
+- **D-06 (BYO-Auth principle):** Local auth is first-class; OIDC is treated as a **generic RFC-compliant provider, not Authelia-hardcoded**. `@hono/oidc-auth` is already provider-agnostic — work is to de-Authelia-ize config keys and user-facing copy.
+- **D-07 (BYO-Auth scope):** Ship **local + one generic OIDC** with a **clean internal seam** for future methods. **No plugin/registry framework** in this phase.
+- **D-08:** Hash local passwords with **`node:crypto` scrypt** — zero new dependency, no native node-gyp build. Encode **algorithm + params + salt alongside the hash** so parameters can evolve.
+- **D-09:** Store local credentials in a **new `local_credentials` table** — `user_id` (FK to `users`, UNIQUE), `username` (UNIQUE), `password_hash` (encoded), `createdAt`/`updatedAt`. Drizzle **generate+migrate, never push**.
+- **D-10:** **Admin creates members** + sets an initial password; the member changes it later. **No open self-signup**.
+- **D-11:** Password lifecycle = **self-change (current + new) + admin-reset** from the admin UI. **No email reset**.
+- **D-12:** **OIDC link replaces local at the per-user level**: when a local user links an OIDC identity (explicit action while authenticated as that user — never an email match), **delete that user's `local_credentials` row** → they become OIDC-only.
+- **D-13 (break-glass):** Lockout recovery is a **CLI/console command and/or env override** (e.g. create/reset a local admin, or disable/force-off OIDC), run on the host/container. **No new role/capability model**; reuse today's single `users.is_admin`.
+- **D-14:** The new login UI requires touching existing API/unit tests and the **Phase 7/8 Playwright harness** (which today reaches the authed PWA purely via `DEV_AUTH_BYPASS`, skipping any login). Both the already-authed fast path and the **real login form** must remain testable.
+- **D-15 (hard constraint):** Any seeded test login / reworked dev-bypass mechanism **stays dev-only and never ships in the Docker/prod image**. Bound by the existing Phase-16 image-hygiene gates: IMG-01 boot guard (`assertNotDevBypassInProduction`), `.dockerignore` (IMG-02), and the publish-time hygiene assertion (IMG-03).
+
+### Claude's Discretion (decided in-discussion)
+- Session backing mechanism (chose stateless signed JWT cookie — D-05).
+- Credential storage location (chose separate `local_credentials` table — D-09).
+
+### Deferred Ideas (OUT OF SCOPE)
+- **Full pluggable auth-provider framework** (registry/plugin for LDAP, magic-link, multiple simultaneous OIDC providers) — auth-layer counterpart of backlog 999.1.
+- **Member-vs-operator capability/role split** — explicitly rejected in favor of a CLI/env recovery mechanism.
+- **Email-based password reset** — out of project scope.
+
+
+---
+
+
+## Phase Requirements
+
+The following REQ-IDs are newly defined by this phase. The planner should include them verbatim in PLAN.md task descriptions and VALIDATION.md.
+
+| ID | Description | Research Support |
+|----|-------------|------------------|
+| AUTH-LOCAL-01 | `local_credentials` Drizzle schema + migration (0003): `user_id` FK UNIQUE, `username` UNIQUE, `password_hash` varchar | §Schema Change, §Standard Stack §Migration |
+| AUTH-LOCAL-02 | `hashPassword(password)` and `verifyPassword(hash, candidate)` helpers using `node:crypto` scrypt in PHC-style encoded format | §Password Hashing |
+| AUTH-LOCAL-03 | `POST /api/auth/local/login` route — timing-safe verify, issue `local-session` JWT cookie, return 401/429/423/200 | §Local Login Endpoint |
+| AUTH-LOCAL-04 | `localAuthMiddleware` — reads `local-session` cookie, validates JWT, sets `c.get('user')` identical to devBypass; mounts in `index.ts` before OIDC guard | §Middleware Slot |
+| AUTH-LOCAL-05 | `GET /api/auth/mode` pre-auth endpoint — returns `{ localEnabled: true, oidcEnabled: boolean }` based on `app_config` OIDC keys | §Auth Mode Endpoint |
+| AUTH-LOCAL-06 | `POST /api/auth/local/logout` — clears `local-session` cookie; `GET /api/auth/local/logout` alias | §Logout |
+| AUTH-LOCAL-07 | `POST /api/admin/members` — admin creates local member: insert `users` row + `local_credentials` row with hashed initial password | §Admin Account Management |
+| AUTH-LOCAL-08 | `POST /api/admin/members/:id/password` — admin resets a local member's password (no current-password required); admin-gated | §Admin Account Management |
+| AUTH-LOCAL-09 | `POST /api/me/password` — self-change password: verify current password, hash new, update `local_credentials` | §Self-Service Password |
+| AUTH-LOCAL-10 | OIDC-link flow: `POST /api/me/link-oidc` (or reuse OIDC callback, see §OIDC-Link Flow) — bind `oidc_iss+oidc_sub` to the authenticated user, delete their `local_credentials` row | §OIDC-Link Flow |
+| AUTH-LOCAL-11 | Break-glass CLI — Node.js script `scripts/reset-admin.ts` runnable as `tsx scripts/reset-admin.ts` inside the container, creates/resets a local admin by username without requiring an existing session | §Break-Glass |
+| AUTH-LOCAL-12 | PWA `LoginPage` component (`/login` route) per UI-SPEC Surface 1–10: username+password form, `BrandSlot`, OIDC button when `oidcEnabled`, error state machine | §UI — LoginPage |
+| AUTH-LOCAL-13 | PWA admin additions: "Add member" form (UI-SPEC Surface 11A) + "Reset password" modal (Surface 11B) in `AdminPage.tsx` | §UI — Admin |
+| AUTH-LOCAL-14 | PWA self-service: "Change password" sheet (UI-SPEC Surface 12) in `SettingsSheet.tsx`; "Link OIDC identity" confirmation sheet (Surface 13) | §UI — Settings |
+| AUTH-LOCAL-15 | `App.tsx` login gate: fetch `/api/auth/mode` pre-auth, add `/login` route (standalone), redirect unauthenticated users there when `localEnabled`, skip if valid local or OIDC session | §PWA Routing Gate |
+| AUTH-LOCAL-16 | Playwright harness dev-bypass rework: "option C" (bypass issues a real local-session cookie), update `global-setup.ts` to seed `local_credentials` for dev user (id=1), update CI workflow | §Dev-Bypass Rework |
+| AUTH-LOCAL-17 | `/api/me` response extended with `hasLocalCredential: boolean` so `SettingsSheet` and `AdminPage` know which users have local creds | §API Extensions |
+| AUTH-LOCAL-18 | `app_config` OIDC key de-Authelia-ization: rename any Authelia-specific copy in config keys and comments to generic OIDC labels (no key name change — keys are already generic `oidc_issuer` etc.) | §BYO-Auth De-Authelia-ization |
+| AUTH-LOCAL-19 | Rate-limiting on `POST /api/auth/local/login`: in-memory per-IP counter (Map), 5 failures → 60s cooldown → 429; account lockout at 10 failures → 423; cleared on success | §Rate Limiting |
+| AUTH-LOCAL-20 | Vitest unit tests: password hashing round-trip, timing-safe compare, login success/failure/lockout, middleware session validation, OIDC-link 409 conflict | §Validation Architecture |
+
+
+---
+
+## Summary
+
+Phase 19 builds a complete local username/password authentication system on top of the Phase 12 pre-OIDC user foundation. The core architectural move is to add a parallel authentication path alongside the existing `@hono/oidc-auth` middleware: a new `localAuthMiddleware` that reads a signed `local-session` cookie (JWT, HS256 via Hono's built-in `Jwt.sign`/`Jwt.verify`) and populates `c.get('user')` with the same shape that `devAuthBypass()` uses, so all downstream routes work unchanged.
+
+No new npm dependencies are required. Password hashing uses `node:crypto` scrypt (Node.js stdlib), JWT signing uses Hono's built-in `Jwt` from `hono/utils/jwt`, and session cookies use the existing `hono/cookie` helpers (`getCookie`/`setCookie`). The `local_credentials` table mirrors the `member_credentials` shape already in the schema. The migration is a straightforward additive `drizzle-kit generate` + `migrate`.
+
+The PWA adds a `/login` route (standalone, no AppNav/BottomTabBar — same pattern as `/setup`). The login gate is driven by a new pre-auth `GET /api/auth/mode` endpoint. Existing OIDC users are completely unaffected by all of this: their `oidcAuthMiddleware` path is unchanged; the local auth path only fires for requests that arrive with a `local-session` cookie.
+
+**Primary recommendation:** Implement in three waves: (1) schema + credential helpers + API routes, (2) middleware wiring + mode endpoint + PWA login page, (3) admin UI extensions + OIDC-link + break-glass + harness update.
+
+---
+
+## Architectural Responsibility Map
+
+| Capability | Primary Tier | Secondary Tier | Rationale |
+|------------|-------------|----------------|-----------|
+| Password hashing/verification | API / Backend | — | Secrets never leave server; `node:crypto` scrypt runs server-side only |
+| Session JWT sign/verify | API / Backend | — | `LOCAL_SESSION_SECRET` is an env-floor secret; client only holds opaque cookie |
+| Auth middleware selection | API / Backend | — | `index.ts` is the single mount-order source of truth |
+| Auth mode signalling | API / Backend (pre-auth endpoint) | Frontend fetch | `/api/auth/mode` is the authoritative source; PWA reads it |
+| Login form UI | Browser / Client | — | React component, form state, client-side validation |
+| Admin member management | API / Backend | Admin UI (client) | Server enforces `requireAdmin`; client is UX-only |
+| OIDC-link binding | API / Backend | Browser / Client (confirmation UI) | Actual `iss+sub` binding + `local_credentials` delete is a backend transaction |
+| Break-glass recovery | API / Backend (CLI script) | — | Host/container-side only; no UI surface |
+| Cookie issuance | API / Backend | — | `httpOnly + Secure + SameSite=Lax`; client cannot write it |
+| Rate-limiting / lockout | API / Backend | Browser / Client (error display) | In-memory Map on the server; UI mirrors the 429/423 response |
+
+---
+
+## Standard Stack
+
+### Core (no new packages — everything already installed)
+
+| Library | Version | Purpose | Status |
+|---------|---------|---------|--------|
+| `node:crypto` | stdlib (Node 22) | `scrypt`, `scryptSync`, `randomBytes`, `timingSafeEqual` | [VERIFIED: codebase — confirmed available in Node 22.22.3 on this machine] |
+| `hono` | 4.12.23 (installed) | `Jwt.sign`, `Jwt.verify` from `hono/utils/jwt`; `getCookie`, `setCookie` from `hono/cookie` | [VERIFIED: codebase — `Jwt.sign` and `Jwt.verify` confirmed callable at runtime from `hono/utils/jwt`; `getCookie`/`setCookie` confirmed from `hono/cookie`] |
+| `drizzle-orm` | 0.45.2 (installed) | New `local_credentials` table; `drizzle-kit generate` + `migrate` | [VERIFIED: codebase — already in use; `mysqlTable`, `unique`, `index` patterns from `schema.ts`] |
+| `zod` | 3.25.x (installed) | Validate login request body (`username`, `password`) | [VERIFIED: codebase — already used in every route] |
+
+### Zero new npm dependencies
+
+This phase installs **no new packages**. All required capabilities are in the existing stack:
+
+- Password hashing: `node:crypto` scrypt (stdlib, confirmed available)
+- JWT signing: `hono/utils/jwt` `Jwt.sign`/`Jwt.verify` (confirmed callable from `hono@4.12.23`)
+- Cookie read/write: `hono/cookie` `getCookie`/`setCookie` (already used in `persistSessionCookie.ts`)
+- Schema: `drizzle-orm` `mysqlTable` (same pattern as `member_credentials`)
+- Input validation: `zod` + `@hono/zod-validator` (already used in every route)
+
+**Installation:** None required.
+
+---
+
+## Package Legitimacy Audit
+
+No new packages are introduced in this phase. All libraries listed above are already installed and legitimacy-verified from prior phases.
+
+**Packages removed due to SLOP verdict:** None
+**Packages flagged as suspicious:** None (hono is flagged SUS by the seam's "too-new" heuristic because its last publish date happens to be recent, but it is the same `hono@4.12.23` already installed and running in production — not a new install)
+
+---
+
+## Architecture Patterns
+
+### System Architecture Diagram
+
+```
+PWA /login page
+ ↓ GET /api/auth/mode (pre-auth, no middleware)
+ ← { localEnabled: true, oidcEnabled: boolean }
+
+ ↓ POST /api/auth/local/login { username, password }
+ API auth/localLogin.ts
+ → local_credentials lookup by username
+ → verifyPassword(stored_hash, candidate) [node:crypto timingSafeEqual]
+ → Jwt.sign({ userId, iat, exp }, LOCAL_SESSION_SECRET)
+ ← Set-Cookie: local-session=; httpOnly; Secure; SameSite=Lax
+ ← 200 { ok: true }
+
+ ↓ Any subsequent /api/* request (carries local-session cookie)
+ index.ts middleware chain:
+ devAuthBypass() — no-op passthrough (bypass not set in prod)
+ localAuthMiddleware() — getCookie('local-session'), Jwt.verify(), c.set('user', {id, ...})
+ → next() if valid cookie; else fall through
+ oidcConfigFallback — injects OIDC config from app_config if absent
+ oidcAuthMiddleware() — skipped if c.get('user') already set? [see §Middleware Slot]
+ persistSessionCookie()— OIDC sessions only
+ ↓ downstream routes read c.get('user') — unchanged
+
+PWA /admin → AdminPage
+ ↓ GET /api/admin/members (returns hasLocalCredential per member)
+ ↓ POST /api/admin/members { displayName, username, initialPassword }
+ → users INSERT + local_credentials INSERT (hashed)
+ ↓ POST /api/admin/members/:id/password { newPassword, confirmPassword }
+ → local_credentials UPDATE (hashed)
+
+PWA SettingsSheet
+ ↓ POST /api/me/password { currentPassword, newPassword }
+ → verifyPassword(stored, current) → UPDATE hash
+ ↓ POST /api/me/link-oidc (authenticated as local user)
+ → initiates OIDC authorization-code redirect
+ → on /callback with valid OIDC session:
+ bind iss+sub to users row (must not conflict with existing user)
+ DELETE local_credentials WHERE user_id = current
+ → user is now OIDC-only
+
+Break-glass:
+ docker exec familysync-api node scripts/reset-admin.ts --username admin --password
+ → direct DB write: upsert local_credentials for username, ensure is_admin=true
+```
+
+### Recommended Project Structure
+
+New files (additions only):
+
+```
+apps/api/src/
+├── auth/
+│ ├── localCredentials.ts # hashPassword(), verifyPassword() using node:crypto scrypt
+│ ├── localSession.ts # issueLocalSessionCookie(), verifyLocalSessionCookie(), clearLocalSessionCookie()
+│ └── localAuthMiddleware.ts # Hono middleware: getCookie → Jwt.verify → c.set('user')
+├── routes/
+│ ├── authMode.ts # GET /api/auth/mode (pre-auth)
+│ └── localAuth.ts # POST /api/auth/local/login, /logout
+└── db/
+ └── migrations/
+ └── 0003_local_credentials.sql # generated by drizzle-kit
+
+apps/api/scripts/
+└── reset-admin.ts # break-glass CLI (dev-only gate: .dockerignore excludes scripts/)
+
+apps/pwa/src/
+├── routes/
+│ └── LoginPage.tsx # /login standalone page (UI-SPEC Surfaces 1–10)
+└── components/
+ └── BrandSlot.tsx # phase-17 seam component (UI-SPEC §Brand Slot)
+```
+
+Modified files:
+
+```
+apps/api/src/
+├── db/schema.ts # + local_credentials table definition
+├── routes/admin.ts # + POST /members, POST /members/:id/password
+├── routes/me.ts # + POST /password, + hasLocalCredential in GET response
+├── routes/setup.ts # + link-oidc callback handler (or reuse /callback)
+└── index.ts # + mount localAuthMiddleware, authModeRouter before OIDC guard
+
+apps/pwa/src/
+├── App.tsx # + /login route, auth-mode fetch gate
+├── api/client.ts # + fetchAuthMode(), fetchLocalLogin(), fetchLocalLogout(), etc.
+├── routes/AdminPage.tsx # + LOCAL ACCOUNTS section (Surfaces 11A, 11B)
+└── components/SettingsSheet.tsx # + Change password row (Surface 12), Link OIDC row (Surface 13)
+```
+
+---
+
+## Password Hashing Pattern
+
+### PHC-Style Encoding with `node:crypto` scrypt
+
+D-08 mandates `node:crypto` scrypt with the algorithm + params + salt encoded alongside the hash so parameters can evolve. The established pattern for self-describing encoded hashes is a `$`-delimited PHC-style string. [VERIFIED: codebase — scryptSync, randomBytes, timingSafeEqual all available in Node 22.22.3; runtime confirmed]
+
+```typescript
+// Source: node:crypto docs + runtime-verified on Node 22.22.3
+import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto';
+
+// Parameters (OWASP-compatible for scrypt at this resource level)
+const SCRYPT_N = 16384; // CPU/memory cost — 2^14; increase to 2^15 if hardware permits
+const SCRYPT_R = 8;
+const SCRYPT_P = 1;
+const KEY_LEN = 32; // 256-bit output
+
+/**
+ * Hash a password. Returns a self-describing encoded string:
+ * scrypt$N$r$p$$
+ *
+ * The $-delimited format is inspired by PHC and allows future parameter upgrades
+ * without a separate migration — verifyPassword parses all fields from the string.
+ */
+export function hashPassword(password: string): string {
+ const salt = randomBytes(16);
+ const hash = scryptSync(password, salt, KEY_LEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P });
+ return [
+ 'scrypt',
+ SCRYPT_N,
+ SCRYPT_R,
+ SCRYPT_P,
+ salt.toString('base64url'),
+ hash.toString('base64url'),
+ ].join('$');
+}
+
+/**
+ * Verify a password against a stored encoded hash.
+ * Uses timingSafeEqual to prevent timing-oracle attacks.
+ * Returns false (never throws) on any parse/format mismatch.
+ */
+export function verifyPassword(storedEncoded: string, candidate: string): boolean {
+ try {
+ const [, n, r, p, saltB64, hashB64] = storedEncoded.split('$');
+ const salt = Buffer.from(saltB64, 'base64url');
+ const storedHash = Buffer.from(hashB64, 'base64url');
+ const candidateHash = scryptSync(candidate, salt, storedHash.length, {
+ N: Number(n), r: Number(r), p: Number(p),
+ });
+ return timingSafeEqual(storedHash, candidateHash);
+ } catch {
+ return false;
+ }
+}
+```
+
+**Key points:**
+- `scryptSync` blocks the event loop. For login (infrequent in a 2-person household) this is acceptable. If async is preferred, use `promisify(scrypt)` from `node:util`.
+- `timingSafeEqual` requires equal-length buffers — `storedHash.length` as keylen ensures this.
+- The encoded string is ~83 characters at N=16384 — fits comfortably in `varchar(256)`.
+
+**Note on pepper:** D-08 specifies no pepper (env-only secret kernel). The scrypt salt + encoding is sufficient for this use case. Adding a pepper would require another env var and would not materially improve security for this threat model (household scale, Pangolin-exposed but not public).
+
+---
+
+## Drizzle Schema Change
+
+### `local_credentials` Table
+
+Mirrors the `member_credentials` shape but stores username + password hash. [VERIFIED: codebase — `member_credentials` pattern in `schema.ts` lines 74–94; `mysqlTable`, `int`, `varchar`, `timestamp`, `unique`, `index` all imported]
+
+```typescript
+// In apps/api/src/db/schema.ts — additive only
+export const localCredentials = mysqlTable(
+ 'local_credentials',
+ {
+ id: int().primaryKey().autoincrement(),
+ userId: int('user_id')
+ .notNull()
+ .references(() => users.id, { onDelete: 'cascade' }),
+ username: varchar('username', { length: 128 }).notNull(),
+ // PHC-encoded: scrypt$N$r$p$$ — self-describing
+ passwordHash: varchar('password_hash', { length: 256 }).notNull(),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
+ },
+ (t) => [
+ // One local credential per user (UNIQUE on user_id)
+ unique('uniq_local_cred_user').on(t.userId),
+ // Username is globally unique (login identifier)
+ unique('uniq_local_cred_username').on(t.username),
+ index('idx_local_credentials_user_id').on(t.userId),
+ ],
+);
+```
+
+### Migration Workflow
+
+Drizzle generate+migrate only — never push (established rule; `push` emits false destructive diffs on MariaDB). [VERIFIED: codebase — 0002 migration and `scripts` in `package.json`]
+
+```bash
+# 1. Add localCredentials to schema.ts
+# 2. Generate migration
+pnpm --filter @familysync/api db:generate
+# → apps/api/src/db/migrations/0003_local_credentials.sql
+
+# 3. Review generated SQL (must be purely additive — CREATE TABLE only)
+# 4. Apply
+pnpm --filter @familysync/api db:migrate
+```
+
+The generated SQL will be something like:
+```sql
+CREATE TABLE `local_credentials` (
+ `id` int AUTO_INCREMENT PRIMARY KEY,
+ `user_id` int NOT NULL REFERENCES `users`(`id`) ON DELETE CASCADE,
+ `username` varchar(128) NOT NULL,
+ `password_hash` varchar(256) NOT NULL,
+ `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ CONSTRAINT `uniq_local_cred_user` UNIQUE(`user_id`),
+ CONSTRAINT `uniq_local_cred_username` UNIQUE(`username`),
+ INDEX `idx_local_credentials_user_id`(`user_id`)
+);
+```
+
+**Remember:** Export `localCredentials` from `schema.ts` so `test/setup.ts` can truncate it in `afterEach`.
+
+---
+
+## Middleware Slot and Ordering
+
+### Current `index.ts` middleware chain
+
+[VERIFIED: codebase — read `apps/api/src/index.ts`]
+
+```
+app.route('/api/setup', setupRouter) // pre-auth
+app.use('/api/*', devAuthBypass()) // DEV only — no-op in prod
+if (!devBypassActive) {
+ app.use('/api/*', oidcConfigFallbackMiddleware)
+ app.use('/api/*', oidcAuthMiddleware())
+ app.use('/api/*', persistSessionCookie())
+}
+```
+
+### New middleware slot for Phase 19
+
+The `localAuthMiddleware` must sit **between `devAuthBypass` and `oidcAuthMiddleware`**. It reads the `local-session` cookie. If the cookie is present and valid, it sets `c.get('user')` and calls `next()`. If no cookie, it falls through to `oidcAuthMiddleware`.
+
+The key architectural requirement: `oidcAuthMiddleware` must **not** redirect to Authelia when the request already has a valid local session. The solution is to check whether `c.get('user')` is set before mounting `oidcAuthMiddleware`, or to make `localAuthMiddleware` short-circuit the OIDC path.
+
+**Recommended approach:** Wrap `oidcAuthMiddleware` in a guard that skips it when `c.get('user')` is already populated:
+
+```typescript
+// index.ts updated middleware chain
+app.route('/api/setup', setupRouter) // pre-auth (no change)
+app.route('/api/auth', authModeRouter) // GET /api/auth/mode — pre-auth, no middleware
+app.route('/api/auth', localAuthRouter) // POST /api/auth/local/login + /logout — pre-auth
+
+app.use('/api/*', devAuthBypass()) // DEV only (existing)
+
+// NEW: local session check — populates c.get('user') if local-session cookie valid
+app.use('/api/*', localAuthMiddleware())
+
+if (!devBypassActive) {
+ app.use('/api/*', oidcConfigFallbackMiddleware)
+ // OIDC guard: skip if user already set by localAuthMiddleware or devAuthBypass
+ app.use('/api/*', async (c, next) => {
+ if (c.get('user')) { await next(); return; }
+ await oidcAuthMiddleware()(c, next);
+ });
+ app.use('/api/*', persistSessionCookie())
+}
+```
+
+**Why pre-auth for login/logout routes:** `POST /api/auth/local/login` must be reachable without a session (it's how a session is created). Mount it before the OIDC guard, just like `/api/setup/*`. The `GET /api/auth/mode` endpoint similarly needs no auth.
+
+**Security note:** `localAuthMiddleware` must be a no-op when no `local-session` cookie is present — it should not attempt to verify a missing cookie and must not set `c.get('user')` to `undefined`. The downstream `oidcAuthMiddleware` redirects only when `c.get('user')` is falsy.
+
+---
+
+## JWT Session Cookie Pattern
+
+### `issueLocalSessionCookie` / `verifyLocalSessionCookie`
+
+Uses Hono's built-in `Jwt` from `hono/utils/jwt`. The algorithm is HS256 (symmetric, fast, appropriate for a single-server household app). [VERIFIED: codebase — `Jwt.sign` and `Jwt.verify` confirmed callable at runtime]
+
+```typescript
+// Source: hono/utils/jwt (runtime-verified in this project)
+import { Jwt } from 'hono/utils/jwt';
+import { setCookie, getCookie, deleteCookie } from 'hono/cookie';
+import type { Context } from 'hono';
+
+const COOKIE_NAME = 'local-session';
+const SESSION_MAX_AGE_SECONDS = Number(process.env.LOCAL_SESSION_EXPIRES ?? 86400); // 1 day default
+
+export async function issueLocalSessionCookie(c: Context, userId: number): Promise {
+ const secret = process.env.LOCAL_SESSION_SECRET;
+ if (!secret) throw new Error('LOCAL_SESSION_SECRET env var not set');
+
+ const now = Math.floor(Date.now() / 1000);
+ const payload = { userId, iat: now, exp: now + SESSION_MAX_AGE_SECONDS };
+
+ const token = await Jwt.sign(payload, secret, 'HS256');
+
+ setCookie(c, COOKIE_NAME, token, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'Lax',
+ path: '/',
+ maxAge: SESSION_MAX_AGE_SECONDS,
+ });
+}
+
+export async function verifyLocalSessionCookie(c: Context): Promise {
+ const secret = process.env.LOCAL_SESSION_SECRET;
+ if (!secret) return null;
+
+ const token = getCookie(c, COOKIE_NAME);
+ if (!token) return null;
+
+ try {
+ const payload = await Jwt.verify(token, secret, 'HS256');
+ return typeof payload.userId === 'number' ? payload.userId : null;
+ } catch {
+ return null;
+ }
+}
+
+export function clearLocalSessionCookie(c: Context): void {
+ deleteCookie(c, COOKIE_NAME, { path: '/', httpOnly: true, secure: true, sameSite: 'Lax' });
+}
+```
+
+**Cookie name:** `local-session` (distinct from the OIDC cookie `oidc-auth` — avoids collision).
+
+**`LOCAL_SESSION_SECRET` env var:** This is a new env-floor secret that must be added to the D-01 minimal env kernel documentation (it encrypts the local session JWT). It never goes to `app_config`. Operators generate it with `openssl rand -base64 32`. The `generate-secrets` script from Phase 12 should be extended to emit it.
+
+---
+
+## Local Login Endpoint
+
+### `POST /api/auth/local/login`
+
+[ASSUMED — specific implementation, but directly derived from the patterns in `setup.ts` and `admin.ts`]
+
+```typescript
+// apps/api/src/routes/localAuth.ts
+const loginSchema = z.object({
+ username: z.string().min(1).max(128).trim(),
+ password: z.string().min(1).max(1000),
+});
+
+// Rate-limiting: simple in-memory Map (household scale; no Redis needed)
+// { ip → { failCount, lockedUntil } }
+const loginAttempts = new Map();
+const RATE_WINDOW_FAILURES = 5; // 5 failures → 60s cooldown
+const RATE_WINDOW_SECS = 60;
+const LOCKOUT_FAILURES = 10; // 10 failures → account locked (admin must reset)
+
+localAuthRouter.post('/login', zValidator('json', loginSchema, noEchoHook), async (c) => {
+ const ip = c.req.header('x-forwarded-for') ?? c.req.raw.headers.get('host') ?? 'unknown';
+
+ // Check rate limit / lockout
+ const attempt = loginAttempts.get(ip);
+ if (attempt?.lockedOut) {
+ return c.json({ error: 'Account locked' }, 423);
+ }
+ if (attempt && attempt.count >= RATE_WINDOW_FAILURES && Date.now() < attempt.lockedUntil) {
+ return c.json({ error: 'Too many attempts' }, 429);
+ }
+
+ const { username, password } = c.req.valid('json');
+
+ // Lookup by username — constant-time operation for timing safety
+ const [cred] = await db
+ .select({ userId: localCredentials.userId, passwordHash: localCredentials.passwordHash })
+ .from(localCredentials)
+ .where(eq(localCredentials.username, username))
+ .limit(1);
+
+ // Always run verifyPassword even on unknown username (dummy hash) to prevent timing oracle
+ const dummy = hashPassword('dummy-constant-time-filler');
+ const valid = cred ? verifyPassword(cred.passwordHash, password) : verifyPassword(dummy, password);
+
+ if (!valid || !cred) {
+ // Increment failure counter
+ const cur = loginAttempts.get(ip) ?? { count: 0, lockedUntil: 0, lockedOut: false };
+ cur.count += 1;
+ cur.lockedUntil = Date.now() + RATE_WINDOW_SECS * 1000;
+ cur.lockedOut = cur.count >= LOCKOUT_FAILURES;
+ loginAttempts.set(ip, cur);
+ return c.json({ error: 'Invalid credentials' }, 401);
+ }
+
+ // Success: clear failure counter, issue session cookie
+ loginAttempts.delete(ip);
+ await issueLocalSessionCookie(c, cred.userId);
+ return c.json({ ok: true }, 200);
+});
+```
+
+**Security notes:**
+- The `noEchoHook` must be used on `zValidator` for the login route — same as credential routes — to prevent Zod errors from echoing the submitted password.
+- "Username not found" and "wrong password" return the same 401 + same copy — no field discrimination.
+- The dummy hash prevents timing oracle on username enumeration.
+- Rate-limiting is per-IP (from `X-Forwarded-For` header, which Pangolin sets). For a 2-person household this is more than sufficient.
+- Lockout (423) is resolved only by admin password reset — mirrors the UI copy "Contact your admin to reset access".
+
+---
+
+## Auth Mode Endpoint
+
+### `GET /api/auth/mode` (pre-auth)
+
+Must be mounted **before** all auth middleware in `index.ts` (same pre-auth pattern as `/api/setup/*` and `/health`). [VERIFIED: codebase — `app.route('/api/setup', setupRouter)` mounts pre-auth; same pattern applies]
+
+```typescript
+// apps/api/src/routes/authMode.ts
+authModeRouter.get('/', async (c) => {
+ // localEnabled: always true — local auth is the default and always available (D-01)
+ // oidcEnabled: true when oidc_issuer is configured in app_config OR process.env
+ const issuerFromEnv = process.env.OIDC_ISSUER;
+ let oidcEnabled = Boolean(issuerFromEnv);
+
+ if (!oidcEnabled) {
+ const [row] = await db
+ .select({ value: appConfig.value })
+ .from(appConfig)
+ .where(eq(appConfig.key, 'oidc_issuer'))
+ .limit(1);
+ oidcEnabled = Boolean(row?.value);
+ }
+
+ return c.json({ localEnabled: true, oidcEnabled });
+});
+```
+
+This endpoint is not protected by OIDC middleware. The PWA fetches it on app load before knowing if the user is authenticated.
+
+---
+
+## OIDC-Link Flow
+
+D-12: when a local user explicitly links an OIDC identity, their `local_credentials` row is deleted and `users.oidc_iss`/`oidc_sub` are populated. The returned `iss+sub` must not already belong to another user.
+
+### Recommended Implementation
+
+The simplest approach reuses the existing `/callback` handler: add a `link_mode` query param that signals the OIDC callback to run in "link" mode rather than "new session" mode.
+
+**Flow:**
+1. User (authenticated as local user with valid `local-session` cookie) clicks "Continue with OIDC"
+2. PWA calls `POST /api/me/link-oidc` → server initiates OIDC authorization-code redirect with `state` parameter encoding `{ linkUserId: currentUserId, nonce }`
+3. OIDC callback fires → `processOAuthCallback` handles the code exchange, gets `iss+sub`
+4. Backend detects `linkUserId` in state → look up if `iss+sub` already belongs to a different user → if yes, 409 error page; if no, UPDATE `users SET oidc_iss, oidc_sub WHERE id = linkUserId`, DELETE from `local_credentials WHERE user_id = linkUserId`
+5. Issue OIDC session (the user is now OIDC-only) → redirect to `/calendar`
+
+**Alternative (simpler, recommended):** A dedicated `POST /api/me/link-oidc` endpoint that initiates the OIDC redirect. The current user's `userId` is encoded in the OIDC `state` parameter (signed to prevent CSRF). On callback, the backend reads `state.userId`, verifies the OIDC identity is unique, binds it.
+
+**D-10 constraint:** Identity binding must use `iss+sub` from the OIDC token, never email. The `upsertUser` function already enforces this — the link flow must replicate this strictness.
+
+**409 conflict:** If the `iss+sub` returned by OIDC already exists in `users`, return a redirect to an error page. The PWA displays: "This OIDC identity is already linked to another account. Please contact your admin."
+
+---
+
+## Admin Account Management API
+
+### New Routes on `adminRouter`
+
+Extends `apps/api/src/routes/admin.ts`. The `requireAdmin` guard at the router level already covers these. [VERIFIED: codebase — `adminRouter.use('*', requireAdmin)` is the first statement]
+
+**Create member:**
+```typescript
+adminRouter.post('/members', zValidator('json', createMemberSchema, noEchoHook), async (c) => {
+ // 1. Insert into users (displayName, color from palette)
+ // 2. Hash initialPassword via hashPassword()
+ // 3. Insert into local_credentials (userId, username, passwordHash)
+ // If username conflict: 409
+});
+```
+
+**Reset password:**
+```typescript
+adminRouter.post('/members/:id/password', zValidator('json', resetPasswordSchema, noEchoHook), async (c) => {
+ // Admin does not need to know the current password (D-11)
+ // 1. Verify target user exists + has local_credentials row
+ // 2. Hash newPassword
+ // 3. UPDATE local_credentials SET password_hash WHERE user_id
+ // Also clear any in-memory lockout entry for this user
+});
+```
+
+**Extend `GET /api/admin/members`** to include `hasLocalCredential: boolean` (LEFT JOIN on `local_credentials`).
+
+---
+
+## Self-Service Password Change
+
+### `POST /api/me/password`
+
+Added to `apps/api/src/routes/me.ts`. [ASSUMED — derived from existing `POST /api/me/credential` pattern]
+
+```typescript
+meRouter.post('/password', zValidator('json', changePasswordSchema, meNoEchoHook), async (c) => {
+ const currentUserId = await resolveUserId(c);
+ if (!currentUserId) return c.json({ error: 'Unauthorized' }, 401);
+
+ const [cred] = await db.select().from(localCredentials).where(eq(localCredentials.userId, currentUserId)).limit(1);
+ if (!cred) return c.json({ error: 'No local credential' }, 404);
+
+ const { currentPassword, newPassword } = c.req.valid('json');
+ if (!verifyPassword(cred.passwordHash, currentPassword)) {
+ return c.json({ error: 'Current password incorrect' }, 401);
+ }
+
+ const newHash = hashPassword(newPassword);
+ await db.update(localCredentials).set({ passwordHash: newHash }).where(eq(localCredentials.userId, currentUserId));
+
+ return c.json({ ok: true }, 200);
+});
+```
+
+**Note:** After a password change, existing sessions remain valid (D-05 tradeoff — stateless JWT, no revocation). This is documented and accepted.
+
+---
+
+## `/api/me` Extensions
+
+`GET /api/me` must return `hasLocalCredential: boolean` so the PWA knows whether to show "Change password" and "Link OIDC identity" in `SettingsSheet`. [ASSUMED — straightforward LEFT JOIN on `local_credentials`]
+
+Add `hasLocalCredential` alongside `isAdmin` and `needsProviderSetup` in the `resolveAdminAndSetupStatus` function.
+
+---
+
+## Break-Glass CLI
+
+D-13: break-glass is a CLI/console command or env override. Recommended form: a standalone Node.js/tsx script `apps/api/scripts/reset-admin.ts` that:
+1. Accepts `--username` and `--password` CLI args
+2. Connects to the DB using the same env vars as the app (`DB_HOST`, `DB_USER`, etc.)
+3. Upserts a `users` row with `is_admin=true, claimed=true` for the given username (or finds existing by username)
+4. Upserts `local_credentials` for that user with the hashed password
+5. Prints the resulting user ID
+
+This script is:
+- Not imported by any production code
+- Listed in `.dockerignore` `scripts/` exclusion (verify `.dockerignore` covers this; if `scripts/` is not yet excluded, add it)
+- Gated with a `NODE_ENV !== 'production'` guard as defense-in-depth
+
+Usage: `docker exec -it familysync-api node --import=tsx/esm scripts/reset-admin.ts --username admin --password 'newpass'`
+
+Alternatively: an `APP_RECOVERY_USER` + `APP_RECOVERY_PASSWORD` env pair that, if set at boot, creates/updates that local user before the server starts (similar to some Docker apps' `INITIAL_ADMIN_PASSWORD`). This is simpler to deploy but exposes the password in env. The CLI script is cleaner.
+
+---
+
+## Dev-Bypass Rework
+
+### Recommendation: Option C — bypass issues a real local-session cookie
+
+The three options from the CONTEXT.md open questions:
+
+| Option | Description | Assessment |
+|--------|-------------|------------|
+| A | Keep bypass + seed a real test login for login-specific specs | Most complex — two auth paths in harness |
+| B | Replace bypass with seeded auto-login through the real local flow | Requires changing ALL 40+ harness startup assertions; riskiest |
+| C | Bypass auto-issues a real local-session cookie | Minimal change to existing harness; satisfies D-15 |
+
+**Recommendation: Option C.** When `DEV_AUTH_BYPASS=true`:
+- `devAuthBypass()` still sets `c.get('user')` (existing behavior — unchanged)
+- A new companion `devSessionCookieMiddleware()` mounted just after `devAuthBypass()` issues a signed `local-session` cookie for `DEV_USER.id` (using `LOCAL_SESSION_SECRET`) on every request that doesn't already have one
+- The PWA login page sees `local-session` cookie already set → skips to `/calendar`
+- Login-specific Playwright specs can explicitly clear the cookie and test the real login form
+
+This means:
+1. The harness `global-setup.ts` seeds `local_credentials` for `DEV_USER` (id=1) with a known dev-only username/password
+2. The `devAuthBypass()` function already handles the API-side auth
+3. The PWA routing gate (which checks for a valid session cookie) works because a `local-session` cookie is present
+
+**D-15 compliance:** `devSessionCookieMiddleware()` is inside `auth/devBypass.ts` (the same file the IMG-01 boot guard protects) and is only mounted when `DEV_AUTH_BYPASS=true`. The `assertNotDevBypassInProduction()` guard already blocks this in production.
+
+**Minimal `global-setup.ts` change:**
+```typescript
+// Seed local_credentials for dev user (id=1) — Option C
+await conn.execute(
+ `INSERT INTO local_credentials (user_id, username, password_hash) VALUES (1, 'devuser', ?)
+ ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash)`,
+ [hashPassword('devpass')]
+);
+// (hashPassword is called inline or imported from a compiled path)
+```
+
+**CI `ci.yml` change:** Add `LOCAL_SESSION_SECRET=dev-secret-change-me` to the harness job env (used by `devSessionCookieMiddleware` and `global-setup.ts` hash). Add `local_credentials` table seed to the CI seeding step.
+
+---
+
+## PWA Routing Gate
+
+### App.tsx changes
+
+The current `App.tsx` setup gate checks `setupQuery.data?.setupComplete`. Phase 19 adds a parallel auth-mode gate. [VERIFIED: codebase — `App.tsx` lines 140–230 read]
+
+**New fetch on app load:**
+```typescript
+const authModeQuery = useQuery({
+ queryKey: ['authMode'],
+ queryFn: () => fetch('/api/auth/mode').then(r => r.json()),
+ staleTime: 60_000, // auth mode changes rarely; 1 min stale is fine
+});
+```
+
+**Gate logic (simplified):**
+1. If setup not complete → `/setup`
+2. If `meQuery` succeeds (user authenticated) → normal app
+3. If `meQuery` fails 401 and `authMode.localEnabled` → `/login`
+4. If `meQuery` fails 401 and `!authMode.localEnabled && authMode.oidcEnabled` → trigger OIDC redirect (top-level nav to `/api/login`)
+
+The `/login` route renders `` standalone (no AppNav, no BottomTabBar) — same pattern as `/setup`.
+
+---
+
+## BYO-Auth De-Authelia-ization
+
+D-06 requires removing Authelia-specific copy from user-facing strings and config. [VERIFIED: codebase — checked `middleware.ts`, `index.ts`, `setup.ts` for "Authelia" references]
+
+**What to change:**
+- `apps/api/src/auth/middleware.ts` header comment: "Authelia as the identity provider" → "generic OIDC identity provider"
+- Remove references to `OIDC_ISSUER` being "Authelia base URL" in inline comments → "OIDC issuer URL"
+- The `app_config` keys are already generic (`oidc_issuer`, `oidc_client_id`) — no key changes needed
+- The setup wizard Step 3 label (if any) mentioning Authelia → generic "OIDC provider"
+- User-facing copy: already handled by UI-SPEC (never say "Authelia")
+
+**What NOT to change:** The actual `@hono/oidc-auth` library, PKCE flow, or any runtime behavior — these are already provider-agnostic.
+
+---
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| Password hashing | Custom hash function | `node:crypto` scrypt | Side-channel timing, parameter management, salt uniqueness |
+| JWT signing | HMAC-SHA256 manually | `Jwt.sign`/`Jwt.verify` from `hono/utils/jwt` | Already installed; handles base64url encoding, exp checking |
+| Cookie serialization | Manual `Set-Cookie` string | `hono/cookie` `setCookie`/`getCookie` | Already used in `persistSessionCookie.ts`; handles attributes correctly |
+| Constant-time comparison | `===` on hash strings | `timingSafeEqual` from `node:crypto` | Prevents timing oracle attacks on credential comparison |
+| OIDC code exchange | Custom OAuth flow | `@hono/oidc-auth` `processOAuthCallback` + `oidcAuthMiddleware` | Already installed and working |
+| Rate-limiting storage | Redis or DB sessions | In-memory Map | Household scale; single process; Redis is overkill |
+
+**Key insight:** This phase's auth primitives are entirely in stdlib (`node:crypto`) and packages already installed (`hono/utils/jwt`, `hono/cookie`). The zero-new-dependency constraint is achievable without compromise.
+
+---
+
+## Common Pitfalls
+
+### Pitfall 1: OIDC guard 302-redirecting local-session requests
+
+**What goes wrong:** `oidcAuthMiddleware()` intercepts requests that already have a valid `local-session` cookie and redirects to Authelia.
+
+**Why it happens:** `oidcAuthMiddleware` redirects any request where `getAuth(c)` returns null, regardless of whether another auth mechanism already authenticated the user.
+
+**How to avoid:** Wrap `oidcAuthMiddleware` in a guard that skips it when `c.get('user')` is already set (by `localAuthMiddleware` or `devAuthBypass`). See §Middleware Slot.
+
+**Warning signs:** Local login succeeds (200 + cookie set) but next `/api/me` request returns 302.
+
+---
+
+### Pitfall 2: Timing oracle on username enumeration
+
+**What goes wrong:** Login returns faster for non-existent usernames (no hash computation) than for wrong passwords (hash computed).
+
+**Why it happens:** `if (!cred) return 401` skips `verifyPassword`.
+
+**How to avoid:** Always call `verifyPassword` — use a pre-computed dummy hash when the username is not found (see §Local Login Endpoint).
+
+**Warning signs:** Measurable latency difference in login responses for known vs unknown usernames.
+
+---
+
+### Pitfall 3: Zod error echoing the password
+
+**What goes wrong:** `zValidator` default error handler returns `result.error` which includes `issues[].received` — the submitted password.
+
+**Why it happens:** No `noEchoHook` provided.
+
+**How to avoid:** All `zValidator` calls on the login, password-change, and create-member routes MUST use `noEchoHook`. [VERIFIED: codebase — pattern established in `setup.ts`, `admin.ts`, `me.ts`]
+
+---
+
+### Pitfall 4: `local-session` cookie colliding with `oidc-auth` cookie
+
+**What goes wrong:** If the cookie name `local-session` matches the OIDC cookie name, `persistSessionCookie.ts` reads the wrong cookie.
+
+**Why it happens:** `persistSessionCookie.ts` reads `process.env.OIDC_COOKIE_NAME ?? 'oidc-auth'` — these are different names by default.
+
+**How to avoid:** Keep the local-session cookie name `local-session` (distinct from `oidc-auth`). Never set `OIDC_COOKIE_NAME=local-session` in env.
+
+---
+
+### Pitfall 5: `local_credentials` FK constraint before `users` row
+
+**What goes wrong:** Inserting `local_credentials` before the `users` row exists fails with FK error.
+
+**Why it happens:** `local_credentials.user_id` references `users.id`.
+
+**How to avoid:** Always insert `users` row first, then `local_credentials`. Wrap in a transaction for admin create-member. [VERIFIED: codebase — same pattern documented in `setup.ts` at `POST /api/setup/credential`]
+
+---
+
+### Pitfall 6: OIDC-link without checking `iss+sub` uniqueness
+
+**What goes wrong:** Two local users attempt to link the same OIDC account → one succeeds, one silently overwrites.
+
+**Why it happens:** No uniqueness check before binding `iss+sub`.
+
+**How to avoid:** Before UPDATE-ing `users.oidc_iss`/`oidc_sub`, SELECT to verify no existing row has that `iss+sub` pair. Return 409 if conflict. The `uniq_oidc_identity` index on `users` is also a safety net (will throw a DB unique violation). [VERIFIED: codebase — `schema.ts` line 63, `unique('uniq_oidc_identity').on(t.oidcIss, t.oidcSub)`]
+
+---
+
+### Pitfall 7: Dev bypass seeds in prod image
+
+**What goes wrong:** `local_credentials` seed for dev user (id=1) ships in the production Docker image, granting access with a known password.
+
+**Why it happens:** `scripts/` or seed data not excluded from Docker image.
+
+**How to avoid:** Verify `.dockerignore` excludes `scripts/`. The `local_credentials` dev seed goes in `global-setup.ts` (Playwright) and the CI step — NOT in any migration or startup code. The `assertNotDevBypassInProduction()` guard blocks the dev-session-cookie middleware in production. [VERIFIED: codebase — IMG-01/02/03 gates from Phase 16]
+
+---
+
+### Pitfall 8: `Jwt.verify` import path
+
+**What goes wrong:** `import { sign, verify } from 'hono/utils/jwt'` fails — `hono/utils/jwt` exports only `{ Jwt }` (default object), not named exports.
+
+**Why it happens:** The Hono `utils/jwt/index.js` wraps the functions in a `Jwt` namespace object.
+
+**How to avoid:** Use `import { Jwt } from 'hono/utils/jwt'` then call `Jwt.sign()` / `Jwt.verify()`. [VERIFIED: codebase — confirmed at runtime: `Jwt.sign type: function`]
+
+---
+
+### Pitfall 9: `Jwt.verify` throws on expired token (must catch)
+
+**What goes wrong:** If the `local-session` JWT is expired, `Jwt.verify` throws `JwtTokenExpired` rather than returning null.
+
+**Why it happens:** This is expected Hono behavior — errors are thrown, not returned.
+
+**How to avoid:** Wrap `Jwt.verify` in try/catch in `verifyLocalSessionCookie`. Return `null` on any error (including expiry). [ASSUMED — derived from Hono JWT error types visible in `jwt.js` source]
+
+---
+
+### Pitfall 10: `LOCAL_SESSION_SECRET` missing at boot
+
+**What goes wrong:** `issueLocalSessionCookie` throws because `LOCAL_SESSION_SECRET` is not set.
+
+**Why it happens:** New env var; operator didn't add it to Docker compose.
+
+**How to avoid:** Add a boot-time assertion alongside `assertNotDevBypassInProduction()`: check `LOCAL_SESSION_SECRET` is set and >= 32 chars when not in dev-bypass mode. Log a clear error and refuse to start.
+
+---
+
+### Pitfall 11: Harness `global-setup.ts` seeding `local_credentials` hash without importing app code
+
+**What goes wrong:** `global-setup.ts` is plain Node.js (no tsx/TypeScript — per its own comment "Plain Node.js only"). If it tries to import `hashPassword` from the API source, it needs to compile first.
+
+**Why it happens:** `global-setup.ts` uses `mysql2/promise` directly, no app imports.
+
+**How to avoid:** Inline the `hashPassword` implementation in `global-setup.ts` (copy the 5-line scrypt hash function), or pre-hash the dev password at a known constant and hard-code the encoded string in the seed. Since `global-setup.ts` already knows the dev-bypass semantics, a hard-coded dev hash (never used in production) is acceptable.
+
+---
+
+## Code Examples
+
+### scrypt hash + verify (production pattern)
+
+```typescript
+// Source: runtime-verified on Node 22.22.3 in this project
+import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto';
+
+export function hashPassword(password: string): string {
+ const salt = randomBytes(16);
+ const hash = scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 });
+ return ['scrypt', 16384, 8, 1, salt.toString('base64url'), hash.toString('base64url')].join('$');
+}
+
+export function verifyPassword(stored: string, candidate: string): boolean {
+ try {
+ const [, n, r, p, saltB64, hashB64] = stored.split('$');
+ const salt = Buffer.from(saltB64, 'base64url');
+ const storedHash = Buffer.from(hashB64, 'base64url');
+ const check = scryptSync(candidate, salt, storedHash.length, {
+ N: Number(n), r: Number(r), p: Number(p),
+ });
+ return timingSafeEqual(storedHash, check);
+ } catch {
+ return false;
+ }
+}
+```
+
+### Hono JWT session cookie issuance
+
+```typescript
+// Source: runtime-verified — Jwt.sign/verify confirmed from hono/utils/jwt
+import { Jwt } from 'hono/utils/jwt';
+import { setCookie, getCookie, deleteCookie } from 'hono/cookie';
+
+export async function issueLocalSessionCookie(c: Context, userId: number): Promise {
+ const secret = process.env.LOCAL_SESSION_SECRET!;
+ const maxAge = Number(process.env.LOCAL_SESSION_EXPIRES ?? 86400);
+ const now = Math.floor(Date.now() / 1000);
+ const token = await Jwt.sign({ userId, iat: now, exp: now + maxAge }, secret, 'HS256');
+ setCookie(c, 'local-session', token, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'Lax',
+ path: '/',
+ maxAge,
+ });
+}
+```
+
+### localAuthMiddleware pattern (mirrors devAuthBypass)
+
+```typescript
+// Source: apps/api/src/auth/devBypass.ts (verified — the pattern to mirror)
+import type { MiddlewareHandler } from 'hono';
+import { verifyLocalSessionCookie } from './localSession.js';
+import { db } from '../db/client.js';
+import { users } from '../db/schema.js';
+import { eq } from 'drizzle-orm';
+
+export function localAuthMiddleware(): MiddlewareHandler {
+ return async (c, next) => {
+ // Skip if already authenticated (devAuthBypass ran first)
+ if (c.get('user')) { await next(); return; }
+
+ const userId = await verifyLocalSessionCookie(c);
+ if (!userId) { await next(); return; }
+
+ const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
+ if (user) {
+ c.set('user', {
+ id: user.id,
+ oidcIss: user.oidcIss ?? 'local',
+ oidcSub: user.oidcSub ?? String(user.id),
+ displayName: user.displayName ?? null,
+ color: user.color,
+ });
+ }
+ await next();
+ };
+}
+```
+
+---
+
+## Environment Availability
+
+| Dependency | Required By | Available | Version | Fallback |
+|------------|------------|-----------|---------|----------|
+| Node.js 22 | `node:crypto` scrypt | ✓ | 22.22.3 | — (required) |
+| MariaDB | `local_credentials` table | ✓ (Docker) | 11.x | — |
+| `hono/utils/jwt` | JWT session signing | ✓ | hono@4.12.23 | — (already installed) |
+| `hono/cookie` | Cookie read/write | ✓ | hono@4.12.23 | — (already installed) |
+| `drizzle-kit` | Schema migration | ✓ | 0.31.10 | — |
+| `LOCAL_SESSION_SECRET` env | JWT signing | ✗ (not yet set) | — | Add to docker-compose env + generate-secrets script |
+
+**Missing dependencies with no fallback:**
+- `LOCAL_SESSION_SECRET` env var — must be added to the operator's Docker Compose file and to the `generate-secrets` script. Absence must be caught at boot.
+
+---
+
+## Validation Architecture
+
+`workflow.nyquist_validation` is enabled (absent = enabled per config). Security-critical auth flows — all test seams enumerated.
+
+### Test Framework
+
+| Property | Value |
+|----------|-------|
+| Framework | Vitest 4.1.8 |
+| Config file | `apps/api/vitest.config.ts` |
+| Quick run command | `pnpm --filter @familysync/api test` |
+| Full suite command | `pnpm --filter @familysync/api test && pnpm --filter @familysync/pwa test:e2e` |
+
+### Phase Requirements → Test Map
+
+| Req ID | Behavior | Test Type | Automated Command | File Exists? |
+|--------|----------|-----------|-------------------|-------------|
+| AUTH-LOCAL-01 | `local_credentials` table schema | integration | `pnpm --filter @familysync/api test tests/db/schema.test.ts` | ❌ Wave 0 |
+| AUTH-LOCAL-02 | `hashPassword` round-trip + `verifyPassword` timing-safe | unit | `pnpm --filter @familysync/api test tests/auth/localCredentials.test.ts` | ❌ Wave 0 |
+| AUTH-LOCAL-03 | `POST /api/auth/local/login` — 200 success, 401 wrong, 429 rate, 423 lockout | unit+integration | `pnpm --filter @familysync/api test tests/routes/localAuth.test.ts` | ❌ Wave 0 |
+| AUTH-LOCAL-04 | `localAuthMiddleware` — sets c.get('user') with valid cookie; no-op without cookie | unit | `pnpm --filter @familysync/api test tests/auth/localAuthMiddleware.test.ts` | ❌ Wave 0 |
+| AUTH-LOCAL-05 | `GET /api/auth/mode` — returns `{localEnabled:true, oidcEnabled}` | unit | `pnpm --filter @familysync/api test tests/routes/authMode.test.ts` | ❌ Wave 0 |
+| AUTH-LOCAL-06 | `POST /api/auth/local/logout` — clears cookie | unit | included in `localAuth.test.ts` | ❌ Wave 0 |
+| AUTH-LOCAL-07 | Admin create member — 201 on success, 409 on duplicate username | unit | `pnpm --filter @familysync/api test tests/routes/admin.test.ts` | ✅ (extend) |
+| AUTH-LOCAL-08 | Admin reset password — updates hash | unit | included in `admin.test.ts` | ✅ (extend) |
+| AUTH-LOCAL-09 | Self-change password — verify current, update hash | unit | `pnpm --filter @familysync/api test tests/routes/me.test.ts` | ✅ (extend) |
+| AUTH-LOCAL-10 | OIDC-link — binds iss+sub, deletes local_credentials, 409 on conflict | unit | included in `me.test.ts` | ✅ (extend) |
+| AUTH-LOCAL-11 | Break-glass CLI — creates admin user | manual/smoke | `tsx scripts/reset-admin.ts --dry-run` | ❌ Wave 0 |
+| AUTH-LOCAL-12 | LoginPage renders brand slot + form; submits and receives cookie | e2e (Playwright) | `pnpm --filter @familysync/pwa test:e2e --grep "login"` | ❌ Wave 0 |
+| AUTH-LOCAL-15 | App.tsx redirects unauthed user to /login | e2e | included in login spec | ❌ Wave 0 |
+| AUTH-LOCAL-16 | Harness continues to work with Option C dev-bypass | e2e | existing harness | ✅ (verify after change) |
+| AUTH-LOCAL-19 | Rate-limit 429 after 5 failures; lockout 423 after 10 | unit | included in `localAuth.test.ts` | ❌ Wave 0 |
+
+### Sampling Rate
+
+- **Per task commit:** `pnpm --filter @familysync/api test` (unit suite, ~10s)
+- **Per wave merge:** `pnpm --filter @familysync/api test && pnpm --filter @familysync/pwa test` (unit + PWA unit)
+- **Phase gate:** Full suite including Playwright harness before `/gsd-verify-work`
+
+### Wave 0 Gaps
+
+- [ ] `tests/auth/localCredentials.test.ts` — covers AUTH-LOCAL-02 (hash round-trip, timing-safe)
+- [ ] `tests/auth/localAuthMiddleware.test.ts` — covers AUTH-LOCAL-04
+- [ ] `tests/routes/authMode.test.ts` — covers AUTH-LOCAL-05
+- [ ] `tests/routes/localAuth.test.ts` — covers AUTH-LOCAL-03/06/19
+- [ ] `apps/pwa/e2e/login.spec.ts` — covers AUTH-LOCAL-12/15
+
+---
+
+## Security Domain
+
+`security_enforcement: true` (enabled in config.json).
+
+### Applicable ASVS Categories (Level 1)
+
+| ASVS Category | Applies | Standard Control |
+|---------------|---------|-----------------|
+| V2 Authentication | YES | `node:crypto` scrypt + PHC encoding; `timingSafeEqual`; no username-enumeration timing oracle |
+| V3 Session Management | YES | Stateless signed JWT; `httpOnly + Secure + SameSite=Lax`; maxAge 1 day; logout = cookie clear |
+| V4 Access Control | YES | `requireAdmin` on all admin routes; `resolveUserId` always from session, never body; self-service can only modify own credential |
+| V5 Input Validation | YES | `zod` + `@hono/zod-validator` on all auth routes; `noEchoHook` prevents Zod errors from echoing passwords |
+| V6 Cryptography | YES (partially) | `node:crypto` scrypt (strong KDF); HS256 JWT (symmetric — acceptable for single-server; if multi-server ever applies, upgrade to RS256) |
+
+### Known Threat Patterns
+
+| Pattern | STRIDE | Standard Mitigation |
+|---------|--------|---------------------|
+| Username enumeration via timing | Information Disclosure | Dummy hash verify when username not found; `timingSafeEqual` |
+| Credential brute force | Elevation of Privilege | Per-IP rate-limit (5 failures → 60s cooldown → 429); account lockout at 10 (423) |
+| Session fixation | Elevation of Privilege | Issue new JWT on every login; old JWTs expire via `exp` claim |
+| Password echoed in error response | Information Disclosure | `noEchoHook` on all `zValidator` calls on auth routes |
+| Local-session cookie in prod image | Elevation of Privilege | D-15: `assertNotDevBypassInProduction()`; `.dockerignore` for scripts; seed only in `global-setup.ts` |
+| OIDC-link CSRF | Tampering | `state` parameter in OIDC redirect must be signed/nonce'd; `iss+sub` uniqueness checked server-side |
+| OIDC-link identity collision | Elevation of Privilege | `uniq_oidc_identity` DB constraint + pre-flight SELECT → 409 on conflict |
+| Missing `LOCAL_SESSION_SECRET` | Elevation of Privilege | Boot-time assertion; loud error + non-zero exit |
+| `local-session` cookie in XSS | Information Disclosure | `httpOnly: true` prevents JS access; OIDC cookie has same protection |
+
+---
+
+## State of the Art
+
+| Old Approach | Current Approach | Notes |
+|--------------|------------------|-------|
+| argon2id (native addon) | `node:crypto` scrypt | D-08 mandates no native deps; scrypt is OWASP-approved for this use |
+| DB session table | Stateless JWT cookie | D-05; consistent with `@hono/oidc-auth` pattern |
+| Hardcoded Authelia references | Generic OIDC copy | D-06 BYO-Auth principle |
+
+---
+
+## Open Questions
+
+### Resolved by Research
+
+1. **Dev-bypass rework** → Recommend Option C (bypass issues real `local-session` cookie). Minimal harness change; satisfies D-14 and D-15.
+2. **Break-glass form** → Recommend CLI script `scripts/reset-admin.ts` (tsx, runnable via `docker exec`). No new role model needed.
+3. **OIDC-link mechanism** → Reuse OIDC callback (`/callback`) with a signed `state` parameter encoding `{ linkUserId }`. Simpler than a new endpoint because `processOAuthCallback` already handles the code exchange.
+4. **`Jwt.sign`/`Jwt.verify` import** → `import { Jwt } from 'hono/utils/jwt'` (namespace import) — not named exports.
+
+### Still Open (require planner decision)
+
+1. **OIDC-only user provisioning:** How does an OIDC-only user get their `users` row now that the setup wizard's single-unclaimed-row claim (Phase 12 D-08) only works for one pre-created user? The current `upsertUser` in `auth/user.ts` already handles this: when `setup_complete === true` and no unclaimed row exists, it inserts a new fully-claimed OIDC user row (step 3/5 in `upsertUser`). The existing behavior already handles OIDC-only users without Phase 19 changes — no open issue here in practice. **Confirm:** planner should verify this path still works after Phase 19 DB changes.
+
+2. **Admin UI for managing OIDC-only users:** Can an admin remove a user's OIDC binding (reverting them to local-only)? D-12 says OIDC-link is one-directional (removes local cred) and "can't be undone from the app." This is correct per the UI-SPEC. No admin UI for OIDC-unlinking is in scope for Phase 19.
+
+3. **`LOCAL_SESSION_SECRET` and `generate-secrets` script update:** The Phase 12 `generate-secrets` script generates `SESSION_SECRET`, `APP_PASSWORD_ENCRYPTION_KEY`, and VAPID keys. It should be extended to also generate `LOCAL_SESSION_SECRET`. Planner should include a task to update `scripts/generate-secrets.ts`.
+
+4. **`scripts/` in `.dockerignore`:** Verify `.dockerignore` already excludes `scripts/`. If not, a task to add `scripts/` to `.dockerignore` is required for D-15 compliance.
+
+---
+
+## Assumptions Log
+
+| # | Claim | Section | Risk if Wrong |
+|---|-------|---------|---------------|
+| A1 | `Jwt.verify` throws on expired token (caught in try/catch in `verifyLocalSessionCookie`) | §Common Pitfalls 9, §JWT Session Cookie | Session cookie not cleared on expiry; user remains "logged in" until cookie maxAge expires naturally |
+| A2 | `deleteCookie` from `hono/cookie` is available in hono@4.12.23 | §JWT Session Cookie | Logout implementation needs alternative cookie-clear method (can use `setCookie` with `maxAge: 0`) |
+| A3 | OIDC-link via signed `state` param in `/callback` is the cleanest mechanism | §OIDC-Link Flow | May need a dedicated endpoint; planner may choose differently |
+| A4 | The `scripts/reset-admin.ts` approach satisfies break-glass requirement | §Break-Glass | Operator may prefer env-var recovery; both are viable |
+| A5 | `hono/utils/jwt` `Jwt.sign` uses Web Crypto API internally (async, returns Promise) | §JWT Session Cookie | If sync behavior is needed, alternative needed — but async is the Hono convention |
+
+---
+
+## Sources
+
+### Primary (VERIFIED from codebase)
+- `apps/api/src/auth/devBypass.ts` — `c.set('user')` pattern, `DEV_USER` shape, IMG-01 guard
+- `apps/api/src/auth/middleware.ts` — OIDC middleware wiring, `oidcConfigFallbackMiddleware`
+- `apps/api/src/auth/persistSessionCookie.ts` — `setCookie` pattern, maxAge, httpOnly/Secure
+- `apps/api/src/auth/user.ts` — `upsertUser`, first-login-claims, D-10 identity model
+- `apps/api/src/db/schema.ts` — `users`, `memberCredentials`, `appConfig` definitions
+- `apps/api/src/index.ts` — full middleware ordering, pre-auth routes, `devBypassActive` pattern
+- `apps/api/src/routes/admin.ts` — `requireAdmin`, `noEchoHook`, admin route patterns
+- `apps/api/src/routes/me.ts` — `resolveUserId`, `resolveAdminAndSetupStatus`, `POST /credential`
+- `apps/api/src/routes/setup.ts` — pre-auth route pattern, `isSetupLocked`, `noEchoHook`
+- `apps/api/src/lib/bootGuards.ts` — `assertNotDevBypassInProduction`
+- `apps/api/tests/routes/login.test.ts` — vitest mock patterns for auth tests
+- `apps/pwa/e2e/global-setup.ts` — harness seed pattern, DEV_AUTH_BYPASS guard
+- `apps/pwa/src/App.tsx` — PWA routing gate, setup gate, `meQuery`, route structure
+- `apps/api/src/db/migrations/0002_lethal_millenium_guard.sql` — additive migration example
+- `.gitea/workflows/ci.yml` — CI harness step, DEV_AUTH_BYPASS env, seed step pattern
+- Node.js 22.22.3 runtime — confirmed: `scryptSync`, `randomBytes`, `timingSafeEqual` available
+- `hono@4.12.23` runtime — confirmed: `Jwt.sign`, `Jwt.verify` from `hono/utils/jwt`; `getCookie`, `setCookie` from `hono/cookie`
+- `hono/dist/utils/jwt/jwt.js` — source-read to understand error types and import shape
+
+### Secondary (ASSUMED from training + project patterns)
+- OWASP scrypt parameters (N=16384, r=8, p=1 as minimum; N=65536 preferred if hardware allows)
+- PHC-style `$`-delimited encoded hash format for self-describing password hashes
+- Rate-limit implementation as in-memory Map (appropriate for single-process household scale)
+
+---
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH — everything is the existing installed stack; no new packages; runtime-verified
+- Architecture: HIGH — derived directly from reading the actual middleware chain and existing patterns
+- Pitfalls: HIGH — derived from actual code reading; most pitfalls are known from existing code comments
+- Password hashing: HIGH — runtime-verified on Node 22.22.3
+- JWT signing: HIGH — runtime-verified `Jwt.sign`/`Jwt.verify` from `hono/utils/jwt`
+- OIDC-link mechanism: MEDIUM — derived from CONTEXT.md D-12 + existing `upsertUser` patterns; specific implementation is ASSUMED
+
+**Research date:** 2026-06-17
+**Valid until:** 2026-07-17 (stable stack; scrypt parameters are stable)