Compare commits
3
Commits
v0.1.1
..
5b78ee0ffb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b78ee0ffb | ||
|
|
2323467aef | ||
|
|
fe390bb09d |
@@ -124,35 +124,24 @@ jobs:
|
||||
# The platform-only image (ADR 0044): a checkout has no `modules/`, so this proves the image
|
||||
# builds, validates, and assembles with zero modules. Build only — no registry login, no
|
||||
# credentials, no push, no tag anywhere but this runner's local image store.
|
||||
# Tagged per run and attempt, not only per commit: a pull request and a push of the same commit
|
||||
# can build at once on one runner's Docker daemon, and neither may remove the other's image.
|
||||
- name: Build the production image without modules
|
||||
run: >-
|
||||
docker build
|
||||
--file deploy/Dockerfile
|
||||
--build-arg REVISION="$GITHUB_SHA"
|
||||
--tag ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}"
|
||||
--tag ekklesia-ci:"$GITHUB_SHA"
|
||||
.
|
||||
|
||||
- name: Remove the image from the runner
|
||||
if: always()
|
||||
run: docker image rm --force ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}" || true
|
||||
run: docker image rm --force ekklesia-ci:"$GITHUB_SHA" || true
|
||||
|
||||
deploy-smoke:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
# Both topologies (ADR 0045): same-origin, the default every installation gets, and split, which
|
||||
# adds a test-only module and drives a real headless browser across the two origins.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
topology:
|
||||
- same-origin
|
||||
- split
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
EKKLESIA_SMOKE_TOPOLOGY: ${{ matrix.topology }}
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -167,5 +156,5 @@ jobs:
|
||||
# PostgreSQL, then the API, Dashboard, storage delivery, client-address chain, and verify-full
|
||||
# database TLS through the proxy. Every secret is generated for this run and discarded with
|
||||
# it; nothing is pushed, published, or logged in to.
|
||||
- name: Smoke-test the reference deployment (${{ matrix.topology }})
|
||||
- name: Smoke-test the reference deployment
|
||||
run: bash scripts/smoke-deploy.sh
|
||||
|
||||
@@ -200,19 +200,3 @@ export function createApiRuntimeConfig(
|
||||
storage,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact browser origins the API trusts: CORS on `/api/auth/*` and `/api/v1/*`, and every
|
||||
* mutation route's origin check (ADR 0045).
|
||||
*
|
||||
* The configured trusted origins plus the identity base URL's own origin, which is where the API
|
||||
* lives. Better Auth already trusts its base URL implicitly; this makes the API's own checks agree,
|
||||
* so a page the API itself serves (the display host, and the Dashboard in the same-origin topology)
|
||||
* passes without an operator listing the API's origin. Order is preserved and duplicates removed;
|
||||
* every entry is already a canonical origin.
|
||||
*/
|
||||
export function apiTrustedOrigins(
|
||||
identity: Pick<IdentityConfig, "baseUrl" | "trustedOrigins">,
|
||||
): readonly string[] {
|
||||
return Object.freeze([...new Set([...identity.trustedOrigins, identity.baseUrl])]);
|
||||
}
|
||||
|
||||
@@ -54,54 +54,15 @@ export function createDashboardHostAssets(html: string, script: string): Dashboa
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact element the built Dashboard page carries for its API origin (ADR 0045), empty. The shell
|
||||
* reads the filled-in value at run time; empty means the page's own origin.
|
||||
*/
|
||||
export const DASHBOARD_API_ORIGIN_PLACEHOLDER = '<meta content="" name="ekklesia-api-origin" />';
|
||||
|
||||
function isCanonicalHttpOrigin(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (url.protocol === "https:" || url.protocol === "http:") && url.origin === value;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the API's own origin into the Dashboard page, once, before the page's `ETag` is computed.
|
||||
*
|
||||
* The origin is the identity base URL's, which is where `/api` lives in either topology: the page's
|
||||
* own origin when the Dashboard and the API share one, the API hostname when they are split. The
|
||||
* built page must carry exactly one empty placeholder; anything else is a shell build this API does
|
||||
* not understand, and startup fails rather than serving a page that might send credentials somewhere
|
||||
* unintended. The value is escaped for an attribute even though a canonical origin cannot contain
|
||||
* any character that needs it.
|
||||
*/
|
||||
export function renderDashboardPage(html: string, apiOrigin: string): string {
|
||||
const parts = html.split(DASHBOARD_API_ORIGIN_PLACEHOLDER);
|
||||
if (parts.length !== 2 || !isCanonicalHttpOrigin(apiOrigin)) {
|
||||
throw new DashboardHostAssetError();
|
||||
}
|
||||
const escaped = apiOrigin
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
return parts.join(`<meta content="${escaped}" name="ekklesia-api-origin" />`);
|
||||
}
|
||||
|
||||
/** Loads only the two surface-declared files, and renders the API origin into the page. A missing,
|
||||
* unreadable, or incompatible build fails startup rather than leaving an API whose own origin answers
|
||||
* `/` with a 404 or a page that cannot find its API. */
|
||||
export async function loadDashboardHostAssets(apiOrigin: string): Promise<DashboardHostAssets> {
|
||||
/** Loads only the two surface-declared files. A missing or unreadable build fails startup rather
|
||||
* than leaving an API whose own origin answers `/` with a 404. */
|
||||
export async function loadDashboardHostAssets(): Promise<DashboardHostAssets> {
|
||||
try {
|
||||
// Sequential deliberately, as for the display host: diagnostics stay deterministic and no
|
||||
// rejected sibling read is left running after this function has already failed.
|
||||
const html = await readFile(new URL(dashboardHostAssetUrls.html), "utf8");
|
||||
const script = await readFile(new URL(dashboardHostAssetUrls.script), "utf8");
|
||||
return createDashboardHostAssets(renderDashboardPage(html, apiOrigin), script);
|
||||
return createDashboardHostAssets(html, script);
|
||||
} catch {
|
||||
throw new DashboardHostAssetError();
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
createClientAddressResolver,
|
||||
wrapFetchWithClientAddress,
|
||||
} from "./client-address.js";
|
||||
import { type ApiRuntimeConfig, apiTrustedOrigins, createApiRuntimeConfig } from "./config.js";
|
||||
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
|
||||
import { loadDashboardHostAssets } from "./dashboard-host-assets.js";
|
||||
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
|
||||
import { withDevicePrincipals } from "./device-principal.js";
|
||||
@@ -550,9 +550,7 @@ export async function startApi(
|
||||
moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets();
|
||||
// Unconditional, unlike the display host: every installation serves its Dashboard (ADR 0044),
|
||||
// so a missing shell build fails here, before any module code is imported.
|
||||
// The page learns its API origin from the API that serves it, so one build serves either
|
||||
// topology (ADR 0045).
|
||||
dashboardHostAssets = await loadDashboardHostAssets(config.identity.baseUrl);
|
||||
dashboardHostAssets = await loadDashboardHostAssets();
|
||||
} catch (error) {
|
||||
await safeCloseStorage(storageProvider);
|
||||
await safeCloseMail(mailProvider);
|
||||
@@ -650,8 +648,7 @@ export async function startApi(
|
||||
// Principal resolution, extended with managed devices. A human session still wins, and this
|
||||
// is the only place a device credential ever becomes a principal.
|
||||
runtime: withDevicePrincipals({ devices: deviceStore, runtime }),
|
||||
// The configured origins plus the API's own (ADR 0045).
|
||||
trustedOrigins: apiTrustedOrigins(config.identity),
|
||||
trustedOrigins: config.identity.trustedOrigins,
|
||||
});
|
||||
} catch (error) {
|
||||
await safeStopModuleRuntime(moduleRuntime);
|
||||
|
||||
@@ -29,7 +29,6 @@ import type { ClientAddressConfigInput } from "../src/client-address.js";
|
||||
import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets";
|
||||
import { createApiRuntimeConfig } from "../src/config.js";
|
||||
import { createApiDatabase } from "../src/database.js";
|
||||
import { DASHBOARD_API_ORIGIN_PLACEHOLDER } from "../src/dashboard-host-assets.js";
|
||||
import {
|
||||
DASHBOARD_HOST_PAGE_PATH,
|
||||
DASHBOARD_HOST_SCRIPT_PATH,
|
||||
@@ -2990,7 +2989,7 @@ describe.sequential("live API — published module UI artifact", () => {
|
||||
});
|
||||
|
||||
describe.sequential("live API — the built Dashboard on the API's own origin", () => {
|
||||
it("serves the built shell's index.html at /, with only its API origin written in, and its exact script, and nothing beside them", async () => {
|
||||
it("serves the built shell's exact index.html at / and its exact script, and nothing beside them", async () => {
|
||||
const database = await createDisposableDatabase("dashboard_host");
|
||||
await applyCoreMigrations(database.url);
|
||||
const running = await startMigratedApi(database.url);
|
||||
@@ -3007,15 +3006,7 @@ describe.sequential("live API — the built Dashboard on the API's own origin",
|
||||
expect(page.headers.get("x-frame-options")).toBe("DENY");
|
||||
expect(page.headers.get("content-security-policy")).toBe("frame-ancestors 'none'");
|
||||
expect(page.headers.get("referrer-policy")).toBe("no-referrer");
|
||||
// The built page carries one empty API-origin element; the API fills in its own origin
|
||||
// (the identity base URL) and changes nothing else (ADR 0045).
|
||||
expect(builtHtml.split(DASHBOARD_API_ORIGIN_PLACEHOLDER)).toHaveLength(2);
|
||||
expect(html).toBe(
|
||||
builtHtml.replace(
|
||||
DASHBOARD_API_ORIGIN_PLACEHOLDER,
|
||||
`<meta content="${identityBaseUrl}" name="ekklesia-api-origin" />`,
|
||||
),
|
||||
);
|
||||
expect(html).toBe(builtHtml);
|
||||
expect(html).toContain('id="ekklesia-host"');
|
||||
expect(html).toContain(`<script src="${DASHBOARD_HOST_SCRIPT_PATH}" type="module"></script>`);
|
||||
|
||||
|
||||
@@ -3,16 +3,11 @@ import {
|
||||
createAuthenticatedPrincipal,
|
||||
type ModuleDatabase,
|
||||
type ModuleRuntimeServices,
|
||||
moduleIdSchema,
|
||||
parseEkklesiaUserId,
|
||||
parsePermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationEvaluator,
|
||||
type AuthorizationStore,
|
||||
CORE_PERMISSION_DECLARATIONS,
|
||||
CORE_PERMISSION_REGISTRY,
|
||||
createPermissionRegistry,
|
||||
type PermissionRegistry,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
@@ -21,7 +16,6 @@ import {
|
||||
createModuleUiPublicationRegistry,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
type ModuleUiPublicationRegistry,
|
||||
StagedModuleRealtimeRegistry,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
@@ -916,101 +910,3 @@ describe("Ekklesia API Hono application", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("split Dashboard and API origins (ADR 0045)", () => {
|
||||
const moduleId = moduleIdSchema.parse("announcements");
|
||||
const permission = parsePermissionId("announcements.slideshow.view");
|
||||
const endpoint = "/api/v1/module-realtime/announcements/state";
|
||||
|
||||
/** The full application with one live channel, a signed-in caller, and an evaluator that allows
|
||||
* it: the same composition a Dashboard on another origin reaches. */
|
||||
function realtimeApp() {
|
||||
const staged = new StagedModuleRealtimeRegistry();
|
||||
const registration = staged.createRegistrationStage(moduleId, new Set([permission]));
|
||||
registration.realtime.channel({ channel: "state", permission });
|
||||
registration.commit();
|
||||
staged.publisherFor(moduleId).publish("state", { mode: "on" } as never);
|
||||
const moduleRealtime = staged.publish();
|
||||
const app = createApiAppWithModules({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
moduleRealtime,
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({
|
||||
evaluator: { authorize: vi.fn().mockResolvedValue({ allowed: true }) },
|
||||
registry: createPermissionRegistry([
|
||||
...CORE_PERMISSION_DECLARATIONS,
|
||||
{
|
||||
baseline: "restricted",
|
||||
description: "View the announcement slideshow.",
|
||||
id: permission,
|
||||
},
|
||||
]),
|
||||
}),
|
||||
runtime: fakeRuntime({
|
||||
resolvePrincipal: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
createAuthenticatedPrincipal(
|
||||
parseEkklesiaUserId("88888888-8888-4888-8888-888888888888"),
|
||||
),
|
||||
),
|
||||
}),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
return { app, moduleRealtime };
|
||||
}
|
||||
|
||||
it("lets the Dashboard's origin open a credentialed realtime stream", async () => {
|
||||
const { app, moduleRealtime } = realtimeApp();
|
||||
|
||||
const response = await app.request(endpoint, {
|
||||
headers: { cookie: "session=1", origin: trustedOrigin },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("text/event-stream; charset=utf-8");
|
||||
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
|
||||
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
|
||||
expect(response.headers.get("vary")).toContain("Origin");
|
||||
await response.body?.cancel();
|
||||
moduleRealtime.closeSubscriptions();
|
||||
});
|
||||
|
||||
it("gives an untrusted origin's realtime request no CORS permission to read the stream", async () => {
|
||||
const { app, moduleRealtime } = realtimeApp();
|
||||
|
||||
const response = await app.request(endpoint, {
|
||||
headers: { cookie: "session=1", origin: untrustedOrigin },
|
||||
});
|
||||
|
||||
expect(response.headers.get("access-control-allow-origin")).toBeNull();
|
||||
await response.body?.cancel();
|
||||
moduleRealtime.closeSubscriptions();
|
||||
});
|
||||
|
||||
it("allows only Content-Type beyond the CORS-safelisted request headers, and exposes none", async () => {
|
||||
const app = createAuthWiringApp(fakeRuntime());
|
||||
|
||||
const response = await app.request("/api/v1/principal", {
|
||||
headers: {
|
||||
"access-control-request-headers": "content-type, x-module-custom",
|
||||
"access-control-request-method": "PATCH",
|
||||
origin: trustedOrigin,
|
||||
},
|
||||
method: "OPTIONS",
|
||||
});
|
||||
|
||||
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
|
||||
expect(response.headers.get("access-control-allow-headers")?.toLowerCase()).toBe(
|
||||
"content-type",
|
||||
);
|
||||
expect(response.headers.get("access-control-expose-headers")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ApiConfigError, apiTrustedOrigins, createApiRuntimeConfig } from "../src/config.js";
|
||||
import { ApiConfigError, createApiRuntimeConfig } from "../src/config.js";
|
||||
|
||||
const validSecret = "correct-horse-battery-staple-0123456789";
|
||||
|
||||
@@ -337,24 +337,3 @@ describe("API runtime configuration", () => {
|
||||
expect(publicText).not.toContain("db.internal.example");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the API's trusted browser origins (ADR 0045)", () => {
|
||||
it("adds the API's own origin to the configured ones, as Better Auth does", () => {
|
||||
const origins = apiTrustedOrigins({
|
||||
baseUrl: "https://api.example.test",
|
||||
trustedOrigins: ["https://dash.example.test"],
|
||||
});
|
||||
|
||||
expect(origins).toEqual(["https://dash.example.test", "https://api.example.test"]);
|
||||
expect(Object.isFrozen(origins)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not duplicate an API origin already listed (the same-origin topology)", () => {
|
||||
expect(
|
||||
apiTrustedOrigins({
|
||||
baseUrl: "https://ekklesia.example.test",
|
||||
trustedOrigins: ["https://ekklesia.example.test"],
|
||||
}),
|
||||
).toEqual(["https://ekklesia.example.test"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,14 +13,10 @@ vi.mock("@ekklesia/shell/dashboard-assets", () => ({
|
||||
|
||||
import {
|
||||
createDashboardHostAssets,
|
||||
DASHBOARD_API_ORIGIN_PLACEHOLDER,
|
||||
DashboardHostAssetError,
|
||||
loadDashboardHostAssets,
|
||||
renderDashboardPage,
|
||||
} from "../src/dashboard-host-assets.js";
|
||||
|
||||
const API_ORIGIN = "https://api.example.test";
|
||||
|
||||
function sha256Etag(text: string): string {
|
||||
return `"${createHash("sha256").update(text).digest("base64url")}"`;
|
||||
}
|
||||
@@ -29,20 +25,19 @@ describe("Dashboard host asset loading", () => {
|
||||
beforeEach(() => readFileMock.mockReset());
|
||||
|
||||
it("reads only the two declared file URLs in order and freezes one consistent snapshot", async () => {
|
||||
const html = `<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}<div id="ekklesia-host"></div>`;
|
||||
const html = '<!doctype html><div id="ekklesia-host"></div>';
|
||||
const script = "export const dashboardHost = true;\n";
|
||||
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce(script);
|
||||
|
||||
const assets = await loadDashboardHostAssets(API_ORIGIN);
|
||||
const assets = await loadDashboardHostAssets();
|
||||
|
||||
expect(readFileMock.mock.calls).toEqual([
|
||||
[new URL("file:///surface/browser/index.html"), "utf8"],
|
||||
[new URL("file:///surface/browser/assets/ekklesia-dashboard/app.js"), "utf8"],
|
||||
]);
|
||||
const rendered = `<!doctype html><meta content="${API_ORIGIN}" name="ekklesia-api-origin" /><div id="ekklesia-host"></div>`;
|
||||
expect(assets).toEqual({
|
||||
html: rendered,
|
||||
htmlEtag: sha256Etag(rendered),
|
||||
html,
|
||||
htmlEtag: sha256Etag(html),
|
||||
script,
|
||||
scriptEtag: sha256Etag(script),
|
||||
});
|
||||
@@ -54,7 +49,7 @@ describe("Dashboard host asset loading", () => {
|
||||
new Error("ENOENT /home/operator/private/surface-build secret-token"),
|
||||
);
|
||||
|
||||
const failure = await loadDashboardHostAssets(API_ORIGIN).catch((error: unknown) => error);
|
||||
const failure = await loadDashboardHostAssets().catch((error: unknown) => error);
|
||||
|
||||
expect(readFileMock).toHaveBeenCalledTimes(1);
|
||||
expect(failure).toBeInstanceOf(DashboardHostAssetError);
|
||||
@@ -74,12 +69,10 @@ describe("Dashboard host asset loading", () => {
|
||||
|
||||
it("fails when the script is missing even though the page was read", async () => {
|
||||
readFileMock
|
||||
.mockResolvedValueOnce(`<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}`)
|
||||
.mockResolvedValueOnce("<!doctype html>")
|
||||
.mockRejectedValueOnce(new Error("ENOENT app.js"));
|
||||
|
||||
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
|
||||
DashboardHostAssetError,
|
||||
);
|
||||
await expect(loadDashboardHostAssets()).rejects.toBeInstanceOf(DashboardHostAssetError);
|
||||
expect(readFileMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -91,41 +84,4 @@ describe("Dashboard host asset loading", () => {
|
||||
expect(() => createDashboardHostAssets(html, script)).toThrow(DashboardHostAssetError);
|
||||
}
|
||||
});
|
||||
|
||||
it("fails startup on a page without exactly one empty API-origin element (ADR 0045)", async () => {
|
||||
for (const html of [
|
||||
"<!doctype html>",
|
||||
`<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}${DASHBOARD_API_ORIGIN_PLACEHOLDER}`,
|
||||
'<!doctype html><meta content="https://elsewhere.test" name="ekklesia-api-origin" />',
|
||||
]) {
|
||||
readFileMock.mockReset();
|
||||
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce("export {};\n");
|
||||
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
|
||||
DashboardHostAssetError,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dashboard page API origin (ADR 0045)", () => {
|
||||
const page = `<head>${DASHBOARD_API_ORIGIN_PLACEHOLDER}</head>`;
|
||||
|
||||
it("writes the API's origin into the one placeholder, in either topology", () => {
|
||||
expect(renderDashboardPage(page, "https://api.example.test")).toBe(
|
||||
'<head><meta content="https://api.example.test" name="ekklesia-api-origin" /></head>',
|
||||
);
|
||||
expect(renderDashboardPage(page, "http://127.0.0.1:3000")).toBe(
|
||||
'<head><meta content="http://127.0.0.1:3000" name="ekklesia-api-origin" /></head>',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"https://api.example.test/",
|
||||
"https://api.example.test/api",
|
||||
'https://api.example.test"><script>',
|
||||
"javascript:alert(1)",
|
||||
"",
|
||||
])("refuses a value that is not a canonical http(s) origin: %s", (origin) => {
|
||||
expect(() => renderDashboardPage(page, origin)).toThrow(DashboardHostAssetError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -757,35 +757,6 @@ function identitySchemaSummary(): string {
|
||||
return `Core identity schema defines ${tableNames.length} ${plural(tableNames.length, "table")} (${tableNames.join(", ")}).`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A heuristic for "same site" between two canonical origins, for a warning only (ADR 0045).
|
||||
*
|
||||
* The same hostname on another port is the same site. Otherwise the two hostnames must be siblings:
|
||||
* identical after their first label, with that parent itself holding at least one dot
|
||||
* (`dash.example.org` and `api.example.org`). Without the Public Suffix List this can be wrong in
|
||||
* both directions for unusual names, which is why its result is a warning and never a failure.
|
||||
*/
|
||||
function plausiblySameSite(dashboardOrigin: string, apiOrigin: string): boolean {
|
||||
const dashboard = new URL(dashboardOrigin).hostname;
|
||||
const api = new URL(apiOrigin).hostname;
|
||||
if (dashboard === api) {
|
||||
return true;
|
||||
}
|
||||
// An IP address is a site of its own; two different ones never share one.
|
||||
const isIpAddress = (hostname: string): boolean =>
|
||||
hostname.startsWith("[") || /^[0-9]+(\.[0-9]+){3}$/u.test(hostname);
|
||||
if (isIpAddress(dashboard) || isIpAddress(api)) {
|
||||
return false;
|
||||
}
|
||||
const parent = (hostname: string): string => hostname.slice(hostname.indexOf(".") + 1);
|
||||
return (
|
||||
dashboard.includes(".") &&
|
||||
api.includes(".") &&
|
||||
parent(dashboard) === parent(api) &&
|
||||
parent(dashboard).includes(".")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure configuration validation only: never constructs Better Auth, never opens a database
|
||||
* connection, and never prints the configured secret, base URL, or trusted origins. Missing
|
||||
@@ -798,7 +769,6 @@ function identityConfigurationCheck(
|
||||
const hasAnyValue =
|
||||
input?.secret !== undefined ||
|
||||
input?.baseUrl !== undefined ||
|
||||
(input?.dashboardOrigin ?? "") !== "" ||
|
||||
input?.publicSignupEnabled !== undefined ||
|
||||
(input?.trustedOrigins?.length ?? 0) > 0;
|
||||
|
||||
@@ -813,43 +783,17 @@ function identityConfigurationCheck(
|
||||
const config = createIdentityConfig(
|
||||
{
|
||||
baseUrl: input?.baseUrl,
|
||||
// Validated exactly as the API validates it at startup, so a Dashboard origin the API would
|
||||
// refuse (untrusted, insecure, malformed) fails here first.
|
||||
dashboardOrigin: input?.dashboardOrigin,
|
||||
publicSignupEnabled: input?.publicSignupEnabled,
|
||||
secret: input?.secret,
|
||||
trustedOrigins: input?.trustedOrigins,
|
||||
},
|
||||
{ environment: input?.environment ?? "production" },
|
||||
);
|
||||
const signupDetail = config.publicSignupEnabled
|
||||
return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
|
||||
schemaDetail,
|
||||
config.publicSignupEnabled
|
||||
? "Public signup is explicitly ENABLED."
|
||||
: "Public signup is disabled (safe default).";
|
||||
if (config.dashboardOrigin === config.baseUrl) {
|
||||
return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
|
||||
schemaDetail,
|
||||
signupDetail,
|
||||
"Topology: same-origin (the Dashboard is served from the API's origin).",
|
||||
]);
|
||||
}
|
||||
const topologyDetail = "Topology: split (the Dashboard and the API are on separate origins).";
|
||||
if (!plausiblySameSite(config.dashboardOrigin, config.baseUrl)) {
|
||||
return freezeCheck(
|
||||
"identity.configuration",
|
||||
"warning",
|
||||
"Identity configuration valid; split origins may not share a site",
|
||||
[
|
||||
schemaDetail,
|
||||
signupDetail,
|
||||
topologyDetail,
|
||||
"The Dashboard and API hostnames are not siblings under one parent domain. Browsers send the session cookie across split origins only within one registrable domain (ADR 0045); Doctor cannot check the Public Suffix List, so confirm it.",
|
||||
],
|
||||
);
|
||||
}
|
||||
return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
|
||||
schemaDetail,
|
||||
signupDetail,
|
||||
topologyDetail,
|
||||
: "Public signup is disabled (safe default).",
|
||||
]);
|
||||
} catch (error) {
|
||||
const code =
|
||||
|
||||
@@ -705,88 +705,6 @@ describe("Ekklesia Doctor diagnostics", () => {
|
||||
expect(report.result).toBe("ready-with-warnings"); // PostgreSQL is still skipped in this fixture
|
||||
});
|
||||
|
||||
describe("Dashboard and API topology (ADR 0045)", () => {
|
||||
const secret = "correct-horse-battery-staple-0123456789";
|
||||
async function identityCheck(identity: {
|
||||
readonly baseUrl: string;
|
||||
readonly dashboardOrigin?: string;
|
||||
readonly environment?: "development" | "production";
|
||||
readonly trustedOrigins: readonly string[];
|
||||
}) {
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
...options(),
|
||||
identityConfiguration: { environment: "production", secret, ...identity },
|
||||
},
|
||||
dependencies(),
|
||||
);
|
||||
return { check: check(report, "identity.configuration"), report };
|
||||
}
|
||||
|
||||
it("reports the same-origin topology when no Dashboard origin is set", async () => {
|
||||
const { check: identity } = await identityCheck({
|
||||
baseUrl: "https://ekklesia.example.test",
|
||||
trustedOrigins: ["https://ekklesia.example.test"],
|
||||
});
|
||||
expect(identity.status).toBe("pass");
|
||||
expect(identity.details).toContain(
|
||||
"Topology: same-origin (the Dashboard is served from the API's origin).",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports the split topology for sibling hostnames without printing either", async () => {
|
||||
const { check: identity, report } = await identityCheck({
|
||||
baseUrl: "https://api.example.test",
|
||||
dashboardOrigin: "https://dash.example.test",
|
||||
trustedOrigins: ["https://dash.example.test"],
|
||||
});
|
||||
expect(identity.status).toBe("pass");
|
||||
expect(identity.details).toContain(
|
||||
"Topology: split (the Dashboard and the API are on separate origins).",
|
||||
);
|
||||
expect(JSON.stringify(report)).not.toContain("dash.example.test");
|
||||
expect(JSON.stringify(report)).not.toContain("api.example.test");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["different registrable domains", "https://dash.example.test", "https://api.example.org"],
|
||||
["two IP addresses", "https://10.0.0.2", "https://10.0.0.1"],
|
||||
["a parent that is only a top-level domain", "https://dash.test", "https://api.test"],
|
||||
])("warns, without failing, when split hostnames may not share a site: %s", async (_case, dashboardOrigin, baseUrl) => {
|
||||
const { check: identity, report } = await identityCheck({
|
||||
baseUrl,
|
||||
dashboardOrigin,
|
||||
trustedOrigins: [dashboardOrigin],
|
||||
});
|
||||
expect(identity.status).toBe("warning");
|
||||
expect(identity.details.join("\n")).toContain("not siblings under one parent domain");
|
||||
expect(doctorExitCode(report)).toBe(0);
|
||||
});
|
||||
|
||||
it("treats another port on the same host as the same site (split development)", async () => {
|
||||
const { check: identity } = await identityCheck({
|
||||
baseUrl: "http://localhost:3000",
|
||||
dashboardOrigin: "http://localhost:5173",
|
||||
environment: "development",
|
||||
trustedOrigins: ["http://localhost:5173"],
|
||||
});
|
||||
expect(identity.status).toBe("pass");
|
||||
});
|
||||
|
||||
it("fails, as the API's startup would, for a Dashboard origin the API does not trust", async () => {
|
||||
const { check: identity, report } = await identityCheck({
|
||||
baseUrl: "https://api.example.test",
|
||||
dashboardOrigin: "https://dash.example.test",
|
||||
trustedOrigins: ["https://other.example.test"],
|
||||
});
|
||||
expect(identity.status).toBe("failure");
|
||||
expect(identity.details.some((detail) => detail.includes("DASHBOARD_ORIGIN_UNTRUSTED"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(doctorExitCode(report)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("fails identity configuration for an invalid secret without leaking it", async () => {
|
||||
const hostileSecret = "s3cr3t-do-not-log";
|
||||
const report = await diagnoseEkklesia(
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
# Ekklesia reference proxy configuration for the split topology (ADR 0045): the Dashboard and the API
|
||||
# on separate hostnames of one site, for example dash.example.org and api.example.org. For the
|
||||
# default same-origin topology use Caddyfile.example instead. Copy this file to deploy/Caddyfile.
|
||||
#
|
||||
# Hostnames, the ACME contact, and the tunnel connector's address come from the proxy service's
|
||||
# environment (deploy/.env): EKKLESIA_DASHBOARD_HOSTNAME is the Dashboard's, EKKLESIA_HOSTNAME the
|
||||
# API's (which also serves the display host), EKKLESIA_STORAGE_HOSTNAME the storage origin's. Only
|
||||
# the two site blocks below differ from Caddyfile.example.
|
||||
{
|
||||
# ACME account contact. Certificates use the HTTP-01 challenge on port 80 by default, which needs
|
||||
# all three hostnames to resolve publicly to this host with 80 and 443 reachable.
|
||||
email {$EKKLESIA_ACME_EMAIL}
|
||||
|
||||
# DNS-01, for an installation that keeps Ekklesia off the public Internet, serves only a LAN
|
||||
# (split-horizon DNS), or is reachable only through Cloudflare Tunnel. Cloudflare terminates TLS at
|
||||
# its edge, so HTTP-01 cannot reach this proxy through a tunnel.
|
||||
#
|
||||
# DNS-01 needs a DNS provider module that the stock Caddy image does not contain. Build an image
|
||||
# with it (for example `xcaddy build --with github.com/caddy-dns/cloudflare`), pin that image by
|
||||
# digest in your own overlay, give it CLOUDFLARE_API_TOKEN through a root-owned env file, then
|
||||
# enable:
|
||||
#
|
||||
# acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
||||
|
||||
servers {
|
||||
# A client-address header is honored only from the tunnel connector's fixed address
|
||||
# (compose.tunnel.yaml). From every other peer, the client address is the TCP peer itself and
|
||||
# any such header is ignored. Without a tunnel, nothing has this address.
|
||||
trusted_proxies static {$EKKLESIA_TUNNEL_ADDRESS}/32
|
||||
client_ip_headers CF-Connecting-IP
|
||||
trusted_proxies_strict
|
||||
}
|
||||
|
||||
# Access logs are off unless enabled. If you enable them, keep request headers out: they carry
|
||||
# cookies and forwarding chains (docs/development/security.md, launch checklist item 7).
|
||||
}
|
||||
|
||||
# What every request to the API carries upstream: exactly one forwarding header, overwritten with
|
||||
# the client address Caddy resolved, and none of the headers a caller could use to choose its own.
|
||||
(ekklesia_forwarding) {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
header_up -CF-Connecting-IP
|
||||
header_up -X-Real-IP
|
||||
header_up -Forwarded
|
||||
header_up -True-Client-IP
|
||||
}
|
||||
|
||||
# The Dashboard: its page and its one script, nothing else. Emailed verification and reset links land
|
||||
# on `/` with a query. `/api/*` is refused here on purpose, so the session cookie has exactly one
|
||||
# home, the API hostname, and the Dashboard can never fall back to a same-origin API.
|
||||
{$EKKLESIA_DASHBOARD_HOSTNAME} {
|
||||
@dashboard path / /assets/ekklesia-dashboard/*
|
||||
|
||||
handle @dashboard {
|
||||
reverse_proxy api:3000 {
|
||||
import ekklesia_forwarding
|
||||
}
|
||||
}
|
||||
|
||||
handle {
|
||||
respond 404
|
||||
}
|
||||
}
|
||||
|
||||
# The API, and the display host on the API's own origin: a screen's pages, service worker, device
|
||||
# cookies, and requests are all same-origin (ADR 0045). Nothing is served at `/`.
|
||||
{$EKKLESIA_HOSTNAME} {
|
||||
@api path /api/* /display/* /assets/ekklesia-display/*
|
||||
|
||||
# Module realtime is Server-Sent Events: flush every event immediately.
|
||||
@realtime path /api/v1/module-realtime/*
|
||||
|
||||
handle @realtime {
|
||||
reverse_proxy api:3000 {
|
||||
import ekklesia_forwarding
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
handle @api {
|
||||
# Request bodies stream through unbuffered and unlimited here; the API enforces the platform's
|
||||
# ceilings itself (8 KiB for authentication, up to 32 MiB for a streamed module route).
|
||||
reverse_proxy api:3000 {
|
||||
import ekklesia_forwarding
|
||||
}
|
||||
}
|
||||
|
||||
handle {
|
||||
respond 404
|
||||
}
|
||||
}
|
||||
|
||||
# Browsers fetch signed object URLs here (S3_DELIVERY_ENDPOINT, ADR 0031).
|
||||
{$EKKLESIA_STORAGE_HOSTNAME} {
|
||||
reverse_proxy https://object-storage:8443 {
|
||||
# An S3 signature covers the Host header: pass the original through unchanged, or every signed
|
||||
# URL fails verification.
|
||||
header_up Host {host}
|
||||
header_up -CF-Connecting-IP
|
||||
header_up -X-Real-IP
|
||||
header_up -Forwarded
|
||||
header_up -True-Client-IP
|
||||
transport http {
|
||||
# object-storage presents a certificate from the installation-private CA.
|
||||
tls_trust_pool file /etc/caddy/ekklesia-ca.crt
|
||||
tls_server_name object-storage
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-50
@@ -10,8 +10,7 @@ specific to one organization.
|
||||
| [`compose.yaml`](compose.yaml) | The base: `api`, `postgres`, `object-storage`, and the operator-only `migrate`. Publishes no port. |
|
||||
| [`compose.proxy.yaml`](compose.proxy.yaml) | Overlay: Caddy as the TLS proxy, the only service with published ports. |
|
||||
| [`compose.tunnel.yaml`](compose.tunnel.yaml) | Overlay on top of the proxy: a Cloudflare Tunnel connector. |
|
||||
| [`Caddyfile.example`](Caddyfile.example) | The proxy's configuration for the default same-origin topology; copy to `Caddyfile`. |
|
||||
| [`Caddyfile.split.example`](Caddyfile.split.example) | The proxy's configuration when the Dashboard and the API have separate hostnames ([Split Dashboard and API origins](#split-dashboard-and-api-origins)); copy to `Caddyfile` instead. |
|
||||
| [`Caddyfile.example`](Caddyfile.example) | The proxy's configuration; copy to `Caddyfile`. |
|
||||
| [`postgres/pg_hba.conf`](postgres/pg_hba.conf) | PostgreSQL client authentication: TLS only, SCRAM passwords. |
|
||||
| [`env/`](env/) | Commented examples of every env file. No values. |
|
||||
| [`scripts/init-tls.sh`](scripts/init-tls.sh) | Creates the installation-private CA and the `postgres` and `object-storage` server certificates. |
|
||||
@@ -143,41 +142,6 @@ image, because it was not built for the newer schema.
|
||||
Slice 6 adds the scheduled backup job and the restore procedure (ADR 0044, Backups). Until then,
|
||||
take a `pg_dump` and a copy of every bucket before each upgrade by hand.
|
||||
|
||||
### Split Dashboard and API origins
|
||||
|
||||
By default one hostname serves the Dashboard (`/`), the API (`/api/*`), and displays (`/display/*`).
|
||||
An installation may instead give the Dashboard its own hostname, for example `dash.example.org`
|
||||
beside `api.example.org` ([ADR 0045](../docs/architecture/decisions/0045-split-dashboard-and-api-origins.md)).
|
||||
The same image serves both; only configuration differs.
|
||||
|
||||
| Hostname | Serves | Refuses (`404`) |
|
||||
| --- | --- | --- |
|
||||
| Dashboard (`EKKLESIA_DASHBOARD_HOSTNAME`) | `/` and `/assets/ekklesia-dashboard/*` | everything else, including `/api/*` |
|
||||
| API (`EKKLESIA_HOSTNAME`) | `/api/*`, `/display/*`, `/assets/ekklesia-display/*` | everything else, including `/` |
|
||||
| Storage (`EKKLESIA_STORAGE_HOSTNAME`) | signed object URLs, unchanged | |
|
||||
|
||||
Displays live on the API's hostname (`https://api.example.org/display/<module>`), because their
|
||||
device cookies are bound to it and their offline cache keeps only same-origin responses. That
|
||||
hostname is what every screen and paired device depends on: choose it once.
|
||||
|
||||
To run split:
|
||||
|
||||
1. Copy `Caddyfile.split.example` to `Caddyfile` instead of `Caddyfile.example`.
|
||||
2. In `deploy/.env`, set `EKKLESIA_HOSTNAME` to the API's hostname and `EKKLESIA_DASHBOARD_HOSTNAME`
|
||||
to the Dashboard's.
|
||||
3. In `ekklesia.env`, set `EKKLESIA_IDENTITY_BASE_URL` to `https://` and the API's hostname,
|
||||
`EKKLESIA_DASHBOARD_ORIGIN` to `https://` and the Dashboard's, and
|
||||
`EKKLESIA_IDENTITY_TRUSTED_ORIGINS` to the Dashboard's origin. The API always trusts its own
|
||||
origin, so it need not be listed.
|
||||
|
||||
**Both hostnames must belong to one registrable domain** (`dash.example.org` and `api.example.org`,
|
||||
not `dash.example.org` and `api.example.net`). The session cookie stays host-only on the API's
|
||||
hostname; browsers send it from the Dashboard only between origins of the same site. Doctor reports
|
||||
the topology and warns when the two hostnames are not siblings.
|
||||
|
||||
The Dashboard learns the API's origin at run time: the API writes its identity base URL into the page
|
||||
it serves (`<meta name="ekklesia-api-origin">`). Nothing about the topology is built into the image.
|
||||
|
||||
### Behind an existing reverse proxy
|
||||
|
||||
Run the base alone and attach `api` and `object-storage` to your proxy's network with an overlay of
|
||||
@@ -239,9 +203,8 @@ dashboard. No Cloudflare API is called from here.
|
||||
the Ekklesia hostname. Service type **HTTPS**, URL `proxy:443`.
|
||||
3. Under that hostname's **TLS** settings, set **Origin Server Name** to the same Ekklesia hostname,
|
||||
and leave **No TLS Verify** off. The connector then verifies the proxy's certificate for that name.
|
||||
4. Add a public hostname for the storage hostname, the same way, with its own origin server name.
|
||||
In the [split topology](#split-dashboard-and-api-origins), add one for the Dashboard's hostname
|
||||
too: three in all, each with its own origin server name.
|
||||
4. Add a second public hostname for the storage hostname, the same way, with its own origin server
|
||||
name.
|
||||
5. Start with all three files. The proxy honors `CF-Connecting-IP` only from the connector's fixed
|
||||
address. From any other peer it is stripped.
|
||||
|
||||
@@ -269,7 +232,7 @@ deployment:
|
||||
|
||||
| # | Item | Where it is met |
|
||||
| --- | --- | --- |
|
||||
| 1 | The proxy forwards the Dashboard and `/api` on the intended origin | `Caddyfile.example` routes `/`, `/api/*`, `/display/*`, `/assets/*` on one hostname to `api`; the API serves the Dashboard itself. In the split topology, `Caddyfile.split.example` gives each hostname exactly its own paths and the exact trusted-origin CORS of ADR 0045 covers the rest. |
|
||||
| 1 | The proxy forwards the Dashboard and `/api` on the intended origin | `Caddyfile.example` routes `/`, `/api/*`, `/display/*`, `/assets/*` on one hostname to `api`; the API serves the Dashboard itself. |
|
||||
| 2 | The origin port is unreachable from the Internet | The base publishes no port; only `proxy` publishes 80 and 443. |
|
||||
| 3 | The origin port is unreachable from untrusted networks | `api` is reachable only on Docker networks; `EKKLESIA_PUBLISH_ADDRESS` limits the proxy to one interface. Firewall the host itself. |
|
||||
| 4 | The proxy overwrites the trusted client-IP header | `header_up X-Forwarded-For {client_ip}` in `Caddyfile.example`. |
|
||||
@@ -295,15 +258,7 @@ deployment:
|
||||
wrong host name;
|
||||
- Doctor.
|
||||
|
||||
`EKKLESIA_SMOKE_TOPOLOGY=split` runs the [split topology](#split-dashboard-and-api-origins)
|
||||
instead: an image with the repository's module template installed as a test-only module, every
|
||||
hostname's served and refused paths, exact CORS for the Dashboard's origin and none for others,
|
||||
and then a real headless Chromium (pinned by digest, driven over the DevTools protocol) that signs
|
||||
in on the Dashboard's hostname, checks the session cookie lives only on the API's, mounts the
|
||||
module's UI from the API's origin and sees its own credentialed requests succeed, mounts its display
|
||||
on the API's hostname with its service worker, and signs out.
|
||||
|
||||
CI runs both topologies on every change.
|
||||
CI runs it on every change.
|
||||
- **Test-only:** the smoke test runs Caddy with its internal CA (`local_certs`), because a test run
|
||||
has no public hostname. Never use it in an installation; `Caddyfile.example` does not contain it.
|
||||
|
||||
|
||||
@@ -35,10 +35,6 @@ services:
|
||||
environment:
|
||||
EKKLESIA_HOSTNAME: ${EKKLESIA_HOSTNAME:?EKKLESIA_HOSTNAME is required, for example ekklesia.example.org}
|
||||
EKKLESIA_STORAGE_HOSTNAME: ${EKKLESIA_STORAGE_HOSTNAME:?EKKLESIA_STORAGE_HOSTNAME is required, for example storage.ekklesia.example.org}
|
||||
# The split topology only (ADR 0045, Caddyfile.split.example), where EKKLESIA_HOSTNAME is the
|
||||
# API's hostname and this is the Dashboard's. Caddyfile.example ignores it; the split
|
||||
# configuration refuses to start without it.
|
||||
EKKLESIA_DASHBOARD_HOSTNAME: ${EKKLESIA_DASHBOARD_HOSTNAME:-}
|
||||
EKKLESIA_ACME_EMAIL: ${EKKLESIA_ACME_EMAIL:?EKKLESIA_ACME_EMAIL is required for ACME account notices}
|
||||
EKKLESIA_TUNNEL_ADDRESS: ${EKKLESIA_TUNNEL_ADDRESS:-10.249.250.253}
|
||||
volumes:
|
||||
|
||||
Vendored
+1
-6
@@ -10,14 +10,9 @@ EKKLESIA_ENV_DIR=/etc/ekklesia
|
||||
EKKLESIA_TLS_DIR=/etc/ekklesia/tls
|
||||
|
||||
# --- compose.proxy.yaml ---------------------------------------------------------------------------
|
||||
# The Ekklesia hostname and the storage hostname (signed object URLs). In the default same-origin
|
||||
# topology (Caddyfile.example) EKKLESIA_HOSTNAME serves the Dashboard, the API, and displays.
|
||||
# The Ekklesia hostname (Dashboard, API, displays) and the storage hostname (signed object URLs).
|
||||
EKKLESIA_HOSTNAME=
|
||||
EKKLESIA_STORAGE_HOSTNAME=
|
||||
# The split topology only (ADR 0045, Caddyfile.split.example): the Dashboard's own hostname, while
|
||||
# EKKLESIA_HOSTNAME is the API's and the displays'. Both must share one registrable domain, for
|
||||
# example dash.example.org and api.example.org. Leave empty for the same-origin topology.
|
||||
EKKLESIA_DASHBOARD_HOSTNAME=
|
||||
# Contact address for the ACME account (certificate expiry notices).
|
||||
EKKLESIA_ACME_EMAIL=
|
||||
# EKKLESIA_CADDYFILE=./Caddyfile
|
||||
|
||||
Vendored
+5
-9
@@ -23,19 +23,15 @@ EKKLESIA_DATABASE_TLS_MODE=verify-full
|
||||
# scripts/init-tls.sh prints this exact line. It is the only CA the database connection trusts.
|
||||
EKKLESIA_DATABASE_CA=
|
||||
|
||||
# --- Identity (ADR 0011, ADR 0018, ADR 0034, ADR 0045) -----------------------------------------
|
||||
# The public https origin of the API, with no path: the Ekklesia hostname in the same-origin
|
||||
# topology, the API's hostname in the split one. Production rejects http and loopback origins.
|
||||
# --- Identity (ADR 0011, ADR 0018, ADR 0034) ----------------------------------------------------
|
||||
# The public https origin of the Ekklesia hostname, with no path. Production rejects http and
|
||||
# loopback origins.
|
||||
EKKLESIA_IDENTITY_BASE_URL=
|
||||
# Comma-separated exact origins allowed to make credentialed requests. The API's own origin is
|
||||
# always trusted and need not be listed. Same-origin topology: the base URL. Split topology: the
|
||||
# Dashboard's origin.
|
||||
# Comma-separated exact origins allowed to make credentialed requests. Normally just the base URL.
|
||||
EKKLESIA_IDENTITY_TRUSTED_ORIGINS=
|
||||
# At least 32 random characters. Rotating it signs every user out.
|
||||
EKKLESIA_IDENTITY_SECRET=
|
||||
# Same-origin topology: leave unset; the Dashboard is served from the base URL (ADR 0044).
|
||||
# Split topology: the Dashboard's https origin, which must also be in the trusted origins. Emailed
|
||||
# verification and reset links land here (ADR 0045).
|
||||
# Leave unset: the Dashboard is served from the base URL (ADR 0044).
|
||||
# EKKLESIA_DASHBOARD_ORIGIN=
|
||||
# Production refuses public sign-up. Leave unset.
|
||||
# EKKLESIA_PUBLIC_SIGNUP_ENABLED=
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 0045: The Dashboard and the API may live on separate origins
|
||||
|
||||
- Status: Accepted
|
||||
- Status: Proposed
|
||||
- Date: 2026-10-03
|
||||
- Amends: [0034](0034-host-owned-browser-authentication.md)
|
||||
- Extends: [0044](0044-self-hosted-reference-deployment.md)
|
||||
|
||||
@@ -53,5 +53,5 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0042: Explicit, bounded streaming request bodies](0042-bounded-streaming-request-bodies.md) — Accepted
|
||||
- [0043: The display caches its shell and declared code, and nothing else](0043-display-offline-code-fallback.md) — Accepted
|
||||
- [0044: Ekklesia ships one self-hosted reference deployment, composed from pinned sources](0044-self-hosted-reference-deployment.md) — Accepted
|
||||
- [0045: The Dashboard and the API may live on separate origins](0045-split-dashboard-and-api-origins.md) — Accepted
|
||||
- [0045: The Dashboard and the API may live on separate origins](0045-split-dashboard-and-api-origins.md) — Proposed
|
||||
- [0046: Ekklesia's branding is never forced on an installation](0046-installation-owned-branding.md) — Proposed
|
||||
|
||||
+17
-40
@@ -62,7 +62,7 @@ No real values are shown here — configure per environment and never commit the
|
||||
| `EKKLESIA_API_PORT` | Optional. Listen port, `0`–`65535` (`0` requests an OS-assigned port). Defaults to `3000`. |
|
||||
| `EKKLESIA_IDENTITY_SECRET` | At least 32 characters, high entropy (for example `openssl rand -base64 32`). Same requirement as Phase 4A. |
|
||||
| `EKKLESIA_IDENTITY_BASE_URL` | Exactly one canonical `http`/`https` origin — scheme, host, and non-default port only, with no path, trailing slash, query, fragment, credentials, or explicit default port. `https` is required in production; loopback and unspecified hosts are rejected in production. |
|
||||
| `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` | Comma-separated list of canonical origins, validated identically to the base URL (no wildcards). Used for both Better Auth's own CSRF/origin checks and the API's CORS policy — there is only one trusted-origin list. The base URL's own origin is always trusted as well and need not be listed ([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)). |
|
||||
| `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` | Comma-separated list of canonical origins, validated identically to the base URL (no wildcards). Used for both Better Auth's own CSRF/origin checks and the API's CORS policy — there is only one trusted-origin list. |
|
||||
| `EKKLESIA_PUBLIC_SIGNUP_ENABLED` | Optional, **development/test only**. Exactly `"true"` enables public sign-up when `NODE_ENV=development`; defaults to disabled, and a production configuration rejects `"true"` outright. Omitted, misspelled, or any other value never enables it. See [Public registration policy](#public-registration-policy). |
|
||||
| `EKKLESIA_CLIENT_ADDRESS_MODE` | Optional. `"direct"` (default when unset) or `"trusted-proxy"`. See [Client-address resolution](#client-address-resolution). |
|
||||
| `EKKLESIA_CLIENT_ADDRESS_HEADER` | Required only when `EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy`. The one forwarded-header name the reverse proxy is trusted to set (for example `x-forwarded-for`). |
|
||||
@@ -77,7 +77,7 @@ No real values are shown here — configure per environment and never commit the
|
||||
| `/api/auth/{get-session,list-sessions,update-session,change-password}` | Deliberately unavailable | Token-bearing native session inspection/mutation endpoints are denied at the Ekklesia HTTP boundary with sanitized `404 {"code":"NOT_FOUND"}`. Use `/api/v1/principal`; no browser session-list/update or password-management contract exists. |
|
||||
| `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Supported Better Auth routes retain their native shapes except for the successful email sign-in normalization above. The four denied paths above never reach Better Auth. See [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for supported endpoints such as `/sign-up/email` and `/sign-out`. |
|
||||
| `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. |
|
||||
| `GET /` | Ekklesia | The built Dashboard page (`surfaces/shell`'s `index.html`), with the API's origin written into it. Served from the API's origin, or from the Dashboard's own hostname through the proxy in the split topology. `no-cache` with a strong `ETag`; framing refused. See [Dashboard host](#dashboard-host). |
|
||||
| `GET /` | Ekklesia | The built Dashboard page (`surfaces/shell`'s `index.html`), served from the same origin as `/api`. `no-cache` with a strong `ETag`; framing refused. See [Dashboard host](#dashboard-host). |
|
||||
| `GET /assets/ekklesia-dashboard/app.js` | Ekklesia | The Dashboard's one script. `no-cache` with a strong `ETag`; its source map is never served. See [Dashboard host](#dashboard-host). |
|
||||
| `GET /api/v1/health/live` | Ekklesia | Unauthenticated liveness: fixed `200 {"status":"live"}` whenever the process is serving HTTP. `Cache-Control: no-store`; GET only. See [Health](#health). |
|
||||
| `GET /api/v1/health/ready` | Ekklesia | Unauthenticated readiness: fixed `200 {"status":"ready"}` only when a bounded database ping succeeds and module startup completed, otherwise `503 {"code":"NOT_READY"}`. `Cache-Control: no-store`; GET only. See [Health](#health). |
|
||||
@@ -140,29 +140,19 @@ role, permission, membership, or volunteer data.
|
||||
|
||||
## Dashboard host
|
||||
|
||||
`apps/api` serves the built Dashboard itself
|
||||
([ADR 0044](../architecture/decisions/0044-self-hosted-reference-deployment.md)). In the default
|
||||
same-origin topology it does so from the same origin as `/api`: `EKKLESIA_DASHBOARD_ORIGIN` stays
|
||||
unset, the session cookie stays host-only, and no second static server or proxy file root is
|
||||
needed. In the split topology
|
||||
([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)) the proxy forwards
|
||||
the Dashboard's own hostname to the same two routes; the API does not inspect `Host`. It works
|
||||
exactly like the display host:
|
||||
`apps/api` serves the built Dashboard itself, from the same origin as `/api`
|
||||
([ADR 0044](../architecture/decisions/0044-self-hosted-reference-deployment.md)). That keeps the
|
||||
identity system's default same-origin shape: `EKKLESIA_DASHBOARD_ORIGIN` stays unset, the session
|
||||
cookie stays host-only, and no second static server or proxy file root is needed. It works exactly
|
||||
like the display host:
|
||||
|
||||
- **A closed set of files, read once at startup.** `@ekklesia/shell/dashboard-assets` exports the
|
||||
exact URLs of two built files, `dist/browser/index.html` and
|
||||
`dist/browser/assets/ekklesia-dashboard/app.js`. `startApi()` reads both before importing any
|
||||
module code or binding the listener, and keeps one frozen snapshot of their text with a SHA-256
|
||||
strong `ETag` for each.
|
||||
- **The API's origin is written in at startup.** The built page carries one empty
|
||||
`<meta content="" name="ekklesia-api-origin" />`. Before computing the page's `ETag`, `startApi()`
|
||||
fills it with the identity base URL's origin, which is where `/api` lives in either topology. The
|
||||
shell reads it before rendering, uses the page's own origin when it is empty, and fails closed,
|
||||
making no request, when it is anything but a canonical `http(s)` origin. Nothing about the topology
|
||||
is built into the bundle.
|
||||
- **A missing or incompatible build fails startup.** An absent, unreadable, or empty file, or a page
|
||||
without exactly one empty API-origin element, stops `startApi()` with a fixed
|
||||
`DashboardHostAssetError` (`DASHBOARD_HOST_ASSET_UNAVAILABLE`), after closing the resources
|
||||
- **A missing build fails startup.** An absent, unreadable, or empty file stops `startApi()` with a
|
||||
fixed `DashboardHostAssetError` (`DASHBOARD_HOST_ASSET_UNAVAILABLE`), after closing the resources
|
||||
already opened. It never carries a path or the filesystem error. Build the shell first;
|
||||
`pnpm api:start` already runs the full build.
|
||||
- **Two exact routes, never a directory.** `GET /` answers the page and
|
||||
@@ -205,9 +195,7 @@ strips that token from the address bar before it renders anything.
|
||||
|
||||
**Development.** `pnpm --filter @ekklesia/shell dev` serves `dist/browser` at the same absolute
|
||||
paths, because the build emits the script at `assets/ekklesia-dashboard/app.js`. `index.html` names
|
||||
that path absolutely, so the page never depends on the URL that served it. For a split-origin
|
||||
development API, set `EKKLESIA_API_BASE_URL` for the development server: it writes that origin into
|
||||
its copy of the page exactly as the API does in production. A production build ignores it.
|
||||
that path absolutely, so the page never depends on the URL that served it.
|
||||
|
||||
## Dashboard browser authentication
|
||||
|
||||
@@ -352,10 +340,7 @@ real server: 7 characters rejected, 8 accepted, 128 accepted, 129 rejected.
|
||||
## Origin, CORS, and cookies
|
||||
|
||||
The API reuses the same `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` list for both CORS and Better Auth's
|
||||
own origin/CSRF checks — there is no second, competing configuration. Like Better Auth, the API also
|
||||
trusts the identity base URL's own origin without it being listed, so a page the API itself serves
|
||||
(the display host, or the Dashboard in the same-origin topology) passes the API's origin checks
|
||||
([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)). CORS reflects a request's
|
||||
own origin/CSRF checks — there is no second, competing configuration. CORS reflects a request's
|
||||
`Origin` header back only when it exactly matches a configured trusted origin (never a wildcard,
|
||||
since `Access-Control-Allow-Origin: *` cannot be combined with `Access-Control-Allow-Credentials:
|
||||
true`); a disallowed origin never receives the header at all, though the request itself may still
|
||||
@@ -389,18 +374,10 @@ Cookies keep Better Auth's defaults: `HttpOnly` always, `SameSite=Lax` always, `
|
||||
configured base URL is `https://`. Cross-subdomain cookies are not enabled and no cookie `Domain`
|
||||
attribute is set, so every cookie is host-only. See [Client-address resolution](#client-address-resolution)
|
||||
for how (and whether) a reverse proxy's forwarded headers are trusted — a separate concern from
|
||||
CORS/CSRF above. The Dashboard does not broaden this policy. In the split topology
|
||||
([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)) the cookie stays
|
||||
host-only on the API's hostname, and the Dashboard on a sibling hostname reaches the API with
|
||||
credentialed CORS; browsers send the cookie because the two hostnames are origins of one site, which
|
||||
is therefore a requirement of that topology. The reference deployment provides both topologies
|
||||
([`deploy/README.md`](../../deploy/README.md#split-dashboard-and-api-origins)).
|
||||
|
||||
**CORS request and response headers are deliberately narrow, and part of the module UI contract.**
|
||||
Beyond the CORS-safelisted request headers, only `Content-Type` is allowed, and no response header is
|
||||
exposed beyond the safelisted ones. A module UI's `api.request` inherits this policy, so portable
|
||||
module UI code sends only safelisted headers plus `Content-Type` and reads only safelisted response
|
||||
headers. That keeps one module artifact working in either topology.
|
||||
CORS/CSRF above. The Dashboard does not broaden this policy: same-origin `/api` proxying is the
|
||||
simplest production topology, while a deliberately split browser/API origin must use the exact
|
||||
trusted-origin allowlist and credentialed CORS. Sibling subdomains do not implicitly share the
|
||||
cookie. No production Dashboard hostname or proxy deployment exists in this repository.
|
||||
|
||||
## Logging
|
||||
|
||||
@@ -1636,7 +1613,7 @@ Cookie: better-auth.session_token=<redacted, now revoked>
|
||||
See the repository root [README](../../README.md#current-status) and
|
||||
[platform architecture](../architecture/platform-architecture.md#current-implementation-status)
|
||||
for what has landed across every phase and what remains. Surface work is underway; the API now
|
||||
serves the built Dashboard ([Dashboard host](#dashboard-host)) in either topology, while the full
|
||||
Content-Security-Policy, enrollment/recovery, display device identity, and public Website
|
||||
serves the built Dashboard ([Dashboard host](#dashboard-host)), while its production proxy, TLS,
|
||||
full Content-Security-Policy, enrollment/recovery, display device identity, and public Website
|
||||
routing remain separate work. This document tracks `apps/api`'s own HTTP, module-runtime, and
|
||||
database-composition contracts as they evolve.
|
||||
|
||||
@@ -153,8 +153,7 @@ enforced by `requirePermission` regardless; the test suite proves it by having a
|
||||
"denied" call its API anyway and still receive `403`.
|
||||
|
||||
**The host executes trusted module code in its own page and does not pretend otherwise.** The loader
|
||||
performs a dynamic `import()` from the API's origin (the page's own in the same-origin topology, the
|
||||
API hostname in the split one, ADR 0045) — no `eval`, no `new Function`, no `blob:` URL — and the
|
||||
performs a same-origin dynamic `import()` — no `eval`, no `new Function`, no `blob:` URL — and the
|
||||
shell owns the container, the loading state, and every error, so a failing module costs its own panel
|
||||
rather than the shell. None of that is containment: once imported, a module UI is ordinary same-realm
|
||||
JavaScript, exactly as described above.
|
||||
@@ -494,9 +493,7 @@ outstanding.
|
||||
split origin is covered by the exact trusted-origin/CORS configuration. `apps/api` serves the
|
||||
built Dashboard itself at `/` ([API § Dashboard host](api.md#dashboard-host)), and the
|
||||
reference deployment's proxy routes it ([deploy/README.md](../../deploy/README.md#launch-checklist)
|
||||
maps every item here to its configuration). A split installation (ADR 0045) puts its Dashboard
|
||||
and API hostnames on one registrable domain and uses `Caddyfile.split.example`, which refuses
|
||||
`/api/*` on the Dashboard's hostname so the session cookie has exactly one home.
|
||||
maps every item here to its configuration).
|
||||
2. The origin port is unreachable from the public Internet.
|
||||
3. The origin port is also unreachable from any untrusted network/VLAN — only the intended private
|
||||
proxy path connects. Security must not depend on an unpublished DNS name or origin IP.
|
||||
|
||||
@@ -125,11 +125,6 @@ export interface ModuleUiDisplayLocation {
|
||||
*
|
||||
* A host is responsible for credentials, base URL, and any platform headers. A module UI neither
|
||||
* supplies nor sees a session token.
|
||||
*
|
||||
* The module's API may be on another origin than the page (Ekklesia's split topology, ADR 0045), so
|
||||
* a portable module UI keeps to what cross-origin requests always allow: CORS-safelisted request
|
||||
* headers plus `Content-Type`, and only CORS-safelisted response headers read back. Anything more
|
||||
* works under one host topology and fails under another.
|
||||
*/
|
||||
export interface ModuleUiApiClient {
|
||||
/**
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Why a disposable Compose project did not become ready: `docker compose ps` and the last log lines
|
||||
# of every service, so a CI failure says what went wrong (a port already allocated, a container that
|
||||
# exited, a health check that never passed) instead of only that something did.
|
||||
#
|
||||
# source scripts/lib/compose-diagnostics.sh
|
||||
# COMPOSE_DIAGNOSTICS_SECRETS=("$password" "$secret_key") # literal values to mask, optional
|
||||
# compose_report_not_ready "$up_output" "${compose[@]}"
|
||||
#
|
||||
# Everything printed passes through `compose_diagnostics_redact` first. The projects these scripts
|
||||
# start hold only disposable, generated test credentials, but output is redacted anyway: every
|
||||
# literal in COMPOSE_DIAGNOSTICS_SECRETS, the value of any `NAME=value` or `NAME: value` whose name
|
||||
# looks like a credential, the user-information part of any URL, and the rest of any Authorization
|
||||
# header. Terminal control sequences are stripped. Output goes to standard error.
|
||||
|
||||
COMPOSE_DIAGNOSTICS_SECRETS=()
|
||||
COMPOSE_DIAGNOSTICS_TAIL_LINES=40
|
||||
|
||||
# compose_diagnostics_redact: standard input to standard output, with credentials masked.
|
||||
compose_diagnostics_redact() {
|
||||
local secrets=""
|
||||
if ((${#COMPOSE_DIAGNOSTICS_SECRETS[@]} > 0)); then
|
||||
secrets="$(printf '%s\n' "${COMPOSE_DIAGNOSTICS_SECRETS[@]}")"
|
||||
fi
|
||||
# Literal values first, by plain substring rather than as patterns. Very short values are skipped:
|
||||
# masking every occurrence of a two-letter word would only make the output unreadable.
|
||||
COMPOSE_DIAGNOSTICS_LITERALS="$secrets" awk '
|
||||
BEGIN { count = split(ENVIRON["COMPOSE_DIAGNOSTICS_LITERALS"], secret, "\n") }
|
||||
{
|
||||
line = $0
|
||||
for (i = 1; i <= count; i++) {
|
||||
if (length(secret[i]) < 6) {
|
||||
continue
|
||||
}
|
||||
masked = ""
|
||||
while ((at = index(line, secret[i])) > 0) {
|
||||
masked = masked substr(line, 1, at - 1) "[redacted]"
|
||||
line = substr(line, at + length(secret[i]))
|
||||
}
|
||||
line = masked line
|
||||
}
|
||||
print line
|
||||
}
|
||||
' | sed -E \
|
||||
-e 's/\x1b\[[0-9;?]*[A-Za-z]//g' \
|
||||
-e 's#([Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn][[:space:]]*[:=]).*#\1 [redacted]#' \
|
||||
-e 's#([A-Za-z0-9_.-]*([Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Pp][Aa][Ss][Ss][Ww][Dd]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[_.-][Kk][Ee][Yy])[A-Za-z0-9_.-]*"?[[:space:]]*[=:][[:space:]]*)("[^"]*"|[^[:space:],;"]+)#\1[redacted]#g' \
|
||||
-e 's#([A-Za-z][A-Za-z0-9+.-]*://)[^/@[:space:]]+@#\1[redacted]@#g'
|
||||
}
|
||||
|
||||
# compose_report_not_ready <up output> <compose command...>: the failed command's own output, then
|
||||
# the project's containers and the tail of each service's logs, all redacted.
|
||||
compose_report_not_ready() {
|
||||
local up_output="$1"
|
||||
shift
|
||||
local service services
|
||||
{
|
||||
if [[ -n "$up_output" ]]; then
|
||||
echo "--- docker compose up (last 20 lines)"
|
||||
printf '%s\n' "$up_output" | tail -n 20 | compose_diagnostics_redact
|
||||
fi
|
||||
echo "--- docker compose ps"
|
||||
"$@" ps --all 2>&1 | compose_diagnostics_redact
|
||||
services="$("$@" config --services 2>/dev/null || true)"
|
||||
for service in $services; do
|
||||
echo "--- docker compose logs ${service} (last ${COMPOSE_DIAGNOSTICS_TAIL_LINES} lines)"
|
||||
"$@" logs --no-color --tail "$COMPOSE_DIAGNOSTICS_TAIL_LINES" "$service" 2>&1 |
|
||||
compose_diagnostics_redact
|
||||
done
|
||||
echo "--- end of diagnostics"
|
||||
} >&2
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
// Real-browser assertions for scripts/smoke-deploy.sh in the split topology (ADR 0045). Runs inside a
|
||||
// disposable container on the deployment's `edge` network and drives a headless Chromium, itself a
|
||||
// disposable container on the same network, over the Chrome DevTools Protocol, with Node.js's own
|
||||
// fetch and WebSocket and no other dependency.
|
||||
//
|
||||
// node deploy-smoke-browser.mjs
|
||||
//
|
||||
// What only a browser can prove: that a person signing in on the Dashboard's hostname gets a session
|
||||
// cookie on the API's hostname alone, that the Dashboard then imports a module's UI from the API's
|
||||
// origin and the module's credentialed requests succeed under CORS, that the module's display page
|
||||
// mounts on the API's origin with that same session, and that sign-out ends it. Output is one line
|
||||
// per check; nothing printed carries a credential, cookie value, or response body.
|
||||
|
||||
function required(name) {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const apiOrigin = `https://${required("SMOKE_HOSTNAME")}`;
|
||||
const dashboardOrigin = `https://${required("SMOKE_DASHBOARD_HOSTNAME")}`;
|
||||
const apiHostname = new URL(apiOrigin).hostname;
|
||||
const devtools = required("SMOKE_CDP_ADDRESS");
|
||||
const timeoutMs = 30_000;
|
||||
|
||||
let failures = 0;
|
||||
function check(condition, description) {
|
||||
console.log(`${condition ? "ok " : "FAIL"} ${description}`);
|
||||
if (!condition) {
|
||||
failures += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** The smallest Chrome DevTools Protocol client: numbered commands, flat sessions, and events. */
|
||||
async function connect() {
|
||||
// The browser container was started moments ago; give it time to listen.
|
||||
// One overall deadline, with every attempt bounded, so a browser that never comes up fails the
|
||||
// run in seconds rather than waiting out TCP timeouts.
|
||||
const deadline = Date.now() + 30_000;
|
||||
let version;
|
||||
while (version === undefined) {
|
||||
try {
|
||||
const response = await fetch(`http://${devtools}/json/version`, {
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
});
|
||||
version = await response.json();
|
||||
} catch {
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error("The headless browser never answered.");
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
}
|
||||
// Chromium names its WebSocket after the Host it was asked on; ask on the same address.
|
||||
const socketUrl = version.webSocketDebuggerUrl.replace(/^ws:\/\/[^/]+/u, `ws://${devtools}`);
|
||||
const socket = new WebSocket(socketUrl);
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.addEventListener("open", resolve, { once: true });
|
||||
socket.addEventListener("error", () => reject(new Error("DevTools connection failed.")), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
let nextId = 1;
|
||||
const pending = new Map();
|
||||
const listeners = [];
|
||||
socket.addEventListener("message", (event) => {
|
||||
const message = JSON.parse(String(event.data));
|
||||
if (message.id !== undefined) {
|
||||
const waiter = pending.get(message.id);
|
||||
pending.delete(message.id);
|
||||
if (message.error) {
|
||||
waiter?.reject(new Error(`DevTools ${waiter.method} failed.`));
|
||||
} else {
|
||||
waiter?.resolve(message.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const listener of listeners) {
|
||||
listener(message);
|
||||
}
|
||||
});
|
||||
return {
|
||||
close: () => socket.close(),
|
||||
on: (listener) => listeners.push(listener),
|
||||
send(method, params = {}, sessionId = undefined) {
|
||||
const id = nextId;
|
||||
nextId += 1;
|
||||
socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
|
||||
return new Promise((resolve, reject) => pending.set(id, { method, reject, resolve }));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const cdp = await connect();
|
||||
const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
|
||||
const { sessionId } = await cdp.send("Target.attachToTarget", { flatten: true, targetId });
|
||||
const send = (method, params) => cdp.send(method, params, sessionId);
|
||||
|
||||
/** Every response and failure the page sees, for the API's origin. A CORS refusal is a
|
||||
* `loadingFailed` here, never a response. */
|
||||
const responses = [];
|
||||
const failed = [];
|
||||
cdp.on((message) => {
|
||||
if (message.sessionId !== sessionId) {
|
||||
return;
|
||||
}
|
||||
if (message.method === "Network.responseReceived") {
|
||||
const { response, type } = message.params;
|
||||
responses.push({ status: response.status, type, url: response.url });
|
||||
} else if (message.method === "Network.loadingFailed") {
|
||||
failed.push(message.params);
|
||||
}
|
||||
});
|
||||
await send("Network.enable");
|
||||
await send("Page.enable");
|
||||
await send("Runtime.enable");
|
||||
// A desktop-sized window: a module may reasonably decline to render its management UI on a small one.
|
||||
await send("Emulation.setDeviceMetricsOverride", {
|
||||
deviceScaleFactor: 1,
|
||||
height: 1080,
|
||||
mobile: false,
|
||||
width: 1920,
|
||||
});
|
||||
|
||||
async function evaluate(expression) {
|
||||
const result = await send("Runtime.evaluate", {
|
||||
awaitPromise: true,
|
||||
expression,
|
||||
returnByValue: true,
|
||||
});
|
||||
if (result.exceptionDetails) {
|
||||
throw new Error("A page expression threw.");
|
||||
}
|
||||
return result.result.value;
|
||||
}
|
||||
|
||||
async function waitFor(expression, description) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await evaluate(`Boolean(${expression})`).catch(() => false)) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
console.log(`FAIL timed out waiting for ${description}`);
|
||||
failures += 1;
|
||||
return false;
|
||||
}
|
||||
|
||||
async function navigate(url) {
|
||||
await send("Page.navigate", { url });
|
||||
}
|
||||
|
||||
const selector = (testId) => `document.querySelector('[data-testid="${testId}"]')`;
|
||||
|
||||
async function cookiesFor(origin) {
|
||||
const { cookies } = await send("Network.getCookies", { urls: [`${origin}/`] });
|
||||
return cookies;
|
||||
}
|
||||
|
||||
// --- Sign in on the Dashboard's hostname -------------------------------------------------------
|
||||
await navigate(`${dashboardOrigin}/`);
|
||||
await waitFor(selector("login-email"), "the sign-in form on the Dashboard hostname");
|
||||
await evaluate(`(() => {
|
||||
// React tracks input values through the native setter; assigning .value alone is invisible to it.
|
||||
const setValue = (element, value) => {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(element, value);
|
||||
element.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
};
|
||||
setValue(${selector("login-email")}, ${JSON.stringify(required("SMOKE_OWNER_EMAIL"))});
|
||||
setValue(${selector("login-password")}, ${JSON.stringify(required("SMOKE_OWNER_PASSWORD"))});
|
||||
${selector("login-email")}.form.requestSubmit();
|
||||
})()`);
|
||||
check(
|
||||
await waitFor(selector("host-shell"), "the signed-in Dashboard"),
|
||||
"a person signs in through the real form on the Dashboard hostname",
|
||||
);
|
||||
|
||||
const apiCookies = await cookiesFor(apiOrigin);
|
||||
const dashboardCookies = await cookiesFor(dashboardOrigin);
|
||||
check(
|
||||
apiCookies.some((cookie) => cookie.httpOnly && cookie.secure && cookie.domain === apiHostname) &&
|
||||
dashboardCookies.length === 0,
|
||||
"the session cookie is host-only on the API hostname, and the Dashboard hostname holds none",
|
||||
);
|
||||
|
||||
// --- A module UI, imported from the API's origin -------------------------------------------------
|
||||
const moduleButton = "document.querySelector('[data-testid^=\"select-\"]')";
|
||||
const hasModule = await waitFor(moduleButton, "a module in the Dashboard navigation");
|
||||
if (hasModule) {
|
||||
const moduleId = await evaluate(`${moduleButton}.dataset.testid.slice("select-".length)`);
|
||||
await evaluate(`${moduleButton}.click()`);
|
||||
const mounted = await waitFor(
|
||||
`${selector("module-container")}?.childElementCount > 0 && !${selector("module-loading")} && !${selector("module-error")}`,
|
||||
"the module UI to mount",
|
||||
);
|
||||
check(
|
||||
mounted && !(await evaluate(`Boolean(${selector("module-error")})`)),
|
||||
`the ${moduleId} module UI mounts`,
|
||||
);
|
||||
// Give the module's first requests a moment to settle before judging them.
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
||||
check(
|
||||
responses.some(
|
||||
(response) =>
|
||||
response.url.startsWith(`${apiOrigin}/api/v1/module-ui/${moduleId}/`) &&
|
||||
response.status === 200,
|
||||
),
|
||||
"its UI artifact was imported from the API's origin",
|
||||
);
|
||||
check(
|
||||
responses.some(
|
||||
(response) =>
|
||||
response.url.startsWith(`${apiOrigin}/api/v1/modules/${moduleId}`) &&
|
||||
response.status >= 200 &&
|
||||
response.status < 300,
|
||||
),
|
||||
"its own credentialed API request to the API's origin succeeded under CORS",
|
||||
);
|
||||
const streams = responses.filter((response) => response.type === "EventSource");
|
||||
check(
|
||||
streams.every((response) => response.status === 200),
|
||||
`every realtime stream it opened was accepted (${streams.length} opened)`,
|
||||
);
|
||||
}
|
||||
// A CORS refusal or a blocked request is always a failure. A request the page itself cancelled (a
|
||||
// module unmounting, a navigation) is not.
|
||||
const refused = failed.filter(
|
||||
(failure) =>
|
||||
failure.corsErrorStatus !== undefined ||
|
||||
failure.blockedReason !== undefined ||
|
||||
!(failure.canceled || failure.errorText === "net::ERR_ABORTED"),
|
||||
);
|
||||
check(
|
||||
refused.length === 0,
|
||||
`no request from the Dashboard failed or was refused by CORS (${refused.length} refused)`,
|
||||
);
|
||||
|
||||
// --- The display host, on the API's origin, with the same session --------------------------------
|
||||
const displayPath =
|
||||
await evaluate(`fetch(${JSON.stringify(`${apiOrigin}/api/v1/dashboard/navigation`)}, {
|
||||
credentials: "include",
|
||||
}).then((response) => response.json()).then((navigation) => {
|
||||
const entries = [...navigation.ungrouped, ...navigation.groups.flatMap((group) => group.modules)];
|
||||
return entries.find((entry) => entry.module.display !== null)?.module.display.url ?? null;
|
||||
})`);
|
||||
if (displayPath === null) {
|
||||
console.log("skip no installed module publishes a display");
|
||||
} else {
|
||||
await navigate(`${apiOrigin}${displayPath}`);
|
||||
check(
|
||||
await waitFor(
|
||||
`${selector("display-module-container")}?.childElementCount > 0 && !${selector("display-error")}`,
|
||||
"the display to mount",
|
||||
),
|
||||
"the module's display page mounts on the API hostname with the same session",
|
||||
);
|
||||
check(
|
||||
await waitFor(
|
||||
"navigator.serviceWorker.getRegistrations().then((registrations) => registrations.length > 0)",
|
||||
"the display's service worker",
|
||||
),
|
||||
"the display's offline service worker registers on the API's origin",
|
||||
);
|
||||
}
|
||||
|
||||
// --- Sign out on the Dashboard -------------------------------------------------------------------
|
||||
await navigate(`${dashboardOrigin}/`);
|
||||
await waitFor(selector("host-logout"), "the sign-out control");
|
||||
await evaluate(`${selector("host-logout")}.click()`);
|
||||
check(
|
||||
await waitFor(selector("login-email"), "the sign-in form after sign-out"),
|
||||
"sign-out returns the Dashboard to the sign-in form",
|
||||
);
|
||||
const principal = await evaluate(`fetch(${JSON.stringify(`${apiOrigin}/api/v1/principal`)}, {
|
||||
credentials: "include",
|
||||
}).then((response) => response.json()).then((body) => body.kind)`);
|
||||
check(principal === "anonymous", "after sign-out, the API answers anonymous to the same browser");
|
||||
|
||||
await cdp.send("Target.closeTarget", { targetId });
|
||||
cdp.close();
|
||||
if (failures > 0) {
|
||||
console.error(`${failures} check(s) failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -7,20 +7,12 @@
|
||||
// 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";
|
||||
const hostname = required("SMOKE_HOSTNAME");
|
||||
const origin = `https://${hostname}`;
|
||||
|
||||
function required(name) {
|
||||
const value = process.env[name];
|
||||
@@ -42,9 +34,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(`${apiOrigin}/api/auth/sign-in/email`, {
|
||||
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
|
||||
body: JSON.stringify({ email: "[email protected]", password: "definitely-not-the-password" }),
|
||||
headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers },
|
||||
headers: { "content-type": "application/json", origin, ...headers },
|
||||
method: "POST",
|
||||
});
|
||||
await response.arrayBuffer();
|
||||
@@ -52,13 +44,13 @@ async function signInAttempt(headers = {}) {
|
||||
}
|
||||
|
||||
async function edgeChecks() {
|
||||
const ready = await fetch(`${apiOrigin}/api/v1/health/ready`);
|
||||
const ready = await fetch(`${origin}/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 page = await fetch(`${origin}/`);
|
||||
const html = await page.text();
|
||||
check(page.status === 200, "GET / through the proxy is 200");
|
||||
check(
|
||||
@@ -74,32 +66,22 @@ async function edgeChecks() {
|
||||
"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`);
|
||||
const script = await fetch(`${origin}/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`);
|
||||
const outside = await fetch(`${origin}/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`);
|
||||
const unknownApi = await fetch(`${origin}/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
|
||||
@@ -124,74 +106,6 @@ 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.
|
||||
@@ -270,9 +184,9 @@ async function sessionChecks() {
|
||||
"/api/auth/update-session",
|
||||
"/api/auth/change-password",
|
||||
]) {
|
||||
const response = await fetch(`${apiOrigin}${path}`, {
|
||||
const response = await fetch(`${origin}${path}`, {
|
||||
method: path.endsWith("get-session") || path.endsWith("list-sessions") ? "GET" : "POST",
|
||||
headers: { origin: dashboardOrigin },
|
||||
headers: { origin },
|
||||
});
|
||||
check(
|
||||
response.status === 404 && (await response.text()) === '{"code":"NOT_FOUND"}',
|
||||
@@ -280,12 +194,12 @@ async function sessionChecks() {
|
||||
);
|
||||
}
|
||||
|
||||
const signIn = await fetch(`${apiOrigin}/api/auth/sign-in/email`, {
|
||||
const signIn = await fetch(`${origin}/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 },
|
||||
headers: { "content-type": "application/json", origin },
|
||||
method: "POST",
|
||||
});
|
||||
const signInBody = await signIn.text();
|
||||
@@ -304,24 +218,20 @@ async function sessionChecks() {
|
||||
);
|
||||
const cookie = setCookie.split(";")[0] ?? "";
|
||||
|
||||
const principal = await fetch(`${apiOrigin}/api/v1/principal`, {
|
||||
headers: { cookie, origin: dashboardOrigin },
|
||||
});
|
||||
const principal = await fetch(`${origin}/api/v1/principal`, { headers: { cookie, origin } });
|
||||
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`, {
|
||||
const signOut = await fetch(`${origin}/api/auth/sign-out`, {
|
||||
body: "{}",
|
||||
headers: { "content-type": "application/json", cookie, origin: dashboardOrigin },
|
||||
headers: { "content-type": "application/json", cookie, origin },
|
||||
method: "POST",
|
||||
});
|
||||
await signOut.arrayBuffer();
|
||||
const after = await fetch(`${apiOrigin}/api/v1/principal`, {
|
||||
headers: { cookie, origin: dashboardOrigin },
|
||||
});
|
||||
const after = await fetch(`${origin}/api/v1/principal`, { headers: { cookie, origin } });
|
||||
check(
|
||||
signOut.status === 200 && (await after.json()).kind === "anonymous",
|
||||
"sign-out ends the session",
|
||||
|
||||
+17
-157
@@ -5,15 +5,6 @@
|
||||
#
|
||||
# bash scripts/smoke-deploy.sh builds the image first
|
||||
# EKKLESIA_SMOKE_IMAGE=<image> bash scripts/smoke-deploy.sh
|
||||
# EKKLESIA_SMOKE_TOPOLOGY=split bash scripts/smoke-deploy.sh
|
||||
#
|
||||
# Either topology (ADR 0045). `same-origin` (the default) serves the Dashboard, the API, and displays
|
||||
# from one hostname with Caddyfile.example and a platform-only image. `split` serves the Dashboard on
|
||||
# its own hostname with Caddyfile.split.example, builds an image with the repository's module
|
||||
# template installed as a test-only module (so a module UI, its API routes, and a display exist), and
|
||||
# then drives a real headless Chromium through the Dashboard, the module, the display, and sign-out.
|
||||
# With EKKLESIA_SMOKE_IMAGE, the given image is used as is in either topology; a split run then
|
||||
# exercises whichever of its modules publishes UI and a display.
|
||||
#
|
||||
# TEST-ONLY: the proxy runs with Caddy's internal CA (`local_certs`) instead of ACME, because a test
|
||||
# run has no public hostname. Never use that option in an installation.
|
||||
@@ -31,9 +22,6 @@ set -Eeuo pipefail
|
||||
repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$repository_root"
|
||||
|
||||
# shellcheck source=scripts/lib/compose-diagnostics.sh
|
||||
source "$repository_root/scripts/lib/compose-diagnostics.sh"
|
||||
|
||||
for command in docker openssl awk; do
|
||||
command -v "$command" >/dev/null || {
|
||||
echo "smoke-deploy: $command is required." >&2
|
||||
@@ -48,67 +36,24 @@ docker compose version >/dev/null 2>&1 || {
|
||||
# The pinned PostgreSQL image doubles as a root shell for file ownership, so the run needs neither
|
||||
# root nor sudo on the host.
|
||||
postgres_image="postgres:18.4-alpine3.24@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15"
|
||||
# The split topology's real browser: headless Chromium, pinned by its multi-architecture index digest.
|
||||
browser_image="chromedp/headless-shell:151.0.7922.109@sha256:2d349b544a1ea6b5b5fd7c0fe99215ff662339c57407ee2e8c0a11af93516b04"
|
||||
|
||||
topology="${EKKLESIA_SMOKE_TOPOLOGY:-same-origin}"
|
||||
case "$topology" in
|
||||
same-origin | split) ;;
|
||||
*)
|
||||
echo "smoke-deploy: EKKLESIA_SMOKE_TOPOLOGY must be same-origin or split." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
run_id="$(openssl rand -hex 4)"
|
||||
project="ekklesia-deploy-smoke-${run_id}"
|
||||
work="$(mktemp -d)"
|
||||
image="${EKKLESIA_SMOKE_IMAGE:-}"
|
||||
built_image=""
|
||||
# `hostname` is always the API's (and the display host's); the Dashboard has its own only when split.
|
||||
if [[ "$topology" == split ]]; then
|
||||
hostname="api.smoke.test"
|
||||
dashboard_hostname="dash.smoke.test"
|
||||
caddyfile_example="deploy/Caddyfile.split.example"
|
||||
else
|
||||
hostname="ekklesia.smoke.test"
|
||||
dashboard_hostname=""
|
||||
caddyfile_example="deploy/Caddyfile.example"
|
||||
fi
|
||||
dashboard_origin="https://${dashboard_hostname:-$hostname}"
|
||||
hostname="ekklesia.smoke.test"
|
||||
storage_hostname="storage.smoke.test"
|
||||
browser_container=""
|
||||
|
||||
# choose_edge_network: a per-run edge subnet that no network on this Docker daemon already uses, so
|
||||
# concurrent runs sharing a runner's daemon do not collide. Two runs can still pick the same free
|
||||
# subnet at the same moment; the first `up` below detects that and chooses again. Fixed addresses come
|
||||
# from the upper half, outside Docker's dynamic range, exactly as documented.
|
||||
choose_edge_network() {
|
||||
local in_use candidate attempt
|
||||
in_use="$(
|
||||
docker network ls --quiet |
|
||||
xargs -r docker network inspect --format '{{range .IPAM.Config}}{{println .Subnet}}{{end}}' 2>/dev/null |
|
||||
sed -n 's#^10\.249\.\([0-9]*\)\.0/24$#\1#p'
|
||||
)" || in_use=""
|
||||
for attempt in $(seq 1 100); do
|
||||
candidate=$((RANDOM % 200 + 20))
|
||||
if ! grep -qx "$candidate" <<<"$in_use"; then
|
||||
octet="$candidate"
|
||||
edge_subnet="10.249.${octet}.0/24"
|
||||
edge_range="10.249.${octet}.0/25"
|
||||
proxy_address="10.249.${octet}.254"
|
||||
tunnel_address="10.249.${octet}.253"
|
||||
# A test-only client address, also outside the dynamic range, so its limiter bucket is its own.
|
||||
session_client_address="10.249.${octet}.252"
|
||||
# The headless browser's fixed address, likewise (split topology only).
|
||||
browser_address="10.249.${octet}.251"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
echo "smoke-deploy: no free 10.249.x.0/24 edge subnet on this Docker daemon." >&2
|
||||
exit 1
|
||||
}
|
||||
choose_edge_network
|
||||
# A per-run edge subnet, so concurrent runs and the host's own networks do not collide. Fixed
|
||||
# addresses come from the upper half, outside Docker's dynamic range, exactly as documented.
|
||||
octet=$((RANDOM % 200 + 20))
|
||||
edge_subnet="10.249.${octet}.0/24"
|
||||
edge_range="10.249.${octet}.0/25"
|
||||
proxy_address="10.249.${octet}.254"
|
||||
tunnel_address="10.249.${octet}.253"
|
||||
# A test-only client address, also outside the dynamic range, so its limiter bucket is its own.
|
||||
session_client_address="10.249.${octet}.252"
|
||||
|
||||
# daemon_work: where the Docker daemon sees this run's mounted files. The same directory as `work`
|
||||
# on a workstation; a staged copy on the Docker host when this script runs in a job container.
|
||||
@@ -138,9 +83,6 @@ compose=(
|
||||
cleanup() {
|
||||
local status=$?
|
||||
trap - EXIT INT TERM
|
||||
if [[ -n "$browser_container" ]]; then
|
||||
docker rm --force "$browser_container" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [[ -f "$work/compose.env" ]]; then
|
||||
"${compose[@]}" --profile ops down --volumes --remove-orphans --timeout 10 >/dev/null 2>&1 || status=1
|
||||
fi
|
||||
@@ -171,32 +113,11 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [[ -z "$image" && "$topology" == same-origin ]]; then
|
||||
if [[ -z "$image" ]]; then
|
||||
step "Building the platform-only image"
|
||||
built_image="ekklesia-deploy-smoke:${run_id}"
|
||||
DOCKER_BUILDKIT=1 docker build --quiet --file deploy/Dockerfile --tag "$built_image" . >/dev/null
|
||||
image="$built_image"
|
||||
elif [[ -z "$image" ]]; then
|
||||
# TEST-ONLY: the repository's module template, installed as the module `example`, in a copy of the
|
||||
# working tree. Its lockfile importer moves with it; the depth is the same, so every relative link
|
||||
# in that importer stays correct and the install stays frozen.
|
||||
step "Building an image with the module template installed as a test-only module"
|
||||
command -v git >/dev/null || fail "git is required to build the split topology's test image."
|
||||
context="$work/build-context"
|
||||
mkdir -p "$context"
|
||||
git ls-files -z --cached --others --exclude-standard |
|
||||
tar --null --files-from=- --create --file=- |
|
||||
tar -C "$context" -xf -
|
||||
mkdir -p "$context/modules"
|
||||
mv "$context/templates/module" "$context/modules/example"
|
||||
[[ "$(grep -c '^ templates/module:$' "$context/pnpm-lock.yaml")" == 1 ]] ||
|
||||
fail "the lockfile has no single templates/module importer to move."
|
||||
sed -i 's|^ templates/module:$| modules/example:|' "$context/pnpm-lock.yaml"
|
||||
built_image="ekklesia-deploy-smoke:${run_id}"
|
||||
DOCKER_BUILDKIT=1 docker build --quiet --file "$context/deploy/Dockerfile" --tag "$built_image" \
|
||||
"$context" >/dev/null || fail "the test-module image did not build."
|
||||
rm -rf "$context"
|
||||
image="$built_image"
|
||||
fi
|
||||
|
||||
# The compose project directory: its relative bind mount (postgres/pg_hba.conf) resolves here.
|
||||
@@ -233,8 +154,6 @@ postgres_password="$(secret POSTGRES_PASSWORD)"
|
||||
s3_access_key="$(secret S3_ACCESS_KEY)"
|
||||
s3_secret_key="$(secret S3_SECRET_KEY)"
|
||||
identity_secret="$(secret EKKLESIA_IDENTITY_SECRET)"
|
||||
# Masked in any diagnostics below, along with anything else that looks like a credential.
|
||||
COMPOSE_DIAGNOSTICS_SECRETS=("$postgres_password" "$s3_access_key" "$s3_secret_key" "$identity_secret")
|
||||
|
||||
mkdir -p "$work/env"
|
||||
# set_env <file> <key> <value>: replaces an uncommented KEY= line, keeping the value byte-exact.
|
||||
@@ -254,10 +173,7 @@ ekklesia_env="$work/env/ekklesia.env"
|
||||
set_env "$ekklesia_env" DATABASE_URL "postgresql://ekklesia:${postgres_password}@postgres:5432/ekklesia"
|
||||
set_env "$ekklesia_env" EKKLESIA_DATABASE_CA "${database_ca_line#EKKLESIA_DATABASE_CA=}"
|
||||
set_env "$ekklesia_env" EKKLESIA_IDENTITY_BASE_URL "https://${hostname}"
|
||||
set_env "$ekklesia_env" EKKLESIA_IDENTITY_TRUSTED_ORIGINS "$dashboard_origin"
|
||||
if [[ "$topology" == split ]]; then
|
||||
set_env "$ekklesia_env" EKKLESIA_DASHBOARD_ORIGIN "$dashboard_origin"
|
||||
fi
|
||||
set_env "$ekklesia_env" EKKLESIA_IDENTITY_TRUSTED_ORIGINS "https://${hostname}"
|
||||
set_env "$ekklesia_env" EKKLESIA_IDENTITY_SECRET "$identity_secret"
|
||||
set_env "$ekklesia_env" S3_DELIVERY_ENDPOINT "https://${storage_hostname}"
|
||||
set_env "$ekklesia_env" S3_ACCESS_KEY "$s3_access_key"
|
||||
@@ -269,7 +185,7 @@ chmod 600 "$work"/env/*.env
|
||||
|
||||
# TEST-ONLY: Caddy's internal CA instead of ACME. Inserted as the first global option.
|
||||
awk '!done && $0 == "{" { print; print "\tlocal_certs"; done = 1; next } { print }' \
|
||||
"$caddyfile_example" >"$work/Caddyfile"
|
||||
deploy/Caddyfile.example >"$work/Caddyfile"
|
||||
grep -q $'^\tlocal_certs$' "$work/Caddyfile" || fail "could not enable local_certs for the test proxy."
|
||||
stage Caddyfile
|
||||
|
||||
@@ -278,7 +194,6 @@ EKKLESIA_IMAGE=${image}
|
||||
EKKLESIA_ENV_DIR=${work}/env
|
||||
EKKLESIA_TLS_DIR=${daemon_work}/tls
|
||||
EKKLESIA_HOSTNAME=${hostname}
|
||||
EKKLESIA_DASHBOARD_HOSTNAME=${dashboard_hostname}
|
||||
EKKLESIA_STORAGE_HOSTNAME=${storage_hostname}
|
||||
EKKLESIA_ACME_EMAIL=[email protected]
|
||||
EKKLESIA_CADDYFILE=${daemon_work}/Caddyfile
|
||||
@@ -292,28 +207,8 @@ EKKLESIA_TUNNEL_ADDRESS=${tunnel_address}
|
||||
EOF
|
||||
|
||||
step "Starting PostgreSQL and object storage"
|
||||
# This `up` creates the edge network. If another run claimed the same subnet since it was chosen,
|
||||
# Docker refuses with an address-pool overlap: choose another and start again, a bounded number of
|
||||
# times. Any other failure is reported with the project's containers and logs.
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if up_output="$(
|
||||
"${compose[@]}" up --detach --wait --wait-timeout 180 postgres object-storage 2>&1
|
||||
)"; then
|
||||
break
|
||||
fi
|
||||
if [[ "$up_output" == *"overlap"* ]] && ((attempt < 5)); then
|
||||
echo " edge subnet ${edge_subnet} was taken by a concurrent run; choosing another"
|
||||
"${compose[@]}" down --volumes --remove-orphans --timeout 10 >/dev/null 2>&1 || true
|
||||
choose_edge_network
|
||||
set_env "$work/compose.env" EKKLESIA_EDGE_SUBNET "$edge_subnet"
|
||||
set_env "$work/compose.env" EKKLESIA_EDGE_DYNAMIC_RANGE "$edge_range"
|
||||
set_env "$work/compose.env" EKKLESIA_PROXY_ADDRESS "$proxy_address"
|
||||
set_env "$work/compose.env" EKKLESIA_TUNNEL_ADDRESS "$tunnel_address"
|
||||
continue
|
||||
fi
|
||||
compose_report_not_ready "$up_output" "${compose[@]}"
|
||||
"${compose[@]}" up --detach --wait --wait-timeout 180 postgres object-storage >/dev/null 2>&1 ||
|
||||
fail "PostgreSQL and object storage did not become healthy."
|
||||
done
|
||||
|
||||
step "Planning, then explicitly applying, migrations against this run's fresh database"
|
||||
"${compose[@]}" run --rm --no-deps migrate >/dev/null || fail "migrate (plan) failed."
|
||||
@@ -321,10 +216,8 @@ step "Planning, then explicitly applying, migrations against this run's fresh da
|
||||
fail "migrate (apply) failed."
|
||||
|
||||
step "Starting the API and the proxy"
|
||||
if ! up_output="$("${compose[@]}" up --detach --wait --wait-timeout 180 2>&1)"; then
|
||||
compose_report_not_ready "$up_output" "${compose[@]}"
|
||||
"${compose[@]}" up --detach --wait --wait-timeout 180 >/dev/null 2>&1 ||
|
||||
fail "the API and proxy did not become healthy."
|
||||
fi
|
||||
|
||||
# Caddy creates its internal CA on first start. The test clients trust it, plus the installation CA
|
||||
# for their direct connection to object-storage.
|
||||
@@ -342,17 +235,13 @@ stage client-ca.pem
|
||||
stage client.mjs
|
||||
|
||||
edge_network="${project}_edge"
|
||||
host_mappings=(--add-host "${hostname}:${proxy_address}" --add-host "${storage_hostname}:${proxy_address}")
|
||||
if [[ -n "$dashboard_hostname" ]]; then
|
||||
host_mappings+=(--add-host "${dashboard_hostname}:${proxy_address}")
|
||||
fi
|
||||
client() {
|
||||
local mode="$1"
|
||||
shift
|
||||
docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
|
||||
--network "$edge_network" "$@" "${host_mappings[@]}" \
|
||||
--network "$edge_network" "$@" \
|
||||
--add-host "${hostname}:${proxy_address}" --add-host "${storage_hostname}:${proxy_address}" \
|
||||
-e NODE_EXTRA_CA_CERTS=/smoke/client-ca.pem -e SMOKE_HOSTNAME="$hostname" \
|
||||
-e SMOKE_DASHBOARD_HOSTNAME="$dashboard_hostname" \
|
||||
-e SMOKE_STORAGE_HOSTNAME="$storage_hostname" \
|
||||
-e S3_ACCESS_KEY="$s3_access_key" -e S3_SECRET_KEY="$s3_secret_key" \
|
||||
-v "$daemon_work/client.mjs:/smoke/client.mjs:ro" \
|
||||
@@ -377,35 +266,6 @@ client session --ip "$session_client_address" \
|
||||
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" ||
|
||||
fail "session checks failed."
|
||||
|
||||
if [[ "$topology" == split ]]; then
|
||||
step "Driving a real browser through the split Dashboard, a module, its display, and sign-out"
|
||||
# Chromium trusts this run's proxy through the public-key hash of Caddy's internal intermediate CA,
|
||||
# which signs every certificate the test proxy serves: no CA is installed anywhere, and nothing but
|
||||
# this run's certificates is accepted.
|
||||
"${compose[@]}" exec -T proxy cat /data/caddy/pki/authorities/local/intermediate.crt \
|
||||
>"$work/caddy-intermediate.crt" || fail "the test proxy's intermediate CA is unavailable."
|
||||
spki="$(openssl x509 -in "$work/caddy-intermediate.crt" -pubkey -noout |
|
||||
openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl base64 -A)"
|
||||
browser_container="${project}-browser"
|
||||
# The image's entry script passes its arguments on unquoted, so none may contain a space: the
|
||||
# hostnames reach Chromium through /etc/hosts, which it reads, rather than a resolver rule.
|
||||
docker run --detach --rm --name "$browser_container" \
|
||||
--cap-drop ALL --security-opt no-new-privileges --tmpfs /tmp \
|
||||
--network "$edge_network" --ip "$browser_address" "${host_mappings[@]}" \
|
||||
"$browser_image" \
|
||||
"--ignore-certificate-errors-spki-list=${spki}" \
|
||||
--user-data-dir=/tmp/profile >/dev/null || fail "the headless browser did not start."
|
||||
cp scripts/lib/deploy-smoke-browser.mjs "$work/browser.mjs"
|
||||
stage browser.mjs
|
||||
docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
|
||||
--network "$edge_network" \
|
||||
-e SMOKE_CDP_ADDRESS="${browser_address}:9222" \
|
||||
-e SMOKE_HOSTNAME="$hostname" -e SMOKE_DASHBOARD_HOSTNAME="$dashboard_hostname" \
|
||||
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" \
|
||||
-v "$daemon_work/browser.mjs:/smoke/browser.mjs:ro" \
|
||||
--entrypoint node "$image" /smoke/browser.mjs || fail "browser checks failed."
|
||||
fi
|
||||
|
||||
step "Proving the API's database connection is certificate-verified TLS"
|
||||
api_container="$("${compose[@]}" ps -q api)"
|
||||
api_data_address="$(
|
||||
|
||||
@@ -7,8 +7,6 @@ cd "$repository_root"
|
||||
|
||||
# shellcheck source=scripts/lib/compose-job-network.sh
|
||||
source "$repository_root/scripts/lib/compose-job-network.sh"
|
||||
# shellcheck source=scripts/lib/compose-diagnostics.sh
|
||||
source "$repository_root/scripts/lib/compose-diagnostics.sh"
|
||||
|
||||
command -v docker >/dev/null || {
|
||||
echo "Docker is required for the infrastructure smoke test." >&2
|
||||
@@ -30,9 +28,7 @@ if [[ "$curl_help" != *"--aws-sigv4"* ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Unique per invocation, not only per run: several runs can share a runner's Docker daemon at once,
|
||||
# and the shell's PID is often identical across job containers.
|
||||
raw_project="ekklesia-smoke-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-$$-${RANDOM}${RANDOM}"
|
||||
raw_project="ekklesia-smoke-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-$$"
|
||||
project="${raw_project//[^a-zA-Z0-9_-]/-}"
|
||||
project="${project,,}"
|
||||
temporary_directory="$(mktemp -d)"
|
||||
@@ -50,8 +46,6 @@ export S3_SECRET_KEY="ekklesia-smoke-secret-key"
|
||||
export S3_BUCKET="ekklesia-smoke"
|
||||
export S3_REGION="us-east-1"
|
||||
export S3_PORT="${EKKLESIA_SMOKE_S3_PORT:-0}"
|
||||
COMPOSE_DIAGNOSTICS_SECRETS=("$POSTGRES_PASSWORD" "$S3_ACCESS_KEY" "$S3_SECRET_KEY")
|
||||
up_output=""
|
||||
|
||||
compose=(
|
||||
docker compose
|
||||
@@ -69,7 +63,7 @@ cleanup() {
|
||||
local cleanup_output
|
||||
trap - EXIT INT TERM
|
||||
if ((exit_status != 0)); then
|
||||
compose_report_not_ready "$up_output" "${compose[@]}" || true
|
||||
"${compose[@]}" logs --no-color >&2 || true
|
||||
fi
|
||||
if ! compose_job_network_disconnect; then
|
||||
echo "Infrastructure smoke network cleanup failed." >&2
|
||||
@@ -94,15 +88,8 @@ trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
"${compose[@]}" config --quiet
|
||||
# Started twice: the second `up` proves the project is idempotent. A failure's own output is kept for
|
||||
# the cleanup's diagnostics, which also print every container and the tail of its logs.
|
||||
for _ in 1 2; do
|
||||
if ! up_output="$("${compose[@]}" up --detach --wait --wait-timeout 120 2>&1)"; then
|
||||
echo "Disposable PostgreSQL and object storage did not become ready." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
up_output=""
|
||||
"${compose[@]}" up --detach --wait --wait-timeout 120
|
||||
"${compose[@]}" up --detach --wait --wait-timeout 120
|
||||
|
||||
database_result="$(
|
||||
"${compose[@]}" exec -T \
|
||||
|
||||
@@ -7,8 +7,6 @@ cd "$repository_root"
|
||||
|
||||
# shellcheck source=scripts/lib/compose-job-network.sh
|
||||
source "$repository_root/scripts/lib/compose-job-network.sh"
|
||||
# shellcheck source=scripts/lib/compose-diagnostics.sh
|
||||
source "$repository_root/scripts/lib/compose-diagnostics.sh"
|
||||
|
||||
command -v docker >/dev/null || {
|
||||
echo "Docker is required for the PostgreSQL migration integration suite." >&2
|
||||
@@ -27,20 +25,14 @@ if ! compose_job_network_initialize; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Unique per invocation, not only per run: several runs (and several jobs of one run) can share a
|
||||
# runner's Docker daemon at once, and the shell's PID is often identical across job containers.
|
||||
raw_project="ekklesia-migration-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-${GITHUB_JOB:-job}-$$-${RANDOM}${RANDOM}"
|
||||
raw_project="ekklesia-migration-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-${GITHUB_JOB:-job}-$$"
|
||||
project="${raw_project//[^a-zA-Z0-9_-]/-}"
|
||||
project="${project,,}"
|
||||
|
||||
export POSTGRES_DB="ekklesia_migration_test"
|
||||
export POSTGRES_USER="ekklesia_migration_test"
|
||||
export POSTGRES_PASSWORD="temporary-$project-${RANDOM}-${RANDOM}"
|
||||
# Both host ports are ephemeral: concurrent runs on one Docker daemon must never contend for a
|
||||
# fixed one. `.env.example` pins the storage port for local development, so it is overridden here
|
||||
# exactly like the database's. The bound ports are discovered below.
|
||||
export POSTGRES_PORT="0"
|
||||
export S3_PORT="0"
|
||||
|
||||
compose=(
|
||||
docker compose
|
||||
@@ -80,17 +72,8 @@ fi
|
||||
# and CI already knows how to provision. SeaweedFS is the intended deployment target (ADR 0004), and
|
||||
# proving the storage contract against a mock instead would have hidden the provider quirks this
|
||||
# suite exists to catch.
|
||||
# Masked in any diagnostics below, along with anything else that looks like a credential.
|
||||
COMPOSE_DIAGNOSTICS_SECRETS=(
|
||||
"$POSTGRES_PASSWORD"
|
||||
"$(grep -E '^S3_ACCESS_KEY=' .env.example | cut -d= -f2-)"
|
||||
"$(grep -E '^S3_SECRET_KEY=' .env.example | cut -d= -f2-)"
|
||||
)
|
||||
if ! up_output="$(
|
||||
"${compose[@]}" up --detach --wait --wait-timeout 180 postgres object-storage 2>&1
|
||||
)"; then
|
||||
if ! "${compose[@]}" up --detach --wait --wait-timeout 180 postgres object-storage >/dev/null 2>&1; then
|
||||
echo "Disposable PostgreSQL and object storage did not become ready." >&2
|
||||
compose_report_not_ready "$up_output" "${compose[@]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -3,10 +3,6 @@
|
||||
<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">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { copyFile, mkdir, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import esbuild from "esbuild";
|
||||
@@ -23,16 +23,12 @@ const outputDirectory = path.join(packageDirectory, "dist", "browser");
|
||||
const serve = process.argv.includes("--serve");
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
const apiBaseUrl = serve ? (process.env.EKKLESIA_API_BASE_URL ?? "") : "";
|
||||
const API_ORIGIN_PLACEHOLDER = '<meta content="" name="ekklesia-api-origin" />';
|
||||
const apiBaseUrl = process.env.EKKLESIA_API_BASE_URL ?? "";
|
||||
|
||||
/** @type {import("esbuild").BuildOptions} */
|
||||
const buildOptions = {
|
||||
@@ -41,6 +37,7 @@ 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"),
|
||||
},
|
||||
@@ -70,17 +67,7 @@ const buildOptions = {
|
||||
await rm(outputDirectory, { force: true, recursive: true });
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
await mkdir(path.join(outputDirectory, "display"), { recursive: true });
|
||||
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("&", "&").replaceAll('"', """)}" name="ekklesia-api-origin" />`,
|
||||
),
|
||||
);
|
||||
await copyFile(path.join(packageDirectory, "index.html"), path.join(outputDirectory, "index.html"));
|
||||
await copyFile(
|
||||
path.join(packageDirectory, "display.html"),
|
||||
path.join(outputDirectory, "display", "index.html"),
|
||||
|
||||
@@ -2,22 +2,29 @@ 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, resolveApiOrigin } from "../host-protocol.js";
|
||||
import type { EkklesiaHostConfig } 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.
|
||||
*
|
||||
* 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`.
|
||||
* 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`.
|
||||
*/
|
||||
const apiBaseUrl = resolveApiOrigin(
|
||||
document.querySelector<HTMLMetaElement>('meta[name="ekklesia-api-origin"]')?.content,
|
||||
window.location.origin,
|
||||
);
|
||||
const config: EkklesiaHostConfig = {
|
||||
apiBaseUrl: __EKKLESIA_API_BASE_URL__ || 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. */
|
||||
@@ -39,29 +46,13 @@ 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 />
|
||||
{renderHost(apiBaseUrl === undefined ? undefined : { apiBaseUrl })}
|
||||
{authLanding ? (
|
||||
<HostApp authLanding={authLanding} config={config} />
|
||||
) : (
|
||||
<HostApp config={config} />
|
||||
)}
|
||||
</>,
|
||||
);
|
||||
|
||||
@@ -6,13 +6,10 @@ import { parseDisplayRoute } from "../host-protocol.js";
|
||||
import { DisplayApp } from "./DisplayApp.js";
|
||||
import { type DisplayConnectivitySource, registerDisplayServiceWorker } from "./offline-status.js";
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
declare const __EKKLESIA_API_BASE_URL__: string;
|
||||
|
||||
const config: EkklesiaHostConfig = {
|
||||
apiBaseUrl: window.location.origin,
|
||||
apiBaseUrl: __EKKLESIA_API_BASE_URL__ || window.location.origin,
|
||||
};
|
||||
|
||||
const container = document.getElementById("ekklesia-display");
|
||||
|
||||
@@ -4,11 +4,10 @@ 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 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.
|
||||
* 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.
|
||||
*/
|
||||
export interface EkklesiaHostConfig {
|
||||
readonly apiBaseUrl: string;
|
||||
@@ -703,34 +702,6 @@ 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 {
|
||||
|
||||
@@ -32,7 +32,6 @@ export {
|
||||
parseDisplayRoute,
|
||||
parseHostModuleDisplay,
|
||||
parseHostPrincipal,
|
||||
resolveApiOrigin,
|
||||
resolveApiUrl,
|
||||
} from "./host-protocol.js";
|
||||
export {
|
||||
|
||||
@@ -44,22 +44,6 @@ 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,7 +6,6 @@ import {
|
||||
loadHostPrincipal,
|
||||
parseDashboardNavigation,
|
||||
parseHostPrincipal,
|
||||
resolveApiOrigin,
|
||||
} from "../src/host-protocol.js";
|
||||
import { createModuleUiContext, moduleUiSourceUrl } from "../src/module-context.js";
|
||||
|
||||
@@ -102,39 +101,6 @@ 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]));
|
||||
|
||||
@@ -120,6 +120,7 @@ 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")],
|
||||
@@ -199,6 +200,7 @@ 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")],
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* The scripts CI runs against a shared Docker daemon. Several runs, and several jobs of one run, can
|
||||
* be active on one runner at once (and the inventoried Gitea ignores workflow `concurrency`), so
|
||||
* nothing they start may claim a fixed host port, a fixed project or network name, or a fixed
|
||||
* subnet. And when a disposable project does not become ready, the failure must say why without
|
||||
* printing a credential.
|
||||
*/
|
||||
|
||||
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const ciScripts = [
|
||||
"scripts/test-migrations-integration.sh",
|
||||
"scripts/smoke-infrastructure.sh",
|
||||
"scripts/smoke-deploy.sh",
|
||||
];
|
||||
|
||||
async function source(relativePath: string): Promise<string> {
|
||||
return readFile(path.join(repositoryRoot, relativePath), "utf8");
|
||||
}
|
||||
|
||||
/** Runs the real helper under bash with the given literal secrets and input. */
|
||||
async function redact(input: string, secrets: readonly string[] = []): Promise<string> {
|
||||
const helper = path.join(repositoryRoot, "scripts", "lib", "compose-diagnostics.sh");
|
||||
const script = `source "$1"; shift; COMPOSE_DIAGNOSTICS_SECRETS=("$@"); printf '%s' "$INPUT" | compose_diagnostics_redact`;
|
||||
const { stdout } = await execFileAsync("bash", ["-c", script, "redact", helper, ...secrets], {
|
||||
env: { ...process.env, INPUT: input },
|
||||
});
|
||||
return stdout;
|
||||
}
|
||||
|
||||
describe("CI scripts on a shared Docker daemon", () => {
|
||||
it("publish every host port ephemerally, never a fixed one", async () => {
|
||||
const migrations = await source("scripts/test-migrations-integration.sh");
|
||||
// `.env.example` pins both ports for local development; the suite must override both.
|
||||
expect(migrations).toContain('export POSTGRES_PORT="0"');
|
||||
expect(migrations).toContain('export S3_PORT="0"');
|
||||
|
||||
const infrastructure = await source("scripts/smoke-infrastructure.sh");
|
||||
expect(infrastructure).toContain('export POSTGRES_PORT="${EKKLESIA_SMOKE_POSTGRES_PORT:-0}"');
|
||||
expect(infrastructure).toContain('export S3_PORT="${EKKLESIA_SMOKE_S3_PORT:-0}"');
|
||||
|
||||
const deploy = await source("scripts/smoke-deploy.sh");
|
||||
expect(deploy).toContain("EKKLESIA_HTTP_PORT=0");
|
||||
expect(deploy).toContain("EKKLESIA_HTTPS_PORT=0");
|
||||
|
||||
for (const relativePath of ciScripts) {
|
||||
const text = await source(relativePath);
|
||||
// No `-p 8333:…`, `127.0.0.1:5432:…`, or `PORT=<non-zero>` anywhere.
|
||||
expect(text, relativePath).not.toMatch(/\b(?:-p|--publish)\s+[^\s]*\d{2,5}:\d/u);
|
||||
expect(text, relativePath).not.toMatch(/\b[A-Z_]*PORT="?[1-9]\d*"?\s*$/mu);
|
||||
}
|
||||
});
|
||||
|
||||
it("give every Compose project a name unique to the invocation, not only the run", async () => {
|
||||
expect(await source("scripts/test-migrations-integration.sh")).toMatch(
|
||||
/raw_project="ekklesia-migration-[^"\n]*\$\$-\$\{RANDOM\}\$\{RANDOM\}"/u,
|
||||
);
|
||||
expect(await source("scripts/smoke-infrastructure.sh")).toMatch(
|
||||
/raw_project="ekklesia-smoke-[^"\n]*\$\$-\$\{RANDOM\}\$\{RANDOM\}"/u,
|
||||
);
|
||||
const deploy = await source("scripts/smoke-deploy.sh");
|
||||
expect(deploy).toContain('run_id="$(openssl rand -hex 4)"');
|
||||
expect(deploy).toContain('project="ekklesia-deploy-smoke-${run_id}"');
|
||||
for (const relativePath of ciScripts) {
|
||||
expect(await source(relativePath), relativePath).toContain('--project-name "$project"');
|
||||
}
|
||||
});
|
||||
|
||||
it("choose the deploy smoke's edge subnet from those not in use, and choose again on a race", async () => {
|
||||
const deploy = await source("scripts/smoke-deploy.sh");
|
||||
expect(deploy).not.toMatch(/^octet=\$\(\(RANDOM/mu);
|
||||
expect(deploy).toContain("choose_edge_network() {");
|
||||
expect(deploy).toContain("docker network inspect --format '{{range .IPAM.Config}}");
|
||||
expect(deploy).toContain('[[ "$up_output" == *"overlap"* ]]');
|
||||
});
|
||||
|
||||
it("tag the CI image per run and attempt, so concurrent runs of one commit cannot remove it", async () => {
|
||||
const workflow = await source(".github/workflows/ci.yml");
|
||||
expect(workflow).toContain(
|
||||
'ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}"',
|
||||
);
|
||||
expect(workflow).not.toContain('ekklesia-ci:"$GITHUB_SHA"');
|
||||
});
|
||||
|
||||
it("report every readiness failure with the project's containers and logs, through the redactor only", async () => {
|
||||
for (const relativePath of ciScripts) {
|
||||
const text = await source(relativePath);
|
||||
expect(text, relativePath).toContain(
|
||||
'source "$repository_root/scripts/lib/compose-diagnostics.sh"',
|
||||
);
|
||||
expect(text, relativePath).toContain("compose_report_not_ready");
|
||||
expect(text, relativePath).toContain("COMPOSE_DIAGNOSTICS_SECRETS=(");
|
||||
// Compose output is never printed raw: logs are only ever captured (the deploy smoke scans
|
||||
// the API's for secrets), and a readiness failure's own output is never discarded.
|
||||
for (const line of text
|
||||
.split("\n")
|
||||
.filter((candidate) => candidate.includes('compose[@]}" logs'))) {
|
||||
expect(line, relativePath).toMatch(/="\$\("\$\{compose\[@\]\}" logs /u);
|
||||
}
|
||||
expect(text, relativePath).not.toMatch(/up --detach --wait[^\n]*>\/dev\/null 2>&1/u);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("compose diagnostics redaction", () => {
|
||||
it("masks credential-looking values, URL user information, and Authorization headers", async () => {
|
||||
const output = await redact(
|
||||
[
|
||||
"POSTGRES_PASSWORD=hunter2hunter2",
|
||||
' "AWS_SECRET_ACCESS_KEY": "abc/def+ghi"',
|
||||
"S3_ACCESS_KEY=AKIAEXAMPLE other=fine",
|
||||
"api_key: xyz123",
|
||||
"connecting to postgresql://ekklesia:s3cr3t@postgres:5432/ekklesia",
|
||||
"Authorization: Bearer eyJhbGciOi.payload.sig",
|
||||
"EKKLESIA_IDENTITY_SECRET=0123456789abcdef",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
for (const secret of [
|
||||
"hunter2hunter2",
|
||||
"abc/def+ghi",
|
||||
"AKIAEXAMPLE",
|
||||
"xyz123",
|
||||
"s3cr3t",
|
||||
"eyJhbGciOi",
|
||||
"0123456789abcdef",
|
||||
]) {
|
||||
expect(output).not.toContain(secret);
|
||||
}
|
||||
expect(output).toContain("POSTGRES_PASSWORD=[redacted]");
|
||||
expect(output).toContain("other=fine");
|
||||
expect(output).toContain("postgresql://[redacted]@postgres:5432/ekklesia");
|
||||
});
|
||||
|
||||
it("masks every occurrence of a supplied literal, wherever it appears", async () => {
|
||||
const output = await redact("started with temporary-pw-12345 and again temporary-pw-12345", [
|
||||
"temporary-pw-12345",
|
||||
]);
|
||||
expect(output).toBe("started with [redacted] and again [redacted]\n");
|
||||
});
|
||||
|
||||
it("keeps the diagnosis itself readable", async () => {
|
||||
const diagnosis =
|
||||
"Error response from daemon: Bind for 127.0.0.1:8333 failed: port is already allocated";
|
||||
expect(await redact(diagnosis)).toBe(`${diagnosis}\n`);
|
||||
expect(await redact("duplicate key value violates unique constraint")).toBe(
|
||||
"duplicate key value violates unique constraint\n",
|
||||
);
|
||||
expect(await redact('password authentication failed for user "ekklesia"')).toBe(
|
||||
'password authentication failed for user "ekklesia"\n',
|
||||
);
|
||||
});
|
||||
|
||||
it("strips terminal control sequences from container logs", async () => {
|
||||
expect(await redact("\u001b[2Kpostgres-1 | ready")).toBe("postgres-1 | ready\n");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user