import { NextRequest, NextResponse } from 'next/server'; import { writeFile } from 'node:fs/promises'; import { detectSetupState } from '@/lib/setup/state'; import { authenticateWizardRequest, SETUP_COOKIE } from '@/lib/setup/session'; import { configManager } from '@/lib/admin/config-manager'; import { setInitialAdminPassword } from '@/lib/admin/password'; import { clearSetupToken } from '@/lib/setup/token'; import { ensureConfigDir, getConfigPath } from '@/lib/admin/paths'; import { auditLog } from '@/lib/admin/audit'; import { logger } from '@/lib/logger'; export const dynamic = 'force-dynamic'; /** * POST /api/setup/finish * * Final wizard step. Validates that required config is in place, hashes the * admin password, marks setup complete, deletes the setup token (which * invalidates the wizard cookie), and optionally drops a `.config-locked` * marker so the operator remembers they intended to mount :ro. * * Body: { adminPassword: string, lockConfig?: boolean } */ export async function POST(request: NextRequest) { if (detectSetupState() !== 'bootstrap') { return NextResponse.json({ error: 'Setup is not active' }, { status: 404 }); } if (!(await authenticateWizardRequest())) { return NextResponse.json({ error: 'Wizard session required' }, { status: 401 }); } let body: { adminPassword?: unknown; lockConfig?: unknown }; try { body = await request.json(); } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); } const adminPassword = typeof body?.adminPassword === 'string' ? body.adminPassword : ''; if (adminPassword.length < 8) { return NextResponse.json( { error: 'Admin password must be at least 8 characters' }, { status: 400 }, ); } const lockConfig = body?.lockConfig === true; // Validate required config is present. await configManager.ensureLoaded(); const jmapUrl = configManager.get('jmapServerUrl', ''); if (!jmapUrl || typeof jmapUrl !== 'string') { return NextResponse.json( { error: 'JMAP server URL is required (run the Server step first)' }, { status: 400 }, ); } try { // 1. Provision the admin account. Aborts cleanly if one already exists // (defence in depth - should be impossible in bootstrap state). const created = await setInitialAdminPassword(adminPassword); if (!created) { return NextResponse.json( { error: 'Admin account already exists; cannot finish setup again' }, { status: 409 }, ); } // 2. Persist setupComplete flag. After this, detectSetupState() flips // to 'configured' and middleware starts 404'ing /setup paths. await configManager.markSetupComplete(); // 3. Optional advisory lock marker. if (lockConfig) { await ensureConfigDir(); await writeFile( getConfigPath('.config-locked'), new Date().toISOString(), 'utf-8', ); } // 4. Destroy the setup token. Any other browser holding the cookie is // now unauthenticated. await clearSetupToken(); await auditLog( 'setup.finish', { lockConfig, jmapServerUrl: jmapUrl }, request.headers.get('x-forwarded-for') ?? 'unknown', ); const response = NextResponse.json({ ok: true, lockConfig }); response.cookies.delete(SETUP_COOKIE); return response; } catch (error) { logger.error('Wizard finish failed', { error: error instanceof Error ? error.message : 'Unknown error', }); return NextResponse.json( { error: 'Failed to finish setup', detail: error instanceof Error ? error.message : 'Unknown' }, { status: 500 }, ); } }