Compare commits
6
Commits
v0.1.0
...
ef1383d080
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef1383d080 | ||
|
|
fc422b6d8d | ||
|
|
d5d4928b9f | ||
|
|
a12cbe9600 | ||
|
|
220b7b152a | ||
|
|
aaec9789c4 |
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
|
||||
Generated
+6
-6
@@ -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])
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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(');
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"include": [
|
||||
"src/index.ts",
|
||||
"src/dashboard-assets.ts",
|
||||
"src/display-assets.ts",
|
||||
"src/host-protocol.ts",
|
||||
"src/module-context.ts"
|
||||
|
||||
@@ -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
|
||||
|
||||
+29
@@ -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"
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import { build } from "esbuild";
|
||||
|
||||
await build({ entryPoints: ["src/index.ts"] });
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
describe("sdk", () => {
|
||||
it("runs with development dependencies installed", () => undefined);
|
||||
});
|
||||
+13
@@ -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 };
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export function View() {
|
||||
return <div />;
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import { build } from "esbuild";
|
||||
|
||||
export const builder = build;
|
||||
+2
-1
@@ -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"
|
||||
|
||||
+3
@@ -2,6 +2,9 @@
|
||||
"name": "@ekklesia/module-announcements",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"react": "19.2.8"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
|
||||
+12
@@ -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"
|
||||
}
|
||||
}
|
||||
+3
@@ -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>;
|
||||
}
|
||||
+13
@@ -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"
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import { pgTableCreator } from "drizzle-orm/pg-core";
|
||||
|
||||
export const createTable = pgTableCreator((name) => name);
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@ekklesia/module-calendar",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"ekklesia": {
|
||||
"layer": "module"
|
||||
}
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
export async function loadSharp() {
|
||||
return await import("sharp");
|
||||
}
|
||||
Reference in New Issue
Block a user