# 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