feat: support split Dashboard and API origins (ADR 0045)
CI / verify (pull_request) Successful in 51s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m49s
CI / image-build (pull_request) Successful in 17s
CI / deploy-smoke (same-origin) (pull_request) Successful in 48s
CI / deploy-smoke (split) (pull_request) Successful in 55s

An installation can now serve the Dashboard and the API on sibling
hostnames (dash./api.) beside the default same-origin topology, from one
image and with configuration alone.

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

ADR 0045 is marked Accepted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-03 13:29:11 -05:00
co-authored by Claude Opus 5.5
parent 55accd0e65
commit 65908ac97c
32 changed files with 1275 additions and 116 deletions
+100 -6
View File
@@ -5,6 +5,15 @@
#
# bash scripts/smoke-deploy.sh builds the image first
# EKKLESIA_SMOKE_IMAGE=<image> bash scripts/smoke-deploy.sh
# EKKLESIA_SMOKE_TOPOLOGY=split bash scripts/smoke-deploy.sh
#
# Either topology (ADR 0045). `same-origin` (the default) serves the Dashboard, the API, and displays
# from one hostname with Caddyfile.example and a platform-only image. `split` serves the Dashboard on
# its own hostname with Caddyfile.split.example, builds an image with the repository's module
# template installed as a test-only module (so a module UI, its API routes, and a display exist), and
# then drives a real headless Chromium through the Dashboard, the module, the display, and sign-out.
# With EKKLESIA_SMOKE_IMAGE, the given image is used as is in either topology; a split run then
# exercises whichever of its modules publishes UI and a display.
#
# TEST-ONLY: the proxy runs with Caddy's internal CA (`local_certs`) instead of ACME, because a test
# run has no public hostname. Never use that option in an installation.
@@ -39,14 +48,36 @@ docker compose version >/dev/null 2>&1 || {
# The pinned PostgreSQL image doubles as a root shell for file ownership, so the run needs neither
# root nor sudo on the host.
postgres_image="postgres:18.4-alpine3.24@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15"
# The split topology's real browser: headless Chromium, pinned by its multi-architecture index digest.
browser_image="chromedp/headless-shell:151.0.7922.109@sha256:2d349b544a1ea6b5b5fd7c0fe99215ff662339c57407ee2e8c0a11af93516b04"
topology="${EKKLESIA_SMOKE_TOPOLOGY:-same-origin}"
case "$topology" in
same-origin | split) ;;
*)
echo "smoke-deploy: EKKLESIA_SMOKE_TOPOLOGY must be same-origin or split." >&2
exit 1
;;
esac
run_id="$(openssl rand -hex 4)"
project="ekklesia-deploy-smoke-${run_id}"
work="$(mktemp -d)"
image="${EKKLESIA_SMOKE_IMAGE:-}"
built_image=""
hostname="ekklesia.smoke.test"
# `hostname` is always the API's (and the display host's); the Dashboard has its own only when split.
if [[ "$topology" == split ]]; then
hostname="api.smoke.test"
dashboard_hostname="dash.smoke.test"
caddyfile_example="deploy/Caddyfile.split.example"
else
hostname="ekklesia.smoke.test"
dashboard_hostname=""
caddyfile_example="deploy/Caddyfile.example"
fi
dashboard_origin="https://${dashboard_hostname:-$hostname}"
storage_hostname="storage.smoke.test"
browser_container=""
# choose_edge_network: a per-run edge subnet that no network on this Docker daemon already uses, so
# concurrent runs sharing a runner's daemon do not collide. Two runs can still pick the same free
@@ -69,6 +100,8 @@ choose_edge_network() {
tunnel_address="10.249.${octet}.253"
# A test-only client address, also outside the dynamic range, so its limiter bucket is its own.
session_client_address="10.249.${octet}.252"
# The headless browser's fixed address, likewise (split topology only).
browser_address="10.249.${octet}.251"
return 0
fi
done
@@ -105,6 +138,9 @@ compose=(
cleanup() {
local status=$?
trap - EXIT INT TERM
if [[ -n "$browser_container" ]]; then
docker rm --force "$browser_container" >/dev/null 2>&1 || true
fi
if [[ -f "$work/compose.env" ]]; then
"${compose[@]}" --profile ops down --volumes --remove-orphans --timeout 10 >/dev/null 2>&1 || status=1
fi
@@ -135,11 +171,32 @@ fail() {
exit 1
}
if [[ -z "$image" ]]; then
if [[ -z "$image" && "$topology" == same-origin ]]; then
step "Building the platform-only image"
built_image="ekklesia-deploy-smoke:${run_id}"
DOCKER_BUILDKIT=1 docker build --quiet --file deploy/Dockerfile --tag "$built_image" . >/dev/null
image="$built_image"
elif [[ -z "$image" ]]; then
# TEST-ONLY: the repository's module template, installed as the module `example`, in a copy of the
# working tree. Its lockfile importer moves with it; the depth is the same, so every relative link
# in that importer stays correct and the install stays frozen.
step "Building an image with the module template installed as a test-only module"
command -v git >/dev/null || fail "git is required to build the split topology's test image."
context="$work/build-context"
mkdir -p "$context"
git ls-files -z --cached --others --exclude-standard |
tar --null --files-from=- --create --file=- |
tar -C "$context" -xf -
mkdir -p "$context/modules"
mv "$context/templates/module" "$context/modules/example"
[[ "$(grep -c '^ templates/module:$' "$context/pnpm-lock.yaml")" == 1 ]] ||
fail "the lockfile has no single templates/module importer to move."
sed -i 's|^ templates/module:$| modules/example:|' "$context/pnpm-lock.yaml"
built_image="ekklesia-deploy-smoke:${run_id}"
DOCKER_BUILDKIT=1 docker build --quiet --file "$context/deploy/Dockerfile" --tag "$built_image" \
"$context" >/dev/null || fail "the test-module image did not build."
rm -rf "$context"
image="$built_image"
fi
# The compose project directory: its relative bind mount (postgres/pg_hba.conf) resolves here.
@@ -197,7 +254,10 @@ ekklesia_env="$work/env/ekklesia.env"
set_env "$ekklesia_env" DATABASE_URL "postgresql://ekklesia:${postgres_password}@postgres:5432/ekklesia"
set_env "$ekklesia_env" EKKLESIA_DATABASE_CA "${database_ca_line#EKKLESIA_DATABASE_CA=}"
set_env "$ekklesia_env" EKKLESIA_IDENTITY_BASE_URL "https://${hostname}"
set_env "$ekklesia_env" EKKLESIA_IDENTITY_TRUSTED_ORIGINS "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" S3_DELIVERY_ENDPOINT "https://${storage_hostname}"
set_env "$ekklesia_env" S3_ACCESS_KEY "$s3_access_key"
@@ -209,7 +269,7 @@ chmod 600 "$work"/env/*.env
# TEST-ONLY: Caddy's internal CA instead of ACME. Inserted as the first global option.
awk '!done && $0 == "{" { print; print "\tlocal_certs"; done = 1; next } { print }' \
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."
stage Caddyfile
@@ -218,6 +278,7 @@ EKKLESIA_IMAGE=${image}
EKKLESIA_ENV_DIR=${work}/env
EKKLESIA_TLS_DIR=${daemon_work}/tls
EKKLESIA_HOSTNAME=${hostname}
EKKLESIA_DASHBOARD_HOSTNAME=${dashboard_hostname}
EKKLESIA_STORAGE_HOSTNAME=${storage_hostname}
[email protected]
EKKLESIA_CADDYFILE=${daemon_work}/Caddyfile
@@ -281,13 +342,17 @@ stage client-ca.pem
stage client.mjs
edge_network="${project}_edge"
host_mappings=(--add-host "${hostname}:${proxy_address}" --add-host "${storage_hostname}:${proxy_address}")
if [[ -n "$dashboard_hostname" ]]; then
host_mappings+=(--add-host "${dashboard_hostname}:${proxy_address}")
fi
client() {
local mode="$1"
shift
docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
--network "$edge_network" "$@" \
--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 SMOKE_DASHBOARD_HOSTNAME="$dashboard_hostname" \
-e SMOKE_STORAGE_HOSTNAME="$storage_hostname" \
-e S3_ACCESS_KEY="$s3_access_key" -e S3_SECRET_KEY="$s3_secret_key" \
-v "$daemon_work/client.mjs:/smoke/client.mjs:ro" \
@@ -312,6 +377,35 @@ client session --ip "$session_client_address" \
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" ||
fail "session checks failed."
if [[ "$topology" == split ]]; then
step "Driving a real browser through the split Dashboard, a module, its display, and sign-out"
# Chromium trusts this run's proxy through the public-key hash of Caddy's internal intermediate CA,
# which signs every certificate the test proxy serves: no CA is installed anywhere, and nothing but
# this run's certificates is accepted.
"${compose[@]}" exec -T proxy cat /data/caddy/pki/authorities/local/intermediate.crt \
>"$work/caddy-intermediate.crt" || fail "the test proxy's intermediate CA is unavailable."
spki="$(openssl x509 -in "$work/caddy-intermediate.crt" -pubkey -noout |
openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl base64 -A)"
browser_container="${project}-browser"
# The image's entry script passes its arguments on unquoted, so none may contain a space: the
# hostnames reach Chromium through /etc/hosts, which it reads, rather than a resolver rule.
docker run --detach --rm --name "$browser_container" \
--cap-drop ALL --security-opt no-new-privileges --tmpfs /tmp \
--network "$edge_network" --ip "$browser_address" "${host_mappings[@]}" \
"$browser_image" \
"--ignore-certificate-errors-spki-list=${spki}" \
--user-data-dir=/tmp/profile >/dev/null || fail "the headless browser did not start."
cp scripts/lib/deploy-smoke-browser.mjs "$work/browser.mjs"
stage browser.mjs
docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
--network "$edge_network" \
-e SMOKE_CDP_ADDRESS="${browser_address}:9222" \
-e SMOKE_HOSTNAME="$hostname" -e SMOKE_DASHBOARD_HOSTNAME="$dashboard_hostname" \
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" \
-v "$daemon_work/browser.mjs:/smoke/browser.mjs:ro" \
--entrypoint node "$image" /smoke/browser.mjs || fail "browser checks failed."
fi
step "Proving the API's database connection is certificate-verified TLS"
api_container="$("${compose[@]}" ps -q api)"
api_data_address="$(