feat(api): add unauthenticated health endpoints
CI / verify (pull_request) Successful in 45s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 1m42s

Add GET /api/v1/health/live and GET /api/v1/health/ready per ADR 0044.

- live returns a fixed 200 {"status":"live"} whenever the process serves.
- ready returns 200 {"status":"ready"} only when module startup has
  completed (and shutdown has not begun) and a database ping succeeds
  within 2s; otherwise a fixed 503 {"code":"NOT_READY"}.
- The ping runs on the shared pool as one simple-protocol message with
  a transaction-local statement_timeout, so PostgreSQL cancels a stalled
  statement; the pool discards the connection on failure.
- At most one ping is ever unsettled: probes share its outcome and, once
  its deadline passes, answer 503 immediately until it settles.
- Both routes set Cache-Control: no-store, accept GET only (405 with
  Allow: GET otherwise, HEAD included), never resolve a principal, and
  are mounted in app.ts beside, never inside, /api/v1/modules/.
- createApiApp gains a required `health` option; startApi supplies it
  and clears module readiness when shutdown begins.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-01 16:47:01 -05:00
co-authored by Claude Opus 5.5
parent d593c10f1d
commit aaec9789c4
18 changed files with 727 additions and 0 deletions
+13
View File
@@ -31,6 +31,7 @@ import { createDashboardRoutes } from "./dashboard-routes.js";
import { createDeviceRoutes } from "./device-routes.js"; import { createDeviceRoutes } from "./device-routes.js";
import type { DisplayHostAssets } from "./display-host-assets.js"; import type { DisplayHostAssets } from "./display-host-assets.js";
import { createGrantsRoutes } from "./grants-routes.js"; import { createGrantsRoutes } from "./grants-routes.js";
import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js";
import { createModuleConfigurationRoutes } from "./module-configuration-routes.js"; import { createModuleConfigurationRoutes } from "./module-configuration-routes.js";
import { mountModuleDisplayRoutes } from "./module-display-routes.js"; import { mountModuleDisplayRoutes } from "./module-display-routes.js";
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js"; import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
@@ -137,6 +138,13 @@ export interface CreateApiAppOptions {
* presentation only — nothing reachable through this store influences discovery, dependency * presentation only — nothing reachable through this store influences discovery, dependency
* ordering, module startup, or any authorization decision. */ * ordering, module startup, or any authorization decision. */
readonly dashboard: DashboardStore; readonly dashboard: DashboardStore;
/**
* The readiness inputs behind the unauthenticated health routes (ADR 0044). Required rather than
* defaulted, like every other dependency here: a composition that cannot reach a database says so
* with a check that rejects, never falls through to an implicitly healthy one. `checkDatabase` is
* the bare query; the routes themselves bound and serialize it. See `./health-routes.js`.
*/
readonly health: ApiHealthOptions;
readonly runtime: EkklesiaIdentityRuntime; readonly runtime: EkklesiaIdentityRuntime;
/** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an /** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an
* exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set * exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set
@@ -341,6 +349,11 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
} }
}); });
// Unauthenticated liveness/readiness under a platform-owned sibling prefix: like every route below,
// it is mounted before module routes and never inside `/api/v1/modules/`, which installed modules
// own entirely. See `./health-routes.js`.
app.route("/", createHealthRoutes(options.health));
app.route( app.route(
"/", "/",
createGrantsRoutes({ createGrantsRoutes({
+23
View File
@@ -26,11 +26,31 @@ export interface ApiDatabaseHandle {
readonly module: ModuleDatabase; readonly module: ModuleDatabase;
readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase; readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase;
readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase; readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase;
/**
* One trivial round trip on the shared pool, for readiness (`./health-routes.js`). The `SELECT 1`
* runs in a read-only transaction carrying its own `SET LOCAL statement_timeout`, so PostgreSQL
* cancels a stalled statement itself rather than the caller merely abandoning it; the pool's
* session-wide `statement_timeout`/`query_timeout` remain the outer bound. The four statements
* travel as one simple-protocol message, so the check costs a single round trip. On any failure
* the pool discards that connection instead of returning it, so a transaction left open by a
* cancelled statement never reaches another caller. Rejects with whatever the driver raised; no
* caller may surface it.
*/
readonly ping: (statementTimeoutMs: number) => Promise<void>;
/** Closes the one shared pool exactly once, regardless of how many typed views were created /** Closes the one shared pool exactly once, regardless of how many typed views were created
* over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */ * over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */
readonly close: () => Promise<void>; readonly close: () => Promise<void>;
} }
function pingStatement(statementTimeoutMs: number): string {
// Interpolated rather than bound: neither SET nor a multi-statement simple query accepts
// parameters. Only a positive safe integer can ever reach the SQL text.
if (!Number.isSafeInteger(statementTimeoutMs) || statementTimeoutMs <= 0) {
throw new RangeError("The ping statement timeout must be a positive integer.");
}
return `BEGIN READ ONLY; SET LOCAL statement_timeout = ${statementTimeoutMs}; SELECT 1; COMMIT`;
}
/** /**
* Composes the one physical PostgreSQL pool this process uses for Core's identity and * Composes the one physical PostgreSQL pool this process uses for Core's identity and
* authorization schemas plus the module database facade: one validated, timeout-bounded * authorization schemas plus the module database facade: one validated, timeout-bounded
@@ -58,5 +78,8 @@ export function createApiDatabase(
module: createModuleDatabaseView(pool), module: createModuleDatabaseView(pool),
moduleConfiguration: createModuleConfigurationView(pool), moduleConfiguration: createModuleConfigurationView(pool),
moduleLifecycle: createModuleLifecycleView(pool), moduleLifecycle: createModuleLifecycleView(pool),
ping: async (statementTimeoutMs: number) => {
await pool.query(pingStatement(statementTimeoutMs));
},
}); });
} }
+121
View File
@@ -0,0 +1,121 @@
import { Hono } from "hono";
/**
* The longest a readiness probe waits on the database before answering `503` (ADR 0044). Short
* enough for a Compose health check or an external monitor to treat a stall as "not ready" rather
* than as a hung probe, and well inside the shared pool's own 10s connection and operation
* timeouts, which bound the underlying query independently.
*/
export const READINESS_DATABASE_TIMEOUT_MS = 2_000;
export const HEALTH_LIVE_PATH = "/api/v1/health/live";
export const HEALTH_READY_PATH = "/api/v1/health/ready";
export interface ApiHealthOptions {
/**
* One trivial database round trip. Resolves when the database answered; rejects on any failure.
* The rejection is never read — not its message, code, or cause — so nothing a driver says can
* reach a response. The route bounds the wait and never overlaps two calls; this function only
* has to perform the query.
*/
readonly checkDatabase: () => Promise<void>;
/** Whether module startup has completed. `false` again from the moment shutdown begins, so a
* draining instance stops reporting ready while it finishes in-flight requests. */
readonly isModuleStartupComplete: () => boolean;
}
const LIVE_BODY = Object.freeze({ status: "live" });
const READY_BODY = Object.freeze({ status: "ready" });
const NOT_READY_BODY = Object.freeze({ code: "NOT_READY" });
/**
* Wraps `checkDatabase` so at most one call is ever unsettled, and no probe waits longer than
* `timeoutMs`.
*
* Every probe arriving while a check is unsettled shares that check's outcome, including after its
* deadline has passed: the deadline resolves the shared outcome to `false`, so those later probes
* answer immediately. A new check starts only once the previous call has actually settled. A timed-
* out query still occupies a pool connection until the database or the pool's own timeouts end it,
* so starting a fresh query on every probe would let a stalled database drain the pool one health
* check at a time.
*/
function createBoundedDatabaseCheck(
checkDatabase: () => Promise<void>,
timeoutMs: number,
): () => Promise<boolean> {
let inFlight: Promise<boolean> | undefined;
return () => {
if (inFlight !== undefined) {
return inFlight;
}
let timer: ReturnType<typeof setTimeout> | undefined;
// The executor runs `checkDatabase` synchronously, so a synchronous throw is a rejection like
// any other rather than an exception escaping the route.
const settled = new Promise<void>((resolve) => resolve(checkDatabase())).then(
() => true,
() => false,
);
const outcome = Promise.race([
settled,
new Promise<false>((resolve) => {
timer = setTimeout(() => resolve(false), timeoutMs);
}),
]);
inFlight = outcome;
void settled.then(() => {
clearTimeout(timer);
inFlight = undefined;
});
return outcome;
};
}
/** The same fixed refusal `./module-realtime-routes.js` uses: Hono lets a GET handler answer HEAD,
* and these routes accept GET alone. */
function methodNotAllowed(): Response {
return new Response(null, {
headers: { Allow: "GET", "Cache-Control": "no-store" },
status: 405,
});
}
/**
* Unauthenticated liveness and readiness (ADR 0044), for Compose health checks and external
* monitoring. Neither route resolves a principal or reads a cookie, and both return fixed bodies
* that name no module, version, error, or connection detail; `pnpm doctor` remains the detailed
* operator report. They live under `/api/v1/health/`, a sibling of the `/api/v1/modules/` prefix that
* installed modules own entirely, so no module route can shadow them.
*/
export function createHealthRoutes(options: ApiHealthOptions): Hono {
const checkDatabase = createBoundedDatabaseCheck(
options.checkDatabase,
READINESS_DATABASE_TIMEOUT_MS,
);
const routes = new Hono();
routes.all(HEALTH_LIVE_PATH, (c) => {
if (c.req.method !== "GET") {
return methodNotAllowed();
}
c.header("Cache-Control", "no-store");
return c.json(LIVE_BODY, 200);
});
routes.all(HEALTH_READY_PATH, async (c) => {
if (c.req.method !== "GET") {
return methodNotAllowed();
}
c.header("Cache-Control", "no-store");
if (!options.isModuleStartupComplete()) {
return c.json(NOT_READY_BODY, 503);
}
const databaseReady = await checkDatabase();
// Checked again afterwards: shutdown may have begun while the query was in flight.
return databaseReady && options.isModuleStartupComplete()
? c.json(READY_BODY, 200)
: c.json(NOT_READY_BODY, 503);
});
return routes;
}
+12
View File
@@ -48,6 +48,7 @@ import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js"; import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
import { withDevicePrincipals } from "./device-principal.js"; import { withDevicePrincipals } from "./device-principal.js";
import { loadDisplayHostAssets } from "./display-host-assets.js"; import { loadDisplayHostAssets } from "./display-host-assets.js";
import { READINESS_DATABASE_TIMEOUT_MS } from "./health-routes.js";
import { prepareStartupModuleInventory } from "./platform-permissions.js"; import { prepareStartupModuleInventory } from "./platform-permissions.js";
const connectionTimeoutMs = 10_000; const connectionTimeoutMs = 10_000;
@@ -576,6 +577,10 @@ export async function startApi(
throw error; throw error;
} }
// Set by the first `shutdown()` call, before anything is drained or stopped, so readiness turns
// `503` while the instance still finishes in-flight requests.
let shuttingDown = false;
let app: Hono; let app: Hono;
try { try {
app = createApiApp({ app = createApiApp({
@@ -620,6 +625,12 @@ export async function startApi(
}, },
// A typed view over the same single pool as identity, authorization, and the module facade. // A typed view over the same single pool as identity, authorization, and the module facade.
dashboard: createDashboardStore(databaseHandle.dashboard), dashboard: createDashboardStore(databaseHandle.dashboard),
// The app is only composed after `startDiscoveredModuleRuntime` resolved, so module startup
// has completed whenever it can answer; it stops counting as complete once shutdown begins.
health: {
checkDatabase: () => databaseHandle.ping(READINESS_DATABASE_TIMEOUT_MS),
isModuleStartupComplete: () => !shuttingDown,
},
devices: { devices: {
// `Secure` follows the deployment's own scheme, read from the already-validated identity // `Secure` follows the deployment's own scheme, read from the already-validated identity
// base URL rather than from any request. Production is https by configuration policy, so a // base URL rather than from any request. Production is https by configuration policy, so a
@@ -677,6 +688,7 @@ export async function startApi(
let shutdownPromise: Promise<void> | undefined; let shutdownPromise: Promise<void> | undefined;
const shutdown = (): Promise<void> => { const shutdown = (): Promise<void> => {
if (!shutdownPromise) { if (!shutdownPromise) {
shuttingDown = true;
shutdownPromise = (async () => { shutdownPromise = (async () => {
// Close long-lived streams so HTTP can drain, but leave publishers active until the drain // Close long-lived streams so HTTP can drain, but leave publishers active until the drain
// completes: an in-flight module command may still commit and publish its resulting state. // completes: an in-flight module command may still commit and publish its resulting state.
+56
View File
@@ -27,6 +27,12 @@ import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it, vi } from "vitest"; import { afterAll, describe, expect, it, vi } from "vitest";
import type { ClientAddressConfigInput } from "../src/client-address.js"; import type { ClientAddressConfigInput } from "../src/client-address.js";
import { createApiRuntimeConfig } from "../src/config.js"; import { createApiRuntimeConfig } from "../src/config.js";
import { createApiDatabase } from "../src/database.js";
import {
HEALTH_LIVE_PATH,
HEALTH_READY_PATH,
READINESS_DATABASE_TIMEOUT_MS,
} from "../src/health-routes.js";
import { import {
MODULE_UI_CACHE_CONTROL, MODULE_UI_CACHE_CONTROL,
MODULE_UI_CONTENT_TYPE, MODULE_UI_CONTENT_TYPE,
@@ -2977,6 +2983,56 @@ describe.sequential("live API — published module UI artifact", () => {
}); });
}); });
describe.sequential("live API — health", () => {
it("answers liveness and readiness with 200 against a migrated disposable PostgreSQL", async () => {
const database = await createDisposableDatabase("health");
await applyCoreMigrations(database.url);
const running = await startMigratedApi(database.url);
try {
const origin = apiOrigin(running);
const live = await fetch(`${origin}${HEALTH_LIVE_PATH}`);
expect(live.status).toBe(200);
expect(live.headers.get("cache-control")).toBe("no-store");
expect(await live.json()).toEqual({ status: "live" });
const ready = await fetch(`${origin}${HEALTH_READY_PATH}`);
expect(ready.status).toBe(200);
expect(ready.headers.get("cache-control")).toBe("no-store");
expect(await ready.json()).toEqual({ status: "ready" });
} finally {
await running.shutdown();
}
});
it("pings through the shared pool, and PostgreSQL itself cancels a statement that outlives the ping's transaction-local timeout", async () => {
const database = await createDisposableDatabase("health_ping");
const handle = createApiDatabase(database.url);
try {
await expect(handle.ping(READINESS_DATABASE_TIMEOUT_MS)).resolves.toBeUndefined();
} finally {
await handle.close();
}
// The ping's exact statement shape, with a statement that cannot finish inside its timeout: the
// server cancels it (query_canceled) long before the sleep would end, on a client that has no
// client-side timeout of its own.
const client = new Client({ connectionString: database.url });
await client.connect();
try {
const startedAt = Date.now();
await expect(
client.query(
"BEGIN READ ONLY; SET LOCAL statement_timeout = 50; SELECT pg_sleep(5); COMMIT",
),
).rejects.toMatchObject({ code: "57014" });
expect(Date.now() - startedAt).toBeLessThan(2_000);
} finally {
await client.end();
}
});
});
describe.sequential("live API — public signup is disabled by default", () => { describe.sequential("live API — public signup is disabled by default", () => {
it("rejects sign-up with a stable sanitized 400 when publicSignupEnabled is not set, creating no user row", async () => { it("rejects sign-up with a stable sanitized 400 when publicSignupEnabled is not set, creating no user row", async () => {
const database = await createDisposableDatabase("signup_disabled"); const database = await createDisposableDatabase("signup_disabled");
+23
View File
@@ -25,6 +25,7 @@ import {
} from "../src/app.js"; } from "../src/app.js";
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js"; import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { readyHealth } from "./support/ready-health.js";
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js"; import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
import { emptyModuleStorage } from "./support/in-memory-module-storage.js"; import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
@@ -104,6 +105,7 @@ function createAuthWiringApp(runtime: EkklesiaIdentityRuntime) {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -121,6 +123,7 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -355,6 +358,7 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -378,6 +382,7 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -403,6 +408,7 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -430,6 +436,7 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -457,6 +464,7 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -481,6 +489,7 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -507,6 +516,7 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -530,6 +540,7 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -555,6 +566,7 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -587,6 +599,7 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -620,6 +633,7 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -658,6 +672,7 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime: fakeRuntime(), runtime: fakeRuntime(),
@@ -678,6 +693,7 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime: fakeRuntime(), runtime: fakeRuntime(),
@@ -700,6 +716,7 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime: fakeRuntime(), runtime: fakeRuntime(),
@@ -721,6 +738,7 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime: fakeRuntime(), runtime: fakeRuntime(),
@@ -740,6 +758,7 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime: fakeRuntime(), runtime: fakeRuntime(),
@@ -766,6 +785,7 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime: fakeRuntime(), runtime: fakeRuntime(),
@@ -795,6 +815,7 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }), authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
runtime: fakeRuntime(), runtime: fakeRuntime(),
@@ -827,6 +848,7 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({ authorization: fakeAuthorization({
evaluator: { authorize: evaluatorAuthorize }, evaluator: { authorize: evaluatorAuthorize },
@@ -850,6 +872,7 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
health: readyHealth(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime: fakeRuntime(), runtime: fakeRuntime(),
+2
View File
@@ -27,6 +27,7 @@ import { createApiApp } from "../src/app.js";
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js"; import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js"; import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { readyHealth } from "./support/ready-health.js";
import { import {
createInMemoryModuleLifecycleStore, createInMemoryModuleLifecycleStore,
moduleLifecycleSnapshotFor, moduleLifecycleSnapshotFor,
@@ -139,6 +140,7 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
const app = createApiApp({ const app = createApiApp({
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store }, authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
dashboard: createInMemoryDashboardStore(options.configuration ?? {}), dashboard: createInMemoryDashboardStore(options.configuration ?? {}),
health: readyHealth(),
moduleRoutes: Object.freeze([]), moduleRoutes: Object.freeze([]),
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY, moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: Object.freeze({ moduleServices: Object.freeze({
+371
View File
@@ -0,0 +1,371 @@
import {
ANONYMOUS_PRINCIPAL,
type ModuleDatabase,
type ModuleRouteHandler,
type ModuleRuntimeServices,
parsePermissionId,
type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts";
import {
type AuthorizationEvaluator,
type AuthorizationStore,
createPermissionRegistry,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
createModuleUiPublicationRegistry,
EMPTY_MODULE_REALTIME_REGISTRY,
} from "@ekklesia/core/modules";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApiApp } from "../src/app.js";
import {
type ApiHealthOptions,
HEALTH_LIVE_PATH,
HEALTH_READY_PATH,
READINESS_DATABASE_TIMEOUT_MS,
} from "../src/health-routes.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 modulePermission = parsePermissionId("health.status.read");
/** What a real driver failure can carry, and what must therefore never reach a response. */
const DRIVER_FAILURE_DETAIL =
"connect ECONNREFUSED postgresql://ekklesia:[email protected]:5432/ekklesia SELECT 1";
function fakeRuntime(): EkklesiaIdentityRuntime {
return {
handleAuthRequest: vi.fn(),
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
};
}
function fakeStore(): AuthorizationStore {
return {
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(),
};
}
function fakeModuleServices(): ModuleRuntimeServices {
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
}
function healthOptions(overrides: Partial<ApiHealthOptions> = {}) {
return {
checkDatabase: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
isModuleStartupComplete: vi.fn<() => boolean>().mockReturnValue(true),
...overrides,
};
}
function buildApp(
health: ApiHealthOptions,
moduleRoutes: readonly RegisteredModuleHttpRoute[] = Object.freeze([]),
) {
const evaluator: AuthorizationEvaluator = {
authorize: vi.fn().mockResolvedValue({ allowed: true }),
};
const runtime = fakeRuntime();
const app = createApiApp({
authorization: {
evaluator,
registry: createPermissionRegistry([
{ baseline: "public", description: "Test permission.", id: modulePermission },
]),
store: fakeStore(),
},
moduleRoutes,
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: fakeModuleServices(),
moduleUi: createModuleUiPublicationRegistry([]),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
health,
runtime,
trustedOrigins: [trustedOrigin],
});
return { app, evaluator, runtime };
}
/** Resolves once every already-queued microtask has run, using the real `setImmediate` — the only
* timer functions these tests fake are `setTimeout` and `clearTimeout`. */
function drainMicrotasks(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve));
}
/** Whether `promise` settled with no fake time advanced at all: "promptly", made exact. */
async function settlesWithoutWaiting(promise: Response | Promise<Response>): Promise<boolean> {
let settled = false;
void Promise.resolve(promise).then(
() => {
settled = true;
},
() => {
settled = true;
},
);
await drainMicrotasks();
return settled;
}
async function expectNotReady(response: Response): Promise<void> {
expect(response.status).toBe(503);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.json()).toEqual({ code: "NOT_READY" });
}
afterEach(() => {
vi.useRealTimers();
});
describe("GET /api/v1/health/live", () => {
it("returns 200 with a fixed body and Cache-Control: no-store", async () => {
const health = healthOptions();
const { app } = buildApp(health);
const response = await app.request(HEALTH_LIVE_PATH);
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("content-type")).toMatch(/^application\/json/u);
expect(await response.json()).toEqual({ status: "live" });
});
it("does not consult the database or module startup, so it stays 200 while readiness is 503", async () => {
const health = healthOptions({
checkDatabase: vi.fn().mockRejectedValue(new Error(DRIVER_FAILURE_DETAIL)),
isModuleStartupComplete: vi.fn().mockReturnValue(false),
});
const { app } = buildApp(health);
const response = await app.request(HEALTH_LIVE_PATH);
expect(response.status).toBe(200);
expect(health.checkDatabase).not.toHaveBeenCalled();
expect(health.isModuleStartupComplete).not.toHaveBeenCalled();
});
});
describe("GET /api/v1/health/ready", () => {
it("returns 200 with a fixed body when the database answers and module startup completed", async () => {
const health = healthOptions();
const { app } = buildApp(health);
const response = await app.request(HEALTH_READY_PATH);
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.json()).toEqual({ status: "ready" });
expect(health.checkDatabase).toHaveBeenCalledTimes(1);
});
it("returns a fixed 503 when the database query fails, carrying nothing from the failure", async () => {
const health = healthOptions({
checkDatabase: vi.fn().mockRejectedValue(new Error(DRIVER_FAILURE_DETAIL)),
});
const { app } = buildApp(health);
const response = await app.request(HEALTH_READY_PATH);
const text = await response.clone().text();
await expectNotReady(response);
for (const fragment of ["ECONNREFUSED", "hunter2", "db.internal", "SELECT", "Error"]) {
expect(text).not.toContain(fragment);
}
});
it("treats a check that throws synchronously exactly like one that rejects", async () => {
const health = healthOptions({
checkDatabase: vi.fn(() => {
throw new Error(DRIVER_FAILURE_DETAIL);
}),
});
const { app } = buildApp(health);
await expectNotReady(await app.request(HEALTH_READY_PATH));
});
it("returns 503 without touching the database before module startup completed", async () => {
const health = healthOptions({ isModuleStartupComplete: vi.fn().mockReturnValue(false) });
const { app } = buildApp(health);
await expectNotReady(await app.request(HEALTH_READY_PATH));
expect(health.checkDatabase).not.toHaveBeenCalled();
});
it("returns 503 when shutdown began while the database query was in flight", async () => {
let startupComplete = true;
const health = healthOptions({
checkDatabase: vi.fn(async () => {
startupComplete = false;
}),
isModuleStartupComplete: () => startupComplete,
});
const { app } = buildApp(health);
await expectNotReady(await app.request(HEALTH_READY_PATH));
});
it("starts a fresh check for each sequential probe rather than caching a success", async () => {
const health = healthOptions();
const { app } = buildApp(health);
await app.request(HEALTH_READY_PATH);
await app.request(HEALTH_READY_PATH);
expect(health.checkDatabase).toHaveBeenCalledTimes(2);
});
it("reports ready for a slow query that still answers inside the bound", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
let answer: () => void = () => undefined;
const health = healthOptions({
checkDatabase: vi.fn(
() =>
new Promise<void>((resolve) => {
answer = resolve;
}),
),
});
const { app } = buildApp(health);
const pending = app.request(HEALTH_READY_PATH);
await vi.advanceTimersByTimeAsync(READINESS_DATABASE_TIMEOUT_MS - 1);
answer();
expect((await pending).status).toBe(200);
});
});
describe("readiness never piles up database checks", () => {
it("keeps exactly one check in flight for a query that never settles, while every probe answers 503 promptly", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const health = healthOptions({
checkDatabase: vi.fn(() => new Promise<void>(() => undefined)),
});
const { app } = buildApp(health);
// Probes arriving inside the first check's window share it and its single deadline.
const first = app.request(HEALTH_READY_PATH);
const concurrent = app.request(HEALTH_READY_PATH);
await drainMicrotasks();
expect(health.checkDatabase).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(READINESS_DATABASE_TIMEOUT_MS);
await expectNotReady(await first);
await expectNotReady(await concurrent);
// After the deadline the query is still unsettled: no new check starts, and each probe answers
// without waiting out another bound.
for (let probe = 0; probe < 20; probe += 1) {
const pending = app.request(HEALTH_READY_PATH);
expect(await settlesWithoutWaiting(pending)).toBe(true);
await expectNotReady(await pending);
}
expect(health.checkDatabase).toHaveBeenCalledTimes(1);
});
it("starts a new check only once the timed-out query has actually settled", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
let settleHungQuery: (error: Error) => void = () => undefined;
const checkDatabase = vi
.fn<() => Promise<void>>()
.mockImplementationOnce(
() =>
new Promise<void>((_resolve, reject) => {
settleHungQuery = reject;
}),
)
.mockResolvedValue(undefined);
const { app } = buildApp(healthOptions({ checkDatabase }));
const first = app.request(HEALTH_READY_PATH);
await vi.advanceTimersByTimeAsync(READINESS_DATABASE_TIMEOUT_MS);
await expectNotReady(await first);
await expectNotReady(await app.request(HEALTH_READY_PATH));
expect(checkDatabase).toHaveBeenCalledTimes(1);
// The database cancels the abandoned statement; the next probe may finally ask again.
settleHungQuery(new Error(DRIVER_FAILURE_DETAIL));
await drainMicrotasks();
const recovered = await app.request(HEALTH_READY_PATH);
expect(recovered.status).toBe(200);
expect(checkDatabase).toHaveBeenCalledTimes(2);
});
});
describe("health route boundaries", () => {
it.each([
HEALTH_LIVE_PATH,
HEALTH_READY_PATH,
])("%s accepts GET only, answering every other method with a fixed 405 and never checking readiness", async (path) => {
const health = healthOptions();
const { app } = buildApp(health);
for (const method of ["HEAD", "POST", "PUT", "PATCH", "DELETE"]) {
const response = await app.request(path, { method });
expect(response.status, method).toBe(405);
expect(response.headers.get("allow")).toBe("GET");
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.text()).toBe("");
}
expect(health.checkDatabase).not.toHaveBeenCalled();
expect(health.isModuleStartupComplete).not.toHaveBeenCalled();
});
it.each([
HEALTH_LIVE_PATH,
HEALTH_READY_PATH,
])("%s is unauthenticated: it never resolves a principal or evaluates authorization, even with a cookie", async (path) => {
const { app, evaluator, runtime } = buildApp(healthOptions());
const response = await app.request(path, {
headers: { Cookie: "better-auth.session_token=anything", Origin: trustedOrigin },
});
expect(response.status).toBe(200);
expect(runtime.resolvePrincipal).not.toHaveBeenCalled();
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
expect(evaluator.authorize).not.toHaveBeenCalled();
});
it('cannot be shadowed by a module, even one whose id is "health"', async () => {
const moduleHandler = vi.fn<ModuleRouteHandler>(
async () => new Response("module", { status: 200 }),
);
const moduleRoutes = ["/live", "/ready"].map(
(path) =>
Object.freeze({
bodyMode: "buffered",
handler: moduleHandler,
maxBodyBytes: 1024,
method: "GET",
moduleId: "health",
path,
permission: modulePermission,
}) as RegisteredModuleHttpRoute,
);
const { app } = buildApp(healthOptions(), moduleRoutes);
expect(await (await app.request(HEALTH_LIVE_PATH)).json()).toEqual({ status: "live" });
expect(await (await app.request(HEALTH_READY_PATH)).json()).toEqual({ status: "ready" });
expect(moduleHandler).not.toHaveBeenCalled();
// The module's own routes still live where every module route lives, and only there.
const moduleResponse = await app.request("/api/v1/modules/health/ready");
expect(await moduleResponse.text()).toBe("module");
expect(moduleHandler).toHaveBeenCalledTimes(1);
});
});
+35
View File
@@ -36,6 +36,11 @@ import { PermissionRegistryError } from "@ekklesia/core/authorization";
import { createApiApp } from "../src/app.js"; import { createApiApp } from "../src/app.js";
import { createApiRuntimeConfig } from "../src/config.js"; import { createApiRuntimeConfig } from "../src/config.js";
import { DisplayHostAssetError } from "../src/display-host-assets.js"; import { DisplayHostAssetError } from "../src/display-host-assets.js";
import {
HEALTH_LIVE_PATH,
HEALTH_READY_PATH,
READINESS_DATABASE_TIMEOUT_MS,
} from "../src/health-routes.js";
import { installShutdownSignalHandlers, startApi } from "../src/server.js"; import { installShutdownSignalHandlers, startApi } from "../src/server.js";
function validConfig() { function validConfig() {
@@ -447,6 +452,36 @@ describe("Ekklesia API shutdown lifecycle", () => {
expect(poolEnd).toHaveBeenCalledTimes(1); expect(poolEnd).toHaveBeenCalledTimes(1);
}); });
it("answers readiness with one bounded ping on the shared pool, and turns it 503 the moment shutdown begins", async () => {
const query = emptyQuery();
poolConstructor.mockImplementation(function fakePool() {
return { end: vi.fn().mockResolvedValue(undefined), on: vi.fn(), query };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
const running = await startApi(validConfig());
query.mockClear();
const ready = await running.app.request(HEALTH_READY_PATH);
expect(ready.status).toBe(200);
expect(query).toHaveBeenCalledTimes(1);
// The statement timeout is server-side and scoped to this one transaction, so PostgreSQL itself
// cancels a stalled ping rather than the route merely abandoning it.
expect(query).toHaveBeenCalledWith(
`BEGIN READ ONLY; SET LOCAL statement_timeout = ${READINESS_DATABASE_TIMEOUT_MS}; SELECT 1; COMMIT`,
);
const shutdown = running.shutdown();
const draining = await running.app.request(HEALTH_READY_PATH);
expect(draining.status).toBe(503);
expect(await draining.json()).toEqual({ code: "NOT_READY" });
// Draining is not dead: liveness still answers while in-flight requests finish.
expect((await running.app.request(HEALTH_LIVE_PATH)).status).toBe(200);
expect(query).toHaveBeenCalledTimes(1);
await shutdown;
});
it("keeps shutdown idempotent for calls made after the first has already resolved", async () => { it("keeps shutdown idempotent for calls made after the first has already resolved", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined); const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() { poolConstructor.mockImplementation(function fakePool() {
@@ -35,6 +35,7 @@ import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js"; import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js";
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js"; import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { readyHealth } from "./support/ready-health.js";
import { import {
createFakeModuleSecretSource, createFakeModuleSecretSource,
createInMemoryModuleConfigurationStore, createInMemoryModuleConfigurationStore,
@@ -189,6 +190,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
store: authorizationStore, store: authorizationStore,
}, },
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
health: readyHealth(),
moduleConfiguration: { moduleConfiguration: {
bootSettings, bootSettings,
discoveredModules: modules, discoveredModules: modules,
@@ -38,6 +38,7 @@ import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js"; import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js"; import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { readyHealth } from "./support/ready-health.js";
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js"; import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
import { import {
createInMemoryModuleLifecycleStore, createInMemoryModuleLifecycleStore,
@@ -198,6 +199,7 @@ async function install(options: InstallOptions): Promise<Installation> {
store: fakeAuthorizationStore(), store: fakeAuthorizationStore(),
}, },
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
health: readyHealth(),
...(moduleUi.listDisplays().length > 0 && options.withAssets !== false ...(moduleUi.listDisplays().length > 0 && options.withAssets !== false
? { displayHostAssets: options.assets ?? displayHostAssets } ? { displayHostAssets: options.assets ?? displayHostAssets }
: {}), : {}),
@@ -23,6 +23,7 @@ import {
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { readyHealth } from "./support/ready-health.js";
import { createApiApp } from "../src/app.js"; import { createApiApp } from "../src/app.js";
import { import {
canonicalizeVaryHeader, canonicalizeVaryHeader,
@@ -123,6 +124,7 @@ function buildModuleApp(options: {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
health: readyHealth(),
runtime, runtime,
trustedOrigins: options.trustedOrigins ?? [trustedOrigin], trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
}); });
@@ -33,6 +33,7 @@ import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js"; import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js"; import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { readyHealth } from "./support/ready-health.js";
import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js"; import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js";
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js"; import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js"; import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
@@ -171,6 +172,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
store: authorizationStore, store: authorizationStore,
}, },
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
health: readyHealth(),
moduleConfiguration: moduleConfigurationOptionFor(modules), moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules), moduleStorage: moduleStorageOptionFor(modules),
moduleLifecycle: { moduleLifecycle: {
@@ -36,6 +36,7 @@ import {
} from "@ekklesia/shell"; } from "@ekklesia/shell";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { readyHealth } from "./support/ready-health.js";
import { createApiApp } from "../src/app.js"; import { createApiApp } from "../src/app.js";
import { createDisplayHostAssets } from "../src/display-host-assets.js"; import { createDisplayHostAssets } from "../src/display-host-assets.js";
import { import {
@@ -288,6 +289,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
moduleConfiguration: moduleConfigurationOptionFor(modules), moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules), moduleStorage: moduleStorageOptionFor(modules),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
health: readyHealth(),
runtime, runtime,
trustedOrigins: ["https://dashboard.example.test"], trustedOrigins: ["https://dashboard.example.test"],
}); });
@@ -25,6 +25,7 @@ import {
import type { Hono } from "hono"; import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { readyHealth } from "./support/ready-health.js";
import { createApiApp } from "../src/app.js"; import { createApiApp } from "../src/app.js";
import { moduleApiBaseUrl } from "../src/module-http-routes.js"; import { moduleApiBaseUrl } from "../src/module-http-routes.js";
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js"; import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
@@ -208,6 +209,7 @@ async function install(options: InstallOptions): Promise<Installation> {
moduleConfiguration: moduleConfigurationOptionFor(modules), moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules), moduleStorage: moduleStorageOptionFor(modules),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
health: readyHealth(),
runtime, runtime,
trustedOrigins: [trustedOrigin], trustedOrigins: [trustedOrigin],
}); });
+2
View File
@@ -21,6 +21,7 @@ import { Window } from "happy-dom";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { readyHealth } from "./support/ready-health.js";
import { createApiApp } from "../src/app.js"; import { createApiApp } from "../src/app.js";
import { createDisplayHostAssets } from "../src/display-host-assets.js"; import { createDisplayHostAssets } from "../src/display-host-assets.js";
import { import {
@@ -255,6 +256,7 @@ async function install(
moduleConfiguration: moduleConfigurationOptionFor(modules), moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules), moduleStorage: moduleStorageOptionFor(modules),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
health: readyHealth(),
runtime, runtime,
trustedOrigins: [trustedOrigin], trustedOrigins: [trustedOrigin],
}); });
+10
View File
@@ -0,0 +1,10 @@
import type { ApiHealthOptions } from "../../src/health-routes.js";
/** Readiness inputs for a composition whose tests do not exercise health: the database answers and
* module startup has completed. Health behavior itself is tested in `../health-routes.test.ts`. */
export function readyHealth(): ApiHealthOptions {
return {
checkDatabase: async () => undefined,
isModuleStartupComplete: () => true,
};
}
+47
View File
@@ -77,6 +77,8 @@ No real values are shown here — configure per environment and never commit the
| `/api/auth/{get-session,list-sessions,update-session,change-password}` | Deliberately unavailable | Token-bearing native session inspection/mutation endpoints are denied at the Ekklesia HTTP boundary with sanitized `404 {"code":"NOT_FOUND"}`. Use `/api/v1/principal`; no browser session-list/update or password-management contract exists. | | `/api/auth/{get-session,list-sessions,update-session,change-password}` | Deliberately unavailable | Token-bearing native session inspection/mutation endpoints are denied at the Ekklesia HTTP boundary with sanitized `404 {"code":"NOT_FOUND"}`. Use `/api/v1/principal`; no browser session-list/update or password-management contract exists. |
| `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Supported Better Auth routes retain their native shapes except for the successful email sign-in normalization above. The four denied paths above never reach Better Auth. See [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for supported endpoints such as `/sign-up/email` and `/sign-out`. | | `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Supported Better Auth routes retain their native shapes except for the successful email sign-in normalization above. The four denied paths above never reach Better Auth. See [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for supported endpoints such as `/sign-up/email` and `/sign-out`. |
| `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. | | `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. |
| `GET /api/v1/health/live` | Ekklesia | Unauthenticated liveness: fixed `200 {"status":"live"}` whenever the process is serving HTTP. `Cache-Control: no-store`; GET only. See [Health](#health). |
| `GET /api/v1/health/ready` | Ekklesia | Unauthenticated readiness: fixed `200 {"status":"ready"}` only when a bounded database ping succeeds and module startup completed, otherwise `503 {"code":"NOT_READY"}`. `Cache-Control: no-store`; GET only. See [Health](#health). |
| `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. Listing is bounded keyset pagination; creation is unscoped-only and conflicts (`409`) on an equivalent effective grant; revocation clears every unrevoked equivalent. See [Authorization](authorization.md#explicit-grant-http-api). | | `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. Listing is bounded keyset pagination; creation is unscoped-only and conflicts (`409`) on an equivalent effective grant; revocation clears every unrevoked equivalent. See [Authorization](authorization.md#explicit-grant-http-api). |
| `/api/v1/modules/<module-id>/*` | Installed modules | Every route an installed module registered during its `register` hook, gated by that route's own declared permission. See [Module HTTP routes](#module-http-routes). | | `/api/v1/modules/<module-id>/*` | Installed modules | Every route an installed module registered during its `register` hook, gated by that route's own declared permission. See [Module HTTP routes](#module-http-routes). |
| `GET /api/v1/module-realtime/<module-id>/<channel>` | Installed modules | Snapshot-first Server-Sent Events for one registered module-local channel, gated by that channel's declared permission. See [Module realtime](#module-realtime). | | `GET /api/v1/module-realtime/<module-id>/<channel>` | Installed modules | Snapshot-first Server-Sent Events for one registered module-local channel, gated by that channel's declared permission. See [Module realtime](#module-realtime). |
@@ -1420,6 +1422,51 @@ TLS protects the *connection*, not the exposure: it does not make an internet-re
safe, and the network boundary remains the deployment's own responsibility. See safe, and the network boundary remains the deployment's own responsibility. See
[ADR 0018](../architecture/decisions/0018-identity-session-resolution-and-database-transport.md). [ADR 0018](../architecture/decisions/0018-identity-session-resolution-and-database-transport.md).
## Health
Two unauthenticated endpoints exist for Compose health checks and external monitoring
([ADR 0044](../architecture/decisions/0044-self-hosted-reference-deployment.md)). They are
deliberately uninformative: neither names a module, version, dependency, error, or connection
detail, and `pnpm doctor` remains the detailed operator report.
| Request | Answer |
| --- | --- |
| `GET /api/v1/health/live` | Always `200 {"status":"live"}`. Liveness consults nothing; if the process can answer, it is live. |
| `GET /api/v1/health/ready`, ready | `200 {"status":"ready"}` |
| `GET /api/v1/health/ready`, not ready | `503 {"code":"NOT_READY"}` |
| Any other method on either path, including `HEAD` | `405` with `Allow: GET` and no body |
Every answer sets `Cache-Control: no-store`. Neither route resolves a principal, reads a cookie, or
evaluates authorization. Both live under `/api/v1/health/`, a sibling of the `/api/v1/modules/`
prefix, so no module route can shadow them, including a module whose id is `health`. As for every
`/api/v1/*` route, an `OPTIONS` request is answered by the platform's CORS preflight and never
reaches the handlers.
Readiness is `200` only when **both** of the following hold:
- **Module startup completed, and shutdown has not begun.** `startApi()` binds the HTTP listener
only after the module runtime has started, so this holds whenever the server is answering. The
first `shutdown()` call clears it before anything drains, so a draining instance reports `503`
while liveness still reports `200`. It is checked again after the database ping, in case shutdown
began meanwhile.
- **The database answered a trivial query within 2 seconds** (`READINESS_DATABASE_TIMEOUT_MS`).
The ping runs on the one shared pool as a single message,
`BEGIN READ ONLY; SET LOCAL statement_timeout = 2000; SELECT 1; COMMIT`. The transaction-local
timeout means PostgreSQL cancels a stalled statement itself rather than leaving it running. On
any failure the pool discards that connection instead of reusing it. The pool's own 10-second
connection, query, and statement timeouts remain the outer bound, for example for a database
that cannot be reached at all.
**Pings never pile up.** At most one ping is ever unsettled. Probes that arrive while it runs share
its result and its single 2-second deadline. Once the deadline passes they answer `503` immediately,
without starting another query, until that ping has actually settled. A stalled database therefore
holds at most one pool connection on behalf of health checks, however often a monitor polls. A
successful result is never cached: every probe after a settled ping runs a new one.
A failed ping is never inspected. The response is the same fixed `503` whether the database
refused the connection, timed out, rejected the credentials, or was mid-restart, and nothing about
the failure is logged by the route.
## Graceful shutdown ## Graceful shutdown
`startApi()` (the composition function `apps/api/src/server.ts` exports, used by both the `startApi()` (the composition function `apps/api/src/server.ts` exports, used by both the