import { isAuthenticatedPrincipal } from "@ekklesia/contracts"; import { type AccountPreferences, type AccountStore, AccountStoreError, parseAccountPreferencesUpdate, } from "@ekklesia/core/account"; import { CORE_ACCOUNT_SELF_MANAGE } from "@ekklesia/core/authorization"; import { type Context, Hono } from "hono"; import { type AuthorizationMiddlewareOptions, getAuthorizedPrincipal, requirePermission, requireTrustedOrigin, } from "./authorization-middleware.js"; import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js"; export const ACCOUNT_PATH = "/api/v1/account"; export const ACCOUNT_PREFERENCES_PATH = `${ACCOUNT_PATH}/preferences`; /** `GET /api/v1/account`: the signed-in person's own account, as the account area shows it. */ export interface AccountResponse { readonly name: string; readonly email: string; readonly preferences: AccountPreferences; } export interface CreateAccountRoutesOptions extends AuthorizationMiddlewareOptions { readonly store: AccountStore; readonly trustedOrigins: ReadonlySet; } function sanitizedFailure(c: Context, status: 400 | 403 | 404 | 503, code: string): Response { c.header("Cache-Control", "no-store"); return c.json({ code }, status); } /** Maps the store's own sanitized codes onto HTTP without inventing a second vocabulary. */ function storeFailure( c: Context, error: unknown, fallback: "ACCOUNT_READ_FAILED" | "ACCOUNT_WRITE_FAILED", ): Response { if (error instanceof AccountStoreError) { if (error.code === "ACCOUNT_NOT_FOUND") { return sanitizedFailure(c, 404, error.code); } if (error.code === "ACCOUNT_PREFERENCES_INVALID") { return sanitizedFailure(c, 400, "INVALID_REQUEST"); } return sanitizedFailure(c, 503, error.code); } return sanitizedFailure(c, 503, fallback); } async function readJsonBody(c: Context): Promise { try { return (await c.req.json()) as unknown; } catch { return undefined; } } /** * A signed-in person's own account (ADR 0062): who they are signed in as, and their own * preferences. * * ## Self-only by construction * * Neither route accepts a user id — not in the path, not in the query, not in the body. The account * is always the one the **session** names, so there is no request that could read or change anyone * else's, whatever permission the caller holds. A body that names any key besides a known * preference is refused whole. * * ## Authorization * * Both routes require `core.account.self.manage`, whose baseline is `authenticated`: every * signed-in person may manage their own account, and needs no church-management permission to do * so. An anonymous caller and a paired device are refused (`403`). Changing a preference is a * mutation like any other Core mutation: an exact trusted `Origin` and a bounded body are checked * before authorization work begins. * * ## What the browser learns * * Name, email, and preferences — exactly what the account area shows, and nothing else. Not the * account's image URL, verification state, timestamps, or any credential. Responses are per-person * and never cached. */ export function createAccountRoutes(options: CreateAccountRoutesOptions): Hono { const routes = new Hono(); const requireSelf = requirePermission(options, CORE_ACCOUNT_SELF_MANAGE); const requireOrigin = requireTrustedOrigin(options.trustedOrigins); const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES); routes.get(ACCOUNT_PATH, requireSelf, async (c) => { c.header("Cache-Control", "no-store"); c.header("Vary", "Origin, Cookie"); const principal = getAuthorizedPrincipal(c); // Unreachable through the `authenticated` baseline, which only a person satisfies; checked // again so the account read can never be keyed by anything but a person's own session. if (!isAuthenticatedPrincipal(principal)) { return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED"); } try { const account = await options.store.readAccount(principal.userId); if (!account) { return sanitizedFailure(c, 404, "ACCOUNT_NOT_FOUND"); } return c.json( { email: account.email, name: account.name, preferences: { appearance: account.preferences.appearance, motion: account.preferences.motion, }, } satisfies AccountResponse, 200, ); } catch (error) { return storeFailure(c, error, "ACCOUNT_READ_FAILED"); } }); routes.patch( ACCOUNT_PREFERENCES_PATH, requireOrigin, mutationBodyLimit, requireSelf, async (c) => { c.header("Cache-Control", "no-store"); c.header("Vary", "Origin, Cookie"); const principal = getAuthorizedPrincipal(c); if (!isAuthenticatedPrincipal(principal)) { return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED"); } const update = parseAccountPreferencesUpdate(await readJsonBody(c)); if (!update) { return sanitizedFailure(c, 400, "INVALID_REQUEST"); } try { const preferences = await options.store.updatePreferences(principal.userId, update); return c.json( { appearance: preferences.appearance, motion: preferences.motion, } satisfies AccountPreferences, 200, ); } catch (error) { return storeFailure(c, error, "ACCOUNT_WRITE_FAILED"); } }, ); return routes; }