# 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<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 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<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 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<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 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 && <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 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<AdminMember | null>(null);
const resetTriggerRef = useRef<HTMLButtonElement>(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 && (
  <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 146–178) — "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 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 && (
  <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 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 <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 156–167):
```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 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<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 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
