Files
familysync/.planning/phases/12-initial-setup-wizard/12-PATTERNS.md
T
2026-06-15 11:37:37 -04:00

565 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 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 112142) | 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 2236):
```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 5464):
```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 4752):
```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 102122):
```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 1627):
```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<boolean> {
// 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 3555
**Pre-auth mount pattern to replicate** (index.ts lines 3555):
```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 3551 (users table) and lines 282286 (appConfig table)
**Current users table definition** (schema.ts lines 3551):
```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 282286):
```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 76142
**Current first-login-wins block to replace** (user.ts lines 112123):
```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<number>`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 2637):
```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 81117.
**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 58168
**Existing `meQuery` pattern to extend** (App.tsx lines 6570):
```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 133153 show the existing isAdmin gate pattern to copy):
```typescript
// New /setup route — rendered standalone (no AppNav/BottomTabBar)
<Route path="/setup" element={<SetupPage />} />
// Redirect gate: if setup not complete, send all routes to /setup
// Mirror the isAdmin loading-gate pattern (lines 144150) for the loading state
{setupQuery.data?.setupComplete === false && <Navigate to="/setup" replace />}
```
**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 5464
**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 108119, `apps/api/src/broker/credentialSync.ts` lines 3136
**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 126141
**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 137, `apps/api/src/routes/health.ts` lines 16
**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