Commit Graph
4 Commits
Author SHA1 Message Date
ekklesiaandClaude Opus 5.5 a4966dd0dd feat: support split Dashboard and API origins (ADR 0045)
CI / verify (pull_request) Successful in 50s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Failing after 25s
CI / image-build (pull_request) Successful in 18s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m17s
An installation can now serve the Dashboard and the API on sibling
hostnames (dash./api.) beside the default same-origin topology, from one
image and with configuration alone.

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

ADR 0045 is marked Accepted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-03 11:28:03 -05:00
ekklesiaandClaude Opus 5.5 167b4c4f14 feat(deploy): add the reference deployment Compose project
CI / verify (pull_request) Successful in 54s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m48s
CI / image-build (pull_request) Successful in 27s
CI / deploy-smoke (pull_request) Successful in 42s
Implement ADR 0044's reference deployment as a base Compose file plus
overlays, so an installation that already runs a proxy can use the base
alone.

- compose.yaml: api, postgres, object-storage, and the operator-only
  migrate (profile ops, plan by default). No published port. api and
  migrate run init, read-only, tmpfs /tmp, cap_drop ALL,
  no-new-privileges. postgres is only on the internal data network.
- TLS everywhere from one installation-private CA: PostgreSQL TLS 1.3
  only, with pg_hba rejecting every non-TLS connection, and the API on
  verify-full via EKKLESIA_DATABASE_CA (PEM text). SeaweedFS serves S3
  over HTTPS (ADR 0030 requires https in production), trusted by the
  API through NODE_EXTRA_CA_CERTS. No Core change.
- compose.proxy.yaml + Caddyfile.example: Caddy pinned by digest, ACME
  HTTP-01 (DNS-01 documented), routes /, /api/*, /display/*, /assets/*
  to api and refuses everything else; the storage hostname preserves
  Host. The API trusts exactly the proxy's fixed /32 on an edge network
  whose dynamic range excludes the fixed addresses; Caddy overwrites
  X-Forwarded-For and strips caller-supplied forwarding headers.
- compose.tunnel.yaml: cloudflared pinned by digest, token from its own
  env file; Caddy honors CF-Connecting-IP only from its fixed address.
- Four per-service root-owned env files, examples with no values;
  scripts/init-tls.sh (idempotent, never overwrites a key) and
  scripts/gen-secrets.sh (prints only).
- CI: pnpm deploy:config in ci:verify for all three combinations, and a
  deploy-smoke job (scripts/smoke-deploy.sh) proving readiness, the
  Dashboard, signed storage delivery, the client-address chain, initial
  owner sign-in/out, verify-full database TLS, and Doctor through the
  proxy, against disposable services. tests/deploy.test.ts pins the
  structural guarantees.
- ADR 0044 (still Proposed) amended to match: API-to-storage HTTPS,
  DNS-01 needs a custom Caddy build and tunnel-only needs DNS-01, the
  tunnel's origin TLS settings live in the Cloudflare dashboard, port
  80 serves HTTP-01, and secrets are one env file per service.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 20:59:38 -05:00
ekklesiaandClaude Opus 5 27088fc054 fix: run the TLS integration harness under a containerized job
CI / verify (pull_request) Successful in 28s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 41s
The `migration-smoke` job fails on the Gitea runner at
`docker port ekklesia-database-tls-<pid> 5432` with
`no public port '5432' published`; the later invalid-URL and
`ECONNREFUSED 127.0.0.1:5432` failures all cascade from it.

Gitea executes the job inside a container and drives sibling containers
through the runner's Docker daemon. The harness held two host-only
assumptions, both reproduced in a nested job container and fixed here.

Bind-mount source paths are resolved by the daemon against the runner
host's filesystem. The certificate directory is created in the job
container's `/tmp`, which does not exist there, so the daemon silently
creates an empty directory, the entrypoint's `cp` fails, the container
exits, and `docker port` reports no published port for an exited
container. The material is now transferred with `docker cp` into the
created-but-not-yet-started container, which crosses the daemon boundary
correctly in both topologies, and only the server leaf and its key are
transferred — both CA private keys stay in the harness directory.

A published port lands on the runner host's loopback, a different
network namespace from the job container's; measured, a container the
daemon published at `127.0.0.1:32907` is `ECONNREFUSED` at that address
from inside the job container. Under a containerized job the harness now
publishes nothing and joins both containers to one isolated user-defined
network, reaching PostgreSQL by container DNS on 5432. Direct-host runs
keep a random port pinned to loopback via Docker's `127.0.0.1::5432`
form. `compose_job_network_job_container` exports the topology decision
the integration script already made, so there is still exactly one
detector.

Every TLS assertion is preserved, and the certificate identity is
generated from the host actually connected to — `localhost` directly, the
container's DNS name otherwise. The mismatch and chain-only controls use
the TLS container's address on the isolated network rather than a
hardcoded one, and the certificate carries no IP SAN, so verify-full
still fails with `ERR_TLS_CERT_ALTNAME_INVALID`.

Correcting one measurement in passing: chain-only verification does not
skip the identity check. For an IP host `pg` omits `servername` and hands
`tls.connect` a bare socket with no host, and `net.Socket._host` is null
for an IP literal, so Node checks the fixed fallback name `localhost` —
never the address dialled. A certificate whose only SAN is
`DNS:example.invalid`, reached chain-only over `127.0.0.1`, is rejected
with `Host: localhost. is not in the cert's altnames`. The direct-host
control therefore only ever passed because its SAN was `DNS:localhost`.
The SAN set now always includes that fallback name so the control proves
the same gap in both topologies, and the stale claim is corrected in the
transport doc comment. No production behavior changes.

Teardown also removes the container's anonymous volume, the isolated
network, and the job container's attachment, after success and failure
alike.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 21:42:26 -05:00
ekklesia 831a48187c test: add PostgreSQL migration integration coverage
CI / verify (pull_request) Successful in 19s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Failing after 16s
2026-07-21 09:37:15 -05:00