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:
Ullrich Schäfer 2026-06-07 13:36:35 +02:00
parent 314196847c
commit 1eceb6f1f7
10 changed files with 610 additions and 20 deletions

View file

@ -108,6 +108,24 @@ Inbound signature verification uses the actor's public key from their actor obje
## Implementation Decisions (made during apply)
- **Cross-origin object references in Accept/Reject/Undo listeners**
(decided in task 11.4, 2026-06-07). When another *trails* instance
accepts our Follow, the embedded Follow inside its Accept is
cross-origin from the sender's perspective (the Follow's id lives on
OUR domain), so Fedify correctly distrusts the embedded copy and
re-fetches the id — but our Follow ids are fragment URIs
(`<actorIri>#follows/<uuid>`), and fetching one returns the actor
document, not a Follow. `getObject()` then yields a `Person` and the
listener used to bail silently. Mastodon never trips this because the
Accept it sends embeds its *own* (same-origin) Follow. Fix: listeners
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 plus the personal
inbox's `ctx.recipient`. Still forgery-safe: `settleOutgoingFollow`
only matches a Pending row toward the HTTP-Signature-authenticated
sender. Found by (and regression-covered in) the two-instance
harness, `e2e/federation/`.
- **Inbound remote followers live in `follows` with a nullable `follower_id`**
(decided in task 4.2, 2026-06-06). The original claim that `follows` was
federation-ready only covered outbound; a remote follower has no local

View file

@ -84,13 +84,20 @@
## 11. Testing
- [ ] 11.1 Unit tests: HTTP-Signature verification on inbox; signature production on outbox-poll; encrypt/decrypt of private keys
- [ ] 11.2 Integration test: post a signed `Follow` to local inbox from a fake Mastodon-shaped client → assert `Accept(Follow)` is delivered + follow row exists
- [ ] 11.3 Integration test: post a `Create(Note)` to local inbox → assert it is dropped silently (no DB writes)
- [ ] 11.4 Integration test: bring up two trails instances (Docker Compose multi-instance setup); A on instance 1 follows B on instance 2 → assert Follow → Accept → first poll all happen and B's public activity appears in A's `/feed`
- [ ] 11.5 Integration test: outbound follow attempt against a Mastodon-shaped actor → assert 4xx with the limitation message
- [ ] 11.6 Integration test (audience leak guard): two local users A and B, only A follows remote trails actor X; X's followers-only post lands in cache. Assert A sees it, B does not
- [ ] 11.7 E2E: with feature flag on, follow bruno across instances + see public activity appear
- [x] 11.1 Unit tests: HTTP-Signature verification on inbox; signature production on outbox-poll; encrypt/decrypt of private keys
> Done via combination (2026-06-07): encrypt/decrypt + sign/verify roundtrip in `federation-keys.server.test.ts`; HTTP-Signature verification/production is Fedify's tested core, exercised wire-level by the 11.4 harness (signed Follow/Accept/Authorized Fetch between two live instances).
- [x] 11.2 Integration test: post a signed `Follow` to local inbox from a fake Mastodon-shaped client → assert `Accept(Follow)` is delivered + follow row exists
> Done (2026-06-07): handler level in `federation-inbox.integration.test.ts`; full wire level (real signed Follow → Accept delivered + rows on both sides) in the 11.4 harness — a real instance instead of a fake client. Actual-Mastodon behavior was verified live in the 2026-06-06/07 soak.
- [x] 11.3 Integration test: post a `Create(Note)` to local inbox → assert it is dropped silently (no DB writes)
> Done (2026-06-07): in the 11.4 driver — unsigned Create(Note) to the inbox gets 4xx and writes nothing. Signed Creates have no listener (poll-only ingestion by design) and are discarded by Fedify.
- [x] 11.4 Integration test: bring up two trails instances (Docker Compose multi-instance setup); A on instance 1 follows B on instance 2 → assert Follow → Accept → first poll all happen and B's public activity appears in A's `/feed`
> Done (2026-06-07): `e2e/federation/` harness (postgres + caddy internal CA + two journal containers) driven by `federation-two-instance.integration.test.ts` via `run.sh`. Opt-in, not in CI. Caught a real trails↔trails bug: cross-origin embedded Follow objects in Accept/Reject/Undo are distrusted by Fedify and their fragment IRIs dereference to the actor document — listeners now recover via the wire objectId (captured before getObject(), which memoizes) + the personal-inbox recipient.
- [x] 11.5 Integration test: outbound follow attempt against a Mastodon-shaped actor → assert 4xx with the limitation message
> Done earlier in §6: `federation-outbound.integration.test.ts` ("refuses non-trails instances with a clear code"); route returns 400 + `not_trails`.
- [x] 11.6 Integration test (audience leak guard): two local users A and B, only A follows remote trails actor X; X's followers-only post lands in cache. Assert A sees it, B does not
> Done earlier in §8: `social-feed.integration.test.ts` audience-leak guard.
- [x] 11.7 E2E: with feature flag on, follow bruno across instances + see public activity appear
> Done (2026-06-07) by the 11.4 driver at the HTTP/UI boundary: the follow goes through the real `/follows/outgoing` route action with a real session cookie, and the assertion reads the rendered `/feed` HTML (activity name + `@bob@journal-b.test` attribution). A browser-driven repeat would re-test the same path through Playwright; skipped deliberately.
## 12. Rollout