Files
Ekklesia/scripts/lib/deploy-smoke-client.mjs
T
ekklesiaandClaude Opus 5.5 a4966dd0dd
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
feat: support split Dashboard and API origins (ADR 0045)
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]>
2026-10-03 11:28:03 -05:00

345 lines
13 KiB
JavaScript

// HTTP assertions for scripts/smoke-deploy.sh, run inside a disposable container on the reference
// deployment's `edge` network: every request goes through the proxy by its public hostname, exactly
// as a browser's would. Not a test of the API in isolation, and never run against a real
// installation.
//
// node deploy-smoke-client.mjs edge an ordinary, untrusted peer of the proxy
// 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 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];
if (!value) {
throw new Error(`${name} is required.`);
}
return value;
}
let failures = 0;
function check(condition, description) {
console.log(`${condition ? "ok " : "FAIL"} ${description}`);
if (!condition) {
failures += 1;
}
}
/** 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(`${apiOrigin}/api/auth/sign-in/email`, {
body: JSON.stringify({ email: "[email protected]", password: "definitely-not-the-password" }),
headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers },
method: "POST",
});
await response.arrayBuffer();
return response.status;
}
async function edgeChecks() {
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(`${dashboardOrigin}/`);
const html = await page.text();
check(page.status === 200, "GET / through the proxy is 200");
check(
page.headers.get("content-type") === "text/html; charset=utf-8" &&
html.includes('id="ekklesia-host"') &&
html.includes('src="/assets/ekklesia-dashboard/app.js"'),
"GET / is the Dashboard HTML",
);
check(
page.headers.get("x-frame-options") === "DENY" &&
page.headers.get("content-security-policy") === "frame-ancestors 'none'" &&
page.headers.get("referrer-policy") === "no-referrer",
"the Dashboard page keeps its security headers through the proxy",
);
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(`${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(`${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
// API's client-address resolution, each attempt would land in a fresh limiter bucket and the
// fourth would not be limited.
const statuses = [];
for (let attempt = 1; attempt <= 4; attempt += 1) {
statuses.push(
await signInAttempt({
"cf-connecting-ip": `198.51.100.${attempt}`,
forwarded: `for=198.51.100.${attempt + 10}`,
"true-client-ip": `198.51.100.${attempt + 20}`,
"x-ekklesia-client-address": `198.51.100.${attempt + 50}`,
"x-forwarded-for": `198.51.100.${attempt + 30}`,
"x-real-ip": `198.51.100.${attempt + 40}`,
}),
);
}
check(
statuses.slice(0, 3).every((status) => status !== 429 && status < 500) && statuses[3] === 429,
`spoofed forwarding headers do not choose the client address (statuses ${statuses.join(",")})`,
);
}
/** 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.
const { createS3StorageProvider, createStorageProviderConfig } = await import(
"/opt/ekklesia/core/dist/storage/index.js"
);
const config = createStorageProviderConfig(
{
accessKey: required("S3_ACCESS_KEY"),
bucketPrefix: "ekklesia-smoke",
deliveryEndpoint: `https://${required("SMOKE_STORAGE_HOSTNAME")}`,
endpoint: "https://object-storage:8443",
forcePathStyle: true,
region: "us-east-1",
secretKey: required("S3_SECRET_KEY"),
},
{ environment: "production" },
);
const provider = createS3StorageProvider(config);
try {
const bucket = "ekklesia-smoke-delivery";
const body = new TextEncoder().encode("delivered through the proxy\n");
await provider.ensureBucket(bucket);
await provider.put(bucket, "smoke/object.txt", body, {
contentLength: body.byteLength,
contentType: "text/plain",
});
const readUrl = await provider.createReadUrl(bucket, "smoke/object.txt", {
expiresInSeconds: 300,
});
const url = new URL(readUrl.url);
check(
url.origin === `https://${required("SMOKE_STORAGE_HOSTNAME")}`,
"a signed read URL names the storage hostname",
);
const delivered = await fetch(url);
const text = await delivered.text();
check(
delivered.status === 200 && text === "delivered through the proxy\n",
"a signed read URL verifies through the proxy (original Host preserved)",
);
check(
(delivered.headers.get("cache-control") ?? "").startsWith("private"),
"a delivered object is Cache-Control: private",
);
await provider.delete(bucket, "smoke/object.txt");
} finally {
await provider.close();
}
}
async function tunnelChecks() {
// From the connector's fixed address, CF-Connecting-IP is the client: one visitor is limited on
// its fourth attempt, and a different visitor is not limited by the first one's attempts.
const first = [];
for (let attempt = 1; attempt <= 4; attempt += 1) {
first.push(await signInAttempt({ "cf-connecting-ip": "203.0.113.10" }));
}
check(
first.slice(0, 3).every((status) => status !== 429 && status < 500) && first[3] === 429,
`the connector's CF-Connecting-IP identifies the visitor (statuses ${first.join(",")})`,
);
const second = await signInAttempt({ "cf-connecting-ip": "203.0.113.11" });
check(
second !== 429 && second < 500,
`a second visitor behind the connector has its own limiter bucket (status ${second})`,
);
}
/** The deployed browser authentication contract (launch checklist item 9), as the initial owner
* created by the image's own CLI (item 10). */
async function sessionChecks() {
for (const path of [
"/api/auth/get-session",
"/api/auth/list-sessions",
"/api/auth/update-session",
"/api/auth/change-password",
]) {
const response = await fetch(`${apiOrigin}${path}`, {
method: path.endsWith("get-session") || path.endsWith("list-sessions") ? "GET" : "POST",
headers: { origin: dashboardOrigin },
});
check(
response.status === 404 && (await response.text()) === '{"code":"NOT_FOUND"}',
`${path} is unavailable through the proxy`,
);
}
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: dashboardOrigin },
method: "POST",
});
const signInBody = await signIn.text();
const setCookie = signIn.headers.get("set-cookie") ?? "";
check(
signIn.status === 200 && signInBody === '{"ok":true}',
'sign-in returns the fixed {"ok":true}',
);
const attributes = setCookie.toLowerCase();
check(
attributes.includes("httponly") &&
attributes.includes("secure") &&
attributes.includes("samesite=lax") &&
!attributes.includes("domain="),
"the session cookie is host-only, HttpOnly, Secure, and SameSite=Lax",
);
const cookie = setCookie.split(";")[0] ?? "";
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(`${apiOrigin}/api/auth/sign-out`, {
body: "{}",
headers: { "content-type": "application/json", cookie, origin: dashboardOrigin },
method: "POST",
});
await signOut.arrayBuffer();
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",
);
}
if (mode === "edge") {
await edgeChecks();
} else if (mode === "tunnel") {
await tunnelChecks();
} else if (mode === "session") {
await sessionChecks();
} else {
throw new Error('Mode must be "edge", "tunnel", or "session".');
}
if (failures > 0) {
console.log(`${failures} check(s) failed.`);
process.exit(1);
}