Files
familysync/apps/api/src/routes/setup.ts
T
Lucas BergerandClaude Opus 4.8 a193bc8236
CI / changes (pull_request) Successful in 3s
CI / fast-checks (pull_request) Successful in 2m16s
CI / api (pull_request) Failing after 1m37s
CI / harness (pull_request) Failing after 1h3m45s
CI / security (pull_request) Failing after 11s
CI / gate (pull_request) Failing after 1s
fix(12): satisfy CI fast-checks — lint unused vars, typed contract-test body, prettier
- Remove unused 'res'/'container' assignments (no-unused-vars)
- setupClient.contract.test.ts: typed parseSentBody helper + non-async json mock
  (no-unsafe-*/require-await)
- Prettier format 7 setup files

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:53:14 -04:00

426 lines
18 KiB
TypeScript

/**
* 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);
});