Serve surfaces/shell's built Dashboard from apps/api on the same origin as /api (ADR 0044), exactly as the display host is served: a closed set of files read once at startup, never a static directory, never a path built from request input. - The shell exports @ekklesia/shell/dashboard-assets (the exact built index.html and script URLs), emits the script at assets/ekklesia-dashboard/app.js, and index.html names that absolute path so dev and production serve the same URLs. - GET / and GET /assets/ekklesia-dashboard/app.js answer from a frozen snapshot: no-cache with a strong ETag and 304 on match. Source maps are never served; every other path is the ordinary sanitized 404. - The page sets nosniff, X-Frame-Options: DENY, Content-Security-Policy: frame-ancestors 'none' (that directive only), and Referrer-Policy: no-referrer, on 200 and 304 alike. - A missing or empty shell build fails startup with a fixed DashboardHostAssetError before any module code is imported. - The If-None-Match comparison moves to conditional-request.ts, shared with the display host routes. - The emailed-link token stripping moves from browser/main.tsx into consumeAuthLanding (location and history injected) so it is tested; behavior is unchanged. Co-Authored-By: Claude Opus 5.5 <[email protected]>
348 lines
13 KiB
TypeScript
348 lines
13 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|