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

38 KiB
Raw Blame History

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):

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:

import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto';
// No npm deps — pure stdlib

Core pattern — PHC-encoded hash (from RESEARCH.md §Password Hashing, runtime-verified):

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):

import type { MiddlewareHandler } from 'hono';
import { setCookie } from 'hono/cookie';

New file extends to:

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:

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):

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:

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):

import type { MiddlewareHandler } from 'hono';
import { COLOR_PALETTE } from './user.js';

New file:

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):

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):

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.tsGET /status (lines 8695)

Imports pattern (setup.ts lines 2941):

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:

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.tsPOST /credential + noEchoHook pattern (lines 4453, 7377)

Imports pattern (setup.ts lines 2939):

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:

const noEchoHook = (result: { success: boolean }, c: Context) => {
  if (!result.success) {
    return c.json({ error: 'Invalid request' }, 400);
  }
};

zValidator usage (admin.ts line 112):

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):

return c.json({ ok: true }, 200);

Error response pattern (admin.ts lines 119130):

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:

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):

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:

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):

adminRouter.use('*', requireAdmin);  // FIRST statement; never move this

noEchoHook pattern (admin.ts lines 7074) — reuse existing:

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):

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:

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:

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:

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:

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):

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):

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:

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:

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:

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):

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):

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:

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):

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:

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:

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):

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):

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):

{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:

<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.

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:

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):

const [resetSheetOpen, setResetSheetOpen] = useState(false);
const [resetTargetMember, setResetTargetMember] = useState<AdminMember | null>(null);
const resetTriggerRef = useRef<HTMLButtonElement>(null);

Query + mutation pattern (AdminPage.tsx lines 7681):

const membersQuery = useQuery({
  queryKey: ['admin', 'members'],
  queryFn: fetchAdminMembers,
  retry: false,
  staleTime: 60 * 1000,
});

Invalidate on success (CredentialSheet.tsx lines 136139):

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):

{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:

<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):

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):

useEffect(() => {
  if (isOpen && headingRef.current) {
    headingRef.current.focus();
  }
}, [isOpen]);

Mutation error state pattern (CredentialSheet.tsx lines 141144):

onError: () => {
  setValidationError(FAILURE_TEXT);
},

Conditional row render — "Change password" row shown only when hasLocalCredential:

{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):

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:

// 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):

<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:

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):

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:

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):

// 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):

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:

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)

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

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.tsPOST /members (must insert users + local_credentials atomically)

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)

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

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