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 { cors } from "hono/cors";
|
||||
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||
import type { DashboardHostAssets } from "./dashboard-host-assets.js";
|
||||
import { createDashboardHostRoutes } from "./dashboard-host-routes.js";
|
||||
import { createDashboardRoutes } from "./dashboard-routes.js";
|
||||
import { createDeviceRoutes } from "./device-routes.js";
|
||||
import type { DisplayHostAssets } from "./display-host-assets.js";
|
||||
@@ -138,6 +140,13 @@ export interface CreateApiAppOptions {
|
||||
* presentation only — nothing reachable through this store influences discovery, dependency
|
||||
* ordering, module startup, or any authorization decision. */
|
||||
readonly dashboard: DashboardStore;
|
||||
/**
|
||||
* The built Dashboard's two files, read once by production startup (ADR 0044). Required rather
|
||||
* than optional, unlike `displayHostAssets`: every installation has a Dashboard, and a missing
|
||||
* shell build fails startup instead of leaving this origin answering `/` with a 404. See
|
||||
* `./dashboard-host-routes.js`.
|
||||
*/
|
||||
readonly dashboardHostAssets: DashboardHostAssets;
|
||||
/**
|
||||
* The readiness inputs behind the unauthenticated health routes (ADR 0044). Required rather than
|
||||
* defaulted, like every other dependency here: a composition that cannot reach a database says so
|
||||
@@ -354,6 +363,11 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
// own entirely. See `./health-routes.js`.
|
||||
app.route("/", createHealthRoutes(options.health));
|
||||
|
||||
// The Dashboard page and its one script, at exact literal paths outside `/api/` (ADR 0044). They
|
||||
// grant no authority and resolve no principal; the page asks `/api/v1/principal` itself. See
|
||||
// `./dashboard-host-routes.js`.
|
||||
app.route("/", createDashboardHostRoutes(options.dashboardHostAssets));
|
||||
|
||||
app.route(
|
||||
"/",
|
||||
createGrantsRoutes({
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* RFC 9110 weak comparison for a GET/HEAD `If-None-Match` precondition, shared by the host-owned
|
||||
* asset routes that answer `304` against a strong validator they computed from their own bytes.
|
||||
*/
|
||||
export function etagMatches(headerValue: string | undefined, etag: string): boolean {
|
||||
if (headerValue === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (headerValue.trim() === "*") {
|
||||
return true;
|
||||
}
|
||||
return headerValue.split(",").some((candidate) => {
|
||||
const value = candidate.trim();
|
||||
return (value.startsWith("W/") ? value.slice(2) : value) === etag;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets";
|
||||
|
||||
/**
|
||||
* The two immutable, platform-owned files that make up the built Ekklesia Dashboard (ADR 0044).
|
||||
*
|
||||
* Exactly the model `./display-host-assets.js` uses: read once, before the HTTP listener is bound,
|
||||
* from the exact URLs the surface package exports. Request input is never involved in this read, the
|
||||
* package is never exposed as a static directory, and the source map beside the script is never
|
||||
* read. Text rather than a mutable `Buffer` keeps the frozen snapshot a genuinely immutable value
|
||||
* every response can reuse.
|
||||
*/
|
||||
export interface DashboardHostAssets {
|
||||
readonly html: string;
|
||||
readonly htmlEtag: string;
|
||||
readonly script: string;
|
||||
readonly scriptEtag: string;
|
||||
}
|
||||
|
||||
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package
|
||||
* location, and machine path are intentionally not retained or surfaced. */
|
||||
export class DashboardHostAssetError extends Error {
|
||||
readonly code = "DASHBOARD_HOST_ASSET_UNAVAILABLE" as const;
|
||||
|
||||
constructor() {
|
||||
super("The Dashboard host browser assets are unavailable.");
|
||||
this.name = "DashboardHostAssetError";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
function strongEtag(text: string): string {
|
||||
return `"${createHash("sha256").update(text).digest("base64url")}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures one immutable, internally consistent Dashboard asset snapshot.
|
||||
*
|
||||
* Each strong validator is derived from the exact text in the same snapshot, so bytes from one build
|
||||
* can never be paired with an ETag from another. Empty output is a failed shell build rather than a
|
||||
* valid page that can never render anything.
|
||||
*/
|
||||
export function createDashboardHostAssets(html: string, script: string): DashboardHostAssets {
|
||||
if (html.trim().length === 0 || script.trim().length === 0) {
|
||||
throw new DashboardHostAssetError();
|
||||
}
|
||||
return Object.freeze({
|
||||
html,
|
||||
htmlEtag: strongEtag(html),
|
||||
script,
|
||||
scriptEtag: strongEtag(script),
|
||||
});
|
||||
}
|
||||
|
||||
/** Loads only the two surface-declared files. A missing or unreadable build fails startup rather
|
||||
* than leaving an API whose own origin answers `/` with a 404. */
|
||||
export async function loadDashboardHostAssets(): Promise<DashboardHostAssets> {
|
||||
try {
|
||||
// Sequential deliberately, as for the display host: diagnostics stay deterministic and no
|
||||
// rejected sibling read is left running after this function has already failed.
|
||||
const html = await readFile(new URL(dashboardHostAssetUrls.html), "utf8");
|
||||
const script = await readFile(new URL(dashboardHostAssetUrls.script), "utf8");
|
||||
return createDashboardHostAssets(html, script);
|
||||
} catch {
|
||||
throw new DashboardHostAssetError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Hono } from "hono";
|
||||
import { etagMatches } from "./conditional-request.js";
|
||||
import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard-host-assets.js";
|
||||
|
||||
/**
|
||||
* The Dashboard's two host-owned routes (ADR 0044). Neither can collide with anything else the API
|
||||
* serves: `/` is the only page the Dashboard has — it has no client router, and every emailed
|
||||
* verification or reset link lands on `/` with a query (ADR 0038) — and the script lives beside, never
|
||||
* inside, the display host's `/assets/ekklesia-display/` namespace. Module routes all live under
|
||||
* `/api/v1/modules/<module-id>`.
|
||||
*/
|
||||
export const DASHBOARD_HOST_PAGE_PATH = "/";
|
||||
export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js";
|
||||
|
||||
/**
|
||||
* Revalidate-always with a strong `ETag`, exactly the module UI and display-host script policy. The
|
||||
* shell emits fixed, unhashed file names, so no response here may be cached as `immutable`: a
|
||||
* rebuilt Dashboard must reach a browser on its next load, and an unchanged one costs a `304`.
|
||||
*/
|
||||
const DASHBOARD_HOST_CACHE_CONTROL = "no-cache";
|
||||
|
||||
/**
|
||||
* Headers the Dashboard page carries on every answer, including a `304`, so a revalidated cache
|
||||
* entry never keeps an older set.
|
||||
*
|
||||
* The page holds the platform sign-in form, so it refuses to be framed by anyone — `X-Frame-Options`
|
||||
* for older browsers, and CSP `frame-ancestors`, the one directive a policy here carries until the
|
||||
* full Content-Security-Policy is decided separately. `no-referrer` keeps the page's URL, which can
|
||||
* briefly hold an emailed reset token before the shell strips it, out of every request it makes.
|
||||
*/
|
||||
const DASHBOARD_PAGE_SECURITY_HEADERS = Object.freeze({
|
||||
"Content-Security-Policy": "frame-ancestors 'none'",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
});
|
||||
|
||||
/** A composition error, detected before any Dashboard route is registered. Carries no file path,
|
||||
* package location, or asset content. */
|
||||
export class DashboardHostConfigurationError extends Error {
|
||||
constructor() {
|
||||
super("The Dashboard host configuration is inconsistent.");
|
||||
this.name = "DashboardHostConfigurationError";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
/** Copies and validates injected assets before registering any route, as the display host does, so
|
||||
* a composition can never pair bytes with a validator computed from different bytes. */
|
||||
function snapshotAssets(input: DashboardHostAssets): DashboardHostAssets {
|
||||
try {
|
||||
const snapshot = createDashboardHostAssets(input.html, input.script);
|
||||
if (snapshot.htmlEtag !== input.htmlEtag || snapshot.scriptEtag !== input.scriptEtag) {
|
||||
throw new DashboardHostConfigurationError();
|
||||
}
|
||||
return snapshot;
|
||||
} catch {
|
||||
throw new DashboardHostConfigurationError();
|
||||
}
|
||||
}
|
||||
|
||||
function fixedAsset(
|
||||
ifNoneMatch: string | undefined,
|
||||
body: string,
|
||||
etag: string,
|
||||
contentType: string,
|
||||
extraHeaders: Readonly<Record<string, string>>,
|
||||
): Response {
|
||||
if (etagMatches(ifNoneMatch, etag)) {
|
||||
return new Response(null, {
|
||||
headers: { ...extraHeaders, "Cache-Control": DASHBOARD_HOST_CACHE_CONTROL, ETag: etag },
|
||||
status: 304,
|
||||
});
|
||||
}
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
...extraHeaders,
|
||||
"Cache-Control": DASHBOARD_HOST_CACHE_CONTROL,
|
||||
"Content-Type": contentType,
|
||||
ETag: etag,
|
||||
},
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves the built Dashboard from the same origin as `/api`, so the identity system keeps its
|
||||
* default same-origin shape.
|
||||
*
|
||||
* Two exact literal routes over one frozen snapshot: never a static directory, never a path or file
|
||||
* name taken from the request, and never the source map. Any other path — `/index.html`, `/app.js`,
|
||||
* the `.map`, a traversal spelling — has no route and falls through to the application's ordinary
|
||||
* sanitized `404`. Like the display host's code, these bytes grant no authority: the page renders
|
||||
* the sign-in form until `/api/v1/principal` says otherwise.
|
||||
*/
|
||||
export function createDashboardHostRoutes(assets: DashboardHostAssets): Hono {
|
||||
const snapshot = snapshotAssets(assets);
|
||||
const routes = new Hono();
|
||||
|
||||
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) =>
|
||||
fixedAsset(
|
||||
c.req.header("if-none-match"),
|
||||
snapshot.html,
|
||||
snapshot.htmlEtag,
|
||||
"text/html; charset=utf-8",
|
||||
DASHBOARD_PAGE_SECURITY_HEADERS,
|
||||
),
|
||||
);
|
||||
|
||||
routes.get(DASHBOARD_HOST_SCRIPT_PATH, (c) =>
|
||||
fixedAsset(
|
||||
c.req.header("if-none-match"),
|
||||
snapshot.script,
|
||||
snapshot.scriptEtag,
|
||||
"text/javascript; charset=utf-8",
|
||||
{},
|
||||
),
|
||||
);
|
||||
|
||||
return routes;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getAuthorizedPrincipal,
|
||||
requirePermission,
|
||||
} from "./authorization-middleware.js";
|
||||
import { etagMatches } from "./conditional-request.js";
|
||||
import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js";
|
||||
export {
|
||||
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
|
||||
@@ -100,20 +101,6 @@ function displayFailure(c: Context): Response {
|
||||
return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503);
|
||||
}
|
||||
|
||||
/** RFC 9110 weak comparison for a GET/HEAD `If-None-Match` precondition. */
|
||||
function etagMatches(headerValue: string | undefined, etag: string): boolean {
|
||||
if (headerValue === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (headerValue.trim() === "*") {
|
||||
return true;
|
||||
}
|
||||
return headerValue.split(",").some((candidate) => {
|
||||
const value = candidate.trim();
|
||||
return (value.startsWith("W/") ? value.slice(2) : value) === etag;
|
||||
});
|
||||
}
|
||||
|
||||
/** Copies and validates injected assets before registering any route. Production's loader already
|
||||
* creates this exact shape, while this check keeps a test/composition caller from pairing mutable
|
||||
* bytes with a stale validator. */
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
wrapFetchWithClientAddress,
|
||||
} from "./client-address.js";
|
||||
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
|
||||
import { loadDashboardHostAssets } from "./dashboard-host-assets.js";
|
||||
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
|
||||
import { withDevicePrincipals } from "./device-principal.js";
|
||||
import { loadDisplayHostAssets } from "./display-host-assets.js";
|
||||
@@ -543,9 +544,13 @@ export async function startApi(
|
||||
// starts and application composition succeeds.
|
||||
const moduleUi = createModuleUiPublicationRegistry(eligibility.eligibleModules);
|
||||
let displayHostAssets: Awaited<ReturnType<typeof loadDisplayHostAssets>> | undefined;
|
||||
let dashboardHostAssets: Awaited<ReturnType<typeof loadDashboardHostAssets>>;
|
||||
try {
|
||||
displayHostAssets =
|
||||
moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets();
|
||||
// Unconditional, unlike the display host: every installation serves its Dashboard (ADR 0044),
|
||||
// so a missing shell build fails here, before any module code is imported.
|
||||
dashboardHostAssets = await loadDashboardHostAssets();
|
||||
} catch (error) {
|
||||
await safeCloseStorage(storageProvider);
|
||||
await safeCloseMail(mailProvider);
|
||||
@@ -625,6 +630,7 @@ export async function startApi(
|
||||
},
|
||||
// A typed view over the same single pool as identity, authorization, and the module facade.
|
||||
dashboard: createDashboardStore(databaseHandle.dashboard),
|
||||
dashboardHostAssets,
|
||||
// The app is only composed after `startDiscoveredModuleRuntime` resolved, so module startup
|
||||
// has completed whenever it can answer; it stops counting as complete once shutdown begins.
|
||||
health: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import net from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -26,8 +26,13 @@ import {
|
||||
import { Client, type QueryResultRow } from "pg";
|
||||
import { afterAll, describe, expect, it, vi } from "vitest";
|
||||
import type { ClientAddressConfigInput } from "../src/client-address.js";
|
||||
import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets";
|
||||
import { createApiRuntimeConfig } from "../src/config.js";
|
||||
import { createApiDatabase } from "../src/database.js";
|
||||
import {
|
||||
DASHBOARD_HOST_PAGE_PATH,
|
||||
DASHBOARD_HOST_SCRIPT_PATH,
|
||||
} from "../src/dashboard-host-routes.js";
|
||||
import {
|
||||
HEALTH_LIVE_PATH,
|
||||
HEALTH_READY_PATH,
|
||||
@@ -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", () => {
|
||||
it("answers liveness and readiness with 200 against a migrated disposable PostgreSQL", async () => {
|
||||
const database = await createDisposableDatabase("health");
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from "../src/app.js";
|
||||
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||
@@ -105,6 +106,7 @@ function createAuthWiringApp(runtime: EkklesiaIdentityRuntime) {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -123,6 +125,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -358,6 +361,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -382,6 +386,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -408,6 +413,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -436,6 +442,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -464,6 +471,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -489,6 +497,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -516,6 +525,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -540,6 +550,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -566,6 +577,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -599,6 +611,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -633,6 +646,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -672,6 +686,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -693,6 +708,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -716,6 +732,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -738,6 +755,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -758,6 +776,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -785,6 +804,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
@@ -815,6 +835,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
||||
@@ -848,6 +869,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({
|
||||
@@ -872,6 +894,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
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 { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
import {
|
||||
createInMemoryModuleLifecycleStore,
|
||||
@@ -140,6 +141,7 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
|
||||
const app = createApiApp({
|
||||
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
|
||||
dashboard: createInMemoryDashboardStore(options.configuration ?? {}),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
READINESS_DATABASE_TIMEOUT_MS,
|
||||
} from "../src/health-routes.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
||||
@@ -91,6 +92,7 @@ function buildApp(
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health,
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
|
||||
@@ -9,11 +9,21 @@ const drizzleAdapterMock = vi.hoisted(() => vi.fn());
|
||||
const serveMock = vi.hoisted(() => vi.fn());
|
||||
const processOnSpy = vi.hoisted(() => vi.fn());
|
||||
const loadDisplayHostAssetsMock = vi.hoisted(() => vi.fn());
|
||||
const loadDashboardHostAssetsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("pg", () => ({ Pool: poolConstructor }));
|
||||
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
|
||||
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
|
||||
vi.mock("@hono/node-server", () => ({ serve: serveMock }));
|
||||
// Every startup reads the built Dashboard (ADR 0044), and this file never depends on a built shell,
|
||||
// so the loader answers a small consistent snapshot unless a test makes it fail.
|
||||
vi.mock("../src/dashboard-host-assets.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../src/dashboard-host-assets.js")>();
|
||||
loadDashboardHostAssetsMock.mockImplementation(async () =>
|
||||
actual.createDashboardHostAssets("<!doctype html>", "export {};\n"),
|
||||
);
|
||||
return { ...actual, loadDashboardHostAssets: loadDashboardHostAssetsMock };
|
||||
});
|
||||
vi.mock("../src/display-host-assets.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../src/display-host-assets.js")>();
|
||||
return { ...actual, loadDisplayHostAssets: loadDisplayHostAssetsMock };
|
||||
@@ -35,6 +45,7 @@ import {
|
||||
import { PermissionRegistryError } from "@ekklesia/core/authorization";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import { createApiRuntimeConfig } from "../src/config.js";
|
||||
import { DashboardHostAssetError } from "../src/dashboard-host-assets.js";
|
||||
import { DisplayHostAssetError } from "../src/display-host-assets.js";
|
||||
import {
|
||||
HEALTH_LIVE_PATH,
|
||||
@@ -1057,6 +1068,30 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
||||
expect(serveMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails on missing Dashboard assets before importing module code and still closes startup resources", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder);
|
||||
const { poolEnd } = mockPoolAndAuth();
|
||||
loadDashboardHostAssetsMock.mockRejectedValueOnce(new DashboardHostAssetError());
|
||||
|
||||
const failure = await startApi(validConfig(), {
|
||||
modulesDirectoryOverride: modulesDirectory,
|
||||
}).catch((error: unknown) => error);
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
code: "DASHBOARD_HOST_ASSET_UNAVAILABLE",
|
||||
message: "The Dashboard host browser assets are unavailable.",
|
||||
name: "DashboardHostAssetError",
|
||||
});
|
||||
expect((failure as Error).stack).toBe(
|
||||
"DashboardHostAssetError: The Dashboard host browser assets are unavailable.",
|
||||
);
|
||||
expect(recorder.events).toEqual([]);
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
expect(serveMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not read display-host assets when the eligible set declares no display", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
|
||||
@@ -35,6 +35,7 @@ import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||
import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js";
|
||||
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
import {
|
||||
createFakeModuleSecretSource,
|
||||
@@ -190,6 +191,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
||||
store: authorizationStore,
|
||||
},
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleConfiguration: {
|
||||
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 { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||
import {
|
||||
@@ -199,6 +200,7 @@ async function install(options: InstallOptions): Promise<Installation> {
|
||||
store: fakeAuthorizationStore(),
|
||||
},
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
...(moduleUi.listDisplays().length > 0 && options.withAssets !== false
|
||||
? { displayHostAssets: options.assets ?? displayHostAssets }
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "@ekklesia/core/modules";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import {
|
||||
@@ -124,6 +125,7 @@ function buildModuleApp(options: {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
runtime,
|
||||
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_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js";
|
||||
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||
@@ -172,6 +173,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
||||
store: authorizationStore,
|
||||
},
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
moduleStorage: moduleStorageOptionFor(modules),
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from "@ekklesia/shell";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import { createDisplayHostAssets } from "../src/display-host-assets.js";
|
||||
@@ -289,6 +290,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
moduleStorage: moduleStorageOptionFor(modules),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
runtime,
|
||||
trustedOrigins: ["https://dashboard.example.test"],
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import type { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
|
||||
@@ -209,6 +210,7 @@ async function install(options: InstallOptions): Promise<Installation> {
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
moduleStorage: moduleStorageOptionFor(modules),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Window } from "happy-dom";
|
||||
import type { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import { createDisplayHostAssets } from "../src/display-host-assets.js";
|
||||
@@ -256,6 +257,7 @@ async function install(
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
moduleStorage: moduleStorageOptionFor(modules),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
createDashboardHostAssets,
|
||||
type DashboardHostAssets,
|
||||
} from "../../src/dashboard-host-assets.js";
|
||||
|
||||
/** A small, internally consistent Dashboard snapshot for compositions whose tests do not exercise
|
||||
* the Dashboard host. Its own behavior is tested in `../dashboard-host-routes.test.ts`. */
|
||||
export function testDashboardHostAssets(): DashboardHostAssets {
|
||||
return createDashboardHostAssets(
|
||||
'<!doctype html><div id="ekklesia-host"></div><script src="/assets/ekklesia-dashboard/app.js" type="module"></script>',
|
||||
"export const dashboardHost = true;\n",
|
||||
);
|
||||
}
|
||||
+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. |
|
||||
| `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Supported Better Auth routes retain their native shapes except for the successful email sign-in normalization above. The four denied paths above never reach Better Auth. See [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for supported endpoints such as `/sign-up/email` and `/sign-out`. |
|
||||
| `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. |
|
||||
| `GET /` | Ekklesia | The built Dashboard page (`surfaces/shell`'s `index.html`), served from the same origin as `/api`. `no-cache` with a strong `ETag`; framing refused. See [Dashboard host](#dashboard-host). |
|
||||
| `GET /assets/ekklesia-dashboard/app.js` | Ekklesia | The Dashboard's one script. `no-cache` with a strong `ETag`; its source map is never served. See [Dashboard host](#dashboard-host). |
|
||||
| `GET /api/v1/health/live` | Ekklesia | Unauthenticated liveness: fixed `200 {"status":"live"}` whenever the process is serving HTTP. `Cache-Control: no-store`; GET only. See [Health](#health). |
|
||||
| `GET /api/v1/health/ready` | Ekklesia | Unauthenticated readiness: fixed `200 {"status":"ready"}` only when a bounded database ping succeeds and module startup completed, otherwise `503 {"code":"NOT_READY"}`. `Cache-Control: no-store`; GET only. See [Health](#health). |
|
||||
| `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. Listing is bounded keyset pagination; creation is unscoped-only and conflicts (`409`) on an equivalent effective grant; revocation clears every unrevoked equivalent. See [Authorization](authorization.md#explicit-grant-http-api). |
|
||||
@@ -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
|
||||
role, permission, membership, or volunteer data.
|
||||
|
||||
## Dashboard host
|
||||
|
||||
`apps/api` serves the built Dashboard itself, from the same origin as `/api`
|
||||
([ADR 0044](../architecture/decisions/0044-self-hosted-reference-deployment.md)). That keeps the
|
||||
identity system's default same-origin shape: `EKKLESIA_DASHBOARD_ORIGIN` stays unset, the session
|
||||
cookie stays host-only, and no second static server or proxy file root is needed. It works exactly
|
||||
like the display host:
|
||||
|
||||
- **A closed set of files, read once at startup.** `@ekklesia/shell/dashboard-assets` exports the
|
||||
exact URLs of two built files, `dist/browser/index.html` and
|
||||
`dist/browser/assets/ekklesia-dashboard/app.js`. `startApi()` reads both before importing any
|
||||
module code or binding the listener, and keeps one frozen snapshot of their text with a SHA-256
|
||||
strong `ETag` for each.
|
||||
- **A missing build fails startup.** An absent, unreadable, or empty file stops `startApi()` with a
|
||||
fixed `DashboardHostAssetError` (`DASHBOARD_HOST_ASSET_UNAVAILABLE`), after closing the resources
|
||||
already opened. It never carries a path or the filesystem error. Build the shell first;
|
||||
`pnpm api:start` already runs the full build.
|
||||
- **Two exact routes, never a directory.** `GET /` answers the page and
|
||||
`GET /assets/ekklesia-dashboard/app.js` answers the script. No request value selects a file, and
|
||||
nothing touches the filesystem while answering. Every other path, including `/index.html`,
|
||||
`/app.js`, the script's `.map`, and any traversal spelling, falls through to the ordinary
|
||||
`404 {"code":"NOT_FOUND"}`. There is no catch-all HTML fallback: the Dashboard has no client
|
||||
router, and emailed links land on `/` with a query (see
|
||||
[The four anonymous screens](#the-four-anonymous-screens)). Methods other than `GET` (and Hono's
|
||||
implicit `HEAD`) are also the ordinary `404`.
|
||||
- **No source maps.** The build emits `app.js.map` beside the script, but it is not one of the two
|
||||
files, so it is never served, matching module UI publication (ADR 0025). Browser devtools receive
|
||||
a `404` for it.
|
||||
|
||||
Neither route resolves a principal or reads a cookie, and neither is under `/api/`, so the API's
|
||||
CORS policy does not apply. The bytes grant no authority: the page renders sign-in until
|
||||
`/api/v1/principal` says otherwise.
|
||||
|
||||
| Header | `GET /` | `GET /assets/ekklesia-dashboard/app.js` |
|
||||
| --- | --- | --- |
|
||||
| `Content-Type` | `text/html; charset=utf-8` | `text/javascript; charset=utf-8` |
|
||||
| `Cache-Control` | `no-cache` | `no-cache` |
|
||||
| `ETag` | strong SHA-256 of the page | strong SHA-256 of the script |
|
||||
| `X-Content-Type-Options` | `nosniff` | `nosniff` |
|
||||
| `X-Frame-Options` | `DENY` | — |
|
||||
| `Content-Security-Policy` | `frame-ancestors 'none'` | — |
|
||||
| `Referrer-Policy` | `no-referrer` | — |
|
||||
|
||||
**Caching.** The shell emits fixed, unhashed file names, so both files are revalidated on every
|
||||
load, exactly like module UI artifacts and the display host's script. A matching `If-None-Match`
|
||||
(strong, weak `W/`, a list, or `*`) answers an empty `304` carrying the same `ETag` and
|
||||
`Cache-Control`. The page's `304` also carries its security headers, so a revalidated cache entry
|
||||
keeps them. Neither file is ever `immutable`: a rebuilt Dashboard reaches a browser on its next load.
|
||||
|
||||
**Page security headers.** The page holds the platform sign-in form, so it refuses to be framed,
|
||||
through `X-Frame-Options` and CSP `frame-ancestors`. That is the only directive the policy carries:
|
||||
the full Content-Security-Policy is a separate decision. `no-referrer` keeps the page URL out of
|
||||
every request the page makes, which matters while an emailed reset token is briefly in it. The shell
|
||||
strips that token from the address bar before it renders anything.
|
||||
|
||||
**Development.** `pnpm --filter @ekklesia/shell dev` serves `dist/browser` at the same absolute
|
||||
paths, because the build emits the script at `assets/ekklesia-dashboard/app.js`. `index.html` names
|
||||
that path absolutely, so the page never depends on the URL that served it.
|
||||
|
||||
## Dashboard browser authentication
|
||||
|
||||
The Dashboard flow is owned by `surfaces/shell`; modules never drive these routes and the
|
||||
@@ -1551,7 +1612,8 @@ Cookie: better-auth.session_token=<redacted, now revoked>
|
||||
|
||||
See the repository root [README](../../README.md#current-status) and
|
||||
[platform architecture](../architecture/platform-architecture.md#current-implementation-status)
|
||||
for what has landed across every phase and what remains. Surface work is underway; production
|
||||
Dashboard asset hosting, enrollment/recovery, display device identity, and public Website routing
|
||||
remain separate work. This document tracks `apps/api`'s own HTTP, module-runtime, and
|
||||
for what has landed across every phase and what remains. Surface work is underway; the API now
|
||||
serves the built Dashboard ([Dashboard host](#dashboard-host)), while its production proxy, TLS,
|
||||
full Content-Security-Policy, enrollment/recovery, display device identity, and public Website
|
||||
routing remain separate work. This document tracks `apps/api`'s own HTTP, module-runtime, and
|
||||
database-composition contracts as they evolve.
|
||||
|
||||
@@ -486,9 +486,10 @@ This checklist gates a **public launch**. It does not gate development: Surfaces
|
||||
work all proceed against the local development infrastructure while every item below is still
|
||||
outstanding.
|
||||
|
||||
1. The reverse proxy serves the built Dashboard assets and `/api` on the intended origin, or an
|
||||
explicitly split origin is covered by the exact trusted-origin/CORS configuration. The
|
||||
repository currently supplies neither deployment.
|
||||
1. The reverse proxy forwards the Dashboard and `/api` on the intended origin, or an explicitly
|
||||
split origin is covered by the exact trusted-origin/CORS configuration. `apps/api` serves the
|
||||
built Dashboard itself at `/` ([API § Dashboard host](api.md#dashboard-host)); the repository
|
||||
does not yet supply the proxy deployment.
|
||||
2. The origin port is unreachable from the public Internet.
|
||||
3. The origin port is also unreachable from any untrusted network/VLAN — only the intended private
|
||||
proxy path connects. Security must not depend on an unpublished DNS name or origin IP.
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
<div id="ekklesia-host"></div>
|
||||
<!-- A real module script, from the same origin, with no inline script anywhere on the page:
|
||||
a deployment can therefore apply a strict script-src without exceptions, and the runtime
|
||||
`import()` of a published module artifact stays same-origin under it. -->
|
||||
<script src="./app.js" type="module"></script>
|
||||
`import()` of a published module artifact stays same-origin under it. The path is absolute
|
||||
and fixed, exactly like the display host's, so it names one host-owned route rather than a
|
||||
file relative to whatever URL served the page. -->
|
||||
<script src="/assets/ekklesia-dashboard/app.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./dashboard-assets": {
|
||||
"types": "./dist/dashboard-assets.d.ts",
|
||||
"import": "./dist/dashboard-assets.js"
|
||||
},
|
||||
"./display-assets": {
|
||||
"types": "./dist/display-assets.d.ts",
|
||||
"import": "./dist/display-assets.js"
|
||||
|
||||
@@ -42,7 +42,9 @@ const buildOptions = {
|
||||
"process.env.NODE_ENV": JSON.stringify(serve ? "development" : "production"),
|
||||
},
|
||||
entryPoints: {
|
||||
app: path.join(packageDirectory, "src", "browser", "main.tsx"),
|
||||
// Emitted under the exact path `apps/api` serves it from (ADR 0044), so the development server's
|
||||
// `servedir` and production answer the same absolute URL `index.html` names.
|
||||
"assets/ekklesia-dashboard/app": path.join(packageDirectory, "src", "browser", "main.tsx"),
|
||||
"display/app": path.join(packageDirectory, "src", "display", "main.tsx"),
|
||||
// The display service worker (ADR 0043). A separate entry because a worker is a separate
|
||||
// realm with its own globals; bundling it into the page would make it unregisterable and its
|
||||
|
||||
@@ -277,3 +277,36 @@ export function readAuthLanding(search: string): HostAuthLanding | undefined {
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures what an emailed verification or reset link asked for, then removes it from the address
|
||||
* bar.
|
||||
*
|
||||
* A password-reset token is a bearer credential for one account. Leaving it in `location.search`
|
||||
* would put it in the browser's history, in the tab title's shareable URL, and in the `Referer` of
|
||||
* anything the page subsequently loaded. `history.replaceState` rewrites the entry in place rather
|
||||
* than pushing a new one, so the Back button cannot return to the URL that held it. It is
|
||||
* deliberately *not* stored anywhere afterwards — it lives only in React state for the lifetime of
|
||||
* the reset form, and never in `localStorage` or `sessionStorage`.
|
||||
*
|
||||
* The location and history are passed in rather than read here, so this module still reads no
|
||||
* global and `browser/main.tsx` remains the shell's one browser-global boundary. Only a recognized
|
||||
* Ekklesia landing rewrites the URL; any other query is left exactly as it arrived.
|
||||
*/
|
||||
export function consumeAuthLanding(
|
||||
location: Pick<Location, "pathname" | "search">,
|
||||
history: Pick<History, "replaceState">,
|
||||
): HostAuthLanding | undefined {
|
||||
const landing = readAuthLanding(location.search);
|
||||
if (!landing) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
history.replaceState(null, "", location.pathname);
|
||||
} catch {
|
||||
// A browser that refuses the rewrite (an exotic sandbox, a file: URL) must still be able to
|
||||
// finish the flow: the token is already captured, and failing here would strand the person on a
|
||||
// page that cannot do anything.
|
||||
}
|
||||
return landing;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { installEkklesiaDesignTokens } from "@ekklesia/design-tokens";
|
||||
import { EkklesiaUiStyles } from "@ekklesia/ui";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { type HostAuthLanding, readAuthLanding } from "../auth-protocol.js";
|
||||
import { consumeAuthLanding } from "../auth-protocol.js";
|
||||
import type { EkklesiaHostConfig } from "../host-protocol.js";
|
||||
import { HostApp } from "./HostApp.js";
|
||||
|
||||
@@ -26,36 +26,9 @@ const config: EkklesiaHostConfig = {
|
||||
apiBaseUrl: __EKKLESIA_API_BASE_URL__ || window.location.origin,
|
||||
};
|
||||
|
||||
/**
|
||||
* Captures what an emailed verification or reset link asked for, then removes it from the address
|
||||
* bar.
|
||||
*
|
||||
* A password-reset token is a bearer credential for one account. Leaving it in `location.search`
|
||||
* would put it in the browser's history, in the tab title's shareable URL, and in the `Referer` of
|
||||
* anything the page subsequently loaded. `history.replaceState` rewrites the entry in place rather
|
||||
* than pushing a new one, so the Back button cannot return to the URL that held it. It is
|
||||
* deliberately *not* stored anywhere afterwards — it lives only in React state for the lifetime of
|
||||
* the reset form, and never in `localStorage` or `sessionStorage`.
|
||||
*
|
||||
* The parse is a pure function that recognizes only Ekklesia's own two landings, so nothing in the
|
||||
* query string can name a destination.
|
||||
*/
|
||||
function captureAuthLanding(): HostAuthLanding | undefined {
|
||||
const landing = readAuthLanding(window.location.search);
|
||||
if (!landing) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
window.history.replaceState(null, "", window.location.pathname);
|
||||
} catch {
|
||||
// A browser that refuses the rewrite (an exotic sandbox, a file: URL) must still be able to
|
||||
// finish the flow: the token is already captured, and failing here would strand the person on a
|
||||
// page that cannot do anything.
|
||||
}
|
||||
return landing;
|
||||
}
|
||||
|
||||
const authLanding = captureAuthLanding();
|
||||
/** Read once, before anything renders, and stripped from the address bar in the same step. See
|
||||
* `consumeAuthLanding` for why the token must not stay in the URL. */
|
||||
const authLanding = consumeAuthLanding(window.location, window.history);
|
||||
|
||||
const container = document.getElementById("ekklesia-host");
|
||||
if (!container) {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Exact built files a deployable app may publish for the Ekklesia Dashboard (ADR 0044).
|
||||
*
|
||||
* The same shape as `./display-assets.ts`, for the same reason: these are file URLs relative to this
|
||||
* package's compiled `dist/dashboard-assets.js`, not repository paths and not request-derived
|
||||
* selectors. `apps/api` reads these exact two files before binding and serves their bytes at fixed
|
||||
* host-owned routes on the same origin as `/api`. Nothing here exposes a directory or a generic
|
||||
* static-file root, and the source map beside the script is deliberately not listed.
|
||||
*/
|
||||
const packageRoot = new URL("../", import.meta.url);
|
||||
|
||||
export const dashboardHostAssetUrls = Object.freeze({
|
||||
html: new URL("dist/browser/index.html", packageRoot).href,
|
||||
script: new URL("dist/browser/assets/ekklesia-dashboard/app.js", packageRoot).href,
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js";
|
||||
import { consumeAuthLanding, type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js";
|
||||
import { HostApp } from "../src/browser/HostApp.js";
|
||||
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
|
||||
|
||||
@@ -140,6 +140,58 @@ async function submit(hostTestId: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
describe("consuming an emailed-link landing", () => {
|
||||
it("captures a reset token and strips the whole query from the address bar in place", () => {
|
||||
window.history.replaceState({}, "", "/?ekklesia_auth=reset-password&token=secret-reset-token");
|
||||
const lengthBefore = window.history.length;
|
||||
|
||||
const landing = consumeAuthLanding(window.location, window.history);
|
||||
|
||||
expect(landing).toEqual({ kind: "reset-password", token: "secret-reset-token" });
|
||||
expect(window.location.search).toBe("");
|
||||
expect(window.location.href).not.toContain("secret-reset-token");
|
||||
// Replaced, not pushed: Back cannot return to the entry that held the token.
|
||||
expect(window.history.length).toBe(lengthBefore);
|
||||
});
|
||||
|
||||
it("strips every recognized landing, including a failed verification and an invalid reset link", () => {
|
||||
for (const search of [
|
||||
"?ekklesia_auth=verify-email",
|
||||
"?ekklesia_auth=verify-email&error=TOKEN_EXPIRED",
|
||||
"?ekklesia_auth=reset-password&error=INVALID_TOKEN",
|
||||
"?ekklesia_auth=reset-password&token=not%20a%20valid%20token",
|
||||
]) {
|
||||
window.history.replaceState({}, "", `/${search}`);
|
||||
|
||||
expect(consumeAuthLanding(window.location, window.history), search).toBeDefined();
|
||||
expect(window.location.search, search).toBe("");
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves an unrelated query untouched and rewrites nothing", () => {
|
||||
window.history.replaceState({}, "", "/?unrelated=1");
|
||||
const replaceState = vi.fn();
|
||||
|
||||
expect(consumeAuthLanding(window.location, { replaceState })).toBeUndefined();
|
||||
expect(replaceState).not.toHaveBeenCalled();
|
||||
expect(window.location.search).toBe("?unrelated=1");
|
||||
});
|
||||
|
||||
it("still returns the captured landing when the browser refuses the rewrite", () => {
|
||||
const replaceState = vi.fn(() => {
|
||||
throw new Error("SecurityError");
|
||||
});
|
||||
|
||||
expect(
|
||||
consumeAuthLanding(
|
||||
{ pathname: "/", search: "?ekklesia_auth=reset-password&token=abc123" },
|
||||
{ replaceState },
|
||||
),
|
||||
).toEqual({ kind: "reset-password", token: "abc123" });
|
||||
expect(replaceState).toHaveBeenCalledWith(null, "", "/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("emailed-link landing parsing", () => {
|
||||
it("recognizes exactly Ekklesia's own two landings", () => {
|
||||
expect(readAuthLanding("?ekklesia_auth=verify-email")).toEqual({ kind: "verified" });
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// @vitest-environment node
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { dashboardHostAssetUrls } from "../src/dashboard-assets.js";
|
||||
|
||||
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||
|
||||
describe("Dashboard host asset locator", () => {
|
||||
it("exports only frozen immutable href strings for the two exact built files, never the source map", () => {
|
||||
expect(Object.isFrozen(dashboardHostAssetUrls)).toBe(true);
|
||||
expect(dashboardHostAssetUrls).toEqual({
|
||||
html: new URL("dist/browser/index.html", `file://${packageDirectory}/`).href,
|
||||
script: new URL(
|
||||
"dist/browser/assets/ekklesia-dashboard/app.js",
|
||||
`file://${packageDirectory}/`,
|
||||
).href,
|
||||
});
|
||||
expect(JSON.stringify(dashboardHostAssetUrls)).not.toContain(".map");
|
||||
});
|
||||
|
||||
it("publishes the locator through a narrow package subpath, not the shell root barrel", async () => {
|
||||
const manifest = JSON.parse(
|
||||
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
|
||||
) as { exports?: Record<string, unknown> };
|
||||
expect(manifest.exports?.["./dashboard-assets"]).toEqual({
|
||||
import: "./dist/dashboard-assets.js",
|
||||
types: "./dist/dashboard-assets.d.ts",
|
||||
});
|
||||
|
||||
const rootSource = await readFile(path.join(packageDirectory, "src/index.ts"), "utf8");
|
||||
expect(rootSource).not.toContain("dashboardHostAssetUrls");
|
||||
expect(rootSource).not.toContain("dashboard-assets");
|
||||
});
|
||||
|
||||
it("loads exactly one fixed, absolute, same-origin script and no inline script", async () => {
|
||||
const html = await readFile(path.join(packageDirectory, "index.html"), "utf8");
|
||||
|
||||
expect(html).toContain('id="ekklesia-host"');
|
||||
expect(html.match(/<script\b/gu)).toHaveLength(1);
|
||||
expect(html).toContain(
|
||||
'<script src="/assets/ekklesia-dashboard/app.js" type="module"></script>',
|
||||
);
|
||||
});
|
||||
|
||||
it("builds the Dashboard entry at the exact path the locator names", async () => {
|
||||
const buildScript = await readFile(
|
||||
path.join(packageDirectory, "scripts/build-browser.mjs"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(buildScript).toContain('"assets/ekklesia-dashboard/app": path.join(');
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"include": [
|
||||
"src/index.ts",
|
||||
"src/dashboard-assets.ts",
|
||||
"src/display-assets.ts",
|
||||
"src/host-protocol.ts",
|
||||
"src/module-context.ts"
|
||||
|
||||
Reference in New Issue
Block a user