Compare commits
25
Commits
55accd0e65
...
v0.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc504eee0a | ||
|
|
5abc8be167 | ||
|
|
12dca3e8a4 | ||
|
|
0ec1755279 | ||
|
|
874deabea3 | ||
|
|
62951975da | ||
|
|
703d92436b | ||
|
|
43911a088d | ||
|
|
3a2f5d68f6 | ||
|
|
3827841205 | ||
|
|
2152eb036a | ||
|
|
e1ac1da416 | ||
|
|
dcbd7613ee | ||
|
|
dbb4648a35 | ||
|
|
f1d6501239 | ||
|
|
54ac6ab83f | ||
|
|
fa493365bd | ||
|
|
f50e8f4201 | ||
|
|
ff8a3e8097 | ||
|
|
b2efb4b029 | ||
|
|
f63f80f2f0 | ||
|
|
cbf5962d04 | ||
|
|
38cf52757d | ||
|
|
5162b4cc77 | ||
|
|
8ea4d4d2e4 |
+79
-21
@@ -1,18 +1,13 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
# IMPORTANT — the currently inventoried server is Gitea 1.24.2, which *ignores* several keys used
|
# The server runs Gitea 1.27.2 (docs/development/security.md, "What this server honours"):
|
||||||
# below. Per https://docs.gitea.com/1.24/usage/actions/comparison, its unsupported workflow syntax
|
|
||||||
# includes `permissions` / `jobs.<job_id>.permissions`, `concurrency`, and
|
|
||||||
# `jobs.<job_id>.timeout-minutes`. They are declared here deliberately, so the workflow is already
|
|
||||||
# correct on a supported release and on GitHub Actions, but on this server they are inert:
|
|
||||||
#
|
#
|
||||||
# * `permissions:` does NOT restrict the in-job `GITEA_TOKEN`. Job-token permission controls,
|
# * `concurrency:` is honoured: a newer push to a pull request cancels the run it supersedes, so a
|
||||||
# repository/owner defaults, and server-side maximum-permission clamping arrive in the 1.26
|
# cancelled run's own cleanup is never guaranteed (see the CI stack sweep in each stack job).
|
||||||
# feature line (https://docs.gitea.com/1.26/usage/actions/token-permissions). Until the server
|
# * `permissions:` is supported by 1.27 with Gitea's own scopes, but what the job token can do on
|
||||||
# is upgraded and configured, the token's authority is whatever the server grants — not what
|
# this server is not yet proven, so nothing here relies on it being read-only.
|
||||||
# this file requests.
|
# * `timeout-minutes:` is no longer listed as unsupported, but is not verified here either;
|
||||||
# * `concurrency:` and `timeout-minutes:` are NOT bounded-execution guarantees here. Cancellation,
|
# teardown relies on the stack sweep and the runner's own reaper.
|
||||||
# timeout, and teardown must be enforced by the trusted runner provisioner/reaper.
|
|
||||||
#
|
#
|
||||||
# `persist-credentials: false` below is unaffected by any of this and is enforced by the action
|
# `persist-credentials: false` below is unaffected by any of this and is enforced by the action
|
||||||
# itself. See docs/development/security.md.
|
# itself. See docs/development/security.md.
|
||||||
@@ -63,6 +58,9 @@ jobs:
|
|||||||
needs: verify
|
needs: verify
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
env:
|
||||||
|
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
|
||||||
|
EKKLESIA_CI_JOB_KEY: infrastructure-smoke
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
@@ -72,13 +70,29 @@ jobs:
|
|||||||
# in this workflow pushes, so the credential is never needed after checkout.
|
# in this workflow pushes, so the credential is never needed after checkout.
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
|
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
|
||||||
|
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
|
||||||
|
- name: Sweep stale CI stacks from the runner
|
||||||
|
run: bash scripts/ci-sweep.sh --stale
|
||||||
|
|
||||||
|
- name: Prove the sweep removes exactly what it should, against real Docker resources
|
||||||
|
run: bash scripts/test-ci-sweep.sh
|
||||||
|
|
||||||
- name: Exercise PostgreSQL and S3 infrastructure
|
- name: Exercise PostgreSQL and S3 infrastructure
|
||||||
run: bash scripts/smoke-infrastructure.sh
|
run: bash scripts/smoke-infrastructure.sh
|
||||||
|
|
||||||
|
# Whatever happened above, this job's own stacks do not outlive it.
|
||||||
|
- name: Remove this job's CI stacks
|
||||||
|
if: always()
|
||||||
|
run: bash scripts/ci-sweep.sh --job
|
||||||
|
|
||||||
migration-smoke:
|
migration-smoke:
|
||||||
needs: verify
|
needs: verify
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
|
env:
|
||||||
|
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
|
||||||
|
EKKLESIA_CI_JOB_KEY: migration-smoke
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
@@ -88,6 +102,11 @@ jobs:
|
|||||||
# in this workflow pushes, so the credential is never needed after checkout.
|
# in this workflow pushes, so the credential is never needed after checkout.
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
|
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
|
||||||
|
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
|
||||||
|
- name: Sweep stale CI stacks from the runner
|
||||||
|
run: bash scripts/ci-sweep.sh --stale
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
@@ -104,11 +123,18 @@ jobs:
|
|||||||
- name: Exercise transactional migrations against PostgreSQL
|
- name: Exercise transactional migrations against PostgreSQL
|
||||||
run: pnpm test:migrations:integration
|
run: pnpm test:migrations:integration
|
||||||
|
|
||||||
|
# Whatever happened above, this job's own stacks do not outlive it.
|
||||||
|
- name: Remove this job's CI stacks
|
||||||
|
if: always()
|
||||||
|
run: bash scripts/ci-sweep.sh --job
|
||||||
|
|
||||||
image-build:
|
image-build:
|
||||||
needs: verify
|
needs: verify
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
env:
|
env:
|
||||||
|
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
|
||||||
|
EKKLESIA_CI_JOB_KEY: image-build
|
||||||
# Cache mounts in deploy/Dockerfile need BuildKit; this selects it even where the Docker CLI
|
# Cache mounts in deploy/Dockerfile need BuildKit; this selects it even where the Docker CLI
|
||||||
# has no buildx plugin.
|
# has no buildx plugin.
|
||||||
DOCKER_BUILDKIT: "1"
|
DOCKER_BUILDKIT: "1"
|
||||||
@@ -121,29 +147,51 @@ jobs:
|
|||||||
# in this workflow pushes, so the credential is never needed after checkout.
|
# in this workflow pushes, so the credential is never needed after checkout.
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
|
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
|
||||||
|
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
|
||||||
|
- name: Sweep stale CI stacks from the runner
|
||||||
|
run: bash scripts/ci-sweep.sh --stale
|
||||||
|
|
||||||
# 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
|
# 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.
|
# can build at once on one runner's Docker daemon, and neither may remove the other's image.
|
||||||
|
# Labeled like every CI stack, so this job's final step removes it even if the build is killed.
|
||||||
- name: Build the production image without modules
|
- name: Build the production image without modules
|
||||||
run: >-
|
shell: bash
|
||||||
docker build
|
run: |
|
||||||
--file deploy/Dockerfile
|
source scripts/lib/ci-project.sh
|
||||||
--build-arg REVISION="$GITHUB_SHA"
|
docker build \
|
||||||
--tag ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}"
|
--file deploy/Dockerfile \
|
||||||
|
--build-arg REVISION="$GITHUB_SHA" \
|
||||||
|
--label "${EKKLESIA_CI_LABEL}=$(ci_project_name image)" \
|
||||||
|
--label "${EKKLESIA_CI_CREATED_LABEL}=$(date +%s)" \
|
||||||
|
--tag ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}" \
|
||||||
.
|
.
|
||||||
|
|
||||||
- name: Remove the image from the runner
|
# Whatever happened above, this job's own stacks do not outlive it.
|
||||||
|
- name: Remove this job's CI stacks
|
||||||
if: always()
|
if: always()
|
||||||
run: docker image rm --force ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}" || true
|
run: bash scripts/ci-sweep.sh --job
|
||||||
|
|
||||||
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:
|
||||||
|
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
|
||||||
|
EKKLESIA_CI_JOB_KEY: deploy-smoke-${{ matrix.topology }}
|
||||||
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
|
||||||
@@ -153,10 +201,20 @@ jobs:
|
|||||||
# in this workflow pushes, so the credential is never needed after checkout.
|
# in this workflow pushes, so the credential is never needed after checkout.
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
|
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
|
||||||
|
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
|
||||||
|
- name: Sweep stale CI stacks from the runner
|
||||||
|
run: bash scripts/ci-sweep.sh --stale
|
||||||
|
|
||||||
# The reference deployment end to end (ADR 0044): a platform-only image, base + proxy with
|
# The reference deployment end to end (ADR 0044): a platform-only image, base + proxy with
|
||||||
# Caddy's internal CA (test-only), an explicit migration apply against this job's own fresh
|
# Caddy's internal CA (test-only), an explicit migration apply against this job's own fresh
|
||||||
# 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
|
||||||
|
|
||||||
|
# Whatever happened above, this job's own stacks do not outlive it.
|
||||||
|
- name: Remove this job's CI stacks
|
||||||
|
if: always()
|
||||||
|
run: bash scripts/ci-sweep.sh --job
|
||||||
|
|||||||
@@ -218,9 +218,10 @@ undeployed state, and the launch checklist in a single place. **CI is accepted a
|
|||||||
trusted-code-only boundary**: the self-hosted Gitea runner executes only code controlled and
|
trusted-code-only boundary**: the self-hosted Gitea runner executes only code controlled and
|
||||||
reviewed by trusted repository collaborators, and unknown or untrusted pull requests must not
|
reviewed by trusted repository collaborators, and unknown or untrusted pull requests must not
|
||||||
execute on it automatically. What remains is operational and cannot be proven from this repository —
|
execute on it automatically. What remains is operational and cannot be proven from this repository —
|
||||||
upgrading Gitea past 1.24.2 (which *ignores* the workflow's `permissions`, `concurrency`, and
|
configuring and proving job-token authority server-side (the server now runs Gitea 1.27.2, which
|
||||||
`timeout-minutes` declarations), clamping job-token authority server-side, enforcing timeout and
|
honours `concurrency` and documents `permissions`, but neither token limits nor `timeout-minutes` are
|
||||||
teardown in a trusted provisioner/reaper, and proving disposable runner isolation. Those are
|
verified on it yet), enforcing timeout and teardown in a trusted provisioner/reaper, and proving
|
||||||
|
disposable runner isolation. Those are
|
||||||
prerequisites for **executing untrusted contributions**, alongside the deployment checklist that
|
prerequisites for **executing untrusted contributions**, alongside the deployment checklist that
|
||||||
gates a **public launch**; moving future public contribution workflows to a service with isolated
|
gates a **public launch**; moving future public contribution workflows to a service with isolated
|
||||||
hosted runners is an acceptable alternative. **Neither set of requirements blocks Phase 6** — Surface
|
hosted runners is an acceptable alternative. **Neither set of requirements blocks Phase 6** — Surface
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/api",
|
"name": "@ekklesia/api",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -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])]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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("&", "&")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">");
|
||||||
|
return parts.join(`<meta content="${escaped}" name="ekklesia-api-origin" />`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loads only the two surface-declared files, and renders the API origin into the page. A missing,
|
||||||
|
* unreadable, or incompatible build fails startup rather than leaving an API whose own origin answers
|
||||||
|
* `/` with a 404 or a page that cannot find its API. */
|
||||||
|
export async function loadDashboardHostAssets(apiOrigin: string): Promise<DashboardHostAssets> {
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>`);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/core",
|
"name": "@ekklesia/core",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -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 =
|
||||||
@@ -849,17 +905,25 @@ function databaseTransportCheck(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Doctor reads only its own process's environment, so it says so: run as the reference
|
||||||
|
* deployment's `migrate`, it is given the API's values by the proxy overlay, but run any other way
|
||||||
|
* it may be checking a configuration the API is not running. */
|
||||||
|
const CLIENT_ADDRESS_SCOPE =
|
||||||
|
"Checked: the environment Doctor runs with. In the reference deployment `migrate` is given the API's client-address values; a running API's own value is shown by `docker compose exec api printenv EKKLESIA_CLIENT_ADDRESS_MODE`.";
|
||||||
|
|
||||||
function clientAddressCheck(input: ClientAddressConfigInput | undefined): DoctorCheck {
|
function clientAddressCheck(input: ClientAddressConfigInput | undefined): DoctorCheck {
|
||||||
try {
|
try {
|
||||||
const config = createClientAddressConfig(input, TRUSTED_CLIENT_ADDRESS_HEADER);
|
const config = createClientAddressConfig(input, TRUSTED_CLIENT_ADDRESS_HEADER);
|
||||||
if (config.mode === "direct") {
|
if (config.mode === "direct") {
|
||||||
return freezeCheck("identity.client-address", "pass", "Direct client-address mode", [
|
return freezeCheck("identity.client-address", "pass", "Direct client-address mode", [
|
||||||
"Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.",
|
"Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.",
|
||||||
|
CLIENT_ADDRESS_SCOPE,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
return freezeCheck("identity.client-address", "pass", "Trusted-proxy client-address mode", [
|
return freezeCheck("identity.client-address", "pass", "Trusted-proxy client-address mode", [
|
||||||
`Trusts header "${config.header}" only from a request whose immediate socket peer matches one of ${config.trustedProxies.length} configured trusted ${plural(config.trustedProxies.length, "proxy")}.`,
|
`Trusts header "${config.header}" only from a request whose immediate socket peer matches one of ${config.trustedProxies.length} configured trusted ${plural(config.trustedProxies.length, "proxy")}.`,
|
||||||
"The reverse proxy must overwrite (never append to) this header, and the origin must not be reachable except through it.",
|
"The reverse proxy must overwrite (never append to) this header, and the origin must not be reachable except through it.",
|
||||||
|
CLIENT_ADDRESS_SCOPE,
|
||||||
]);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const code =
|
const code =
|
||||||
@@ -870,7 +934,7 @@ function clientAddressCheck(input: ClientAddressConfigInput | undefined): Doctor
|
|||||||
"identity.client-address",
|
"identity.client-address",
|
||||||
"failure",
|
"failure",
|
||||||
"Client-address configuration invalid",
|
"Client-address configuration invalid",
|
||||||
[`[${code}] Configured client-address values failed validation.`],
|
[`[${code}] Configured client-address values failed validation.`, CLIENT_ADDRESS_SCOPE],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ Identity
|
|||||||
Identity runtime configuration (signing key, base URL, trusted origins) is not set.
|
Identity runtime configuration (signing key, base URL, trusted origins) is not set.
|
||||||
✓ Direct client-address mode
|
✓ Direct client-address mode
|
||||||
Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.
|
Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.
|
||||||
|
Checked: the environment Doctor runs with. In the reference deployment \`migrate\` is given the API's client-address values; a running API's own value is shown by \`docker compose exec api printenv EKKLESIA_CLIENT_ADDRESS_MODE\`.
|
||||||
|
|
||||||
Object storage
|
Object storage
|
||||||
○ Object storage not configured
|
○ Object storage not configured
|
||||||
|
|||||||
@@ -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(
|
||||||
@@ -876,6 +958,21 @@ describe("Ekklesia Doctor diagnostics", () => {
|
|||||||
expect(report.result).toBe("ready-with-warnings");
|
expect(report.result).toBe("ready-with-warnings");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("says whose client-address configuration it checked: this process's, not a running API's", async () => {
|
||||||
|
for (const clientAddress of [
|
||||||
|
undefined,
|
||||||
|
{ header: "x-forwarded-for", mode: "trusted-proxy" as const, trustedProxies: ["10.0.0.1"] },
|
||||||
|
{ header: "x-forwarded-for", mode: "trusted-proxy" as const, trustedProxies: ["bad"] },
|
||||||
|
]) {
|
||||||
|
const report = await diagnoseEkklesia(
|
||||||
|
{ ...options(), ...(clientAddress ? { clientAddress } : {}) },
|
||||||
|
dependencies(),
|
||||||
|
);
|
||||||
|
const details = check(report, "identity.client-address").details;
|
||||||
|
expect(details.some((detail) => detail.includes("environment Doctor runs with"))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("passes client-address configuration with an explicitly configured trusted-proxy mode", async () => {
|
it("passes client-address configuration with an explicitly configured trusted-proxy mode", async () => {
|
||||||
const report = await diagnoseEkklesia(
|
const report = await diagnoseEkklesia(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -70,18 +70,57 @@
|
|||||||
|
|
||||||
# Browsers fetch signed object URLs here (S3_DELIVERY_ENDPOINT, ADR 0031).
|
# Browsers fetch signed object URLs here (S3_DELIVERY_ENDPOINT, ADR 0031).
|
||||||
{$EKKLESIA_STORAGE_HOSTNAME} {
|
{$EKKLESIA_STORAGE_HOSTNAME} {
|
||||||
|
# Delivery-layer CORS belongs to the deployment, not to the object store: this block decides it,
|
||||||
|
# so it stays the same with any S3 backend behind it. Only the origins whose pages read objects
|
||||||
|
# with fetch() get CORS: the Ekklesia origin, which serves the Dashboard and the displays. An <img> needs none. Every object
|
||||||
|
# URL is still a signed, expiring capability; CORS only decides which pages may read the bytes.
|
||||||
|
@cors_origin header Origin https://{$EKKLESIA_HOSTNAME}
|
||||||
|
|
||||||
|
# A preflight is answered here and never reaches storage. Only GET and HEAD are ever allowed, and
|
||||||
|
# no request header beyond the CORS-safelisted ones.
|
||||||
|
@preflight method OPTIONS
|
||||||
|
handle @preflight {
|
||||||
|
header @cors_origin {
|
||||||
|
Access-Control-Allow-Origin {http.request.header.Origin}
|
||||||
|
Access-Control-Allow-Methods "GET, HEAD"
|
||||||
|
Access-Control-Max-Age 600
|
||||||
|
}
|
||||||
|
header Vary Origin
|
||||||
|
respond 204
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
# Set when the response is written, after the upstream's own CORS headers were removed below.
|
||||||
|
header @cors_origin >Access-Control-Allow-Origin {http.request.header.Origin}
|
||||||
|
header >Vary Origin
|
||||||
|
|
||||||
reverse_proxy https://object-storage:8443 {
|
reverse_proxy https://object-storage:8443 {
|
||||||
# An S3 signature covers the Host header: pass the original through unchanged, or every signed
|
# An S3 signature covers the Host header: pass the original through unchanged, or every
|
||||||
# URL fails verification.
|
# signed URL fails verification.
|
||||||
header_up Host {host}
|
header_up Host {host}
|
||||||
header_up -CF-Connecting-IP
|
header_up -CF-Connecting-IP
|
||||||
header_up -X-Real-IP
|
header_up -X-Real-IP
|
||||||
header_up -Forwarded
|
header_up -Forwarded
|
||||||
header_up -True-Client-IP
|
header_up -True-Client-IP
|
||||||
|
# The object store's own CORS answer (SeaweedFS reflects any origin, with every method)
|
||||||
|
# is not the deployment's policy.
|
||||||
|
header_down -Access-Control-Allow-Origin
|
||||||
|
header_down -Access-Control-Allow-Methods
|
||||||
|
header_down -Access-Control-Allow-Headers
|
||||||
|
header_down -Access-Control-Expose-Headers
|
||||||
|
header_down -Access-Control-Allow-Credentials
|
||||||
|
header_down -Access-Control-Max-Age
|
||||||
|
header_down -Vary
|
||||||
transport http {
|
transport http {
|
||||||
# object-storage presents a certificate from the installation-private CA.
|
# object-storage presents a certificate from the installation-private CA.
|
||||||
tls_trust_pool file /etc/caddy/ekklesia-ca.crt
|
tls_trust_pool file /etc/caddy/ekklesia-ca.crt
|
||||||
tls_server_name object-storage
|
tls_server_name object-storage
|
||||||
|
# HTTP/1.1 only. Over HTTP/2, SeaweedFS answers a conditional request with a 304 that
|
||||||
|
# declares a Content-Length it never sends; Caddy then aborts the browser's stream,
|
||||||
|
# which a browser reports as ERR_HTTP2_PROTOCOL_ERROR on every revalidation. Its
|
||||||
|
# HTTP/1.1 304 is clean. Browsers still reach this proxy over HTTP/2 and HTTP/3.
|
||||||
|
versions 1.1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# 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} {
|
||||||
|
# Delivery-layer CORS belongs to the deployment, not to the object store: this block decides it,
|
||||||
|
# so it stays the same with any S3 backend behind it. Only the origins whose pages read objects
|
||||||
|
# with fetch() get CORS: the API origin, where displays run, and the Dashboard origin,
|
||||||
|
# where a module's preview may cache images too. An <img> needs none. Every object
|
||||||
|
# URL is still a signed, expiring capability; CORS only decides which pages may read the bytes.
|
||||||
|
@cors_origin {
|
||||||
|
header Origin https://{$EKKLESIA_HOSTNAME}
|
||||||
|
header Origin https://{$EKKLESIA_DASHBOARD_HOSTNAME}
|
||||||
|
}
|
||||||
|
|
||||||
|
# A preflight is answered here and never reaches storage. Only GET and HEAD are ever allowed, and
|
||||||
|
# no request header beyond the CORS-safelisted ones.
|
||||||
|
@preflight method OPTIONS
|
||||||
|
handle @preflight {
|
||||||
|
header @cors_origin {
|
||||||
|
Access-Control-Allow-Origin {http.request.header.Origin}
|
||||||
|
Access-Control-Allow-Methods "GET, HEAD"
|
||||||
|
Access-Control-Max-Age 600
|
||||||
|
}
|
||||||
|
header Vary Origin
|
||||||
|
respond 204
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
# Set when the response is written, after the upstream's own CORS headers were removed below.
|
||||||
|
header @cors_origin >Access-Control-Allow-Origin {http.request.header.Origin}
|
||||||
|
header >Vary Origin
|
||||||
|
|
||||||
|
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
|
||||||
|
# The object store's own CORS answer (SeaweedFS reflects any origin, with every method)
|
||||||
|
# is not the deployment's policy.
|
||||||
|
header_down -Access-Control-Allow-Origin
|
||||||
|
header_down -Access-Control-Allow-Methods
|
||||||
|
header_down -Access-Control-Allow-Headers
|
||||||
|
header_down -Access-Control-Expose-Headers
|
||||||
|
header_down -Access-Control-Allow-Credentials
|
||||||
|
header_down -Access-Control-Max-Age
|
||||||
|
header_down -Vary
|
||||||
|
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
|
||||||
|
# HTTP/1.1 only. Over HTTP/2, SeaweedFS answers a conditional request with a 304 that
|
||||||
|
# declares a Content-Length it never sends; Caddy then aborts the browser's stream,
|
||||||
|
# which a browser reports as ERR_HTTP2_PROTOCOL_ERROR on every revalidation. Its
|
||||||
|
# HTTP/1.1 304 is clean. Browsers still reach this proxy over HTTP/2 and HTTP/3.
|
||||||
|
versions 1.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+89
-8
@@ -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,64 @@ 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.
|
||||||
|
|
||||||
|
### Storage delivery: CORS is the deployment's
|
||||||
|
|
||||||
|
Browsers fetch objects directly from the storage hostname through signed, expiring URLs (ADR 0031).
|
||||||
|
**Which pages may read those bytes with `fetch()` is decided by the delivery layer, the proxy, and
|
||||||
|
never by the object store.** That policy therefore stays the same with SeaweedFS, R2, or any other
|
||||||
|
S3 backend behind it, and an installation that changes backends changes nothing about CORS.
|
||||||
|
|
||||||
|
The reference proxy's storage block:
|
||||||
|
|
||||||
|
- grants CORS only to the origins whose pages read objects with `fetch()`: the Ekklesia origin in the
|
||||||
|
same-origin topology; the API origin (displays) and the Dashboard origin (a module's preview) in
|
||||||
|
the split topology. An `<img>` needs no CORS and is unaffected;
|
||||||
|
- allows `GET` and `HEAD` only, and no request header beyond the CORS-safelisted ones, always with
|
||||||
|
`Vary: Origin`;
|
||||||
|
- answers preflight `OPTIONS` requests itself, so they never reach storage;
|
||||||
|
- removes the object store's own CORS headers. SeaweedFS's default reflects any origin and
|
||||||
|
advertises `PUT`, `POST`, and `DELETE`.
|
||||||
|
|
||||||
|
The proxy reaches storage over **HTTP/1.1 only**. Over HTTP/2, SeaweedFS answers a conditional
|
||||||
|
request with a `304` that declares a `Content-Length` it never sends. The proxy then resets the
|
||||||
|
browser's stream, and every reload of a cached image fails with `ERR_HTTP2_PROTOCOL_ERROR`.
|
||||||
|
Browsers still reach the proxy itself over HTTP/2 and HTTP/3.
|
||||||
|
|
||||||
### 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
|
||||||
@@ -177,6 +236,8 @@ Then make your proxy do what `Caddyfile.example` does:
|
|||||||
- forward the storage hostname to `https://object-storage:8443`, verifying it against
|
- forward the storage hostname to `https://object-storage:8443`, verifying it against
|
||||||
`/etc/ekklesia/tls/ca.crt` with server name `object-storage`, and **pass the original `Host`
|
`/etc/ekklesia/tls/ca.crt` with server name `object-storage`, and **pass the original `Host`
|
||||||
through unchanged**, or every signed URL fails (ADR 0031).
|
through unchanged**, or every signed URL fails (ADR 0031).
|
||||||
|
- reach `object-storage` over **HTTP/1.1** only, and own the storage hostname's CORS as described in
|
||||||
|
[Storage delivery](#storage-delivery-cors-is-the-deployments).
|
||||||
|
|
||||||
The proxy's address must be fixed and must never be handed to anything else. With Docker, give its
|
The proxy's address must be fixed and must never be handed to anything else. With Docker, give its
|
||||||
network an explicit subnet, limit `ip_range` to part of it, and fix the proxy's address outside that
|
network an explicit subnet, limit `ip_range` to part of it, and fix the proxy's address outside that
|
||||||
@@ -203,8 +264,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 +294,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`. |
|
||||||
@@ -251,14 +313,25 @@ deployment:
|
|||||||
platform-only image and a disposable PostgreSQL and SeaweedFS that exist only for that run. It
|
platform-only image and a disposable PostgreSQL and SeaweedFS that exist only for that run. It
|
||||||
follows the first boot above and checks the following through the proxy:
|
follows the first boot above and checks the following through the proxy:
|
||||||
- readiness and the Dashboard;
|
- readiness and the Dashboard;
|
||||||
- a signed object URL fetched through the storage hostname;
|
- a signed object URL fetched through the storage hostname, and the same image loaded twice over
|
||||||
|
HTTP/2, the second time as a conditional request that must be a clean `304`;
|
||||||
|
- storage CORS: exactly the allowed origins, `GET` and `HEAD` only, preflights answered by the
|
||||||
|
proxy, and none of the object store's own CORS headers;
|
||||||
- the client-address chain, both from an ordinary peer and from the tunnel connector's address;
|
- the client-address chain, both from an ordinary peer and from the tunnel connector's address;
|
||||||
- initial-owner sign-in and sign-out;
|
- initial-owner sign-in and sign-out;
|
||||||
- that the API's database connection is TLS 1.3, and that it is refused by a foreign CA and by a
|
- that the API's database connection is TLS 1.3, and that it is refused by a foreign CA and by a
|
||||||
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.
|
||||||
|
|
||||||
@@ -311,10 +384,10 @@ finds its files:
|
|||||||
```sh
|
```sh
|
||||||
docker build \
|
docker build \
|
||||||
--file deploy/Dockerfile \
|
--file deploy/Dockerfile \
|
||||||
--build-arg VERSION=0.1.1 \
|
--build-arg VERSION=0.1.2 \
|
||||||
--build-arg REVISION="$(git rev-parse HEAD)" \
|
--build-arg REVISION="$(git rev-parse HEAD)" \
|
||||||
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \
|
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \
|
||||||
--tag ekklesia:0.1.1 \
|
--tag ekklesia:0.1.2 \
|
||||||
.
|
.
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -395,6 +468,14 @@ for a development checkout, not for the image.
|
|||||||
`migrate` has `profiles: ["ops"]`, so `up` never starts it. It never restarts, has no health check,
|
`migrate` has `profiles: ["ops"]`, so `up` never starts it. It never restarts, has no health check,
|
||||||
and its default command is the read-only plan. **Nothing applies migrations automatically.**
|
and its default command is the read-only plan. **Nothing applies migrations automatically.**
|
||||||
|
|
||||||
|
Run operator commands with the same Compose files as the stack. `COMPOSE_FILE` in `deploy/.env`
|
||||||
|
(set in [`env/compose.env.example`](env/compose.env.example)) does that for every plain
|
||||||
|
`docker compose` command below. It matters for Doctor in particular: Doctor checks the
|
||||||
|
client-address configuration of the process it runs in. The proxy overlay gives `migrate` the
|
||||||
|
API's trusted-proxy settings, so Doctor reports what the API runs. Without the overlay, Doctor
|
||||||
|
reports direct mode for an API that trusts its proxy. The running API's own value is shown by
|
||||||
|
`docker compose exec api printenv EKKLESIA_CLIENT_ADDRESS_MODE`.
|
||||||
|
|
||||||
| Task | Command (from `deploy/`) |
|
| Task | Command (from `deploy/`) |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Plan migrations (read-only) | `docker compose run --rm migrate` |
|
| Plan migrations (read-only) | `docker compose run --rm migrate` |
|
||||||
|
|||||||
@@ -13,6 +13,13 @@
|
|||||||
# its lower half; the proxy (and the tunnel connector, if used) take addresses from the upper half,
|
# its lower half; the proxy (and the tunnel connector, if used) take addresses from the upper half,
|
||||||
# which Docker never hands to another container.
|
# which Docker never hands to another container.
|
||||||
|
|
||||||
|
# Exactly one trusted peer: this proxy. Set from the same variable that fixes the proxy's address,
|
||||||
|
# so the two cannot drift apart. These override any value in ekklesia.env.
|
||||||
|
x-ekklesia-client-address: &ekklesia-client-address
|
||||||
|
EKKLESIA_CLIENT_ADDRESS_MODE: trusted-proxy
|
||||||
|
EKKLESIA_CLIENT_ADDRESS_HEADER: x-forwarded-for
|
||||||
|
EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: ${EKKLESIA_PROXY_ADDRESS:-10.249.250.254}/32
|
||||||
|
|
||||||
services:
|
services:
|
||||||
proxy:
|
proxy:
|
||||||
image: caddy:2.11.4-alpine@sha256:6aeddd44c3078b0f9a35206472a11420648a79c184603ef95957d0a20044cb2b
|
image: caddy:2.11.4-alpine@sha256:6aeddd44c3078b0f9a35206472a11420648a79c184603ef95957d0a20044cb2b
|
||||||
@@ -35,6 +42,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:
|
||||||
@@ -55,12 +66,12 @@ services:
|
|||||||
api:
|
api:
|
||||||
networks:
|
networks:
|
||||||
edge: {}
|
edge: {}
|
||||||
environment:
|
environment: *ekklesia-client-address
|
||||||
# Exactly one trusted peer: this proxy. Set here from the same variable that fixes the proxy's
|
|
||||||
# address, so the two cannot drift apart. These override any value in ekklesia.env.
|
# Operator commands (migrations, Doctor) run as `migrate`. It gets the API's client-address
|
||||||
EKKLESIA_CLIENT_ADDRESS_MODE: trusted-proxy
|
# configuration too, so that Doctor checks what the API actually runs rather than an empty one.
|
||||||
EKKLESIA_CLIENT_ADDRESS_HEADER: x-forwarded-for
|
migrate:
|
||||||
EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: ${EKKLESIA_PROXY_ADDRESS:-10.249.250.254}/32
|
environment: *ekklesia-client-address
|
||||||
|
|
||||||
object-storage:
|
object-storage:
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
Vendored
+12
-1
@@ -2,6 +2,12 @@
|
|||||||
# Compose reads automatically from this directory. Nothing here is a secret; secrets live in the
|
# Compose reads automatically from this directory. Nothing here is a secret; secrets live in the
|
||||||
# per-service files under EKKLESIA_ENV_DIR.
|
# per-service files under EKKLESIA_ENV_DIR.
|
||||||
|
|
||||||
|
# Which Compose files make up this installation, so that every plain `docker compose …` command
|
||||||
|
# run from this directory, operator commands through `migrate` included, sees the same services and
|
||||||
|
# settings as the running stack. Add compose.tunnel.yaml if you use the tunnel connector. Without
|
||||||
|
# the proxy overlay here, Doctor checks a `migrate` that lacks the API's client-address settings.
|
||||||
|
COMPOSE_FILE=compose.yaml:compose.proxy.yaml
|
||||||
|
|
||||||
# The image to run, by registry, name, and tag (or digest). Required.
|
# The image to run, by registry, name, and tag (or digest). Required.
|
||||||
EKKLESIA_IMAGE=
|
EKKLESIA_IMAGE=
|
||||||
|
|
||||||
@@ -10,9 +16,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
|
||||||
|
|||||||
Vendored
+9
-5
@@ -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=
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 0045: The Dashboard and the API may live on separate origins
|
# 0045: The Dashboard and the API may live on separate origins
|
||||||
|
|
||||||
- Status: Proposed
|
- Status: Accepted
|
||||||
- Date: 2026-10-03
|
- Date: 2026-10-03
|
||||||
- Amends: [0034](0034-host-owned-browser-authentication.md)
|
- Amends: [0034](0034-host-owned-browser-authentication.md)
|
||||||
- Extends: [0044](0044-self-hosted-reference-deployment.md)
|
- Extends: [0044](0044-self-hosted-reference-deployment.md)
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
# 0047: Every module proves itself inside Ekklesia, with one harness the platform publishes
|
||||||
|
|
||||||
|
- Status: Proposed
|
||||||
|
- Date: 2026-10-05
|
||||||
|
- Supersedes: none
|
||||||
|
- Related: [ADR 0016](0016-module-database-access.md),
|
||||||
|
[ADR 0021](0021-modules-as-hostable-products.md),
|
||||||
|
[ADR 0028](0028-module-operational-lifecycle.md),
|
||||||
|
[ADR 0030](0030-host-provided-module-object-storage.md),
|
||||||
|
[ADR 0033](0033-display-kiosk-host-contract.md),
|
||||||
|
[ADR 0036](0036-managed-device-identity-and-pairing.md),
|
||||||
|
[ADR 0041](0041-module-browser-contributions.md),
|
||||||
|
[ADR 0043](0043-display-offline-code-fallback.md),
|
||||||
|
[ADR 0044](0044-self-hosted-reference-deployment.md),
|
||||||
|
[ADR 0045](0045-split-dashboard-and-api-origins.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Modules are independently versioned products (ADR 0021). They live in their own repositories, and
|
||||||
|
none of them can be built, run, or rendered without Ekklesia. The first production module, Signage,
|
||||||
|
reached a church with three defects that only show when it runs **inside** Ekklesia, in a real
|
||||||
|
browser:
|
||||||
|
|
||||||
|
- every heartbeat its display sent was refused with a 400, because the display and the server
|
||||||
|
disagreed about `null`;
|
||||||
|
- a display that failed one request at start-up never recovered without a reload;
|
||||||
|
- its media cache leaked one blob per slide per refresh, until the browser refused the display's own
|
||||||
|
requests.
|
||||||
|
|
||||||
|
Signage had unit tests, browser tests against a stubbed host, and no CI. Every test passed, because
|
||||||
|
each half was tested against its own idea of the other, and nothing ever ran the module in the
|
||||||
|
platform it ships into.
|
||||||
|
|
||||||
|
The platform's own deployment smoke (ADR 0044, ADR 0045) does run a module in a real browser, but
|
||||||
|
only the repository's template, and only far enough to mount it. The instance repository runs the
|
||||||
|
smoke against its real modules, but only at release time, after a module has already been tagged.
|
||||||
|
Each module repository that wants more than that has to assemble the platform, build an image,
|
||||||
|
start a deployment, and drive a browser itself. Signage is about to do exactly that by hand. The
|
||||||
|
second module, Classroom, would copy it, and the copies would drift from the platform they test.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### The platform publishes a versioned module harness
|
||||||
|
|
||||||
|
Each platform release publishes one OCI image, `ekklesia-module-harness:<platform version>`. It is
|
||||||
|
built from the platform at that release tag by the platform's own release CI, and pushed to the
|
||||||
|
organization's registry. A version is never overwritten. Modules reference it **by digest**.
|
||||||
|
|
||||||
|
The harness contains the platform source at that version: its `deploy/` directory, its Dockerfile,
|
||||||
|
its lockfile, and its smoke scripts. It also contains the pinned Node.js and pnpm, the browser
|
||||||
|
driver, and the pinned headless Chromium image reference. Given a module checkout, it:
|
||||||
|
|
||||||
|
1. **assembles** the module into the platform at `modules/<id>`, exactly as an instance does (ADR
|
||||||
|
0044), and installs with `--frozen-lockfile`;
|
||||||
|
2. runs the platform's own gates against that set: `pnpm boundaries`, `modules:validate`,
|
||||||
|
`migrations:validate`, and the build;
|
||||||
|
3. **builds** the deployment image from the assembled workspace with the platform's Dockerfile;
|
||||||
|
4. starts the **reference deployment** in the split topology (ADR 0045), with a disposable
|
||||||
|
PostgreSQL and object store, Caddy's internal CA, and freshly generated secrets;
|
||||||
|
5. plans, then **explicitly applies**, migrations against that fresh database. This is the only
|
||||||
|
database the harness ever migrates;
|
||||||
|
6. creates the initial owner, grants the module's declared permissions to a test person, and
|
||||||
|
**mounts every UI surface the manifest declares**, in headless Chromium. That covers:
|
||||||
|
- the primary UI in the Dashboard;
|
||||||
|
- the display, paired as a device with no human session (ADR 0036);
|
||||||
|
- each browser contribution, whenever the module that hosts it is present (ADR 0041);
|
||||||
|
7. runs a short **display soak**, and the **no-dead-ends** injections below;
|
||||||
|
8. tears everything down: containers, networks, volumes, and the work directory.
|
||||||
|
|
||||||
|
It **fails** on any of the following:
|
||||||
|
|
||||||
|
- a `console.error` or uncaught exception on any page;
|
||||||
|
- an unhandled promise rejection;
|
||||||
|
- any 4xx or 5xx response that the harness did not itself expect. Expected ones are listed in the
|
||||||
|
harness, for example a display's descriptor before it is paired;
|
||||||
|
- a page that never mounts;
|
||||||
|
- a failed or refused request (CORS, blocked, network error);
|
||||||
|
- a soak leak.
|
||||||
|
|
||||||
|
Each failure line names the surface, the request path (never its query), and the step.
|
||||||
|
|
||||||
|
### The display soak and the no-dead-ends checks are generic
|
||||||
|
|
||||||
|
These checks come from what Signage got wrong, and they apply to any module with a display.
|
||||||
|
|
||||||
|
- **Soak**, two minutes by default. The display stays mounted with no remount and no offline
|
||||||
|
indicator. Its realtime streams stay healthy. Live object URLs stay bounded. JavaScript heap
|
||||||
|
growth, measured over DevTools, stays within a fixed ratio.
|
||||||
|
- **No dead ends.** One at a time, the harness refuses the first request to each module route the
|
||||||
|
display calls at start-up, and the first realtime connection. Within a bounded time and with no
|
||||||
|
reload, the display must make that request again and succeed. A display has nobody to press
|
||||||
|
Retry.
|
||||||
|
|
||||||
|
Module-specific behaviour, such as Signage's heartbeat being recorded, is not guessed by the
|
||||||
|
harness. A module declares it (below).
|
||||||
|
|
||||||
|
### A module is tested against both ends of its compatibility range
|
||||||
|
|
||||||
|
A module's CI runs the harness twice: against the **lowest** and the **newest** published platform
|
||||||
|
version within its manifest's `compatibility.platform` range. Testing every version in between adds
|
||||||
|
cost and finds little. Testing only the newest misses the floor the manifest promises to work on.
|
||||||
|
|
||||||
|
The two versions, and their harness digests, are pinned in the module's repository. The harness
|
||||||
|
refuses to run if:
|
||||||
|
|
||||||
|
- a pinned version lies outside the module's range;
|
||||||
|
- the lowest pinned version is not the range's floor.
|
||||||
|
|
||||||
|
Moving to a newer platform is a deliberate, reviewed pin change, as an instance's is (ADR 0044). It
|
||||||
|
is never a moving tag.
|
||||||
|
|
||||||
|
A frozen install of the module assembled into each of those two platforms needs a lockfile per
|
||||||
|
pinned version. The module keeps them (`ci/lock/<platform version>.yaml`), and the harness provides
|
||||||
|
the one command that regenerates them inside its own pinned Node.js. A stale lockfile fails the
|
||||||
|
frozen install, which is the signal to regenerate it in a reviewed change.
|
||||||
|
|
||||||
|
### Modules declare extra smoke steps, beside their manifest rather than in it
|
||||||
|
|
||||||
|
A module may add steps of its own in `ekklesia.harness.json`, next to `ekklesia.module.json`. Steps
|
||||||
|
are declarative first:
|
||||||
|
|
||||||
|
- open a surface;
|
||||||
|
- wait for a test id;
|
||||||
|
- call one of the module's routes as a named role, expecting a status;
|
||||||
|
- soak for N minutes;
|
||||||
|
- inject a failure into a named route;
|
||||||
|
- check a value in the module's own table, through its own route.
|
||||||
|
|
||||||
|
For what a declaration cannot express, a step may name a driver script in the module's repository.
|
||||||
|
The harness runs that script with the same DevTools client and the same failure rules.
|
||||||
|
|
||||||
|
**This is deliberately not a manifest field.** Manifest versions are strict, hard cutovers that every
|
||||||
|
host validates (ADR 0033). A test step is not a runtime contract, and a host must never read one.
|
||||||
|
Putting steps in the manifest would force a manifest version bump on every module for something
|
||||||
|
that has no meaning outside CI.
|
||||||
|
|
||||||
|
### Later: the harness checks what a module actually touches against what it declares
|
||||||
|
|
||||||
|
In a later phase, the harness records what the module does while it runs:
|
||||||
|
|
||||||
|
- the tables and schemas it reads and writes;
|
||||||
|
- the buckets it uses;
|
||||||
|
- its outbound network connections, with the egress network blocked and every attempt logged.
|
||||||
|
|
||||||
|
It compares them with what the manifest declares (ADR 0016, ADR 0030). A module that touches what it
|
||||||
|
did not declare fails. This is the module-trust check. The egress half needs a manifest declaration
|
||||||
|
of outbound destinations that does not exist yet, and is a separate decision. This ADR fixes only
|
||||||
|
the harness's shape, so that the recording phases can be added without changing how modules call
|
||||||
|
it.
|
||||||
|
|
||||||
|
### The template's workflow stays short, and calls the harness by digest
|
||||||
|
|
||||||
|
`templates/module` ships a workflow that:
|
||||||
|
|
||||||
|
- checks out the module with `persist-credentials: false` and pinned actions;
|
||||||
|
- runs the module's own unit tests, typecheck, and lint;
|
||||||
|
- sweeps stale CI stacks from the runner, then runs the harness, once per pinned platform version,
|
||||||
|
with one `docker run` of the harness image **by digest**, and finally, always, removes this job's
|
||||||
|
own stacks. The module directory is mounted read-only, and the runner's Docker is used exactly
|
||||||
|
as the platform's smoke uses it.
|
||||||
|
|
||||||
|
It references no secret, needs no registry credential (the harness image is public within the
|
||||||
|
organization), and uses no `pull_request_target`. The trusted-code-only rule for self-hosted runners
|
||||||
|
applies to module repositories exactly as it does to the platform (see `docs/development/security.md`).
|
||||||
|
|
||||||
|
**Why `docker run`, and not a reusable workflow or a composite action.** The server runs Gitea
|
||||||
|
1.27.2. (This proposal first assumed 1.24.2; the reasons below hold on the actual version.)
|
||||||
|
|
||||||
|
- Its runner can call a reusable workflow from another repository by cloning that repository, and
|
||||||
|
since 1.26 that includes private repositories (go-gitea/gitea#32562). That support is still thinly
|
||||||
|
documented, and has known problems in practice.
|
||||||
|
- A reusable workflow would make the harness's behaviour depend on how this server and runner
|
||||||
|
version expand it. An image runs the same everywhere it runs.
|
||||||
|
- Composite actions from another repository work through absolute `uses:` URLs. That would still
|
||||||
|
wrap the same `docker run`, adding a second pinned reference and nothing else.
|
||||||
|
|
||||||
|
An image digest is the one reference that is equally immutable, equally reviewable, and works
|
||||||
|
unchanged on any Gitea release and on GitHub Actions. A composite action can be added later as a
|
||||||
|
convenience over the same image, without changing what a module pins.
|
||||||
|
|
||||||
|
### Nothing the harness starts outlives the job that started it
|
||||||
|
|
||||||
|
The harness starts Docker stacks on a shared runner, and a runner can kill a job outright: a
|
||||||
|
cancelled or superseded job (`concurrency` cancels superseded runs on this server), or the runner's
|
||||||
|
own time limit. A script's own cleanup trap does not run then. The platform's CI leaked a whole
|
||||||
|
migration stack that way, and it ran until someone removed it by hand. So the harness follows the
|
||||||
|
platform's rule (`scripts/ci-sweep.sh`, `docs/development/security.md`):
|
||||||
|
|
||||||
|
- everything it starts is named and labeled as a CI stack of its run, attempt, and job key;
|
||||||
|
- it removes its own stacks in a trap;
|
||||||
|
- the template workflow runs the harness's **stale sweep** before the harness starts, and its
|
||||||
|
**job sweep** in a final `if: always()` step, so a killed run's stacks are removed by that run's
|
||||||
|
last step, or failing that by the next job on the runner.
|
||||||
|
|
||||||
|
The sweep ships inside the harness image, so a module never copies it.
|
||||||
|
|
||||||
|
### Publishing follows the instance's release pattern
|
||||||
|
|
||||||
|
The harness image is pushed only by a `publish` job that runs on `v*` tags, after the platform's CI
|
||||||
|
has built and tested that exact image (checked by image ID). That job:
|
||||||
|
|
||||||
|
- uses a write-only package token, the only secret in the platform's workflow;
|
||||||
|
- refuses to overwrite an existing version;
|
||||||
|
- prints the digest.
|
||||||
|
|
||||||
|
Pull requests and branch builds build the harness and run it against the template, but never push.
|
||||||
|
The registry is reached exactly as the instance's publish job reaches it.
|
||||||
|
|
||||||
|
### Adoption
|
||||||
|
|
||||||
|
- **Signage** uses an interim CI job until the first harness is published. That job assembles
|
||||||
|
Signage into a pinned platform commit and runs the platform's display-device smoke scenarios.
|
||||||
|
Signage switches to the harness as soon as one is published.
|
||||||
|
- **Classroom** follows.
|
||||||
|
- **The template** carries the workflow from the first harness release on, so every new module has
|
||||||
|
it from its first commit.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- A module cannot reach a release without having run inside Ekklesia, in a real browser, at both
|
||||||
|
ends of the platform range it claims.
|
||||||
|
- The defects that reached the first production Signage deployment fail a module's CI instead:
|
||||||
|
refused requests, dead ends, leaks, and console errors.
|
||||||
|
- The platform owns one harness, versioned with itself. Modules do not copy deployment scripts that
|
||||||
|
then drift.
|
||||||
|
- Every module's CI needs Docker on its runner, and takes minutes rather than seconds. The harness
|
||||||
|
runs as separate jobs from the unit tests, so a quick failure still reports quickly.
|
||||||
|
- The platform's release process gains one published artifact, one credential, and one more failure
|
||||||
|
mode at release time.
|
||||||
|
- A module now carries a lockfile for each pinned platform version. The harness makes regenerating
|
||||||
|
them one command, and a stale one fails visibly.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **Each module keeps its own copy of the smoke scripts.** Rejected. That is today's situation in
|
||||||
|
embryo, and it drifts.
|
||||||
|
- **A reusable workflow in the platform repository.** Rejected for now, for the reasons above: thin
|
||||||
|
support in practice, and a harness whose behaviour depends on whichever runner and server version
|
||||||
|
happens to expand it.
|
||||||
|
- **Publish the harness as an npm package.** Rejected. The harness needs Docker, a browser, and the
|
||||||
|
platform's deployment files anyway, so a package would ship a fraction of it and assume the rest.
|
||||||
|
- **Test only the newest platform version.** Rejected. A module's range floor would go untested
|
||||||
|
until an installation that has not upgraded meets it.
|
||||||
|
- **Put smoke steps in the manifest.** Rejected. Manifest versions are hard cutovers with runtime
|
||||||
|
meaning, and test steps have neither.
|
||||||
|
- **Run the harness in the platform's CI against every known module.** Rejected. The platform would
|
||||||
|
need every module's source and would fail on modules it does not own. The direction is module
|
||||||
|
tests platform, not the reverse.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- The data-access and egress recording phases, and the manifest declaration egress needs.
|
||||||
|
- A composite action over the harness image.
|
||||||
|
- Running the harness against a module's declared dependencies at their own pinned versions, for
|
||||||
|
contributions hosted by another module.
|
||||||
|
- Visual regression of mounted surfaces.
|
||||||
@@ -53,4 +53,6 @@ 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) — Proposed
|
- [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
|
||||||
|
- [0047: Every module proves itself inside Ekklesia, with one harness the platform publishes](0047-module-ci-harness.md) — Proposed
|
||||||
|
|||||||
+40
-17
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -690,8 +693,10 @@ public Dashboard login must be assessed with the actual edge/proxy controls befo
|
|||||||
|
|
||||||
## Continuous integration boundary
|
## Continuous integration boundary
|
||||||
|
|
||||||
CI runs on self-hosted Gitea Actions runners. The inventoried server is **Gitea 1.24.2**, and that
|
CI runs on self-hosted Gitea Actions runners. The server reports **Gitea 1.27.2** (`GET
|
||||||
version determines which parts of the workflow are real guarantees and which are not.
|
/api/v1/version`, checked 2026-10-06; this document previously said 1.24.2, which was stale). The
|
||||||
|
server's actual version, not the documentation of an earlier one, determines which parts of the
|
||||||
|
workflow are real guarantees and which are not.
|
||||||
|
|
||||||
### The current operating rule: trusted code only
|
### The current operating rule: trusted code only
|
||||||
|
|
||||||
@@ -724,24 +729,53 @@ so a later step in the same job cannot read or reuse it from there.
|
|||||||
It does **not** remove the short-lived `GITEA_TOKEN` from the job environment, and it does **not**
|
It does **not** remove the short-lived `GITEA_TOKEN` from the job environment, and it does **not**
|
||||||
reduce that token's server-side authority. A step that wants the token can still read it.
|
reduce that token's server-side authority. A step that wants the token can still read it.
|
||||||
|
|
||||||
### What Gitea 1.24 ignores — so the branch cannot claim it
|
### What this server honours, and what is not yet verified
|
||||||
|
|
||||||
Per the official comparison for this version
|
Each item separates what Gitea documents for 1.27
|
||||||
(<https://docs.gitea.com/1.24/usage/actions/comparison>), Gitea 1.24's unsupported workflow syntax
|
(<https://docs.gitea.com/1.27/usage/actions/comparison>) from what has been observed on this server:
|
||||||
includes `permissions` and `jobs.<job_id>.permissions`, `concurrency`, and
|
|
||||||
`jobs.<job_id>.timeout-minutes`. On this server those declarations are inert. Therefore:
|
|
||||||
|
|
||||||
- **The workflow's `permissions: contents: read` does not restrict the job token.** This branch
|
- **`concurrency` is honoured.** Gitea supports it since 1.26 (go-gitea/gitea#32751), and on this
|
||||||
cannot claim the `GITEA_TOKEN` is read-only, because nothing in workflow YAML makes it so on
|
server a newer push to a pull request cancels the run it supersedes. Observed: Ekklesia run 133 was
|
||||||
1.24. Job-token permission controls, repository/owner defaults, and server-side
|
cancelled when run 134 started for the same pull request, and run 135 when run 137 did. A
|
||||||
maximum-permission clamping arrive in the 1.26 feature line
|
superseded run's own cleanup is therefore never guaranteed to run, which is one reason for the
|
||||||
(<https://docs.gitea.com/1.26/usage/actions/token-permissions>).
|
stack sweep below.
|
||||||
- **`concurrency` and `timeout-minutes` are not bounded-execution guarantees here.** Cancellation,
|
- **`permissions` is supported by 1.27, with Gitea's own scopes, but is not verified here.** The
|
||||||
timeout, and teardown must be enforced by a trusted provisioner and reaper outside the workflow.
|
server-side token-permission settings are not visible through the API, and nothing has yet proven
|
||||||
|
what the job token can and cannot do on this server. Until that proof exists (requirement 3
|
||||||
|
below), this repository still does not claim the `GITEA_TOKEN` is read-only.
|
||||||
|
- **`timeout-minutes` is no longer listed as unsupported for 1.27, but is not verified here.**
|
||||||
|
Teardown does not rely on it: the stack sweep and a trusted reaper do.
|
||||||
|
- **Still ignored:** `jobs.<job_id>.environment`, and any `runs-on` other than a single label.
|
||||||
|
|
||||||
The declarations are kept deliberately: they are already correct for a supported Gitea release and
|
The declarations stay in the workflow either way: they state the intent, and are correct on GitHub
|
||||||
for GitHub Actions, and keeping them means the upgrade does not require re-deriving the intent. They
|
Actions as well.
|
||||||
are documented as ignored, not removed.
|
|
||||||
|
### Disposable stacks never outlive their job
|
||||||
|
|
||||||
|
Every CI script removes its own Compose stack in an `EXIT` trap, but a trap never runs when the runner
|
||||||
|
kills the job container: a cancelled job, or the runner's own time limit. One such stack ran on the
|
||||||
|
shared runner for two days. So stack cleanup has three layers:
|
||||||
|
|
||||||
|
- **The script's own trap**, for every ordinary failure.
|
||||||
|
- **A final `if: always()` step** in every job that starts stacks: `scripts/ci-sweep.sh --job` removes
|
||||||
|
everything that job started, at any age, even after a failed step.
|
||||||
|
- **A sweep before every such job starts**: `scripts/ci-sweep.sh --stale` removes anything a CI job
|
||||||
|
left more than three hours ago, which catches whatever a killed job could not clean up itself.
|
||||||
|
|
||||||
|
A stack is found by name and label, never by guesswork (`scripts/lib/ci-project.sh`):
|
||||||
|
|
||||||
|
- every CI Compose project is named `ekklesia-ci-<run>-<attempt>-<job key>-…`, and Compose labels
|
||||||
|
its containers, networks, and volumes with that name;
|
||||||
|
- anything a script starts outside Compose carries `org.riverofglorymn.ekklesia.ci.project`;
|
||||||
|
- images also carry the time CI built them, because an image's own creation time can be its base's.
|
||||||
|
|
||||||
|
The job key is per matrix leg, so one leg's cleanup cannot match another's running stack. Nothing
|
||||||
|
outside the `ekklesia-ci-` prefix is ever removed. A job container attached to a stack's network is
|
||||||
|
disconnected, never removed.
|
||||||
|
|
||||||
|
**This is not a substitute for a runner-side reaper.** A stack killed with its job is removed only
|
||||||
|
when another job that sweeps runs on the same runner. Bounded execution and teardown are still the
|
||||||
|
runner host's to enforce, as above.
|
||||||
|
|
||||||
### Runner topology is not verified
|
### Runner topology is not verified
|
||||||
|
|
||||||
@@ -763,16 +797,17 @@ they are not conditions on merging trusted, reviewed work.
|
|||||||
None of them can be satisfied by editing this repository; each requires access to the Gitea server
|
None of them can be satisfied by editing this repository; each requires access to the Gitea server
|
||||||
and runner hosts, and each must be proven there.
|
and runner hosts, and each must be proven there.
|
||||||
|
|
||||||
1. Upgrade Gitea to a reviewed supported release with job-token permission controls — at least the
|
1. ~~Upgrade Gitea to a reviewed supported release with job-token permission controls — at least
|
||||||
1.26 feature line. Select the actual release during infrastructure work, after backup and
|
the 1.26 feature line.~~ **Done:** the server runs 1.27.2. Items 2 and 3 now configure and prove
|
||||||
compatibility review; do not pin a version from this document.
|
what it makes possible.
|
||||||
2. Configure repository and owner defaults, and the server-side maximum permission, so workflow
|
2. Configure repository and owner defaults, and the server-side maximum permission, so workflow
|
||||||
code cannot request more than read-only repository access.
|
code cannot request more than read-only repository access.
|
||||||
3. Prove against the real server that the job's `GITEA_TOKEN` cannot push code, create or update
|
3. Prove against the real server that the job's `GITEA_TOKEN` cannot push code, create or update
|
||||||
releases, packages, issues, or pull requests, change Actions configuration, or reach another
|
releases, packages, issues, or pull requests, change Actions configuration, or reach another
|
||||||
private repository.
|
private repository.
|
||||||
4. Enforce job timeout, cancellation, concurrency, and teardown in the trusted
|
4. Enforce job timeout and teardown in the trusted provisioner/reaper. `concurrency` is honoured
|
||||||
provisioner/reaper rather than relying on the ignored workflow keys above.
|
by the server; `timeout-minutes` is not yet verified, and the in-workflow stack sweep only
|
||||||
|
complements a reaper.
|
||||||
5. Complete a disposable-guest runner isolation proof: one throwaway guest per job, torn down by a
|
5. Complete a disposable-guest runner isolation proof: one throwaway guest per job, torn down by a
|
||||||
trusted reaper outside workflow YAML, holding no deployment credential, registration token, or
|
trusted reaper outside workflow YAML, holding no deployment credential, registration token, or
|
||||||
reusable key.
|
reusable key.
|
||||||
|
|||||||
@@ -159,6 +159,12 @@ also receives a safely named disposable database. Direct host runs use a Docker-
|
|||||||
port bound only to `127.0.0.1`; a containerized Gitea job joins the isolated Compose network and
|
port bound only to `127.0.0.1`; a containerized Gitea job joins the isolated Compose network and
|
||||||
uses the PostgreSQL service name.
|
uses the PostgreSQL service name.
|
||||||
|
|
||||||
|
In CI, a stack whose job was killed before its own cleanup ran is still removed: by that job's final
|
||||||
|
`if: always()` step, or by the stale sweep before a later job starts (`scripts/ci-sweep.sh`, see
|
||||||
|
[Disposable stacks never outlive their job](security.md#disposable-stacks-never-outlive-their-job)).
|
||||||
|
`bash scripts/test-ci-sweep.sh` proves what the sweep removes and what it leaves alone, against real
|
||||||
|
Docker resources.
|
||||||
|
|
||||||
The live suite proves fresh read-only planning, initial and incremental application, genuine
|
The live suite proves fresh read-only planning, initial and incremental application, genuine
|
||||||
no-op behavior, exact applied checksums and history, edited or corrupt history rejection,
|
no-op behavior, exact applied checksums and history, edited or corrupt history rejection,
|
||||||
whole-batch DDL rollback, Drizzle breakpoint execution, real advisory-lock concurrency and
|
whole-batch DDL rollback, Drizzle breakpoint execution, real advisory-lock concurrency and
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ekklesia",
|
"name": "ekklesia",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Open, modular church technology platform",
|
"description": "Open, modular church technology platform",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/contracts",
|
"name": "@ekklesia/contracts",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/design-tokens",
|
"name": "@ekklesia/design-tokens",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/module-sdk",
|
"name": "@ekklesia/module-sdk",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/module-ui-build",
|
"name": "@ekklesia/module-ui-build",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"bin",
|
"bin",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/module-ui-host",
|
"name": "@ekklesia/module-ui-host",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/module-ui-sdk",
|
"name": "@ekklesia/module-ui-sdk",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
|
|||||||
@@ -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 {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/typescript-config",
|
"name": "@ekklesia/typescript-config",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"files": [
|
"files": [
|
||||||
"base.json",
|
"base.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/ui",
|
"name": "@ekklesia/ui",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
|
|||||||
Executable
+206
@@ -0,0 +1,206 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Removes disposable CI stacks from the Docker daemon: containers, networks, volumes, images, and
|
||||||
|
# staging directories on the Docker host.
|
||||||
|
#
|
||||||
|
# bash scripts/ci-sweep.sh --stale [--max-age-seconds N] any CI stack older than N (default 3 h)
|
||||||
|
# bash scripts/ci-sweep.sh --job this job's own stacks, at any age
|
||||||
|
#
|
||||||
|
# Every CI script already removes its own stack in an EXIT trap, but a trap never runs when the runner
|
||||||
|
# kills the job container: a cancelled or superseded job (the server honours `concurrency`), or
|
||||||
|
# the runner's own time limit. So each workflow job that starts stacks ends with `--job` in an
|
||||||
|
# `if: always()` step, and begins with `--stale`, which catches whatever a killed job left before
|
||||||
|
# it could clean up.
|
||||||
|
#
|
||||||
|
# What a stack is, and how it is found, is defined in scripts/lib/ci-project.sh: a Compose project
|
||||||
|
# named with the `ekklesia-ci-` prefix (Compose labels everything it creates with that name), plus
|
||||||
|
# anything labeled EKKLESIA_CI_LABEL. Nothing outside that prefix is ever touched, at any age.
|
||||||
|
#
|
||||||
|
# --stale's threshold is far above any job's run time, so a stack another job on this daemon is using
|
||||||
|
# right now is never young enough to match. --job refuses to run outside CI: locally, its prefix would
|
||||||
|
# match a developer's own stacks.
|
||||||
|
#
|
||||||
|
# Best effort by design: a resource that cannot be removed is reported, and the sweep goes on. Output
|
||||||
|
# names resources only; nothing a stack holds is printed.
|
||||||
|
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
# shellcheck source=scripts/lib/ci-project.sh
|
||||||
|
source "$repository_root/scripts/lib/ci-project.sh"
|
||||||
|
|
||||||
|
compose_label="com.docker.compose.project"
|
||||||
|
mode=""
|
||||||
|
max_age_seconds="${EKKLESIA_CI_SWEEP_MAX_AGE_SECONDS:-10800}"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "usage: ci-sweep.sh --stale [--max-age-seconds N] | --job" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
while (($# > 0)); do
|
||||||
|
case "$1" in
|
||||||
|
--stale | --job)
|
||||||
|
[[ -z "$mode" ]] || usage
|
||||||
|
mode="${1#--}"
|
||||||
|
;;
|
||||||
|
--max-age-seconds)
|
||||||
|
[[ "${2:-}" =~ ^[0-9]+$ ]] || usage
|
||||||
|
max_age_seconds="$2"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*) usage ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
[[ -n "$mode" ]] || usage
|
||||||
|
|
||||||
|
if [[ "$mode" == job ]]; then
|
||||||
|
[[ -n "${GITHUB_RUN_ID:-}" ]] || {
|
||||||
|
echo "ci-sweep: --job runs only in CI (GITHUB_RUN_ID is unset); locally it would match your own stacks." >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
prefix="$(ci_job_prefix)"
|
||||||
|
cutoff=""
|
||||||
|
else
|
||||||
|
prefix="$EKKLESIA_CI_PREFIX"
|
||||||
|
cutoff="$(($(date +%s) - max_age_seconds))"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! docker info >/dev/null 2>&1; then
|
||||||
|
echo "ci-sweep: no Docker daemon reachable; nothing to sweep."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
removed=0
|
||||||
|
warn() {
|
||||||
|
echo "ci-sweep: warning: $*" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
# selected <project> <created>: whether a resource belongs to the sweep.
|
||||||
|
selected() {
|
||||||
|
local project="$1" created="$2" seconds
|
||||||
|
[[ -n "$project" && "$project" == "$prefix"* ]] || return 1
|
||||||
|
[[ -z "$cutoff" ]] && return 0
|
||||||
|
seconds="$(date -d "$created" +%s 2>/dev/null)" || return 1
|
||||||
|
((seconds <= cutoff))
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Containers: Compose's own, and those a script started itself --------------------------------
|
||||||
|
mapfile -t containers < <(
|
||||||
|
{
|
||||||
|
docker ps --all --quiet --no-trunc --filter "label=${compose_label}"
|
||||||
|
docker ps --all --quiet --no-trunc --filter "label=${EKKLESIA_CI_LABEL}"
|
||||||
|
} | sort -u
|
||||||
|
)
|
||||||
|
for id in "${containers[@]}"; do
|
||||||
|
[[ -n "$id" ]] || continue
|
||||||
|
record="$(docker inspect --format \
|
||||||
|
"{{index .Config.Labels \"${compose_label}\"}}|{{index .Config.Labels \"${EKKLESIA_CI_LABEL}\"}}|{{.Created}}|{{.Name}}" \
|
||||||
|
"$id" 2>/dev/null)" || continue
|
||||||
|
IFS='|' read -r compose_project ci_project created name <<<"$record"
|
||||||
|
project="${compose_project:-$ci_project}"
|
||||||
|
selected "$project" "$created" || continue
|
||||||
|
if docker rm --force --volumes "$id" >/dev/null 2>&1; then
|
||||||
|
echo "ci-sweep: removed container ${name#/} (${project})"
|
||||||
|
removed=$((removed + 1))
|
||||||
|
else
|
||||||
|
warn "could not remove container ${name#/}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- Networks: anything still attached (a job container) is disconnected, never removed ---------
|
||||||
|
mapfile -t networks < <(docker network ls --quiet --no-trunc --filter "label=${compose_label}")
|
||||||
|
for id in "${networks[@]}"; do
|
||||||
|
[[ -n "$id" ]] || continue
|
||||||
|
# A network's creation time is a time value, not a string: ask for RFC 3339, which `date` reads.
|
||||||
|
record="$(docker network inspect --format \
|
||||||
|
"{{index .Labels \"${compose_label}\"}}|{{.Created.Format \"2006-01-02T15:04:05Z07:00\"}}|{{.Name}}" \
|
||||||
|
"$id" 2>/dev/null)" || continue
|
||||||
|
IFS='|' read -r project created name <<<"$record"
|
||||||
|
selected "$project" "$created" || continue
|
||||||
|
for endpoint in $(docker network inspect --format '{{range $id, $_ := .Containers}}{{$id}} {{end}}' "$id" 2>/dev/null); do
|
||||||
|
docker network disconnect --force "$id" "$endpoint" >/dev/null 2>&1 ||
|
||||||
|
warn "could not disconnect a container from network ${name}"
|
||||||
|
done
|
||||||
|
if docker network rm "$id" >/dev/null 2>&1; then
|
||||||
|
echo "ci-sweep: removed network ${name} (${project})"
|
||||||
|
removed=$((removed + 1))
|
||||||
|
else
|
||||||
|
warn "could not remove network ${name}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- Volumes ---------------------------------------------------------------------------------------
|
||||||
|
mapfile -t volumes < <(
|
||||||
|
{
|
||||||
|
docker volume ls --quiet --filter "label=${compose_label}"
|
||||||
|
docker volume ls --quiet --filter "label=${EKKLESIA_CI_LABEL}"
|
||||||
|
} | sort -u
|
||||||
|
)
|
||||||
|
for name in "${volumes[@]}"; do
|
||||||
|
[[ -n "$name" ]] || continue
|
||||||
|
record="$(docker volume inspect --format \
|
||||||
|
"{{index .Labels \"${compose_label}\"}}|{{index .Labels \"${EKKLESIA_CI_LABEL}\"}}|{{.CreatedAt}}" \
|
||||||
|
"$name" 2>/dev/null)" || continue
|
||||||
|
IFS='|' read -r compose_project ci_project created <<<"$record"
|
||||||
|
project="${compose_project:-$ci_project}"
|
||||||
|
selected "$project" "$created" || continue
|
||||||
|
if docker volume rm --force "$name" >/dev/null 2>&1; then
|
||||||
|
echo "ci-sweep: removed volume ${name} (${project})"
|
||||||
|
removed=$((removed + 1))
|
||||||
|
else
|
||||||
|
warn "could not remove volume ${name}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- Images built for a stack ------------------------------------------------------------------------
|
||||||
|
mapfile -t images < <(docker image ls --quiet --no-trunc --filter "label=${EKKLESIA_CI_LABEL}" | sort -u)
|
||||||
|
for id in "${images[@]}"; do
|
||||||
|
[[ -n "$id" ]] || continue
|
||||||
|
# Judged by when CI built it (EKKLESIA_CI_CREATED_LABEL), never by the image's own creation time,
|
||||||
|
# which BuildKit may have taken from its base. An image without that label is never swept by age.
|
||||||
|
record="$(docker image inspect --format \
|
||||||
|
"{{index .Config.Labels \"${EKKLESIA_CI_LABEL}\"}}|{{index .Config.Labels \"${EKKLESIA_CI_CREATED_LABEL}\"}}" \
|
||||||
|
"$id" 2>/dev/null)" || continue
|
||||||
|
IFS='|' read -r project built <<<"$record"
|
||||||
|
if [[ -n "$cutoff" ]]; then
|
||||||
|
[[ "$built" =~ ^[0-9]+$ ]] || continue
|
||||||
|
selected "$project" "@${built}" || continue
|
||||||
|
else
|
||||||
|
selected "$project" "" || continue
|
||||||
|
fi
|
||||||
|
if docker image rm --force "$id" >/dev/null 2>&1; then
|
||||||
|
echo "ci-sweep: removed image ${id#sha256:} (${project})"
|
||||||
|
removed=$((removed + 1))
|
||||||
|
else
|
||||||
|
warn "could not remove image ${id#sha256:}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- Staging directories on the Docker host ------------------------------------------------------
|
||||||
|
# A deploy smoke run inside a job container stages its mounted files under the Docker host's /tmp,
|
||||||
|
# named for its project, as root-owned files; they hold that run's disposable secrets and keys. They
|
||||||
|
# are removed with the same pinned image that created them. If that image is not on this daemon, no
|
||||||
|
# smoke has staged anything here.
|
||||||
|
image="$(sed -n 's/^[[:space:]]*image: \(postgres:[^[:space:]]*\)$/\1/p' "$repository_root/deploy/compose.yaml" | head -1)"
|
||||||
|
if [[ -n "$image" ]] && docker image inspect "$image" >/dev/null 2>&1; then
|
||||||
|
age=()
|
||||||
|
# `find -mmin +0` does not match something younger than a minute, so a threshold under a minute
|
||||||
|
# sweeps without an age filter, like the rest of this script.
|
||||||
|
if [[ -n "$cutoff" ]] && ((max_age_seconds >= 60)); then
|
||||||
|
age=(-mmin "+$((max_age_seconds / 60))")
|
||||||
|
fi
|
||||||
|
if staged="$(docker run --rm --entrypoint find -v /tmp:/host-tmp "$image" \
|
||||||
|
/host-tmp -mindepth 1 -maxdepth 1 -type d -name "${prefix}*" "${age[@]}" \
|
||||||
|
-print -exec rm -rf {} + 2>/dev/null)"; then
|
||||||
|
while IFS= read -r directory; do
|
||||||
|
[[ -n "$directory" ]] || continue
|
||||||
|
echo "ci-sweep: removed staging directory /tmp/${directory#/host-tmp/}"
|
||||||
|
removed=$((removed + 1))
|
||||||
|
done <<<"$staged"
|
||||||
|
else
|
||||||
|
warn "could not sweep staging directories on the Docker host"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "ci-sweep: ${mode} sweep removed ${removed} resource(s)."
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# shellcheck shell=bash
|
||||||
|
# How CI names, labels, and later finds the disposable stacks it starts.
|
||||||
|
#
|
||||||
|
# Every Compose project a CI script starts is named
|
||||||
|
#
|
||||||
|
# ekklesia-ci-<run id>-<run attempt>-<job key>-<kind>-<pid><random>
|
||||||
|
#
|
||||||
|
# and Compose labels every container, network, and volume it creates with that name. What a script
|
||||||
|
# creates outside Compose (a browser container, a volume, an image) gets EKKLESIA_CI_LABEL with the
|
||||||
|
# same name. scripts/ci-sweep.sh finds stacks by those labels: this job's own, by the prefix up to
|
||||||
|
# the job key (in a final `if: always()` step), and any CI stack by age (before a job starts).
|
||||||
|
#
|
||||||
|
# The job key is EKKLESIA_CI_JOB_KEY, which the workflow sets per job, and per matrix leg where a job
|
||||||
|
# has one, so that one leg's cleanup cannot match another leg's running stack. Outside CI the
|
||||||
|
# identifiers are "local".
|
||||||
|
|
||||||
|
# shellcheck disable=SC2034 # read by the scripts that source this
|
||||||
|
EKKLESIA_CI_LABEL="org.riverofglorymn.ekklesia.ci.project"
|
||||||
|
# On images, when they were built, in Unix seconds. An image's own creation time is not that: BuildKit
|
||||||
|
# gives an image that only adds metadata its base's time, and a reproducible build a fixed one. A
|
||||||
|
# stale sweep that trusted it could remove an image another job is using right now.
|
||||||
|
EKKLESIA_CI_CREATED_LABEL="org.riverofglorymn.ekklesia.ci.created"
|
||||||
|
EKKLESIA_CI_PREFIX="ekklesia-ci-"
|
||||||
|
|
||||||
|
# ci_name_safe <text>: lower case, and only what a Compose project name may contain.
|
||||||
|
ci_name_safe() {
|
||||||
|
local text="${1//[^a-zA-Z0-9_-]/-}"
|
||||||
|
printf '%s' "${text,,}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ci_job_prefix: every project this job starts begins with exactly this.
|
||||||
|
ci_job_prefix() {
|
||||||
|
ci_name_safe "ekklesia-ci-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-${EKKLESIA_CI_JOB_KEY:-${GITHUB_JOB:-local}}-"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ci_project_name <kind>: a project name unique to this invocation. Not only per run: several runs,
|
||||||
|
# and several jobs of one run, share a runner's Docker daemon, and job containers often share a PID.
|
||||||
|
ci_project_name() {
|
||||||
|
ci_name_safe "ekklesia-ci-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-${EKKLESIA_CI_JOB_KEY:-${GITHUB_JOB:-local}}-${1:?ci_project_name needs a kind}-$$-${RANDOM}${RANDOM}"
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -7,12 +7,22 @@
|
|||||||
// 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.
|
||||||
|
|
||||||
|
import { connect as connectHttp2 } from "node:http2";
|
||||||
|
|
||||||
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 +44,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 +54,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 +76,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 +126,114 @@ 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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One GET over a real HTTP/2 connection. Node's fetch speaks HTTP/1.1, and the failure this guards
|
||||||
|
* against exists only on HTTP/2: a reset stream, which a browser reports as ERR_HTTP2_PROTOCOL_ERROR.
|
||||||
|
*/
|
||||||
|
function http2Get(url, headers = {}) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const session = connectHttp2(url.origin);
|
||||||
|
const finish = (result) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
session.close();
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => finish({ error: "timeout" }), 10_000);
|
||||||
|
session.on("error", () => finish({ error: "session" }));
|
||||||
|
const stream = session.request({
|
||||||
|
":method": "GET",
|
||||||
|
":path": `${url.pathname}${url.search}`,
|
||||||
|
...headers,
|
||||||
|
});
|
||||||
|
let status;
|
||||||
|
let responseHeaders = {};
|
||||||
|
let bytes = 0;
|
||||||
|
stream.on("response", (received) => {
|
||||||
|
status = received[":status"];
|
||||||
|
responseHeaders = received;
|
||||||
|
});
|
||||||
|
stream.on("data", (chunk) => {
|
||||||
|
bytes += chunk.length;
|
||||||
|
});
|
||||||
|
stream.on("error", (error) => finish({ error: error.code ?? "stream" }));
|
||||||
|
stream.on("end", () => finish({ bytes, headers: responseHeaders, status }));
|
||||||
|
stream.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The CORS headers of a response, by name: what a browser would act on. */
|
||||||
|
function corsOf(response) {
|
||||||
|
return [...response.headers.keys()].filter((name) => name.startsWith("access-control-"));
|
||||||
|
}
|
||||||
|
|
||||||
async function storageDeliveryCheck() {
|
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.
|
||||||
@@ -152,6 +280,79 @@ async function storageDeliveryCheck() {
|
|||||||
"a delivered object is Cache-Control: private",
|
"a delivered object is Cache-Control: private",
|
||||||
);
|
);
|
||||||
await provider.delete(bucket, "smoke/object.txt");
|
await provider.delete(bucket, "smoke/object.txt");
|
||||||
|
|
||||||
|
// The same image, twice, over HTTP/2: a browser's second load of a signed URL it has cached is a
|
||||||
|
// conditional request, and must come back as a clean 304, not a reset stream.
|
||||||
|
const png = Buffer.from(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
||||||
|
"base64",
|
||||||
|
);
|
||||||
|
await provider.put(bucket, "smoke/image.png", png, {
|
||||||
|
contentLength: png.byteLength,
|
||||||
|
contentType: "image/png",
|
||||||
|
});
|
||||||
|
const image = new URL(
|
||||||
|
(await provider.createReadUrl(bucket, "smoke/image.png", { expiresInSeconds: 300 })).url,
|
||||||
|
);
|
||||||
|
const first = await http2Get(image);
|
||||||
|
check(
|
||||||
|
first.status === 200 && first.bytes === png.byteLength && Boolean(first.headers?.etag),
|
||||||
|
"an image loads over HTTP/2 through the proxy, with a validator (200, ETag)",
|
||||||
|
);
|
||||||
|
const revalidated = await http2Get(image, { "if-none-match": first.headers?.etag ?? "" });
|
||||||
|
check(
|
||||||
|
revalidated.status === 304 && revalidated.error === undefined,
|
||||||
|
`the same image loaded again over HTTP/2 revalidates cleanly (${revalidated.status ?? revalidated.error})`,
|
||||||
|
);
|
||||||
|
const sinceModified = await http2Get(image, {
|
||||||
|
"if-modified-since": first.headers?.["last-modified"] ?? "",
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
sinceModified.status === 304 && sinceModified.error === undefined,
|
||||||
|
`a revalidation by date over HTTP/2 is a clean 304 too (${sinceModified.status ?? sinceModified.error})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delivery-layer CORS is the deployment's own, set by the proxy: exactly the origins whose pages
|
||||||
|
// read objects with fetch(), GET and HEAD only, and nothing of the object store's own answer.
|
||||||
|
const fromApi = await fetch(image, { headers: { origin: apiOrigin } });
|
||||||
|
check(
|
||||||
|
fromApi.headers.get("access-control-allow-origin") === apiOrigin &&
|
||||||
|
(fromApi.headers.get("vary") ?? "").toLowerCase().includes("origin") &&
|
||||||
|
corsOf(fromApi).length === 1,
|
||||||
|
"a read from the API origin is allowed for exactly that origin, with Vary: Origin and nothing else",
|
||||||
|
);
|
||||||
|
if (split) {
|
||||||
|
const fromDashboard = await fetch(image, { headers: { origin: dashboardOrigin } });
|
||||||
|
check(
|
||||||
|
fromDashboard.headers.get("access-control-allow-origin") === dashboardOrigin,
|
||||||
|
"a read from the Dashboard origin is allowed for exactly that origin",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const fromElsewhere = await fetch(image, { headers: { origin: untrustedOrigin } });
|
||||||
|
check(
|
||||||
|
fromElsewhere.status === 200 && corsOf(fromElsewhere).length === 0,
|
||||||
|
"a read from any other origin gets no CORS header at all",
|
||||||
|
);
|
||||||
|
const preflight = await fetch(image, {
|
||||||
|
headers: { "access-control-request-method": "GET", origin: apiOrigin },
|
||||||
|
method: "OPTIONS",
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
preflight.status === 204 &&
|
||||||
|
preflight.headers.get("access-control-allow-origin") === apiOrigin &&
|
||||||
|
preflight.headers.get("access-control-allow-methods") === "GET, HEAD" &&
|
||||||
|
preflight.headers.get("x-amz-request-id") === null,
|
||||||
|
"the proxy answers a preflight itself (204, GET and HEAD only), never the object store",
|
||||||
|
);
|
||||||
|
const foreignPreflight = await fetch(image, {
|
||||||
|
headers: { "access-control-request-method": "GET", origin: untrustedOrigin },
|
||||||
|
method: "OPTIONS",
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
foreignPreflight.status === 204 && corsOf(foreignPreflight).length === 0,
|
||||||
|
"a preflight from any other origin is answered without permission",
|
||||||
|
);
|
||||||
|
await provider.delete(bucket, "smoke/image.png");
|
||||||
} finally {
|
} finally {
|
||||||
await provider.close();
|
await provider.close();
|
||||||
}
|
}
|
||||||
@@ -184,9 +385,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 +395,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 +419,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",
|
||||||
|
|||||||
@@ -0,0 +1,979 @@
|
|||||||
|
// Real-browser display-device scenarios for scripts/smoke-deploy.sh in the split topology (ADR 0036,
|
||||||
|
// ADR 0045). Runs inside a disposable container on the deployment's `edge` network and drives two
|
||||||
|
// headless Chromiums over the Chrome DevTools Protocol, with Node.js's own fetch and WebSocket and no
|
||||||
|
// other dependency: the smoke run's browser, where the owner signs in, and a "kiosk" browser whose
|
||||||
|
// profile directory is a Docker volume that outlives the kiosk container.
|
||||||
|
//
|
||||||
|
// node deploy-smoke-display.mjs <step>
|
||||||
|
//
|
||||||
|
// pair seed content, pair the kiosk, then close its browser cleanly
|
||||||
|
// restart same profile, new browser process: still paired and live?
|
||||||
|
// two-pages two pages, one device credential: does either flap?
|
||||||
|
// connectivity-blip the browser reports offline then online: is a live display remounted?
|
||||||
|
// session-blip Signage's first viewer-session request fails once: does the display recover?
|
||||||
|
// stream-failure the first realtime connection is refused: is realtime re-established?
|
||||||
|
// caching-probe can a display on the API origin store storage-origin images in Cache Storage?
|
||||||
|
// stream-recycle one page past the five-minute realtime recycle, with a timestamped timeline
|
||||||
|
// revoke revoke the paired kiosk while its display is open
|
||||||
|
//
|
||||||
|
// Every DevTools command and every page-side request has its own deadline, and a step has an overall
|
||||||
|
// budget. A call that never answers fails the step with a line naming that exact call, because a call
|
||||||
|
// that can hang the driver may be a request that can hang a real display.
|
||||||
|
//
|
||||||
|
// Output is one line per check or observation; nothing printed carries a credential, cookie value,
|
||||||
|
// signed URL, query string, or response body.
|
||||||
|
|
||||||
|
function required(name) {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`${name} is required.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A positive whole number of milliseconds from the environment, or the default. */
|
||||||
|
function milliseconds(name, fallback) {
|
||||||
|
const raw = process.env[name];
|
||||||
|
const value = raw === undefined || raw === "" ? fallback : Number(raw);
|
||||||
|
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||||
|
throw new Error(`${name} must be a positive whole number of milliseconds.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = process.argv[2];
|
||||||
|
const apiOrigin = `https://${required("SMOKE_HOSTNAME")}`;
|
||||||
|
const dashboardOrigin = `https://${required("SMOKE_DASHBOARD_HOSTNAME")}`;
|
||||||
|
const ownerAddress = required("SMOKE_CDP_ADDRESS");
|
||||||
|
const kioskAddress = required("SMOKE_KIOSK_CDP_ADDRESS");
|
||||||
|
const watchMs = milliseconds("SMOKE_WATCH_MS", 120_000);
|
||||||
|
const recycleWatchMs = milliseconds("SMOKE_RECYCLE_WATCH_MS", 330_000);
|
||||||
|
const stepBudgetMs = milliseconds("SMOKE_STEP_BUDGET_MS", 180_000);
|
||||||
|
/** How long one DevTools command may go unanswered. */
|
||||||
|
const callTimeoutMs = milliseconds("SMOKE_CALL_TIMEOUT_MS", 10_000);
|
||||||
|
/** How long one page-side HTTP request may go unanswered; shorter than the call deadline, so a hung
|
||||||
|
* request is reported as the request rather than as the evaluation that awaited it. */
|
||||||
|
const requestTimeoutMs = Math.max(1_000, callTimeoutMs - 2_000);
|
||||||
|
const waitTimeoutMs = 30_000;
|
||||||
|
const deviceCookie = "ekklesia_device_session";
|
||||||
|
/** Far above what three cached slides need, which is one object URL each. */
|
||||||
|
const MAX_LIVE_OBJECT_URLS = 50;
|
||||||
|
const stepStartedAt = Date.now();
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
function check(condition, description) {
|
||||||
|
console.log(`${condition ? "ok " : "FAIL"} ${description}`);
|
||||||
|
if (!condition) {
|
||||||
|
failures += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function note(description) {
|
||||||
|
console.log(` ${description}`);
|
||||||
|
}
|
||||||
|
const clock = (ms = Date.now()) => new Date(ms).toISOString().slice(11, 19);
|
||||||
|
const since = (from) => `+${Math.round((Date.now() - from) / 1000)}s`;
|
||||||
|
function progress(description) {
|
||||||
|
console.log(`···· ${clock()} ${since(stepStartedAt)} ${description}`);
|
||||||
|
}
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
/** A URL's path only: a signed delivery URL's query is a credential. */
|
||||||
|
const pathOf = (url) => {
|
||||||
|
try {
|
||||||
|
return new URL(url).pathname;
|
||||||
|
} catch {
|
||||||
|
return "(unparseable)";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Hang guards ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class HangError extends Error {
|
||||||
|
constructor(label, ms) {
|
||||||
|
super(`HANG ${label}: no answer within ${ms / 1000}s`);
|
||||||
|
this.name = "HangError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything awaited right now, by name, so a step that overruns its budget says what it was
|
||||||
|
* waiting for. */
|
||||||
|
const inFlight = new Map();
|
||||||
|
let nextOperation = 1;
|
||||||
|
|
||||||
|
function guard(label, promise, ms = callTimeoutMs) {
|
||||||
|
const id = nextOperation;
|
||||||
|
nextOperation += 1;
|
||||||
|
inFlight.set(id, { at: Date.now(), label });
|
||||||
|
let timer;
|
||||||
|
return Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise((_, reject) => {
|
||||||
|
timer = setTimeout(() => reject(new HangError(label, ms)), ms);
|
||||||
|
}),
|
||||||
|
]).finally(() => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
inFlight.delete(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeInFlight() {
|
||||||
|
const pending = [...inFlight.values()].map(
|
||||||
|
(operation) => `${operation.label} (${Math.round((Date.now() - operation.at) / 1000)}s)`,
|
||||||
|
);
|
||||||
|
return pending.length === 0 ? "nothing — the step itself overran" : pending.join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log(
|
||||||
|
`FAIL ${mode}: step budget of ${stepBudgetMs / 1000}s exceeded; in flight: ${describeInFlight()}`,
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}, stepBudgetMs).unref();
|
||||||
|
|
||||||
|
// --- DevTools ------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** The same minimal DevTools client as deploy-smoke-browser.mjs, with a deadline on every command. */
|
||||||
|
async function connect(devtools, name) {
|
||||||
|
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 HangError(`the ${name} browser's DevTools endpoint`, 30_000);
|
||||||
|
}
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const socketUrl = version.webSocketDebuggerUrl.replace(/^ws:\/\/[^/]+/u, `ws://${devtools}`);
|
||||||
|
const socket = new WebSocket(socketUrl);
|
||||||
|
await guard(
|
||||||
|
`the ${name} browser's DevTools socket`,
|
||||||
|
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, where = name) {
|
||||||
|
const id = nextId;
|
||||||
|
nextId += 1;
|
||||||
|
socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
|
||||||
|
return guard(
|
||||||
|
`DevTools ${method} on ${where}`,
|
||||||
|
new Promise((resolve, reject) => pending.set(id, { method, reject, resolve })),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Finds a test id anywhere in the document, including inside module artifacts' open shadow roots
|
||||||
|
* (ADR 0040). */
|
||||||
|
const deepFind = `((testId) => {
|
||||||
|
const walk = (root) => {
|
||||||
|
const found = root.querySelector('[data-testid="' + testId + '"]');
|
||||||
|
if (found) return found;
|
||||||
|
for (const element of root.querySelectorAll("*")) {
|
||||||
|
if (element.shadowRoot) {
|
||||||
|
const inner = walk(element.shadowRoot);
|
||||||
|
if (inner) return inner;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
return walk(document);
|
||||||
|
})`;
|
||||||
|
const find = (testId) => `${deepFind}(${JSON.stringify(testId)})`;
|
||||||
|
|
||||||
|
/** Installed before any page script: the browser's own connectivity events and the display service
|
||||||
|
* worker's fallback reports, which are what the display host remounts on (ADR 0043). */
|
||||||
|
const instrumentation = `(() => {
|
||||||
|
const events = [];
|
||||||
|
// Object URLs created and revoked in this document. Each live one pins its blob in the browser's
|
||||||
|
// blob storage until it is revoked or the document goes away.
|
||||||
|
const blobs = { created: 0, revoked: 0 };
|
||||||
|
Object.defineProperty(window, "__displayRepro", { value: { blobs, events } });
|
||||||
|
const create = URL.createObjectURL.bind(URL);
|
||||||
|
const revoke = URL.revokeObjectURL.bind(URL);
|
||||||
|
URL.createObjectURL = (object) => { blobs.created += 1; return create(object); };
|
||||||
|
URL.revokeObjectURL = (url) => { blobs.revoked += 1; return revoke(url); };
|
||||||
|
const record = (kind) => events.push({ at: Date.now(), kind });
|
||||||
|
addEventListener("online", () => record("online"));
|
||||||
|
addEventListener("offline", () => record("offline"));
|
||||||
|
navigator.serviceWorker?.addEventListener("message", (event) => {
|
||||||
|
const status = event.data && event.data.ekklesiaDisplayOffline;
|
||||||
|
if (status) record(status.usingFallback ? "sw-fallback:" + (status.kind || "") : "sw-network");
|
||||||
|
});
|
||||||
|
})()`;
|
||||||
|
|
||||||
|
/** One page target with its own flat session, and what it does on the network. */
|
||||||
|
async function openPage(cdp, url, label) {
|
||||||
|
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, `page "${label}"`);
|
||||||
|
const requests = new Map();
|
||||||
|
const page = {
|
||||||
|
label,
|
||||||
|
/** Every realtime connection: when it opened, its status, and when and how it ended. */
|
||||||
|
streams: [],
|
||||||
|
/** Signage viewer-session requests: one per module mount. */
|
||||||
|
sessionRequests: [],
|
||||||
|
/** Heartbeat statuses, by status code. */
|
||||||
|
heartbeats: new Map(),
|
||||||
|
/** Channel-state fetches and how they went. */
|
||||||
|
stateFetches: [],
|
||||||
|
/** Non-2xx responses and failed requests, by path only. */
|
||||||
|
problems: [],
|
||||||
|
};
|
||||||
|
cdp.on((message) => {
|
||||||
|
if (message.sessionId !== sessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const params = message.params ?? {};
|
||||||
|
if (message.method === "Network.requestWillBeSent") {
|
||||||
|
const path = pathOf(params.request.url);
|
||||||
|
const entry = { at: Date.now(), path, type: params.type };
|
||||||
|
requests.set(params.requestId, entry);
|
||||||
|
if (params.type === "EventSource") {
|
||||||
|
entry.stream = {
|
||||||
|
openedAt: Date.now(),
|
||||||
|
path,
|
||||||
|
status: undefined,
|
||||||
|
endedAt: undefined,
|
||||||
|
how: undefined,
|
||||||
|
};
|
||||||
|
page.streams.push(entry.stream);
|
||||||
|
} else if (path.endsWith("/viewers/session")) {
|
||||||
|
page.sessionRequests.push(Date.now());
|
||||||
|
} else if (path.endsWith("/display-state") && !path.includes("module-realtime")) {
|
||||||
|
entry.state = { at: Date.now(), outcome: "pending" };
|
||||||
|
page.stateFetches.push(entry.state);
|
||||||
|
}
|
||||||
|
} else if (message.method === "Network.responseReceived") {
|
||||||
|
const entry = requests.get(params.requestId);
|
||||||
|
const { status } = params.response;
|
||||||
|
if (entry?.stream) {
|
||||||
|
entry.stream.status = status;
|
||||||
|
}
|
||||||
|
if (entry?.state) {
|
||||||
|
entry.state.outcome = String(status);
|
||||||
|
}
|
||||||
|
if (entry?.path.endsWith("/heartbeat")) {
|
||||||
|
page.heartbeats.set(status, (page.heartbeats.get(status) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
if (status >= 400 || status === 0) {
|
||||||
|
page.problems.push(`${clock()} ${status} ${pathOf(params.response.url)}`);
|
||||||
|
}
|
||||||
|
} else if (message.method === "Network.loadingFinished") {
|
||||||
|
const entry = requests.get(params.requestId);
|
||||||
|
if (entry?.stream) {
|
||||||
|
entry.stream.endedAt = Date.now();
|
||||||
|
entry.stream.how = "finished";
|
||||||
|
}
|
||||||
|
} else if (message.method === "Network.loadingFailed") {
|
||||||
|
const entry = requests.get(params.requestId);
|
||||||
|
if (entry?.stream) {
|
||||||
|
entry.stream.endedAt = Date.now();
|
||||||
|
entry.stream.how = params.canceled ? "canceled" : `failed ${params.errorText}`;
|
||||||
|
}
|
||||||
|
if (entry?.state) {
|
||||||
|
entry.state.outcome = params.canceled ? "canceled" : `failed ${params.errorText}`;
|
||||||
|
}
|
||||||
|
if (entry && !params.canceled) {
|
||||||
|
page.problems.push(`${clock()} failed ${params.errorText} ${entry.path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await send("Network.enable");
|
||||||
|
await send("Page.enable");
|
||||||
|
await send("Runtime.enable");
|
||||||
|
await send("Page.addScriptToEvaluateOnNewDocument", { source: instrumentation });
|
||||||
|
await send("Emulation.setDeviceMetricsOverride", {
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
height: 1080,
|
||||||
|
mobile: false,
|
||||||
|
width: 1920,
|
||||||
|
});
|
||||||
|
page.send = send;
|
||||||
|
/** Runs an expression in the page. `what` names it if it never comes back. */
|
||||||
|
page.evaluate = async (expression, what = "an evaluation") => {
|
||||||
|
const result = await guard(
|
||||||
|
`page "${label}": ${what}`,
|
||||||
|
send("Runtime.evaluate", { awaitPromise: true, expression, returnByValue: true }),
|
||||||
|
);
|
||||||
|
if (result.exceptionDetails) {
|
||||||
|
throw new Error(`A page expression threw (${what}).`);
|
||||||
|
}
|
||||||
|
return result.result.value;
|
||||||
|
};
|
||||||
|
page.waitFor = async (expression, description, ms = waitTimeoutMs) => {
|
||||||
|
const deadline = Date.now() + ms;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (
|
||||||
|
await page
|
||||||
|
.evaluate(`Boolean(${expression})`, `waiting for ${description}`)
|
||||||
|
.catch((error) => {
|
||||||
|
if (error instanceof HangError) throw error;
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await sleep(250);
|
||||||
|
}
|
||||||
|
note(`timed out waiting for ${description}`);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
/** A credentialed request from this page, bounded in the page itself, so a request that never
|
||||||
|
* answers is reported as that request. */
|
||||||
|
page.request = async (method, url, body) => {
|
||||||
|
const result = await page.evaluate(
|
||||||
|
`fetch(${JSON.stringify(url)}, {
|
||||||
|
credentials: "include",
|
||||||
|
method: ${JSON.stringify(method)},
|
||||||
|
signal: AbortSignal.timeout(${requestTimeoutMs}),
|
||||||
|
${body === undefined ? "" : `headers: { "content-type": "application/json" }, body: ${JSON.stringify(JSON.stringify(body))},`}
|
||||||
|
}).then(
|
||||||
|
async (response) => ({ status: response.status, body: await response.json().catch(() => null) }),
|
||||||
|
(error) => ({ status: error && error.name === "TimeoutError" ? "timeout" : "network-error" }),
|
||||||
|
)`,
|
||||||
|
`${method} ${pathOf(url)}`,
|
||||||
|
);
|
||||||
|
if (result.status === "timeout") {
|
||||||
|
throw new HangError(`HTTP ${method} ${pathOf(url)} from page "${label}"`, requestTimeoutMs);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
page.close = () => cdp.send("Target.closeTarget", { targetId });
|
||||||
|
page.navigate = (to) => send("Page.navigate", { url: to });
|
||||||
|
if (url) {
|
||||||
|
await page.navigate(url);
|
||||||
|
}
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The owner, signed in on the Dashboard hostname of the smoke run's own browser. */
|
||||||
|
async function ownerPage() {
|
||||||
|
const cdp = await connect(ownerAddress, "owner");
|
||||||
|
const page = await openPage(cdp, `${dashboardOrigin}/`, "owner");
|
||||||
|
await page.waitFor(`${find("login-email")} || ${find("host-shell")}`, "the Dashboard");
|
||||||
|
if (!(await page.evaluate(`Boolean(${find("host-shell")})`, "checking the session"))) {
|
||||||
|
await page.evaluate(
|
||||||
|
`(() => {
|
||||||
|
const setValue = (element, value) => {
|
||||||
|
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(element, value);
|
||||||
|
element.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
};
|
||||||
|
setValue(${find("login-email")}, ${JSON.stringify(required("SMOKE_OWNER_EMAIL"))});
|
||||||
|
setValue(${find("login-password")}, ${JSON.stringify(required("SMOKE_OWNER_PASSWORD"))});
|
||||||
|
${find("login-email")}.form.requestSubmit();
|
||||||
|
})()`,
|
||||||
|
"submitting the sign-in form",
|
||||||
|
);
|
||||||
|
check(await page.waitFor(find("host-shell"), "the signed-in Dashboard"), "the owner signs in");
|
||||||
|
}
|
||||||
|
page.api = (method, path, body) => page.request(method, `${apiOrigin}${path}`, body);
|
||||||
|
return { cdp, page };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes the owner's tab as well as the connection: the owner browser is shared by every step, and a
|
||||||
|
* tab left behind by each one accumulates until the Dashboard no longer renders in time. */
|
||||||
|
async function finishOwner(owner) {
|
||||||
|
await owner.page.close().catch(() => undefined);
|
||||||
|
owner.cdp.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function displayModuleId(owner) {
|
||||||
|
const navigation = await owner.api("GET", "/api/v1/dashboard/navigation");
|
||||||
|
const entries = [
|
||||||
|
...navigation.body.ungrouped,
|
||||||
|
...navigation.body.groups.flatMap((group) => group.modules),
|
||||||
|
];
|
||||||
|
const withDisplay = entries.filter((entry) => entry.module.display !== null);
|
||||||
|
const signage = withDisplay.find((entry) => entry.module.id === "signage");
|
||||||
|
return (signage ?? withDisplay[0])?.module.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Three slides of plain colour, on a five-second rotation, so a display has real content to persist
|
||||||
|
* and continue from, as the production screen did. Signage only. */
|
||||||
|
async function seedSignage(owner) {
|
||||||
|
const base = "/api/v1/modules/signage";
|
||||||
|
const images = [];
|
||||||
|
for (const [index, colour] of ["#b03030", "#3060b0", "#30a050"].entries()) {
|
||||||
|
const uploaded = await owner.evaluate(
|
||||||
|
`(async () => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = 640;
|
||||||
|
canvas.height = 360;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
context.fillStyle = ${JSON.stringify(colour)};
|
||||||
|
context.fillRect(0, 0, 640, 360);
|
||||||
|
const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
|
||||||
|
const response = await fetch(${JSON.stringify(`${apiOrigin}${base}/images?filename=slide-${index}.png&displayName=Slide%20${index}`)}, {
|
||||||
|
body: blob, credentials: "include", headers: { "content-type": "image/png" }, method: "POST",
|
||||||
|
signal: AbortSignal.timeout(${requestTimeoutMs}),
|
||||||
|
});
|
||||||
|
return { status: response.status, body: await response.json().catch(() => null) };
|
||||||
|
})()`,
|
||||||
|
`uploading slide ${index}`,
|
||||||
|
);
|
||||||
|
images.push(uploaded.body?.image?.id);
|
||||||
|
}
|
||||||
|
check(
|
||||||
|
images.every((id) => typeof id === "string"),
|
||||||
|
"three slides are uploaded to Signage",
|
||||||
|
);
|
||||||
|
const timeline = await owner.api("POST", `${base}/timelines`, { name: "Display repro" });
|
||||||
|
const timelineId = timeline.body?.timeline?.id ?? timeline.body?.id;
|
||||||
|
for (const itemId of images) {
|
||||||
|
await owner.api("POST", `${base}/timelines/${timelineId}/slots`, {
|
||||||
|
durationOverrideMs: 5_000,
|
||||||
|
itemId,
|
||||||
|
providerId: "signage.native-images",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const groups = await owner.api("GET", `${base}/channel-groups`);
|
||||||
|
const groupId = groups.body?.groups?.[0]?.id;
|
||||||
|
const activated = await owner.api("POST", `${base}/playback/${groupId}/activate`, {
|
||||||
|
timelineId,
|
||||||
|
});
|
||||||
|
check(activated.status === 200, "a three-slide timeline is playing on the default channel group");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signageViewers(owner) {
|
||||||
|
const listed = await owner.api("GET", "/api/v1/modules/signage/viewers");
|
||||||
|
return listed.body?.viewers ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function coreDevices(owner) {
|
||||||
|
const listed = await owner.api("GET", "/api/v1/devices");
|
||||||
|
return listed.body?.devices ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The display's own view of itself: which host state is showing, and Signage's offline flag. */
|
||||||
|
function displayState(page) {
|
||||||
|
return page.evaluate(
|
||||||
|
`(() => {
|
||||||
|
const stage = ${find("signage-stage")};
|
||||||
|
return {
|
||||||
|
pairing: Boolean(${find("display-pairing")}),
|
||||||
|
hostLoading: Boolean(${find("display-host-loading")}) || Boolean(${find("display-loading")}),
|
||||||
|
hostError: Boolean(${find("display-host-error")}) || Boolean(${find("display-error")}),
|
||||||
|
hostOffline: Boolean(${find("display-offline")}),
|
||||||
|
mounted: (${find("display-module-container")}?.childElementCount ?? 0) > 0,
|
||||||
|
signageOffline: stage ? stage.dataset.offline === "true" : null,
|
||||||
|
};
|
||||||
|
})()`,
|
||||||
|
"reading the display state",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function blobCounts(page) {
|
||||||
|
return page.evaluate(
|
||||||
|
"window.__displayRepro ? window.__displayRepro.blobs : { created: 0, revoked: 0 }",
|
||||||
|
"reading object-URL counts",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectivityEvents(page) {
|
||||||
|
return page.evaluate(
|
||||||
|
"window.__displayRepro ? window.__displayRepro.events : []",
|
||||||
|
"reading connectivity events",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function describeDeviceCookie(cdp) {
|
||||||
|
const { cookies } = await cdp.send("Storage.getCookies");
|
||||||
|
const cookie = cookies.find((candidate) => candidate.name === deviceCookie);
|
||||||
|
if (!cookie) {
|
||||||
|
note(`${deviceCookie}: absent from the profile`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const days = cookie.session
|
||||||
|
? "session"
|
||||||
|
: `${((cookie.expires * 1000 - Date.now()) / 86_400_000).toFixed(1)} days`;
|
||||||
|
note(
|
||||||
|
`${deviceCookie}: domain=${cookie.domain} session=${cookie.session} lifetime=${days} httpOnly=${cookie.httpOnly} secure=${cookie.secure} sameSite=${cookie.sameSite}`,
|
||||||
|
);
|
||||||
|
return cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
const liveExpression = `(() => {
|
||||||
|
const stage = ${find("signage-stage")};
|
||||||
|
const mounted = (${find("display-module-container")}?.childElementCount ?? 0) > 0;
|
||||||
|
return mounted && !${find("display-pairing")} && (stage === null || stage.dataset.offline === "false");
|
||||||
|
})()`;
|
||||||
|
|
||||||
|
function waitLive(page, description, ms = 45_000) {
|
||||||
|
return page.waitFor(liveExpression, description, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A page's network story, one line each, by path only. */
|
||||||
|
function summarize(page, from) {
|
||||||
|
const at = (ms) => (ms === undefined ? "-" : `${clock(ms)} (${Math.round((ms - from) / 1000)}s)`);
|
||||||
|
note(`${page.label}: viewer sessions opened ${page.sessionRequests.length} (= module mounts)`);
|
||||||
|
note(
|
||||||
|
`${page.label}: heartbeats ${[...page.heartbeats].map(([status, count]) => `${status}×${count}`).join(" ") || "none"}`,
|
||||||
|
);
|
||||||
|
const states = page.stateFetches.reduce((tally, fetch) => {
|
||||||
|
tally[fetch.outcome] = (tally[fetch.outcome] ?? 0) + 1;
|
||||||
|
return tally;
|
||||||
|
}, {});
|
||||||
|
note(`${page.label}: channel-state fetches ${JSON.stringify(states)}`);
|
||||||
|
for (const stream of page.streams) {
|
||||||
|
const lived =
|
||||||
|
stream.endedAt === undefined
|
||||||
|
? "open"
|
||||||
|
: `${Math.round((stream.endedAt - stream.openedAt) / 1000)}s`;
|
||||||
|
note(
|
||||||
|
`${page.label}: stream opened ${at(stream.openedAt)} status ${stream.status ?? "-"} ended ${at(stream.endedAt)} ${stream.how ?? ""} lived ${lived}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
note(`${page.label}: problems ${page.problems.slice(-12).join(" | ") || "none"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Samples display state on an interval for a fixed time, printing progress, and returns a tally. */
|
||||||
|
async function watch(pages, ms, { onSample, viewerOwner } = {}) {
|
||||||
|
const from = Date.now();
|
||||||
|
const tally = new Map(
|
||||||
|
pages.map((page) => [
|
||||||
|
page.label,
|
||||||
|
{ loading: 0, offline: 0, pairing: 0, samples: 0, transitions: [] },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const last = new Map();
|
||||||
|
let nextProgress = from + 15_000;
|
||||||
|
const roster = [];
|
||||||
|
while (Date.now() - from < ms) {
|
||||||
|
for (const page of pages) {
|
||||||
|
const state = await displayState(page);
|
||||||
|
const t = tally.get(page.label);
|
||||||
|
t.samples += 1;
|
||||||
|
if (state.signageOffline || state.hostOffline) t.offline += 1;
|
||||||
|
if (state.pairing) t.pairing += 1;
|
||||||
|
if (state.hostLoading) t.loading += 1;
|
||||||
|
const key = JSON.stringify(state);
|
||||||
|
if (last.get(page.label) !== key) {
|
||||||
|
last.set(page.label, key);
|
||||||
|
t.transitions.push(`${clock()} ${since(from)} ${key}`);
|
||||||
|
}
|
||||||
|
if (onSample && (await onSample(page, state))) {
|
||||||
|
return { from, roster, stopped: true, tally };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Date.now() >= nextProgress) {
|
||||||
|
nextProgress = Date.now() + 15_000;
|
||||||
|
if (viewerOwner) {
|
||||||
|
const viewers = await signageViewers(viewerOwner);
|
||||||
|
roster.push(
|
||||||
|
`${since(from)} ${viewers.map((v) => `${v.name}:${v.online ? "Online" : "Offline"}`).join(",")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const lines = [];
|
||||||
|
for (const page of pages) {
|
||||||
|
const t = tally.get(page.label);
|
||||||
|
const blobs = await blobCounts(page);
|
||||||
|
lines.push(
|
||||||
|
`${page.label}: ${t.samples} samples, offline ${t.offline}, mounts ${page.sessionRequests.length}, streams ${page.streams.length}, live object URLs ${blobs.created - blobs.revoked}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
progress(lines.join(" · "));
|
||||||
|
}
|
||||||
|
await sleep(2_000);
|
||||||
|
}
|
||||||
|
return { from, roster, stopped: false, tally };
|
||||||
|
}
|
||||||
|
|
||||||
|
function printTally(result) {
|
||||||
|
for (const [label, t] of result.tally) {
|
||||||
|
note(
|
||||||
|
`${label}: ${t.samples} samples, offline in ${t.offline}, pairing in ${t.pairing}, loading in ${t.loading}`,
|
||||||
|
);
|
||||||
|
for (const transition of t.transitions.slice(0, 30)) {
|
||||||
|
note(` ${label} ${transition}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.roster.length > 0) {
|
||||||
|
note(`roster over time: ${result.roster.join(" ; ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Steps ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
if (mode === "pair") {
|
||||||
|
const owner = await ownerPage();
|
||||||
|
const moduleId = await displayModuleId(owner.page);
|
||||||
|
check(moduleId !== undefined, `a module publishes a display (${moduleId})`);
|
||||||
|
if (moduleId === "signage") {
|
||||||
|
await seedSignage(owner.page);
|
||||||
|
}
|
||||||
|
const kiosk = await connect(kioskAddress, "kiosk");
|
||||||
|
const display = await openPage(kiosk, `${apiOrigin}/display/${moduleId}`, "kiosk");
|
||||||
|
check(
|
||||||
|
await display.waitFor(find("display-pairing-code"), "a pairing code"),
|
||||||
|
"an unpaired kiosk shows a pairing code",
|
||||||
|
);
|
||||||
|
const code = (
|
||||||
|
await display.evaluate(`${find("display-pairing-code")}.textContent`, "reading the code")
|
||||||
|
).replace(/\D/gu, "");
|
||||||
|
const approved = await owner.page.api("POST", "/api/v1/device-pairing/approvals", {
|
||||||
|
code,
|
||||||
|
deviceName: "Repro kiosk",
|
||||||
|
});
|
||||||
|
check(approved.status === 200, "the owner approves the code");
|
||||||
|
check(
|
||||||
|
await waitLive(display, "the paired display to go live"),
|
||||||
|
"the paired kiosk mounts the display and is live",
|
||||||
|
);
|
||||||
|
await describeDeviceCookie(kiosk);
|
||||||
|
progress("letting heartbeats, a persisted snapshot, and media prefetch happen (20s)");
|
||||||
|
await sleep(20_000);
|
||||||
|
for (const viewer of await signageViewers(owner.page)) {
|
||||||
|
note(
|
||||||
|
`signage viewer "${viewer.name}" online=${viewer.online} offlineReady=${viewer.offlineReady} cachedAssets=${viewer.cachedAssetCount} error=${viewer.lastError?.code ?? "none"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
summarize(display, stepStartedAt);
|
||||||
|
// A clean exit: the browser shuts down normally, exactly as a systemd stop of a well-behaved kiosk.
|
||||||
|
await kiosk.send("Browser.close").catch(() => undefined);
|
||||||
|
kiosk.close();
|
||||||
|
await finishOwner(owner);
|
||||||
|
} else if (mode === "restart") {
|
||||||
|
const owner = await ownerPage();
|
||||||
|
const moduleId = await displayModuleId(owner.page);
|
||||||
|
const kiosk = await connect(kioskAddress, "kiosk");
|
||||||
|
const cookie = await describeDeviceCookie(kiosk);
|
||||||
|
check(cookie !== undefined, "the device credential survived a clean browser exit");
|
||||||
|
const display = await openPage(kiosk, `${apiOrigin}/display/${moduleId}`, "kiosk");
|
||||||
|
const live = await waitLive(display, "the restarted display to go live");
|
||||||
|
const state = await displayState(display);
|
||||||
|
check(
|
||||||
|
live && !state.pairing,
|
||||||
|
`after a clean restart the kiosk is still paired and live (${JSON.stringify(state)})`,
|
||||||
|
);
|
||||||
|
summarize(display, stepStartedAt);
|
||||||
|
await display.close();
|
||||||
|
kiosk.close();
|
||||||
|
await finishOwner(owner);
|
||||||
|
} else if (mode === "two-pages") {
|
||||||
|
const owner = await ownerPage();
|
||||||
|
const moduleId = await displayModuleId(owner.page);
|
||||||
|
const kiosk = await connect(kioskAddress, "kiosk");
|
||||||
|
const url = `${apiOrigin}/display/${moduleId}`;
|
||||||
|
const first = await openPage(kiosk, url, "first");
|
||||||
|
check(await waitLive(first, "the first page"), "the first page is live");
|
||||||
|
const second = await openPage(kiosk, url, "second");
|
||||||
|
check(await waitLive(second, "the second page"), "the second page is live");
|
||||||
|
const result = await watch([first, second], watchMs, { viewerOwner: owner.page });
|
||||||
|
printTally(result);
|
||||||
|
for (const page of [first, second]) {
|
||||||
|
summarize(page, result.from);
|
||||||
|
note(`${page.label}: connectivity events ${JSON.stringify(await connectivityEvents(page))}`);
|
||||||
|
}
|
||||||
|
check(
|
||||||
|
[...result.tally.values()].every((t) => t.offline === 0),
|
||||||
|
`neither page showed an offline state while both were open (${watchMs / 1000}s)`,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
[first, second].every((page) => page.sessionRequests.length === 1),
|
||||||
|
"neither page was remounted while both were open",
|
||||||
|
);
|
||||||
|
await first.close();
|
||||||
|
await second.close();
|
||||||
|
kiosk.close();
|
||||||
|
await finishOwner(owner);
|
||||||
|
} else if (mode === "connectivity-blip") {
|
||||||
|
// The browser's own offline/online events, as a Wi-Fi reassociation or a link flap produces. The
|
||||||
|
// display host re-resolves its descriptor on "online" (ADR 0043); this measures what that costs.
|
||||||
|
const owner = await ownerPage();
|
||||||
|
const moduleId = await displayModuleId(owner.page);
|
||||||
|
const kiosk = await connect(kioskAddress, "kiosk");
|
||||||
|
const page = await openPage(kiosk, `${apiOrigin}/display/${moduleId}`, "blip");
|
||||||
|
check(await waitLive(page, "the display"), "the display is live");
|
||||||
|
const mountsBefore = page.sessionRequests.length;
|
||||||
|
await page.evaluate(
|
||||||
|
`dispatchEvent(new Event("offline")); setTimeout(() => dispatchEvent(new Event("online")), 1000)`,
|
||||||
|
"dispatching offline then online",
|
||||||
|
);
|
||||||
|
let firstOfflineAt;
|
||||||
|
let lastOfflineAt;
|
||||||
|
const result = await watch([page], 30_000, {
|
||||||
|
onSample: (_page, state) => {
|
||||||
|
if (state.signageOffline || state.hostOffline || state.hostLoading || !state.mounted) {
|
||||||
|
firstOfflineAt ??= Date.now();
|
||||||
|
lastOfflineAt = Date.now();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
printTally(result);
|
||||||
|
const remounts = page.sessionRequests.length - mountsBefore;
|
||||||
|
note(`remounts caused by one offline/online pair: ${remounts}`);
|
||||||
|
note(
|
||||||
|
firstOfflineAt === undefined
|
||||||
|
? "nothing visible changed"
|
||||||
|
: `visible disruption from ${clock(firstOfflineAt)} to ${clock(lastOfflineAt)} (sampled every 2s)`,
|
||||||
|
);
|
||||||
|
summarize(page, result.from);
|
||||||
|
check(remounts === 0, "a connectivity blip does not remount a live display");
|
||||||
|
check(
|
||||||
|
firstOfflineAt === undefined,
|
||||||
|
"a connectivity blip does not show an offline state on a live display",
|
||||||
|
);
|
||||||
|
await page.close();
|
||||||
|
kiosk.close();
|
||||||
|
await finishOwner(owner);
|
||||||
|
} else if (mode === "session-blip") {
|
||||||
|
// One transient failure of Signage's viewer-session request at start-up, as a kiosk booting
|
||||||
|
// before its network settles, or an API restart, would produce. Everything else reaches the
|
||||||
|
// server.
|
||||||
|
const owner = await ownerPage();
|
||||||
|
const moduleId = await displayModuleId(owner.page);
|
||||||
|
const kiosk = await connect(kioskAddress, "kiosk");
|
||||||
|
const page = await openPage(kiosk, undefined, "session-blip");
|
||||||
|
let blocked = 0;
|
||||||
|
kiosk.on((message) => {
|
||||||
|
if (message.method === "Fetch.requestPaused") {
|
||||||
|
const { requestId, request } = message.params;
|
||||||
|
if (blocked === 0 && pathOf(request.url).endsWith("/viewers/session")) {
|
||||||
|
blocked += 1;
|
||||||
|
page
|
||||||
|
.send("Fetch.failRequest", { errorReason: "ConnectionRefused", requestId })
|
||||||
|
.catch((error) => note(`interception reply failed: ${error.message}`));
|
||||||
|
} else {
|
||||||
|
page
|
||||||
|
.send("Fetch.continueRequest", { requestId })
|
||||||
|
.catch((error) => note(`interception reply failed: ${error.message}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await page.send("Fetch.enable", { patterns: [{ urlPattern: "*/api/v1/modules/*" }] });
|
||||||
|
await page.navigate(`${apiOrigin}/display/${moduleId}`);
|
||||||
|
await page.waitFor(
|
||||||
|
`(${find("display-module-container")}?.childElementCount ?? 0) > 0`,
|
||||||
|
"the module to mount",
|
||||||
|
);
|
||||||
|
note(`viewer-session requests failed on purpose: ${blocked}`);
|
||||||
|
const recovered = await page.waitFor(
|
||||||
|
`${find("signage-stage")}?.dataset.offline === "false"`,
|
||||||
|
"recovery from one failed viewer-session request",
|
||||||
|
90_000,
|
||||||
|
);
|
||||||
|
summarize(page, stepStartedAt);
|
||||||
|
check(
|
||||||
|
recovered,
|
||||||
|
`one failed viewer-session request does not leave the display offline (${JSON.stringify(await displayState(page))})`,
|
||||||
|
);
|
||||||
|
await page.close();
|
||||||
|
kiosk.close();
|
||||||
|
await finishOwner(owner);
|
||||||
|
} else if (mode === "stream-failure") {
|
||||||
|
// The first realtime connection is refused with a 503, as an API mid-restart or a proxy with no
|
||||||
|
// upstream answers. A non-200 ends an EventSource for good; the question is whether anything
|
||||||
|
// re-establishes realtime without a reload.
|
||||||
|
const owner = await ownerPage();
|
||||||
|
const moduleId = await displayModuleId(owner.page);
|
||||||
|
const kiosk = await connect(kioskAddress, "kiosk");
|
||||||
|
const page = await openPage(kiosk, undefined, "stream-failure");
|
||||||
|
let refused = 0;
|
||||||
|
kiosk.on((message) => {
|
||||||
|
if (message.method === "Fetch.requestPaused") {
|
||||||
|
const { requestId } = message.params;
|
||||||
|
if (refused === 0) {
|
||||||
|
refused += 1;
|
||||||
|
page
|
||||||
|
.send("Fetch.fulfillRequest", {
|
||||||
|
body: Buffer.from('{"code":"REALTIME_UNAVAILABLE"}').toString("base64"),
|
||||||
|
requestId,
|
||||||
|
responseCode: 503,
|
||||||
|
responseHeaders: [
|
||||||
|
{ name: "content-type", value: "application/json" },
|
||||||
|
{ name: "cache-control", value: "no-store" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.catch((error) => note(`interception reply failed: ${error.message}`));
|
||||||
|
} else {
|
||||||
|
page
|
||||||
|
.send("Fetch.continueRequest", { requestId })
|
||||||
|
.catch((error) => note(`interception reply failed: ${error.message}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await page.send("Fetch.enable", { patterns: [{ urlPattern: "*/api/v1/module-realtime/*" }] });
|
||||||
|
await page.navigate(`${apiOrigin}/display/${moduleId}`);
|
||||||
|
await page.waitFor(
|
||||||
|
`(${find("display-module-container")}?.childElementCount ?? 0) > 0`,
|
||||||
|
"the module to mount",
|
||||||
|
);
|
||||||
|
note(`realtime connections refused on purpose: ${refused}`);
|
||||||
|
const result = await watch([page], 90_000, {
|
||||||
|
onSample: () => page.streams.some((stream) => stream.status === 200),
|
||||||
|
});
|
||||||
|
printTally(result);
|
||||||
|
summarize(page, result.from);
|
||||||
|
check(
|
||||||
|
page.streams.some((stream) => stream.status === 200),
|
||||||
|
"after one refused realtime connection, realtime is re-established without a reload",
|
||||||
|
);
|
||||||
|
await page.close();
|
||||||
|
kiosk.close();
|
||||||
|
await finishOwner(owner);
|
||||||
|
} else if (mode === "caching-probe") {
|
||||||
|
// Whether a display on the API origin can store an image from the storage origin in Cache
|
||||||
|
// Storage, which is what Signage's offline media relies on. Reports only statuses, response
|
||||||
|
// types, and whether CORS headers were present — never a URL.
|
||||||
|
const owner = await ownerPage();
|
||||||
|
const moduleId = await displayModuleId(owner.page);
|
||||||
|
const groups = await owner.page.api("GET", "/api/v1/modules/signage/channel-groups");
|
||||||
|
const channelId = groups.body?.groups?.[0]?.channels?.[0]?.id;
|
||||||
|
const kiosk = await connect(kioskAddress, "kiosk");
|
||||||
|
const page = await openPage(kiosk, `${apiOrigin}/display/${moduleId}`, "probe");
|
||||||
|
check(await waitLive(page, "the display"), "the display is live");
|
||||||
|
progress("letting the display prefetch its slides (15s)");
|
||||||
|
await sleep(15_000);
|
||||||
|
const probe = await page.evaluate(
|
||||||
|
`(async () => {
|
||||||
|
const result = {};
|
||||||
|
const state = await fetch(${JSON.stringify(`${apiOrigin}/api/v1/modules/signage/channels/${channelId}/display-state`)}, { credentials: "include", signal: AbortSignal.timeout(${requestTimeoutMs}) }).then((r) => r.json());
|
||||||
|
const url = (state.selections || []).map((s) => s.media && s.media.display && s.media.display.url).find(Boolean);
|
||||||
|
result.selections = (state.selections || []).length;
|
||||||
|
result.haveUrl = Boolean(url);
|
||||||
|
if (!url) return result;
|
||||||
|
result.sameOrigin = new URL(url).origin === location.origin;
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { signal: AbortSignal.timeout(${requestTimeoutMs}) });
|
||||||
|
result.cors = { status: response.status, type: response.type, allowOrigin: response.headers.get("access-control-allow-origin") === null ? "absent" : (response.headers.get("access-control-allow-origin") === location.origin || response.headers.get("access-control-allow-origin") === "*" ? "matches" : "other") };
|
||||||
|
try {
|
||||||
|
const cache = await caches.open("display-repro-probe");
|
||||||
|
await cache.put("/display-repro-probe/cors", response.clone());
|
||||||
|
result.cors.cachePut = "stored";
|
||||||
|
} catch (error) { result.cors.cachePut = "refused: " + error.name; }
|
||||||
|
} catch (error) { result.cors = { error: error.name }; }
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { mode: "no-cors", signal: AbortSignal.timeout(${requestTimeoutMs}) });
|
||||||
|
result.noCors = { status: response.status, type: response.type, ok: response.ok };
|
||||||
|
try {
|
||||||
|
const cache = await caches.open("display-repro-probe");
|
||||||
|
await cache.put("/display-repro-probe/opaque", response.clone());
|
||||||
|
result.noCors.cachePut = "stored";
|
||||||
|
} catch (error) { result.noCors.cachePut = "refused: " + error.name; }
|
||||||
|
} catch (error) { result.noCors = { error: error.name }; }
|
||||||
|
result.imgPlain = await new Promise((resolve) => { const i = new Image(); i.onload = () => resolve("loaded"); i.onerror = () => resolve("error"); i.src = url; });
|
||||||
|
result.imgCors = await new Promise((resolve) => { const i = new Image(); i.crossOrigin = "anonymous"; i.onload = () => resolve("loaded"); i.onerror = () => resolve("error"); i.src = url + (url.includes("?") ? "&" : "?") + "cors-probe=1"; });
|
||||||
|
await caches.delete("display-repro-probe");
|
||||||
|
result.signageCachedEntries = await caches.open("ekklesia-signage-media-v1").then((c) => c.keys()).then((k) => k.length).catch(() => "unavailable");
|
||||||
|
return result;
|
||||||
|
})()`,
|
||||||
|
"probing cross-origin image caching",
|
||||||
|
);
|
||||||
|
note(`caching probe: ${JSON.stringify(probe)}`);
|
||||||
|
summarize(page, stepStartedAt);
|
||||||
|
check(probe.signageCachedEntries > 0, "Signage has stored slide images in Cache Storage");
|
||||||
|
await page.close();
|
||||||
|
kiosk.close();
|
||||||
|
await finishOwner(owner);
|
||||||
|
} else if (mode === "stream-recycle") {
|
||||||
|
// One page, past the five-minute realtime connection age, with every stream's open and end
|
||||||
|
// time in UTC so they can be laid against the proxy's own log.
|
||||||
|
const owner = await ownerPage();
|
||||||
|
const moduleId = await displayModuleId(owner.page);
|
||||||
|
const kiosk = await connect(kioskAddress, "kiosk");
|
||||||
|
const page = await openPage(kiosk, `${apiOrigin}/display/${moduleId}`, "recycle");
|
||||||
|
check(await waitLive(page, "the display"), "the display is live");
|
||||||
|
const result = await watch([page], recycleWatchMs, { viewerOwner: owner.page });
|
||||||
|
printTally(result);
|
||||||
|
summarize(page, result.from);
|
||||||
|
note(`connectivity events ${JSON.stringify(await connectivityEvents(page))}`);
|
||||||
|
const blobs = await blobCounts(page);
|
||||||
|
const exhausted = page.stateFetches.filter((fetch) =>
|
||||||
|
fetch.outcome.includes("INSUFFICIENT_RESOURCES"),
|
||||||
|
);
|
||||||
|
note(
|
||||||
|
`object URLs: created ${blobs.created}, revoked ${blobs.revoked}, live ${blobs.created - blobs.revoked}; channel-state fetches refused for insufficient resources: ${exhausted.length}`,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
blobs.created - blobs.revoked <= MAX_LIVE_OBJECT_URLS,
|
||||||
|
`live object URLs stay bounded (at most ${MAX_LIVE_OBJECT_URLS}; ${blobs.created - blobs.revoked} live)`,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
result.tally.get("recycle").offline === 0,
|
||||||
|
`the display stayed live for the whole ${recycleWatchMs / 1000}s watch${recycleWatchMs > 300_000 ? ", across a realtime recycle" : ""}`,
|
||||||
|
);
|
||||||
|
check(page.sessionRequests.length === 1, "the display was never remounted during the watch");
|
||||||
|
await page.close();
|
||||||
|
kiosk.close();
|
||||||
|
await finishOwner(owner);
|
||||||
|
} else if (mode === "revoke") {
|
||||||
|
const owner = await ownerPage();
|
||||||
|
const moduleId = await displayModuleId(owner.page);
|
||||||
|
const kiosk = await connect(kioskAddress, "kiosk");
|
||||||
|
const page = await openPage(kiosk, `${apiOrigin}/display/${moduleId}`, "revoked");
|
||||||
|
check(
|
||||||
|
await waitLive(page, "the display before revocation"),
|
||||||
|
"the display is live before revocation",
|
||||||
|
);
|
||||||
|
const devices = (await coreDevices(owner.page)).filter((device) => !device.revoked);
|
||||||
|
for (const device of devices) {
|
||||||
|
await owner.page.api("POST", `/api/v1/devices/${device.id}/revoke`);
|
||||||
|
}
|
||||||
|
note(`revoked ${devices.length} device(s) at ${clock()}; watching the open page`);
|
||||||
|
const result = await watch([page], watchMs, { onSample: (_page, state) => state.pairing });
|
||||||
|
printTally(result);
|
||||||
|
summarize(page, result.from);
|
||||||
|
check(
|
||||||
|
result.stopped,
|
||||||
|
`a revoked display returns to the pairing screen without a reload (within ${watchMs / 1000}s)`,
|
||||||
|
);
|
||||||
|
await page.close();
|
||||||
|
kiosk.close();
|
||||||
|
await finishOwner(owner);
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"usage: deploy-smoke-display.mjs pair|restart|two-pages|connectivity-blip|session-blip|stream-failure|caching-probe|stream-recycle|revoke",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await run();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HangError) {
|
||||||
|
console.log(`FAIL ${mode}: ${error.message}; also in flight: ${describeInFlight()}`);
|
||||||
|
} else {
|
||||||
|
console.log(`FAIL ${mode}: ${error instanceof Error ? error.message : "an unexpected error"}`);
|
||||||
|
}
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures > 0) {
|
||||||
|
console.error(`${failures} check(s) failed`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
+203
-8
@@ -5,6 +5,21 @@
|
|||||||
#
|
#
|
||||||
# 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.
|
||||||
|
#
|
||||||
|
# Opt-in, split topology only: EKKLESIA_SMOKE_DISPLAY_DEVICE=1 adds the display-device scenarios in
|
||||||
|
# scripts/lib/deploy-smoke-display.mjs (pairing a kiosk browser with a persistent profile, a clean
|
||||||
|
# restart, two pages, injected network failures, revocation), each with its own time budget.
|
||||||
|
# EKKLESIA_SMOKE_DISPLAY_SCENARIOS selects a subset; EKKLESIA_SMOKE_WATCH_MS and
|
||||||
|
# EKKLESIA_SMOKE_RECYCLE_WATCH_MS change how long they watch. Several scenarios expect Signage.
|
||||||
#
|
#
|
||||||
# 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.
|
||||||
@@ -24,6 +39,8 @@ cd "$repository_root"
|
|||||||
|
|
||||||
# shellcheck source=scripts/lib/compose-diagnostics.sh
|
# shellcheck source=scripts/lib/compose-diagnostics.sh
|
||||||
source "$repository_root/scripts/lib/compose-diagnostics.sh"
|
source "$repository_root/scripts/lib/compose-diagnostics.sh"
|
||||||
|
# shellcheck source=scripts/lib/ci-project.sh
|
||||||
|
source "$repository_root/scripts/lib/ci-project.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 || {
|
||||||
@@ -39,14 +56,39 @@ 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}"
|
# Unique per invocation, and findable by run and job if this script is killed before its trap runs
|
||||||
|
# (scripts/lib/ci-project.sh, scripts/ci-sweep.sh). Everything started outside Compose below carries
|
||||||
|
# the same name in EKKLESIA_CI_LABEL.
|
||||||
|
project="$(ci_project_name "deploy-${topology}")"
|
||||||
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=""
|
||||||
|
|
||||||
# choose_edge_network: a per-run edge subnet that no network on this Docker daemon already uses, so
|
# choose_edge_network: a per-run edge subnet that no network on this Docker daemon already uses, so
|
||||||
# concurrent runs sharing a runner's daemon do not collide. Two runs can still pick the same free
|
# concurrent runs sharing a runner's daemon do not collide. Two runs can still pick the same free
|
||||||
@@ -69,6 +111,8 @@ choose_edge_network() {
|
|||||||
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
|
return 0
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
@@ -105,6 +149,15 @@ 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 [[ -n "${kiosk_container:-}" ]]; then
|
||||||
|
docker ps --all --quiet --filter "name=${project}-display-" | xargs -r docker rm --force \
|
||||||
|
>/dev/null 2>&1 || true
|
||||||
|
docker rm --force "$kiosk_container" >/dev/null 2>&1 || true
|
||||||
|
docker volume rm --force "$kiosk_volume" >/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
|
||||||
@@ -135,10 +188,34 @@ 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" \
|
||||||
|
--label "${EKKLESIA_CI_LABEL}=${project}" --label "${EKKLESIA_CI_CREATED_LABEL}=$(date +%s)" \
|
||||||
|
. >/dev/null
|
||||||
|
image="$built_image"
|
||||||
|
elif [[ -z "$image" ]]; then
|
||||||
|
# TEST-ONLY: the repository's module template, installed as the module `example`, in a copy of the
|
||||||
|
# working tree. Its lockfile importer moves with it; the depth is the same, so every relative link
|
||||||
|
# in that importer stays correct and the install stays frozen.
|
||||||
|
step "Building an image with the module template installed as a test-only module"
|
||||||
|
command -v git >/dev/null || fail "git is required to build the split topology's test image."
|
||||||
|
context="$work/build-context"
|
||||||
|
mkdir -p "$context"
|
||||||
|
git ls-files -z --cached --others --exclude-standard |
|
||||||
|
tar --null --files-from=- --create --file=- |
|
||||||
|
tar -C "$context" -xf -
|
||||||
|
mkdir -p "$context/modules"
|
||||||
|
mv "$context/templates/module" "$context/modules/example"
|
||||||
|
[[ "$(grep -c '^ templates/module:$' "$context/pnpm-lock.yaml")" == 1 ]] ||
|
||||||
|
fail "the lockfile has no single templates/module importer to move."
|
||||||
|
sed -i 's|^ templates/module:$| modules/example:|' "$context/pnpm-lock.yaml"
|
||||||
|
built_image="ekklesia-deploy-smoke:${run_id}"
|
||||||
|
DOCKER_BUILDKIT=1 docker build --quiet --file "$context/deploy/Dockerfile" --tag "$built_image" \
|
||||||
|
--label "${EKKLESIA_CI_LABEL}=${project}" --label "${EKKLESIA_CI_CREATED_LABEL}=$(date +%s)" \
|
||||||
|
"$context" >/dev/null || fail "the test-module image did not build."
|
||||||
|
rm -rf "$context"
|
||||||
image="$built_image"
|
image="$built_image"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -197,7 +274,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"
|
||||||
@@ -209,7 +289,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
|
||||||
|
|
||||||
@@ -218,6 +298,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
|
||||||
@@ -281,13 +362,18 @@ 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" "$@" \
|
--label "${EKKLESIA_CI_LABEL}=${project}" \
|
||||||
--add-host "${hostname}:${proxy_address}" --add-host "${storage_hostname}:${proxy_address}" \
|
--network "$edge_network" "$@" "${host_mappings[@]}" \
|
||||||
-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" \
|
||||||
@@ -312,6 +398,111 @@ 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" --label "${EKKLESIA_CI_LABEL}=${project}" \
|
||||||
|
--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 \
|
||||||
|
--label "${EKKLESIA_CI_LABEL}=${project}" \
|
||||||
|
--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."
|
||||||
|
|
||||||
|
if [[ -n "${EKKLESIA_SMOKE_DISPLAY_DEVICE:-}" ]]; then
|
||||||
|
# A kiosk: a second browser whose profile directory is a volume, so it outlives the container
|
||||||
|
# exactly as a kiosk's --user-data-dir outlives a browser restart.
|
||||||
|
kiosk_container="${project}-kiosk"
|
||||||
|
kiosk_volume="${project}-kiosk-profile"
|
||||||
|
kiosk_address="10.249.${octet}.250"
|
||||||
|
docker volume create --label "${EKKLESIA_CI_LABEL}=${project}" "$kiosk_volume" >/dev/null
|
||||||
|
start_kiosk() {
|
||||||
|
docker run --detach --rm --name "$kiosk_container" --label "${EKKLESIA_CI_LABEL}=${project}" \
|
||||||
|
--cap-drop ALL --security-opt no-new-privileges --tmpfs /tmp \
|
||||||
|
--network "$edge_network" --ip "$kiosk_address" "${host_mappings[@]}" \
|
||||||
|
-v "${kiosk_volume}:/profile" \
|
||||||
|
"$browser_image" \
|
||||||
|
"--ignore-certificate-errors-spki-list=${spki}" \
|
||||||
|
--user-data-dir=/profile >/dev/null || fail "the kiosk browser did not start."
|
||||||
|
}
|
||||||
|
wait_kiosk_exit() {
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
docker inspect "$kiosk_container" >/dev/null 2>&1 || return 0
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
fail "the kiosk browser did not exit after a clean close."
|
||||||
|
}
|
||||||
|
cp scripts/lib/deploy-smoke-display.mjs "$work/display.mjs"
|
||||||
|
stage display.mjs
|
||||||
|
# display_driver <budget seconds> <step>: one scenario with a hard budget. The driver enforces it
|
||||||
|
# itself and names whatever call was still waiting; `timeout` is the backstop if it cannot.
|
||||||
|
display_driver() {
|
||||||
|
local budget="$1" step_name="$2" driver="${project}-display-${2}"
|
||||||
|
timeout --signal=TERM "$((budget + 30))" \
|
||||||
|
docker run --rm --name "$driver" --read-only --cap-drop ALL --security-opt no-new-privileges \
|
||||||
|
--network "$edge_network" \
|
||||||
|
-e SMOKE_CDP_ADDRESS="${browser_address}:9222" \
|
||||||
|
-e SMOKE_KIOSK_CDP_ADDRESS="${kiosk_address}:9222" \
|
||||||
|
-e SMOKE_STEP_BUDGET_MS="$((budget * 1000))" \
|
||||||
|
-e SMOKE_WATCH_MS="${EKKLESIA_SMOKE_WATCH_MS:-120000}" \
|
||||||
|
-e SMOKE_RECYCLE_WATCH_MS="${EKKLESIA_SMOKE_RECYCLE_WATCH_MS:-330000}" \
|
||||||
|
-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/display.mjs:/smoke/display.mjs:ro" \
|
||||||
|
--entrypoint node "$image" /smoke/display.mjs "$step_name"
|
||||||
|
local status=$?
|
||||||
|
if ((status == 124)); then
|
||||||
|
echo "FAIL ${step_name}: the driver did not stop within its budget plus 30s; removed it"
|
||||||
|
docker rm --force "$driver" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
return "$status"
|
||||||
|
}
|
||||||
|
display_status=0
|
||||||
|
step "Pairing a kiosk display, then closing its browser cleanly"
|
||||||
|
start_kiosk
|
||||||
|
display_driver 180 pair || display_status=1
|
||||||
|
wait_kiosk_exit
|
||||||
|
step "Restarting the kiosk browser on the same profile"
|
||||||
|
start_kiosk
|
||||||
|
display_driver 120 restart || display_status=1
|
||||||
|
for scenario in ${EKKLESIA_SMOKE_DISPLAY_SCENARIOS:-two-pages connectivity-blip session-blip stream-failure caching-probe stream-recycle revoke}; do
|
||||||
|
case "$scenario" in
|
||||||
|
two-pages) budget=240 ;;
|
||||||
|
stream-recycle) budget=420 ;;
|
||||||
|
revoke) budget=210 ;;
|
||||||
|
*) budget=150 ;;
|
||||||
|
esac
|
||||||
|
step "Display scenario: ${scenario} (budget ${budget}s)"
|
||||||
|
display_driver "$budget" "$scenario" || display_status=1
|
||||||
|
done
|
||||||
|
step "Proxy: streams aborted with an incomplete response (UTC time, path only)"
|
||||||
|
# Captured, never printed: only each line's time and path (no query, so no signature) leave it.
|
||||||
|
proxy_logs="$("${compose[@]}" logs --no-color proxy 2>/dev/null)" || proxy_logs=""
|
||||||
|
grep -o '{.*"aborting with incomplete response".*}' <<<"$proxy_logs" |
|
||||||
|
sed -E 's/.*"ts":([0-9]+)[^,]*,.*"uri":"([^"?]*)[^"]*".*/\1 \2/' |
|
||||||
|
while read -r ts uri; do echo " $(date -u -d "@${ts}" +%T) ${uri}"; done || true
|
||||||
|
((display_status == 0)) || fail "display-device checks failed."
|
||||||
|
fi
|
||||||
|
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="$(
|
||||||
@@ -374,12 +565,16 @@ grep -q 'TLS required with certificate and hostname verification (explicit priva
|
|||||||
<<<"$doctor_output" || fail "Doctor did not report verify-full with the private CA."
|
<<<"$doctor_output" || fail "Doctor did not report verify-full with the private CA."
|
||||||
grep -q '✓ Object storage reachable' <<<"$doctor_output" ||
|
grep -q '✓ Object storage reachable' <<<"$doctor_output" ||
|
||||||
fail "Doctor could not reach object storage over HTTPS."
|
fail "Doctor could not reach object storage over HTTPS."
|
||||||
|
# Doctor runs as `migrate`; it must check the client-address configuration the API runs with, which
|
||||||
|
# behind the proxy is trusted-proxy mode, not the direct mode an unconfigured process reports.
|
||||||
|
grep -q 'Trusted-proxy client-address mode' <<<"$doctor_output" ||
|
||||||
|
fail "Doctor did not check the API's trusted-proxy client-address configuration."
|
||||||
for value in "$postgres_password" "$s3_secret_key" "$identity_secret"; do
|
for value in "$postgres_password" "$s3_secret_key" "$identity_secret"; do
|
||||||
if grep -qF -- "$value" <<<"$doctor_output"; then
|
if grep -qF -- "$value" <<<"$doctor_output"; then
|
||||||
fail "Doctor output contains a secret."
|
fail "Doctor output contains a secret."
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
echo "ok Doctor: READY, verify-full with the private CA, storage reachable, no secret printed"
|
echo "ok Doctor: READY, verify-full with the private CA, storage reachable, the API's client-address mode, no secret printed"
|
||||||
|
|
||||||
api_logs="$("${compose[@]}" logs --no-color api 2>&1)"
|
api_logs="$("${compose[@]}" logs --no-color api 2>&1)"
|
||||||
for value in "$postgres_password" "$s3_secret_key" "$identity_secret" "$owner_password"; do
|
for value in "$postgres_password" "$s3_secret_key" "$identity_secret" "$owner_password"; do
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ cd "$repository_root"
|
|||||||
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
|
# shellcheck source=scripts/lib/compose-diagnostics.sh
|
||||||
source "$repository_root/scripts/lib/compose-diagnostics.sh"
|
source "$repository_root/scripts/lib/compose-diagnostics.sh"
|
||||||
|
# shellcheck source=scripts/lib/ci-project.sh
|
||||||
|
source "$repository_root/scripts/lib/ci-project.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
|
||||||
@@ -30,11 +32,9 @@ if [[ "$curl_help" != *"--aws-sigv4"* ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Unique per invocation, not only per run: several runs can share a runner's Docker daemon at once,
|
# Unique per invocation, and findable by run and job if this script is killed before its trap runs
|
||||||
# and the shell's PID is often identical across job containers.
|
# (scripts/lib/ci-project.sh, scripts/ci-sweep.sh).
|
||||||
raw_project="ekklesia-smoke-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-$$-${RANDOM}${RANDOM}"
|
project="$(ci_project_name infrastructure)"
|
||||||
project="${raw_project//[^a-zA-Z0-9_-]/-}"
|
|
||||||
project="${project,,}"
|
|
||||||
temporary_directory="$(mktemp -d)"
|
temporary_directory="$(mktemp -d)"
|
||||||
payload_file="$temporary_directory/payload.txt"
|
payload_file="$temporary_directory/payload.txt"
|
||||||
download_file="$temporary_directory/download.txt"
|
download_file="$temporary_directory/download.txt"
|
||||||
|
|||||||
Executable
+129
@@ -0,0 +1,129 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Proves scripts/ci-sweep.sh against real Docker resources: what it removes, and above all what it
|
||||||
|
# leaves alone. CI runs it before trusting the sweep with a shared runner's daemon.
|
||||||
|
#
|
||||||
|
# bash scripts/test-ci-sweep.sh
|
||||||
|
#
|
||||||
|
# Every resource it creates is named and labeled for this run alone, under fake run identifiers, so
|
||||||
|
# it can never touch a real stack; and it removes all of them on exit, whatever happens.
|
||||||
|
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$repository_root"
|
||||||
|
|
||||||
|
image="$(sed -n 's/^[[:space:]]*image: \(postgres:[^[:space:]]*\)$/\1/p' deploy/compose.yaml | head -1)"
|
||||||
|
[[ -n "$image" ]] || {
|
||||||
|
echo "test-ci-sweep: no pinned PostgreSQL image in deploy/compose.yaml." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
docker image inspect "$image" >/dev/null 2>&1 || docker pull --quiet "$image" >/dev/null
|
||||||
|
|
||||||
|
nonce="$(openssl rand -hex 4)"
|
||||||
|
compose_label="com.docker.compose.project"
|
||||||
|
ci_label="org.riverofglorymn.ekklesia.ci.project"
|
||||||
|
failures=0
|
||||||
|
created=()
|
||||||
|
|
||||||
|
check() {
|
||||||
|
if eval "$1"; then
|
||||||
|
echo "ok $2"
|
||||||
|
else
|
||||||
|
echo "FAIL $2"
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# project_exists <project>: any container, network, volume, or image still carries its label.
|
||||||
|
project_exists() {
|
||||||
|
local project="$1"
|
||||||
|
[[ -n "$(
|
||||||
|
docker ps --all --quiet --filter "label=${compose_label}=${project}"
|
||||||
|
docker ps --all --quiet --filter "label=${ci_label}=${project}"
|
||||||
|
docker network ls --quiet --filter "label=${compose_label}=${project}"
|
||||||
|
docker volume ls --quiet --filter "label=${compose_label}=${project}"
|
||||||
|
docker image ls --quiet --filter "label=${ci_label}=${project}"
|
||||||
|
)" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
staging_exists() {
|
||||||
|
docker run --rm --entrypoint test -v /tmp:/host-tmp "$image" -d "/host-tmp/$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# make_stack <project>: what a CI script leaves behind when it is killed: a Compose-labeled network,
|
||||||
|
# volume, and running container; a CI-labeled container and image; and a staging directory on the
|
||||||
|
# Docker host.
|
||||||
|
make_stack() {
|
||||||
|
local project="$1"
|
||||||
|
created+=("$project")
|
||||||
|
docker network create --label "${compose_label}=${project}" "${project}_default" >/dev/null
|
||||||
|
docker volume create --label "${compose_label}=${project}" "${project}_data" >/dev/null
|
||||||
|
docker run --detach --name "${project}-db" --label "${compose_label}=${project}" \
|
||||||
|
--network "${project}_default" -v "${project}_data:/data" --entrypoint sleep "$image" 600 >/dev/null
|
||||||
|
docker run --detach --name "${project}-browser" --label "${ci_label}=${project}" \
|
||||||
|
--entrypoint sleep "$image" 600 >/dev/null
|
||||||
|
printf 'FROM %s\nLABEL %s=%s %s=%s\n' "$image" "$ci_label" "$project" \
|
||||||
|
"org.riverofglorymn.ekklesia.ci.created" "$(date +%s)" |
|
||||||
|
docker build --quiet --tag "${project}-image:test" - >/dev/null
|
||||||
|
docker run --rm --entrypoint mkdir -v /tmp:/host-tmp "$image" -p "/host-tmp/${project}"
|
||||||
|
}
|
||||||
|
|
||||||
|
job_container="test-ci-sweep-job-${nonce}"
|
||||||
|
cleanup() {
|
||||||
|
local status=$?
|
||||||
|
trap - EXIT INT TERM
|
||||||
|
docker rm --force "$job_container" >/dev/null 2>&1 || true
|
||||||
|
for project in "${created[@]}"; do
|
||||||
|
docker ps --all --quiet --filter "label=${compose_label}=${project}" | xargs -r docker rm --force >/dev/null 2>&1 || true
|
||||||
|
docker ps --all --quiet --filter "label=${ci_label}=${project}" | xargs -r docker rm --force >/dev/null 2>&1 || true
|
||||||
|
docker network rm "${project}_default" >/dev/null 2>&1 || true
|
||||||
|
docker volume rm --force "${project}_data" >/dev/null 2>&1 || true
|
||||||
|
docker image rm --force "${project}-image:test" >/dev/null 2>&1 || true
|
||||||
|
docker run --rm --entrypoint rm -v /tmp:/host-tmp "$image" -rf "/host-tmp/${project}" >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
exit "$status"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
# shellcheck source=scripts/lib/ci-project.sh
|
||||||
|
source "$repository_root/scripts/lib/ci-project.sh"
|
||||||
|
|
||||||
|
# Fake run identifiers: nothing here can match a real run's stacks.
|
||||||
|
export GITHUB_RUN_ID="sweeptest${nonce}"
|
||||||
|
export GITHUB_RUN_ATTEMPT="1"
|
||||||
|
mine="$(EKKLESIA_CI_JOB_KEY=alpha ci_project_name mine)"
|
||||||
|
sibling="$(EKKLESIA_CI_JOB_KEY=beta ci_project_name sibling)"
|
||||||
|
foreign="ekklesia-sweep-control-${nonce}"
|
||||||
|
for project in "$mine" "$sibling" "$foreign"; do
|
||||||
|
make_stack "$project"
|
||||||
|
done
|
||||||
|
# A job container connected to this job's stack network, exactly as compose-job-network.sh does.
|
||||||
|
docker run --detach --name "$job_container" --entrypoint sleep "$image" 600 >/dev/null
|
||||||
|
docker network connect "${mine}_default" "$job_container"
|
||||||
|
|
||||||
|
echo "--- --job removes this job's stacks, at any age, and nothing else"
|
||||||
|
EKKLESIA_CI_JOB_KEY=alpha bash scripts/ci-sweep.sh --job
|
||||||
|
check '! project_exists "$mine"' "this job's containers, network, volume, and image are gone"
|
||||||
|
check '! staging_exists "$mine"' "this job's staging directory on the Docker host is gone"
|
||||||
|
check 'project_exists "$sibling"' "another job's stack in the same run is left alone"
|
||||||
|
check 'project_exists "$foreign"' "a stack outside the CI prefix is left alone"
|
||||||
|
check 'docker inspect "$job_container" >/dev/null 2>&1' \
|
||||||
|
"a job container connected to the stack's network is disconnected, never removed"
|
||||||
|
|
||||||
|
echo "--- --job refuses to run outside CI, where it could remove a developer's own stacks"
|
||||||
|
check '! env -u GITHUB_RUN_ID bash scripts/ci-sweep.sh --job >/dev/null 2>&1' \
|
||||||
|
"--job without GITHUB_RUN_ID fails"
|
||||||
|
|
||||||
|
echo "--- --stale removes only CI stacks older than its threshold"
|
||||||
|
bash scripts/ci-sweep.sh --stale
|
||||||
|
check 'project_exists "$sibling"' "a young CI stack survives the default threshold"
|
||||||
|
bash scripts/ci-sweep.sh --stale --max-age-seconds 0
|
||||||
|
check '! project_exists "$sibling"' "a CI stack older than the threshold is removed"
|
||||||
|
check '! staging_exists "$sibling"' "its staging directory too"
|
||||||
|
check 'project_exists "$foreign"' "a stack outside the CI prefix is never removed, at any age"
|
||||||
|
|
||||||
|
if ((failures > 0)); then
|
||||||
|
echo "test-ci-sweep: ${failures} check(s) failed." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "CI sweep checks passed."
|
||||||
@@ -9,6 +9,8 @@ cd "$repository_root"
|
|||||||
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
|
# shellcheck source=scripts/lib/compose-diagnostics.sh
|
||||||
source "$repository_root/scripts/lib/compose-diagnostics.sh"
|
source "$repository_root/scripts/lib/compose-diagnostics.sh"
|
||||||
|
# shellcheck source=scripts/lib/ci-project.sh
|
||||||
|
source "$repository_root/scripts/lib/ci-project.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
|
||||||
@@ -27,11 +29,9 @@ if ! compose_job_network_initialize; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Unique per invocation, not only per run: several runs (and several jobs of one run) can share a
|
# Unique per invocation, and findable by run and job if this script is killed before its trap runs
|
||||||
# runner's Docker daemon at once, and the shell's PID is often identical across job containers.
|
# (scripts/lib/ci-project.sh, scripts/ci-sweep.sh).
|
||||||
raw_project="ekklesia-migration-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-${GITHUB_JOB:-job}-$$-${RANDOM}${RANDOM}"
|
project="$(ci_project_name migration)"
|
||||||
project="${raw_project//[^a-zA-Z0-9_-]/-}"
|
|
||||||
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"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/shell",
|
"name": "@ekklesia/shell",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -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("&", "&").replaceAll('"', """)}" 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"),
|
||||||
|
|||||||
@@ -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,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");
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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]));
|
||||||
|
|||||||
@@ -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")],
|
||||||
|
|||||||
+58
-10
@@ -4,6 +4,7 @@ import path from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parse } from "yaml";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The scripts CI runs against a shared Docker daemon. Several runs, and several jobs of one run, can
|
* The scripts CI runs against a shared Docker daemon. Several runs, and several jobs of one run, can
|
||||||
@@ -59,21 +60,68 @@ describe("CI scripts on a shared Docker daemon", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("give every Compose project a name unique to the invocation, not only the run", async () => {
|
it("name every stack through one helper: the reserved CI prefix, the job's key, and a unique suffix", async () => {
|
||||||
expect(await source("scripts/test-migrations-integration.sh")).toMatch(
|
const helper = await source("scripts/lib/ci-project.sh");
|
||||||
/raw_project="ekklesia-migration-[^"\n]*\$\$-\$\{RANDOM\}\$\{RANDOM\}"/u,
|
// Run, attempt, job key, kind, then a per-invocation suffix: unique even when job containers
|
||||||
|
// share a PID, and findable later by run and job alone (scripts/ci-sweep.sh --job).
|
||||||
|
expect(helper).toContain(
|
||||||
|
"ekklesia-ci-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-${EKKLESIA_CI_JOB_KEY:-${GITHUB_JOB:-local}}-",
|
||||||
);
|
);
|
||||||
expect(await source("scripts/smoke-infrastructure.sh")).toMatch(
|
expect(helper).toContain("$$-${RANDOM}${RANDOM}");
|
||||||
/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) {
|
for (const relativePath of ciScripts) {
|
||||||
expect(await source(relativePath), relativePath).toContain('--project-name "$project"');
|
const text = await source(relativePath);
|
||||||
|
expect(text, relativePath).toContain('source "$repository_root/scripts/lib/ci-project.sh"');
|
||||||
|
expect(text, relativePath).toMatch(/^project="\$\(ci_project_name [a-z"${}_-]+\)"$/mu);
|
||||||
|
expect(text, relativePath).not.toContain("raw_project=");
|
||||||
|
expect(text, relativePath).toContain('--project-name "$project"');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("label everything the deploy smoke creates outside Compose, so a sweep can find it", async () => {
|
||||||
|
// Continuation lines joined, so each command is checked whole.
|
||||||
|
const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " ");
|
||||||
|
const creating = deploy
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => /\bdocker (?:run --detach|volume create|build)\b/u.test(line));
|
||||||
|
expect(creating.length).toBeGreaterThanOrEqual(4);
|
||||||
|
for (const line of creating) {
|
||||||
|
expect(line).toContain('--label "${EKKLESIA_CI_LABEL}=${project}"');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sweep stale stacks before, and this job's own stacks after, every job that starts any", async () => {
|
||||||
|
const workflow = parse(await source(".github/workflows/ci.yml")) as {
|
||||||
|
jobs: Record<
|
||||||
|
string,
|
||||||
|
{ env?: Record<string, string>; steps: { if?: string; run?: string; name?: string }[] }
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
const startsStacks =
|
||||||
|
/smoke-infrastructure\.sh|test:migrations:integration|smoke-deploy\.sh|docker build/u;
|
||||||
|
const jobs = Object.entries(workflow.jobs).filter(([, job]) =>
|
||||||
|
job.steps.some((step) => startsStacks.test(step.run ?? "")),
|
||||||
|
);
|
||||||
|
expect(jobs.map(([id]) => id).sort()).toEqual(
|
||||||
|
["deploy-smoke", "image-build", "infrastructure-smoke", "migration-smoke"].sort(),
|
||||||
|
);
|
||||||
|
for (const [id, job] of jobs) {
|
||||||
|
expect(job.env?.EKKLESIA_CI_JOB_KEY, id).toBeTruthy();
|
||||||
|
const runs = job.steps.map((step) => step.run ?? "");
|
||||||
|
const firstStack = runs.findIndex((run) => startsStacks.test(run));
|
||||||
|
const stale = runs.findIndex((run) => run.includes("bash scripts/ci-sweep.sh --stale"));
|
||||||
|
const own = job.steps.findIndex(
|
||||||
|
(step) =>
|
||||||
|
step.if === "always()" && (step.run ?? "").includes("bash scripts/ci-sweep.sh --job"),
|
||||||
|
);
|
||||||
|
expect(stale, `${id}: stale sweep`).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(stale, `${id}: stale sweep runs first`).toBeLessThan(firstStack);
|
||||||
|
expect(own, `${id}: its own cleanup, always`).toBeGreaterThan(firstStack);
|
||||||
|
expect(own, `${id}: cleanup is the last step`).toBe(job.steps.length - 1);
|
||||||
|
}
|
||||||
|
// One matrix leg's cleanup must never remove the other leg's running stack.
|
||||||
|
expect(workflow.jobs["deploy-smoke"]?.env?.EKKLESIA_CI_JOB_KEY).toContain("matrix.topology");
|
||||||
|
});
|
||||||
|
|
||||||
it("choose the deploy smoke's edge subnet from those not in use, and choose again on a race", async () => {
|
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");
|
const deploy = await source("scripts/smoke-deploy.sh");
|
||||||
expect(deploy).not.toMatch(/^octet=\$\(\(RANDOM/mu);
|
expect(deploy).not.toMatch(/^octet=\$\(\(RANDOM/mu);
|
||||||
|
|||||||
@@ -153,6 +153,11 @@ describe("reference deployment compose files", () => {
|
|||||||
EKKLESIA_CLIENT_ADDRESS_MODE: "trusted-proxy",
|
EKKLESIA_CLIENT_ADDRESS_MODE: "trusted-proxy",
|
||||||
EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: `${proxyAddress}/32`,
|
EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: `${proxyAddress}/32`,
|
||||||
});
|
});
|
||||||
|
// Doctor runs as `migrate`. Given anything else, it validates a configuration the API is not
|
||||||
|
// running, and reports "Direct" for an installation that trusts its proxy.
|
||||||
|
expect(proxyOverlay.services?.migrate?.environment).toEqual(
|
||||||
|
proxyOverlay.services?.api?.environment,
|
||||||
|
);
|
||||||
expect(proxyOverlay.networks?.edge?.ipam?.config).toEqual([
|
expect(proxyOverlay.networks?.edge?.ipam?.config).toEqual([
|
||||||
{
|
{
|
||||||
ip_range: "${EKKLESIA_EDGE_DYNAMIC_RANGE:-10.249.250.0/25}",
|
ip_range: "${EKKLESIA_EDGE_DYNAMIC_RANGE:-10.249.250.0/25}",
|
||||||
|
|||||||
Reference in New Issue
Block a user