feat: support split Dashboard and API origins (ADR 0045)
CI / verify (pull_request) Successful in 50s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Failing after 25s
CI / image-build (pull_request) Successful in 18s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m17s
CI / verify (pull_request) Successful in 50s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Failing after 25s
CI / image-build (pull_request) Successful in 18s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m17s
An installation can now serve the Dashboard and the API on sibling hostnames (dash./api.) beside the default same-origin topology, from one image and with configuration alone. - The Dashboard learns its API origin at run time: the built page carries one empty <meta name="ekklesia-api-origin">, which the API fills with its identity base URL's origin at startup, before computing the page's ETag. The shell accepts only a canonical http(s) origin and otherwise fails closed without making a request. The build-time EKKLESIA_API_BASE_URL is gone from the bundle; the development server injects the same element. - The display host stays on the API's origin, so its device cookies, requests, and offline cache are unchanged. - The API trusts its own origin (trusted origins plus the identity base URL), as Better Auth already does, so the display page's mutations pass the origin check without operators listing it. - Doctor validates EKKLESIA_DASHBOARD_ORIGIN exactly as the API's startup does (it previously skipped it), reports the topology without printing hostnames, and warns when split hostnames are not siblings. - The module UI contract states the CORS header limit (safelisted headers plus Content-Type, safelisted response headers only). - Reference deployment: Caddyfile.split.example (Dashboard hostname serves / and its script; API hostname serves /api/*, /display/*, and the display script; each refuses the other's paths) and EKKLESIA_DASHBOARD_HOSTNAME in the proxy overlay and env examples. - The deploy smoke test runs either topology (CI runs both). The split run installs the module template as a test-only module, checks per-hostname routing and exact CORS, then drives a digest-pinned headless Chromium over the DevTools protocol, with no added dependency: sign-in on the Dashboard hostname, the session cookie host-only on the API hostname, the module UI imported cross-origin and its credentialed request succeeding, the display and its service worker on the API hostname, and sign-out. ADR 0045 is marked Accepted. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2323467aef
commit
a4966dd0dd
@@ -29,6 +29,7 @@ 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_API_ORIGIN_PLACEHOLDER } from "../src/dashboard-host-assets.js";
|
||||
import {
|
||||
DASHBOARD_HOST_PAGE_PATH,
|
||||
DASHBOARD_HOST_SCRIPT_PATH,
|
||||
@@ -2989,7 +2990,7 @@ 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 () => {
|
||||
it("serves the built shell's index.html at /, with only its API origin written in, 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);
|
||||
@@ -3006,7 +3007,15 @@ describe.sequential("live API — the built Dashboard on the API's own origin",
|
||||
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);
|
||||
// The built page carries one empty API-origin element; the API fills in its own origin
|
||||
// (the identity base URL) and changes nothing else (ADR 0045).
|
||||
expect(builtHtml.split(DASHBOARD_API_ORIGIN_PLACEHOLDER)).toHaveLength(2);
|
||||
expect(html).toBe(
|
||||
builtHtml.replace(
|
||||
DASHBOARD_API_ORIGIN_PLACEHOLDER,
|
||||
`<meta content="${identityBaseUrl}" name="ekklesia-api-origin" />`,
|
||||
),
|
||||
);
|
||||
expect(html).toContain('id="ekklesia-host"');
|
||||
expect(html).toContain(`<script src="${DASHBOARD_HOST_SCRIPT_PATH}" type="module"></script>`);
|
||||
|
||||
|
||||
@@ -3,11 +3,16 @@ import {
|
||||
createAuthenticatedPrincipal,
|
||||
type ModuleDatabase,
|
||||
type ModuleRuntimeServices,
|
||||
moduleIdSchema,
|
||||
parseEkklesiaUserId,
|
||||
parsePermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationEvaluator,
|
||||
type AuthorizationStore,
|
||||
CORE_PERMISSION_DECLARATIONS,
|
||||
CORE_PERMISSION_REGISTRY,
|
||||
createPermissionRegistry,
|
||||
type PermissionRegistry,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
@@ -16,6 +21,7 @@ import {
|
||||
createModuleUiPublicationRegistry,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
type ModuleUiPublicationRegistry,
|
||||
StagedModuleRealtimeRegistry,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
@@ -910,3 +916,101 @@ describe("Ekklesia API Hono application", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("split Dashboard and API origins (ADR 0045)", () => {
|
||||
const moduleId = moduleIdSchema.parse("announcements");
|
||||
const permission = parsePermissionId("announcements.slideshow.view");
|
||||
const endpoint = "/api/v1/module-realtime/announcements/state";
|
||||
|
||||
/** The full application with one live channel, a signed-in caller, and an evaluator that allows
|
||||
* it: the same composition a Dashboard on another origin reaches. */
|
||||
function realtimeApp() {
|
||||
const staged = new StagedModuleRealtimeRegistry();
|
||||
const registration = staged.createRegistrationStage(moduleId, new Set([permission]));
|
||||
registration.realtime.channel({ channel: "state", permission });
|
||||
registration.commit();
|
||||
staged.publisherFor(moduleId).publish("state", { mode: "on" } as never);
|
||||
const moduleRealtime = staged.publish();
|
||||
const app = createApiAppWithModules({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
moduleRealtime,
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({
|
||||
evaluator: { authorize: vi.fn().mockResolvedValue({ allowed: true }) },
|
||||
registry: createPermissionRegistry([
|
||||
...CORE_PERMISSION_DECLARATIONS,
|
||||
{
|
||||
baseline: "restricted",
|
||||
description: "View the announcement slideshow.",
|
||||
id: permission,
|
||||
},
|
||||
]),
|
||||
}),
|
||||
runtime: fakeRuntime({
|
||||
resolvePrincipal: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
createAuthenticatedPrincipal(
|
||||
parseEkklesiaUserId("88888888-8888-4888-8888-888888888888"),
|
||||
),
|
||||
),
|
||||
}),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
return { app, moduleRealtime };
|
||||
}
|
||||
|
||||
it("lets the Dashboard's origin open a credentialed realtime stream", async () => {
|
||||
const { app, moduleRealtime } = realtimeApp();
|
||||
|
||||
const response = await app.request(endpoint, {
|
||||
headers: { cookie: "session=1", origin: trustedOrigin },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("text/event-stream; charset=utf-8");
|
||||
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
|
||||
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
|
||||
expect(response.headers.get("vary")).toContain("Origin");
|
||||
await response.body?.cancel();
|
||||
moduleRealtime.closeSubscriptions();
|
||||
});
|
||||
|
||||
it("gives an untrusted origin's realtime request no CORS permission to read the stream", async () => {
|
||||
const { app, moduleRealtime } = realtimeApp();
|
||||
|
||||
const response = await app.request(endpoint, {
|
||||
headers: { cookie: "session=1", origin: untrustedOrigin },
|
||||
});
|
||||
|
||||
expect(response.headers.get("access-control-allow-origin")).toBeNull();
|
||||
await response.body?.cancel();
|
||||
moduleRealtime.closeSubscriptions();
|
||||
});
|
||||
|
||||
it("allows only Content-Type beyond the CORS-safelisted request headers, and exposes none", async () => {
|
||||
const app = createAuthWiringApp(fakeRuntime());
|
||||
|
||||
const response = await app.request("/api/v1/principal", {
|
||||
headers: {
|
||||
"access-control-request-headers": "content-type, x-module-custom",
|
||||
"access-control-request-method": "PATCH",
|
||||
origin: trustedOrigin,
|
||||
},
|
||||
method: "OPTIONS",
|
||||
});
|
||||
|
||||
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
|
||||
expect(response.headers.get("access-control-allow-headers")?.toLowerCase()).toBe(
|
||||
"content-type",
|
||||
);
|
||||
expect(response.headers.get("access-control-expose-headers")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ApiConfigError, createApiRuntimeConfig } from "../src/config.js";
|
||||
import { ApiConfigError, apiTrustedOrigins, createApiRuntimeConfig } from "../src/config.js";
|
||||
|
||||
const validSecret = "correct-horse-battery-staple-0123456789";
|
||||
|
||||
@@ -337,3 +337,24 @@ describe("API runtime configuration", () => {
|
||||
expect(publicText).not.toContain("db.internal.example");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the API's trusted browser origins (ADR 0045)", () => {
|
||||
it("adds the API's own origin to the configured ones, as Better Auth does", () => {
|
||||
const origins = apiTrustedOrigins({
|
||||
baseUrl: "https://api.example.test",
|
||||
trustedOrigins: ["https://dash.example.test"],
|
||||
});
|
||||
|
||||
expect(origins).toEqual(["https://dash.example.test", "https://api.example.test"]);
|
||||
expect(Object.isFrozen(origins)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not duplicate an API origin already listed (the same-origin topology)", () => {
|
||||
expect(
|
||||
apiTrustedOrigins({
|
||||
baseUrl: "https://ekklesia.example.test",
|
||||
trustedOrigins: ["https://ekklesia.example.test"],
|
||||
}),
|
||||
).toEqual(["https://ekklesia.example.test"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,10 +13,14 @@ vi.mock("@ekklesia/shell/dashboard-assets", () => ({
|
||||
|
||||
import {
|
||||
createDashboardHostAssets,
|
||||
DASHBOARD_API_ORIGIN_PLACEHOLDER,
|
||||
DashboardHostAssetError,
|
||||
loadDashboardHostAssets,
|
||||
renderDashboardPage,
|
||||
} from "../src/dashboard-host-assets.js";
|
||||
|
||||
const API_ORIGIN = "https://api.example.test";
|
||||
|
||||
function sha256Etag(text: string): string {
|
||||
return `"${createHash("sha256").update(text).digest("base64url")}"`;
|
||||
}
|
||||
@@ -25,19 +29,20 @@ 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 html = `<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}<div id="ekklesia-host"></div>`;
|
||||
const script = "export const dashboardHost = true;\n";
|
||||
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce(script);
|
||||
|
||||
const assets = await loadDashboardHostAssets();
|
||||
const assets = await loadDashboardHostAssets(API_ORIGIN);
|
||||
|
||||
expect(readFileMock.mock.calls).toEqual([
|
||||
[new URL("file:///surface/browser/index.html"), "utf8"],
|
||||
[new URL("file:///surface/browser/assets/ekklesia-dashboard/app.js"), "utf8"],
|
||||
]);
|
||||
const rendered = `<!doctype html><meta content="${API_ORIGIN}" name="ekklesia-api-origin" /><div id="ekklesia-host"></div>`;
|
||||
expect(assets).toEqual({
|
||||
html,
|
||||
htmlEtag: sha256Etag(html),
|
||||
html: rendered,
|
||||
htmlEtag: sha256Etag(rendered),
|
||||
script,
|
||||
scriptEtag: sha256Etag(script),
|
||||
});
|
||||
@@ -49,7 +54,7 @@ describe("Dashboard host asset loading", () => {
|
||||
new Error("ENOENT /home/operator/private/surface-build secret-token"),
|
||||
);
|
||||
|
||||
const failure = await loadDashboardHostAssets().catch((error: unknown) => error);
|
||||
const failure = await loadDashboardHostAssets(API_ORIGIN).catch((error: unknown) => error);
|
||||
|
||||
expect(readFileMock).toHaveBeenCalledTimes(1);
|
||||
expect(failure).toBeInstanceOf(DashboardHostAssetError);
|
||||
@@ -69,10 +74,12 @@ describe("Dashboard host asset loading", () => {
|
||||
|
||||
it("fails when the script is missing even though the page was read", async () => {
|
||||
readFileMock
|
||||
.mockResolvedValueOnce("<!doctype html>")
|
||||
.mockResolvedValueOnce(`<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}`)
|
||||
.mockRejectedValueOnce(new Error("ENOENT app.js"));
|
||||
|
||||
await expect(loadDashboardHostAssets()).rejects.toBeInstanceOf(DashboardHostAssetError);
|
||||
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
|
||||
DashboardHostAssetError,
|
||||
);
|
||||
expect(readFileMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -84,4 +91,41 @@ describe("Dashboard host asset loading", () => {
|
||||
expect(() => createDashboardHostAssets(html, script)).toThrow(DashboardHostAssetError);
|
||||
}
|
||||
});
|
||||
|
||||
it("fails startup on a page without exactly one empty API-origin element (ADR 0045)", async () => {
|
||||
for (const html of [
|
||||
"<!doctype html>",
|
||||
`<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}${DASHBOARD_API_ORIGIN_PLACEHOLDER}`,
|
||||
'<!doctype html><meta content="https://elsewhere.test" name="ekklesia-api-origin" />',
|
||||
]) {
|
||||
readFileMock.mockReset();
|
||||
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce("export {};\n");
|
||||
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
|
||||
DashboardHostAssetError,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dashboard page API origin (ADR 0045)", () => {
|
||||
const page = `<head>${DASHBOARD_API_ORIGIN_PLACEHOLDER}</head>`;
|
||||
|
||||
it("writes the API's origin into the one placeholder, in either topology", () => {
|
||||
expect(renderDashboardPage(page, "https://api.example.test")).toBe(
|
||||
'<head><meta content="https://api.example.test" name="ekklesia-api-origin" /></head>',
|
||||
);
|
||||
expect(renderDashboardPage(page, "http://127.0.0.1:3000")).toBe(
|
||||
'<head><meta content="http://127.0.0.1:3000" name="ekklesia-api-origin" /></head>',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"https://api.example.test/",
|
||||
"https://api.example.test/api",
|
||||
'https://api.example.test"><script>',
|
||||
"javascript:alert(1)",
|
||||
"",
|
||||
])("refuses a value that is not a canonical http(s) origin: %s", (origin) => {
|
||||
expect(() => renderDashboardPage(page, origin)).toThrow(DashboardHostAssetError);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user