Files
Ekklesia/scripts/lib/deploy-smoke-client.mjs
T
ekklesiaandClaude Opus 5.5 f7fb621921 test(deploy): load the same image twice over HTTP/2, and pin storage CORS
Two checks on storage delivery through the proxy, in both topologies.

The same signed image, twice, over a real HTTP/2 connection (node:http2;
Node's fetch speaks HTTP/1.1, where the failure does not exist). The
second load is conditional, as a browser's revalidation is, and must be
a clean 304. Today Caddy reaches SeaweedFS over HTTP/2, SeaweedFS's 304
declares Content-Length: 327 and sends no body, and Caddy resets the
stream. A browser reports that as ERR_HTTP2_PROTOCOL_ERROR on every
reload of a cached files. image.

CORS on delivery is the deployment's own:
- exactly the API origin (and the Dashboard origin when split) is
  allowed, with Vary: Origin and nothing else;
- any other origin gets no CORS header;
- a preflight is answered by the proxy (204, GET and HEAD only) and
  never reaches the object store.

Today SeaweedFS reflects every origin with PUT, POST and DELETE, and
answers the preflight itself.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 19:02:36 -05:00

460 lines
18 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.
import { connect as connectHttp2 } from "node:http2";
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",
);
}
/**
* One GET over a real HTTP/2 connection. Node's fetch speaks HTTP/1.1, and the failure this guards
* against exists only on HTTP/2: a reset stream, which a browser reports as ERR_HTTP2_PROTOCOL_ERROR.
*/
function http2Get(url, headers = {}) {
return new Promise((resolve) => {
const session = connectHttp2(url.origin);
const finish = (result) => {
clearTimeout(timer);
session.close();
resolve(result);
};
const timer = setTimeout(() => finish({ error: "timeout" }), 10_000);
session.on("error", () => finish({ error: "session" }));
const stream = session.request({
":method": "GET",
":path": `${url.pathname}${url.search}`,
...headers,
});
let status;
let responseHeaders = {};
let bytes = 0;
stream.on("response", (received) => {
status = received[":status"];
responseHeaders = received;
});
stream.on("data", (chunk) => {
bytes += chunk.length;
});
stream.on("error", (error) => finish({ error: error.code ?? "stream" }));
stream.on("end", () => finish({ bytes, headers: responseHeaders, status }));
stream.end();
});
}
/** The CORS headers of a response, by name: what a browser would act on. */
function corsOf(response) {
return [...response.headers.keys()].filter((name) => name.startsWith("access-control-"));
}
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");
// The same image, twice, over HTTP/2: a browser's second load of a signed URL it has cached is a
// conditional request, and must come back as a clean 304, not a reset stream.
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"base64",
);
await provider.put(bucket, "smoke/image.png", png, {
contentLength: png.byteLength,
contentType: "image/png",
});
const image = new URL(
(await provider.createReadUrl(bucket, "smoke/image.png", { expiresInSeconds: 300 })).url,
);
const first = await http2Get(image);
check(
first.status === 200 && first.bytes === png.byteLength && Boolean(first.headers?.etag),
"an image loads over HTTP/2 through the proxy, with a validator (200, ETag)",
);
const revalidated = await http2Get(image, { "if-none-match": first.headers?.etag ?? "" });
check(
revalidated.status === 304 && revalidated.error === undefined,
`the same image loaded again over HTTP/2 revalidates cleanly (${revalidated.status ?? revalidated.error})`,
);
const sinceModified = await http2Get(image, {
"if-modified-since": first.headers?.["last-modified"] ?? "",
});
check(
sinceModified.status === 304 && sinceModified.error === undefined,
`a revalidation by date over HTTP/2 is a clean 304 too (${sinceModified.status ?? sinceModified.error})`,
);
// Delivery-layer CORS is the deployment's own, set by the proxy: exactly the origins whose pages
// read objects with fetch(), GET and HEAD only, and nothing of the object store's own answer.
const fromApi = await fetch(image, { headers: { origin: apiOrigin } });
check(
fromApi.headers.get("access-control-allow-origin") === apiOrigin &&
(fromApi.headers.get("vary") ?? "").toLowerCase().includes("origin") &&
corsOf(fromApi).length === 1,
"a read from the API origin is allowed for exactly that origin, with Vary: Origin and nothing else",
);
if (split) {
const fromDashboard = await fetch(image, { headers: { origin: dashboardOrigin } });
check(
fromDashboard.headers.get("access-control-allow-origin") === dashboardOrigin,
"a read from the Dashboard origin is allowed for exactly that origin",
);
}
const fromElsewhere = await fetch(image, { headers: { origin: untrustedOrigin } });
check(
fromElsewhere.status === 200 && corsOf(fromElsewhere).length === 0,
"a read from any other origin gets no CORS header at all",
);
const preflight = await fetch(image, {
headers: { "access-control-request-method": "GET", origin: apiOrigin },
method: "OPTIONS",
});
check(
preflight.status === 204 &&
preflight.headers.get("access-control-allow-origin") === apiOrigin &&
preflight.headers.get("access-control-allow-methods") === "GET, HEAD" &&
preflight.headers.get("x-amz-request-id") === null,
"the proxy answers a preflight itself (204, GET and HEAD only), never the object store",
);
const foreignPreflight = await fetch(image, {
headers: { "access-control-request-method": "GET", origin: untrustedOrigin },
method: "OPTIONS",
});
check(
foreignPreflight.status === 204 && corsOf(foreignPreflight).length === 0,
"a preflight from any other origin is answered without permission",
);
await provider.delete(bucket, "smoke/image.png");
} 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);
}