import type { EkklesiaUserId, GrantId, PermissionId } from "@ekklesia/contracts"; import { createUserActor, isAuthenticatedPrincipal, safeParseEkklesiaUserId, safeParseGrantId, safeParsePermissionId, } from "@ekklesia/contracts"; import { AUTHORIZATION_GRANT_LIST_MAX_LIMIT, type AuthorizationStore, AuthorizationStoreError, CORE_AUTHORIZATION_GRANTS_MANAGE, type GrantPageBoundary, type GrantRecord, } from "@ekklesia/core/authorization"; import { Hono } from "hono"; import type { Context } from "hono"; import { type AuthorizationMiddlewareOptions, getAuthorizedPrincipal, requirePermission, requireTrustedOrigin, } from "./authorization-middleware.js"; import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js"; export interface CreateGrantsRoutesOptions extends AuthorizationMiddlewareOptions { readonly store: AuthorizationStore; readonly trustedOrigins: ReadonlySet; /** Injected only so expiration validation is deterministic under test. Production composition * leaves this unset and the routes read real time. */ readonly now?: () => Date; } const DEFAULT_LIST_LIMIT = 50; function sanitizedStoreErrorResponse(c: Context, error: unknown): Response { c.header("Cache-Control", "no-store"); if (error instanceof AuthorizationStoreError) { if (error.code === "AUTHORIZATION_GRANT_NOT_FOUND") { return c.json({ code: error.code }, 404); } // The one conflict the caller can act on. It deliberately reveals nothing about the grant that // already exists — not its id, actor, scope, source reference, or expiry — only that an // equivalent authorization is already effective for this subject. if (error.code === "AUTHORIZATION_GRANT_ALREADY_EXISTS") { return c.json({ code: error.code }, 409); } // Defense in depth: the routes below reject both of these before ever reaching the store, so // arriving here means a request-shaped problem slipped through. It is still a client input // failure, never an infrastructure one, and it reports exactly like any other malformed // request rather than leaking the store's internal distinction. if ( error.code === "AUTHORIZATION_GRANT_EXPIRATION_INVALID" || error.code === "AUTHORIZATION_GRANT_LIMIT_INVALID" ) { return c.json({ code: "INVALID_REQUEST" }, 400); } return c.json({ code: error.code }, 503); } return c.json({ code: "INTERNAL_ERROR" }, 503); } interface ParsedCreateGrantBody { readonly expiresAt?: Date; readonly permission: PermissionId; readonly subjectUserId: EkklesiaUserId; } const CREATE_GRANT_ALLOWED_KEYS = new Set(["subjectUserId", "permission", "expiresAt"]); /** * The one accepted expiration representation: `YYYY-MM-DDTHH:mm:ss.sssZ`, byte-for-byte equal to * the parsed `Date`'s own `toISOString()`. * * `new Date(value)` alone was far too permissive. It silently *normalized* impossible dates * (`2026-02-31T00:00:00.000Z` became March 3rd) and accepted locale-shaped input * (`"March 5, 2027"`), date-only values, offsets, and missing milliseconds — so what an operator * typed and what the platform stored could differ without any error. The regular expression pins * the lexical form and the round-trip comparison rejects every value the parser would otherwise * quietly rewrite. */ const CANONICAL_INSTANT_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u; function parseCanonicalFutureInstant(value: unknown, now: Date): Date | undefined { if (typeof value !== "string" || !CANONICAL_INSTANT_PATTERN.test(value)) { return undefined; } const parsed = new Date(value); if (Number.isNaN(parsed.getTime()) || parsed.toISOString() !== value) { return undefined; } // Strictly future: an expiration equal to or before the acceptance clock produces a grant that // is already dead, which is never what the caller meant. if (parsed.getTime() <= now.getTime()) { return undefined; } return parsed; } /** * Accepts only `subjectUserId`, `permission`, and an optional `expiresAt`. Any other key present — * `actor`, `sourceKind`, `sourceRef`, any audit field, `createdAt`, `revokedAt`, a grant id, or * anything claiming owner/effective-permission state — makes the whole body invalid rather than * being silently dropped, so a caller cannot smuggle server-derived fields through a request that * otherwise looks well-formed. * * **`scope` is deliberately among the rejected keys**, including when supplied as `null`, empty, or * an otherwise valid scope string. Core's scoped-grant semantics remain intact for trusted internal * callers, but no HTTP route middleware supplies a scope to the evaluator today, so a scope * accepted here would produce a grant that looks meaningful, persists, appears in listings, and can * authorize nothing at all. Accepting it would be offering a switch that is not connected to * anything. When a server-owned resolver exists, this is the one place that changes. */ function parseCreateGrantBody(raw: unknown, now: Date): ParsedCreateGrantBody | undefined { if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { return undefined; } const body = raw as Record; if (Object.keys(body).some((key) => !CREATE_GRANT_ALLOWED_KEYS.has(key))) { return undefined; } const subjectUserId = safeParseEkklesiaUserId(body.subjectUserId); if (!subjectUserId.success) { return undefined; } const permission = safeParsePermissionId(body.permission); if (!permission.success) { return undefined; } let expiresAt: Date | undefined; if (body.expiresAt !== undefined) { expiresAt = parseCanonicalFutureInstant(body.expiresAt, now); if (expiresAt === undefined) { return undefined; } } return Object.freeze({ ...(expiresAt === undefined ? {} : { expiresAt }), permission: permission.data, subjectUserId: subjectUserId.data, }); } const CURSOR_VERSION = 1; const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/u; /** * An opaque pagination *position*, not an authorization token. It carries only the subject user id, * the boundary row's creation instant, and the boundary grant id — never a permission, scope, * source reference, actor, credential, or secret — so there is nothing in it worth protecting. * * It is deliberately unsigned. Signing would imply the cursor carries authority it does not have: * the permission check and the server-side subject filter remain authoritative on every page, and * a forged cursor can at most name a different position within grants the caller is already * entitled to list. The strict decode below still refuses anything that is not exactly what this * function would emit. */ function encodeCursor(userId: EkklesiaUserId, boundary: GrantPageBoundary): string { return Buffer.from( JSON.stringify({ g: boundary.id, t: boundary.createdAt.toISOString(), u: userId, v: CURSOR_VERSION, }), "utf8", ).toString("base64url"); } /** * Strict in every dimension: canonical unpadded base64url, valid UTF-8 JSON, an object with exactly * the four expected keys, the exact version, a subject matching the *requested* one, a canonical * `toISOString()` timestamp, a canonical `GrantId`, and — the catch-all — a re-encoding that * reproduces the supplied string byte-for-byte, which rejects padding, trailing data, and any * alternative encoding of the same payload. A rejected cursor reveals nothing it decoded. */ function decodeCursor(raw: string, userId: EkklesiaUserId): GrantPageBoundary | undefined { if (!BASE64URL_PATTERN.test(raw)) { return undefined; } let parsed: unknown; try { parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8")); } catch { return undefined; } if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { return undefined; } const payload = parsed as Record; if (Object.keys(payload).sort().join(",") !== "g,t,u,v") { return undefined; } if (payload.v !== CURSOR_VERSION || payload.u !== userId || typeof payload.t !== "string") { return undefined; } const createdAt = new Date(payload.t); if (Number.isNaN(createdAt.getTime()) || createdAt.toISOString() !== payload.t) { return undefined; } const id = safeParseGrantId(payload.g); if (!id.success) { return undefined; } const boundary: GrantPageBoundary = Object.freeze({ createdAt, id: id.data }); if (encodeCursor(userId, boundary) !== raw) { return undefined; } return boundary; } const LIST_ALLOWED_QUERY_KEYS = new Set(["userId", "limit", "cursor"]); /** Canonical base-10, no sign, no leading zero, no decimal point, no whitespace, no exponent. */ const CANONICAL_POSITIVE_INTEGER_PATTERN = /^[1-9][0-9]*$/u; interface ParsedListQuery { readonly after?: GrantPageBoundary; readonly limit: number; readonly subjectUserId: EkklesiaUserId; } /** Fully validated before any store or database work happens, so an invalid page request never * becomes a query, and never becomes a way to probe which subjects or grants exist. */ function parseListQuery(url: string): ParsedListQuery | undefined { const searchParams = new URL(url).searchParams; for (const key of searchParams.keys()) { if (!LIST_ALLOWED_QUERY_KEYS.has(key) || searchParams.getAll(key).length > 1) { return undefined; } } const subjectUserId = safeParseEkklesiaUserId(searchParams.get("userId")); if (!subjectUserId.success) { return undefined; } let limit = DEFAULT_LIST_LIMIT; const rawLimit = searchParams.get("limit"); if (rawLimit !== null) { if (!CANONICAL_POSITIVE_INTEGER_PATTERN.test(rawLimit)) { return undefined; } limit = Number(rawLimit); if (!Number.isSafeInteger(limit) || limit < 1 || limit > AUTHORIZATION_GRANT_LIST_MAX_LIMIT) { return undefined; } } let after: GrantPageBoundary | undefined; const rawCursor = searchParams.get("cursor"); if (rawCursor !== null) { after = decodeCursor(rawCursor, subjectUserId.data); if (after === undefined) { return undefined; } } return Object.freeze({ ...(after === undefined ? {} : { after }), limit, subjectUserId: subjectUserId.data, }); } function serializeGrant(grant: GrantRecord) { return { createdAt: grant.createdAt.toISOString(), expiresAt: grant.expiresAt ? grant.expiresAt.toISOString() : null, id: grant.id, permission: grant.permission, revokedAt: grant.revokedAt ? grant.revokedAt.toISOString() : null, scope: grant.scope ?? null, sourceKind: grant.sourceKind, userId: grant.userId, }; } /** * The v1 explicit-grant management API: list, create, and revoke *explicit* grants only. Every * route requires `core.authorization.grants.manage`; a platform owner passes through the * evaluator's existing owner bypass without ever needing an explicit grant of this permission * themselves — that bypass is exactly how a platform owner deliberately delegates grant * administration to another account by granting them this one permission. * * Relationship-derived grants are never reachable through this API in either direction: creation * always hard-codes `sourceKind: "explicit"` server-side (the request body cannot supply a * `sourceKind` at all), and revocation is constrained to `sourceKind: "explicit"`, so attempting * to revoke a relationship-derived grant by id behaves exactly like the grant does not exist — * those grants remain owned by the module operation that established the relationship. * * Creation is unscoped-only; see `parseCreateGrantBody`. */ export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono { const routes = new Hono(); const now = options.now ?? ((): Date => new Date()); const requireManagePermission = requirePermission(options, CORE_AUTHORIZATION_GRANTS_MANAGE); const requireOrigin = requireTrustedOrigin(options.trustedOrigins); // Origin is checked first (a rejected cross-origin mutation must never have its body read), then // the body-size limit runs before permission evaluation, so an oversized mutation never reaches // authorization work or the store. const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES); routes.get("/api/v1/grants", requireManagePermission, async (c) => { c.header("Cache-Control", "no-store"); // Reached only after `requireManagePermission` allowed the caller, so query validation can // never disclose a validation distinction — or the existence of any subject or grant — to an // unauthorized caller. const query = parseListQuery(c.req.url); if (!query) { return c.json({ code: "INVALID_REQUEST" }, 400); } try { const page = await options.store.listGrantsForUser(query.subjectUserId, { ...(query.after === undefined ? {} : { after: query.after }), limit: query.limit, sourceKind: "explicit", }); return c.json( { grants: page.grants.map(serializeGrant), nextCursor: page.nextBoundary === undefined ? null : encodeCursor(query.subjectUserId, page.nextBoundary), }, 200, ); } catch (error) { return sanitizedStoreErrorResponse(c, error); } }); routes.post( "/api/v1/grants", requireOrigin, mutationBodyLimit, requireManagePermission, async (c) => { c.header("Cache-Control", "no-store"); const principal = getAuthorizedPrincipal(c); if (!isAuthenticatedPrincipal(principal)) { return c.json({ code: "AUTHORIZATION_DENIED" }, 403); } let rawBody: unknown; try { rawBody = await c.req.json(); } catch { return c.json({ code: "INVALID_REQUEST" }, 400); } const parsed = parseCreateGrantBody(rawBody, now()); if (!parsed) { return c.json({ code: "INVALID_REQUEST" }, 400); } // A well-formed PermissionId is not necessarily a *registered* one. Only the trusted registry // (never the request) decides what may be granted — the same registry `requireManagePermission` // already consults for the caller's own `core.authorization.grants.manage` check — so a caller // cannot pre-create a dormant grant for an invented identifier that later becomes effective the // moment someone registers it. Sanitized identically to any other denial: this must never reveal // whether the identifier was malformed (400, above) or simply unregistered (403, here). if (!options.registry.has(parsed.permission)) { return c.json({ code: "AUTHORIZATION_DENIED" }, 403); } try { const grant = await options.store.createGrant({ actor: createUserActor(principal.userId), ...(parsed.expiresAt === undefined ? {} : { expiresAt: parsed.expiresAt }), permission: parsed.permission, sourceKind: "explicit", userId: parsed.subjectUserId, }); return c.json({ grant: serializeGrant(grant) }, 201); } catch (error) { return sanitizedStoreErrorResponse(c, error); } }, ); routes.post( "/api/v1/grants/:grantId/revoke", requireOrigin, mutationBodyLimit, requireManagePermission, async (c) => { c.header("Cache-Control", "no-store"); const principal = getAuthorizedPrincipal(c); if (!isAuthenticatedPrincipal(principal)) { return c.json({ code: "AUTHORIZATION_DENIED" }, 403); } // Canonical-UUID validation happens after authorization but strictly before any store or // database call: a malformed id previously reached PostgreSQL's own UUID comparison and came // back as an indistinguishable infrastructure failure (503) rather than a request problem. const grantId = safeParseGrantId(c.req.param("grantId")); if (!grantId.success) { return c.json({ code: "INVALID_REQUEST" }, 400); } try { await options.store.revokeGrant({ actor: createUserActor(principal.userId), grantId: grantId.data satisfies GrantId, sourceKind: "explicit", }); return c.json({ success: true }, 200); } catch (error) { return sanitizedStoreErrorResponse(c, error); } }, ); return routes; }