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>
This commit is contained in:
parent
314196847c
commit
1eceb6f1f7
10 changed files with 610 additions and 20 deletions
16
e2e/federation/Caddyfile
Normal file
16
e2e/federation/Caddyfile
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# TLS terminator for the two-instance federation harness. `local_certs`
|
||||
# issues every site cert from Caddy's internal CA; the journals trust it
|
||||
# via NODE_EXTRA_CA_CERTS (see docker-compose.yml).
|
||||
{
|
||||
local_certs
|
||||
# No public reachability — don't try (or wait on) ACME/OCSP anything.
|
||||
skip_install_trust
|
||||
}
|
||||
|
||||
journal-a.test {
|
||||
reverse_proxy journal-a:3000
|
||||
}
|
||||
|
||||
journal-b.test {
|
||||
reverse_proxy journal-b:3000
|
||||
}
|
||||
56
e2e/federation/README.md
Normal file
56
e2e/federation/README.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# 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
|
||||
|
||||
```bash
|
||||
./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
|
||||
|
||||
```bash
|
||||
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.
|
||||
145
e2e/federation/docker-compose.yml
Normal file
145
e2e/federation/docker-compose.yml
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
# 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:
|
||||
|
||||
6
e2e/federation/init-dbs.sql
Normal file
6
e2e/federation/init-dbs.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
-- Second instance database. trails_a is the image's POSTGRES_DB (the
|
||||
-- postgis image creates the extension there itself); trails_b needs
|
||||
-- both created by hand. Runs once on first postgres boot.
|
||||
CREATE DATABASE trails_b;
|
||||
\connect trails_b
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
55
e2e/federation/run.sh
Executable file
55
e2e/federation/run.sh
Executable file
|
|
@ -0,0 +1,55 @@
|
|||
#!/usr/bin/env bash
|
||||
# Two-instance federation harness runner (social-federation 11.4).
|
||||
#
|
||||
# ./e2e/federation/run.sh # build, test, tear down
|
||||
# KEEP_STACK=1 ./e2e/federation/run.sh # leave the stack up for poking
|
||||
#
|
||||
# Brings up two journal instances that federate with each other (see
|
||||
# docker-compose.yml), pushes the Drizzle schema into both databases,
|
||||
# and runs the driver test. Opt-in and self-contained — nothing here is
|
||||
# wired into `pnpm test` or CI.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
compose() { docker compose -f "$SCRIPT_DIR/docker-compose.yml" "$@"; }
|
||||
|
||||
cleanup() {
|
||||
if [[ "${KEEP_STACK:-}" == "1" ]]; then
|
||||
echo "KEEP_STACK=1 — leaving the stack up. Tear down with:"
|
||||
echo " docker compose -f $SCRIPT_DIR/docker-compose.yml down -v"
|
||||
else
|
||||
compose down -v --remove-orphans >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "==> postgres + caddy (internal CA)"
|
||||
compose up -d --wait postgres caddy
|
||||
|
||||
echo "==> pushing schema into trails_a and trails_b"
|
||||
push_schema() {
|
||||
# A transient postgres backend crash mid-introspection has been seen
|
||||
# under Docker Desktop load ("Connection terminated unexpectedly" +
|
||||
# crash recovery). Retry after the healthcheck reports the server
|
||||
# recovered — without hiding a deterministic failure.
|
||||
local url="$1" attempt
|
||||
for attempt in 1 2 3; do
|
||||
if DATABASE_URL="$url" pnpm --dir "$ROOT" db:push; then return 0; fi
|
||||
echo "schema push failed (attempt $attempt) — waiting for postgres to recover"
|
||||
sleep 5
|
||||
compose up -d --wait postgres
|
||||
done
|
||||
return 1
|
||||
}
|
||||
push_schema postgres://trails:trails@127.0.0.1:5499/trails_a
|
||||
push_schema postgres://trails:trails@127.0.0.1:5499/trails_b
|
||||
|
||||
echo "==> building the journal image (slow on first run)"
|
||||
compose build journal-a
|
||||
|
||||
echo "==> journal-a + journal-b"
|
||||
compose up -d --wait journal-a journal-b
|
||||
|
||||
echo "==> driving the federation flow"
|
||||
FEDERATION_TWO_INSTANCE=1 pnpm --dir "$ROOT/apps/journal" exec vitest run app/lib/federation-two-instance.integration.test.ts
|
||||
Loading…
Add table
Add a link
Reference in a new issue