11 Commits
Author SHA1 Message Date
The_River b2efb4b029 Merge pull request 'feat: support split Dashboard and API origins (ADR 0045)' (#54) from feat/split-origins into main
CI / verify (push) Successful in 1m27s
CI / infrastructure-smoke (push) Successful in 22s
CI / migration-smoke (push) Successful in 1m49s
CI / image-build (push) Successful in 7s
CI / deploy-smoke (same-origin) (push) Successful in 48s
CI / deploy-smoke (split) (push) Successful in 45s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/54
Reviewed-by: Joey <[email protected]>
2026-10-03 19:53:52 +00:00
ekklesiaandClaude Opus 5.5 f63f80f2f0 feat: support split Dashboard and API origins (ADR 0045)
CI / verify (pull_request) Successful in 49s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 1m47s
CI / image-build (pull_request) Successful in 19s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m4s
An installation can now serve the Dashboard and the API on sibling
hostnames (dash./api.) beside the default same-origin topology, from one
image and with configuration alone.

- The Dashboard learns its API origin at run time: the built page carries
  one empty <meta name="ekklesia-api-origin">, which the API fills with its
  identity base URL's origin at startup, before computing the page's ETag.
  The shell accepts only a canonical http(s) origin and otherwise fails
  closed without making a request. The build-time EKKLESIA_API_BASE_URL is
  gone from the bundle; the development server injects the same element.
- The display host stays on the API's origin, so its device cookies,
  requests, and offline cache are unchanged.
- The API trusts its own origin (trusted origins plus the identity base
  URL), as Better Auth already does, so the display page's mutations pass
  the origin check without operators listing it.
- Doctor validates EKKLESIA_DASHBOARD_ORIGIN exactly as the API's startup
  does (it previously skipped it), reports the topology without printing
  hostnames, and warns when split hostnames are not siblings.
- The module UI contract states the CORS header limit (safelisted headers
  plus Content-Type, safelisted response headers only).
- Reference deployment: Caddyfile.split.example (Dashboard hostname serves
  / and its script; API hostname serves /api/*, /display/*, and the
  display script; each refuses the other's paths) and
  EKKLESIA_DASHBOARD_HOSTNAME in the proxy overlay and env examples.
- The deploy smoke test runs either topology (CI runs both). The split run
  installs the module template as a test-only module, checks per-hostname
  routing and exact CORS, then drives a digest-pinned headless Chromium
  over the DevTools protocol, with no added dependency: sign-in on the
  Dashboard hostname, the session cookie host-only on the API hostname,
  the module UI imported cross-origin and its credentialed request
  succeeding, the display and its service worker on the API hostname, and
  sign-out.

ADR 0045 is marked Accepted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-03 14:47:36 -05:00
Joey cbf5962d04 Merge pull request 'docs: propose ADR 0046, installation-owned branding' (#53) from docs/adr-0046-installation-branding into main
CI / verify (push) Successful in 49s
CI / infrastructure-smoke (push) Successful in 21s
CI / migration-smoke (push) Successful in 1m47s
CI / image-build (push) Successful in 8s
CI / deploy-smoke (push) Successful in 48s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/53
Reviewed-by: Joey <[email protected]>
2026-10-03 18:54:46 +00:00
Joey 38cf52757d Merge branch 'main' into docs/adr-0046-installation-branding
CI / verify (pull_request) Successful in 52s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 1m48s
CI / image-build (pull_request) Successful in 7s
CI / deploy-smoke (pull_request) Successful in 42s
2026-10-03 18:43:02 +00:00
Joey 5162b4cc77 Merge pull request 'Docs/adr 0045 split origins' (#51) from docs/adr-0045-split-origins into main
CI / verify (push) Successful in 52s
CI / infrastructure-smoke (push) Successful in 21s
CI / migration-smoke (push) Successful in 1m40s
CI / image-build (push) Successful in 8s
CI / deploy-smoke (push) Successful in 48s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/51
Reviewed-by: Joey <[email protected]>
2026-10-03 18:42:41 +00:00
ekklesiaandClaude Opus 5.5 8ea4d4d2e4 docs: propose ADR 0046, installation-owned branding
CI / verify (pull_request) Successful in 51s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m40s
CI / image-build (pull_request) Successful in 18s
CI / deploy-smoke (pull_request) Successful in 48s
Records a core principle: Ekklesia's branding is never forced on an
installation. Anything a member, visitor, or volunteer can see (titles,
sign-in and auth screens, emails, display and offline screens, error
messages, app names and icons, attribution) is configurable and removable
entirely. With no name configured, wording is neutral, so the default
names nothing and the rule is testable. Internal identifiers, operator
tooling, and developer diagnostics are exempt. Modules follow the same
rule.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-03 13:29:11 -05:00
ekklesiaandClaude Opus 5.5 55accd0e65 docs: state exactly what ADR 0045's browser smoke proves
CI / verify (pull_request) Successful in 51s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 1m48s
CI / image-build (pull_request) Successful in 18s
CI / deploy-smoke (pull_request) Successful in 42s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-03 13:29:11 -05:00
ekklesiaandClaude Opus 5.5 a56a853319 docs: propose ADR 0045, split Dashboard and API origins
Records how an installation runs the Dashboard and the API on sibling
hostnames (dash./api.) beside the default same-origin topology, from one
image: the API origin reaches the Dashboard at run time in a meta element,
the display host stays on the API's origin (device cookies and the offline
code cache depend on it), the API trusts its own origin as Better Auth
already does, cookies stay host-only, CORS stays narrow and becomes part of
the module UI contract, the reference proxy gains per-hostname routing, and
the deploy smoke test proves both topologies, the split one in a real
browser.

Amends ADR 0034 and extends ADR 0044.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-03 13:29:11 -05:00
The_River b642d8fe30 Merge pull request 'fix(ci): isolate concurrent runs on a shared Docker daemon and explain readiness failures' (#52) from fix/ci-concurrent-runs into main
CI / verify (push) Successful in 51s
CI / infrastructure-smoke (push) Successful in 21s
CI / migration-smoke (push) Successful in 1m49s
CI / image-build (push) Successful in 8s
CI / deploy-smoke (push) Successful in 43s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/52
Reviewed-by: The_River <[email protected]>
2026-10-03 18:28:18 +00:00
ekklesiaandClaude Opus 5.5 278e66049f fix(ci): isolate concurrent runs on a shared Docker daemon and explain readiness failures
CI / verify (pull_request) Successful in 50s
CI / infrastructure-smoke (pull_request) Successful in 14s
CI / migration-smoke (pull_request) Successful in 1m49s
CI / image-build (pull_request) Successful in 19s
CI / deploy-smoke (pull_request) Successful in 41s
PR #51's migration-smoke failed 0.7 s after the build: the suite pinned
PostgreSQL to an ephemeral port but inherited S3_PORT=8333 from
.env.example, so concurrent runs on one runner (Gitea ignores workflow
concurrency) contended for one host port. Compose's own error was
discarded, so the failure said only that the services "did not become
ready".

Isolation, from an audit of every script CI runs:
- test-migrations-integration.sh: S3_PORT=0, like POSTGRES_PORT=0.
- test-migrations-integration.sh and smoke-infrastructure.sh: project names
  gain a random suffix; run id plus PID alone is not unique when job
  containers share low PIDs.
- smoke-deploy.sh: the edge /24 is chosen from those no network on the
  daemon uses, and the first `up` chooses again when a concurrent run wins
  a race for the same subnet (Docker's address-pool overlap), up to five
  times.
- CI image-build: the image tag includes the run id and attempt, so a pull
  request and a push of the same commit cannot remove each other's image.
- Already isolated: every other published port (ephemeral), the TLS
  suite's container and network (random), smoke staging directories and
  image tags (random).

Diagnostics: scripts/lib/compose-diagnostics.sh prints the failed `up`
command's output, `docker compose ps`, and the last 40 log lines of each
service, all redacted (supplied literals, credential-looking NAME=value
pairs, URL user information, Authorization headers) with terminal control
sequences stripped. All three scripts use it at every readiness check; the
infrastructure smoke's unredacted full-log dump is replaced by it.

tests/ci-scripts.test.ts pins ephemeral ports, unique project names, the
subnet choice, the CI image tag, the diagnostics, and the redaction itself
(run through bash).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-03 12:15:01 -05:00
The_River c737ebb472 Merge pull request 'chore(release): prepare 0.1.1 and accept ADR 0044' (#50) from chore/release-0.1.1 into main
CI / verify (push) Successful in 50s
CI / infrastructure-smoke (push) Successful in 21s
CI / migration-smoke (push) Successful in 1m47s
CI / image-build (push) Successful in 8s
CI / deploy-smoke (push) Successful in 43s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/50
2026-10-03 16:45:39 +00:00
37 changed files with 1952 additions and 133 deletions
+15 -4
View File
@@ -124,24 +124,35 @@ jobs:
# The platform-only image (ADR 0044): a checkout has no `modules/`, so this proves the image # 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 # 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. # 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 - name: Build the production image without modules
run: >- run: >-
docker build docker build
--file deploy/Dockerfile --file deploy/Dockerfile
--build-arg REVISION="$GITHUB_SHA" --build-arg REVISION="$GITHUB_SHA"
--tag ekklesia-ci:"$GITHUB_SHA" --tag ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}"
. .
- name: Remove the image from the runner - name: Remove the image from the runner
if: always() if: always()
run: docker image rm --force ekklesia-ci:"$GITHUB_SHA" || true run: docker image rm --force ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}" || true
deploy-smoke: deploy-smoke:
needs: verify needs: verify
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 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
env: env:
DOCKER_BUILDKIT: "1" DOCKER_BUILDKIT: "1"
EKKLESIA_SMOKE_TOPOLOGY: ${{ matrix.topology }}
steps: steps:
- name: Check out repository - name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -156,5 +167,5 @@ jobs:
# PostgreSQL, then the API, Dashboard, storage delivery, client-address chain, and verify-full # 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 # database TLS through the proxy. Every secret is generated for this run and discarded with
# it; nothing is pushed, published, or logged in to. # it; nothing is pushed, published, or logged in to.
- name: Smoke-test the reference deployment - name: Smoke-test the reference deployment (${{ matrix.topology }})
run: bash scripts/smoke-deploy.sh run: bash scripts/smoke-deploy.sh
+16
View File
@@ -200,3 +200,19 @@ export function createApiRuntimeConfig(
storage, 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])]);
}
+43 -4
View File
@@ -54,15 +54,54 @@ export function createDashboardHostAssets(html: string, script: string): Dashboa
}); });
} }
/** 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. */ * The exact element the built Dashboard page carries for its API origin (ADR 0045), empty. The shell
export async function loadDashboardHostAssets(): Promise<DashboardHostAssets> { * 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("&", "&amp;")
.replaceAll('"', "&quot;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
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> {
try { try {
// Sequential deliberately, as for the display host: diagnostics stay deterministic and no // Sequential deliberately, as for the display host: diagnostics stay deterministic and no
// rejected sibling read is left running after this function has already failed. // rejected sibling read is left running after this function has already failed.
const html = await readFile(new URL(dashboardHostAssetUrls.html), "utf8"); const html = await readFile(new URL(dashboardHostAssetUrls.html), "utf8");
const script = await readFile(new URL(dashboardHostAssetUrls.script), "utf8"); const script = await readFile(new URL(dashboardHostAssetUrls.script), "utf8");
return createDashboardHostAssets(html, script); return createDashboardHostAssets(renderDashboardPage(html, apiOrigin), script);
} catch { } catch {
throw new DashboardHostAssetError(); throw new DashboardHostAssetError();
} }
+6 -3
View File
@@ -44,7 +44,7 @@ import {
createClientAddressResolver, createClientAddressResolver,
wrapFetchWithClientAddress, wrapFetchWithClientAddress,
} from "./client-address.js"; } from "./client-address.js";
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js"; import { type ApiRuntimeConfig, apiTrustedOrigins, createApiRuntimeConfig } from "./config.js";
import { loadDashboardHostAssets } from "./dashboard-host-assets.js"; import { loadDashboardHostAssets } from "./dashboard-host-assets.js";
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js"; import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
import { withDevicePrincipals } from "./device-principal.js"; import { withDevicePrincipals } from "./device-principal.js";
@@ -550,7 +550,9 @@ export async function startApi(
moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets(); moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets();
// Unconditional, unlike the display host: every installation serves its Dashboard (ADR 0044), // 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. // so a missing shell build fails here, before any module code is imported.
dashboardHostAssets = await loadDashboardHostAssets(); // 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);
} catch (error) { } catch (error) {
await safeCloseStorage(storageProvider); await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider); await safeCloseMail(mailProvider);
@@ -648,7 +650,8 @@ export async function startApi(
// Principal resolution, extended with managed devices. A human session still wins, and this // Principal resolution, extended with managed devices. A human session still wins, and this
// is the only place a device credential ever becomes a principal. // is the only place a device credential ever becomes a principal.
runtime: withDevicePrincipals({ devices: deviceStore, runtime }), runtime: withDevicePrincipals({ devices: deviceStore, runtime }),
trustedOrigins: config.identity.trustedOrigins, // The configured origins plus the API's own (ADR 0045).
trustedOrigins: apiTrustedOrigins(config.identity),
}); });
} catch (error) { } catch (error) {
await safeStopModuleRuntime(moduleRuntime); await safeStopModuleRuntime(moduleRuntime);
+11 -2
View File
@@ -29,6 +29,7 @@ import type { ClientAddressConfigInput } from "../src/client-address.js";
import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets"; import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets";
import { createApiRuntimeConfig } from "../src/config.js"; import { createApiRuntimeConfig } from "../src/config.js";
import { createApiDatabase } from "../src/database.js"; import { createApiDatabase } from "../src/database.js";
import { DASHBOARD_API_ORIGIN_PLACEHOLDER } from "../src/dashboard-host-assets.js";
import { import {
DASHBOARD_HOST_PAGE_PATH, DASHBOARD_HOST_PAGE_PATH,
DASHBOARD_HOST_SCRIPT_PATH, DASHBOARD_HOST_SCRIPT_PATH,
@@ -2989,7 +2990,7 @@ describe.sequential("live API — published module UI artifact", () => {
}); });
describe.sequential("live API — the built Dashboard on the API's own origin", () => { describe.sequential("live API — the built Dashboard on the API's own origin", () => {
it("serves the built shell's exact index.html at / and its exact script, and nothing beside them", async () => { 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 () => {
const database = await createDisposableDatabase("dashboard_host"); const database = await createDisposableDatabase("dashboard_host");
await applyCoreMigrations(database.url); await applyCoreMigrations(database.url);
const running = await startMigratedApi(database.url); const running = await startMigratedApi(database.url);
@@ -3006,7 +3007,15 @@ 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("x-frame-options")).toBe("DENY");
expect(page.headers.get("content-security-policy")).toBe("frame-ancestors 'none'"); expect(page.headers.get("content-security-policy")).toBe("frame-ancestors 'none'");
expect(page.headers.get("referrer-policy")).toBe("no-referrer"); expect(page.headers.get("referrer-policy")).toBe("no-referrer");
expect(html).toBe(builtHtml); // 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).toContain('id="ekklesia-host"'); expect(html).toContain('id="ekklesia-host"');
expect(html).toContain(`<script src="${DASHBOARD_HOST_SCRIPT_PATH}" type="module"></script>`); expect(html).toContain(`<script src="${DASHBOARD_HOST_SCRIPT_PATH}" type="module"></script>`);
+104
View File
@@ -3,11 +3,16 @@ import {
createAuthenticatedPrincipal, createAuthenticatedPrincipal,
type ModuleDatabase, type ModuleDatabase,
type ModuleRuntimeServices, type ModuleRuntimeServices,
moduleIdSchema,
parseEkklesiaUserId,
parsePermissionId,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import { import {
type AuthorizationEvaluator, type AuthorizationEvaluator,
type AuthorizationStore, type AuthorizationStore,
CORE_PERMISSION_DECLARATIONS,
CORE_PERMISSION_REGISTRY, CORE_PERMISSION_REGISTRY,
createPermissionRegistry,
type PermissionRegistry, type PermissionRegistry,
} from "@ekklesia/core/authorization"; } from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
@@ -16,6 +21,7 @@ import {
createModuleUiPublicationRegistry, createModuleUiPublicationRegistry,
EMPTY_MODULE_REALTIME_REGISTRY, EMPTY_MODULE_REALTIME_REGISTRY,
type ModuleUiPublicationRegistry, type ModuleUiPublicationRegistry,
StagedModuleRealtimeRegistry,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import {
@@ -910,3 +916,101 @@ 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();
});
});
+22 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { ApiConfigError, createApiRuntimeConfig } from "../src/config.js"; import { ApiConfigError, apiTrustedOrigins, createApiRuntimeConfig } from "../src/config.js";
const validSecret = "correct-horse-battery-staple-0123456789"; const validSecret = "correct-horse-battery-staple-0123456789";
@@ -337,3 +337,24 @@ describe("API runtime configuration", () => {
expect(publicText).not.toContain("db.internal.example"); 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"]);
});
});
+51 -7
View File
@@ -13,10 +13,14 @@ vi.mock("@ekklesia/shell/dashboard-assets", () => ({
import { import {
createDashboardHostAssets, createDashboardHostAssets,
DASHBOARD_API_ORIGIN_PLACEHOLDER,
DashboardHostAssetError, DashboardHostAssetError,
loadDashboardHostAssets, loadDashboardHostAssets,
renderDashboardPage,
} from "../src/dashboard-host-assets.js"; } from "../src/dashboard-host-assets.js";
const API_ORIGIN = "https://api.example.test";
function sha256Etag(text: string): string { function sha256Etag(text: string): string {
return `"${createHash("sha256").update(text).digest("base64url")}"`; return `"${createHash("sha256").update(text).digest("base64url")}"`;
} }
@@ -25,19 +29,20 @@ describe("Dashboard host asset loading", () => {
beforeEach(() => readFileMock.mockReset()); beforeEach(() => readFileMock.mockReset());
it("reads only the two declared file URLs in order and freezes one consistent snapshot", async () => { it("reads only the two declared file URLs in order and freezes one consistent snapshot", async () => {
const html = '<!doctype html><div id="ekklesia-host"></div>'; const html = `<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}<div id="ekklesia-host"></div>`;
const script = "export const dashboardHost = true;\n"; const script = "export const dashboardHost = true;\n";
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce(script); readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce(script);
const assets = await loadDashboardHostAssets(); const assets = await loadDashboardHostAssets(API_ORIGIN);
expect(readFileMock.mock.calls).toEqual([ expect(readFileMock.mock.calls).toEqual([
[new URL("file:///surface/browser/index.html"), "utf8"], [new URL("file:///surface/browser/index.html"), "utf8"],
[new URL("file:///surface/browser/assets/ekklesia-dashboard/app.js"), "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({ expect(assets).toEqual({
html, html: rendered,
htmlEtag: sha256Etag(html), htmlEtag: sha256Etag(rendered),
script, script,
scriptEtag: sha256Etag(script), scriptEtag: sha256Etag(script),
}); });
@@ -49,7 +54,7 @@ describe("Dashboard host asset loading", () => {
new Error("ENOENT /home/operator/private/surface-build secret-token"), new Error("ENOENT /home/operator/private/surface-build secret-token"),
); );
const failure = await loadDashboardHostAssets().catch((error: unknown) => error); const failure = await loadDashboardHostAssets(API_ORIGIN).catch((error: unknown) => error);
expect(readFileMock).toHaveBeenCalledTimes(1); expect(readFileMock).toHaveBeenCalledTimes(1);
expect(failure).toBeInstanceOf(DashboardHostAssetError); expect(failure).toBeInstanceOf(DashboardHostAssetError);
@@ -69,10 +74,12 @@ describe("Dashboard host asset loading", () => {
it("fails when the script is missing even though the page was read", async () => { it("fails when the script is missing even though the page was read", async () => {
readFileMock readFileMock
.mockResolvedValueOnce("<!doctype html>") .mockResolvedValueOnce(`<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}`)
.mockRejectedValueOnce(new Error("ENOENT app.js")); .mockRejectedValueOnce(new Error("ENOENT app.js"));
await expect(loadDashboardHostAssets()).rejects.toBeInstanceOf(DashboardHostAssetError); await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
DashboardHostAssetError,
);
expect(readFileMock).toHaveBeenCalledTimes(2); expect(readFileMock).toHaveBeenCalledTimes(2);
}); });
@@ -84,4 +91,41 @@ describe("Dashboard host asset loading", () => {
expect(() => createDashboardHostAssets(html, script)).toThrow(DashboardHostAssetError); 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);
});
}); });
+59 -3
View File
@@ -757,6 +757,35 @@ function identitySchemaSummary(): string {
return `Core identity schema defines ${tableNames.length} ${plural(tableNames.length, "table")} (${tableNames.join(", ")}).`; 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 * 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 * connection, and never prints the configured secret, base URL, or trusted origins. Missing
@@ -769,6 +798,7 @@ function identityConfigurationCheck(
const hasAnyValue = const hasAnyValue =
input?.secret !== undefined || input?.secret !== undefined ||
input?.baseUrl !== undefined || input?.baseUrl !== undefined ||
(input?.dashboardOrigin ?? "") !== "" ||
input?.publicSignupEnabled !== undefined || input?.publicSignupEnabled !== undefined ||
(input?.trustedOrigins?.length ?? 0) > 0; (input?.trustedOrigins?.length ?? 0) > 0;
@@ -783,17 +813,43 @@ function identityConfigurationCheck(
const config = createIdentityConfig( const config = createIdentityConfig(
{ {
baseUrl: input?.baseUrl, 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, publicSignupEnabled: input?.publicSignupEnabled,
secret: input?.secret, secret: input?.secret,
trustedOrigins: input?.trustedOrigins, trustedOrigins: input?.trustedOrigins,
}, },
{ environment: input?.environment ?? "production" }, { environment: input?.environment ?? "production" },
); );
const signupDetail = 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", [ return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
schemaDetail, schemaDetail,
config.publicSignupEnabled signupDetail,
? "Public signup is explicitly ENABLED." "Topology: same-origin (the Dashboard is served from the API's origin).",
: "Public signup is disabled (safe default).", ]);
}
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,
]); ]);
} catch (error) { } catch (error) {
const code = const code =
+82
View File
@@ -705,6 +705,88 @@ describe("Ekklesia Doctor diagnostics", () => {
expect(report.result).toBe("ready-with-warnings"); // PostgreSQL is still skipped in this fixture 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 () => { it("fails identity configuration for an invalid secret without leaking it", async () => {
const hostileSecret = "s3cr3t-do-not-log"; const hostileSecret = "s3cr3t-do-not-log";
const report = await diagnoseEkklesia( const report = await diagnoseEkklesia(
+109
View File
@@ -0,0 +1,109 @@
# 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
}
}
}
+50 -5
View File
@@ -10,7 +10,8 @@ specific to one organization.
| [`compose.yaml`](compose.yaml) | The base: `api`, `postgres`, `object-storage`, and the operator-only `migrate`. Publishes no port. | | [`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.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. | | [`compose.tunnel.yaml`](compose.tunnel.yaml) | Overlay on top of the proxy: a Cloudflare Tunnel connector. |
| [`Caddyfile.example`](Caddyfile.example) | The proxy's configuration; copy to `Caddyfile`. | | [`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. |
| [`postgres/pg_hba.conf`](postgres/pg_hba.conf) | PostgreSQL client authentication: TLS only, SCRAM passwords. | | [`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. | | [`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. | | [`scripts/init-tls.sh`](scripts/init-tls.sh) | Creates the installation-private CA and the `postgres` and `object-storage` server certificates. |
@@ -142,6 +143,41 @@ 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, 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. 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 ### Behind an existing reverse proxy
Run the base alone and attach `api` and `object-storage` to your proxy's network with an overlay of Run the base alone and attach `api` and `object-storage` to your proxy's network with an overlay of
@@ -203,8 +239,9 @@ dashboard. No Cloudflare API is called from here.
the Ekklesia hostname. Service type **HTTPS**, URL `proxy:443`. 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, 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. and leave **No TLS Verify** off. The connector then verifies the proxy's certificate for that name.
4. Add a second public hostname for the storage hostname, the same way, with its own origin server 4. Add a public hostname for the storage hostname, the same way, with its own origin server name.
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.
5. Start with all three files. The proxy honors `CF-Connecting-IP` only from the connector's fixed 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. address. From any other peer it is stripped.
@@ -232,7 +269,7 @@ deployment:
| # | Item | Where it is met | | # | 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. | | 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. |
| 2 | The origin port is unreachable from the Internet | The base publishes no port; only `proxy` publishes 80 and 443. | | 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. | | 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`. | | 4 | The proxy overwrites the trusted client-IP header | `header_up X-Forwarded-For {client_ip}` in `Caddyfile.example`. |
@@ -258,7 +295,15 @@ deployment:
wrong host name; wrong host name;
- Doctor. - Doctor.
CI runs it on every change. `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.
- **Test-only:** the smoke test runs Caddy with its internal CA (`local_certs`), because a test run - **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. has no public hostname. Never use it in an installation; `Caddyfile.example` does not contain it.
+4
View File
@@ -35,6 +35,10 @@ services:
environment: environment:
EKKLESIA_HOSTNAME: ${EKKLESIA_HOSTNAME:?EKKLESIA_HOSTNAME is required, for example ekklesia.example.org} 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} 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_ACME_EMAIL: ${EKKLESIA_ACME_EMAIL:?EKKLESIA_ACME_EMAIL is required for ACME account notices}
EKKLESIA_TUNNEL_ADDRESS: ${EKKLESIA_TUNNEL_ADDRESS:-10.249.250.253} EKKLESIA_TUNNEL_ADDRESS: ${EKKLESIA_TUNNEL_ADDRESS:-10.249.250.253}
volumes: volumes:
+6 -1
View File
@@ -10,9 +10,14 @@ EKKLESIA_ENV_DIR=/etc/ekklesia
EKKLESIA_TLS_DIR=/etc/ekklesia/tls EKKLESIA_TLS_DIR=/etc/ekklesia/tls
# --- compose.proxy.yaml --------------------------------------------------------------------------- # --- compose.proxy.yaml ---------------------------------------------------------------------------
# The Ekklesia hostname (Dashboard, API, displays) and the storage hostname (signed object URLs). # 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.
EKKLESIA_HOSTNAME= EKKLESIA_HOSTNAME=
EKKLESIA_STORAGE_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). # Contact address for the ACME account (certificate expiry notices).
EKKLESIA_ACME_EMAIL= EKKLESIA_ACME_EMAIL=
# EKKLESIA_CADDYFILE=./Caddyfile # EKKLESIA_CADDYFILE=./Caddyfile
+9 -5
View File
@@ -23,15 +23,19 @@ EKKLESIA_DATABASE_TLS_MODE=verify-full
# scripts/init-tls.sh prints this exact line. It is the only CA the database connection trusts. # scripts/init-tls.sh prints this exact line. It is the only CA the database connection trusts.
EKKLESIA_DATABASE_CA= EKKLESIA_DATABASE_CA=
# --- Identity (ADR 0011, ADR 0018, ADR 0034) ---------------------------------------------------- # --- Identity (ADR 0011, ADR 0018, ADR 0034, ADR 0045) -----------------------------------------
# The public https origin of the Ekklesia hostname, with no path. Production rejects http and # The public https origin of the API, with no path: the Ekklesia hostname in the same-origin
# loopback origins. # topology, the API's hostname in the split one. Production rejects http and loopback origins.
EKKLESIA_IDENTITY_BASE_URL= EKKLESIA_IDENTITY_BASE_URL=
# Comma-separated exact origins allowed to make credentialed requests. Normally just the 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.
EKKLESIA_IDENTITY_TRUSTED_ORIGINS= EKKLESIA_IDENTITY_TRUSTED_ORIGINS=
# At least 32 random characters. Rotating it signs every user out. # At least 32 random characters. Rotating it signs every user out.
EKKLESIA_IDENTITY_SECRET= EKKLESIA_IDENTITY_SECRET=
# Leave unset: the Dashboard is served from the base URL (ADR 0044). # 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).
# EKKLESIA_DASHBOARD_ORIGIN= # EKKLESIA_DASHBOARD_ORIGIN=
# Production refuses public sign-up. Leave unset. # Production refuses public sign-up. Leave unset.
# EKKLESIA_PUBLIC_SIGNUP_ENABLED= # EKKLESIA_PUBLIC_SIGNUP_ENABLED=
@@ -0,0 +1,233 @@
# 0045: The Dashboard and the API may live on separate origins
- Status: Accepted
- Date: 2026-10-03
- Amends: [0034](0034-host-owned-browser-authentication.md)
- Extends: [0044](0044-self-hosted-reference-deployment.md)
- Related: [ADR 0011](0011-identity-http-transport-and-public-enrollment.md),
[ADR 0012](0012-identity-origin-csrf-protection-under-test.md),
[ADR 0018](0018-identity-session-resolution-and-database-transport.md),
[ADR 0025](0025-trusted-module-ui-asset-publication.md),
[ADR 0032](0032-native-module-realtime-transport.md),
[ADR 0033](0033-display-kiosk-host-contract.md),
[ADR 0036](0036-managed-device-identity-and-pairing.md),
[ADR 0038](0038-email-verification-and-password-recovery.md),
[ADR 0043](0043-display-offline-code-fallback.md)
## Context
ADR 0044's reference deployment serves everything a browser needs from one origin: the Dashboard at
`/`, the API at `/api/*`, and the display host at `/display/*`. ADR 0034 kept a deliberately split
browser/API origin possible through the exact trusted-origin allowlist and credentialed CORS of
ADR 0011, ADR 0012, and ADR 0018, but left that topology undesigned: "any eventual choice to place
the API and Dashboard on different sibling subdomains must be designed and tested rather than
assumed to share a session."
The first production installation needs exactly that: the Dashboard and the API on sibling
hostnames (for example `dash.example.org` and `api.example.org`), plus the existing storage
hostname. The choice has to be made before the first deployment, because a display's bookmark and a
paired device's cookies are bound to a hostname: moving them later re-pairs every screen.
Most of the platform was already written for this. The shell sends every platform and module request
with `credentials: "include"` and opens realtime with `withCredentials: true`; CORS on `/api/auth/*`
and `/api/v1/*` reflects only exact trusted origins with credentials; module artifacts are anonymous
by design so that cross-origin `import()` works (ADR 0025); emailed links already pass through the
API and land on `EKKLESIA_DASHBOARD_ORIGIN` (ADR 0038); and display page URLs are resolved against the
API's origin. Four things were not:
- the Dashboard learns its API origin at **build** time (`EKKLESIA_API_BASE_URL`), so one image
cannot serve both topologies;
- the API's own set of trusted browser origins is exactly `EKKLESIA_IDENTITY_TRUSTED_ORIGINS`, while
Better Auth implicitly adds the identity base URL to its own: a page served by the API itself would
fail the API's origin check unless an operator listed the API's own origin too;
- the reference proxy has no routing for two browser-facing hostnames;
- nothing proves a split deployment end to end, and a Node.js client cannot prove what matters most
here: what a real browser sends across the two origins.
## Decision
### Two topologies, one image
An installation runs in exactly one of two shapes, chosen by configuration alone:
| Topology | Dashboard | API and display host | Storage |
| --- | --- | --- | --- |
| **Same-origin** (default) | `https://ekklesia.example.org/` | `https://ekklesia.example.org/api/*`, `/display/*` | its own hostname |
| **Split** | `https://dash.example.org/` | `https://api.example.org/api/*`, `/display/*` | its own hostname |
Same-origin remains the default and keeps working unchanged for every installation that does not
opt in. The same image and the same built Dashboard serve both.
In both, the API process serves the Dashboard's bytes (ADR 0044 is unchanged in that). In the split
topology the proxy forwards the Dashboard hostname's two Dashboard paths to the same `api` service;
the API does not inspect `Host`, and which hostname serves which path is the proxy's job, as every
other perimeter rule already is.
### The Dashboard learns its API origin at run time
The API writes its own origin into the Dashboard page it serves, as one meta element:
```html
<meta name="ekklesia-api-origin" content="https://api.example.org" />
```
The value is the identity base URL's origin (`EKKLESIA_IDENTITY_BASE_URL`), which is by definition
where `/api` lives; no new setting is introduced. The built page carries the element with an empty
value; the API fills it once at startup, before computing the page's strong `ETag`, and refuses to
start if the built page does not contain exactly one such element. The shell reads it before
anything renders, accepts only an absolute `http(s)` origin with no path, and otherwise fails closed
with its existing sanitized bootstrap failure. An empty value (a static development server with no
API) means the page's own origin.
A meta element rather than an inline script keeps the page free of inline script, so a future strict
`script-src` needs no exception. The build-time `EKKLESIA_API_BASE_URL` is removed; the development
server injects the same element instead. The display host needs neither: it is always served from
the API's origin (below) and uses its own.
### The display host lives on the API's origin
In the split topology the display host stays where it is: `https://api.example.org/display/<module>`,
with its script under `/assets/ekklesia-display/` and its service worker at `/display/sw.js`. A
screen's bookmark names the API hostname.
- **Device cookies are already bound to the API's hostname.** They are host-only, `HttpOnly`, and
`SameSite=Strict` (ADR 0036), and they must accompany device requests to the API, so they live on
whichever hostname answers those requests. Serving the display page from that same origin makes it
the only hostname a screen depends on; serving it anywhere else adds a second one.
- **Offline code fallback is unchanged.** The display's service worker never caches a cross-origin
response (ADR 0043), which is what keeps signed storage URLs, bearer capabilities, out of the
cache. On the API's origin, every artifact it must cache is same-origin. On any other origin, every
artifact would be cross-origin, and the offline guarantee would need a weaker rule.
- **No device request crosses an origin.** Pairing, heartbeats, descriptors, module routes, and
realtime from an unattended screen stay same-origin, with no CORS dependence.
The Dashboard keeps linking to display pages exactly as now: their URLs are resolved against the API
origin.
### The API trusts its own origin
The API's set of trusted browser origins, used for CORS on `/api/auth/*` and `/api/v1/*` and by every
mutation route's origin check, becomes `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` **plus the identity base
URL's origin**, matching what Better Auth already does for its own checks. In the split topology the
display page on the API's origin therefore passes the API's origin check without an operator having
to list it. Trusted origins remain exact: no wildcard, no pattern, and no parent domain.
### Cookies and identity: unchanged, with one stated requirement
The session cookie keeps every property ADR 0034 gave it: `HttpOnly`, `SameSite=Lax`, `Secure` under
HTTPS, `Path=/`, and **host-only on the API's hostname**. No `Domain` attribute is set, and the
Dashboard's hostname never holds a session credential. The Dashboard reaches the API with credentialed
CORS, and browsers send the cookie because the two hostnames are different origins of the **same
site**.
That is a requirement of the split topology: the Dashboard and API hostnames must share a registrable
domain (`dash.example.org` and `api.example.org` do; `dash.example.org` and `api.example.net` do not).
Across sites, browsers withhold `SameSite=Lax` cookies from cross-origin requests and may block or
partition third-party cookies altogether. The documentation states it, and Doctor reports the
topology and warns when the two hostnames are not siblings under one parent domain, because Doctor
cannot see the Public Suffix List and says so.
In the split topology `EKKLESIA_IDENTITY_BASE_URL` is the API's origin, `EKKLESIA_DASHBOARD_ORIGIN` is
the Dashboard's, and `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` lists the Dashboard's. Email verification and
password reset links already point at the API and land on `EKKLESIA_DASHBOARD_ORIGIN` (ADR 0038);
nothing about them changes.
### CORS stays narrow, and that limit is part of the module contract
Credentialed CORS keeps its current shape: exact reflected origin, `Access-Control-Allow-Credentials:
true`, the existing method list, `Content-Type` as the only allowed request header beyond the
CORS-safelisted ones, and no exposed response headers beyond the safelisted ones. The API does not
reflect arbitrary requested headers.
A module UI's requests go through the host's `api.request` and inherit this policy. The module UI
contract therefore states it: portable module UI code uses only CORS-safelisted request headers plus
`Content-Type`, and reads only CORS-safelisted response headers. That is already true of every
existing module, and it keeps one module artifact working under either topology. Widening it for a
real module need is a later, explicit change.
### Routing per hostname
The reference proxy gains a second configuration for the split topology, beside the existing one:
| Hostname | Forwarded to `api` | Refused with `404` |
| --- | --- | --- |
| Dashboard | `/` (emailed links add only a query) and `/assets/ekklesia-dashboard/*` | everything else, including `/api/*` |
| API | `/api/*` (realtime flushed per event, as now), `/display/*`, `/assets/ekklesia-display/*` | everything else, including `/` and `/assets/ekklesia-dashboard/*` |
| Storage | unchanged (ADR 0031) | unchanged |
The Dashboard hostname refuses `/api/*` so that the session cookie has exactly one home: a Dashboard
cannot fall back to a same-origin API on its own hostname. The API hostname answers nothing at `/`.
Both hostnames keep every forwarding-header rule ADR 0044 established (one overwritten
`X-Forwarded-For`, the stripped client-chosen headers, and tunnel trust only from the connector's
address). The proxy overlay takes the Dashboard hostname from one more setting, which the split
configuration requires and the same-origin configuration ignores.
### What a future Content-Security-Policy must allow
This decision does not add a Content-Security-Policy; the Dashboard page keeps `frame-ancestors
'none'` and its other headers. When a full policy is designed, the split topology requires the
Dashboard's to allow the API origin in `script-src` (module artifacts and contributions are imported
from it) and `connect-src` (platform and module requests, realtime), and the storage origin in
`img-src`. The display page, being same-origin with the API, needs only its own origin and the
storage origin.
### Both topologies are proven end to end
The deployment smoke test runs in either topology, and CI runs both. The split run proves, through
the proxy, the routing table above on every hostname, the API origin in the Dashboard page, exact
CORS reflection with credentials for the Dashboard origin and none for an untrusted origin, and the
host-only session cookie.
Because those are browser behaviors, the split run also drives a **real browser**: a headless
Chromium from an image pinned by digest, controlled over the Chrome DevTools Protocol by the
smoke-test client with no added dependency. In it, a person signs in through the real form on the
Dashboard hostname, the session cookie is present only for the API hostname, the Dashboard imports a
module's UI from the API hostname and the module's own credentialed requests succeed under CORS, no
request is refused, the module's display page mounts on the API hostname with its service worker, and
sign-out ends the session. The split run installs the repository's module template as a test-only
module so these paths exist; installations' own smoke tests cover their real modules. Contribution
artifacts are imported exactly as UI artifacts are, and the API's tests cover their CORS headers and
those of realtime streams.
## Consequences
- One image serves both topologies; an installation chooses with configuration and the matching proxy
configuration.
- Same-origin installations change nothing.
- A split installation depends on exactly two browser-facing hostnames plus storage. Screens and
paired devices depend only on the API hostname, which therefore must not change once screens are
paired.
- No credential is broadened: the session cookie stays host-only on the API hostname, device cookies
stay host-only and strict, and trusted origins stay exact.
- Module UI code is held to CORS-safe headers in both topologies, so a module that works in one
works in the other.
- CI gains a second deployment smoke run and a browser in it.
## Alternatives
- **A parent-domain session cookie (`Domain=example.org`).** Rejected, as ADR 0034 already rejected
it: it sends the credential to every sibling host and is unnecessary between same-site origins.
- **The display host on the Dashboard hostname, or on its own hostname.** Rejected: device cookies
are bound to the API's hostname regardless, so either adds a second hostname every screen depends
on, puts every device request behind CORS, and breaks ADR 0043's same-origin-only offline cache.
- **The API serving the display host and the device API on a second hostname.** Rejected: two API
origins for one identity base URL, with the same cookies split between them.
- **Inject the API origin with an inline script, or a configuration request.** Rejected: an inline
script needs a CSP exception, and a request is a round trip and a failure mode before the first
render; a meta element is static and inert.
- **Keep the API origin a build-time setting.** Rejected: the image would have to be built per
installation and per topology.
- **Require operators to list the API's own origin in the trusted origins.** Rejected: an omission
silently breaks every display mutation; Better Auth already trusts its own base URL.
- **Reflect any requested CORS header.** Rejected for now: no module needs it, and a module that
relied on it would be tied to its host's CORS policy.
- **Prove split mode with HTTP checks only.** Rejected: whether a browser sends the cookie, imports
a cross-origin module, and opens a credentialed stream is exactly what is at stake.
## Deferred
- A full Content-Security-Policy, with the requirements above.
- A broader CORS header policy, when a module needs one.
- More than one Dashboard origin per installation.
- Moving an installation between topologies, and the re-pairing that moving the API hostname
implies.
@@ -0,0 +1,110 @@
# 0046: Ekklesia's branding is never forced on an installation
- Status: Proposed
- Date: 2026-10-03
- Related: [ADR 0021](0021-modules-as-hostable-products.md),
[ADR 0034](0034-host-owned-browser-authentication.md),
[ADR 0037](0037-host-provided-outbound-email.md),
[ADR 0038](0038-email-verification-and-password-recovery.md),
[ADR 0039](0039-shared-design-tokens-and-elements.md),
[ADR 0043](0043-display-offline-code-fallback.md),
[ADR 0045](0045-split-dashboard-and-api-origins.md)
## Context
Ekklesia is software a church runs for its own people. The people who use an installation (members,
visitors, volunteers running a screen) know the church, not the software behind it, and the church
decides how it presents itself to them. Today the platform names itself in places those people see:
the sign-in screen asks them to "Sign in to Ekklesia", the Dashboard's heading and page title read
"Ekklesia", emails ask them to verify "your Ekklesia email address", and an offline screen in the
sanctuary says it is "waiting for Ekklesia".
None of those were decided; they accumulated. This records the rule they should have followed, so
every later screen, message, and module is held to it.
## Decision
### The principle
**Ekklesia's branding is never forced on an installation.** Anything a member, visitor, or volunteer
can see is configurable by the installation and removable entirely. That includes, and is not
limited to:
- page titles, headings, and the name of the application in a browser tab, an installed web app, or
a bookmark;
- sign-in, verification, recovery, and every other authentication screen;
- emails: the sender's display name, the subject, and the body;
- display screens, pairing screens, and offline and failure screens on a display;
- error pages and error messages a person reads;
- application names and icons, and any "powered by" or attribution text.
"Removable entirely" means an installation can show its own name, or no product name at all, and in
either case no screen or message a person sees names Ekklesia.
### What is exempt
Internal identifiers are not branding and stay as they are: package names, module ids, URL paths
(`/api/v1/…`, `/assets/ekklesia-dashboard/…`), HTTP header names, environment variable names, cookie
names, CSS custom property names, element ids and `data-` attributes, storage, cache, and database
names, and log field names. A person can find them only by opening developer tools or reading
configuration, and renaming them would break installations for no one's benefit.
Tooling an operator runs to administer the installation (the command-line tools, Doctor, server logs,
and startup messages) names the software being operated, as a package name does, and is exempt too.
So are messages written only for developers: thrown errors that reach a console and never a page, and
build-time diagnostics for module authors.
### How installations configure it
The platform takes the installation's name from configuration, once, and uses it everywhere a
person-facing string would otherwise have said "Ekklesia". When no name is configured, the wording is
neutral ("Sign in", "Verify your email address", "Waiting for the server") rather than falling back to
"Ekklesia". The default installation therefore names nothing, which is what makes the principle
checkable: a test can render every person-facing screen and message with no name configured and fail
on any occurrence of "Ekklesia".
Browser pages receive the name the way they receive the API origin (ADR 0045): the API renders it
into the page it serves at startup, so the image stays generic and nothing is decided at build time.
An email's sender display name is the existing `EKKLESIA_SMTP_FROM_NAME` (ADR 0037); its subject and
body use the same installation name.
Visual identity follows the same rule. The platform ships no logo, favicon, or icon today; any it
ships later must be replaceable by the installation and removable entirely, and must not appear by
default. Colours and type are the installation's theme (ADR 0039), not a mark.
### It applies to modules
A module's user-visible text follows the same principle. A module that names its host ("Open the
Ekklesia Dashboard") also ties itself to one host, which ADR 0021 already asks modules not to do.
Modules refer to the host generically ("the Dashboard") or not at all.
## Consequences
- Every person-facing string that names the platform today becomes either the configured name or
neutral wording. An implementation slice makes that change and adds the test that keeps it true.
- New screens, emails, and modules are reviewed against this rule; "Ekklesia" in a person-facing
string is a defect.
- Internal identifiers, operator tooling, and developer diagnostics keep the name, so nothing
operational is renamed.
- An installation that wants to credit Ekklesia can do so in its own words; the platform never
inserts that credit.
## Alternatives
- **Keep "Ekklesia" as the default and let installations override it.** Rejected: an installation
that never thought to configure it would show the software's name to its people, which is forcing
it by default, and "no occurrence of Ekklesia" could not be tested in the default state.
- **Rename internal identifiers too.** Rejected: invisible to the people this protects, and costly
to every installation and module.
- **A configurable "powered by" line, on by default.** Rejected for the same reason as the first
alternative.
- **Per-string customization of every message.** Not needed for this principle: one installation
name and neutral wording remove the product's name everywhere. Richer copy customization, if
wanted, is separate work.
## Deferred
- The implementation: the setting, its delivery to pages and emails, the rewording, and the
regression test.
- Installation-supplied logos, icons, and favicons, and a web app manifest.
- Translation and per-string copy customization.
+2
View File
@@ -53,3 +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 - [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 - [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 - [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
- [0046: Ekklesia's branding is never forced on an installation](0046-installation-owned-branding.md) — Proposed
+40 -17
View File
@@ -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_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_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_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. | | `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_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_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_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`). | | `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. | | `/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`. | | `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 /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`), served from the same origin as `/api`. `no-cache` with a strong `ETag`; framing refused. See [Dashboard host](#dashboard-host). | | `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 /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 /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/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). | | `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,19 +140,29 @@ role, permission, membership, or volunteer data.
## Dashboard host ## Dashboard host
`apps/api` serves the built Dashboard itself, from the same origin as `/api` `apps/api` serves the built Dashboard itself
([ADR 0044](../architecture/decisions/0044-self-hosted-reference-deployment.md)). That keeps the ([ADR 0044](../architecture/decisions/0044-self-hosted-reference-deployment.md)). In the default
identity system's default same-origin shape: `EKKLESIA_DASHBOARD_ORIGIN` stays unset, the session same-origin topology it does so from the same origin as `/api`: `EKKLESIA_DASHBOARD_ORIGIN` stays
cookie stays host-only, and no second static server or proxy file root is needed. It works exactly unset, the session cookie stays host-only, and no second static server or proxy file root is
like the display host: 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:
- **A closed set of files, read once at startup.** `@ekklesia/shell/dashboard-assets` exports the - **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 exact URLs of two built files, `dist/browser/index.html` and
`dist/browser/assets/ekklesia-dashboard/app.js`. `startApi()` reads both before importing any `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 module code or binding the listener, and keeps one frozen snapshot of their text with a SHA-256
strong `ETag` for each. strong `ETag` for each.
- **A missing build fails startup.** An absent, unreadable, or empty file stops `startApi()` with a - **The API's origin is written in at startup.** The built page carries one empty
fixed `DashboardHostAssetError` (`DASHBOARD_HOST_ASSET_UNAVAILABLE`), after closing the resources `<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
already opened. It never carries a path or the filesystem error. Build the shell first; already opened. It never carries a path or the filesystem error. Build the shell first;
`pnpm api:start` already runs the full build. `pnpm api:start` already runs the full build.
- **Two exact routes, never a directory.** `GET /` answers the page and - **Two exact routes, never a directory.** `GET /` answers the page and
@@ -195,7 +205,9 @@ strips that token from the address bar before it renders anything.
**Development.** `pnpm --filter @ekklesia/shell dev` serves `dist/browser` at the same absolute **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 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. 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.
## Dashboard browser authentication ## Dashboard browser authentication
@@ -340,7 +352,10 @@ real server: 7 characters rejected, 8 accepted, 128 accepted, 129 rejected.
## Origin, CORS, and cookies ## Origin, CORS, and cookies
The API reuses the same `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` list for both CORS and Better Auth's 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. CORS reflects a request'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
`Origin` header back only when it exactly matches a configured trusted origin (never a wildcard, `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: 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 true`); a disallowed origin never receives the header at all, though the request itself may still
@@ -374,10 +389,18 @@ 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` 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) 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 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: same-origin `/api` proxying is the CORS/CSRF above. The Dashboard does not broaden this policy. In the split topology
simplest production topology, while a deliberately split browser/API origin must use the exact ([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)) the cookie stays
trusted-origin allowlist and credentialed CORS. Sibling subdomains do not implicitly share the host-only on the API's hostname, and the Dashboard on a sibling hostname reaches the API with
cookie. No production Dashboard hostname or proxy deployment exists in this repository. 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.
## Logging ## Logging
@@ -1613,7 +1636,7 @@ Cookie: better-auth.session_token=<redacted, now revoked>
See the repository root [README](../../README.md#current-status) and See the repository root [README](../../README.md#current-status) and
[platform architecture](../architecture/platform-architecture.md#current-implementation-status) [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 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)), while its production proxy, TLS, serves the built Dashboard ([Dashboard host](#dashboard-host)) in either topology, while the full
full Content-Security-Policy, enrollment/recovery, display device identity, and public Website 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 routing remain separate work. This document tracks `apps/api`'s own HTTP, module-runtime, and
database-composition contracts as they evolve. database-composition contracts as they evolve.
+5 -2
View File
@@ -153,7 +153,8 @@ enforced by `requirePermission` regardless; the test suite proves it by having a
"denied" call its API anyway and still receive `403`. "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 **The host executes trusted module code in its own page and does not pretend otherwise.** The loader
performs a same-origin dynamic `import()` — no `eval`, no `new Function`, no `blob:` URL — and the 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
shell owns the container, the loading state, and every error, so a failing module costs its own panel 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 rather than the shell. None of that is containment: once imported, a module UI is ordinary same-realm
JavaScript, exactly as described above. JavaScript, exactly as described above.
@@ -493,7 +494,9 @@ outstanding.
split origin is covered by the exact trusted-origin/CORS configuration. `apps/api` serves the 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 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) reference deployment's proxy routes it ([deploy/README.md](../../deploy/README.md#launch-checklist)
maps every item here to its configuration). 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.
2. The origin port is unreachable from the public Internet. 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 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. proxy path connects. Security must not depend on an unpublished DNS name or origin IP.
@@ -125,6 +125,11 @@ export interface ModuleUiDisplayLocation {
* *
* A host is responsible for credentials, base URL, and any platform headers. A module UI neither * A host is responsible for credentials, base URL, and any platform headers. A module UI neither
* supplies nor sees a session token. * 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 { export interface ModuleUiApiClient {
/** /**
+72
View File
@@ -0,0 +1,72 @@
#!/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
}
+287
View File
@@ -0,0 +1,287 @@
// 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);
}
+107 -17
View File
@@ -7,12 +7,20 @@
// node deploy-smoke-client.mjs tunnel the Cloudflare Tunnel connector's fixed address // node deploy-smoke-client.mjs tunnel the Cloudflare Tunnel connector's fixed address
// node deploy-smoke-client.mjs session the initial owner signing in and out through the proxy // 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 // 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. // credential, cookie, signed URL, or response body beyond the fixed shapes asserted here.
const mode = process.argv[2]; const mode = process.argv[2];
const hostname = required("SMOKE_HOSTNAME"); const apiOrigin = `https://${required("SMOKE_HOSTNAME")}`;
const origin = `https://${hostname}`; const dashboardOrigin = process.env.SMOKE_DASHBOARD_HOSTNAME
? `https://${process.env.SMOKE_DASHBOARD_HOSTNAME}`
: apiOrigin;
const split = dashboardOrigin !== apiOrigin;
const untrustedOrigin = "https://untrusted.smoke.test";
function required(name) { function required(name) {
const value = process.env[name]; const value = process.env[name];
@@ -34,9 +42,9 @@ function check(condition, description) {
/** A sign-in attempt that can only fail authentication; what matters is whether the API's /** 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. */ * per-client limiter (3 attempts per 10 seconds) counts it against the expected client. */
async function signInAttempt(headers = {}) { async function signInAttempt(headers = {}) {
const response = await fetch(`${origin}/api/auth/sign-in/email`, { const response = await fetch(`${apiOrigin}/api/auth/sign-in/email`, {
body: JSON.stringify({ email: "[email protected]", password: "definitely-not-the-password" }), body: JSON.stringify({ email: "[email protected]", password: "definitely-not-the-password" }),
headers: { "content-type": "application/json", origin, ...headers }, headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers },
method: "POST", method: "POST",
}); });
await response.arrayBuffer(); await response.arrayBuffer();
@@ -44,13 +52,13 @@ async function signInAttempt(headers = {}) {
} }
async function edgeChecks() { async function edgeChecks() {
const ready = await fetch(`${origin}/api/v1/health/ready`); const ready = await fetch(`${apiOrigin}/api/v1/health/ready`);
check( check(
ready.status === 200 && (await ready.text()) === '{"status":"ready"}', ready.status === 200 && (await ready.text()) === '{"status":"ready"}',
"GET /api/v1/health/ready through the proxy is 200 ready", "GET /api/v1/health/ready through the proxy is 200 ready",
); );
const page = await fetch(`${origin}/`); const page = await fetch(`${dashboardOrigin}/`);
const html = await page.text(); const html = await page.text();
check(page.status === 200, "GET / through the proxy is 200"); check(page.status === 200, "GET / through the proxy is 200");
check( check(
@@ -66,22 +74,32 @@ async function edgeChecks() {
"the Dashboard page keeps its security headers through the proxy", "the Dashboard page keeps its security headers through the proxy",
); );
const script = await fetch(`${origin}/assets/ekklesia-dashboard/app.js`); check(
html.includes(`<meta content="${apiOrigin}" name="ekklesia-api-origin" />`),
"the Dashboard page names the API's origin, rendered at run time (ADR 0045)",
);
const script = await fetch(`${dashboardOrigin}/assets/ekklesia-dashboard/app.js`);
await script.arrayBuffer(); await script.arrayBuffer();
check(script.status === 200, "GET /assets/ekklesia-dashboard/app.js is 200"); check(script.status === 200, "GET /assets/ekklesia-dashboard/app.js is 200");
const outside = await fetch(`${origin}/favicon.ico`); const outside = await fetch(`${dashboardOrigin}/favicon.ico`);
check( check(
outside.status === 404 && !(outside.headers.get("content-type") ?? "").includes("json"), outside.status === 404 && !(outside.headers.get("content-type") ?? "").includes("json"),
"a path the API does not serve is refused by the proxy, never forwarded", "a path the API does not serve is refused by the proxy, never forwarded",
); );
const unknownApi = await fetch(`${origin}/api/v1/does-not-exist`); const unknownApi = await fetch(`${apiOrigin}/api/v1/does-not-exist`);
check( check(
unknownApi.status === 404 && (await unknownApi.text()) === '{"code":"NOT_FOUND"}', unknownApi.status === 404 && (await unknownApi.text()) === '{"code":"NOT_FOUND"}',
"an unknown /api/* path reaches the API and gets its sanitized 404", "an unknown /api/* path reaches the API and gets its sanitized 404",
); );
if (split) {
await splitRoutingChecks();
await splitCorsChecks();
}
await storageDeliveryCheck(); await storageDeliveryCheck();
// Every caller-supplied forwarding header rotates on every attempt. If any of them reached the // Every caller-supplied forwarding header rotates on every attempt. If any of them reached the
@@ -106,6 +124,74 @@ async function edgeChecks() {
); );
} }
/** A proxy refusal: Caddy's own empty `404`, never the API's JSON one, so the request was not
* forwarded at all. */
async function refusedByProxy(url) {
const response = await fetch(url);
const body = await response.text();
return response.status === 404 && body === "";
}
/** Each hostname serves exactly its own paths (ADR 0045, "Routing per hostname"). */
async function splitRoutingChecks() {
check(
await refusedByProxy(`${dashboardOrigin}/api/v1/health/ready`),
"the Dashboard hostname refuses /api/*, so the session cookie has one home",
);
check(
await refusedByProxy(`${dashboardOrigin}/display/example`),
"the Dashboard hostname refuses /display/*",
);
check(await refusedByProxy(`${apiOrigin}/`), "the API hostname serves nothing at /");
check(
await refusedByProxy(`${apiOrigin}/assets/ekklesia-dashboard/app.js`),
"the API hostname refuses the Dashboard's script",
);
const display = await fetch(`${apiOrigin}/display/does-not-exist`);
check(
display.status === 404 && (await display.text()) === '{"code":"NOT_FOUND"}',
"the API hostname forwards /display/* to the API (the display host's origin)",
);
}
/** Credentialed CORS for exactly the Dashboard's origin, and for nothing else. */
async function splitCorsChecks() {
const preflight = await fetch(`${apiOrigin}/api/auth/sign-in/email`, {
headers: {
"access-control-request-headers": "content-type",
"access-control-request-method": "POST",
origin: dashboardOrigin,
},
method: "OPTIONS",
});
await preflight.arrayBuffer();
check(
preflight.status === 204 &&
preflight.headers.get("access-control-allow-origin") === dashboardOrigin &&
preflight.headers.get("access-control-allow-credentials") === "true",
"a preflight from the Dashboard's origin is allowed, with credentials",
);
const foreignPreflight = await fetch(`${apiOrigin}/api/auth/sign-in/email`, {
headers: { "access-control-request-method": "POST", origin: untrustedOrigin },
method: "OPTIONS",
});
await foreignPreflight.arrayBuffer();
check(
foreignPreflight.headers.get("access-control-allow-origin") === null,
"a preflight from any other origin gets no CORS permission",
);
const principal = await fetch(`${apiOrigin}/api/v1/principal`, {
headers: { origin: dashboardOrigin },
});
await principal.arrayBuffer();
check(
principal.headers.get("access-control-allow-origin") === dashboardOrigin &&
principal.headers.get("access-control-allow-credentials") === "true" &&
(principal.headers.get("vary") ?? "").includes("Origin"),
"an API response to the Dashboard's origin reflects exactly that origin, with credentials",
);
}
async function storageDeliveryCheck() { async function storageDeliveryCheck() {
// Core's own provider, from the image, exactly as the API composes it: it reaches object-storage // 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. // over HTTPS on the private network and signs read URLs for the storage hostname.
@@ -184,9 +270,9 @@ async function sessionChecks() {
"/api/auth/update-session", "/api/auth/update-session",
"/api/auth/change-password", "/api/auth/change-password",
]) { ]) {
const response = await fetch(`${origin}${path}`, { const response = await fetch(`${apiOrigin}${path}`, {
method: path.endsWith("get-session") || path.endsWith("list-sessions") ? "GET" : "POST", method: path.endsWith("get-session") || path.endsWith("list-sessions") ? "GET" : "POST",
headers: { origin }, headers: { origin: dashboardOrigin },
}); });
check( check(
response.status === 404 && (await response.text()) === '{"code":"NOT_FOUND"}', response.status === 404 && (await response.text()) === '{"code":"NOT_FOUND"}',
@@ -194,12 +280,12 @@ async function sessionChecks() {
); );
} }
const signIn = await fetch(`${origin}/api/auth/sign-in/email`, { const signIn = await fetch(`${apiOrigin}/api/auth/sign-in/email`, {
body: JSON.stringify({ body: JSON.stringify({
email: required("SMOKE_OWNER_EMAIL"), email: required("SMOKE_OWNER_EMAIL"),
password: required("SMOKE_OWNER_PASSWORD"), password: required("SMOKE_OWNER_PASSWORD"),
}), }),
headers: { "content-type": "application/json", origin }, headers: { "content-type": "application/json", origin: dashboardOrigin },
method: "POST", method: "POST",
}); });
const signInBody = await signIn.text(); const signInBody = await signIn.text();
@@ -218,20 +304,24 @@ async function sessionChecks() {
); );
const cookie = setCookie.split(";")[0] ?? ""; const cookie = setCookie.split(";")[0] ?? "";
const principal = await fetch(`${origin}/api/v1/principal`, { headers: { cookie, origin } }); const principal = await fetch(`${apiOrigin}/api/v1/principal`, {
headers: { cookie, origin: dashboardOrigin },
});
const principalBody = await principal.json(); const principalBody = await principal.json();
check( check(
principal.status === 200 && principalBody.kind === "authenticated", principal.status === 200 && principalBody.kind === "authenticated",
"the session resolves an authenticated principal through the proxy", "the session resolves an authenticated principal through the proxy",
); );
const signOut = await fetch(`${origin}/api/auth/sign-out`, { const signOut = await fetch(`${apiOrigin}/api/auth/sign-out`, {
body: "{}", body: "{}",
headers: { "content-type": "application/json", cookie, origin }, headers: { "content-type": "application/json", cookie, origin: dashboardOrigin },
method: "POST", method: "POST",
}); });
await signOut.arrayBuffer(); await signOut.arrayBuffer();
const after = await fetch(`${origin}/api/v1/principal`, { headers: { cookie, origin } }); const after = await fetch(`${apiOrigin}/api/v1/principal`, {
headers: { cookie, origin: dashboardOrigin },
});
check( check(
signOut.status === 200 && (await after.json()).kind === "anonymous", signOut.status === 200 && (await after.json()).kind === "anonymous",
"sign-out ends the session", "sign-out ends the session",
+150 -10
View File
@@ -5,6 +5,15 @@
# #
# bash scripts/smoke-deploy.sh builds the image first # bash scripts/smoke-deploy.sh builds the image first
# EKKLESIA_SMOKE_IMAGE=<image> bash scripts/smoke-deploy.sh # 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 # 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. # run has no public hostname. Never use that option in an installation.
@@ -22,6 +31,9 @@ set -Eeuo pipefail
repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repository_root" 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 for command in docker openssl awk; do
command -v "$command" >/dev/null || { command -v "$command" >/dev/null || {
echo "smoke-deploy: $command is required." >&2 echo "smoke-deploy: $command is required." >&2
@@ -36,24 +48,67 @@ docker compose version >/dev/null 2>&1 || {
# The pinned PostgreSQL image doubles as a root shell for file ownership, so the run needs neither # The pinned PostgreSQL image doubles as a root shell for file ownership, so the run needs neither
# root nor sudo on the host. # root nor sudo on the host.
postgres_image="postgres:18.4-alpine3.24@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15" 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)" run_id="$(openssl rand -hex 4)"
project="ekklesia-deploy-smoke-${run_id}" project="ekklesia-deploy-smoke-${run_id}"
work="$(mktemp -d)" work="$(mktemp -d)"
image="${EKKLESIA_SMOKE_IMAGE:-}" image="${EKKLESIA_SMOKE_IMAGE:-}"
built_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" hostname="ekklesia.smoke.test"
dashboard_hostname=""
caddyfile_example="deploy/Caddyfile.example"
fi
dashboard_origin="https://${dashboard_hostname:-$hostname}"
storage_hostname="storage.smoke.test" storage_hostname="storage.smoke.test"
browser_container=""
# A per-run edge subnet, so concurrent runs and the host's own networks do not collide. Fixed # choose_edge_network: a per-run edge subnet that no network on this Docker daemon already uses, so
# addresses come from the upper half, outside Docker's dynamic range, exactly as documented. # concurrent runs sharing a runner's daemon do not collide. Two runs can still pick the same free
octet=$((RANDOM % 200 + 20)) # 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_subnet="10.249.${octet}.0/24"
edge_range="10.249.${octet}.0/25" edge_range="10.249.${octet}.0/25"
proxy_address="10.249.${octet}.254" proxy_address="10.249.${octet}.254"
tunnel_address="10.249.${octet}.253" tunnel_address="10.249.${octet}.253"
# A test-only client address, also outside the dynamic range, so its limiter bucket is its own. # A test-only client address, also outside the dynamic range, so its limiter bucket is its own.
session_client_address="10.249.${octet}.252" 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
# daemon_work: where the Docker daemon sees this run's mounted files. The same directory as `work` # 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. # on a workstation; a staged copy on the Docker host when this script runs in a job container.
@@ -83,6 +138,9 @@ compose=(
cleanup() { cleanup() {
local status=$? local status=$?
trap - EXIT INT TERM 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 if [[ -f "$work/compose.env" ]]; then
"${compose[@]}" --profile ops down --volumes --remove-orphans --timeout 10 >/dev/null 2>&1 || status=1 "${compose[@]}" --profile ops down --volumes --remove-orphans --timeout 10 >/dev/null 2>&1 || status=1
fi fi
@@ -113,11 +171,32 @@ fail() {
exit 1 exit 1
} }
if [[ -z "$image" ]]; then if [[ -z "$image" && "$topology" == same-origin ]]; then
step "Building the platform-only image" step "Building the platform-only image"
built_image="ekklesia-deploy-smoke:${run_id}" built_image="ekklesia-deploy-smoke:${run_id}"
DOCKER_BUILDKIT=1 docker build --quiet --file deploy/Dockerfile --tag "$built_image" . >/dev/null DOCKER_BUILDKIT=1 docker build --quiet --file deploy/Dockerfile --tag "$built_image" . >/dev/null
image="$built_image" 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 fi
# The compose project directory: its relative bind mount (postgres/pg_hba.conf) resolves here. # The compose project directory: its relative bind mount (postgres/pg_hba.conf) resolves here.
@@ -154,6 +233,8 @@ postgres_password="$(secret POSTGRES_PASSWORD)"
s3_access_key="$(secret S3_ACCESS_KEY)" s3_access_key="$(secret S3_ACCESS_KEY)"
s3_secret_key="$(secret S3_SECRET_KEY)" s3_secret_key="$(secret S3_SECRET_KEY)"
identity_secret="$(secret EKKLESIA_IDENTITY_SECRET)" 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" mkdir -p "$work/env"
# set_env <file> <key> <value>: replaces an uncommented KEY= line, keeping the value byte-exact. # set_env <file> <key> <value>: replaces an uncommented KEY= line, keeping the value byte-exact.
@@ -173,7 +254,10 @@ ekklesia_env="$work/env/ekklesia.env"
set_env "$ekklesia_env" DATABASE_URL "postgresql://ekklesia:${postgres_password}@postgres:5432/ekklesia" 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_DATABASE_CA "${database_ca_line#EKKLESIA_DATABASE_CA=}"
set_env "$ekklesia_env" EKKLESIA_IDENTITY_BASE_URL "https://${hostname}" set_env "$ekklesia_env" EKKLESIA_IDENTITY_BASE_URL "https://${hostname}"
set_env "$ekklesia_env" EKKLESIA_IDENTITY_TRUSTED_ORIGINS "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_SECRET "$identity_secret" set_env "$ekklesia_env" EKKLESIA_IDENTITY_SECRET "$identity_secret"
set_env "$ekklesia_env" S3_DELIVERY_ENDPOINT "https://${storage_hostname}" set_env "$ekklesia_env" S3_DELIVERY_ENDPOINT "https://${storage_hostname}"
set_env "$ekklesia_env" S3_ACCESS_KEY "$s3_access_key" set_env "$ekklesia_env" S3_ACCESS_KEY "$s3_access_key"
@@ -185,7 +269,7 @@ chmod 600 "$work"/env/*.env
# TEST-ONLY: Caddy's internal CA instead of ACME. Inserted as the first global option. # 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 }' \ awk '!done && $0 == "{" { print; print "\tlocal_certs"; done = 1; next } { print }' \
deploy/Caddyfile.example >"$work/Caddyfile" "$caddyfile_example" >"$work/Caddyfile"
grep -q $'^\tlocal_certs$' "$work/Caddyfile" || fail "could not enable local_certs for the test proxy." grep -q $'^\tlocal_certs$' "$work/Caddyfile" || fail "could not enable local_certs for the test proxy."
stage Caddyfile stage Caddyfile
@@ -194,6 +278,7 @@ EKKLESIA_IMAGE=${image}
EKKLESIA_ENV_DIR=${work}/env EKKLESIA_ENV_DIR=${work}/env
EKKLESIA_TLS_DIR=${daemon_work}/tls EKKLESIA_TLS_DIR=${daemon_work}/tls
EKKLESIA_HOSTNAME=${hostname} EKKLESIA_HOSTNAME=${hostname}
EKKLESIA_DASHBOARD_HOSTNAME=${dashboard_hostname}
EKKLESIA_STORAGE_HOSTNAME=${storage_hostname} EKKLESIA_STORAGE_HOSTNAME=${storage_hostname}
EKKLESIA_ACME_EMAIL=[email protected] EKKLESIA_ACME_EMAIL=[email protected]
EKKLESIA_CADDYFILE=${daemon_work}/Caddyfile EKKLESIA_CADDYFILE=${daemon_work}/Caddyfile
@@ -207,8 +292,28 @@ EKKLESIA_TUNNEL_ADDRESS=${tunnel_address}
EOF EOF
step "Starting PostgreSQL and object storage" step "Starting PostgreSQL and object storage"
"${compose[@]}" up --detach --wait --wait-timeout 180 postgres object-storage >/dev/null 2>&1 || # 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[@]}"
fail "PostgreSQL and object storage did not become healthy." fail "PostgreSQL and object storage did not become healthy."
done
step "Planning, then explicitly applying, migrations against this run's fresh database" step "Planning, then explicitly applying, migrations against this run's fresh database"
"${compose[@]}" run --rm --no-deps migrate >/dev/null || fail "migrate (plan) failed." "${compose[@]}" run --rm --no-deps migrate >/dev/null || fail "migrate (plan) failed."
@@ -216,8 +321,10 @@ step "Planning, then explicitly applying, migrations against this run's fresh da
fail "migrate (apply) failed." fail "migrate (apply) failed."
step "Starting the API and the proxy" step "Starting the API and the proxy"
"${compose[@]}" up --detach --wait --wait-timeout 180 >/dev/null 2>&1 || if ! up_output="$("${compose[@]}" up --detach --wait --wait-timeout 180 2>&1)"; then
compose_report_not_ready "$up_output" "${compose[@]}"
fail "the API and proxy did not become healthy." 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 # Caddy creates its internal CA on first start. The test clients trust it, plus the installation CA
# for their direct connection to object-storage. # for their direct connection to object-storage.
@@ -235,13 +342,17 @@ stage client-ca.pem
stage client.mjs stage client.mjs
edge_network="${project}_edge" 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() { client() {
local mode="$1" local mode="$1"
shift shift
docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \ docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
--network "$edge_network" "$@" \ --network "$edge_network" "$@" "${host_mappings[@]}" \
--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 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 SMOKE_STORAGE_HOSTNAME="$storage_hostname" \
-e S3_ACCESS_KEY="$s3_access_key" -e S3_SECRET_KEY="$s3_secret_key" \ -e S3_ACCESS_KEY="$s3_access_key" -e S3_SECRET_KEY="$s3_secret_key" \
-v "$daemon_work/client.mjs:/smoke/client.mjs:ro" \ -v "$daemon_work/client.mjs:/smoke/client.mjs:ro" \
@@ -266,6 +377,35 @@ client session --ip "$session_client_address" \
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" || -e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" ||
fail "session checks failed." 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" step "Proving the API's database connection is certificate-verified TLS"
api_container="$("${compose[@]}" ps -q api)" api_container="$("${compose[@]}" ps -q api)"
api_data_address="$( api_data_address="$(
+17 -4
View File
@@ -7,6 +7,8 @@ cd "$repository_root"
# shellcheck source=scripts/lib/compose-job-network.sh # shellcheck source=scripts/lib/compose-job-network.sh
source "$repository_root/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 || { command -v docker >/dev/null || {
echo "Docker is required for the infrastructure smoke test." >&2 echo "Docker is required for the infrastructure smoke test." >&2
@@ -28,7 +30,9 @@ if [[ "$curl_help" != *"--aws-sigv4"* ]]; then
exit 1 exit 1
fi fi
raw_project="ekklesia-smoke-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-$$" # 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}"
project="${raw_project//[^a-zA-Z0-9_-]/-}" project="${raw_project//[^a-zA-Z0-9_-]/-}"
project="${project,,}" project="${project,,}"
temporary_directory="$(mktemp -d)" temporary_directory="$(mktemp -d)"
@@ -46,6 +50,8 @@ export S3_SECRET_KEY="ekklesia-smoke-secret-key"
export S3_BUCKET="ekklesia-smoke" export S3_BUCKET="ekklesia-smoke"
export S3_REGION="us-east-1" export S3_REGION="us-east-1"
export S3_PORT="${EKKLESIA_SMOKE_S3_PORT:-0}" export S3_PORT="${EKKLESIA_SMOKE_S3_PORT:-0}"
COMPOSE_DIAGNOSTICS_SECRETS=("$POSTGRES_PASSWORD" "$S3_ACCESS_KEY" "$S3_SECRET_KEY")
up_output=""
compose=( compose=(
docker compose docker compose
@@ -63,7 +69,7 @@ cleanup() {
local cleanup_output local cleanup_output
trap - EXIT INT TERM trap - EXIT INT TERM
if ((exit_status != 0)); then if ((exit_status != 0)); then
"${compose[@]}" logs --no-color >&2 || true compose_report_not_ready "$up_output" "${compose[@]}" || true
fi fi
if ! compose_job_network_disconnect; then if ! compose_job_network_disconnect; then
echo "Infrastructure smoke network cleanup failed." >&2 echo "Infrastructure smoke network cleanup failed." >&2
@@ -88,8 +94,15 @@ trap 'exit 130' INT
trap 'exit 143' TERM trap 'exit 143' TERM
"${compose[@]}" config --quiet "${compose[@]}" config --quiet
"${compose[@]}" up --detach --wait --wait-timeout 120 # Started twice: the second `up` proves the project is idempotent. A failure's own output is kept for
"${compose[@]}" up --detach --wait --wait-timeout 120 # 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=""
database_result="$( database_result="$(
"${compose[@]}" exec -T \ "${compose[@]}" exec -T \
+19 -2
View File
@@ -7,6 +7,8 @@ cd "$repository_root"
# shellcheck source=scripts/lib/compose-job-network.sh # shellcheck source=scripts/lib/compose-job-network.sh
source "$repository_root/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 || { command -v docker >/dev/null || {
echo "Docker is required for the PostgreSQL migration integration suite." >&2 echo "Docker is required for the PostgreSQL migration integration suite." >&2
@@ -25,14 +27,20 @@ if ! compose_job_network_initialize; then
exit 1 exit 1
fi fi
raw_project="ekklesia-migration-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-${GITHUB_JOB:-job}-$$" # 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}"
project="${raw_project//[^a-zA-Z0-9_-]/-}" project="${raw_project//[^a-zA-Z0-9_-]/-}"
project="${project,,}" project="${project,,}"
export POSTGRES_DB="ekklesia_migration_test" export POSTGRES_DB="ekklesia_migration_test"
export POSTGRES_USER="ekklesia_migration_test" export POSTGRES_USER="ekklesia_migration_test"
export POSTGRES_PASSWORD="temporary-$project-${RANDOM}-${RANDOM}" 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 POSTGRES_PORT="0"
export S3_PORT="0"
compose=( compose=(
docker compose docker compose
@@ -72,8 +80,17 @@ fi
# and CI already knows how to provision. SeaweedFS is the intended deployment target (ADR 0004), and # 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 # proving the storage contract against a mock instead would have hidden the provider quirks this
# suite exists to catch. # suite exists to catch.
if ! "${compose[@]}" up --detach --wait --wait-timeout 180 postgres object-storage >/dev/null 2>&1; then # 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
echo "Disposable PostgreSQL and object storage did not become ready." >&2 echo "Disposable PostgreSQL and object storage did not become ready." >&2
compose_report_not_ready "$up_output" "${compose[@]}"
exit 1 exit 1
fi fi
+4
View File
@@ -3,6 +3,10 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta content="width=device-width, initial-scale=1" name="viewport" /> <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> <title>Ekklesia</title>
</head> </head>
<body style="margin: 0; min-width: 0"> <body style="margin: 0; min-width: 0">
+21 -8
View File
@@ -1,4 +1,4 @@
import { copyFile, mkdir, rm } from "node:fs/promises"; import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import esbuild from "esbuild"; import esbuild from "esbuild";
@@ -23,12 +23,16 @@ const outputDirectory = path.join(packageDirectory, "dist", "browser");
const serve = process.argv.includes("--serve"); const serve = process.argv.includes("--serve");
/** /**
* Empty means "the API is on this page's own origin" — the expected production shape. Set it to an * Development only: the API origin the development server writes into its copy of the Dashboard page,
* absolute origin for split-origin development (`EKKLESIA_API_BASE_URL=http://127.0.0.1:3000`), and * exactly as `apps/api` does at startup in production (ADR 0045). Empty means "the API is on this
* add this page's own origin to `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` so the API reflects it for CORS * page's own origin". Set it to an absolute origin for split-origin development
* and the session cookie travels. * (`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.
*/ */
const apiBaseUrl = process.env.EKKLESIA_API_BASE_URL ?? ""; const apiBaseUrl = serve ? (process.env.EKKLESIA_API_BASE_URL ?? "") : "";
const API_ORIGIN_PLACEHOLDER = '<meta content="" name="ekklesia-api-origin" />';
/** @type {import("esbuild").BuildOptions} */ /** @type {import("esbuild").BuildOptions} */
const buildOptions = { const buildOptions = {
@@ -37,7 +41,6 @@ const buildOptions = {
// artifacts it is told about, which are real URLs rather than bare specifiers. // artifacts it is told about, which are real URLs rather than bare specifiers.
bundle: true, bundle: true,
define: { define: {
__EKKLESIA_API_BASE_URL__: JSON.stringify(apiBaseUrl),
// React reads this at module scope and a browser has no `process`. // React reads this at module scope and a browser has no `process`.
"process.env.NODE_ENV": JSON.stringify(serve ? "development" : "production"), "process.env.NODE_ENV": JSON.stringify(serve ? "development" : "production"),
}, },
@@ -67,7 +70,17 @@ const buildOptions = {
await rm(outputDirectory, { force: true, recursive: true }); await rm(outputDirectory, { force: true, recursive: true });
await mkdir(outputDirectory, { recursive: true }); await mkdir(outputDirectory, { recursive: true });
await mkdir(path.join(outputDirectory, "display"), { recursive: true }); await mkdir(path.join(outputDirectory, "display"), { recursive: true });
await copyFile(path.join(packageDirectory, "index.html"), path.join(outputDirectory, "index.html")); 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("&", "&amp;").replaceAll('"', "&quot;")}" name="ekklesia-api-origin" />`,
),
);
await copyFile( await copyFile(
path.join(packageDirectory, "display.html"), path.join(packageDirectory, "display.html"),
path.join(outputDirectory, "display", "index.html"), path.join(outputDirectory, "display", "index.html"),
+31 -22
View File
@@ -2,29 +2,22 @@ import { installEkklesiaDesignTokens } from "@ekklesia/design-tokens";
import { EkklesiaUiStyles } from "@ekklesia/ui"; import { EkklesiaUiStyles } from "@ekklesia/ui";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { consumeAuthLanding } from "../auth-protocol.js"; import { consumeAuthLanding } from "../auth-protocol.js";
import type { EkklesiaHostConfig } from "../host-protocol.js"; import { type EkklesiaHostConfig, resolveApiOrigin } from "../host-protocol.js";
import { HostApp } from "./HostApp.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 one place the shell touches a browser global.
* *
* An empty build-time value means the API is served from the same origin as this page, which is the * The API that served this page wrote its own origin into `<meta name="ekklesia-api-origin">`
* expected production shape; a non-empty one is the split-origin development shape the platform's * (ADR 0045): empty in the same-origin topology, the API's hostname when the Dashboard and the API
* trusted-origin configuration already supports (`EKKLESIA_IDENTITY_TRUSTED_ORIGINS` must list this * live on separate origins. Read at run time, so one built shell serves either topology. Everything
* page's origin for the session cookie to travel). Everything downstream receives the resolved * downstream receives the resolved origin as a plain argument, so no other file in this package
* origin as a plain argument, so no other file in this package reads `window`. * reads `window`.
*/ */
const config: EkklesiaHostConfig = { const apiBaseUrl = resolveApiOrigin(
apiBaseUrl: __EKKLESIA_API_BASE_URL__ || window.location.origin, document.querySelector<HTMLMetaElement>('meta[name="ekklesia-api-origin"]')?.content,
}; window.location.origin,
);
/** Read once, before anything renders, and stripped from the address bar in the same step. See /** 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. */ * `consumeAuthLanding` for why the token must not stay in the URL. */
@@ -46,13 +39,29 @@ if (!container) {
*/ */
installEkklesiaDesignTokens(document); installEkklesiaDesignTokens(document);
createRoot(container).render( /**
<> * A page whose API origin is not a canonical origin fails closed: it makes no request at all, because
<EkklesiaUiStyles /> * every request it would make carries credentials to an address it cannot vouch for. The message is
{authLanding ? ( * 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 authLanding={authLanding} config={config} />
) : ( ) : (
<HostApp config={config} /> <HostApp config={config} />
)} );
}
createRoot(container).render(
<>
<EkklesiaUiStyles />
{renderHost(apiBaseUrl === undefined ? undefined : { apiBaseUrl })}
</>, </>,
); );
+6 -3
View File
@@ -6,10 +6,13 @@ import { parseDisplayRoute } from "../host-protocol.js";
import { DisplayApp } from "./DisplayApp.js"; import { DisplayApp } from "./DisplayApp.js";
import { type DisplayConnectivitySource, registerDisplayServiceWorker } from "./offline-status.js"; import { type DisplayConnectivitySource, registerDisplayServiceWorker } from "./offline-status.js";
declare const __EKKLESIA_API_BASE_URL__: string; /**
* 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).
*/
const config: EkklesiaHostConfig = { const config: EkklesiaHostConfig = {
apiBaseUrl: __EKKLESIA_API_BASE_URL__ || window.location.origin, apiBaseUrl: window.location.origin,
}; };
const container = document.getElementById("ekklesia-display"); const container = document.getElementById("ekklesia-display");
+33 -4
View File
@@ -4,10 +4,11 @@ import type { ModuleUiPrincipal } from "@ekklesia/module-ui-sdk";
* How this shell reaches the Ekklesia API. * How this shell reaches the Ekklesia API.
* *
* `apiBaseUrl` is an absolute **origin** (`https://ekklesia.example.test`, `http://127.0.0.1:3000`) * `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 a build-time * rather than something read from a global. The browser entry point supplies it — from the API
* value when the shell and the API are served from different origins in development, and from * origin the serving API wrote into the Dashboard page (ADR 0045, {@link resolveApiOrigin}), and from
* `window.location.origin` otherwise — so every function below is a pure function of its arguments * `window.location.origin` for the display host, which is always served from the API's own origin —
* and can be exercised without a DOM, a `window`, or a page. Nothing here reads a global. * 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 { export interface EkklesiaHostConfig {
readonly apiBaseUrl: string; readonly apiBaseUrl: string;
@@ -702,6 +703,34 @@ export function parseHostPrincipal(payload: unknown): ModuleUiPrincipal {
throw new HostProtocolError("HOST_RESPONSE_INVALID"); 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 /** 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. */ * shell turns a server-produced path into an absolute URL. */
export function resolveApiUrl(config: EkklesiaHostConfig, rootRelativePath: string): string { export function resolveApiUrl(config: EkklesiaHostConfig, rootRelativePath: string): string {
+1
View File
@@ -32,6 +32,7 @@ export {
parseDisplayRoute, parseDisplayRoute,
parseHostModuleDisplay, parseHostModuleDisplay,
parseHostPrincipal, parseHostPrincipal,
resolveApiOrigin,
resolveApiUrl, resolveApiUrl,
} from "./host-protocol.js"; } from "./host-protocol.js";
export { export {
@@ -44,6 +44,22 @@ 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 () => { it("builds the Dashboard entry at the exact path the locator names", async () => {
const buildScript = await readFile( const buildScript = await readFile(
path.join(packageDirectory, "scripts/build-browser.mjs"), path.join(packageDirectory, "scripts/build-browser.mjs"),
@@ -6,6 +6,7 @@ import {
loadHostPrincipal, loadHostPrincipal,
parseDashboardNavigation, parseDashboardNavigation,
parseHostPrincipal, parseHostPrincipal,
resolveApiOrigin,
} from "../src/host-protocol.js"; } from "../src/host-protocol.js";
import { createModuleUiContext, moduleUiSourceUrl } from "../src/module-context.js"; import { createModuleUiContext, moduleUiSourceUrl } from "../src/module-context.js";
@@ -101,6 +102,39 @@ function navigationWithModule(module: unknown): unknown {
return { groups: [], ungrouped: [{ module, name: "Example Module" }] }; 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", () => { describe("host protocol — descriptor validation", () => {
it("parses a well-formed descriptor out of the navigation view", () => { it("parses a well-formed descriptor out of the navigation view", () => {
const navigation = parseDashboardNavigation(navigationWithModule(inventoryPayload.modules[0])); const navigation = parseDashboardNavigation(navigationWithModule(inventoryPayload.modules[0]));
-2
View File
@@ -120,7 +120,6 @@ describe("the shell builds no module knowledge into its bundle", () => {
absWorkingDir: packageDirectory, absWorkingDir: packageDirectory,
bundle: true, bundle: true,
define: { define: {
__EKKLESIA_API_BASE_URL__: '""',
"process.env.NODE_ENV": '"production"', "process.env.NODE_ENV": '"production"',
}, },
entryPoints: [path.join(sourceDirectory, "browser", "main.tsx")], entryPoints: [path.join(sourceDirectory, "browser", "main.tsx")],
@@ -200,7 +199,6 @@ describe("the display host is a separate generic browser bundle", () => {
absWorkingDir: packageDirectory, absWorkingDir: packageDirectory,
bundle: true, bundle: true,
define: { define: {
__EKKLESIA_API_BASE_URL__: '""',
"process.env.NODE_ENV": '"production"', "process.env.NODE_ENV": '"production"',
}, },
entryPoints: [path.join(sourceDirectory, "display", "main.tsx")], entryPoints: [path.join(sourceDirectory, "display", "main.tsx")],
+165
View File
@@ -0,0 +1,165 @@
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");
});
});