/** * The public frontend service account. * * The static frontend (the platform's container builds, pull-request previews * and a developer's machine) reads the site's content snapshot with an API * token — the same policy-scoped tokens everything else uses — instead of a * bespoke HMAC secret. The token belongs to a system user nobody signs in as: * its address is unroutable (`.invalid`), it holds the `frontend` role, and that * role's only policy grants reading content + schema and the snapshot route. * Everything is idempotent and created on first boot; admins read (or rotate) * the token under Settings. */ import type { Kysely } from "kysely"; import { ulid } from "ulidx"; import { handleApiTokenCreate } from "../api/handlers/api-tokens.js"; import { OptionsRepository } from "../database/repositories/options.js"; import type { Database } from "../database/types.js"; export const FRONTEND_ROLE_SLUG = "frontend"; export const FRONTEND_ROLE_ID = "role:frontend"; export const FRONTEND_POLICY_SLUG = "frontend"; export const FRONTEND_POLICY_ID = "policy:frontend"; export const FRONTEND_USER_EMAIL = "frontend@service.invalid"; /** Options-table key holding the raw token (the one place it can be read back from). */ export const FRONTEND_TOKEN_OPTION_KEY = "emdash:frontend_token"; export const FRONTEND_USER_OPTION_KEY = "emdash:frontend_user"; const TOKEN_NAME = "Frontend builds, previews and local development"; /** * The lowest built-in tier (subscriber). The numeric level has to be one the * auth layer knows (`toRoleLevel` rejects anything else when it loads a user); * it is never what grants anything here — with authz attached, permission * checks use the grants, and those come from the frontend policy alone. */ const FRONTEND_ROLE_LEVEL = 10; /** Route grants are `[METHOD ]/path` relative to `/_emdash/api`. */ const POLICY_RULES = { permissions: ["content:read", "schema:read"], routes: ["GET /snapshot"], }; export interface FrontendAccount { userId: string; token: string; } /** Create the role, policy, user and token if any is missing; return the token. */ export async function ensureFrontendServiceAccount(db: Kysely): Promise { const options = new OptionsRepository(db); const now = new Date().toISOString(); await db .insertInto("_emdash_policies") .values({ id: FRONTEND_POLICY_ID, slug: FRONTEND_POLICY_SLUG, name: "Frontend", description: "Read content and schema, and the content snapshot — what a frontend build needs, nothing else.", builtin: 1, rules: JSON.stringify(POLICY_RULES), created_at: now, updated_at: now, } as never) .onConflict((oc) => oc.column("id").doNothing()) .execute(); await db .insertInto("_emdash_roles") .values({ id: FRONTEND_ROLE_ID, slug: FRONTEND_ROLE_SLUG, name: "Frontend", description: "The public frontend's service account. Not for people.", level: FRONTEND_ROLE_LEVEL, builtin: 1, created_at: now, updated_at: now, } as never) .onConflict((oc) => oc.column("id").doNothing()) .execute(); await db .insertInto("_emdash_role_policies") .values({ role_id: FRONTEND_ROLE_ID, policy_id: FRONTEND_POLICY_ID, sort_order: 0 } as never) .onConflict((oc) => oc.doNothing()) .execute(); // Keep rows created by an earlier build in line with the current shape (cheap, idempotent). await db.updateTable("_emdash_policies").set({ rules: JSON.stringify(POLICY_RULES) } as never).where("id", "=", FRONTEND_POLICY_ID).execute(); await db.updateTable("_emdash_roles").set({ level: FRONTEND_ROLE_LEVEL } as never).where("id", "=", FRONTEND_ROLE_ID).execute(); await db.updateTable("users").set({ role: FRONTEND_ROLE_LEVEL } as never).where("email", "=", FRONTEND_USER_EMAIL).execute(); let userId: string = (await options.get(FRONTEND_USER_OPTION_KEY)) ?? ""; const existingUser = await db.selectFrom("users").select("id").where("email", "=", FRONTEND_USER_EMAIL).executeTakeFirst(); if (existingUser) { userId = existingUser.id; } else { userId = ulid(); await db .insertInto("users") .values({ id: userId, email: FRONTEND_USER_EMAIL, name: "Public frontend", role: FRONTEND_ROLE_LEVEL, role_id: FRONTEND_ROLE_ID, email_verified: 0, disabled: 0, created_at: now, updated_at: now, } as never) .execute(); } await options.set(FRONTEND_USER_OPTION_KEY, userId); // Tokens minted before migration 075 predate the CORS opt-in; the guard // covers a boot where that migration has not run yet. try { await db.updateTable("_emdash_api_tokens").set({ cors: 1 }).where("user_id", "=", userId).execute(); } catch { // column not migrated yet — the next boot repairs it } const token = await options.get(FRONTEND_TOKEN_OPTION_KEY); const tokenRow = await db.selectFrom("_emdash_api_tokens").select("id").where("user_id", "=", userId).executeTakeFirst(); if (typeof token === "string" && token.length > 0 && tokenRow) return { userId, token }; return { userId, token: await mintFrontendToken(db, userId) }; } /** Replace the frontend's token: every build and developer must pick up the new one. */ export async function rotateFrontendToken(db: Kysely): Promise { const { userId } = await ensureFrontendServiceAccount(db); await db.deleteFrom("_emdash_api_tokens").where("user_id", "=", userId).execute(); return { userId, token: await mintFrontendToken(db, userId) }; } async function mintFrontendToken(db: Kysely, userId: string): Promise { const created = await handleApiTokenCreate(db, userId, { name: TOKEN_NAME, policies: [FRONTEND_POLICY_SLUG], cors: true }); if (!created.success) throw new Error(`frontend token: ${created.error.message}`); await new OptionsRepository(db).set(FRONTEND_TOKEN_OPTION_KEY, created.data.token); return created.data.token; }