feat(api): serve the built Dashboard from the API origin #46
@@ -27,6 +27,8 @@ import type { Context, MiddlewareHandler } from "hono";
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
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 { createDashboardRoutes } from "./dashboard-routes.js";
|
||||||
import { createDeviceRoutes } from "./device-routes.js";
|
import { createDeviceRoutes } from "./device-routes.js";
|
||||||
import type { DisplayHostAssets } from "./display-host-assets.js";
|
import type { DisplayHostAssets } from "./display-host-assets.js";
|
||||||
@@ -138,6 +140,13 @@ export interface CreateApiAppOptions {
|
|||||||
* presentation only — nothing reachable through this store influences discovery, dependency
|
* presentation only — nothing reachable through this store influences discovery, dependency
|
||||||
* ordering, module startup, or any authorization decision. */
|
* ordering, module startup, or any authorization decision. */
|
||||||
readonly dashboard: DashboardStore;
|
readonly dashboard: DashboardStore;
|
||||||
|
/**
|
||||||
|
* The 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
|
* 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
|
* defaulted, like every other dependency here: a composition that cannot reach a database says so
|
||||||
@@ -354,6 +363,11 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
|||||||
// own entirely. See `./health-routes.js`.
|
// own entirely. See `./health-routes.js`.
|
||||||
app.route("/", createHealthRoutes(options.health));
|
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(
|
app.route(
|
||||||
"/",
|
"/",
|
||||||
createGrantsRoutes({
|
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;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
getAuthorizedPrincipal,
|
getAuthorizedPrincipal,
|
||||||
requirePermission,
|
requirePermission,
|
||||||
} from "./authorization-middleware.js";
|
} from "./authorization-middleware.js";
|
||||||
|
import { etagMatches } from "./conditional-request.js";
|
||||||
import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js";
|
import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js";
|
||||||
export {
|
export {
|
||||||
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
|
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
|
||||||
@@ -100,20 +101,6 @@ function displayFailure(c: Context): Response {
|
|||||||
return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503);
|
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
|
/** 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
|
* creates this exact shape, while this check keeps a test/composition caller from pairing mutable
|
||||||
* bytes with a stale validator. */
|
* bytes with a stale validator. */
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
wrapFetchWithClientAddress,
|
wrapFetchWithClientAddress,
|
||||||
} from "./client-address.js";
|
} from "./client-address.js";
|
||||||
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
|
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
|
||||||
|
import { loadDashboardHostAssets } from "./dashboard-host-assets.js";
|
||||||
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
|
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
|
||||||
import { withDevicePrincipals } from "./device-principal.js";
|
import { withDevicePrincipals } from "./device-principal.js";
|
||||||
import { loadDisplayHostAssets } from "./display-host-assets.js";
|
import { loadDisplayHostAssets } from "./display-host-assets.js";
|
||||||
@@ -543,9 +544,13 @@ export async function startApi(
|
|||||||
// starts and application composition succeeds.
|
// starts and application composition succeeds.
|
||||||
const moduleUi = createModuleUiPublicationRegistry(eligibility.eligibleModules);
|
const moduleUi = createModuleUiPublicationRegistry(eligibility.eligibleModules);
|
||||||
let displayHostAssets: Awaited<ReturnType<typeof loadDisplayHostAssets>> | undefined;
|
let displayHostAssets: Awaited<ReturnType<typeof loadDisplayHostAssets>> | undefined;
|
||||||
|
let dashboardHostAssets: Awaited<ReturnType<typeof loadDashboardHostAssets>>;
|
||||||
try {
|
try {
|
||||||
displayHostAssets =
|
displayHostAssets =
|
||||||
moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets();
|
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) {
|
} catch (error) {
|
||||||
await safeCloseStorage(storageProvider);
|
await safeCloseStorage(storageProvider);
|
||||||
await safeCloseMail(mailProvider);
|
await safeCloseMail(mailProvider);
|
||||||
@@ -625,6 +630,7 @@ export async function startApi(
|
|||||||
},
|
},
|
||||||
// A typed view over the same single pool as identity, authorization, and the module facade.
|
// A typed view over the same single pool as identity, authorization, and the module facade.
|
||||||
dashboard: createDashboardStore(databaseHandle.dashboard),
|
dashboard: createDashboardStore(databaseHandle.dashboard),
|
||||||
|
dashboardHostAssets,
|
||||||
// The app is only composed after `startDiscoveredModuleRuntime` resolved, so module startup
|
// 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.
|
// has completed whenever it can answer; it stops counting as complete once shutdown begins.
|
||||||
health: {
|
health: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
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 { request as httpRequest } from "node:http";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
@@ -26,8 +26,13 @@ import {
|
|||||||
import { Client, type QueryResultRow } from "pg";
|
import { Client, type QueryResultRow } from "pg";
|
||||||
import { afterAll, describe, expect, it, vi } from "vitest";
|
import { afterAll, describe, expect, it, vi } from "vitest";
|
||||||
import type { ClientAddressConfigInput } from "../src/client-address.js";
|
import type { ClientAddressConfigInput } from "../src/client-address.js";
|
||||||
|
import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets";
|
||||||
import { createApiRuntimeConfig } from "../src/config.js";
|
import { createApiRuntimeConfig } from "../src/config.js";
|
||||||
import { createApiDatabase } from "../src/database.js";
|
import { createApiDatabase } from "../src/database.js";
|
||||||
|
import {
|
||||||
|
DASHBOARD_HOST_PAGE_PATH,
|
||||||
|
DASHBOARD_HOST_SCRIPT_PATH,
|
||||||
|
} from "../src/dashboard-host-routes.js";
|
||||||
import {
|
import {
|
||||||
HEALTH_LIVE_PATH,
|
HEALTH_LIVE_PATH,
|
||||||
HEALTH_READY_PATH,
|
HEALTH_READY_PATH,
|
||||||
@@ -2983,6 +2988,48 @@ 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", () => {
|
describe.sequential("live API — health", () => {
|
||||||
it("answers liveness and readiness with 200 against a migrated disposable PostgreSQL", async () => {
|
it("answers liveness and readiness with 200 against a migrated disposable PostgreSQL", async () => {
|
||||||
const database = await createDisposableDatabase("health");
|
const database = await createDisposableDatabase("health");
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
} from "../src/app.js";
|
} from "../src/app.js";
|
||||||
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
|
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
import { readyHealth } from "./support/ready-health.js";
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||||
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||||
@@ -105,6 +106,7 @@ function createAuthWiringApp(runtime: EkklesiaIdentityRuntime) {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -123,6 +125,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -358,6 +361,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -382,6 +386,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -408,6 +413,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -436,6 +442,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -464,6 +471,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -489,6 +497,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -516,6 +525,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -540,6 +550,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -566,6 +577,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -599,6 +611,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -633,6 +646,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -672,6 +686,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -693,6 +708,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -716,6 +732,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -738,6 +755,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -758,6 +776,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -785,6 +804,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
@@ -815,6 +835,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
||||||
@@ -848,6 +869,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({
|
authorization: fakeAuthorization({
|
||||||
@@ -872,6 +894,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
|
|||||||
@@ -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,7 @@ import { createApiApp } from "../src/app.js";
|
|||||||
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
|
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
|
||||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
import { readyHealth } from "./support/ready-health.js";
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
import {
|
import {
|
||||||
createInMemoryModuleLifecycleStore,
|
createInMemoryModuleLifecycleStore,
|
||||||
@@ -140,6 +141,7 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
|
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
|
||||||
dashboard: createInMemoryDashboardStore(options.configuration ?? {}),
|
dashboard: createInMemoryDashboardStore(options.configuration ?? {}),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
READINESS_DATABASE_TIMEOUT_MS,
|
READINESS_DATABASE_TIMEOUT_MS,
|
||||||
} from "../src/health-routes.js";
|
} from "../src/health-routes.js";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.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 { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||||
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||||
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
||||||
@@ -91,6 +92,7 @@ function buildApp(
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health,
|
health,
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
|
|||||||
@@ -9,11 +9,21 @@ const drizzleAdapterMock = vi.hoisted(() => vi.fn());
|
|||||||
const serveMock = vi.hoisted(() => vi.fn());
|
const serveMock = vi.hoisted(() => vi.fn());
|
||||||
const processOnSpy = vi.hoisted(() => vi.fn());
|
const processOnSpy = vi.hoisted(() => vi.fn());
|
||||||
const loadDisplayHostAssetsMock = vi.hoisted(() => vi.fn());
|
const loadDisplayHostAssetsMock = vi.hoisted(() => vi.fn());
|
||||||
|
const loadDashboardHostAssetsMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock("pg", () => ({ Pool: poolConstructor }));
|
vi.mock("pg", () => ({ Pool: poolConstructor }));
|
||||||
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
|
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
|
||||||
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
|
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
|
||||||
vi.mock("@hono/node-server", () => ({ serve: serveMock }));
|
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) => {
|
vi.mock("../src/display-host-assets.js", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("../src/display-host-assets.js")>();
|
const actual = await importOriginal<typeof import("../src/display-host-assets.js")>();
|
||||||
return { ...actual, loadDisplayHostAssets: loadDisplayHostAssetsMock };
|
return { ...actual, loadDisplayHostAssets: loadDisplayHostAssetsMock };
|
||||||
@@ -35,6 +45,7 @@ import {
|
|||||||
import { PermissionRegistryError } from "@ekklesia/core/authorization";
|
import { PermissionRegistryError } from "@ekklesia/core/authorization";
|
||||||
import { createApiApp } from "../src/app.js";
|
import { createApiApp } from "../src/app.js";
|
||||||
import { createApiRuntimeConfig } from "../src/config.js";
|
import { createApiRuntimeConfig } from "../src/config.js";
|
||||||
|
import { DashboardHostAssetError } from "../src/dashboard-host-assets.js";
|
||||||
import { DisplayHostAssetError } from "../src/display-host-assets.js";
|
import { DisplayHostAssetError } from "../src/display-host-assets.js";
|
||||||
import {
|
import {
|
||||||
HEALTH_LIVE_PATH,
|
HEALTH_LIVE_PATH,
|
||||||
@@ -1057,6 +1068,30 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
|||||||
expect(serveMock).not.toHaveBeenCalled();
|
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 () => {
|
it("does not read display-host assets when the eligible set declares no display", async () => {
|
||||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
const recorder = createEventRecorder();
|
const recorder = createEventRecorder();
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
|
|||||||
import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js";
|
import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js";
|
||||||
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
import { readyHealth } from "./support/ready-health.js";
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
import {
|
import {
|
||||||
createFakeModuleSecretSource,
|
createFakeModuleSecretSource,
|
||||||
@@ -190,6 +191,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
|||||||
store: authorizationStore,
|
store: authorizationStore,
|
||||||
},
|
},
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
moduleConfiguration: {
|
moduleConfiguration: {
|
||||||
bootSettings,
|
bootSettings,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
|
|||||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||||
import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js";
|
import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
import { readyHealth } from "./support/ready-health.js";
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||||
import {
|
import {
|
||||||
@@ -199,6 +200,7 @@ async function install(options: InstallOptions): Promise<Installation> {
|
|||||||
store: fakeAuthorizationStore(),
|
store: fakeAuthorizationStore(),
|
||||||
},
|
},
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
...(moduleUi.listDisplays().length > 0 && options.withAssets !== false
|
...(moduleUi.listDisplays().length > 0 && options.withAssets !== false
|
||||||
? { displayHostAssets: options.assets ?? displayHostAssets }
|
? { displayHostAssets: options.assets ?? displayHostAssets }
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
import { readyHealth } from "./support/ready-health.js";
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
import { createApiApp } from "../src/app.js";
|
import { createApiApp } from "../src/app.js";
|
||||||
import {
|
import {
|
||||||
@@ -124,6 +125,7 @@ function buildModuleApp(options: {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
|
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
|
|||||||
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
||||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
import { readyHealth } from "./support/ready-health.js";
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js";
|
import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js";
|
||||||
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||||
@@ -172,6 +173,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
|||||||
store: authorizationStore,
|
store: authorizationStore,
|
||||||
},
|
},
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||||
moduleStorage: moduleStorageOptionFor(modules),
|
moduleStorage: moduleStorageOptionFor(modules),
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
} from "@ekklesia/shell";
|
} from "@ekklesia/shell";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
import { readyHealth } from "./support/ready-health.js";
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
import { createApiApp } from "../src/app.js";
|
import { createApiApp } from "../src/app.js";
|
||||||
import { createDisplayHostAssets } from "../src/display-host-assets.js";
|
import { createDisplayHostAssets } from "../src/display-host-assets.js";
|
||||||
@@ -289,6 +290,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
|||||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||||
moduleStorage: moduleStorageOptionFor(modules),
|
moduleStorage: moduleStorageOptionFor(modules),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: ["https://dashboard.example.test"],
|
trustedOrigins: ["https://dashboard.example.test"],
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
import { readyHealth } from "./support/ready-health.js";
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
import { createApiApp } from "../src/app.js";
|
import { createApiApp } from "../src/app.js";
|
||||||
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
|
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
|
||||||
@@ -209,6 +210,7 @@ async function install(options: InstallOptions): Promise<Installation> {
|
|||||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||||
moduleStorage: moduleStorageOptionFor(modules),
|
moduleStorage: moduleStorageOptionFor(modules),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { Window } from "happy-dom";
|
|||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
import { readyHealth } from "./support/ready-health.js";
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
import { createApiApp } from "../src/app.js";
|
import { createApiApp } from "../src/app.js";
|
||||||
import { createDisplayHostAssets } from "../src/display-host-assets.js";
|
import { createDisplayHostAssets } from "../src/display-host-assets.js";
|
||||||
@@ -256,6 +257,7 @@ async function install(
|
|||||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||||
moduleStorage: moduleStorageOptionFor(modules),
|
moduleStorage: moduleStorageOptionFor(modules),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
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",
|
||||||
|
);
|
||||||
|
}
|
||||||
+65
-3
@@ -77,6 +77,8 @@ No real values are shown here — configure per environment and never commit the
|
|||||||
| `/api/auth/{get-session,list-sessions,update-session,change-password}` | Deliberately unavailable | Token-bearing native session inspection/mutation endpoints are denied at the Ekklesia HTTP boundary with sanitized `404 {"code":"NOT_FOUND"}`. Use `/api/v1/principal`; no browser session-list/update or password-management contract exists. |
|
| `/api/auth/{get-session,list-sessions,update-session,change-password}` | Deliberately unavailable | Token-bearing native session inspection/mutation endpoints are denied at the Ekklesia HTTP boundary with sanitized `404 {"code":"NOT_FOUND"}`. Use `/api/v1/principal`; no browser session-list/update or password-management contract exists. |
|
||||||
| `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Supported Better Auth routes retain their native shapes except for the successful email sign-in normalization above. The four denied paths above never reach Better Auth. See [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for supported endpoints such as `/sign-up/email` and `/sign-out`. |
|
| `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Supported Better Auth routes retain their native shapes except for the successful email sign-in normalization above. The four denied paths above never reach Better Auth. See [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for supported endpoints such as `/sign-up/email` and `/sign-out`. |
|
||||||
| `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. |
|
| `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. |
|
||||||
|
| `GET /` | 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/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 /api/v1/health/ready` | Ekklesia | Unauthenticated readiness: fixed `200 {"status":"ready"}` only when a bounded database ping succeeds and module startup completed, otherwise `503 {"code":"NOT_READY"}`. `Cache-Control: no-store`; GET only. See [Health](#health). |
|
||||||
| `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. Listing is bounded keyset pagination; creation is unscoped-only and conflicts (`409`) on an equivalent effective grant; revocation clears every unrevoked equivalent. See [Authorization](authorization.md#explicit-grant-http-api). |
|
| `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. Listing is bounded keyset pagination; creation is unscoped-only and conflicts (`409`) on an equivalent effective grant; revocation clears every unrevoked equivalent. See [Authorization](authorization.md#explicit-grant-http-api). |
|
||||||
@@ -136,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
|
contains email, name, image, session id or expiry, session token, account/provider details, or any
|
||||||
role, permission, membership, or volunteer data.
|
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
|
## Dashboard browser authentication
|
||||||
|
|
||||||
The Dashboard flow is owned by `surfaces/shell`; modules never drive these routes and the
|
The Dashboard flow is owned by `surfaces/shell`; modules never drive these routes and the
|
||||||
@@ -1551,7 +1612,8 @@ Cookie: better-auth.session_token=<redacted, now revoked>
|
|||||||
|
|
||||||
See the repository root [README](../../README.md#current-status) and
|
See the repository root [README](../../README.md#current-status) and
|
||||||
[platform architecture](../architecture/platform-architecture.md#current-implementation-status)
|
[platform architecture](../architecture/platform-architecture.md#current-implementation-status)
|
||||||
for what has landed across every phase and what remains. Surface work is underway; production
|
for what has landed across every phase and what remains. Surface work is underway; the API now
|
||||||
Dashboard asset hosting, enrollment/recovery, display device identity, and public Website routing
|
serves the built Dashboard ([Dashboard host](#dashboard-host)), while its production proxy, TLS,
|
||||||
remain separate work. This document tracks `apps/api`'s own HTTP, module-runtime, and
|
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.
|
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
|
work all proceed against the local development infrastructure while every item below is still
|
||||||
outstanding.
|
outstanding.
|
||||||
|
|
||||||
1. The reverse proxy serves the built Dashboard assets and `/api` on the intended origin, or an
|
1. The reverse proxy forwards the Dashboard and `/api` on the intended origin, or an explicitly
|
||||||
explicitly split origin is covered by the exact trusted-origin/CORS configuration. The
|
split origin is covered by the exact trusted-origin/CORS configuration. `apps/api` serves the
|
||||||
repository currently supplies neither deployment.
|
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.
|
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
|
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.
|
proxy path connects. Security must not depend on an unpublished DNS name or origin IP.
|
||||||
|
|||||||
@@ -11,7 +11,9 @@
|
|||||||
<div id="ekklesia-host"></div>
|
<div id="ekklesia-host"></div>
|
||||||
<!-- A real module script, from the same origin, with no inline script anywhere on the page:
|
<!-- 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
|
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. -->
|
`import()` of a published module artifact stays same-origin under it. The path is absolute
|
||||||
<script src="./app.js" type="module"></script>
|
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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -11,6 +11,10 @@
|
|||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"import": "./dist/index.js"
|
"import": "./dist/index.js"
|
||||||
},
|
},
|
||||||
|
"./dashboard-assets": {
|
||||||
|
"types": "./dist/dashboard-assets.d.ts",
|
||||||
|
"import": "./dist/dashboard-assets.js"
|
||||||
|
},
|
||||||
"./display-assets": {
|
"./display-assets": {
|
||||||
"types": "./dist/display-assets.d.ts",
|
"types": "./dist/display-assets.d.ts",
|
||||||
"import": "./dist/display-assets.js"
|
"import": "./dist/display-assets.js"
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ const buildOptions = {
|
|||||||
"process.env.NODE_ENV": JSON.stringify(serve ? "development" : "production"),
|
"process.env.NODE_ENV": JSON.stringify(serve ? "development" : "production"),
|
||||||
},
|
},
|
||||||
entryPoints: {
|
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"),
|
"display/app": path.join(packageDirectory, "src", "display", "main.tsx"),
|
||||||
// The display service worker (ADR 0043). A separate entry because a worker is a separate
|
// 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
|
// 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;
|
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 { installEkklesiaDesignTokens } from "@ekklesia/design-tokens";
|
||||||
import { EkklesiaUiStyles } from "@ekklesia/ui";
|
import { EkklesiaUiStyles } from "@ekklesia/ui";
|
||||||
import { createRoot } from "react-dom/client";
|
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 type { EkklesiaHostConfig } from "../host-protocol.js";
|
||||||
import { HostApp } from "./HostApp.js";
|
import { HostApp } from "./HostApp.js";
|
||||||
|
|
||||||
@@ -26,36 +26,9 @@ const config: EkklesiaHostConfig = {
|
|||||||
apiBaseUrl: __EKKLESIA_API_BASE_URL__ || window.location.origin,
|
apiBaseUrl: __EKKLESIA_API_BASE_URL__ || window.location.origin,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/** Read once, before anything renders, and stripped from the address bar in the same step. See
|
||||||
* Captures what an emailed verification or reset link asked for, then removes it from the address
|
* `consumeAuthLanding` for why the token must not stay in the URL. */
|
||||||
* bar.
|
const authLanding = consumeAuthLanding(window.location, window.history);
|
||||||
*
|
|
||||||
* 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();
|
|
||||||
|
|
||||||
const container = document.getElementById("ekklesia-host");
|
const container = document.getElementById("ekklesia-host");
|
||||||
if (!container) {
|
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 { act } from "react";
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
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 { HostApp } from "../src/browser/HostApp.js";
|
||||||
import type { EkklesiaHostConfig } from "../src/host-protocol.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", () => {
|
describe("emailed-link landing parsing", () => {
|
||||||
it("recognizes exactly Ekklesia's own two landings", () => {
|
it("recognizes exactly Ekklesia's own two landings", () => {
|
||||||
expect(readAuthLanding("?ekklesia_auth=verify-email")).toEqual({ kind: "verified" });
|
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": [
|
"include": [
|
||||||
"src/index.ts",
|
"src/index.ts",
|
||||||
|
"src/dashboard-assets.ts",
|
||||||
"src/display-assets.ts",
|
"src/display-assets.ts",
|
||||||
"src/host-protocol.ts",
|
"src/host-protocol.ts",
|
||||||
"src/module-context.ts"
|
"src/module-context.ts"
|
||||||
|
|||||||
Reference in New Issue
Block a user