feat: support split Dashboard and API origins (ADR 0045)
CI / verify (pull_request) Successful in 49s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 1m47s
CI / image-build (pull_request) Successful in 19s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m4s

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:
ekklesia
2026-10-03 14:47:36 -05:00
co-authored by Claude Opus 5.5
parent cbf5962d04
commit f63f80f2f0
32 changed files with 1275 additions and 116 deletions
+4
View File
@@ -3,6 +3,10 @@
<head>
<meta charset="utf-8" />
<meta content="width=device-width, initial-scale=1" name="viewport" />
<!-- The API's origin (ADR 0045). The API serving this page fills it in at startup; empty means
this page's own origin, the same-origin topology. Never an inline script, so a strict
script-src needs no exception for it. -->
<meta content="" name="ekklesia-api-origin" />
<title>Ekklesia</title>
</head>
<body style="margin: 0; min-width: 0">
+21 -8
View File
@@ -1,4 +1,4 @@
import { copyFile, mkdir, rm } from "node:fs/promises";
import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import esbuild from "esbuild";
@@ -23,12 +23,16 @@ const outputDirectory = path.join(packageDirectory, "dist", "browser");
const serve = process.argv.includes("--serve");
/**
* Empty means "the API is on this page's own origin" — the expected production shape. Set it to an
* absolute origin for split-origin development (`EKKLESIA_API_BASE_URL=http://127.0.0.1:3000`), and
* add this page's own origin to `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` so the API reflects it for CORS
* and the session cookie travels.
* Development only: the API origin the development server writes into its copy of the Dashboard page,
* exactly as `apps/api` does at startup in production (ADR 0045). Empty means "the API is on this
* page's own origin". Set it to an absolute origin for split-origin development
* (`EKKLESIA_API_BASE_URL=http://127.0.0.1:3000`), and add this page's own origin to
* `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` so the API reflects it for CORS and the session cookie travels.
* A production build never reads it: the built page always carries an empty value, and the bundle
* carries no origin at all.
*/
const apiBaseUrl = process.env.EKKLESIA_API_BASE_URL ?? "";
const apiBaseUrl = serve ? (process.env.EKKLESIA_API_BASE_URL ?? "") : "";
const API_ORIGIN_PLACEHOLDER = '<meta content="" name="ekklesia-api-origin" />';
/** @type {import("esbuild").BuildOptions} */
const buildOptions = {
@@ -37,7 +41,6 @@ const buildOptions = {
// artifacts it is told about, which are real URLs rather than bare specifiers.
bundle: true,
define: {
__EKKLESIA_API_BASE_URL__: JSON.stringify(apiBaseUrl),
// React reads this at module scope and a browser has no `process`.
"process.env.NODE_ENV": JSON.stringify(serve ? "development" : "production"),
},
@@ -67,7 +70,17 @@ const buildOptions = {
await rm(outputDirectory, { force: true, recursive: true });
await mkdir(outputDirectory, { recursive: true });
await mkdir(path.join(outputDirectory, "display"), { recursive: true });
await copyFile(path.join(packageDirectory, "index.html"), path.join(outputDirectory, "index.html"));
const dashboardPage = await readFile(path.join(packageDirectory, "index.html"), "utf8");
if (dashboardPage.split(API_ORIGIN_PLACEHOLDER).length !== 2) {
throw new Error("index.html must contain exactly one empty ekklesia-api-origin meta element.");
}
await writeFile(
path.join(outputDirectory, "index.html"),
dashboardPage.replace(
API_ORIGIN_PLACEHOLDER,
`<meta content="${apiBaseUrl.replaceAll("&", "&amp;").replaceAll('"', "&quot;")}" name="ekklesia-api-origin" />`,
),
);
await copyFile(
path.join(packageDirectory, "display.html"),
path.join(outputDirectory, "display", "index.html"),
+31 -22
View File
@@ -2,29 +2,22 @@ import { installEkklesiaDesignTokens } from "@ekklesia/design-tokens";
import { EkklesiaUiStyles } from "@ekklesia/ui";
import { createRoot } from "react-dom/client";
import { consumeAuthLanding } from "../auth-protocol.js";
import type { EkklesiaHostConfig } from "../host-protocol.js";
import { type EkklesiaHostConfig, resolveApiOrigin } from "../host-protocol.js";
import { HostApp } from "./HostApp.js";
/**
* Replaced at build time by `scripts/build-browser.mjs` with a JSON string literal: the API origin
* when `EKKLESIA_API_BASE_URL` was set for the build, and `""` otherwise. Declared rather than read
* from `process.env`, because a browser has no `process` and the substitution must be visible here
* instead of hidden in bundler configuration.
*/
declare const __EKKLESIA_API_BASE_URL__: string;
/**
* The one place the shell touches a browser global.
*
* An empty build-time value means the API is served from the same origin as this page, which is the
* expected production shape; a non-empty one is the split-origin development shape the platform's
* trusted-origin configuration already supports (`EKKLESIA_IDENTITY_TRUSTED_ORIGINS` must list this
* page's origin for the session cookie to travel). Everything downstream receives the resolved
* origin as a plain argument, so no other file in this package reads `window`.
* The API that served this page wrote its own origin into `<meta name="ekklesia-api-origin">`
* (ADR 0045): empty in the same-origin topology, the API's hostname when the Dashboard and the API
* live on separate origins. Read at run time, so one built shell serves either topology. Everything
* downstream receives the resolved origin as a plain argument, so no other file in this package
* reads `window`.
*/
const config: EkklesiaHostConfig = {
apiBaseUrl: __EKKLESIA_API_BASE_URL__ || window.location.origin,
};
const apiBaseUrl = resolveApiOrigin(
document.querySelector<HTMLMetaElement>('meta[name="ekklesia-api-origin"]')?.content,
window.location.origin,
);
/** 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. */
@@ -46,13 +39,29 @@ if (!container) {
*/
installEkklesiaDesignTokens(document);
/**
* A page whose API origin is not a canonical origin fails closed: it makes no request at all, because
* every request it would make carries credentials to an address it cannot vouch for. The message is
* fixed and names no address.
*/
function renderHost(config: EkklesiaHostConfig | undefined) {
if (!config) {
return (
<main data-testid="host-configuration-invalid" role="alert">
This page cannot start: the server address it was given is not valid.
</main>
);
}
return authLanding ? (
<HostApp authLanding={authLanding} config={config} />
) : (
<HostApp config={config} />
);
}
createRoot(container).render(
<>
<EkklesiaUiStyles />
{authLanding ? (
<HostApp authLanding={authLanding} config={config} />
) : (
<HostApp config={config} />
)}
{renderHost(apiBaseUrl === undefined ? undefined : { apiBaseUrl })}
</>,
);
+6 -3
View File
@@ -6,10 +6,13 @@ import { parseDisplayRoute } from "../host-protocol.js";
import { DisplayApp } from "./DisplayApp.js";
import { type DisplayConnectivitySource, registerDisplayServiceWorker } from "./offline-status.js";
declare const __EKKLESIA_API_BASE_URL__: string;
/**
* The display host is always served from the API's own origin, in either topology (ADR 0045): its
* device cookies, pairing, descriptors, module requests, and realtime are all same-origin, and its
* offline cache stores only same-origin responses (ADR 0043).
*/
const config: EkklesiaHostConfig = {
apiBaseUrl: __EKKLESIA_API_BASE_URL__ || window.location.origin,
apiBaseUrl: window.location.origin,
};
const container = document.getElementById("ekklesia-display");
+33 -4
View File
@@ -4,10 +4,11 @@ import type { ModuleUiPrincipal } from "@ekklesia/module-ui-sdk";
* How this shell reaches the Ekklesia API.
*
* `apiBaseUrl` is an absolute **origin** (`https://ekklesia.example.test`, `http://127.0.0.1:3000`)
* rather than something read from a global. The browser entry point supplies it — from a build-time
* value when the shell and the API are served from different origins in development, and from
* `window.location.origin` otherwise — so every function below is a pure function of its arguments
* and can be exercised without a DOM, a `window`, or a page. Nothing here reads a global.
* rather than something read from a global. The browser entry point supplies it — from the API
* origin the serving API wrote into the Dashboard page (ADR 0045, {@link resolveApiOrigin}), and from
* `window.location.origin` for the display host, which is always served from the API's own origin —
* so every function below is a pure function of its arguments and can be exercised without a DOM, a
* `window`, or a page. Nothing here reads a global.
*/
export interface EkklesiaHostConfig {
readonly apiBaseUrl: string;
@@ -702,6 +703,34 @@ export function parseHostPrincipal(payload: unknown): ModuleUiPrincipal {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
/**
* The API origin a Dashboard page was served with (ADR 0045).
*
* `declared` is the `content` of the page's `<meta name="ekklesia-api-origin">`, which the serving API
* fills in at startup. Absent or empty means the API shares this page's origin, the same-origin
* topology. Anything else must be exactly a canonical `http(s)` origin — no path, no trailing slash,
* no credentials, no query — or the result is `undefined` and the page must fail closed rather than
* send credentialed requests to an address it cannot vouch for.
*/
export function resolveApiOrigin(
declared: string | null | undefined,
pageOrigin: string,
): string | undefined {
if (declared === null || declared === undefined || declared === "") {
return pageOrigin;
}
let url: URL;
try {
url = new URL(declared);
} catch {
return undefined;
}
if ((url.protocol !== "https:" && url.protocol !== "http:") || url.origin !== declared) {
return undefined;
}
return url.origin;
}
/** Resolves a root-relative platform path against the configured API origin. The one place the
* shell turns a server-produced path into an absolute URL. */
export function resolveApiUrl(config: EkklesiaHostConfig, rootRelativePath: string): string {
+1
View File
@@ -32,6 +32,7 @@ export {
parseDisplayRoute,
parseHostModuleDisplay,
parseHostPrincipal,
resolveApiOrigin,
resolveApiUrl,
} from "./host-protocol.js";
export {
@@ -44,6 +44,22 @@ describe("Dashboard host asset locator", () => {
);
});
it("carries exactly one empty API-origin meta element for the serving API to fill (ADR 0045)", async () => {
const html = await readFile(path.join(packageDirectory, "index.html"), "utf8");
expect(html.match(/name="ekklesia-api-origin"/gu)).toHaveLength(1);
expect(html).toContain('<meta content="" name="ekklesia-api-origin" />');
});
it("bakes no API origin into the bundle: the build defines nothing but NODE_ENV", async () => {
const buildScript = await readFile(
path.join(packageDirectory, "scripts/build-browser.mjs"),
"utf8",
);
expect(buildScript).not.toContain("__EKKLESIA_API_BASE_URL__");
});
it("builds the Dashboard entry at the exact path the locator names", async () => {
const buildScript = await readFile(
path.join(packageDirectory, "scripts/build-browser.mjs"),
@@ -6,6 +6,7 @@ import {
loadHostPrincipal,
parseDashboardNavigation,
parseHostPrincipal,
resolveApiOrigin,
} from "../src/host-protocol.js";
import { createModuleUiContext, moduleUiSourceUrl } from "../src/module-context.js";
@@ -101,6 +102,39 @@ function navigationWithModule(module: unknown): unknown {
return { groups: [], ungrouped: [{ module, name: "Example Module" }] };
}
describe("host protocol — API origin (ADR 0045)", () => {
const pageOrigin = "https://dash.example.test";
it("uses the page's own origin when the page declares none (same-origin topology)", () => {
expect(resolveApiOrigin(undefined, pageOrigin)).toBe(pageOrigin);
expect(resolveApiOrigin(null, pageOrigin)).toBe(pageOrigin);
expect(resolveApiOrigin("", pageOrigin)).toBe(pageOrigin);
});
it("uses a declared canonical origin (split topology)", () => {
expect(resolveApiOrigin("https://api.example.test", pageOrigin)).toBe(
"https://api.example.test",
);
expect(resolveApiOrigin("http://127.0.0.1:3000", pageOrigin)).toBe("http://127.0.0.1:3000");
});
it.each([
["a trailing slash", "https://api.example.test/"],
["a path", "https://api.example.test/api"],
["a query", "https://api.example.test?x=1"],
["credentials", "https://user:[email protected]"],
["a non-canonical spelling", "HTTPS://API.example.test"],
["a default port spelled out", "https://api.example.test:443"],
["another scheme", "javascript:alert(1)"],
["a protocol-relative value", "//api.example.test"],
["a relative value", "/api"],
["surrounding whitespace", " https://api.example.test"],
["something that is not a URL", "api.example.test"],
])("refuses %s, so the page fails closed", (_case, declared) => {
expect(resolveApiOrigin(declared, pageOrigin)).toBeUndefined();
});
});
describe("host protocol — descriptor validation", () => {
it("parses a well-formed descriptor out of the navigation view", () => {
const navigation = parseDashboardNavigation(navigationWithModule(inventoryPayload.modules[0]));
-2
View File
@@ -120,7 +120,6 @@ describe("the shell builds no module knowledge into its bundle", () => {
absWorkingDir: packageDirectory,
bundle: true,
define: {
__EKKLESIA_API_BASE_URL__: '""',
"process.env.NODE_ENV": '"production"',
},
entryPoints: [path.join(sourceDirectory, "browser", "main.tsx")],
@@ -200,7 +199,6 @@ describe("the display host is a separate generic browser bundle", () => {
absWorkingDir: packageDirectory,
bundle: true,
define: {
__EKKLESIA_API_BASE_URL__: '""',
"process.env.NODE_ENV": '"production"',
},
entryPoints: [path.join(sourceDirectory, "display", "main.tsx")],