/** * POST /_emdash/api/setup * * Executes the setup wizard - applies seed file and marks setup complete */ import type { APIRoute } from "astro"; export const prerender = false; import { apiError, apiSuccess, handleError } from "#api/error.js"; import { isParseError, parseBody } from "#api/parse.js"; import { getPublicOrigin } from "#api/public-url.js"; import { setupBody } from "#api/schemas.js"; import { getAuthMode } from "#auth/mode.js"; import { runMigrations } from "#db/migrations/runner.js"; import { OptionsRepository } from "#db/repositories/options.js"; import { applySeed } from "#seed/apply.js"; import { loadSeed } from "#seed/load.js"; import { validateSeed } from "#seed/validate.js"; export const POST: APIRoute = async ({ request, url, locals }) => { const { emdash } = locals; if (!emdash?.db) { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); } try { // Guard: reject if setup has already been completed. // The options table may not exist on first-ever setup (pre-migration), // so a query failure means setup hasn't run yet — allow it to proceed. try { const options = new OptionsRepository(emdash.db); const setupComplete = await options.get("emdash:setup_complete"); if (setupComplete === true || setupComplete === "true") { return apiError("ALREADY_CONFIGURED", "Setup has already been completed", 409); } } catch { // Options table doesn't exist yet — first-ever setup, allow it } // Parse request body const body = await parseBody(request, setupBody); if (isParseError(body)) return body; // 1. Run core migrations try { await runMigrations(emdash.db); } catch (error) { return handleError(error, "Failed to run database migrations", "MIGRATION_ERROR"); } // 2. Load seed file (user seed or built-in default) const seed = await loadSeed(); // 3. Override seed settings with form values seed.settings = { ...seed.settings, title: body.title, tagline: body.tagline, }; // 4. Apply seed const validation = validateSeed(seed); if (!validation.valid) { return apiError("INVALID_SEED", `Invalid seed file: ${validation.errors.join(", ")}`, 400); } let result; try { result = await applySeed(emdash.db, seed, { includeContent: body.includeContent, onConflict: "skip", storage: emdash.storage ?? undefined, }); } catch (error) { return handleError(error, "Failed to apply seed", "SEED_ERROR"); } // 5. Store setup state // In external auth mode, mark setup complete immediately (first user to login becomes admin) // Otherwise, setup_complete is set after admin user is created (passkey or auth provider) const authMode = getAuthMode(emdash.config); const useExternalAuth = authMode.type === "external"; try { const options = new OptionsRepository(emdash.db); // Store the canonical site URL from the setup request. // Write-once at the DB level so concurrent setup POSTs can't both // observe an empty value and race to write. A spoofed Host header // on a later call during the wizard window must not be able to // replace the first value. const siteUrl = getPublicOrigin(url, emdash.config); await options.setIfAbsent("emdash:site_url", siteUrl); if (useExternalAuth) { // External auth mode: mark setup complete now // First user to log in via external provider will become admin await options.set("emdash:setup_complete", true); await options.set("emdash:site_title", body.title); if (body.tagline) { await options.set("emdash:site_tagline", body.tagline); } } else { // Passkey/provider mode: store state for next step (admin creation) await options.set("emdash:setup_state", { step: "site_complete", title: body.title, tagline: body.tagline, }); } } catch (error) { console.error("Failed to save setup state:", error); // Non-fatal - continue anyway } // 6. Return success with result return apiSuccess({ success: true, // In external auth mode, setup is complete - redirect to admin setupComplete: useExternalAuth, result, }); } catch (error) { return handleError(error, "Setup failed", "SETUP_ERROR"); } };