diff --git a/.planning/phases/12-initial-setup-wizard/12-PATTERNS.md b/.planning/phases/12-initial-setup-wizard/12-PATTERNS.md new file mode 100644 index 0000000..b4542b8 --- /dev/null +++ b/.planning/phases/12-initial-setup-wizard/12-PATTERNS.md @@ -0,0 +1,564 @@ +# Phase 12: Initial Setup Wizard - Pattern Map + +**Mapped:** 2026-06-15 +**Files analyzed:** 10 new/modified files +**Analogs found:** 10 / 10 + +--- + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|---|---|---|---|---| +| `apps/api/src/routes/setup.ts` | route | request-response | `apps/api/src/routes/admin.ts` | exact | +| `apps/api/src/lib/setupGuard.ts` | utility | request-response | `apps/api/src/lib/householdTimezone.ts` (compiled: `apps/api/dist/lib/householdTimezone.js`) | role-match | +| `apps/api/src/index.ts` (modify) | config | request-response | itself — pre-auth `/health` mounting pattern | exact | +| `apps/api/src/db/schema.ts` (modify) | model | CRUD | itself — `users`, `appConfig`, `memberCredentials` table definitions | exact | +| `apps/api/src/auth/user.ts` (modify) | service | request-response | itself — `upsertUser` first-login-wins block (lines 112–142) | exact | +| `apps/api/src/db/migrations/0002_*.sql` | migration | batch | `apps/api/src/db/migrations/0001_famous_mad_thinker.sql` | role-match | +| `scripts/generate-secrets.mjs` | utility | batch | `scripts/check-audit.mjs` (structure only; content is new) | partial | +| `apps/pwa/src/routes/SetupPage.tsx` | component | request-response | `apps/pwa/src/routes/AdminPage.tsx` | role-match | +| `apps/pwa/src/App.tsx` (modify) | component | request-response | itself — existing `Routes` block + `meQuery` gate pattern | exact | +| `apps/api/tests/setup.test.ts` | test | request-response | `apps/api/src/routes/admin.ts` (test patterns from same codebase convention) | role-match | + +--- + +## Pattern Assignments + +### `apps/api/src/routes/setup.ts` (route, request-response) + +**Analog:** `apps/api/src/routes/admin.ts` + +**Imports pattern** (admin.ts lines 22–36): +```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'; +import { users, memberCredentials, appConfig } from '../db/schema.js'; +import { + validateEncryptAndStoreCredential, + CredentialValidationError, +} from '../broker/credentialSync.js'; + +export const setupRouter = new Hono(); +``` + +**noEchoHook pattern — copy exactly** (admin.ts lines 54–64): +```typescript +const noEchoHook = (result: { success: boolean }, c: Context) => { + if (!result.success) { + return c.json({ error: 'Invalid request' }, 400); + } +}; +``` + +**Zod schema pattern for credential step** (admin.ts lines 47–52): +```typescript +const credentialSchema = z.object({ + userId: z.number().int().positive(), + providerType: z.literal('caldav'), + fastmailEmail: z.string().email().max(256), + appPassword: z.string().min(1).max(500), +}); +``` + +**validateEncryptAndStoreCredential call + error-handling pattern** (admin.ts lines 102–122): +```typescript +adminRouter.post('/credentials', zValidator('json', credentialSchema, noEchoHook), async (c) => { + const { userId, fastmailEmail, appPassword, providerType } = c.req.valid('json'); + // T-10-10: NEVER log appPassword or c.req.valid('json') here + + try { + await validateEncryptAndStoreCredential(userId, fastmailEmail, appPassword, providerType); + } catch (err) { + if (err instanceof CredentialValidationError) { + return c.json({ error: 'Invalid request' }, 400); + } + console.error( + '[admin/POST /credentials] Unexpected error:', + err instanceof Error ? err.message : String(err), + ); + return c.json({ error: 'Service unavailable' }, 503); + } + + return c.json({ ok: true }, 200); +}); +``` + +**app_config upsert pattern** (from compiled `apps/api/dist/lib/householdTimezone.js`, confirmed by admin.ts app_config usage): +```typescript +// Drizzle onDuplicateKeyUpdate upsert — the project standard for app_config writes +await db + .insert(appConfig) + .values({ key: 'oidc_issuer', value: issuer }) + .onDuplicateKeyUpdate({ set: { value: issuer } }); +``` + +**Guard pattern — FIRST statement in every handler** (D-10, per RESEARCH.md Pattern 3): +```typescript +// Copy this call at the top of every setup route handler — before any other logic +const locked = await isSetupLocked(); +if (locked) return c.json({ error: 'Setup already complete' }, 423); +``` + +**VAPID structural validation pattern** (RESEARCH.md Pattern 7): +```typescript +import webpush from 'web-push'; +try { + webpush.setVapidDetails( + subject || 'mailto:validate@familysync.local', + publicKey, // from process.env.VAPID_PUBLIC_KEY or already-written app_config + privateKey, // from process.env.VAPID_PRIVATE_KEY only — NEVER from app_config + ); + return c.json({ ok: true }); +} catch (err) { + return c.json({ ok: false, error: err instanceof Error ? err.message : 'VAPID validation failed' }, 400); +} +``` + +**DB connectivity validation pattern** (health.ts lines 16–27): +```typescript +import { sql } from 'drizzle-orm'; +// ... +try { + await db.execute(sql`SELECT 1`); + return c.json({ ok: true }); +} catch (err) { + console.error('[setup/validate/db] DB round-trip failed:', err); + return c.json({ ok: false, error: 'DB unavailable' }, 503); +} +``` + +--- + +### `apps/api/src/lib/setupGuard.ts` (utility, request-response) + +**Analog:** `apps/api/dist/lib/householdTimezone.js` (compiled output of `householdTimezone.ts`) + +**app_config read pattern** (householdTimezone, confirmed from RESEARCH.md Pattern 2): +```typescript +import { db } from '../db/client.js'; +import { appConfig, memberCredentials } from '../db/schema.js'; +import { eq } from 'drizzle-orm'; + +/** Returns true if the wizard is already locked. Re-evaluated fresh — NEVER cache at module level. */ +export async function isSetupLocked(): Promise { + // Check 1: explicit setup_complete flag in app_config + const [flagRow] = await db + .select({ value: appConfig.value }) + .from(appConfig) + .where(eq(appConfig.key, 'setup_complete')) + .limit(1); + if (flagRow?.value === 'true') return true; + + // Check 2: effective configuration — member_credentials row exists AND VAPID env set + const [credRow] = await db + .select({ id: memberCredentials.id }) + .from(memberCredentials) + .limit(1); + const vapidPresent = !!process.env.VAPID_PRIVATE_KEY && !!process.env.VAPID_PUBLIC_KEY; + return !!credRow && vapidPresent; +} +``` + +**Key constraint:** The return value MUST NOT be hoisted to a module-level variable. Callers must call `isSetupLocked()` as the first line of each handler. This is the same per-call freshness pattern as `db.select()` in the health router — no startup caching. + +--- + +### `apps/api/src/index.ts` (modify — route mounting order) + +**Analog:** itself, lines 35–55 + +**Pre-auth mount pattern to replicate** (index.ts lines 35–55): +```typescript +// OIDC callback — must be registered BEFORE oidcAuthMiddleware (T-02-02) +app.get('/callback', (c) => processOAuthCallback(c)); + +// GET /health — unauthenticated, mounted BEFORE the OIDC guard (T-01-03, T-02-05) +app.route('/health', healthRouter); + +// Dev-auth bypass — must be mounted BEFORE oidcAuthMiddleware (T-02-01) +app.use('/api/*', devAuthBypass()); + +// OIDC guard — protects all /api/* routes +if (!devBypassActive) { + app.use('/api/*', oidcAuthMiddleware()); + app.use('/api/*', persistSessionCookie()); +} +``` + +**New mount line to insert — before `app.use('/api/*', devAuthBypass())`:** +```typescript +// /api/setup/* — pre-auth wizard surface; must mount BEFORE the /api/* middleware chain. +// Inserting here mirrors the /health pattern: pre-auth, no OIDC, no devAuthBypass needed. +import { setupRouter } from './routes/setup.js'; +app.route('/api/setup', setupRouter); // ← INSERT before app.use('/api/*', devAuthBypass()) +``` + +--- + +### `apps/api/src/db/schema.ts` (modify — users table + new app_config keys) + +**Analog:** itself, lines 35–51 (users table) and lines 282–286 (appConfig table) + +**Current users table definition** (schema.ts lines 35–51): +```typescript +export const users = mysqlTable( + 'users', + { + id: int().primaryKey().autoincrement(), + oidcIss: varchar('oidc_iss', { length: 512 }).notNull(), // ← change to nullable + oidcSub: varchar('oidc_sub', { length: 256 }).notNull(), // ← change to nullable + displayName: varchar('display_name', { length: 256 }), + color: varchar('color', { length: 7 }).notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + isAdmin: boolean('is_admin').default(false).notNull(), + }, + (t) => [ + unique('uniq_oidc_identity').on(t.oidcIss, t.oidcSub), + ], +); +``` + +**Required schema changes (D-07):** +```typescript +// Make oidcIss and oidcSub nullable (remove .notNull()): +oidcIss: varchar('oidc_iss', { length: 512 }), // WAS .notNull() +oidcSub: varchar('oidc_sub', { length: 256 }), // WAS .notNull() +// Add claimed marker: +claimed: boolean('claimed').default(false).notNull(), // false = pending wizard user +``` + +**appConfig table — unchanged, but new keys documented** (schema.ts lines 282–286): +```typescript +export const appConfig = mysqlTable('app_config', { + key: varchar('key', { length: 128 }).primaryKey(), + value: text('value'), // nullable + updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(), +}); +// Phase 12 new keys: 'oidc_issuer', 'oidc_client_id', 'vapid_public_key', +// 'app_external_url', 'setup_complete' (already exists from Phase 10) +// DO NOT add: 'vapid_private_key', 'app_password_encryption_key' — D-01 / SC-3 +``` + +**Migration backfill requirement** (RESEARCH.md Runtime State Inventory): +```sql +-- In 0002_*.sql — after altering the columns: +UPDATE users SET claimed = true WHERE oidc_iss IS NOT NULL; +-- Existing OIDC users are "effectively claimed" — prevents the claim query from matching them. +``` + +--- + +### `apps/api/src/auth/user.ts` (modify — upsertUser first-login-claims) + +**Analog:** itself, lines 76–142 + +**Current first-login-wins block to replace** (user.ts lines 112–123): +```typescript +// 3. First-login-wins is_admin bootstrap (D-01). +// Phase 12 will tighten this to: first user after app_config.setup_complete. +const [{ count }] = await db + .select({ count: sql`COUNT(*)` }) + .from(users) + .where(eq(users.isAdmin, true)) + .limit(1); +const shouldBeAdmin = Number(count) === 0; +``` + +**Replacement pattern (D-08 first-login-claims)** — insert between existing step 1 (look up by iss+sub) and existing step 4 (insert new user): +```typescript +// 2. Check setup_complete; if true, look for unclaimed local user (first-login-claims, D-08) +// MUST use oidcIss IS NULL + claimed=false — never email-keyed (D-10) +import { isNull } from 'drizzle-orm'; // add to imports at top of file +import { appConfig } from '../db/schema.js'; // add to imports + +const [flagRow] = await db + .select({ value: appConfig.value }) + .from(appConfig) + .where(eq(appConfig.key, 'setup_complete')) + .limit(1); +if (flagRow?.value === 'true') { + const [unclaimed] = await db + .select() + .from(users) + .where(and(isNull(users.oidcIss), eq(users.claimed, false))) + .limit(1); + if (unclaimed) { + await db.update(users).set({ + oidcIss, + oidcSub, + claimed: true, + displayName: displayName ?? unclaimed.displayName, + }).where(eq(users.id, unclaimed.id)); + return { ...unclaimed, oidcIss, oidcSub, claimed: true }; + } +} + +// 3. No unclaimed user found — normal insert path +// isAdmin: only when setup_complete is false (no unclaimed user exists yet) +const shouldBeAdmin = flagRow?.value !== 'true' && Number(count) === 0; +``` + +**Import additions needed at top of user.ts** (add to existing `import { and, eq, sql } from 'drizzle-orm'`): +```typescript +import { and, eq, isNull, sql } from 'drizzle-orm'; +import { users, appConfig } from '../db/schema.js'; // add appConfig +``` + +--- + +### `apps/api/src/db/migrations/0002_*.sql` (migration, batch) + +**Analog:** `apps/api/src/db/migrations/0001_famous_mad_thinker.sql` + +**Migration workflow (NEVER drizzle-kit push — D-Task5-DDL):** +1. Edit `schema.ts` with the nullable + claimed changes. +2. Run: `pnpm --filter @familysync/api exec drizzle-kit generate` +3. Review the generated SQL — confirm it contains `ALTER COLUMN` (not DROP/recreate of existing data). +4. Run: `pnpm --filter @familysync/api exec drizzle-kit migrate` + +**Expected SQL shape** (Pitfall 9 awareness — check for DROP CONSTRAINT before ADD CONSTRAINT on the unique index): +```sql +ALTER TABLE `users` + MODIFY COLUMN `oidc_iss` varchar(512), -- remove NOT NULL + MODIFY COLUMN `oidc_sub` varchar(256), -- remove NOT NULL + ADD COLUMN `claimed` boolean NOT NULL DEFAULT false; + +-- Backfill: existing OIDC users are already "claimed" +UPDATE `users` SET `claimed` = true WHERE `oidc_iss` IS NOT NULL; +``` + +--- + +### `scripts/generate-secrets.mjs` (utility, batch) + +**Analog:** `scripts/check-audit.mjs` (structure — plain ESM `.mjs`, no compilation) + +**Core pattern** (RESEARCH.md Pattern 6): +```javascript +// scripts/generate-secrets.mjs — plain ESM; no TypeScript compilation needed +import { generateVAPIDKeys } from '../apps/api/node_modules/web-push/src/index.js'; +import { randomBytes } from 'node:crypto'; + +const vapid = generateVAPIDKeys(); +const sessionSecret = randomBytes(32).toString('hex'); +const encKey = randomBytes(32).toString('hex'); + +console.log(` +# FamilySync Bootstrap Secrets — generated ${new Date().toISOString()} +# Paste into docker-compose.yml environment block. +# Keep this output safe — these values cannot be recovered if lost. + +SESSION_SECRET=${sessionSecret} +APP_PASSWORD_ENCRYPTION_KEY=${encKey} +VAPID_PUBLIC_KEY=${vapid.publicKey} +VAPID_PRIVATE_KEY=${vapid.privateKey} +`); +``` + +**Root package.json script addition:** +```json +"generate-secrets": "node scripts/generate-secrets.mjs" +``` + +**VAPID output format** (VERIFIED: live execution per RESEARCH.md): +- `publicKey`: base64url, 87 chars (uncompressed EC P-256, 65 bytes) +- `privateKey`: base64url, 43 chars (raw P-256 scalar, 32 bytes) + +--- + +### `apps/pwa/src/routes/SetupPage.tsx` (component, request-response) + +**Analog:** `apps/pwa/src/routes/AdminPage.tsx` + +**Imports pattern** (AdminPage.tsx lines 26–37): +```typescript +import { useState, useRef } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +// For SetupPage — replace admin-specific imports with setup-specific: +import { BrowserRouter, Routes, Route, Navigate } from 'react-router'; // no router needed inside +// SetupPage-specific: +import { fetchSetupStatus, postSetupConfig, postSetupCredential, postSetupComplete } from '../api/client.js'; +``` + +**TanStack Query mutation pattern** (AdminPage.tsx uses `useMutation`): +```typescript +const configMutation = useMutation({ + mutationFn: postSetupConfig, + onSuccess: () => { + // advance to next step + setStep((s) => s + 1); + }, + onError: () => { + setError('Configuration failed. Check your inputs and try again.'); + }, +}); +``` + +**Step state pattern** (Claude's discretion per CONTEXT.md — use local state, not URL params): +```typescript +const [step, setStep] = useState<1 | 2 | 3 | 4 | 5>(1); +// Steps: 1=Welcome, 2=Config, 3=Validate, 4=Credential, 5=Complete +``` + +**Security constraint:** All copy is plain-text JSX children — no `dangerouslySetInnerHTML` (UI-SPEC security contract). Pattern confirmed in SetupBanner.tsx lines 81–117. + +**No AppNav / BottomTabBar** — SetupPage renders standalone (per UI-SPEC §Routing). The App.tsx gate prevents authenticated routes from showing when unconfigured. + +--- + +### `apps/pwa/src/App.tsx` (modify — setup gate + /setup route) + +**Analog:** itself, lines 58–168 + +**Existing `meQuery` pattern to extend** (App.tsx lines 65–70): +```typescript +const meQuery = useQuery({ + queryKey: ['me'], + queryFn: fetchMe, + retry: false, + staleTime: 5 * 60 * 1000, +}); +``` + +**New setup status query to add alongside meQuery:** +```typescript +const setupQuery = useQuery({ + queryKey: ['setupStatus'], + queryFn: () => fetch('/api/setup/status').then((r) => r.json()) as Promise<{ setupComplete: boolean }>, + retry: false, + staleTime: 0, // always fresh — guard must not be stale (mirrors D-10 spirit on client) +}); +``` + +**Gate pattern to add in Routes block** (App.tsx lines 133–153 show the existing isAdmin gate pattern to copy): +```typescript +// New /setup route — rendered standalone (no AppNav/BottomTabBar) +} /> + +// Redirect gate: if setup not complete, send all routes to /setup +// Mirror the isAdmin loading-gate pattern (lines 144–150) for the loading state +{setupQuery.data?.setupComplete === false && } +``` + +**Import addition:** +```typescript +import { SetupPage } from './routes/SetupPage.js'; +``` + +--- + +### `apps/api/tests/setup.test.ts` (test, request-response) + +**Analog:** Existing test files under `apps/api/tests/` (same Vitest + Hono test convention) + +**Test structure pattern** (from RESEARCH.md Validation Architecture — mirrors admin.test.ts conventions): +```typescript +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { app } from '../src/index.js'; + +// Mock the DB and external calls — same pattern as admin.test.ts +vi.mock('../src/db/client.js', () => ({ db: mockDb })); +vi.mock('../src/broker/credentialSync.js', () => ({ + validateEncryptAndStoreCredential: vi.fn(), + CredentialValidationError: class extends Error {}, +})); + +describe('POST /api/setup/complete — 423 guard (SETUP-04)', () => { + it('first call returns 200', async () => { /* ... */ }); + it('second call returns 423', async () => { /* ... */ }); +}); + +describe('POST /api/setup/* when effectively configured (D-10)', () => { + it('returns 423 when member_credentials row exists AND VAPID env set', async () => { /* ... */ }); +}); +``` + +--- + +## Shared Patterns + +### 1. app_config Key/Value Read +**Source:** `apps/api/dist/lib/householdTimezone.js` (compiled) + `apps/api/src/routes/admin.ts` (upsert usage) +**Apply to:** `setup.ts` (all config reads), `setupGuard.ts` (setup_complete read), `auth/user.ts` (setup_complete read in upsertUser) +```typescript +// READ: +const [row] = await db + .select({ value: appConfig.value }) + .from(appConfig) + .where(eq(appConfig.key, 'some_key')) + .limit(1); +const val = row?.value ?? null; + +// WRITE (upsert): +await db + .insert(appConfig) + .values({ key: 'some_key', value: theValue }) + .onDuplicateKeyUpdate({ set: { value: theValue } }); +``` + +### 2. noEchoHook (credential endpoints) +**Source:** `apps/api/src/routes/admin.ts` lines 54–64 +**Apply to:** `setup.ts` POST /api/setup/credential handler only +```typescript +const noEchoHook = (result: { success: boolean }, c: Context) => { + if (!result.success) { + return c.json({ error: 'Invalid request' }, 400); + } +}; +``` + +### 3. CredentialValidationError error mapping +**Source:** `apps/api/src/routes/admin.ts` lines 108–119, `apps/api/src/broker/credentialSync.ts` lines 31–36 +**Apply to:** `setup.ts` credential handler +```typescript +} catch (err) { + if (err instanceof CredentialValidationError) { + return c.json({ error: 'Invalid request' }, 400); // no echo, no Zod details + } + console.error('[setup/credential] Unexpected error:', err instanceof Error ? err.message : String(err)); + return c.json({ error: 'Service unavailable' }, 503); +} +``` + +### 4. mysql2 insert + $returningId() re-select +**Source:** `apps/api/src/auth/user.ts` lines 126–141 +**Apply to:** `setup.ts` local user creation step (mysql2 has no RETURNING clause) +```typescript +const [inserted] = await db + .insert(users) + .values({ /* ... */ }) + .$returningId(); +const [newUser] = await db.select().from(users).where(eq(users.id, inserted.id)).limit(1); +``` + +### 5. Hono router export + file-level doc comment +**Source:** `apps/api/src/routes/admin.ts` lines 1–37, `apps/api/src/routes/health.ts` lines 1–6 +**Apply to:** `setup.ts`, all new route files +```typescript +export const setupRouter = new Hono(); +// Mounted in index.ts: app.route('/api/setup', setupRouter) +// Mounted BEFORE app.use('/api/*', devAuthBypass()) — pre-auth surface. +``` + +--- + +## No Analog Found + +All Phase 12 files have close analogs in the codebase. No new patterns need to be sourced from RESEARCH.md examples alone — all implementation patterns are grounded in existing code. + +| File | Note | +|---|---| +| `scripts/generate-secrets.mjs` | Script structure from `scripts/check-audit.mjs` but the web-push + crypto logic is net-new. RESEARCH.md Pattern 6 is the authoritative reference for the output format. | + +--- + +## Metadata + +**Analog search scope:** `apps/api/src/routes/`, `apps/api/src/auth/`, `apps/api/src/db/`, `apps/api/src/lib/`, `apps/api/src/broker/`, `apps/pwa/src/`, `scripts/` +**Files read:** 14 source files +**Pattern extraction date:** 2026-06-15