/** * API token management endpoints * * GET /_emdash/api/admin/api-tokens — List tokens for current user * POST /_emdash/api/admin/api-tokens — Create a new token * * Tokens are minted from policies. A token can only carry policies its * owner's role holds (an admin whose grants are unrestricted may mint any), * and at authentication time its grants are additionally intersected with * the owner's, so a later narrowing of the role narrows every token too. * * Tokens cannot be managed while authenticated *by* a token. A leaked token * must not be able to mint successors or revoke its siblings — that is a * session-only operation. */ import type { APIRoute } from "astro"; import { z } from "zod"; import { requirePerm } from "#api/authorize.js"; import { apiError, handleError, unwrapResult } from "#api/error.js"; import { handleApiTokenCreate, handleApiTokenList } from "#api/handlers/api-tokens.js"; import { isParseError, parseBody } from "#api/parse.js"; import { AuthzRepository } from "#db/repositories/authz.js"; export const prerender = false; const createTokenSchema = z.object({ name: z.string().min(1).max(100), /** Policy slugs the token is minted from — at least one. */ policies: z.array(z.string().min(1)).min(1).max(100), expiresAt: z.string().datetime().optional(), /** Opt the token into CORS: browser code on any origin may attach it. */ cors: z.boolean().optional(), }); /** Refuse token management when the caller is itself a token. */ function rejectTokenAuth(locals: App.Locals): Response | null { if (!locals.tokenAuth) return null; return apiError( "TOKEN_AUTH_FORBIDDEN", "API tokens cannot be created or revoked using an API token. Sign in to the admin to manage tokens.", 403, ); } /** * List API tokens for the current user. */ export const GET: APIRoute = async ({ locals }) => { const { emdash, user } = locals; if (!emdash?.db) { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); } const denied = requirePerm(user, "api_tokens:manage"); if (denied) return denied; const result = await handleApiTokenList(emdash.db, user!.id); return unwrapResult(result); }; /** * Create a new API token. * Returns the raw token once — it cannot be retrieved again. */ export const POST: APIRoute = async ({ request, locals }) => { const { emdash, user, authz } = locals; if (!emdash?.db) { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); } const tokenAuth = rejectTokenAuth(locals); if (tokenAuth) return tokenAuth; const denied = requirePerm(user, "api_tokens:manage"); if (denied) return denied; try { const body = await parseBody(request, createTokenSchema); if (isParseError(body)) return body; { if (!authz) { return apiError("NOT_CONFIGURED", "Authorization was not resolved for this request", 500); } // A token may only carry policies its owner holds, unless the owner // is unrestricted. Reject rather than silently clamp: an operator // should learn that the policy they asked for is not theirs to give. const unrestricted = authz.ownerGrants.permissions.has("*"); const held = new Set(authz.rolePolicies); const notHeld = unrestricted ? [] : body.policies.filter((slug) => !held.has(slug)); if (notHeld.length > 0) { return apiError( "POLICY_NOT_HELD", `Your role does not hold: ${notHeld.join(", ")}. A token cannot carry policies its owner lacks.`, 403, { notHeld }, ); } const repo = new AuthzRepository(emdash.db); const policies = await repo.policiesBySlugs(body.policies); const unknown = body.policies.filter((slug) => !policies.some((p) => p.slug === slug)); if (unknown.length > 0) { return apiError("UNKNOWN_POLICY", `Unknown policy: ${unknown.join(", ")}`, 400, { unknown, }); } const result = await handleApiTokenCreate(emdash.db, user!.id, { name: body.name, policies: body.policies, expiresAt: body.expiresAt, cors: body.cors, }); return unwrapResult(result, 201); } } catch (error) { return handleError(error, "Failed to create API token", "TOKEN_CREATE_ERROR"); } };