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
+107 -17
View File
@@ -7,12 +7,20 @@
// node deploy-smoke-client.mjs tunnel the Cloudflare Tunnel connector's fixed address
// node deploy-smoke-client.mjs session the initial owner signing in and out through the proxy
//
// Either topology (ADR 0045): SMOKE_HOSTNAME is the API's hostname, and SMOKE_DASHBOARD_HOSTNAME,
// when set, the Dashboard's. Unset means the same-origin topology, where both are one hostname. Every
// request goes to the API's origin and carries the Dashboard's origin as `Origin`, as a browser would.
//
// Output is one line per check; any failed check exits non-zero. Nothing printed carries a
// credential, cookie, signed URL, or response body beyond the fixed shapes asserted here.
const mode = process.argv[2];
const hostname = required("SMOKE_HOSTNAME");
const origin = `https://${hostname}`;
const apiOrigin = `https://${required("SMOKE_HOSTNAME")}`;
const dashboardOrigin = process.env.SMOKE_DASHBOARD_HOSTNAME
? `https://${process.env.SMOKE_DASHBOARD_HOSTNAME}`
: apiOrigin;
const split = dashboardOrigin !== apiOrigin;
const untrustedOrigin = "https://untrusted.smoke.test";
function required(name) {
const value = process.env[name];
@@ -34,9 +42,9 @@ function check(condition, description) {
/** A sign-in attempt that can only fail authentication; what matters is whether the API's
* per-client limiter (3 attempts per 10 seconds) counts it against the expected client. */
async function signInAttempt(headers = {}) {
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
const response = await fetch(`${apiOrigin}/api/auth/sign-in/email`, {
body: JSON.stringify({ email: "[email protected]", password: "definitely-not-the-password" }),
headers: { "content-type": "application/json", origin, ...headers },
headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers },
method: "POST",
});
await response.arrayBuffer();
@@ -44,13 +52,13 @@ async function signInAttempt(headers = {}) {
}
async function edgeChecks() {
const ready = await fetch(`${origin}/api/v1/health/ready`);
const ready = await fetch(`${apiOrigin}/api/v1/health/ready`);
check(
ready.status === 200 && (await ready.text()) === '{"status":"ready"}',
"GET /api/v1/health/ready through the proxy is 200 ready",
);
const page = await fetch(`${origin}/`);
const page = await fetch(`${dashboardOrigin}/`);
const html = await page.text();
check(page.status === 200, "GET / through the proxy is 200");
check(
@@ -66,22 +74,32 @@ async function edgeChecks() {
"the Dashboard page keeps its security headers through the proxy",
);
const script = await fetch(`${origin}/assets/ekklesia-dashboard/app.js`);
check(
html.includes(`<meta content="${apiOrigin}" name="ekklesia-api-origin" />`),
"the Dashboard page names the API's origin, rendered at run time (ADR 0045)",
);
const script = await fetch(`${dashboardOrigin}/assets/ekklesia-dashboard/app.js`);
await script.arrayBuffer();
check(script.status === 200, "GET /assets/ekklesia-dashboard/app.js is 200");
const outside = await fetch(`${origin}/favicon.ico`);
const outside = await fetch(`${dashboardOrigin}/favicon.ico`);
check(
outside.status === 404 && !(outside.headers.get("content-type") ?? "").includes("json"),
"a path the API does not serve is refused by the proxy, never forwarded",
);
const unknownApi = await fetch(`${origin}/api/v1/does-not-exist`);
const unknownApi = await fetch(`${apiOrigin}/api/v1/does-not-exist`);
check(
unknownApi.status === 404 && (await unknownApi.text()) === '{"code":"NOT_FOUND"}',
"an unknown /api/* path reaches the API and gets its sanitized 404",
);
if (split) {
await splitRoutingChecks();
await splitCorsChecks();
}
await storageDeliveryCheck();
// Every caller-supplied forwarding header rotates on every attempt. If any of them reached the
@@ -106,6 +124,74 @@ async function edgeChecks() {
);
}
/** A proxy refusal: Caddy's own empty `404`, never the API's JSON one, so the request was not
* forwarded at all. */
async function refusedByProxy(url) {
const response = await fetch(url);
const body = await response.text();
return response.status === 404 && body === "";
}
/** Each hostname serves exactly its own paths (ADR 0045, "Routing per hostname"). */
async function splitRoutingChecks() {
check(
await refusedByProxy(`${dashboardOrigin}/api/v1/health/ready`),
"the Dashboard hostname refuses /api/*, so the session cookie has one home",
);
check(
await refusedByProxy(`${dashboardOrigin}/display/example`),
"the Dashboard hostname refuses /display/*",
);
check(await refusedByProxy(`${apiOrigin}/`), "the API hostname serves nothing at /");
check(
await refusedByProxy(`${apiOrigin}/assets/ekklesia-dashboard/app.js`),
"the API hostname refuses the Dashboard's script",
);
const display = await fetch(`${apiOrigin}/display/does-not-exist`);
check(
display.status === 404 && (await display.text()) === '{"code":"NOT_FOUND"}',
"the API hostname forwards /display/* to the API (the display host's origin)",
);
}
/** Credentialed CORS for exactly the Dashboard's origin, and for nothing else. */
async function splitCorsChecks() {
const preflight = await fetch(`${apiOrigin}/api/auth/sign-in/email`, {
headers: {
"access-control-request-headers": "content-type",
"access-control-request-method": "POST",
origin: dashboardOrigin,
},
method: "OPTIONS",
});
await preflight.arrayBuffer();
check(
preflight.status === 204 &&
preflight.headers.get("access-control-allow-origin") === dashboardOrigin &&
preflight.headers.get("access-control-allow-credentials") === "true",
"a preflight from the Dashboard's origin is allowed, with credentials",
);
const foreignPreflight = await fetch(`${apiOrigin}/api/auth/sign-in/email`, {
headers: { "access-control-request-method": "POST", origin: untrustedOrigin },
method: "OPTIONS",
});
await foreignPreflight.arrayBuffer();
check(
foreignPreflight.headers.get("access-control-allow-origin") === null,
"a preflight from any other origin gets no CORS permission",
);
const principal = await fetch(`${apiOrigin}/api/v1/principal`, {
headers: { origin: dashboardOrigin },
});
await principal.arrayBuffer();
check(
principal.headers.get("access-control-allow-origin") === dashboardOrigin &&
principal.headers.get("access-control-allow-credentials") === "true" &&
(principal.headers.get("vary") ?? "").includes("Origin"),
"an API response to the Dashboard's origin reflects exactly that origin, with credentials",
);
}
async function storageDeliveryCheck() {
// Core's own provider, from the image, exactly as the API composes it: it reaches object-storage
// over HTTPS on the private network and signs read URLs for the storage hostname.
@@ -184,9 +270,9 @@ async function sessionChecks() {
"/api/auth/update-session",
"/api/auth/change-password",
]) {
const response = await fetch(`${origin}${path}`, {
const response = await fetch(`${apiOrigin}${path}`, {
method: path.endsWith("get-session") || path.endsWith("list-sessions") ? "GET" : "POST",
headers: { origin },
headers: { origin: dashboardOrigin },
});
check(
response.status === 404 && (await response.text()) === '{"code":"NOT_FOUND"}',
@@ -194,12 +280,12 @@ async function sessionChecks() {
);
}
const signIn = await fetch(`${origin}/api/auth/sign-in/email`, {
const signIn = await fetch(`${apiOrigin}/api/auth/sign-in/email`, {
body: JSON.stringify({
email: required("SMOKE_OWNER_EMAIL"),
password: required("SMOKE_OWNER_PASSWORD"),
}),
headers: { "content-type": "application/json", origin },
headers: { "content-type": "application/json", origin: dashboardOrigin },
method: "POST",
});
const signInBody = await signIn.text();
@@ -218,20 +304,24 @@ async function sessionChecks() {
);
const cookie = setCookie.split(";")[0] ?? "";
const principal = await fetch(`${origin}/api/v1/principal`, { headers: { cookie, origin } });
const principal = await fetch(`${apiOrigin}/api/v1/principal`, {
headers: { cookie, origin: dashboardOrigin },
});
const principalBody = await principal.json();
check(
principal.status === 200 && principalBody.kind === "authenticated",
"the session resolves an authenticated principal through the proxy",
);
const signOut = await fetch(`${origin}/api/auth/sign-out`, {
const signOut = await fetch(`${apiOrigin}/api/auth/sign-out`, {
body: "{}",
headers: { "content-type": "application/json", cookie, origin },
headers: { "content-type": "application/json", cookie, origin: dashboardOrigin },
method: "POST",
});
await signOut.arrayBuffer();
const after = await fetch(`${origin}/api/v1/principal`, { headers: { cookie, origin } });
const after = await fetch(`${apiOrigin}/api/v1/principal`, {
headers: { cookie, origin: dashboardOrigin },
});
check(
signOut.status === 200 && (await after.json()).kind === "anonymous",
"sign-out ends the session",