6 Commits
Author SHA1 Message Date
The_River ef1383d080 Merge pull request 'fix(packages): declare runtime imports as production dependencies' (#47) from fix/module-sdk-runtime-dependencies into main
CI / verify (push) Successful in 49s
CI / infrastructure-smoke (push) Successful in 13s
CI / migration-smoke (push) Successful in 1m46s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/47
2026-10-02 00:58:38 +00:00
ekklesiaandClaude Opus 5.5 fc422b6d8d fix(packages): declare runtime imports as production dependencies
CI / verify (pull_request) Successful in 47s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m45s
@ekklesia/module-sdk imports drizzle-orm at run time but declared it
only as a peer and a development dependency. A workspace package is
linked into its consumers as a directory, so its imports resolve from
its own node_modules, where a consumer never satisfies a peer; a
production install omits development dependencies. In the production
image this made every module with a schema fail: the API exited at
startup and Doctor reported NOT READY.

- Move drizzle-orm to module-sdk's dependencies, keeping the peer.
- Move react to @ekklesia/ui's dependencies, keeping the peer. The new
  guard flags it the same way; it is bundled at build time today, so
  it was not a live failure. Both lockfile entries keep the identical
  resolution, so nothing new is installed and the shell bundles still
  contain one React.
- Add the boundary rule runtime-imports-are-production-dependencies:
  code a package ships (src/, bin/, excluding tests and .d.ts) must
  declare every runtime import in dependencies or optionalDependencies.
  Type-only imports are exempt; .tsx/.jsx implies react (automatic
  JSX runtime). Fixtures cover peer-only, dev-only, JSX, undeclared,
  and the accepted cases.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 19:29:35 -05:00
The_River d5d4928b9f Merge pull request 'feat(api): serve the built Dashboard from the API origin' (#46) from feat/api-serve-dashboard into main
CI / verify (push) Successful in 46s
CI / infrastructure-smoke (push) Successful in 20s
CI / migration-smoke (push) Successful in 1m44s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/46
2026-10-01 23:28:56 +00:00
ekklesiaandClaude Opus 5.5 a12cbe9600 feat(api): serve the built Dashboard from the API origin
CI / verify (pull_request) Successful in 46s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 1m44s
Serve surfaces/shell's built Dashboard from apps/api on the same origin
as /api (ADR 0044), exactly as the display host is served: a closed set
of files read once at startup, never a static directory, never a path
built from request input.

- The shell exports @ekklesia/shell/dashboard-assets (the exact built
  index.html and script URLs), emits the script at
  assets/ekklesia-dashboard/app.js, and index.html names that absolute
  path so dev and production serve the same URLs.
- GET / and GET /assets/ekklesia-dashboard/app.js answer from a frozen
  snapshot: no-cache with a strong ETag and 304 on match. Source maps
  are never served; every other path is the ordinary sanitized 404.
- The page sets nosniff, X-Frame-Options: DENY, Content-Security-Policy:
  frame-ancestors 'none' (that directive only), and Referrer-Policy:
  no-referrer, on 200 and 304 alike.
- A missing or empty shell build fails startup with a fixed
  DashboardHostAssetError before any module code is imported.
- The If-None-Match comparison moves to conditional-request.ts, shared
  with the display host routes.
- The emailed-link token stripping moves from browser/main.tsx into
  consumeAuthLanding (location and history injected) so it is tested;
  behavior is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 17:54:55 -05:00
The_River 220b7b152a Merge pull request 'feat(api): add unauthenticated health endpoints' (#45) from feat/api-health-endpoints into main
CI / verify (push) Successful in 45s
CI / infrastructure-smoke (push) Successful in 20s
CI / migration-smoke (push) Successful in 1m42s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/45
2026-10-01 22:25:02 +00:00
ekklesiaandClaude Opus 5.5 aaec9789c4 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]>
2026-10-01 16:47:01 -05:00
58 changed files with 2090 additions and 69 deletions
+27
View File
@@ -27,10 +27,13 @@ import type { Context, MiddlewareHandler } from "hono";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
import type { DashboardHostAssets } from "./dashboard-host-assets.js";
import { createDashboardHostRoutes } from "./dashboard-host-routes.js";
import { createDashboardRoutes } from "./dashboard-routes.js";
import { createDeviceRoutes } from "./device-routes.js";
import type { DisplayHostAssets } from "./display-host-assets.js";
import { createGrantsRoutes } from "./grants-routes.js";
import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js";
import { createModuleConfigurationRoutes } from "./module-configuration-routes.js";
import { mountModuleDisplayRoutes } from "./module-display-routes.js";
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
@@ -137,6 +140,20 @@ export interface CreateApiAppOptions {
* presentation only — nothing reachable through this store influences discovery, dependency
* ordering, module startup, or any authorization decision. */
readonly dashboard: DashboardStore;
/**
* The built Dashboard's two files, read once by production startup (ADR 0044). Required rather
* than optional, unlike `displayHostAssets`: every installation has a Dashboard, and a missing
* shell build fails startup instead of leaving this origin answering `/` with a 404. See
* `./dashboard-host-routes.js`.
*/
readonly dashboardHostAssets: DashboardHostAssets;
/**
* 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;
/** 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
@@ -341,6 +358,16 @@ 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));
// The Dashboard page and its one script, at exact literal paths outside `/api/` (ADR 0044). They
// grant no authority and resolve no principal; the page asks `/api/v1/principal` itself. See
// `./dashboard-host-routes.js`.
app.route("/", createDashboardHostRoutes(options.dashboardHostAssets));
app.route(
"/",
createGrantsRoutes({
+16
View File
@@ -0,0 +1,16 @@
/**
* RFC 9110 weak comparison for a GET/HEAD `If-None-Match` precondition, shared by the host-owned
* asset routes that answer `304` against a strong validator they computed from their own bytes.
*/
export function etagMatches(headerValue: string | undefined, etag: string): boolean {
if (headerValue === undefined) {
return false;
}
if (headerValue.trim() === "*") {
return true;
}
return headerValue.split(",").some((candidate) => {
const value = candidate.trim();
return (value.startsWith("W/") ? value.slice(2) : value) === etag;
});
}
+69
View File
@@ -0,0 +1,69 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets";
/**
* The two immutable, platform-owned files that make up the built Ekklesia Dashboard (ADR 0044).
*
* Exactly the model `./display-host-assets.js` uses: read once, before the HTTP listener is bound,
* from the exact URLs the surface package exports. Request input is never involved in this read, the
* package is never exposed as a static directory, and the source map beside the script is never
* read. Text rather than a mutable `Buffer` keeps the frozen snapshot a genuinely immutable value
* every response can reuse.
*/
export interface DashboardHostAssets {
readonly html: string;
readonly htmlEtag: string;
readonly script: string;
readonly scriptEtag: string;
}
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package
* location, and machine path are intentionally not retained or surfaced. */
export class DashboardHostAssetError extends Error {
readonly code = "DASHBOARD_HOST_ASSET_UNAVAILABLE" as const;
constructor() {
super("The Dashboard host browser assets are unavailable.");
this.name = "DashboardHostAssetError";
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
function strongEtag(text: string): string {
return `"${createHash("sha256").update(text).digest("base64url")}"`;
}
/**
* Captures one immutable, internally consistent Dashboard asset snapshot.
*
* Each strong validator is derived from the exact text in the same snapshot, so bytes from one build
* can never be paired with an ETag from another. Empty output is a failed shell build rather than a
* valid page that can never render anything.
*/
export function createDashboardHostAssets(html: string, script: string): DashboardHostAssets {
if (html.trim().length === 0 || script.trim().length === 0) {
throw new DashboardHostAssetError();
}
return Object.freeze({
html,
htmlEtag: strongEtag(html),
script,
scriptEtag: strongEtag(script),
});
}
/** Loads only the two surface-declared files. A missing or unreadable build fails startup rather
* than leaving an API whose own origin answers `/` with a 404. */
export async function loadDashboardHostAssets(): Promise<DashboardHostAssets> {
try {
// Sequential deliberately, as for the display host: diagnostics stay deterministic and no
// rejected sibling read is left running after this function has already failed.
const html = await readFile(new URL(dashboardHostAssetUrls.html), "utf8");
const script = await readFile(new URL(dashboardHostAssetUrls.script), "utf8");
return createDashboardHostAssets(html, script);
} catch {
throw new DashboardHostAssetError();
}
}
+123
View File
@@ -0,0 +1,123 @@
import { Hono } from "hono";
import { etagMatches } from "./conditional-request.js";
import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard-host-assets.js";
/**
* The Dashboard's two host-owned routes (ADR 0044). Neither can collide with anything else the API
* serves: `/` is the only page the Dashboard has — it has no client router, and every emailed
* verification or reset link lands on `/` with a query (ADR 0038) — and the script lives beside, never
* inside, the display host's `/assets/ekklesia-display/` namespace. Module routes all live under
* `/api/v1/modules/<module-id>`.
*/
export const DASHBOARD_HOST_PAGE_PATH = "/";
export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js";
/**
* Revalidate-always with a strong `ETag`, exactly the module UI and display-host script policy. The
* shell emits fixed, unhashed file names, so no response here may be cached as `immutable`: a
* rebuilt Dashboard must reach a browser on its next load, and an unchanged one costs a `304`.
*/
const DASHBOARD_HOST_CACHE_CONTROL = "no-cache";
/**
* Headers the Dashboard page carries on every answer, including a `304`, so a revalidated cache
* entry never keeps an older set.
*
* The page holds the platform sign-in form, so it refuses to be framed by anyone — `X-Frame-Options`
* for older browsers, and CSP `frame-ancestors`, the one directive a policy here carries until the
* full Content-Security-Policy is decided separately. `no-referrer` keeps the page's URL, which can
* briefly hold an emailed reset token before the shell strips it, out of every request it makes.
*/
const DASHBOARD_PAGE_SECURITY_HEADERS = Object.freeze({
"Content-Security-Policy": "frame-ancestors 'none'",
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
});
/** A composition error, detected before any Dashboard route is registered. Carries no file path,
* package location, or asset content. */
export class DashboardHostConfigurationError extends Error {
constructor() {
super("The Dashboard host configuration is inconsistent.");
this.name = "DashboardHostConfigurationError";
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
/** Copies and validates injected assets before registering any route, as the display host does, so
* a composition can never pair bytes with a validator computed from different bytes. */
function snapshotAssets(input: DashboardHostAssets): DashboardHostAssets {
try {
const snapshot = createDashboardHostAssets(input.html, input.script);
if (snapshot.htmlEtag !== input.htmlEtag || snapshot.scriptEtag !== input.scriptEtag) {
throw new DashboardHostConfigurationError();
}
return snapshot;
} catch {
throw new DashboardHostConfigurationError();
}
}
function fixedAsset(
ifNoneMatch: string | undefined,
body: string,
etag: string,
contentType: string,
extraHeaders: Readonly<Record<string, string>>,
): Response {
if (etagMatches(ifNoneMatch, etag)) {
return new Response(null, {
headers: { ...extraHeaders, "Cache-Control": DASHBOARD_HOST_CACHE_CONTROL, ETag: etag },
status: 304,
});
}
return new Response(body, {
headers: {
"X-Content-Type-Options": "nosniff",
...extraHeaders,
"Cache-Control": DASHBOARD_HOST_CACHE_CONTROL,
"Content-Type": contentType,
ETag: etag,
},
status: 200,
});
}
/**
* Serves the built Dashboard from the same origin as `/api`, so the identity system keeps its
* default same-origin shape.
*
* Two exact literal routes over one frozen snapshot: never a static directory, never a path or file
* name taken from the request, and never the source map. Any other path — `/index.html`, `/app.js`,
* the `.map`, a traversal spelling — has no route and falls through to the application's ordinary
* sanitized `404`. Like the display host's code, these bytes grant no authority: the page renders
* the sign-in form until `/api/v1/principal` says otherwise.
*/
export function createDashboardHostRoutes(assets: DashboardHostAssets): Hono {
const snapshot = snapshotAssets(assets);
const routes = new Hono();
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) =>
fixedAsset(
c.req.header("if-none-match"),
snapshot.html,
snapshot.htmlEtag,
"text/html; charset=utf-8",
DASHBOARD_PAGE_SECURITY_HEADERS,
),
);
routes.get(DASHBOARD_HOST_SCRIPT_PATH, (c) =>
fixedAsset(
c.req.header("if-none-match"),
snapshot.script,
snapshot.scriptEtag,
"text/javascript; charset=utf-8",
{},
),
);
return routes;
}
+23
View File
@@ -26,11 +26,31 @@ export interface ApiDatabaseHandle {
readonly module: ModuleDatabase;
readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase;
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
* over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */
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
* authorization schemas plus the module database facade: one validated, timeout-bounded
@@ -58,5 +78,8 @@ export function createApiDatabase(
module: createModuleDatabaseView(pool),
moduleConfiguration: createModuleConfigurationView(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;
}
+1 -14
View File
@@ -6,6 +6,7 @@ import {
getAuthorizedPrincipal,
requirePermission,
} from "./authorization-middleware.js";
import { etagMatches } from "./conditional-request.js";
import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js";
export {
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
@@ -100,20 +101,6 @@ function displayFailure(c: Context): Response {
return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503);
}
/** RFC 9110 weak comparison for a GET/HEAD `If-None-Match` precondition. */
function etagMatches(headerValue: string | undefined, etag: string): boolean {
if (headerValue === undefined) {
return false;
}
if (headerValue.trim() === "*") {
return true;
}
return headerValue.split(",").some((candidate) => {
const value = candidate.trim();
return (value.startsWith("W/") ? value.slice(2) : value) === etag;
});
}
/** Copies and validates injected assets before registering any route. Production's loader already
* creates this exact shape, while this check keeps a test/composition caller from pairing mutable
* bytes with a stale validator. */
+18
View File
@@ -45,9 +45,11 @@ import {
wrapFetchWithClientAddress,
} from "./client-address.js";
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
import { loadDashboardHostAssets } from "./dashboard-host-assets.js";
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
import { withDevicePrincipals } from "./device-principal.js";
import { loadDisplayHostAssets } from "./display-host-assets.js";
import { READINESS_DATABASE_TIMEOUT_MS } from "./health-routes.js";
import { prepareStartupModuleInventory } from "./platform-permissions.js";
const connectionTimeoutMs = 10_000;
@@ -542,9 +544,13 @@ export async function startApi(
// starts and application composition succeeds.
const moduleUi = createModuleUiPublicationRegistry(eligibility.eligibleModules);
let displayHostAssets: Awaited<ReturnType<typeof loadDisplayHostAssets>> | undefined;
let dashboardHostAssets: Awaited<ReturnType<typeof loadDashboardHostAssets>>;
try {
displayHostAssets =
moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets();
// Unconditional, unlike the display host: every installation serves its Dashboard (ADR 0044),
// so a missing shell build fails here, before any module code is imported.
dashboardHostAssets = await loadDashboardHostAssets();
} catch (error) {
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
@@ -576,6 +582,10 @@ export async function startApi(
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;
try {
app = createApiApp({
@@ -620,6 +630,13 @@ export async function startApi(
},
// A typed view over the same single pool as identity, authorization, and the module facade.
dashboard: createDashboardStore(databaseHandle.dashboard),
dashboardHostAssets,
// 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: {
// `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
@@ -677,6 +694,7 @@ export async function startApi(
let shutdownPromise: Promise<void> | undefined;
const shutdown = (): Promise<void> => {
if (!shutdownPromise) {
shuttingDown = true;
shutdownPromise = (async () => {
// 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.
+104 -1
View File
@@ -1,5 +1,5 @@
import { randomBytes } from "node:crypto";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { request as httpRequest } from "node:http";
import net from "node:net";
import { tmpdir } from "node:os";
@@ -26,7 +26,18 @@ import {
import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it, vi } from "vitest";
import type { ClientAddressConfigInput } from "../src/client-address.js";
import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets";
import { createApiRuntimeConfig } from "../src/config.js";
import { createApiDatabase } from "../src/database.js";
import {
DASHBOARD_HOST_PAGE_PATH,
DASHBOARD_HOST_SCRIPT_PATH,
} from "../src/dashboard-host-routes.js";
import {
HEALTH_LIVE_PATH,
HEALTH_READY_PATH,
READINESS_DATABASE_TIMEOUT_MS,
} from "../src/health-routes.js";
import {
MODULE_UI_CACHE_CONTROL,
MODULE_UI_CONTENT_TYPE,
@@ -2977,6 +2988,98 @@ describe.sequential("live API — published module UI artifact", () => {
});
});
describe.sequential("live API — the built Dashboard on the API's own origin", () => {
it("serves the built shell's exact index.html at / and its exact script, and nothing beside them", async () => {
const database = await createDisposableDatabase("dashboard_host");
await applyCoreMigrations(database.url);
const running = await startMigratedApi(database.url);
try {
const origin = apiOrigin(running);
const builtHtml = await readFile(new URL(dashboardHostAssetUrls.html), "utf8");
const builtScript = await readFile(new URL(dashboardHostAssetUrls.script), "utf8");
const page = await fetch(`${origin}${DASHBOARD_HOST_PAGE_PATH}`);
const html = await page.text();
expect(page.status).toBe(200);
expect(page.headers.get("content-type")).toBe("text/html; charset=utf-8");
expect(page.headers.get("cache-control")).toBe("no-cache");
expect(page.headers.get("x-frame-options")).toBe("DENY");
expect(page.headers.get("content-security-policy")).toBe("frame-ancestors 'none'");
expect(page.headers.get("referrer-policy")).toBe("no-referrer");
expect(html).toBe(builtHtml);
expect(html).toContain('id="ekklesia-host"');
expect(html).toContain(`<script src="${DASHBOARD_HOST_SCRIPT_PATH}" type="module"></script>`);
const revalidated = await fetch(`${origin}${DASHBOARD_HOST_PAGE_PATH}`, {
headers: { "if-none-match": page.headers.get("etag") ?? "" },
});
expect(revalidated.status).toBe(304);
const script = await fetch(`${origin}${DASHBOARD_HOST_SCRIPT_PATH}`);
expect(script.status).toBe(200);
expect(script.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
expect(await script.text()).toBe(builtScript);
// The source map sits beside the script on disk and is never published.
const sourceMap = await fetch(`${origin}${DASHBOARD_HOST_SCRIPT_PATH}.map`);
expect(sourceMap.status).toBe(404);
expect(await sourceMap.json()).toEqual({ code: "NOT_FOUND" });
} finally {
await running.shutdown();
}
});
});
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", () => {
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");
+46
View File
@@ -25,6 +25,8 @@ import {
} 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 { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.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";
@@ -104,6 +106,8 @@ function createAuthWiringApp(runtime: EkklesiaIdentityRuntime) {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -121,6 +125,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -355,6 +361,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -378,6 +386,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -403,6 +413,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -430,6 +442,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -457,6 +471,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -481,6 +497,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -507,6 +525,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -530,6 +550,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -555,6 +577,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -587,6 +611,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -620,6 +646,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
@@ -658,6 +686,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
@@ -678,6 +708,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
@@ -700,6 +732,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
@@ -721,6 +755,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
@@ -740,6 +776,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
@@ -766,6 +804,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
@@ -795,6 +835,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
runtime: fakeRuntime(),
@@ -827,6 +869,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({
evaluator: { authorize: evaluatorAuthorize },
@@ -850,6 +894,8 @@ describe("Ekklesia API Hono application", () => {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
@@ -0,0 +1,87 @@
import { createHash } from "node:crypto";
import { beforeEach, describe, expect, it, vi } from "vitest";
const readFileMock = vi.hoisted(() => vi.fn());
vi.mock("node:fs/promises", () => ({ readFile: readFileMock }));
vi.mock("@ekklesia/shell/dashboard-assets", () => ({
dashboardHostAssetUrls: Object.freeze({
html: "file:///surface/browser/index.html",
script: "file:///surface/browser/assets/ekklesia-dashboard/app.js",
}),
}));
import {
createDashboardHostAssets,
DashboardHostAssetError,
loadDashboardHostAssets,
} from "../src/dashboard-host-assets.js";
function sha256Etag(text: string): string {
return `"${createHash("sha256").update(text).digest("base64url")}"`;
}
describe("Dashboard host asset loading", () => {
beforeEach(() => readFileMock.mockReset());
it("reads only the two declared file URLs in order and freezes one consistent snapshot", async () => {
const html = '<!doctype html><div id="ekklesia-host"></div>';
const script = "export const dashboardHost = true;\n";
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce(script);
const assets = await loadDashboardHostAssets();
expect(readFileMock.mock.calls).toEqual([
[new URL("file:///surface/browser/index.html"), "utf8"],
[new URL("file:///surface/browser/assets/ekklesia-dashboard/app.js"), "utf8"],
]);
expect(assets).toEqual({
html,
htmlEtag: sha256Etag(html),
script,
scriptEtag: sha256Etag(script),
});
expect(Object.isFrozen(assets)).toBe(true);
});
it("fails deterministically without attempting a neighboring file or exposing the read error", async () => {
readFileMock.mockRejectedValueOnce(
new Error("ENOENT /home/operator/private/surface-build secret-token"),
);
const failure = await loadDashboardHostAssets().catch((error: unknown) => error);
expect(readFileMock).toHaveBeenCalledTimes(1);
expect(failure).toBeInstanceOf(DashboardHostAssetError);
expect(failure).toMatchObject({
code: "DASHBOARD_HOST_ASSET_UNAVAILABLE",
message: "The Dashboard host browser assets are unavailable.",
name: "DashboardHostAssetError",
});
expect((failure as Error).stack).toBe(
"DashboardHostAssetError: The Dashboard host browser assets are unavailable.",
);
const publicText = `${String(failure)}\n${JSON.stringify(failure)}\n${(failure as Error).stack}`;
for (const secret of ["/home/", "operator", "ENOENT", "secret-token"]) {
expect(publicText).not.toContain(secret);
}
});
it("fails when the script is missing even though the page was read", async () => {
readFileMock
.mockResolvedValueOnce("<!doctype html>")
.mockRejectedValueOnce(new Error("ENOENT app.js"));
await expect(loadDashboardHostAssets()).rejects.toBeInstanceOf(DashboardHostAssetError);
expect(readFileMock).toHaveBeenCalledTimes(2);
});
it("rejects an empty page or script as an unavailable shell build", () => {
for (const [html, script] of [
["", "export {};"],
["<html></html>", " \n"],
] as const) {
expect(() => createDashboardHostAssets(html, script)).toThrow(DashboardHostAssetError);
}
});
});
@@ -0,0 +1,347 @@
import { createHash } from "node:crypto";
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 { describe, expect, it, vi } from "vitest";
// Every byte these routes serve must come from the injected startup snapshot. Any filesystem read
// while answering a request — the hallmark of a static directory — fails these tests.
const readFileMock = vi.hoisted(() => vi.fn());
vi.mock("node:fs/promises", () => ({ readFile: readFileMock }));
import { createApiApp } from "../src/app.js";
import {
createDashboardHostAssets,
type DashboardHostAssets,
} from "../src/dashboard-host-assets.js";
import {
DASHBOARD_HOST_PAGE_PATH,
DASHBOARD_HOST_SCRIPT_PATH,
DashboardHostConfigurationError,
} from "../src/dashboard-host-routes.js";
import { DISPLAY_HOST_SCRIPT_PATH } from "../src/module-display-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";
import { readyHealth } from "./support/ready-health.js";
const trustedOrigin = "https://dashboard.example.test";
const modulePermission = parsePermissionId("dashboard.page.read");
const PAGE_HTML =
'<!doctype html><html lang="en"><body><div id="ekklesia-host"></div><script src="/assets/ekklesia-dashboard/app.js" type="module"></script></body></html>\n';
const SCRIPT = "export const dashboard = 'built shell';\n";
function sha256Etag(text: string): string {
return `"${createHash("sha256").update(text).digest("base64url")}"`;
}
const PAGE_ETAG = sha256Etag(PAGE_HTML);
const SCRIPT_ETAG = sha256Etag(SCRIPT);
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 buildApp(
options: {
readonly assets?: DashboardHostAssets;
readonly moduleRoutes?: readonly RegisteredModuleHttpRoute[];
} = {},
) {
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: options.moduleRoutes ?? Object.freeze([]),
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: fakeModuleServices(),
moduleUi: createModuleUiPublicationRegistry([]),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: options.assets ?? createDashboardHostAssets(PAGE_HTML, SCRIPT),
health: readyHealth(),
runtime,
trustedOrigins: [trustedOrigin],
});
return { app, evaluator, runtime };
}
function expectPageSecurityHeaders(response: Response): void {
expect(response.headers.get("x-frame-options")).toBe("DENY");
expect(response.headers.get("content-security-policy")).toBe("frame-ancestors 'none'");
expect(response.headers.get("referrer-policy")).toBe("no-referrer");
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
}
async function expectSanitizedNotFound(response: Response, label: string): Promise<void> {
expect(response.status, label).toBe(404);
expect(response.headers.get("content-type"), label).toMatch(/^application\/json/u);
expect(await response.json(), label).toEqual({ code: "NOT_FOUND" });
}
describe("GET / (the Dashboard page)", () => {
it("serves the exact startup HTML bytes with revalidation, a strong ETag, and the page security headers", async () => {
const { app } = buildApp();
const response = await app.request(DASHBOARD_HOST_PAGE_PATH);
expect(response.status).toBe(200);
expect(await response.text()).toBe(PAGE_HTML);
expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8");
expect(response.headers.get("cache-control")).toBe("no-cache");
expect(response.headers.get("etag")).toBe(PAGE_ETAG);
expectPageSecurityHeaders(response);
// The one CSP directive is the only one: the full policy is a separate decision.
expect(response.headers.get("content-security-policy")).not.toContain(";");
});
it("serves the same page for an emailed-link landing, since the query never selects a file", async () => {
const { app } = buildApp();
const response = await app.request("/?ekklesia_auth=reset-password&token=abc123");
expect(response.status).toBe(200);
expect(await response.text()).toBe(PAGE_HTML);
expect(response.headers.get("etag")).toBe(PAGE_ETAG);
});
it("answers a matching If-None-Match with an empty 304 that keeps the validator and every page security header", async () => {
const { app } = buildApp();
for (const ifNoneMatch of [PAGE_ETAG, `W/${PAGE_ETAG}`, `"other", ${PAGE_ETAG}`, "*"]) {
const response = await app.request(DASHBOARD_HOST_PAGE_PATH, {
headers: { "If-None-Match": ifNoneMatch },
});
expect(response.status, ifNoneMatch).toBe(304);
expect(await response.text(), ifNoneMatch).toBe("");
expect(response.headers.get("etag")).toBe(PAGE_ETAG);
expect(response.headers.get("cache-control")).toBe("no-cache");
expectPageSecurityHeaders(response);
}
});
it("serves the full page again when the browser holds a different build's validator", async () => {
const { app } = buildApp();
const response = await app.request(DASHBOARD_HOST_PAGE_PATH, {
headers: { "If-None-Match": sha256Etag("an older build") },
});
expect(response.status).toBe(200);
expect(await response.text()).toBe(PAGE_HTML);
});
it("is unauthenticated and outside the API's CORS policy: it resolves no principal and reflects no origin", async () => {
const { app, evaluator, runtime } = buildApp();
const response = await app.request(DASHBOARD_HOST_PAGE_PATH, {
headers: { Cookie: "better-auth.session_token=anything", Origin: trustedOrigin },
});
expect(response.status).toBe(200);
expect(runtime.resolvePrincipal).not.toHaveBeenCalled();
expect(evaluator.authorize).not.toHaveBeenCalled();
expect(response.headers.get("access-control-allow-origin")).toBeNull();
expect(response.headers.get("set-cookie")).toBeNull();
});
});
describe("GET /assets/ekklesia-dashboard/app.js (the Dashboard script)", () => {
it("serves the exact startup script bytes as JavaScript with revalidation and a strong ETag", async () => {
const { app } = buildApp();
const response = await app.request(DASHBOARD_HOST_SCRIPT_PATH);
expect(response.status).toBe(200);
expect(await response.text()).toBe(SCRIPT);
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
expect(response.headers.get("cache-control")).toBe("no-cache");
expect(response.headers.get("etag")).toBe(SCRIPT_ETAG);
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
});
it("answers a matching If-None-Match with an empty 304 carrying the script's own validator", async () => {
const { app } = buildApp();
const response = await app.request(DASHBOARD_HOST_SCRIPT_PATH, {
headers: { "If-None-Match": SCRIPT_ETAG },
});
expect(response.status).toBe(304);
expect(await response.text()).toBe("");
expect(response.headers.get("etag")).toBe(SCRIPT_ETAG);
expect(response.headers.get("cache-control")).toBe("no-cache");
});
it("does not accept the page's validator as the script's", async () => {
const { app } = buildApp();
const response = await app.request(DASHBOARD_HOST_SCRIPT_PATH, {
headers: { "If-None-Match": PAGE_ETAG },
});
expect(response.status).toBe(200);
});
});
describe("Dashboard host boundaries", () => {
it("serves nothing but its two exact paths, answering every other spelling with the sanitized 404", async () => {
const { app } = buildApp();
for (const path of [
"/index.html",
"/app.js",
"/app.js.map",
"/assets/ekklesia-dashboard/app.js.map",
"/assets/ekklesia-dashboard/",
"/assets/ekklesia-dashboard",
"/assets/ekklesia-dashboard/app.js/",
"/assets/ekklesia-dashboard/index.html",
"/assets/",
"/assets/ekklesia-dashboard/APP.JS",
"/dashboard",
"/login",
]) {
await expectSanitizedNotFound(await app.request(path), path);
}
});
it("cannot be walked out of: encoded traversal, separators, and double slashes reach no file", async () => {
const { app } = buildApp();
for (const path of [
"/assets/ekklesia-dashboard/%2e%2e/%2e%2e/package.json",
"/assets/ekklesia-dashboard/..%2fapp.js.map",
"/assets/ekklesia-dashboard/%2e%2e%2fapp.js.map",
"/assets/ekklesia-dashboard/app.js%2f..%2f..%2fpackage.json",
"/assets/ekklesia-dashboard/..%5c..%5cpackage.json",
"/%2e%2e/%2e%2e/etc/passwd",
"/../../etc/passwd",
"//assets/ekklesia-dashboard/app.js",
"/assets//ekklesia-dashboard/app.js",
"/assets/ekklesia-dashboard/app.js%00.map",
]) {
const response = await app.request(path);
expect(await response.text(), path).not.toContain(SCRIPT);
expect(response.status, path).toBe(404);
}
expect(readFileMock).not.toHaveBeenCalled();
});
it("never touches the filesystem while answering, even across every served path", async () => {
const { app } = buildApp();
await app.request(DASHBOARD_HOST_PAGE_PATH);
await app.request(DASHBOARD_HOST_SCRIPT_PATH);
await app.request("/assets/ekklesia-dashboard/app.js.map");
expect(readFileMock).not.toHaveBeenCalled();
});
it("serves GET (and Hono's implicit HEAD) only; every other method is the ordinary 404", async () => {
const { app } = buildApp();
const head = await app.request(DASHBOARD_HOST_PAGE_PATH, { method: "HEAD" });
expect(head.status).toBe(200);
expect(head.headers.get("etag")).toBe(PAGE_ETAG);
expect(await head.text()).toBe("");
for (const path of [DASHBOARD_HOST_PAGE_PATH, DASHBOARD_HOST_SCRIPT_PATH]) {
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
await expectSanitizedNotFound(await app.request(path, { method }), `${method} ${path}`);
}
}
});
it("leaves the API, the display host's namespace, and module routes exactly where they were", async () => {
const moduleHandler = vi.fn<ModuleRouteHandler>(
async () => new Response("module", { status: 200 }),
);
const { app } = buildApp({
moduleRoutes: [
Object.freeze({
bodyMode: "buffered",
handler: moduleHandler,
maxBodyBytes: 1024,
method: "GET",
moduleId: "dashboard",
path: "/",
permission: modulePermission,
}) as RegisteredModuleHttpRoute,
],
});
expect(await (await app.request("/api/v1/principal")).json()).toEqual({ kind: "anonymous" });
expect(await (await app.request("/api/v1/health/live")).json()).toEqual({ status: "live" });
expect(await (await app.request("/api/v1/modules/dashboard")).text()).toBe("module");
await expectSanitizedNotFound(await app.request("/api/v1/unknown"), "/api/v1/unknown");
// No display is published here, so the display host's script is absent rather than answered
// by the Dashboard's.
await expectSanitizedNotFound(
await app.request(DISPLAY_HOST_SCRIPT_PATH),
DISPLAY_HOST_SCRIPT_PATH,
);
expect(DASHBOARD_HOST_SCRIPT_PATH.startsWith("/assets/ekklesia-display/")).toBe(false);
expect(await (await app.request(DASHBOARD_HOST_PAGE_PATH)).text()).toBe(PAGE_HTML);
expect(moduleHandler).toHaveBeenCalledTimes(1);
});
it("refuses at composition a snapshot whose validator does not match its bytes, or empty bytes", () => {
const consistent = createDashboardHostAssets(PAGE_HTML, SCRIPT);
for (const assets of [
{ ...consistent, html: `${PAGE_HTML}<!-- tampered -->` },
{ ...consistent, scriptEtag: PAGE_ETAG },
{ html: "", htmlEtag: sha256Etag(""), script: SCRIPT, scriptEtag: SCRIPT_ETAG },
]) {
expect(() => buildApp({ assets })).toThrow(DashboardHostConfigurationError);
}
});
});
+4
View File
@@ -27,6 +27,8 @@ import { createApiApp } from "../src/app.js";
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
import {
createInMemoryModuleLifecycleStore,
moduleLifecycleSnapshotFor,
@@ -139,6 +141,8 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
const app = createApiApp({
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
dashboard: createInMemoryDashboardStore(options.configuration ?? {}),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleRoutes: Object.freeze([]),
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: Object.freeze({
+373
View File
@@ -0,0 +1,373 @@
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 { testDashboardHostAssets } from "./support/dashboard-host-assets.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(),
dashboardHostAssets: testDashboardHostAssets(),
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);
});
});
+70
View File
@@ -9,11 +9,21 @@ const drizzleAdapterMock = vi.hoisted(() => vi.fn());
const serveMock = vi.hoisted(() => vi.fn());
const processOnSpy = vi.hoisted(() => vi.fn());
const loadDisplayHostAssetsMock = vi.hoisted(() => vi.fn());
const loadDashboardHostAssetsMock = vi.hoisted(() => vi.fn());
vi.mock("pg", () => ({ Pool: poolConstructor }));
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
vi.mock("@hono/node-server", () => ({ serve: serveMock }));
// Every startup reads the built Dashboard (ADR 0044), and this file never depends on a built shell,
// so the loader answers a small consistent snapshot unless a test makes it fail.
vi.mock("../src/dashboard-host-assets.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/dashboard-host-assets.js")>();
loadDashboardHostAssetsMock.mockImplementation(async () =>
actual.createDashboardHostAssets("<!doctype html>", "export {};\n"),
);
return { ...actual, loadDashboardHostAssets: loadDashboardHostAssetsMock };
});
vi.mock("../src/display-host-assets.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/display-host-assets.js")>();
return { ...actual, loadDisplayHostAssets: loadDisplayHostAssetsMock };
@@ -35,7 +45,13 @@ import {
import { PermissionRegistryError } from "@ekklesia/core/authorization";
import { createApiApp } from "../src/app.js";
import { createApiRuntimeConfig } from "../src/config.js";
import { DashboardHostAssetError } from "../src/dashboard-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";
function validConfig() {
@@ -447,6 +463,36 @@ describe("Ekklesia API shutdown lifecycle", () => {
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 () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
@@ -1022,6 +1068,30 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
expect(serveMock).not.toHaveBeenCalled();
});
it("fails on missing Dashboard assets before importing module code and still closes startup resources", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder);
const { poolEnd } = mockPoolAndAuth();
loadDashboardHostAssetsMock.mockRejectedValueOnce(new DashboardHostAssetError());
const failure = await startApi(validConfig(), {
modulesDirectoryOverride: modulesDirectory,
}).catch((error: unknown) => error);
expect(failure).toMatchObject({
code: "DASHBOARD_HOST_ASSET_UNAVAILABLE",
message: "The Dashboard host browser assets are unavailable.",
name: "DashboardHostAssetError",
});
expect((failure as Error).stack).toBe(
"DashboardHostAssetError: The Dashboard host browser assets are unavailable.",
);
expect(recorder.events).toEqual([]);
expect(poolEnd).toHaveBeenCalledTimes(1);
expect(serveMock).not.toHaveBeenCalled();
});
it("does not read display-host assets when the eligible set declares no display", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
@@ -35,6 +35,8 @@ import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js";
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
import {
createFakeModuleSecretSource,
createInMemoryModuleConfigurationStore,
@@ -189,6 +191,8 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
store: authorizationStore,
},
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleConfiguration: {
bootSettings,
discoveredModules: modules,
@@ -38,6 +38,8 @@ import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
import {
createInMemoryModuleLifecycleStore,
@@ -198,6 +200,8 @@ async function install(options: InstallOptions): Promise<Installation> {
store: fakeAuthorizationStore(),
},
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
...(moduleUi.listDisplays().length > 0 && options.withAssets !== false
? { displayHostAssets: options.assets ?? displayHostAssets }
: {}),
@@ -23,6 +23,8 @@ import {
} from "@ekklesia/core/modules";
import { describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
import { createApiApp } from "../src/app.js";
import {
canonicalizeVaryHeader,
@@ -123,6 +125,8 @@ function buildModuleApp(options: {
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
runtime,
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
});
@@ -33,6 +33,8 @@ import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js";
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
@@ -171,6 +173,8 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
store: authorizationStore,
},
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules),
moduleLifecycle: {
@@ -36,6 +36,8 @@ import {
} from "@ekklesia/shell";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
import { createApiApp } from "../src/app.js";
import { createDisplayHostAssets } from "../src/display-host-assets.js";
import {
@@ -288,6 +290,8 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
runtime,
trustedOrigins: ["https://dashboard.example.test"],
});
@@ -25,6 +25,8 @@ import {
import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
import { createApiApp } from "../src/app.js";
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
@@ -208,6 +210,8 @@ async function install(options: InstallOptions): Promise<Installation> {
moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
runtime,
trustedOrigins: [trustedOrigin],
});
+4
View File
@@ -21,6 +21,8 @@ import { Window } from "happy-dom";
import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
import { createApiApp } from "../src/app.js";
import { createDisplayHostAssets } from "../src/display-host-assets.js";
import {
@@ -255,6 +257,8 @@ async function install(
moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
runtime,
trustedOrigins: [trustedOrigin],
});
@@ -0,0 +1,13 @@
import {
createDashboardHostAssets,
type DashboardHostAssets,
} from "../../src/dashboard-host-assets.js";
/** A small, internally consistent Dashboard snapshot for compositions whose tests do not exercise
* the Dashboard host. Its own behavior is tested in `../dashboard-host-routes.test.ts`. */
export function testDashboardHostAssets(): DashboardHostAssets {
return createDashboardHostAssets(
'<!doctype html><div id="ekklesia-host"></div><script src="/assets/ekklesia-dashboard/app.js" type="module"></script>',
"export const dashboardHost = true;\n",
);
}
+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,
};
}
+9 -1
View File
@@ -41,7 +41,7 @@ These rules map platform ownership to filesystem and package boundaries. The exe
general "browser-safe shared packages are fine" relaxation: `api-client`, `auth-client`,
`surface-sdk`, and `module-ui-host` remain rejected inside a module. Both approved packages are
browser-safe by construction — `design-tokens` has no dependencies at all and `ui` depends only on
it — so a module UI file that imports them still cannot typecheck `process.env`, proven by a real
it and React — so a module UI file that imports them still cannot typecheck `process.env`, proven by a real
compile under the exact browser project a module uses. Both are also pinned at the bottom of the
presentation stack by `design-packages-depend-downward-only`: neither may reach an implementation
layer, an API/auth client, or the backend contracts barrel, because a design package that could
@@ -134,6 +134,14 @@ These rules map platform ownership to filesystem and package boundaries. The exe
- Workspace package consumers use declared `exports`, not private subpaths.
- Every internal dependency uses a `workspace:` range and every imported workspace package is
declared by the consumer.
- Code a workspace package ships (its `src/` and `bin/`, excluding tests and declaration files)
declares every runtime import in `dependencies` or `optionalDependencies`. A peer declaration may
stay beside it, but never replaces it: a workspace package is linked into its consumers as a
directory, so its imports resolve from its own `node_modules`, where a consumer never satisfies a
peer, and a production install (`pnpm install --prod`, as the production image runs) omits
development dependencies. Type-only imports are exempt; a `.tsx`/`.jsx` file counts as importing
`react`, because the automatic JSX runtime imports `react/jsx-runtime` implicitly
(`runtime-imports-are-production-dependencies`).
- Workspace aliases are forbidden because they obscure package ownership in the dependency graph.
- Workspace package names are unique and shared packages declare a recognized architectural role.
- Circular source and workspace-package dependencies are rejected.
+112 -3
View File
@@ -77,6 +77,10 @@ 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. |
| `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 /` | Ekklesia | The built Dashboard page (`surfaces/shell`'s `index.html`), served from the same origin as `/api`. `no-cache` with a strong `ETag`; framing refused. See [Dashboard host](#dashboard-host). |
| `GET /assets/ekklesia-dashboard/app.js` | Ekklesia | The Dashboard's one script. `no-cache` with a strong `ETag`; its source map is never served. See [Dashboard host](#dashboard-host). |
| `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). |
| `/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). |
@@ -134,6 +138,65 @@ a new raw token when asked to revoke other sessions. The principal response neve
contains email, name, image, session id or expiry, session token, account/provider details, or any
role, permission, membership, or volunteer data.
## Dashboard host
`apps/api` serves the built Dashboard itself, from the same origin as `/api`
([ADR 0044](../architecture/decisions/0044-self-hosted-reference-deployment.md)). That keeps the
identity system's default same-origin shape: `EKKLESIA_DASHBOARD_ORIGIN` stays unset, the session
cookie stays host-only, and no second static server or proxy file root is needed. It works exactly
like the display host:
- **A closed set of files, read once at startup.** `@ekklesia/shell/dashboard-assets` exports the
exact URLs of two built files, `dist/browser/index.html` and
`dist/browser/assets/ekklesia-dashboard/app.js`. `startApi()` reads both before importing any
module code or binding the listener, and keeps one frozen snapshot of their text with a SHA-256
strong `ETag` for each.
- **A missing build fails startup.** An absent, unreadable, or empty file stops `startApi()` with a
fixed `DashboardHostAssetError` (`DASHBOARD_HOST_ASSET_UNAVAILABLE`), after closing the resources
already opened. It never carries a path or the filesystem error. Build the shell first;
`pnpm api:start` already runs the full build.
- **Two exact routes, never a directory.** `GET /` answers the page and
`GET /assets/ekklesia-dashboard/app.js` answers the script. No request value selects a file, and
nothing touches the filesystem while answering. Every other path, including `/index.html`,
`/app.js`, the script's `.map`, and any traversal spelling, falls through to the ordinary
`404 {"code":"NOT_FOUND"}`. There is no catch-all HTML fallback: the Dashboard has no client
router, and emailed links land on `/` with a query (see
[The four anonymous screens](#the-four-anonymous-screens)). Methods other than `GET` (and Hono's
implicit `HEAD`) are also the ordinary `404`.
- **No source maps.** The build emits `app.js.map` beside the script, but it is not one of the two
files, so it is never served, matching module UI publication (ADR 0025). Browser devtools receive
a `404` for it.
Neither route resolves a principal or reads a cookie, and neither is under `/api/`, so the API's
CORS policy does not apply. The bytes grant no authority: the page renders sign-in until
`/api/v1/principal` says otherwise.
| Header | `GET /` | `GET /assets/ekklesia-dashboard/app.js` |
| --- | --- | --- |
| `Content-Type` | `text/html; charset=utf-8` | `text/javascript; charset=utf-8` |
| `Cache-Control` | `no-cache` | `no-cache` |
| `ETag` | strong SHA-256 of the page | strong SHA-256 of the script |
| `X-Content-Type-Options` | `nosniff` | `nosniff` |
| `X-Frame-Options` | `DENY` | — |
| `Content-Security-Policy` | `frame-ancestors 'none'` | — |
| `Referrer-Policy` | `no-referrer` | — |
**Caching.** The shell emits fixed, unhashed file names, so both files are revalidated on every
load, exactly like module UI artifacts and the display host's script. A matching `If-None-Match`
(strong, weak `W/`, a list, or `*`) answers an empty `304` carrying the same `ETag` and
`Cache-Control`. The page's `304` also carries its security headers, so a revalidated cache entry
keeps them. Neither file is ever `immutable`: a rebuilt Dashboard reaches a browser on its next load.
**Page security headers.** The page holds the platform sign-in form, so it refuses to be framed,
through `X-Frame-Options` and CSP `frame-ancestors`. That is the only directive the policy carries:
the full Content-Security-Policy is a separate decision. `no-referrer` keeps the page URL out of
every request the page makes, which matters while an emailed reset token is briefly in it. The shell
strips that token from the address bar before it renders anything.
**Development.** `pnpm --filter @ekklesia/shell dev` serves `dist/browser` at the same absolute
paths, because the build emits the script at `assets/ekklesia-dashboard/app.js`. `index.html` names
that path absolutely, so the page never depends on the URL that served it.
## Dashboard browser authentication
The Dashboard flow is owned by `surfaces/shell`; modules never drive these routes and the
@@ -1420,6 +1483,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
[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
`startApi()` (the composition function `apps/api/src/server.ts` exports, used by both the
@@ -1504,7 +1612,8 @@ Cookie: better-auth.session_token=<redacted, now revoked>
See the repository root [README](../../README.md#current-status) and
[platform architecture](../architecture/platform-architecture.md#current-implementation-status)
for what has landed across every phase and what remains. Surface work is underway; production
Dashboard asset hosting, enrollment/recovery, display device identity, and public Website routing
remain separate work. This document tracks `apps/api`'s own HTTP, module-runtime, and
for what has landed across every phase and what remains. Surface work is underway; the API now
serves the built Dashboard ([Dashboard host](#dashboard-host)), while its production proxy, TLS,
full Content-Security-Policy, enrollment/recovery, display device identity, and public Website
routing remain separate work. This document tracks `apps/api`'s own HTTP, module-runtime, and
database-composition contracts as they evolve.
+4 -3
View File
@@ -486,9 +486,10 @@ This checklist gates a **public launch**. It does not gate development: Surfaces
work all proceed against the local development infrastructure while every item below is still
outstanding.
1. The reverse proxy serves the built Dashboard assets and `/api` on the intended origin, or an
explicitly split origin is covered by the exact trusted-origin/CORS configuration. The
repository currently supplies neither deployment.
1. The reverse proxy forwards the Dashboard and `/api` on the intended origin, or an explicitly
split origin is covered by the exact trusted-origin/CORS configuration. `apps/api` serves the
built Dashboard itself at `/` ([API § Dashboard host](api.md#dashboard-host)); the repository
does not yet supply the proxy deployment.
2. The origin port is unreachable from the public Internet.
3. The origin port is also unreachable from any untrusted network/VLAN — only the intended private
proxy path connects. Security must not depend on an unpublished DNS name or origin IP.
+2 -2
View File
@@ -19,12 +19,12 @@
"typecheck": "tsc --noEmit --project tsconfig.json"
},
"dependencies": {
"@ekklesia/contracts": "workspace:*"
"@ekklesia/contracts": "workspace:*",
"drizzle-orm": "0.45.2"
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
"@types/node": "24.13.3",
"drizzle-orm": "0.45.2",
"typescript": "7.0.2",
"vitest": "4.1.10"
},
+2 -2
View File
@@ -19,7 +19,8 @@
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.test.json"
},
"dependencies": {
"@ekklesia/design-tokens": "workspace:*"
"@ekklesia/design-tokens": "workspace:*",
"react": "19.2.8"
},
"peerDependencies": {
"react": "19.2.8"
@@ -31,7 +32,6 @@
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4",
"happy-dom": "20.0.11",
"react": "19.2.8",
"react-dom": "19.2.8",
"typescript": "7.0.2",
"vitest": "4.1.10"
+5 -1
View File
@@ -67,7 +67,11 @@ describe("the shared elements consume design tokens, never hardcoded theme value
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
) as { dependencies?: Record<string, string>; peerDependencies?: Record<string, string> };
expect(Object.keys(manifest.dependencies ?? {})).toEqual(["@ekklesia/design-tokens"]);
// React is both a production dependency and a peer: the peer states that the consumer's React
// must be the same one, and the production declaration is what makes the package's own imports
// resolve, since a consumer never satisfies a linked workspace package's peer
// (`runtime-imports-are-production-dependencies`).
expect(Object.keys(manifest.dependencies ?? {})).toEqual(["@ekklesia/design-tokens", "react"]);
expect(Object.keys(manifest.peerDependencies ?? {})).toEqual(["react"]);
});
+6 -6
View File
@@ -201,6 +201,9 @@ importers:
'@ekklesia/contracts':
specifier: workspace:*
version: link:../contracts
drizzle-orm:
specifier: 0.45.2
version: 0.45.2(@types/[email protected])([email protected])([email protected])
devDependencies:
'@biomejs/biome':
specifier: 2.5.3
@@ -208,9 +211,6 @@ importers:
'@types/node':
specifier: 24.13.3
version: 24.13.3
drizzle-orm:
specifier: 0.45.2
version: 0.45.2(@types/[email protected])([email protected])([email protected])
typescript:
specifier: 7.0.2
version: 7.0.2
@@ -287,6 +287,9 @@ importers:
'@ekklesia/design-tokens':
specifier: workspace:*
version: link:../design-tokens
react:
specifier: 19.2.8
version: 19.2.8
devDependencies:
'@biomejs/biome':
specifier: 2.5.3
@@ -306,9 +309,6 @@ importers:
happy-dom:
specifier: 20.0.11
version: 20.0.11
react:
specifier: 19.2.8
version: 19.2.8
react-dom:
specifier: 19.2.8
version: 19.2.8([email protected])
+120
View File
@@ -1,4 +1,5 @@
import { readdir, readFile, stat } from "node:fs/promises";
import { builtinModules } from "node:module";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { parse } from "yaml";
@@ -506,6 +507,82 @@ function collectImportSpecifiers(sourceText: string): string[] {
return [...specifiers];
}
/**
* Whether a file is code a workspace package ships and executes: anything under its `src/` or
* `bin/`, except tests and declaration files. Build scripts, configuration files, and tests run with
* development dependencies installed, so they are outside this rule.
*/
function isRuntimeSourceFile(packageRelativePath: string): boolean {
const normalized = normalizePath(packageRelativePath);
if (!normalized.startsWith("src/") && !normalized.startsWith("bin/")) {
return false;
}
const segments = normalized.split("/");
if (segments.some((segment) => ["__tests__", "test", "tests"].includes(segment))) {
return false;
}
const fileName = segments.at(-1) ?? "";
return !/\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(fileName) && !/\.d\.[cm]?ts$/u.test(fileName);
}
/** `import { type A, type B } from "x"` has no runtime binding: without `verbatimModuleSyntax`, the
* compiler erases it exactly like `import type`. A default or namespace binding, or any specifier
* without `type`, is a runtime import. */
function importClauseIsTypeOnly(clause: string): boolean {
const trimmed = clause.trim();
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
return false;
}
const specifiers = trimmed
.slice(1, -1)
.split(",")
.map((specifier) => specifier.trim())
.filter((specifier) => specifier.length > 0);
return specifiers.length > 0 && specifiers.every((specifier) => /^type\s/u.test(specifier));
}
/**
* Bare specifiers a source file needs at run time: every static, side-effect, and dynamic import
* except type-only ones. A `.tsx`/`.jsx` file also needs `react`, because the platform compiles JSX
* with the automatic runtime (`"jsx": "react-jsx"`), which imports `react/jsx-runtime` without that
* import appearing in the source.
*/
function collectRuntimeImportSpecifiers(sourceText: string, extension: string): string[] {
const specifiers = new Set<string>();
const staticImport = /\b(?:import|export)\s+(type\s+)?([^;]*?)\bfrom\s*["']([^"'\n]+)["']/gu;
for (const match of sourceText.matchAll(staticImport)) {
const [, typeKeyword, clause = "", specifier] = match;
if (specifier && !typeKeyword && !importClauseIsTypeOnly(clause)) {
specifiers.add(specifier);
}
}
const sideEffectImport = /\bimport\s*["']([^"'\n]+)["']/gu;
const dynamicImport = /\b(?:import|require)\s*\(\s*["']([^"'\n]+)["']\s*\)/gu;
for (const pattern of [sideEffectImport, dynamicImport]) {
for (const match of sourceText.matchAll(pattern)) {
if (match[1]) {
specifiers.add(match[1]);
}
}
}
if (extension === ".tsx" || extension === ".jsx") {
specifiers.add("react/jsx-runtime");
}
return [...specifiers];
}
const NODE_BUILTIN_MODULES = new Set(builtinModules);
function isBareSpecifier(specifier: string): boolean {
return !(
specifier.startsWith(".") ||
specifier.startsWith("/") ||
specifier.startsWith("#") ||
specifier.startsWith("node:") ||
/^[a-z][a-z0-9+.-]*:/u.test(specifier)
);
}
async function resolveSourceImport(
fromFile: string,
specifier: string,
@@ -906,6 +983,49 @@ export async function checkBoundaries(rootDirectory: string): Promise<BoundaryRe
sourceGraph.set(relativeFile, new Set());
const sourceText = await readFile(codeFile, "utf8");
// A workspace package is linked into its consumers as a directory, so Node resolves the
// package's own imports from the package's own `node_modules`. A peer dependency is never
// satisfied there by the consumer, and a production install (`pnpm install --prod`, as the
// production image runs) omits development dependencies. Code a package ships must therefore
// declare every runtime import as a production dependency. A peer declaration may stay beside it.
const runtimePackage = findContainingPackage(codeFile, packagesByDepth);
if (runtimePackage && isRuntimeSourceFile(path.relative(runtimePackage.directory, codeFile))) {
const reported = new Set<string>();
for (const specifier of collectRuntimeImportSpecifiers(
sourceText,
path.extname(codeFile).toLowerCase(),
)) {
if (!isBareSpecifier(specifier)) {
continue;
}
const dependencyName = packageNameFromSpecifier(specifier);
const manifest = runtimePackage.manifest;
if (
reported.has(dependencyName) ||
dependencyName === runtimePackage.name ||
NODE_BUILTIN_MODULES.has(dependencyName) ||
manifest.dependencies?.[dependencyName] !== undefined ||
manifest.optionalDependencies?.[dependencyName] !== undefined
) {
continue;
}
reported.add(dependencyName);
const declaredAs =
manifest.peerDependencies?.[dependencyName] !== undefined
? "only as a peer dependency"
: manifest.devDependencies?.[dependencyName] !== undefined
? "only as a development dependency"
: "not at all";
violations.push({
from: relativeFile,
message: `${runtimePackage.name} imports ${dependencyName} at run time but declares it ${declaredAs}; a runtime import must be in dependencies.`,
rule: "runtime-imports-are-production-dependencies",
to: dependencyName,
});
}
}
for (const specifier of collectImportSpecifiers(sourceText)) {
const dependencyName = packageNameFromSpecifier(specifier);
if (from.layer === "module" && isModuleUiPackage(dependencyName)) {
+4 -2
View File
@@ -11,7 +11,9 @@
<div id="ekklesia-host"></div>
<!-- A real module script, from the same origin, with no inline script anywhere on the page:
a deployment can therefore apply a strict script-src without exceptions, and the runtime
`import()` of a published module artifact stays same-origin under it. -->
<script src="./app.js" type="module"></script>
`import()` of a published module artifact stays same-origin under it. The path is absolute
and fixed, exactly like the display host's, so it names one host-owned route rather than a
file relative to whatever URL served the page. -->
<script src="/assets/ekklesia-dashboard/app.js" type="module"></script>
</body>
</html>
+4
View File
@@ -11,6 +11,10 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./dashboard-assets": {
"types": "./dist/dashboard-assets.d.ts",
"import": "./dist/dashboard-assets.js"
},
"./display-assets": {
"types": "./dist/display-assets.d.ts",
"import": "./dist/display-assets.js"
+3 -1
View File
@@ -42,7 +42,9 @@ const buildOptions = {
"process.env.NODE_ENV": JSON.stringify(serve ? "development" : "production"),
},
entryPoints: {
app: path.join(packageDirectory, "src", "browser", "main.tsx"),
// Emitted under the exact path `apps/api` serves it from (ADR 0044), so the development server's
// `servedir` and production answer the same absolute URL `index.html` names.
"assets/ekklesia-dashboard/app": path.join(packageDirectory, "src", "browser", "main.tsx"),
"display/app": path.join(packageDirectory, "src", "display", "main.tsx"),
// The display service worker (ADR 0043). A separate entry because a worker is a separate
// realm with its own globals; bundling it into the page would make it unregisterable and its
+33
View File
@@ -277,3 +277,36 @@ export function readAuthLanding(search: string): HostAuthLanding | undefined {
}
return undefined;
}
/**
* Captures what an emailed verification or reset link asked for, then removes it from the address
* bar.
*
* A password-reset token is a bearer credential for one account. Leaving it in `location.search`
* would put it in the browser's history, in the tab title's shareable URL, and in the `Referer` of
* anything the page subsequently loaded. `history.replaceState` rewrites the entry in place rather
* than pushing a new one, so the Back button cannot return to the URL that held it. It is
* deliberately *not* stored anywhere afterwards — it lives only in React state for the lifetime of
* the reset form, and never in `localStorage` or `sessionStorage`.
*
* The location and history are passed in rather than read here, so this module still reads no
* global and `browser/main.tsx` remains the shell's one browser-global boundary. Only a recognized
* Ekklesia landing rewrites the URL; any other query is left exactly as it arrived.
*/
export function consumeAuthLanding(
location: Pick<Location, "pathname" | "search">,
history: Pick<History, "replaceState">,
): HostAuthLanding | undefined {
const landing = readAuthLanding(location.search);
if (!landing) {
return undefined;
}
try {
history.replaceState(null, "", location.pathname);
} catch {
// A browser that refuses the rewrite (an exotic sandbox, a file: URL) must still be able to
// finish the flow: the token is already captured, and failing here would strand the person on a
// page that cannot do anything.
}
return landing;
}
+4 -31
View File
@@ -1,7 +1,7 @@
import { installEkklesiaDesignTokens } from "@ekklesia/design-tokens";
import { EkklesiaUiStyles } from "@ekklesia/ui";
import { createRoot } from "react-dom/client";
import { type HostAuthLanding, readAuthLanding } from "../auth-protocol.js";
import { consumeAuthLanding } from "../auth-protocol.js";
import type { EkklesiaHostConfig } from "../host-protocol.js";
import { HostApp } from "./HostApp.js";
@@ -26,36 +26,9 @@ const config: EkklesiaHostConfig = {
apiBaseUrl: __EKKLESIA_API_BASE_URL__ || window.location.origin,
};
/**
* Captures what an emailed verification or reset link asked for, then removes it from the address
* bar.
*
* A password-reset token is a bearer credential for one account. Leaving it in `location.search`
* would put it in the browser's history, in the tab title's shareable URL, and in the `Referer` of
* anything the page subsequently loaded. `history.replaceState` rewrites the entry in place rather
* than pushing a new one, so the Back button cannot return to the URL that held it. It is
* deliberately *not* stored anywhere afterwards — it lives only in React state for the lifetime of
* the reset form, and never in `localStorage` or `sessionStorage`.
*
* The parse is a pure function that recognizes only Ekklesia's own two landings, so nothing in the
* query string can name a destination.
*/
function captureAuthLanding(): HostAuthLanding | undefined {
const landing = readAuthLanding(window.location.search);
if (!landing) {
return undefined;
}
try {
window.history.replaceState(null, "", window.location.pathname);
} catch {
// A browser that refuses the rewrite (an exotic sandbox, a file: URL) must still be able to
// finish the flow: the token is already captured, and failing here would strand the person on a
// page that cannot do anything.
}
return landing;
}
const authLanding = captureAuthLanding();
/** Read once, before anything renders, and stripped from the address bar in the same step. See
* `consumeAuthLanding` for why the token must not stay in the URL. */
const authLanding = consumeAuthLanding(window.location, window.history);
const container = document.getElementById("ekklesia-host");
if (!container) {
+15
View File
@@ -0,0 +1,15 @@
/**
* Exact built files a deployable app may publish for the Ekklesia Dashboard (ADR 0044).
*
* The same shape as `./display-assets.ts`, for the same reason: these are file URLs relative to this
* package's compiled `dist/dashboard-assets.js`, not repository paths and not request-derived
* selectors. `apps/api` reads these exact two files before binding and serves their bytes at fixed
* host-owned routes on the same origin as `/api`. Nothing here exposes a directory or a generic
* static-file root, and the source map beside the script is deliberately not listed.
*/
const packageRoot = new URL("../", import.meta.url);
export const dashboardHostAssetUrls = Object.freeze({
html: new URL("dist/browser/index.html", packageRoot).href,
script: new URL("dist/browser/assets/ekklesia-dashboard/app.js", packageRoot).href,
});
+53 -1
View File
@@ -1,7 +1,7 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js";
import { consumeAuthLanding, type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js";
import { HostApp } from "../src/browser/HostApp.js";
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
@@ -140,6 +140,58 @@ async function submit(hostTestId: string): Promise<void> {
});
}
describe("consuming an emailed-link landing", () => {
it("captures a reset token and strips the whole query from the address bar in place", () => {
window.history.replaceState({}, "", "/?ekklesia_auth=reset-password&token=secret-reset-token");
const lengthBefore = window.history.length;
const landing = consumeAuthLanding(window.location, window.history);
expect(landing).toEqual({ kind: "reset-password", token: "secret-reset-token" });
expect(window.location.search).toBe("");
expect(window.location.href).not.toContain("secret-reset-token");
// Replaced, not pushed: Back cannot return to the entry that held the token.
expect(window.history.length).toBe(lengthBefore);
});
it("strips every recognized landing, including a failed verification and an invalid reset link", () => {
for (const search of [
"?ekklesia_auth=verify-email",
"?ekklesia_auth=verify-email&error=TOKEN_EXPIRED",
"?ekklesia_auth=reset-password&error=INVALID_TOKEN",
"?ekklesia_auth=reset-password&token=not%20a%20valid%20token",
]) {
window.history.replaceState({}, "", `/${search}`);
expect(consumeAuthLanding(window.location, window.history), search).toBeDefined();
expect(window.location.search, search).toBe("");
}
});
it("leaves an unrelated query untouched and rewrites nothing", () => {
window.history.replaceState({}, "", "/?unrelated=1");
const replaceState = vi.fn();
expect(consumeAuthLanding(window.location, { replaceState })).toBeUndefined();
expect(replaceState).not.toHaveBeenCalled();
expect(window.location.search).toBe("?unrelated=1");
});
it("still returns the captured landing when the browser refuses the rewrite", () => {
const replaceState = vi.fn(() => {
throw new Error("SecurityError");
});
expect(
consumeAuthLanding(
{ pathname: "/", search: "?ekklesia_auth=reset-password&token=abc123" },
{ replaceState },
),
).toEqual({ kind: "reset-password", token: "abc123" });
expect(replaceState).toHaveBeenCalledWith(null, "", "/");
});
});
describe("emailed-link landing parsing", () => {
it("recognizes exactly Ekklesia's own two landings", () => {
expect(readAuthLanding("?ekklesia_auth=verify-email")).toEqual({ kind: "verified" });
@@ -0,0 +1,55 @@
// @vitest-environment node
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { dashboardHostAssetUrls } from "../src/dashboard-assets.js";
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
describe("Dashboard host asset locator", () => {
it("exports only frozen immutable href strings for the two exact built files, never the source map", () => {
expect(Object.isFrozen(dashboardHostAssetUrls)).toBe(true);
expect(dashboardHostAssetUrls).toEqual({
html: new URL("dist/browser/index.html", `file://${packageDirectory}/`).href,
script: new URL(
"dist/browser/assets/ekklesia-dashboard/app.js",
`file://${packageDirectory}/`,
).href,
});
expect(JSON.stringify(dashboardHostAssetUrls)).not.toContain(".map");
});
it("publishes the locator through a narrow package subpath, not the shell root barrel", async () => {
const manifest = JSON.parse(
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
) as { exports?: Record<string, unknown> };
expect(manifest.exports?.["./dashboard-assets"]).toEqual({
import: "./dist/dashboard-assets.js",
types: "./dist/dashboard-assets.d.ts",
});
const rootSource = await readFile(path.join(packageDirectory, "src/index.ts"), "utf8");
expect(rootSource).not.toContain("dashboardHostAssetUrls");
expect(rootSource).not.toContain("dashboard-assets");
});
it("loads exactly one fixed, absolute, same-origin script and no inline script", async () => {
const html = await readFile(path.join(packageDirectory, "index.html"), "utf8");
expect(html).toContain('id="ekklesia-host"');
expect(html.match(/<script\b/gu)).toHaveLength(1);
expect(html).toContain(
'<script src="/assets/ekklesia-dashboard/app.js" type="module"></script>',
);
});
it("builds the Dashboard entry at the exact path the locator names", async () => {
const buildScript = await readFile(
path.join(packageDirectory, "scripts/build-browser.mjs"),
"utf8",
);
expect(buildScript).toContain('"assets/ekklesia-dashboard/app": path.join(');
});
});
+1
View File
@@ -10,6 +10,7 @@
},
"include": [
"src/index.ts",
"src/dashboard-assets.ts",
"src/display-assets.ts",
"src/host-protocol.ts",
"src/module-context.ts"
+55
View File
@@ -56,6 +56,10 @@ describe("repository dependency boundaries", () => {
["design-package-depends-upward", "design-packages-depend-downward-only"],
["cross-package-relative", "workspace-imports-use-public-exports"],
["cycle", "no-circular-source-dependencies"],
["runtime-peer-only-dependency", "runtime-imports-are-production-dependencies"],
["runtime-dev-only-dependency", "runtime-imports-are-production-dependencies"],
["runtime-jsx-implies-react", "runtime-imports-are-production-dependencies"],
["runtime-undeclared-dependency", "runtime-imports-are-production-dependencies"],
])("rejects %s", async (fixture, expectedRule) => {
const result = await checkBoundaries(path.join(fixtures, fixture));
expect(result.violations.map((violation) => violation.rule)).toContain(expectedRule);
@@ -106,6 +110,57 @@ describe("repository dependency boundaries", () => {
expect(uiViolations.map((violation) => violation.message).join(" ")).not.toContain("compat");
});
it("accepts runtime imports declared as production dependencies, and everything that is not shipped runtime code", async () => {
// Production and optional dependencies, a peer kept beside its production declaration, Node
// built-ins, the package's own exports, type-only imports of development dependencies, and
// tests or build scripts that run with development dependencies installed.
const result = await checkBoundaries(path.join(fixtures, "legal-runtime-dependencies"));
expect(result.violations).toEqual([]);
});
it.each([
[
"runtime-peer-only-dependency",
"packages/sdk/src/index.ts",
"drizzle-orm",
"only as a peer dependency",
],
[
"runtime-dev-only-dependency",
"packages/sdk/src/index.ts",
"zod",
"only as a development dependency",
],
// The automatic JSX runtime imports `react/jsx-runtime` without that import appearing in source.
[
"runtime-jsx-implies-react",
"packages/ui/src/index.tsx",
"react",
"only as a peer dependency",
],
[
"runtime-undeclared-dependency",
"modules/calendar/src/server/index.ts",
"sharp",
"not at all",
],
])("reports %s once, naming the file, the package, and how it is declared", async (fixture, from, to, declaredAs) => {
// A workspace package's imports resolve from its own node_modules: a peer is never satisfied
// there by the consumer, and a production install omits development dependencies, so each of
// these imports fails at run time in the production image even though development works.
const result = await checkBoundaries(path.join(fixtures, fixture));
expect(result.violations).toEqual([
{
from,
message: expect.stringContaining(`declares it ${declaredAs}`),
rule: "runtime-imports-are-production-dependencies",
to,
},
]);
});
it("still rejects module UI source that reaches into Core internals", async () => {
// Allowing `.tsx` inside a module changed which *formats* a module may contain. It did not
// change which layers a module may import: module UI is subject to the identical layer rules
@@ -0,0 +1,29 @@
{
"name": "@ekklesia/sdk",
"version": "0.0.0",
"type": "module",
"dependencies": {
"drizzle-orm": "0.45.2",
"react": "19.2.8",
"polyfill": "1.0.0"
},
"peerDependencies": {
"drizzle-orm": "0.45.2",
"react": "19.2.8"
},
"optionalDependencies": {
"fsevents": "2.3.3"
},
"devDependencies": {
"esbuild": "0.28.1",
"type-only": "1.0.0",
"vitest": "4.1.10"
},
"exports": {
".": "./src/index.ts",
"./schema": "./src/index.ts"
},
"ekklesia": {
"layer": "module-sdk"
}
}
@@ -0,0 +1,3 @@
import { build } from "esbuild";
await build({ entryPoints: ["src/index.ts"] });
@@ -0,0 +1,5 @@
import { describe, it } from "vitest";
describe("sdk", () => {
it("runs with development dependencies installed", () => undefined);
});
@@ -0,0 +1,13 @@
import { readFile } from "fs/promises";
import path from "node:path";
import "polyfill";
import { pgTableCreator } from "drizzle-orm/pg-core";
import type { Config } from "type-only";
import { type Options, type Settings } from "type-only/settings";
export type { Shape } from "type-only/shape";
export { createSchema } from "@ekklesia/sdk/schema";
export async function load(config: Config, options: Options, settings: Settings) {
const watcher = await import("fsevents");
return { config, options, path, readFile, settings, table: pgTableCreator, watcher };
}
@@ -0,0 +1,3 @@
export function View() {
return <div />;
}
@@ -0,0 +1,3 @@
import { build } from "esbuild";
export const builder = build;
@@ -3,7 +3,8 @@
"version": "0.0.0",
"type": "module",
"dependencies": {
"@ekklesia/module-ui-host": "workspace:*"
"@ekklesia/module-ui-host": "workspace:*",
"react": "19.2.8"
},
"exports": {
".": "./src/index.ts"
@@ -2,6 +2,9 @@
"name": "@ekklesia/module-announcements",
"version": "0.0.0",
"type": "module",
"dependencies": {
"react": "19.2.8"
},
"exports": {
".": "./src/index.ts"
},
@@ -0,0 +1,12 @@
{
"name": "@ekklesia/sdk",
"version": "0.0.0",
"type": "module",
"devDependencies": { "zod": "4.0.0" },
"exports": {
".": "./src/index.ts"
},
"ekklesia": {
"layer": "module-sdk"
}
}
@@ -0,0 +1,3 @@
import { z } from "zod";
export const identifier = z.string();
@@ -0,0 +1,13 @@
{
"name": "@ekklesia/ui",
"version": "0.0.0",
"type": "module",
"peerDependencies": { "react": "19.2.8" },
"devDependencies": { "react": "19.2.8" },
"exports": {
".": "./src/index.ts"
},
"ekklesia": {
"layer": "ui"
}
}
@@ -0,0 +1,3 @@
export function Badge(props: { readonly label: string }) {
return <span>{props.label}</span>;
}
@@ -0,0 +1,13 @@
{
"name": "@ekklesia/sdk",
"version": "0.0.0",
"type": "module",
"peerDependencies": { "drizzle-orm": "0.45.2" },
"devDependencies": { "drizzle-orm": "0.45.2" },
"exports": {
".": "./src/index.ts"
},
"ekklesia": {
"layer": "module-sdk"
}
}
@@ -0,0 +1,3 @@
import { pgTableCreator } from "drizzle-orm/pg-core";
export const createTable = pgTableCreator((name) => name);
@@ -0,0 +1,12 @@
{
"name": "@ekklesia/module-calendar",
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"ekklesia": {
"layer": "module"
}
}
@@ -0,0 +1,3 @@
export async function loadSharp() {
return await import("sharp");
}