- status returns { setupComplete, dbName } from process.env.DB_NAME (null fallback)
- only the DB name; never DB_HOST/DB_USER/DB_PASSWORD
- SetupStatusResponse carries dbName?: string | null for the PWA read-only field
415 lines
18 KiB
TypeScript
415 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);
|
|
});
|