trails/e2e/federation/docker-compose.yml
Ullrich Schäfer 1eceb6f1f7 feat(journal): two-instance federation harness + trails↔trails Accept fix (§11)
The 11.4 harness — e2e/federation/: postgres (two DBs) + a Caddy with
an internal CA terminating real HTTPS between two complete journal
containers (journal-a.test / journal-b.test), driven by an opt-in
integration test (FEDERATION_TWO_INSTANCE=1, via run.sh). The driver
seeds users straight into each DB, mints a signed session cookie
(known SESSION_SECRET), follows across instances through the real
/follows/outgoing route, and asserts the full pipeline: Follow →
auto-Accept → settle → first outbox poll → bob's public activity
rendered in alice's /feed with @bob@journal-b.test attribution. Plus a
wire-level 11.3 check: unsigned Create(Note) → 4xx, no DB writes.

And the harness immediately earned its keep — it caught a real
trails↔trails bug Mastodon interop never could: our Follow ids are
fragment URIs on OUR domain, so the Follow embedded in another trails
instance's Accept is cross-origin and Fedify rightly distrusts it;
re-fetching the id returns the actor document, getObject() yields a
Person, and the Accept/Reject/Undo listeners bailed silently — follows
stayed Pending forever. The listeners now fall back to the wire
objectId (captured BEFORE getObject(), which memoizes the fetched
document and changes what objectId reports) validated against our
Follow-id shape + the personal inbox's ctx.recipient. Forgery-safe:
settleOutgoingFollow only matches a Pending row toward the
HTTP-Signature-authenticated sender.

Supporting changes:
- federation.server.ts: env-gated allowPrivateAddress
  (FEDERATION_ALLOW_PRIVATE_ADDRESS=true) so the harness's RFC 1918
  Docker network is fetchable — testing only, loudly documented.
- Root .dockerignore: local builds shipped a 1GB+ context and
  overlaid host (darwin) node_modules over the image's own install
  via COPY . . — CI never noticed because it builds from clean
  checkouts. Context is now ~5MB.
- tasks.md: 11.1–11.7 marked with provenance notes; design.md gains
  the cross-origin embedded-object lesson.

Gate: typecheck ✓ lint ✓ unit+integration (FEDERATION_INTEGRATION=1) ✓
e2e 71/72 + known komoot flake green isolated ✓ harness run clean from
scratch (2/2, teardown included) ✓

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 13:36:35 +02:00

145 lines
4.8 KiB
YAML

# Two-instance federation harness (social-federation task 11.4).
#
# Brings up two complete journal instances — journal-a.test and
# journal-b.test — that federate with each other over a private Docker
# network, with real HTTPS between them (Caddy internal CA; the journals
# trust it via NODE_EXTRA_CA_CERTS). Driven by
# apps/journal/app/lib/federation-two-instance.integration.test.ts via
# ./run.sh — see README.md.
#
# Ports published to the host (driver-facing, loopback only):
# 127.0.0.1:5499 → postgres (schema push + seeding + asserts)
# 127.0.0.1:3401 → journal-a :3000 (plain HTTP; the TLS hop is only
# 127.0.0.1:3402 → journal-b :3000 needed for instance↔instance)
name: trails-federation-e2e
# Shared journal environment. Secrets here are throwaway test values —
# the whole stack is loopback-only and torn down by run.sh.
x-journal-env: &journal-env
SESSION_SECRET: two-instance-test-secret
FEDERATION_ENABLED: "true"
FEDERATION_KEY_ENCRYPTION_KEY: two-instance-test-encryption-key
FEDERATION_LOG_LEVEL: debug
LOG_LEVEL: debug
# Both instances live on RFC 1918 Docker addresses; Fedify's SSRF
# guard must be told to stand down. Testing only — see the comment in
# apps/journal/app/lib/federation.server.ts.
FEDERATION_ALLOW_PRIVATE_ADDRESS: "true"
# Trust Caddy's internal CA for the instance↔instance HTTPS hop.
# Published world-readable by the ca-publisher one-shot — the journal
# runs as a non-root user and can't read caddy's 0700 data volume.
NODE_EXTRA_CA_CERTS: /ca/root.crt
SENTRY_DISABLED: "true"
services:
postgres:
image: imresamu/postgis:16-3.4 # multi-arch, same as docker-compose.dev.yml
environment:
POSTGRES_USER: trails
POSTGRES_PASSWORD: trails
POSTGRES_DB: trails_a
volumes:
- ./init-dbs.sql:/docker-entrypoint-initdb.d/90-init-dbs.sql:ro
ports:
- "127.0.0.1:5499:5432"
healthcheck:
# -h 127.0.0.1 forces TCP: during first-boot initdb a temporary
# postgres answers on the unix socket only, and a socket-based
# pg_isready reports healthy before the init scripts have run.
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U trails -d trails_b"]
interval: 2s
timeout: 3s
retries: 30
caddy:
image: caddy:2-alpine
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
networks:
default:
# The instances reach each other through Caddy: these aliases
# make journal-a.test / journal-b.test resolve to the proxy
# inside the compose network.
aliases:
- journal-a.test
- journal-b.test
healthcheck:
# The journals read the internal CA root at process start
# (NODE_EXTRA_CA_CERTS) — gate their startup on it existing.
test: ["CMD", "test", "-f", "/data/caddy/pki/authorities/local/root.crt"]
interval: 2s
timeout: 3s
retries: 30
# One-shot: copies Caddy's internal root CA into a volume the
# non-root journal user can actually read (caddy's own data dir is
# 0700 root).
ca-publisher:
image: caddy:2-alpine
entrypoint:
- sh
- -c
- |
until [ -f /data/caddy/pki/authorities/local/root.crt ]; do sleep 0.5; done
cp /data/caddy/pki/authorities/local/root.crt /ca/root.crt
chmod 444 /ca/root.crt
volumes:
- caddy-data:/data:ro
- ca-cert:/ca
depends_on:
caddy:
condition: service_healthy
journal-a:
build:
context: ../..
dockerfile: apps/journal/Dockerfile
image: trails-federation-e2e-journal
pull_policy: never
environment:
<<: *journal-env
DATABASE_URL: postgres://trails:trails@postgres:5432/trails_a
ORIGIN: https://journal-a.test
ports:
- "127.0.0.1:3401:3000"
volumes:
- ca-cert:/ca:ro
healthcheck: &journal-health
test: ["CMD", "curl", "-fsS", "http://localhost:3000/api/health"]
interval: 3s
timeout: 5s
retries: 40
start_period: 10s
depends_on:
postgres:
condition: service_healthy
ca-publisher:
condition: service_completed_successfully
journal-b:
# Image-only on purpose: run.sh builds it once via journal-a before
# `up`. Building from both services races the export of the shared
# tag ("already exists"), and a registry pull must never happen.
image: trails-federation-e2e-journal
pull_policy: never
environment:
<<: *journal-env
DATABASE_URL: postgres://trails:trails@postgres:5432/trails_b
ORIGIN: https://journal-b.test
ports:
- "127.0.0.1:3402:3000"
volumes:
- ca-cert:/ca:ro
healthcheck: *journal-health
depends_on:
postgres:
condition: service_healthy
ca-publisher:
condition: service_completed_successfully
volumes:
caddy-data:
ca-cert: