Files
familysync/.planning/milestones/v1.1-phases/12-initial-setup-wizard/12-PATTERNS.md
T
2026-06-18 22:21:38 -04:00

20 KiB
Raw Blame History

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

const meQuery = useQuery({
  queryKey: ['me'],
  queryFn: fetchMe,
  retry: false,
  staleTime: 5 * 60 * 1000,
});

New setup status query to add alongside meQuery:

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

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

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

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)

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

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

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

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

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