trails/e2e/federation
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
..
Caddyfile feat(journal): two-instance federation harness + trails↔trails Accept fix (§11) 2026-06-07 13:36:35 +02:00
docker-compose.yml feat(journal): two-instance federation harness + trails↔trails Accept fix (§11) 2026-06-07 13:36:35 +02:00
init-dbs.sql feat(journal): two-instance federation harness + trails↔trails Accept fix (§11) 2026-06-07 13:36:35 +02:00
README.md feat(journal): two-instance federation harness + trails↔trails Accept fix (§11) 2026-06-07 13:36:35 +02:00
run.sh feat(journal): two-instance federation harness + trails↔trails Accept fix (§11) 2026-06-07 13:36:35 +02:00

Two-instance federation harness

Spec: openspec/changes/social-federation/ task 11.4.

Two complete journal instances — journal-a.test and journal-b.test — federate with each other inside a Docker network, with real HTTPS between them (a Caddy with local_certs terminates TLS; the journals trust its internal CA via NODE_EXTRA_CA_CERTS). The driver test seeds a user on each side, follows across instances through the real UI route, and asserts the full pipeline:

alice@journal-a  ──Follow──▶  bob@journal-b   (NodeInfo + WebFinger +
                ◀──Accept──                    actor fetch over TLS)
                ──signed outbox poll──▶
alice's /feed shows bob's public activity with @bob@journal-b.test

Run it

./e2e/federation/run.sh              # build, test, tear down
KEEP_STACK=1 ./e2e/federation/run.sh # leave the stack up afterwards

First run builds the journal image (a few minutes); later runs reuse it. Opt-in only — nothing here runs under pnpm test, pnpm test:e2e, or CI. The driver lives at apps/journal/app/lib/federation-two-instance.integration.test.ts (gated by FEDERATION_TWO_INSTANCE=1).

How the driver authenticates

Sessions are signed cookies (auth/session.server.ts). The harness journals run with a known SESSION_SECRET, so the driver mints a valid __session cookie for the seeded user instead of driving the WebAuthn ceremony. That secret (and everything else in the compose env) is a throwaway test value on a loopback-only stack.

Why FEDERATION_ALLOW_PRIVATE_ADDRESS

Fedify's document loader refuses private network addresses (SSRF guard). Both instances here live on RFC 1918 Docker addresses, so the harness sets FEDERATION_ALLOW_PRIVATE_ADDRESS=true — the env-gated opt-out in federation.server.ts. Never set it in a real deployment.

Debugging

docker compose -f e2e/federation/docker-compose.yml logs -f journal-a
docker compose -f e2e/federation/docker-compose.yml exec postgres \
  psql -U trails trails_a -c 'TABLE journal.follows'

FEDERATION_LOG_LEVEL=debug is already on — Fedify logs every signature verification and delivery attempt.