feat(api): serve the built Dashboard from the API origin
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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
220b7b152a
commit
a12cbe9600
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user