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