diff --git a/.planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md b/.planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md new file mode 100644 index 0000000..abfeb58 --- /dev/null +++ b/.planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md @@ -0,0 +1,1091 @@ +# Phase 19: Local Auth (No-OIDC Mode) - Pattern Map + +**Mapped:** 2026-06-17 +**Files analyzed:** 20 (new/modified) +**Analogs found:** 20 / 20 + +--- + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|---|---|---|---|---| +| `apps/api/src/auth/localCredentials.ts` | utility | transform | `apps/api/src/auth/user.ts` | role-match | +| `apps/api/src/auth/localSession.ts` | utility | request-response | `apps/api/src/auth/persistSessionCookie.ts` | exact | +| `apps/api/src/auth/localAuthMiddleware.ts` | middleware | request-response | `apps/api/src/auth/devBypass.ts` | exact | +| `apps/api/src/routes/authMode.ts` | route | request-response | `apps/api/src/routes/setup.ts` (GET /status) | exact | +| `apps/api/src/routes/localAuth.ts` | route | request-response | `apps/api/src/routes/setup.ts` (POST /credential) | exact | +| `apps/api/src/db/schema.ts` (modified) | model | CRUD | itself — `memberCredentials` block (lines 74–94) | exact | +| `apps/api/src/db/migrations/0003_local_credentials.sql` | migration | batch | existing `0002` migration | exact | +| `apps/api/src/routes/admin.ts` (modified) | route | CRUD | itself — `POST /credentials` + `GET /members` (lines 83–132) | exact | +| `apps/api/src/routes/me.ts` (modified) | route | request-response | itself — `POST /credential` + `GET /` (lines 88–202) | exact | +| `apps/api/src/index.ts` (modified) | config | request-response | itself — middleware ordering block (lines 31–73) | exact | +| `apps/api/src/auth/middleware.ts` (modified) | middleware | request-response | itself | exact | +| `apps/api/src/lib/bootGuards.ts` (modified) | utility | request-response | itself (lines 1–34) | exact | +| `apps/api/scripts/reset-admin.ts` | utility | CRUD | `apps/pwa/e2e/global-setup.ts` seed pattern | role-match | +| `apps/pwa/src/routes/LoginPage.tsx` | component | request-response | `apps/pwa/src/routes/SetupPage.tsx` | exact | +| `apps/pwa/src/components/BrandSlot.tsx` | component | — | `apps/pwa/src/routes/SetupPage.tsx` (header block) | role-match | +| `apps/pwa/src/routes/AdminPage.tsx` (modified) | component | CRUD | itself — `CredentialSheet` + section pattern (lines 42–100) | exact | +| `apps/pwa/src/components/SettingsSheet.tsx` (modified) | component | request-response | itself + `CredentialSheet.tsx` | exact | +| `apps/pwa/src/App.tsx` (modified) | component | request-response | itself — `setupQuery` gate + `/setup` route (lines 72–167) | exact | +| `apps/pwa/src/api/client.ts` (modified) | utility | request-response | itself — `fetchMe`, `handleAuthResponse` pattern (lines 51–84) | exact | +| `apps/pwa/e2e/global-setup.ts` (modified) | test | batch | itself (lines 119–148) | exact | + +--- + +## Pattern Assignments + +--- + +### `apps/api/src/auth/localCredentials.ts` (utility, transform) + +**Analog:** `apps/api/src/auth/user.ts` + +**Imports pattern** (user.ts lines 11–13): +```typescript +import { and, eq, isNull, sql } from 'drizzle-orm'; +import { db } from '../db/client.js'; +import { users, appConfig } from '../db/schema.js'; +``` +New file will substitute: +```typescript +import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto'; +// No npm deps — pure stdlib +``` + +**Core pattern** — PHC-encoded hash (from RESEARCH.md §Password Hashing, runtime-verified): +```typescript +const SCRYPT_N = 16384; +const SCRYPT_R = 8; +const SCRYPT_P = 1; +const KEY_LEN = 32; + +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('$'); +} + +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; + } +} +``` + +**No error types defined here** — all errors returned as boolean false (timing-safe contract). + +--- + +### `apps/api/src/auth/localSession.ts` (utility, request-response) + +**Analog:** `apps/api/src/auth/persistSessionCookie.ts` + +**Imports pattern** (persistSessionCookie.ts lines 24–26): +```typescript +import type { MiddlewareHandler } from 'hono'; +import { setCookie } from 'hono/cookie'; +``` +New file extends to: +```typescript +import { Jwt } from 'hono/utils/jwt'; +import { setCookie, getCookie, deleteCookie } from 'hono/cookie'; +import type { Context } from 'hono'; +``` + +**Core cookie-issue pattern** — mirrors persistSessionCookie.ts (lines 55–77) but issues a new JWT rather than re-issuing an existing one: +```typescript +const COOKIE_NAME = 'local-session'; +const SESSION_MAX_AGE_SECONDS = Number(process.env.LOCAL_SESSION_EXPIRES ?? 86400); + +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 token = await Jwt.sign({ userId, iat: now, exp: now + SESSION_MAX_AGE_SECONDS }, secret, 'HS256'); + setCookie(c, COOKIE_NAME, token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'Lax', + path: '/', + maxAge: SESSION_MAX_AGE_SECONDS, + }); +} +``` + +**Verify pattern** — wraps Jwt.verify in try/catch (Pitfall 9 — throws on expiry): +```typescript +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; // includes JwtTokenExpired + } +} +``` + +**Clear pattern** — mirrors cookie attribute set on issue: +```typescript +export function clearLocalSessionCookie(c: Context): void { + deleteCookie(c, COOKIE_NAME, { path: '/', httpOnly: true, secure: true, sameSite: 'Lax' }); +} +``` + +**Cookie name:** `local-session` — distinct from OIDC cookie `oidc-auth` (persistSessionCookie.ts line 55: `process.env.OIDC_COOKIE_NAME ?? 'oidc-auth'`). + +--- + +### `apps/api/src/auth/localAuthMiddleware.ts` (middleware, request-response) + +**Analog:** `apps/api/src/auth/devBypass.ts` + +**Imports pattern** (devBypass.ts lines 27–28): +```typescript +import type { MiddlewareHandler } from 'hono'; +import { COLOR_PALETTE } from './user.js'; +``` +New file: +```typescript +import type { MiddlewareHandler } from 'hono'; +import { eq } from 'drizzle-orm'; +import { db } from '../db/client.js'; +import { users } from '../db/schema.js'; +import { verifyLocalSessionCookie } from './localSession.js'; +``` + +**`c.set('user', ...)` pattern** — must produce same shape as `DEV_USER` (devBypass.ts lines 30–36): +```typescript +export const DEV_USER = { + id: 1, + oidcIss: 'dev', + oidcSub: 'dev-user', + displayName: 'Dev User', + color: COLOR_PALETTE[0], +} as const; +// ContextVariableMap declares user: typeof DEV_USER +``` +The new middleware must call `c.set('user', { id, oidcIss, oidcSub, displayName, color })` with the same shape — fetched from the `users` table by the `userId` from the JWT. + +**Core middleware pattern** (mirrors devBypass.ts lines 58–76): +```typescript +export function localAuthMiddleware(): MiddlewareHandler { + return async (c, next) => { + const userId = await verifyLocalSessionCookie(c); + if (!userId) { + await next(); + return; + } + // Load user row to populate the same shape as DEV_USER + const [row] = await db.select({ ... }).from(users).where(eq(users.id, userId)).limit(1); + if (!row) { await next(); return; } + c.set('user', { id: row.id, oidcIss: row.oidcIss ?? '', oidcSub: row.oidcSub ?? '', displayName: row.displayName ?? null, color: row.color }); + await next(); + }; +} +``` + +**Key rule:** Must be a no-op (call `next()`) when no `local-session` cookie is present — never set `c.get('user')` to undefined (Pitfall 1: OIDC guard redirects only when `c.get('user')` is falsy, so leaving it unset is correct fall-through behavior). + +--- + +### `apps/api/src/routes/authMode.ts` (route, request-response) + +**Analog:** `apps/api/src/routes/setup.ts` — `GET /status` (lines 86–95) + +**Imports pattern** (setup.ts lines 29–41): +```typescript +import { Hono } from 'hono'; +import { eq } from 'drizzle-orm'; +import { db } from '../db/client.js'; +import { appConfig } from '../db/schema.js'; +``` + +**Pre-auth pattern** — same pattern as `setupRouter.get('/status', ...)`: no `isSetupLocked()` gate, no auth middleware, always reachable: +```typescript +export const authModeRouter = new Hono(); + +authModeRouter.get('/', async (c) => { + 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 }); +}); +``` + +**Mount position in index.ts:** Before `app.use('/api/*', devAuthBypass())` — same position as `app.route('/api/setup', setupRouter)` (index.ts line 49). + +--- + +### `apps/api/src/routes/localAuth.ts` (route, request-response) + +**Analog:** `apps/api/src/routes/setup.ts` — `POST /credential` + `noEchoHook` pattern (lines 44–53, 73–77) + +**Imports pattern** (setup.ts lines 29–39): +```typescript +import { Hono } from 'hono'; +import type { Context } from 'hono'; +import { zValidator } from '@hono/zod-validator'; +import { z } from 'zod'; +import { eq } from 'drizzle-orm'; +import { db } from '../db/client.js'; +``` + +**noEchoHook pattern** (setup.ts lines 49–53) — copy verbatim: +```typescript +const noEchoHook = (result: { success: boolean }, c: Context) => { + if (!result.success) { + return c.json({ error: 'Invalid request' }, 400); + } +}; +``` + +**zValidator usage** (admin.ts line 112): +```typescript +localAuthRouter.post('/login', zValidator('json', loginSchema, noEchoHook), async (c) => { + const { username, password } = c.req.valid('json'); + // ... +}); +``` + +**Success response pattern** (me.ts line 201, admin.ts line 131): +```typescript +return c.json({ ok: true }, 200); +``` + +**Error response pattern** (admin.ts lines 119–130): +```typescript +if (err instanceof SomeError) { + return c.json({ error: 'Invalid request' }, 400); +} +console.error('[localAuth/POST /login] Unexpected error:', err instanceof Error ? err.message : String(err)); +return c.json({ error: 'Service unavailable' }, 503); +``` + +**Rate-limiting:** In-memory Map — no analog in codebase; pattern is from RESEARCH.md §Rate Limiting. See RESEARCH.md for the full `loginAttempts` Map implementation. + +**Logout route** — uses `clearLocalSessionCookie(c)` then: +```typescript +return c.json({ ok: true }, 200); +``` + +--- + +### `apps/api/src/db/schema.ts` (modified — additive) (model, CRUD) + +**Analog:** `memberCredentials` block in schema.ts (lines 74–94) — direct template. + +**Pattern to copy** (schema.ts lines 74–94): +```typescript +export const memberCredentials = mysqlTable( + 'member_credentials', + { + id: int().primaryKey().autoincrement(), + userId: int('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + encryptedPassword: text('encrypted_password').notNull(), + fastmailEmail: varchar('fastmail_email', { length: 256 }).notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(), + providerType: varchar('provider_type', { length: 64 }).notNull().default('caldav'), + }, + (t) => [ + index('idx_member_credentials_user_id').on(t.userId), + unique('uniq_member_credential_user').on(t.userId), + ], +); +``` + +**New `localCredentials` table** — replace `encryptedPassword`/`fastmailEmail`/`providerType` with `username` + `passwordHash`, add a second `unique` on `username`: +```typescript +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(), + passwordHash: varchar('password_hash', { length: 256 }).notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(), + }, + (t) => [ + unique('uniq_local_cred_user').on(t.userId), + unique('uniq_local_cred_username').on(t.username), + index('idx_local_credentials_user_id').on(t.userId), + ], +); +``` + +**Import additions needed** (schema.ts line 1–14): `varchar` and `timestamp` already imported; `int`, `unique`, `index` already imported — no new imports required. + +**Export rule:** Add `localCredentials` to the existing named exports so `test/setup.ts` can truncate it. + +--- + +### `apps/api/src/routes/admin.ts` (modified — additive) (route, CRUD) + +**Analog:** itself — `POST /credentials` (lines 112–132) and `GET /members` (lines 83–102). + +**Admin guard pattern** (admin.ts line 42) — already applies to all new routes via `adminRouter.use('*', requireAdmin)`: +```typescript +adminRouter.use('*', requireAdmin); // FIRST statement; never move this +``` + +**noEchoHook pattern** (admin.ts lines 70–74) — reuse existing: +```typescript +const noEchoHook = (result: { success: boolean }, c: Context) => { + if (!result.success) { + return c.json({ error: 'Invalid request' }, 400); + } +}; +``` + +**Transaction pattern for create-member** (admin.ts lines 170–183 — `PUT /calendars/:id/shared` uses `db.transaction`): +```typescript +await db.transaction(async (tx) => { + // 1. INSERT into users + // 2. INSERT into local_credentials +}); +``` + +**`GET /members` LEFT JOIN extension** (admin.ts lines 83–101) — extend the existing query to add `hasLocalCredential`: +```typescript +const rows = await db + .select({ + id: users.id, + displayName: users.displayName, + color: users.color, + credentialId: memberCredentials.id, + localCredId: localCredentials.id, // NEW — LEFT JOIN + }) + .from(users) + .leftJoin(memberCredentials, eq(memberCredentials.userId, users.id)) + .leftJoin(localCredentials, eq(localCredentials.userId, users.id)); // NEW + +const members = rows.map((row) => ({ + id: row.id, + displayName: row.displayName, + color: row.color, + hasCredential: row.credentialId !== null, + hasLocalCredential: row.localCredId !== null, // NEW +})); +``` + +**Conflict (409) pattern** — not currently in codebase; use: +```typescript +return c.json({ error: 'Username already in use' }, 409); +``` + +--- + +### `apps/api/src/routes/me.ts` (modified — additive) (route, request-response) + +**Analog:** itself — `POST /credential` (lines 154–202) and `resolveUserId` (lines 74–86). + +**`resolveUserId` pattern** (me.ts lines 74–86) — unchanged; new `POST /password` route calls it the same way: +```typescript +async function resolveUserId(c: Context): Promise { + const devUser = c.get('user') as { id: number } | undefined; + if (devUser) return devUser.id; + const auth = await getAuth(c); + if (!auth) return null; + // ... upsertUser +} +``` + +**`meNoEchoHook` pattern** (me.ts lines 164–168) — copy for password route: +```typescript +const meNoEchoHook = (result: { success: boolean }, c: Context) => { + if (!result.success) { + return c.json({ error: 'Invalid request' }, 400); + } +}; +``` + +**`resolveAdminAndSetupStatus` extension** — add `hasLocalCredential` (after existing `cred` query pattern at lines 50–66): +```typescript +const [localCred] = await db + .select({ id: localCredentials.id }) + .from(localCredentials) + .where(eq(localCredentials.userId, userId)) + .limit(1); +// Add hasLocalCredential: Boolean(localCred) to the return object +``` + +**Response shape extension** (me.ts lines 93–104 / 129–139) — add `hasLocalCredential` alongside `isAdmin`, `needsProviderSetup`. + +**Self-change password route** — zValidator + noEchoHook + resolveUserId + error pattern identical to `POST /credential` (lines 170–202). + +--- + +### `apps/api/src/index.ts` (modified) (config, request-response) + +**Analog:** itself — middleware ordering block (lines 31–73). + +**Current middleware chain** (index.ts lines 49–73): +```typescript +app.route('/api/setup', setupRouter); // pre-auth + +app.use('/api/*', devAuthBypass()); + +if (!devBypassActive) { + app.use('/api/*', oidcConfigFallbackMiddleware); + app.use('/api/*', oidcAuthMiddleware()); + app.use('/api/*', persistSessionCookie()); +} +``` + +**New chain** — insert `authModeRouter`, `localAuthRouter`, `localAuthMiddleware`, and OIDC guard wrapper: +```typescript +app.route('/api/setup', setupRouter); // pre-auth (unchanged) +app.route('/api/auth', authModeRouter); // GET /api/auth/mode — pre-auth +app.route('/api/auth', localAuthRouter); // POST /api/auth/local/login, /logout — pre-auth + +app.use('/api/*', devAuthBypass()); // unchanged +app.use('/api/*', localAuthMiddleware()); // NEW — sets c.get('user') from local-session cookie + +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()); +} +``` + +**`devBypassActive` computation** (index.ts lines 31–32) — unchanged; `localAuthMiddleware` is always mounted (it's a no-op when no cookie is present). + +**Boot guard extension** (index.ts lines 134–136) — add `LOCAL_SESSION_SECRET` assertion alongside `assertNotDevBypassInProduction()`. + +--- + +### `apps/api/src/auth/middleware.ts` (modified) (middleware, request-response) + +**Analog:** itself. + +**Change scope:** Comment-only de-Authelia-ization (D-06). Line 5 header comment "Authelia as the identity provider" → "generic OIDC identity provider". Inline comments referencing "Authelia base URL" → "OIDC issuer URL". No runtime behavior changes. + +--- + +### `apps/api/src/lib/bootGuards.ts` (modified) (utility, request-response) + +**Analog:** itself (lines 26–34). + +**Existing pattern** — copy structure for new `LOCAL_SESSION_SECRET` guard: +```typescript +export function assertNotDevBypassInProduction(): void { + if (process.env.NODE_ENV === 'production' && process.env.DEV_AUTH_BYPASS === 'true') { + console.error('[FATAL] DEV_AUTH_BYPASS=true is set in a production environment. ...'); + process.exit(1); + } +} +``` + +**New guard to add** — same pattern, different env var: +```typescript +export function assertLocalSessionSecretSet(): void { + // Only required when not in dev-bypass mode (bypass doesn't issue local-session cookies) + if (process.env.DEV_AUTH_BYPASS === 'true') return; + const secret = process.env.LOCAL_SESSION_SECRET; + if (!secret || secret.length < 32) { + console.error('[FATAL] LOCAL_SESSION_SECRET is not set or is shorter than 32 characters. Refusing to start.'); + process.exit(1); + } +} +``` + +--- + +### `apps/api/scripts/reset-admin.ts` (utility, CRUD) + +**Analog:** `apps/pwa/e2e/global-setup.ts` — direct DB seed pattern (lines 95–148). + +**DB connection pattern** (global-setup.ts lines 95–101): +```typescript +import mysql from 'mysql2/promise'; + +const conn = await mysql.createConnection({ + host: process.env.DB_HOST ?? '127.0.0.1', + port: Number(process.env.DB_PORT ?? 3306), + user: process.env.DB_USER ?? 'familysync', + password: process.env.DB_PASSWORD ?? '', + database: process.env.DB_NAME ?? 'familysync', +}); +``` + +**Idempotent upsert pattern** (global-setup.ts lines 125–129): +```typescript +await conn.execute( + `INSERT INTO users (id, ...) VALUES (1, ...) ON DUPLICATE KEY UPDATE is_admin=true`, +); +``` + +**Dev-only guard** — same pattern as global-setup.ts lines 34–44: +```typescript +if (process.env.NODE_ENV === 'production') { + throw new Error('reset-admin refused: NODE_ENV=production.'); +} +``` + +**CLI arg parsing** — use `process.argv` directly (no new deps): +```typescript +const args = Object.fromEntries( + process.argv.slice(2).reduce((acc, arg, i, arr) => { + if (arg.startsWith('--')) acc.push([arg.slice(2), arr[i + 1] ?? '']); + return acc; + }, []) +); +const { username, password } = args; +``` + +**hashPassword inline** — copy the 5-line scrypt implementation inline (cannot import compiled TS — same constraint as global-setup.ts "Plain Node.js only" pattern). Use `import { scryptSync, randomBytes } from 'node:crypto'` directly. + +--- + +### `apps/pwa/src/routes/LoginPage.tsx` (component, request-response) + +**Analog:** `apps/pwa/src/routes/SetupPage.tsx` + +**Page shell styles** (SetupPage.tsx lines 60–84) — copy verbatim, adjust `maxWidth`: +```typescript +const pageStyle: React.CSSProperties = { + minHeight: '100dvh', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'flex-start', + background: 'var(--color-surface, #ffffff)', + fontFamily: 'var(--font-family-base)', + color: 'var(--color-text-primary, #111318)', +}; + +const contentColStyle: React.CSSProperties = { + maxWidth: '400px', // LoginPage: 400px, not 540px (UI-SPEC Surface 1) + width: '100%', + margin: '0 auto', + padding: 'var(--space-12, 48px) var(--space-6, 24px)', +}; + +const cardStyle: React.CSSProperties = { + background: 'var(--color-surface, #ffffff)', + border: '1px solid var(--color-border, #e2e4e9)', + borderRadius: '8px', + padding: 'var(--space-6, 24px)', + boxShadow: '0 1px 4px rgba(0,0,0,0.06)', +}; +``` + +**Button styles** (SetupPage.tsx lines 86–113) — copy verbatim: +```typescript +const primaryBtnStyle = (disabled: boolean): React.CSSProperties => ({ + background: disabled ? 'var(--color-border, #e2e4e9)' : 'var(--color-member-0, #4a90d9)', + color: '#ffffff', + border: 'none', + cursor: disabled ? 'default' : 'pointer', + fontSize: 'var(--text-label-size, 13px)', + fontWeight: 600, + minHeight: '44px', + minWidth: '44px', + padding: '0 var(--space-6, 24px)', + borderRadius: 'var(--space-1, 4px)', + fontFamily: 'var(--font-family-base)', + transition: 'background 0.15s ease', +}); + +const ghostBtnStyle: React.CSSProperties = { + background: 'none', + border: 'none', + cursor: 'pointer', + fontSize: 'var(--text-label-size, 13px)', + fontWeight: 600, + color: 'var(--color-text-secondary, #6b7280)', + minHeight: '44px', + minWidth: '44px', + padding: '0 var(--space-4, 16px)', + fontFamily: 'var(--font-family-base)', + borderRadius: 'var(--space-1, 4px)', +}; +``` + +**Input style** (SetupPage.tsx lines 115–126) — copy verbatim (has `hasError` variant): +```typescript +const inputStyle = (hasError: boolean): React.CSSProperties => ({ + width: '100%', + boxSizing: 'border-box', + padding: 'var(--space-3, 12px) var(--space-4, 16px)', + border: `1px solid ${hasError ? 'var(--color-destructive, #dc2626)' : 'var(--color-border, #e2e4e9)'}`, + borderRadius: 'var(--space-1, 4px)', + fontSize: 'var(--text-body-size, 15px)', + color: 'var(--color-text-primary, #111318)', + background: 'var(--color-surface, #ffffff)', + fontFamily: 'var(--font-family-base)', + outline: 'none', +}); + +const labelStyle: React.CSSProperties = { + display: 'block', + fontSize: 'var(--text-label-size, 13px)', + fontWeight: 600, + color: 'var(--color-text-primary, #111318)', + marginBottom: 'var(--space-1, 4px)', +}; +``` + +**Mutation + error state pattern** (SetupPage.tsx `useMutation` + `useState` for error): +```typescript +import { useState } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { Loader2, AlertCircle, Eye, EyeOff, ShieldCheck } from 'lucide-react'; + +const [username, setUsername] = useState(''); +const [password, setPassword] = useState(''); +const [showPassword, setShowPassword] = useState(false); +const [loginError, setLoginError] = useState<'invalid' | 'rate-limit' | 'locked' | 'server' | null>(null); + +const loginMutation = useMutation({ + mutationFn: () => fetchLocalLogin({ username, password }), + onSuccess: () => { window.location.replace('/'); }, + onError: (err) => { + if (err instanceof LoginError) { + setLoginError(err.code); + } else { + setLoginError('server'); + } + }, +}); +``` + +**Loader2 spinner pattern** (SetupPage.tsx — inline during pending): +```tsx +{loginMutation.isPending && } +``` + +**No AppNav / BottomTabBar** — same constraint as SetupPage: this component renders standalone; `App.tsx` routes `/login` outside the normal app shell. + +**Password show/hide toggle** — new pattern (no analog in codebase); position: relative wrapper + absolute button at right: +```tsx +
+ + +
+``` + +--- + +### `apps/pwa/src/components/BrandSlot.tsx` (component, n/a) + +**Analog:** `apps/pwa/src/routes/SetupPage.tsx` — ShieldCheck icon header pattern + +**Pattern:** A standalone component with no props (initially); renders a placeholder circle with "FS" initials above the login card. Phase 17 replaces internals only. + +```tsx +export function BrandSlot() { + return ( +
+ {/* Phase 17 replaces this div with */} +
+ FS +
+

+ FamilySync +

+

+ Family calendar & lists +

+
+ ); +} +``` + +--- + +### `apps/pwa/src/routes/AdminPage.tsx` (modified) (component, CRUD) + +**Analog:** itself — `sectionLabelStyle` (line 42–49) and `CredentialSheet` open pattern (lines 53–100). + +**Section label style** (AdminPage.tsx lines 42–49) — copy verbatim for "LOCAL ACCOUNTS" section: +```typescript +const sectionLabelStyle: React.CSSProperties = { + fontSize: 'var(--text-label-size, 13px)', + fontWeight: 600, + color: 'var(--color-text-muted)', + textTransform: 'uppercase', + letterSpacing: '0.06em', + marginBottom: 'var(--space-2, 8px)', +}; +``` + +**Sheet open/close pattern** (AdminPage.tsx lines 55–59 + `triggerRef` pattern): +```typescript +const [resetSheetOpen, setResetSheetOpen] = useState(false); +const [resetTargetMember, setResetTargetMember] = useState(null); +const resetTriggerRef = useRef(null); +``` + +**Query + mutation pattern** (AdminPage.tsx lines 76–81): +```typescript +const membersQuery = useQuery({ + queryKey: ['admin', 'members'], + queryFn: fetchAdminMembers, + retry: false, + staleTime: 60 * 1000, +}); +``` + +**Invalidate on success** (CredentialSheet.tsx lines 136–139): +```typescript +void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] }); +void queryClient.invalidateQueries({ queryKey: ['me'] }); +``` + +**Member row action button pattern** (follows existing "Rotate"/"Add credential" button pattern in the member row map): +```tsx +{member.hasLocalCredential && ( + +)} +``` + +--- + +### `apps/pwa/src/components/SettingsSheet.tsx` (modified) (component, request-response) + +**Analog:** itself + `CredentialSheet.tsx` + +**Bottom sheet structure** (SettingsSheet.tsx lines 146–178) — "Change password" and "Link OIDC" open nested sheets using the same backdrop + dialog pattern: +```tsx +
+``` + +**Escape key pattern** (SettingsSheet.tsx lines 69–76): +```typescript +useEffect(() => { + if (!isOpen) return; + const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); +}, [isOpen, onClose]); +``` + +**Focus-on-open pattern** (CredentialSheet.tsx lines 97–102): +```typescript +useEffect(() => { + if (isOpen && headingRef.current) { + headingRef.current.focus(); + } +}, [isOpen]); +``` + +**Mutation error state pattern** (CredentialSheet.tsx lines 141–144): +```typescript +onError: () => { + setValidationError(FAILURE_TEXT); +}, +``` + +**Conditional row render** — "Change password" row shown only when `hasLocalCredential`: +```tsx +{meData?.user.hasLocalCredential && ( + +)} +``` + +**New `hasLocalCredential` from `/api/me`** — already flows through `meQuery` in App.tsx; pass as prop or read from `useQuery(['me'])` inside the sheet. + +--- + +### `apps/pwa/src/App.tsx` (modified) (component, request-response) + +**Analog:** itself — `setupQuery` gate (lines 72–79, 140–167). + +**Auth mode query pattern** — mirrors `setupQuery` (App.tsx lines 72–79): +```typescript +const authModeQuery = useQuery({ + queryKey: ['authMode'], + queryFn: () => fetch('/api/auth/mode').then(r => r.json()) as Promise<{ localEnabled: boolean; oidcEnabled: boolean }>, + retry: false, + staleTime: 60_000, // auth mode changes rarely +}); +``` + +**Gate logic** — mirrors the `setupComplete` gate (App.tsx lines 140–167); add login gate after setup gate: +```tsx +// After setup gate, before normal app shell: +// If user is not authenticated (meQuery.isError with 401) AND localEnabled: +// render +// If user is not authenticated AND !localEnabled AND oidcEnabled: +// top-level redirect to /api/login (OIDC flow) +``` + +**`/login` route** — same structure as `/setup` route (App.tsx lines 156–167): +```tsx +} +/> +``` + +**No AppNav/BottomTabBar on `/login`** — same constraint as `/setup`: login route is a sibling of the `*` route, rendered standalone. + +--- + +### `apps/pwa/src/api/client.ts` (modified) (utility, request-response) + +**Analog:** itself — `fetchMe` (lines 74–84) and `handleAuthResponse` (lines 51–58). + +**New fetch functions follow exact same pattern** as existing ones: +```typescript +export async function fetchAuthMode(): Promise<{ localEnabled: boolean; oidcEnabled: boolean }> { + // No credentials:'include' needed — pre-auth endpoint + const res = await fetch('/api/auth/mode'); + if (!res.ok) throw new Error(`fetchAuthMode failed: ${res.status}`); + return res.json() as Promise<{ localEnabled: boolean; oidcEnabled: boolean }>; +} + +export async function fetchLocalLogin(body: { username: string; password: string }): Promise { + const res = await fetch('/api/auth/local/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + redirect: 'manual', + body: JSON.stringify(body), + }); + // 401, 429, 423 are typed errors — throw with code; caller checks instanceof + if (res.status === 401) throw new LoginError('invalid'); + if (res.status === 429) throw new LoginError('rate-limit'); + if (res.status === 423) throw new LoginError('locked'); + if (!res.ok) throw new LoginError('server'); +} +``` + +**Typed error class pattern** (client.ts lines 33–39 — `SessionExpiredError`): +```typescript +export class LoginError extends Error { + readonly name = 'LoginError'; + constructor(public readonly code: 'invalid' | 'rate-limit' | 'locked' | 'server') { + super(`Login failed: ${code}`); + Object.setPrototypeOf(this, LoginError.prototype); + } +} +``` + +**`MeUser` interface extension** (client.ts lines 62–68) — add `hasLocalCredential`: +```typescript +export interface MeUser { + id: number; + displayName: string | null; + color: string; + isAdmin: boolean; + needsProviderSetup: boolean; + hasLocalCredential: boolean; // NEW +} +``` + +--- + +### `apps/pwa/e2e/global-setup.ts` (modified) (test, batch) + +**Analog:** itself — seed block (lines 119–148). + +**Pattern to extend** — add after the existing `users` seed (lines 125–129) and `member_credentials` seed (lines 143–147): +```typescript +// Seed local_credentials for dev user (id=1) — Option C dev-bypass rework +// hashPassword is inlined (see Pitfall 11 — global-setup is plain Node.js, cannot import TS source) +// Pre-hash the dev password at known params and hard-code the encoded string, OR inline hashPassword: +import { scryptSync, randomBytes } from 'node:crypto'; +function hashPasswordInline(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('$'); +} +await conn.execute( + `INSERT INTO local_credentials (user_id, username, password_hash) VALUES (1, 'devuser', ?) + ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash)`, + [hashPasswordInline('devpass')] +); +``` + +**TRUNCATE extension** — add `local_credentials` to the existing TRUNCATE block (lines 106–109): +```typescript +await conn.execute('TRUNCATE TABLE local_credentials'); +``` + +**Guard pattern** (global-setup.ts lines 34–44) — unchanged; existing `NODE_ENV === 'production'` and `DEV_AUTH_BYPASS !== 'true'` guards already cover the new seed. + +--- + +## Shared Patterns + +### Authentication middleware — `c.set('user', ...)` contract +**Source:** `apps/api/src/auth/devBypass.ts` (lines 30–48, 72–75) +**Apply to:** `localAuthMiddleware.ts`, all route files that read `c.get('user')` + +The user object shape declared in `ContextVariableMap`: +```typescript +declare module 'hono' { + interface ContextVariableMap { + user: typeof DEV_USER; // { id, oidcIss, oidcSub, displayName, color } + } +} +``` +`localAuthMiddleware` must produce a value assignable to this type. Import the type augmentation via `import '../auth/devBypass.js'` (side-effect import) in any file that reads `c.get('user')` — exactly as admin.ts (line 36) and me.ts (line 42) already do. + +### noEchoHook — password routes +**Source:** `apps/api/src/routes/setup.ts` (lines 49–53), `apps/api/src/routes/admin.ts` (lines 70–74) +**Apply to:** `localAuth.ts` (login), `admin.ts` (create-member, reset-password), `me.ts` (change-password) + +```typescript +const noEchoHook = (result: { success: boolean }, c: Context) => { + if (!result.success) { + return c.json({ error: 'Invalid request' }, 400); + } +}; +``` +Never return `result.error` — Zod's error object echoes `issues[].received` which may contain the submitted password. + +### Error response pattern +**Source:** `apps/api/src/routes/admin.ts` (lines 119–130), `apps/api/src/routes/me.ts` (lines 191–198) +**Apply to:** all new API routes + +```typescript +try { + // ... business logic +} catch (err) { + if (err instanceof KnownError) { + return c.json({ error: 'descriptive message' }, 4xx); + } + console.error('[routeFile/POST /endpoint] Unexpected error:', err instanceof Error ? err.message : String(err)); + return c.json({ error: 'Service unavailable' }, 503); +} +``` + +### DB transaction pattern +**Source:** `apps/api/src/routes/admin.ts` (lines 170–183) +**Apply to:** `admin.ts` — `POST /members` (must insert `users` + `local_credentials` atomically) + +```typescript +const found = await db.transaction(async (tx) => { + // 1. INSERT users + // 2. INSERT local_credentials + return true; +}); +``` + +### requireAdmin guard +**Source:** `apps/api/src/lib/requireAdmin.ts` (lines 25–47) +**Apply to:** all new `adminRouter.*` routes (already covered by `adminRouter.use('*', requireAdmin)` — no new work needed) + +### Bottom sheet (dialog) pattern +**Source:** `apps/pwa/src/components/SettingsSheet.tsx` (lines 146–178), `apps/pwa/src/components/CredentialSheet.tsx` (lines 86–113) +**Apply to:** `SettingsSheet.tsx` additions (Surfaces 12, 13), `AdminPage.tsx` addition (Surface 11B) + +Key attributes: `role="dialog"`, `aria-modal="true"`, `aria-label`, Escape-close listener, focus-heading-on-open, focus-return-to-trigger-on-close. + +### TanStack Query `useMutation` + cache invalidation +**Source:** `apps/pwa/src/components/CredentialSheet.tsx` (lines 115–144) +**Apply to:** all new PWA mutation surfaces (login, create-member, reset-password, change-password, link-OIDC) + +```typescript +const mutation = useMutation({ + mutationFn: async () => { /* fetch call */ }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] }); + void queryClient.invalidateQueries({ queryKey: ['me'] }); + handleClose(); + }, + onError: () => { + setError(FAILURE_TEXT); + }, +}); +``` + +### Drizzle generate+migrate (never push) +**Source:** CONTEXT.md §Established Patterns + RESEARCH.md §Migration Workflow +**Apply to:** `local_credentials` migration only + +```bash +pnpm --filter @familysync/api db:generate # → 0003_local_credentials.sql +pnpm --filter @familysync/api db:migrate +``` +Review generated SQL before applying — must be purely additive (CREATE TABLE only). + +--- + +## No Analog Found + +All files have close analogs. No entries. + +--- + +## Metadata + +**Analog search scope:** `apps/api/src/` (auth/, routes/, db/, lib/), `apps/pwa/src/` (routes/, components/, api/), `apps/pwa/e2e/` +**Files read:** 18 source files +**Pattern extraction date:** 2026-06-17