import { randomBytes, timingSafeEqual } from 'node:crypto'; import { readFile, writeFile, unlink, stat } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { logger } from '@/lib/logger'; import { ensureStateDir, getStatePath } from '@/lib/admin/paths'; const TOKEN_FILE = '.setup-token'; const TOKEN_BYTES = 32; const DEFAULT_TTL_SECONDS = 60 * 60; // 1 hour interface TokenPayload { token: string; issuedAt: number; ttlSeconds: number; } /** * Read the current token if one exists and hasn't expired. Stale tokens * are deleted lazily - first stale read removes the file. */ async function readToken(): Promise { const path = getStatePath(TOKEN_FILE); if (!existsSync(path)) return null; try { const raw = await readFile(path, 'utf-8'); const payload = JSON.parse(raw) as TokenPayload; if (Date.now() / 1000 - payload.issuedAt > payload.ttlSeconds) { try { await unlink(path); } catch { /* ok */ } return null; } return payload; } catch (error) { logger.warn('Failed to read setup token', { error: error instanceof Error ? error.message : 'Unknown error', }); return null; } } /** * Generate (or refresh) the setup token. Called at startup when the app * detects bootstrap state. Idempotent: returns the existing token if it's * still valid, otherwise issues a fresh one. * * The token lands in a file in ADMIN_STATE_DIR (always writable, never * read-only) and is also printed to the container logs so the operator * can copy it without execing into the container. */ export async function ensureSetupToken(ttlSeconds: number = DEFAULT_TTL_SECONDS): Promise { const existing = await readToken(); if (existing) return existing.token; await ensureStateDir(); const token = randomBytes(TOKEN_BYTES).toString('hex'); const payload: TokenPayload = { token, issuedAt: Math.floor(Date.now() / 1000), ttlSeconds, }; const path = getStatePath(TOKEN_FILE); await writeFile(path, JSON.stringify(payload, null, 2), 'utf-8'); return token; } /** * Verify a token submitted by the wizard. Constant-time comparison; never * leak the stored token via timing. */ export async function verifySetupToken(submitted: string): Promise { if (!submitted || typeof submitted !== 'string') return false; const stored = await readToken(); if (!stored) return false; const a = Buffer.from(submitted); const b = Buffer.from(stored.token); if (a.length !== b.length) return false; return timingSafeEqual(a, b); } /** * Delete the token file. Called by the wizard's finish endpoint after * setupComplete=true is persisted. */ export async function clearSetupToken(): Promise { const path = getStatePath(TOKEN_FILE); try { await unlink(path); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; logger.warn('Failed to clear setup token', { error: error instanceof Error ? error.message : 'Unknown error', }); } } /** * For diagnostics / startup logging. */ export async function getTokenInfo(): Promise<{ exists: boolean; expiresInSeconds: number | null }> { const path = getStatePath(TOKEN_FILE); if (!existsSync(path)) return { exists: false, expiresInSeconds: null }; try { await stat(path); const payload = await readToken(); if (!payload) return { exists: false, expiresInSeconds: null }; const elapsed = Date.now() / 1000 - payload.issuedAt; return { exists: true, expiresInSeconds: Math.max(0, Math.floor(payload.ttlSeconds - elapsed)) }; } catch { return { exists: false, expiresInSeconds: null }; } }