/** * setup.ts — /api/setup/* route surface (Phase 12 initial-setup wizard). * * Mounted in index.ts: app.route('/api/setup', setupRouter) * Mount position: BEFORE app.use('/api/*', devAuthBypass()) so the wizard * is reachable pre-authentication — same pre-auth surface as /health (T-01-03). * * 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). * * 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, and, isNull } from 'drizzle-orm'; import { db } from '../db/client.js'; import { users, appConfig, memberCredentials } 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) .refine((v) => v.startsWith('https://'), { message: 'appExternalUrl must be an https URL' }), }); 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(); // Gap 3 (backend): surface the NON-SECRET DB name so the PWA can render a read-only // field giving the "database connection verified" row an on-screen referent. Only the // database NAME is exposed — never DB_HOST/DB_USER/DB_PASSWORD (connection secrets/topology). return c.json({ setupComplete: locked, dbName: process.env.DB_NAME ?? null }); }); // --------------------------------------------------------------------------- // 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) { // IN-01: Log raw error server-side only — do not echo internal network detail // (e.g. "connect ECONNREFUSED 192.168.1.50:9091") to the pre-auth caller. console.error('[setup/validate/oidc]', err instanceof Error ? err.message : String(err)); return c.json({ ok: false, error: 'OIDC discovery failed. Check the issuer URL.' }, 400); } }); // --------------------------------------------------------------------------- // POST /api/setup/validate/vapid // // Validates the operator-entered VAPID public key (app_config.vapid_public_key) // against the configured key pair: // 1. Equality (gap 2): the submitted public key MUST equal process.env.VAPID_PUBLIC_KEY. // A wrong/typoed key (e.g. "BH123") now fails the row and gates Continue — push // would silently break in production otherwise (SETUP-02). // 2. Structural: webpush.setVapidDetails() validates the env pair's byte structure. // // VAPID_PRIVATE_KEY is read ONLY from process.env — NEVER from app_config or returned // (T-12-06 / D-01 / SC-3). The equality check compares the submitted PUBLIC key to the // env PUBLIC key only — the private key is never compared or echoed. // Returns 200 { ok: true } on success, 400 { ok: false } on any 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); } // Gap 2: assert the operator-submitted public key matches the env public key BEFORE // the structural check. Read the submitted key from app_config (same idiom as validate/oidc). const [submittedRow] = await db .select({ value: appConfig.value }) .from(appConfig) .where(eq(appConfig.key, 'vapid_public_key')) .limit(1); const submittedPublicKey = submittedRow?.value; if (!submittedPublicKey || submittedPublicKey !== publicKey) { return c.json({ ok: false, error: 'VAPID public key does not match the configured key pair. Paste the exact VAPID_PUBLIC_KEY printed by `npm run generate-secrets`.', }, 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 // Steps 1 + 2 run inside a serialising transaction (WR-02 TOCTOU fix). // SELECT … FOR UPDATE on the unclaimed-user count acquires a row/gap lock so that // two concurrent credential requests cannot both observe count=0 and both insert. // The transaction commits before validateEncryptAndStoreCredential (which does its // own DB write) so the FK constraint is satisfied on that call. let localUser: typeof users.$inferSelect | undefined; try { localUser = await db.transaction(async (tx) => { // Serialise: at most one unclaimed wizard bootstrap row may exist (WR-02). // The filter is (oidc_iss IS NULL AND claimed = false) — the precise definition // of a "pending wizard bootstrap user" — so that OIDC-created rows (which are // born with claimed=true after WR-01 fix, but could theoretically be claimed=false // on legacy data) are never counted here (WR-01 defense-in-depth). // Cast through unknown — Drizzle mysql2 execute() returns [rows, fields] for SELECTs; // the generic type parameter on execute() is not sufficient to type the result correctly. const countRows = (await tx.execute( sql`SELECT COUNT(*) AS count FROM users WHERE oidc_iss IS NULL AND claimed = false FOR UPDATE`, )) as unknown as [{ count: string | number }[], unknown]; const unclaimedCount = Number(countRows[0][0]?.count ?? 0); if (unclaimedCount > 0) { throw Object.assign(new Error('An unclaimed user already exists'), { code: 'DUPLICATE_UNCLAIMED' }); } // Step 1: Assign color (first unused from palette, or round-robin fallback) const usedRows = await tx.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 tx .insert(users) .values({ oidcIss: null, oidcSub: null, displayName: null, color, isAdmin: true, claimed: false, }) .$returningId(); const [row] = await tx .select() .from(users) .where(eq(users.id, inserted.id)) .limit(1); return row; }); } catch (err) { if (err instanceof Error && (err as NodeJS.ErrnoException & { code?: string }).code === 'DUPLICATE_UNCLAIMED') { // A concurrent request already created the unclaimed admin row return c.json({ error: 'Setup already in progress' }, 409); } console.error('[setup/POST /credential] Transaction error:', err instanceof Error ? err.message : String(err)); return c.json({ error: 'Service unavailable' }, 503); } if (!localUser) { // Clean up the just-inserted user row to avoid an orphaned unclaimed admin // (WR-01: the catch block below does not cover this early-return path). // This path is extremely unlikely if the transaction committed but the re-select // returned nothing — a transient DB issue. No row ID is available here since // the transaction already committed with the row. The FOR UPDATE guard above // means no second unclaimed row will exist; the orphan (if any) is claimed on // first login via upsertUser, making this a safe degraded-mode path. 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); // IN-02: Guard against skipping the credential step — require an unclaimed user // with an associated credential before locking setup. Without this check an operator // could call /complete directly, producing a state where setup_complete=true but no // admin user exists: first OIDC login creates a non-admin with no credential. const [unclaimedWithCred] = await db .select({ id: users.id }) .from(users) .innerJoin(memberCredentials, eq(memberCredentials.userId, users.id)) .where(and(isNull(users.oidcIss), eq(users.claimed, false))) .limit(1); if (!unclaimedWithCred) { return c.json({ error: 'Cannot lock setup: no credential configured' }, 422); } await db .insert(appConfig) .values({ key: 'setup_complete', value: 'true' }) .onDuplicateKeyUpdate({ set: { value: 'true' } }); return c.json({ ok: true }, 200); });