Files
familysync/.planning/phases/19-local-auth-no-oidc-mode/19-PATTERNS.md
T

1092 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 7494) | 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 83132) | exact |
| `apps/api/src/routes/me.ts` (modified) | route | request-response | itself — `POST /credential` + `GET /` (lines 88202) | exact |
| `apps/api/src/index.ts` (modified) | config | request-response | itself — middleware ordering block (lines 3173) | 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 134) | 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 42100) | 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 72167) | exact |
| `apps/pwa/src/api/client.ts` (modified) | utility | request-response | itself — `fetchMe`, `handleAuthResponse` pattern (lines 5184) | exact |
| `apps/pwa/e2e/global-setup.ts` (modified) | test | batch | itself (lines 119148) | exact |
---
## Pattern Assignments
---
### `apps/api/src/auth/localCredentials.ts` (utility, transform)
**Analog:** `apps/api/src/auth/user.ts`
**Imports pattern** (user.ts lines 1113):
```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 2426):
```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 5577) 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<void> {
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<number | null> {
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 2728):
```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 3036):
```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 5876):
```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 8695)
**Imports pattern** (setup.ts lines 2941):
```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 4453, 7377)
**Imports pattern** (setup.ts lines 2939):
```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 4953) — 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 119130):
```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 7494) — direct template.
**Pattern to copy** (schema.ts lines 7494):
```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 114): `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 112132) and `GET /members` (lines 83102).
**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 7074) — 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 170183 — `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 83101) — 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 154202) and `resolveUserId` (lines 7486).
**`resolveUserId` pattern** (me.ts lines 7486) — unchanged; new `POST /password` route calls it the same way:
```typescript
async function resolveUserId(c: Context): Promise<number | null> {
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 164168) — 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 5066):
```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 93104 / 129139) — add `hasLocalCredential` alongside `isAdmin`, `needsProviderSetup`.
**Self-change password route** — zValidator + noEchoHook + resolveUserId + error pattern identical to `POST /credential` (lines 170202).
---
### `apps/api/src/index.ts` (modified) (config, request-response)
**Analog:** itself — middleware ordering block (lines 3173).
**Current middleware chain** (index.ts lines 4973):
```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 3132) — unchanged; `localAuthMiddleware` is always mounted (it's a no-op when no cookie is present).
**Boot guard extension** (index.ts lines 134136) — 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 2634).
**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 95148).
**DB connection pattern** (global-setup.ts lines 95101):
```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 125129):
```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 3444:
```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<string[][]>((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 6084) — 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 86113) — 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 115126) — 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 && <Loader2 size={16} style={{ animation: 'spin 1s linear infinite', marginRight: 6 }} />}
```
**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
<div style={{ position: 'relative' }}>
<input
type={showPassword ? 'text' : 'password'}
style={{ ...inputStyle(hasError), paddingRight: 44 }}
...
/>
<button
type="button"
aria-label={showPassword ? 'Hide password' : 'Show password'}
aria-pressed={showPassword}
onClick={() => setShowPassword(!showPassword)}
style={{ position: 'absolute', right: 0, top: 0, height: '100%', minWidth: 44, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-text-muted)' }}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
```
---
### `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 (
<div style={{ textAlign: 'center' }}>
{/* Phase 17 replaces this div with <img> */}
<div style={{
width: 'var(--brand-logo-size, 48px)',
height: 'var(--brand-logo-size, 48px)',
borderRadius: 'var(--brand-logo-border-radius, 50%)',
background: 'var(--brand-logo-bg, var(--color-member-0, #4a90d9))',
color: 'var(--brand-logo-text, #ffffff)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto var(--space-2, 8px)',
fontSize: 'var(--text-display-size, 24px)',
fontWeight: 600,
fontFamily: 'var(--font-family-base)',
}}>
FS
</div>
<h1 style={{ fontSize: 'var(--text-display-size, 24px)', fontWeight: 600, lineHeight: 1.2, color: 'var(--color-text-primary)', marginTop: 'var(--space-2, 8px)', marginBottom: 'var(--space-1, 4px)' }}>
FamilySync
</h1>
<p style={{ fontSize: 'var(--text-body-size, 15px)', fontWeight: 400, color: 'var(--color-text-secondary)', marginBottom: 'var(--space-8, 32px)' }}>
Family calendar &amp; lists
</p>
</div>
);
}
```
---
### `apps/pwa/src/routes/AdminPage.tsx` (modified) (component, CRUD)
**Analog:** itself — `sectionLabelStyle` (line 4249) and `CredentialSheet` open pattern (lines 53100).
**Section label style** (AdminPage.tsx lines 4249) — 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 5559 + `triggerRef` pattern):
```typescript
const [resetSheetOpen, setResetSheetOpen] = useState(false);
const [resetTargetMember, setResetTargetMember] = useState<AdminMember | null>(null);
const resetTriggerRef = useRef<HTMLButtonElement>(null);
```
**Query + mutation pattern** (AdminPage.tsx lines 7681):
```typescript
const membersQuery = useQuery({
queryKey: ['admin', 'members'],
queryFn: fetchAdminMembers,
retry: false,
staleTime: 60 * 1000,
});
```
**Invalidate on success** (CredentialSheet.tsx lines 136139):
```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 && (
<button
ref={member === sheetMember ? resetTriggerRef : undefined}
onClick={() => { setResetTargetMember(member); setResetSheetOpen(true); }}
style={ghostBtnStyle}
>
Reset password
</button>
)}
```
---
### `apps/pwa/src/components/SettingsSheet.tsx` (modified) (component, request-response)
**Analog:** itself + `CredentialSheet.tsx`
**Bottom sheet structure** (SettingsSheet.tsx lines 146178) — "Change password" and "Link OIDC" open nested sheets using the same backdrop + dialog pattern:
```tsx
<div
role="dialog"
aria-modal="true"
aria-label="Settings"
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
background: 'var(--color-surface-raised, #ffffff)',
borderRadius: '12px 12px 0 0',
boxShadow: '0 -4px 24px rgba(0,0,0,0.15)',
padding: 'var(--space-6, 24px)',
zIndex: 301,
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
maxWidth: '480px',
margin: '0 auto',
}}
>
```
**Escape key pattern** (SettingsSheet.tsx lines 6976):
```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 97102):
```typescript
useEffect(() => {
if (isOpen && headingRef.current) {
headingRef.current.focus();
}
}, [isOpen]);
```
**Mutation error state pattern** (CredentialSheet.tsx lines 141144):
```typescript
onError: () => {
setValidationError(FAILURE_TEXT);
},
```
**Conditional row render** — "Change password" row shown only when `hasLocalCredential`:
```tsx
{meData?.user.hasLocalCredential && (
<button onClick={() => setChangePasswordOpen(true)} style={settingsRowStyle}>
Change password
</button>
)}
```
**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 7279, 140167).
**Auth mode query pattern** — mirrors `setupQuery` (App.tsx lines 7279):
```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 140167); 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 <Navigate to="/login" replace />
// 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 156167):
```tsx
<Route
path="/login"
element={<LoginPage authMode={authModeQuery.data} />}
/>
```
**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 7484) and `handleAuthResponse` (lines 5158).
**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<void> {
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 3339 — `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 6268) — 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 119148).
**Pattern to extend** — add after the existing `users` seed (lines 125129) and `member_credentials` seed (lines 143147):
```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 106109):
```typescript
await conn.execute('TRUNCATE TABLE local_credentials');
```
**Guard pattern** (global-setup.ts lines 3444) — 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 3048, 7275)
**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 4953), `apps/api/src/routes/admin.ts` (lines 7074)
**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 119130), `apps/api/src/routes/me.ts` (lines 191198)
**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 170183)
**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 2547)
**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 146178), `apps/pwa/src/components/CredentialSheet.tsx` (lines 86113)
**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 115144)
**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