From 20f91e45485083117a680ce40a2fb613a3d688ae Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Mon, 15 Jun 2026 13:58:51 -0400 Subject: [PATCH] =?UTF-8?q?feat(12-02):=20implement=20setup=20router=20?= =?UTF-8?q?=E2=80=94=20all=207=20pre-auth=20routes=20+=20guard-first=20pat?= =?UTF-8?q?tern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fill setupRouter: GET /status, POST /config, POST /validate/{db,oidc,vapid}, POST /credential, POST /complete (SETUP-01/02) - isSetupLocked() is FIRST statement in every handler; returns 423 if locked (SETUP-04/D-10) - /status uses isSetupLocked() directly: covers both explicit + effective-config branches - /config: zod-validates {oidcIssuer:https, oidcClientId, vapidPublicKey, appExternalUrl}; upserts oidc_issuer|oidc_client_id|vapid_public_key|app_external_url into app_config - /validate/db: db.execute(sql`SELECT 1`); 200 ok, 503 on failure - /validate/oidc: fetches discovery doc with AbortSignal.timeout(5000); reads oidc_issuer from app_config; 200 ok, 400 on unreachable/non-2xx - /validate/vapid: webpush.setVapidDetails() structural check; reads ONLY from process.env (VAPID_PRIVATE_KEY never from app_config, never returned; T-12-06/SC-3) - /credential: inserts local user (oidcIss=null, claimed=false, isAdmin=true) FIRST (Pitfall 5 FK), then calls validateEncryptAndStoreCredential(); noEchoHook + error map - /complete: upserts setup_complete='true'; 200 first call, 423 second (Pitfall 8/D-10) - Mount setupRouter pre-auth in index.ts BEFORE devAuthBypass() (T-12-09/Pitfall 1) - All 394 tests pass (5 todo = D-08 RED scaffolds); typecheck clean --- apps/api/src/index.ts | 7 + apps/api/src/routes/setup.ts | 318 ++++++++++++++++++++++++++++++++++- 2 files changed, 321 insertions(+), 4 deletions(-) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 7743208..b76a55f 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -10,6 +10,7 @@ import { sseRouter } from './routes/sse.js'; import { listsRouter, listItemsRouter } from './routes/lists.js'; import { pushRouter } from './routes/push.js'; import { adminRouter } from './routes/admin.js'; +import { setupRouter } from './routes/setup.js'; import { oidcAuthMiddleware, processOAuthCallback } from './auth/middleware.js'; import { devAuthBypass } from './auth/devBypass.js'; import { persistSessionCookie } from './auth/persistSessionCookie.js'; @@ -37,6 +38,12 @@ app.get('/callback', (c) => processOAuthCallback(c)); // GET /health — unauthenticated, mounted BEFORE the OIDC guard (T-01-03, T-02-05) app.route('/health', healthRouter); +// /api/setup/* — pre-auth wizard surface; mounted BEFORE the /api/* middleware chain +// so the wizard is never caught by devAuthBypass or oidcAuthMiddleware (Pitfall 1 / T-12-09). +// Mirrors the /health pre-auth pattern. isSetupLocked() in each handler provides the +// 423 lock after setup is complete (SETUP-04 / D-10). +app.route('/api/setup', setupRouter); + // Dev-auth bypass — no-op passthrough unless DEV_AUTH_BYPASS=true AND NODE_ENV!='production'. // When active, injects DEV_USER into the Hono context and the OIDC guard is NOT mounted. // Must be mounted BEFORE oidcAuthMiddleware (T-02-01 mitigation; see auth/devBypass.ts). diff --git a/apps/api/src/routes/setup.ts b/apps/api/src/routes/setup.ts index eb58669..a7c8380 100644 --- a/apps/api/src/routes/setup.ts +++ b/apps/api/src/routes/setup.ts @@ -5,13 +5,323 @@ * Mount position: BEFORE app.use('/api/*', devAuthBypass()) so the wizard * is reachable pre-authentication — same pre-auth surface as /health (T-01-03). * - * Wave-0 stub (Plan 01): empty router — all handlers are added in Plan 02. - * Plan 02 owns the index.ts mount as well, to keep file-ownership clean. + * Security contract (Pitfall 8, T-12-04, T-12-05, T-12-06): + * - Every handler calls isSetupLocked() as its FIRST statement; returns 423 if locked. + * - noEchoHook: Zod validation errors for the credential step NEVER return received values. + * - App password is NEVER logged or echoed (T-12-05). + * - VAPID_PRIVATE_KEY is read ONLY from process.env — never from app_config or returned (T-12-06). + * - validateEncryptAndStoreCredential is the ONLY credential-handling path (D-09 / no new crypto). + * - Shared helper called directly — no admin route invocation (pre-auth endpoint cannot reach it). * - * Security note: every handler added in Plan 02 MUST call isSetupLocked() as - * its first statement and return 423 if locked (SETUP-04 / Pitfall 8 / D-10). + * Routes: + * GET /api/setup/status → { setupComplete: boolean } + * POST /api/setup/config → upsert oidc_issuer, oidc_client_id, vapid_public_key, app_external_url + * POST /api/setup/validate/db → SELECT 1 connectivity check + * POST /api/setup/validate/oidc → fetch {issuer}/.well-known/openid-configuration + * POST /api/setup/validate/vapid → setVapidDetails structural check (env keys only) + * POST /api/setup/credential → insert local user + validateEncryptAndStoreCredential + * POST /api/setup/complete → set app_config.setup_complete='true' */ import { Hono } from 'hono'; +import type { Context } from 'hono'; +import { zValidator } from '@hono/zod-validator'; +import { z } from 'zod'; +import { eq, sql } from 'drizzle-orm'; +import { db } from '../db/client.js'; +import { users, appConfig } from '../db/schema.js'; +import { isSetupLocked } from '../lib/setupGuard.js'; +import { COLOR_PALETTE } from '../auth/user.js'; +import { + validateEncryptAndStoreCredential, + CredentialValidationError, +} from '../broker/credentialSync.js'; +import webpush from 'web-push'; export const setupRouter = new Hono(); + +// --------------------------------------------------------------------------- +// noEchoHook — NEVER return Zod error details for credential endpoints (T-12-05 / Pitfall 7). +// Zod's error object contains issues[].received which echoes the submitted value. +// Always return { error: 'Invalid request' } 400, no other fields. +// --------------------------------------------------------------------------- + +const noEchoHook = (result: { success: boolean }, c: Context) => { + if (!result.success) { + return c.json({ error: 'Invalid request' }, 400); + } +}; + +// --------------------------------------------------------------------------- +// Zod schemas +// --------------------------------------------------------------------------- + +const configSchema = z.object({ + oidcIssuer: z + .string() + .url() + .refine((v) => v.startsWith('https://'), { message: 'oidcIssuer must be an https URL' }), + oidcClientId: z.string().min(1).max(256), + vapidPublicKey: z.string().min(1).max(512), + appExternalUrl: z.string().url().max(512), +}); + +const credentialSchema = z.object({ + fastmailEmail: z.string().email().max(256), + appPassword: z.string().min(1).max(500), +}); + +// --------------------------------------------------------------------------- +// GET /api/setup/status +// +// Returns { setupComplete: boolean } derived from app_config.setup_complete. +// Reachable pre-auth (no OIDC guard). Does NOT check the 423 guard — status +// is always readable so the PWA can decide whether to show the wizard. +// --------------------------------------------------------------------------- + +setupRouter.get('/status', async (c) => { + // isSetupLocked() is the authoritative check for both explicit and effective-config + // completion (D-10). Using it here keeps the status response in sync with the guard + // without a second DB read pattern (covers setup_complete AND effective-config). + const locked = await isSetupLocked(); + return c.json({ setupComplete: locked }); +}); + +// --------------------------------------------------------------------------- +// POST /api/setup/config +// +// Collects non-secret operator config and upserts into app_config: +// oidc_issuer, oidc_client_id, vapid_public_key, app_external_url +// +// Validates: oidcIssuer must be an https URL (T-12-08 SSRF mitigation). +// All other fields are non-secret (D-01 / D-02). +// --------------------------------------------------------------------------- + +setupRouter.post('/config', zValidator('json', configSchema), async (c) => { + const locked = await isSetupLocked(); + if (locked) return c.json({ error: 'Setup already complete' }, 423); + + const { oidcIssuer, oidcClientId, vapidPublicKey, appExternalUrl } = c.req.valid('json'); + + await db + .insert(appConfig) + .values({ key: 'oidc_issuer', value: oidcIssuer }) + .onDuplicateKeyUpdate({ set: { value: oidcIssuer } }); + + await db + .insert(appConfig) + .values({ key: 'oidc_client_id', value: oidcClientId }) + .onDuplicateKeyUpdate({ set: { value: oidcClientId } }); + + await db + .insert(appConfig) + .values({ key: 'vapid_public_key', value: vapidPublicKey }) + .onDuplicateKeyUpdate({ set: { value: vapidPublicKey } }); + + await db + .insert(appConfig) + .values({ key: 'app_external_url', value: appExternalUrl }) + .onDuplicateKeyUpdate({ set: { value: appExternalUrl } }); + + return c.json({ ok: true }, 200); +}); + +// --------------------------------------------------------------------------- +// POST /api/setup/validate/db +// +// Proves DB connectivity via SELECT 1. +// Returns 200 { ok: true } on success, 503 on failure. +// --------------------------------------------------------------------------- + +setupRouter.post('/validate/db', async (c) => { + const locked = await isSetupLocked(); + if (locked) return c.json({ error: 'Setup already complete' }, 423); + + try { + await db.execute(sql`SELECT 1`); + return c.json({ ok: true }, 200); + } catch (err) { + console.error('[setup/validate/db] DB round-trip failed:', err instanceof Error ? err.message : String(err)); + return c.json({ ok: false, error: 'DB unavailable' }, 503); + } +}); + +// --------------------------------------------------------------------------- +// POST /api/setup/validate/oidc +// +// Validates OIDC issuer by fetching {issuer}/.well-known/openid-configuration. +// Reads oidc_issuer from app_config (written by /config step). +// 5-second timeout via AbortSignal.timeout (Node.js 18+). +// Returns 200 { ok: true } on success, 400 { ok: false } on failure. +// --------------------------------------------------------------------------- + +setupRouter.post('/validate/oidc', async (c) => { + const locked = await isSetupLocked(); + if (locked) return c.json({ error: 'Setup already complete' }, 423); + + const [row] = await db + .select({ value: appConfig.value }) + .from(appConfig) + .where(eq(appConfig.key, 'oidc_issuer')) + .limit(1); + + const issuer = row?.value; + if (!issuer) { + return c.json({ ok: false, error: 'OIDC issuer not configured' }, 400); + } + + try { + const res = await fetch(`${issuer}/.well-known/openid-configuration`, { + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + return c.json({ ok: true }, 200); + } catch (err) { + return c.json({ + ok: false, + error: 'OIDC discovery failed: ' + (err instanceof Error ? err.message : String(err)), + }, 400); + } +}); + +// --------------------------------------------------------------------------- +// POST /api/setup/validate/vapid +// +// Validates VAPID key pair structure by calling webpush.setVapidDetails(). +// Reads BOTH keys ONLY from process.env — NEVER from app_config (T-12-06 / D-01 / SC-3). +// VAPID_PRIVATE_KEY is never returned in any response. +// Returns 200 { ok: true } on structural validity, 400 { ok: false } on failure. +// --------------------------------------------------------------------------- + +setupRouter.post('/validate/vapid', async (c) => { + const locked = await isSetupLocked(); + if (locked) return c.json({ error: 'Setup already complete' }, 423); + + const privateKey = process.env.VAPID_PRIVATE_KEY ?? ''; + const publicKey = process.env.VAPID_PUBLIC_KEY ?? ''; + + if (!privateKey || !publicKey) { + return c.json({ ok: false, error: 'VAPID_PRIVATE_KEY and VAPID_PUBLIC_KEY env vars must be set' }, 400); + } + + try { + // setVapidDetails runs validatePrivateKey (32-byte check) and validatePublicKey (65-byte check) + // internally — this is the library's own structural validation (Pattern 7). + // Using a placeholder subject for validation only (actual subject used at startup in index.ts). + webpush.setVapidDetails( + process.env.VAPID_SUBJECT || 'mailto:validate@familysync.local', + publicKey, + privateKey, + ); + return c.json({ ok: true }, 200); + } catch (err) { + return c.json({ + ok: false, + error: err instanceof Error ? err.message : 'VAPID validation failed', + }, 400); + } +}); + +// --------------------------------------------------------------------------- +// POST /api/setup/credential +// +// Creates the pre-OIDC local user (oidcIss=null, oidcSub=null, claimed=false, +// is_admin=true) FIRST — FK constraint on member_credentials requires the user +// row to exist before inserting the credential (Pitfall 5). +// +// Then calls validateEncryptAndStoreCredential() to validate + encrypt + store +// the Fastmail app password (D-09 — reuses shared helper, no new crypto). +// +// Security (T-12-05 / Pitfall 7): +// - noEchoHook prevents Zod error details from including the app password. +// - app password is NEVER logged or echoed. +// - CredentialValidationError maps to generic 400. +// --------------------------------------------------------------------------- + +setupRouter.post('/credential', zValidator('json', credentialSchema, noEchoHook), async (c) => { + const locked = await isSetupLocked(); + if (locked) return c.json({ error: 'Setup already complete' }, 423); + + const { fastmailEmail, appPassword } = c.req.valid('json'); + // T-12-05: NEVER log appPassword or c.req.valid('json') here + + // Step 1: Assign color (first unused from palette, or round-robin fallback) + const usedRows = await db.select({ color: users.color }).from(users); + const usedColors = new Set(usedRows.map((r) => r.color)); + const color = + COLOR_PALETTE.find((col) => !usedColors.has(col)) ?? + COLOR_PALETTE[usedColors.size % COLOR_PALETTE.length]; + + // Step 2: Insert the local user (oidcIss=null, oidcSub=null, claimed=false) + // mysql2 has no RETURNING clause — use $returningId() then re-select (Pattern 4) + const [inserted] = await db + .insert(users) + .values({ + oidcIss: null, + oidcSub: null, + displayName: null, + color, + isAdmin: true, + claimed: false, + }) + .$returningId(); + + const [localUser] = await db + .select() + .from(users) + .where(eq(users.id, inserted.id)) + .limit(1); + + if (!localUser) { + return c.json({ error: 'Service unavailable' }, 503); + } + + // Step 3: Validate + encrypt + store the credential via the shared helper (D-09) + // The user row MUST exist before this call (Pitfall 5 — FK constraint). + try { + await validateEncryptAndStoreCredential( + localUser.id, + fastmailEmail, + appPassword, + 'caldav', + ); + } catch (err) { + // Roll back the local user insert on credential failure to avoid orphaned rows + await db.delete(users).where(eq(users.id, localUser.id)); + + if (err instanceof CredentialValidationError) { + // T-12-05: map validation failure to generic 400 — no echo of password + return c.json({ error: 'Invalid request' }, 400); + } + // Unexpected errors (DB failure, network, etc.) — log message only, no credential data + console.error( + '[setup/POST /credential] Unexpected error:', + err instanceof Error ? err.message : String(err), + ); + return c.json({ error: 'Service unavailable' }, 503); + } + + return c.json({ ok: true }, 200); +}); + +// --------------------------------------------------------------------------- +// POST /api/setup/complete +// +// Marks setup as complete by setting app_config.setup_complete='true'. +// The guard re-evaluates on the NEXT call — second call returns 423 (Pitfall 8). +// Returns 200 { ok: true } on the first (successful) call. +// --------------------------------------------------------------------------- + +setupRouter.post('/complete', async (c) => { + const locked = await isSetupLocked(); + if (locked) return c.json({ error: 'Setup already complete' }, 423); + + await db + .insert(appConfig) + .values({ key: 'setup_complete', value: 'true' }) + .onDuplicateKeyUpdate({ set: { value: 'true' } }); + + return c.json({ ok: true }, 200); +});