import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal, type ModuleDatabase, type ModuleRuntimeServices, } from "@ekklesia/contracts"; import { type AuthorizationEvaluator, type AuthorizationStore, CORE_PERMISSION_REGISTRY, type PermissionRegistry, } from "@ekklesia/core/authorization"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import { IdentityRuntimeError } from "@ekklesia/core/identity"; import { createModuleUiPublicationRegistry, EMPTY_MODULE_REALTIME_REGISTRY, type ModuleUiPublicationRegistry, } from "@ekklesia/core/modules"; import { describe, expect, it, vi } from "vitest"; import { type CreateApiAppAuthorizationOptions, type CreateApiAppOptions, createApiApp as createApiAppWithModules, } from "../src/app.js"; import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js"; import { emptyModuleStorage } from "./support/in-memory-module-storage.js"; const trustedOrigin = "https://dashboard.example.test"; const untrustedOrigin = "https://evil.example.test"; function fakeRuntime(overrides: Partial = {}): EkklesiaIdentityRuntime { return { handleAuthRequest: vi.fn().mockResolvedValue(new Response("auth-ok", { status: 200 })), resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL), ...overrides, }; } /** A small typed fake `ModuleRuntimeServices` — these tests exercise `createApiApp`'s own wiring, * never real database behavior, so no PostgreSQL is required. */ function fakeModuleServices(): ModuleRuntimeServices { const database = Object.freeze({ delete: vi.fn(), execute: vi.fn(), insert: vi.fn(), select: vi.fn(), selectDistinct: vi.fn(), selectDistinctOn: vi.fn(), transaction: vi.fn(), update: vi.fn(), }) as unknown as ModuleDatabase; return Object.freeze({ database }); } /** No module publishes UI in these wiring tests, so the publication route is mounted with an * empty registry built by Core's own factory — never a hand-written stub, so the route under test * always sees the real policy object production hands it. */ function emptyModuleUiPublication(): ModuleUiPublicationRegistry { return createModuleUiPublicationRegistry([]); } /** Most application-wiring cases exercise no modules. Keep that fact explicit in one test helper * while production composition still requires the realtime registry with no implicit fallback. */ function createApiApp(options: Omit) { return createApiAppWithModules({ ...options, moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY }); } /** A fully stubbed authorization dependency, denying everything by default — tests that need a * specific decision or store behavior override the relevant method explicitly. The registry * defaults to Core's own (containing `core.authorization.grants.manage`, exactly what the * mounted grants routes require) rather than an app-composed one — these tests exercise * `createApiApp`'s wiring, not permission composition itself. */ function fakeAuthorization(overrides: { readonly evaluator?: Partial; readonly registry?: PermissionRegistry; readonly store?: Partial; }): CreateApiAppAuthorizationOptions { const evaluator: AuthorizationEvaluator = { authorize: vi.fn().mockResolvedValue({ allowed: false }), ...overrides.evaluator, }; const store: AuthorizationStore = { createGrant: vi.fn(), grantPlatformOwner: vi.fn(), hasActiveGrant: vi.fn().mockResolvedValue(false), isPlatformOwner: vi.fn().mockResolvedValue(false), listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }), revokeGrant: vi.fn(), revokePlatformOwner: vi.fn(), ...overrides.store, }; return { evaluator, registry: overrides.registry ?? CORE_PERMISSION_REGISTRY, store }; } function createAuthWiringApp(runtime: EkklesiaIdentityRuntime) { return createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, trustedOrigins: [trustedOrigin], }); } describe("Ekklesia API Hono application", () => { it("mounts Better Auth exactly under /api/auth/*", async () => { const runtime = fakeRuntime(); const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/auth/sign-in/email", { method: "POST" }); expect(response.status).toBe(200); expect(await response.json()).toEqual({ ok: true }); expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1); }); it("replaces only a successful email sign-in body without exposing Better Auth's token or user", async () => { const nativeCookie = "__Secure-better-auth.session_token=opaque-cookie-value; Path=/; HttpOnly; Secure; SameSite=Lax"; const nativeBody = JSON.stringify({ token: "javascript-visible-session-token", user: { email: "owner@example.test", id: "user_123", name: "Owner" }, }); const nativeResponse = new Response(nativeBody, { headers: { "Access-Control-Allow-Origin": trustedOrigin, "Content-Length": "4096", "Content-Type": "application/json", "Set-Cookie": nativeCookie, "X-Native-Auth-Header": "preserved", }, status: 201, statusText: "Signed In", }); const runtime = fakeRuntime({ handleAuthRequest: vi.fn().mockResolvedValue(nativeResponse), }); const app = createAuthWiringApp(runtime); const response = await app.request("/api/auth/sign-in/email", { headers: { origin: trustedOrigin }, method: "POST", }); const visibleBody = await response.text(); expect(response.status).toBe(201); expect(response.statusText).toBe("Signed In"); expect(JSON.parse(visibleBody)).toEqual({ ok: true }); expect(visibleBody).not.toContain("token"); expect(visibleBody).not.toContain("user"); expect(visibleBody).not.toContain("session"); expect(visibleBody).not.toContain("owner@example.test"); expect(response.headers.get("set-cookie")).toBe(nativeCookie); expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin); expect(response.headers.get("access-control-allow-credentials")).toBe("true"); expect(response.headers.get("cache-control")).toBe("no-store"); expect(response.headers.get("content-type")).toBe("application/json; charset=UTF-8"); expect(response.headers.get("content-length")).not.toBe("4096"); expect(response.headers.get("x-native-auth-header")).toBe("preserved"); expect(nativeResponse.bodyUsed).toBe(false); }); it.each([ "/api/auth/%73ign-in/email", "/api/auth/sign-in%2Femail", ])("normalizes the router-equivalent encoded sign-in path %s", async (path) => { const runtime = fakeRuntime({ handleAuthRequest: vi.fn().mockResolvedValue( Response.json({ token: "javascript-visible-session-token", user: { email: "owner@example.test" }, }), ), }); const app = createAuthWiringApp(runtime); const response = await app.request(path, { method: "POST" }); expect(response.status).toBe(200); expect(await response.json()).toEqual({ ok: true }); expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1); }); it("preserves a failed email sign-in response body and status unchanged", async () => { const nativeFailure = JSON.stringify({ code: "INVALID_EMAIL_OR_PASSWORD" }); const runtime = fakeRuntime({ handleAuthRequest: vi.fn().mockResolvedValue( new Response(nativeFailure, { headers: { "Content-Type": "application/json" }, status: 401, statusText: "Unauthorized", }), ), }); const app = createAuthWiringApp(runtime); const response = await app.request("/api/auth/sign-in/email", { method: "POST" }); expect(response.status).toBe(401); expect(response.statusText).toBe("Unauthorized"); expect(await response.text()).toBe(nativeFailure); expect(response.headers.get("cache-control")).toBe("no-store"); }); it.each([ ["a GET to the sign-in path", "GET", "/api/auth/sign-in/email"], ["a sign-out POST", "POST", "/api/auth/sign-out"], ["a trailing-slash sign-in POST", "POST", "/api/auth/sign-in/email/"], ])("leaves %s on Better Auth's native success contract", async (_label, method, path) => { const runtime = fakeRuntime({ handleAuthRequest: vi .fn() .mockResolvedValue(new Response("native-auth-body", { status: 200 })), }); const app = createAuthWiringApp(runtime); const response = await app.request(path, { method }); expect(response.status).toBe(200); expect(await response.text()).toBe("native-auth-body"); expect(response.headers.get("cache-control")).toBe("no-store"); }); describe("unused native session-token endpoints", () => { const disabledRequests = [ ["GET", "/api/auth/get-session"], ["POST", "/api/auth/get-session"], ["GET", "/api/auth/list-sessions"], ["POST", "/api/auth/update-session"], ["POST", "/api/auth/change-password"], ] as const; it.each( disabledRequests, )("returns a fixed 404 for %s %s, including query and trailing-slash spellings, without invoking Better Auth", async (method, path) => { const runtime = fakeRuntime({ handleAuthRequest: vi.fn().mockResolvedValue( Response.json({ session: { token: "javascript-visible-session-token" }, token: "another-session-token", }), ), }); const app = createAuthWiringApp(runtime); for (const requestPath of [path, `${path}?include=session`, `${path}/`]) { const response = await app.request(requestPath, { headers: { origin: trustedOrigin }, method, }); const body = await response.text(); expect(response.status).toBe(404); expect(JSON.parse(body)).toEqual({ code: "NOT_FOUND" }); expect(body).not.toContain("token"); expect(body).not.toContain("session"); expect(response.headers.get("cache-control")).toBe("no-store"); expect(response.headers.get("set-cookie")).toBeNull(); expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin); expect(response.headers.get("access-control-allow-credentials")).toBe("true"); } expect(runtime.handleAuthRequest).not.toHaveBeenCalled(); }); it.each([ ["GET", "/api/auth/%67et-session"], ["GET", "/api/auth/%6Cist-sessions"], ["POST", "/api/auth/%75pdate-session"], ["POST", "/api/auth/change-password%2F"], ["GET", "/api/auth/get-session%2F"], ["GET", "/api/auth%2Fget-session"], ["GET", "/api/auth/get-session%"], ])("does not delegate the encoded or malformed spelling %s %s", async (method, path) => { const runtime = fakeRuntime({ handleAuthRequest: vi .fn() .mockResolvedValue(Response.json({ session: { token: "exposed" } })), }); const app = createAuthWiringApp(runtime); const response = await app.request(path, { method }); expect(response.status).toBe(404); expect(await response.json()).toEqual({ code: "NOT_FOUND" }); expect(response.headers.get("cache-control")).toBe("no-store"); expect(runtime.handleAuthRequest).not.toHaveBeenCalled(); }); it("denies an oversized update-session body before the body limiter or Better Auth reads it", async () => { const runtime = fakeRuntime(); const app = createAuthWiringApp(runtime); const response = await app.request("/api/auth/update-session", { body: "x", headers: { "content-length": String(AUTH_REQUEST_MAX_BODY_BYTES + 1), "content-type": "application/json", }, method: "POST", }); expect(response.status).toBe(404); expect(await response.json()).toEqual({ code: "NOT_FOUND" }); expect(runtime.handleAuthRequest).not.toHaveBeenCalled(); }); it.each([ ["POST", "/api/auth/list-sessions"], ["GET", "/api/auth/update-session"], ["GET", "/api/auth/change-password"], ["GET", "/api/auth/list-sessions/export"], ])("leaves the neighbouring non-denied request %s %s on the native contract", async (method, path) => { const runtime = fakeRuntime({ handleAuthRequest: vi .fn() .mockResolvedValue(new Response("native-method-response", { status: 200 })), }); const app = createAuthWiringApp(runtime); const response = await app.request(path, { method }); expect(response.status).toBe(200); expect(await response.text()).toBe("native-method-response"); expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1); }); }); it("does not route requests outside /api/auth/* to Better Auth", async () => { const runtime = fakeRuntime(); const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, trustedOrigins: [trustedOrigin], }); const response = await app.request("/sign-in/email", { method: "POST" }); expect(response.status).toBe(404); expect(runtime.handleAuthRequest).not.toHaveBeenCalled(); }); it("returns the anonymous principal shape with no-store caching", async () => { const runtime = fakeRuntime({ resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL), }); const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/v1/principal"); expect(response.status).toBe(200); expect(await response.json()).toEqual({ kind: "anonymous" }); expect(response.headers.get("cache-control")).toBe("no-store"); expect(response.headers.get("vary")).toContain("Cookie"); }); it("never duplicates Origin in Vary even though the route sets it directly and Hono's own CORS middleware also appends it", async () => { const runtime = fakeRuntime({ resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL), }); const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/v1/principal", { headers: { origin: trustedOrigin }, }); const vary = response.headers.get("vary") ?? ""; const tokens = vary.split(",").map((token) => token.trim().toLowerCase()); expect(tokens.filter((token) => token === "origin")).toHaveLength(1); expect(tokens).toContain("cookie"); }); it("returns the authenticated principal shape containing only kind and userId", async () => { const runtime = fakeRuntime({ resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_123")), }); const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/v1/principal"); const body = (await response.json()) as Record; expect(response.status).toBe(200); expect(body).toEqual({ kind: "authenticated", userId: "user_123" }); expect(Object.keys(body).sort()).toEqual(["kind", "userId"]); }); it("returns a stable sanitized 503 when principal resolution fails, never a false anonymous 200", async () => { const runtime = fakeRuntime({ resolvePrincipal: vi .fn() .mockRejectedValue(new IdentityRuntimeError("PRINCIPAL_RESOLUTION_FAILED")), }); const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/v1/principal"); expect(response.status).toBe(503); expect(await response.json()).toEqual({ code: "PRINCIPAL_RESOLUTION_FAILED" }); expect(response.headers.get("cache-control")).toBe("no-store"); }); it("returns a stable sanitized 500 for an unexpected auth-handler failure, never a raw message", async () => { const runtime = fakeRuntime({ handleAuthRequest: vi.fn().mockRejectedValue(new Error("postgresql://admin:secret@db/x")), }); const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/auth/sign-in/email", { method: "POST" }); const body = await response.json(); expect(response.status).toBe(500); expect(body).toEqual({ code: "INTERNAL_ERROR" }); expect(JSON.stringify(body)).not.toContain("secret"); expect(JSON.stringify(body)).not.toContain("postgresql://"); }); it("forces Cache-Control: no-store on every Better Auth response, including a successful one", async () => { const runtime = fakeRuntime({ handleAuthRequest: vi.fn().mockResolvedValue(new Response("auth-ok", { status: 200 })), }); const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/auth/sign-in/email", { method: "POST" }); expect(response.status).toBe(200); expect(await response.json()).toEqual({ ok: true }); expect(response.headers.get("cache-control")).toBe("no-store"); }); describe("auth request body limit", () => { it("accepts a request exactly at the auth body limit", async () => { const runtime = fakeRuntime(); const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/auth/sign-in/email", { body: "a".repeat(AUTH_REQUEST_MAX_BODY_BYTES), headers: { "content-length": String(AUTH_REQUEST_MAX_BODY_BYTES) }, method: "POST", }); expect(response.status).toBe(200); expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1); }); it("rejects a request one byte over the auth body limit with a sanitized 413, never invoking Better Auth", async () => { const runtime = fakeRuntime(); const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/auth/sign-in/email", { body: "a".repeat(AUTH_REQUEST_MAX_BODY_BYTES + 1), headers: { "content-length": String(AUTH_REQUEST_MAX_BODY_BYTES + 1) }, method: "POST", }); expect(response.status).toBe(413); expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" }); expect(response.headers.get("cache-control")).toBe("no-store"); expect(runtime.handleAuthRequest).not.toHaveBeenCalled(); }); it("rejects an oversized declared Content-Length from its header alone, without invoking Better Auth", async () => { // A declared-oversized Content-Length is rejected synchronously from the header value itself // (`hono/body-limit`'s pinned implementation never reads the stream for this case) — proven // here by the outcome (413, Better Auth never invoked) rather than by instrumenting the // stream, since this in-memory test harness's own `Request`/body plumbing may touch the // stream for reasons unrelated to `bodyLimit`'s own logic. const runtime = fakeRuntime(); const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, trustedOrigins: [trustedOrigin], }); const stream = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode("a")); controller.close(); }, }); const response = await app.request("/api/auth/sign-in/email", { body: stream, duplex: "half", headers: { "content-length": String(AUTH_REQUEST_MAX_BODY_BYTES * 10) }, method: "POST", } as RequestInit); expect(response.status).toBe(413); expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" }); expect(runtime.handleAuthRequest).not.toHaveBeenCalled(); }); it("rejects an oversized streamed body with no declared Content-Length", async () => { const runtime = fakeRuntime(); const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, trustedOrigins: [trustedOrigin], }); const chunk = new Uint8Array(1024).fill(97); let enqueued = 0; const stream = new ReadableStream({ pull(controller) { if (enqueued > AUTH_REQUEST_MAX_BODY_BYTES) { controller.close(); return; } enqueued += chunk.length; controller.enqueue(chunk); }, }); const response = await app.request("/api/auth/sign-in/email", { body: stream, duplex: "half", method: "POST", } as RequestInit); expect(response.status).toBe(413); expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" }); expect(runtime.handleAuthRequest).not.toHaveBeenCalled(); }); }); it("has deterministic 404 behavior for an unknown route", async () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), trustedOrigins: [trustedOrigin], }); const response = await app.request("/does-not-exist"); expect(response.status).toBe(404); expect(await response.json()).toEqual({ code: "NOT_FOUND" }); }); it("reflects an allowed origin in Access-Control-Allow-Origin with credentials support", async () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/v1/principal", { headers: { origin: trustedOrigin }, }); expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin); expect(response.headers.get("access-control-allow-credentials")).toBe("true"); }); it("never returns Access-Control-Allow-Origin for a disallowed origin", async () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/v1/principal", { headers: { origin: untrustedOrigin }, }); expect(response.headers.get("access-control-allow-origin")).toBeNull(); }); it("supports requests with no Origin header at all (native/server clients)", async () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/v1/principal"); expect(response.status).toBe(200); }); it("handles preflight OPTIONS requests deterministically for an allowed origin", async () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/v1/principal", { headers: { "access-control-request-method": "GET", origin: trustedOrigin, }, method: "OPTIONS", }); expect(response.status).toBe(204); expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin); }); it("never wildcards Access-Control-Allow-Origin", async () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/v1/principal", { headers: { origin: trustedOrigin }, }); expect(response.headers.get("access-control-allow-origin")).not.toBe("*"); }); // Unlike Better Auth's own documented Hono example (which hydrates `c.set("user", session.user)` // on every request), createApiApp only ever calls `c.set` once, for the narrow EkklesiaPrincipal // (kind + userId only), and only on routes `requirePermission` actually protects — proven by the // grants-routes tests, not by a black-box context probe here. `/api/v1/principal` itself still // never touches `c.set`; its response shape is asserted directly above. describe("grants routes mounting (see grants-routes.test.ts for full coverage)", () => { it("mounts the v1 grants routes under /api/v1/grants", async () => { const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: false }); const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }), runtime: fakeRuntime(), trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/v1/grants?userId=user_123"); // Reaching the permission check (and being denied by it) proves the route is mounted and // wired to the injected evaluator, rather than falling through to the 404 handler. expect(response.status).toBe(403); expect(evaluatorAuthorize).toHaveBeenCalledTimes(1); }); it("uses exactly the injected registry, never an internal Core-only fallback", async () => { // An evaluator that would allow anything, paired with a registry that does not register // core.authorization.grants.manage at all — if createApiApp silently fell back to some // internal registry containing it, this request would reach the evaluator and succeed // (empty grant list, 200). It must instead fail closed on the registry lookup itself. const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: true }); const emptyRegistry: PermissionRegistry = { declarations: [], get: () => undefined, has: () => false, }; const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize }, registry: emptyRegistry, }), runtime: fakeRuntime(), trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/v1/grants?userId=user_123"); expect(response.status).toBe(403); expect(evaluatorAuthorize).not.toHaveBeenCalled(); }); it("applies the same CORS policy to /api/v1/grants as the rest of /api/v1/*", async () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/v1/grants?userId=user_123", { headers: { origin: untrustedOrigin }, }); expect(response.headers.get("access-control-allow-origin")).toBeNull(); }); }); });