feat: support split Dashboard and API origins (ADR 0045)
CI / verify (pull_request) Successful in 51s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m49s
CI / image-build (pull_request) Successful in 17s
CI / deploy-smoke (same-origin) (pull_request) Successful in 48s
CI / deploy-smoke (split) (pull_request) Successful in 55s
CI / verify (pull_request) Successful in 51s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m49s
CI / image-build (pull_request) Successful in 17s
CI / deploy-smoke (same-origin) (pull_request) Successful in 48s
CI / deploy-smoke (split) (pull_request) Successful in 55s
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
55accd0e65
commit
65908ac97c
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user