/** * POST /_emdash/api/setup/admin * * Step 3 of setup: Start admin creation by returning passkey registration options */ import type { APIRoute } from "astro"; export const prerender = false; import { generateToken } from "@emdash-cms/auth"; import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely"; import { generateRegistrationOptions } from "@emdash-cms/auth/passkey"; import { apiError, apiSuccess, handleError } from "#api/error.js"; import { isParseError, parseBody } from "#api/parse.js"; import { getPublicOrigin } from "#api/public-url.js"; import { setupAdminBody } from "#api/schemas.js"; import { createChallengeStore } from "#auth/challenge-store.js"; import { getPasskeyConfig } from "#auth/passkey-config.js"; import { SETUP_NONCE_COOKIE, SETUP_NONCE_MAX_AGE_SECONDS } from "#auth/setup-nonce.js"; import { OptionsRepository } from "#db/repositories/options.js"; export const POST: APIRoute = async ({ cookies, request, locals }) => { const { emdash } = locals; if (!emdash?.db) { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); } try { // Check if setup is already complete const options = new OptionsRepository(emdash.db); const setupComplete = await options.get("emdash:setup_complete"); if (setupComplete === true || setupComplete === "true") { return apiError("SETUP_COMPLETE", "Setup already complete", 400); } // Check if any users exist const adapter = createKyselyAdapter(emdash.db); const userCount = await adapter.countUsers(); if (userCount > 0) { return apiError("ADMIN_EXISTS", "Admin user already exists", 400); } // Parse request body const body = await parseBody(request, setupAdminBody); if (isParseError(body)) return body; // Preserve title/tagline from step 1 by reading existing setup state // before we overwrite it below. const existingState = await options.get>("emdash:setup_state"); // Mint a fresh session nonce. This binds the follow-up // /setup/admin/verify call to the same browser that made this // request, so an unauthenticated attacker on another host cannot // substitute their own email into the setup state during the // setup window. Rotates on every call so a legitimate retry // always gets a working session. const nonce = generateToken(); // Get passkey config const url = new URL(request.url); const siteName = (await options.get("emdash:site_title")) ?? undefined; const siteUrl = getPublicOrigin(url, emdash?.config); const passkeyConfig = getPasskeyConfig(url, siteName, siteUrl); // Generate registration options const challengeStore = createChallengeStore(emdash.db); // Create a temporary user object for registration options // (not persisted until passkey is verified) const tempUser = { id: `setup-${Date.now()}`, // Temporary ID email: body.email.toLowerCase(), name: body.name || null, }; const registrationOptions = await generateRegistrationOptions( passkeyConfig, tempUser, [], // No existing credentials challengeStore, ); // Store the nonce alongside the rest of the setup state, preserving // title/tagline from step 1. The verify endpoint will constant-time // compare the nonce with the incoming cookie. await options.set("emdash:setup_state", { ...existingState, step: "admin", email: body.email.toLowerCase(), name: body.name || null, tempUserId: tempUser.id, nonce, }); // HttpOnly + SameSite=Strict + path-scoped. The cookie must not be // accessible to JS (nothing in the admin UI needs to read it) and // must not be sent on cross-site navigations. The /_emdash/ path // scope keeps it away from user-authored frontend code. // // Derive `secure` from the public origin, not the internal request // URL. Behind a TLS-terminating reverse proxy the internal hop is // often `http:` while the browser-facing origin is `https:` — // using `url.protocol` there would drop the Secure flag on a // sensitive cookie over the public HTTPS connection. const publicOrigin = new URL(siteUrl); cookies.set(SETUP_NONCE_COOKIE, nonce, { path: "/_emdash/", httpOnly: true, sameSite: "strict", secure: publicOrigin.protocol === "https:", maxAge: SETUP_NONCE_MAX_AGE_SECONDS, }); return apiSuccess({ success: true, options: registrationOptions, }); } catch (error) { return handleError(error, "Failed to create admin", "SETUP_ADMIN_ERROR"); } };