feat(journal): federation inbox — Mastodon follows land here

social-federation tasks 3.1–3.4, 4.1–4.8. With this, a Mastodon user
can follow a public trails user: WebFinger → actor fetch → signed
Follow → recorded + Accept(Follow) pushed back.

Identity surface (section 3):
- Actor objects now carry the user's public key (publicKey +
  assertionMethods via Fedify key pairs dispatcher; keys generated
  lazily as a fallback to the backfill) and an inbox IRI; url uses
  localActorIri (3.1).
- Software discovery shipped as standard NodeInfo
  (/.well-known/nodeinfo + /nodeinfo/2.1, software.name trails-cool)
  instead of the originally-sketched custom AS actor field — Fedify's
  typed vocab can't emit arbitrary actor props and NodeInfo is what
  the fediverse reads. Artifacts updated accordingly (3.4).

Inbox (section 4):
- /users/:username/inbox resource route; HTTP Signatures verified by
  Fedify before any listener runs (4.1). Rate-limited 60 req/5 min per
  source instance (host from Signature keyId) BEFORE verification so
  hostile instances can't burn CPU on key fetches (4.8).
- Listeners: Follow → auto-accept for public profiles + Accept pushed
  back (4.2); Undo(Follow) → row removed (4.3); Accept(Follow) →
  Pending settled + first outbox poll enqueued (4.4); Reject(Follow) →
  Pending dropped (4.5; UI notice deferred to 6.6). Unhandled types
  are acknowledged + dropped by Fedify (4.6).
- Replay protection via Fedify's KvStore, now Postgres-backed
  (journal.federation_kv + daily sweep job) so dedupe survives
  restarts (4.7).

Schema (discovered requirement):
- follows.follower_id relaxed to nullable + follows.follower_actor_iri
  for inbound remote followers — the proposal's 'follows is already
  federation-ready' only held for outbound. Check constraint enforces
  exactly one follower identity; partial unique index dedupes remote
  follows. Notification fan-out + approve flow now filter local
  followers explicitly. Design/proposal updated.

Tests: 7 inbox integration tests (accept/refuse/idempotence/undo/
settle/reject/check-constraint), 6 KvStore integration tests, 2 unit
tests for source-host extraction. All against real Postgres, gated on
FEDERATION_INTEGRATION=1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-06-06 14:33:43 +02:00
parent 9a90b6db55
commit b17685d58c
18 changed files with 804 additions and 61 deletions

View file

@ -106,6 +106,25 @@ Inbound signature verification uses the actor's public key from their actor obje
6. Flip `FEDERATION_ENABLED` on, soften the home blurb, document the federation runbook in `docs/deployment.md`.
7. Rollback: feature flag off (instant), or revert PR (recoverable). Existing `follows` rows stay intact; remote rows would need cleanup.
## Implementation Decisions (made during apply)
- **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
`users` row. Added `follower_actor_iri` with a check constraint enforcing
exactly one of (`follower_id`, `follower_actor_iri`), and a partial unique
index deduping `(follower_actor_iri, followed_user_id)`. Existing queries
filter on `follower_id`/`followed_user_id` and are unaffected; follower
counts now naturally include remote followers; notification fan-out
explicitly filters `follower_id IS NOT NULL`.
- **Software identity ships via NodeInfo, not a custom AS actor field**
(task 3.4). Fedify's typed vocab can't emit arbitrary actor properties, and
NodeInfo (`software.name: trails-cool`) is the standard the fediverse reads.
The trails-to-trails outbound check (task 6.x) should read remote NodeInfo,
with `/.well-known/trails-cool` as the secondary signal.
- **Fedify's KvStore is Postgres-backed** (`journal.federation_kv`) so inbox
replay protection and document caches survive restarts; swept daily.
## Open Questions
- **`activities.owner_id` is NOT NULL but remote-ingested rows have no local

View file

@ -45,7 +45,7 @@ Outbound federation is intentionally narrower: trails users can follow other **t
- **UI**: Pending state on Follow button, "outgoing follows" section listing Pending requests with cancel option, federation-aware empty states on `/feed`.
- **Federation surface**: real federation traffic crosses the public internet for the first time — push to followers, inbox processing, outbox polling. Rate-limited per-host on outbound, per-actor on inbound.
- **Dependencies**: Fedify (or chosen equivalent). No other heavy runtime additions.
- **Schema**: additive — `users.public_key`, `users.private_key_encrypted`, `remote_actors` table, `activities.remote_origin_iri/remote_actor_iri/audience`, no changes to `follows` (already federation-ready).
- **Schema**: additive — `users.public_key`, `users.private_key_encrypted`, `remote_actors` table, `activities.remote_origin_iri/remote_actor_iri/audience`, plus (discovered during implementation) `follows.follower_actor_iri` with `follower_id` relaxed to nullable: the original "no changes to `follows`" claim only held for *outbound* follows — an inbound remote follower has no local `users` row to reference, so the follower side needed its own IRI column (exactly-one-of enforced by check constraint). Also `federation_kv` (Fedify KvStore backing table for replay protection + caches).
- **Privacy manifest**: federation entry covering inbox/outbox traffic, remote actor cache, and what a remote instance learns when it fetches one of our actor objects.
- **Operational**: deploy raises real public-facing concerns — abuse-prone inbox endpoint, outbound rate limits, key rotation. Pre-launch checklist documented in deployment docs.
- **Out of scope** (tracked as later changes):

View file

@ -14,21 +14,23 @@
## 3. Identity surface
- [ ] 3.1 `localActorIri(username)` already exists from `social-feed`; ensure it's reused everywhere (no string concat on URLs)
- [ ] 3.2 Implement `/.well-known/webfinger?resource=acct:user@domain` returning JRD with `rel="self"` actor link (gated on `profile_visibility = 'public'`)
- [ ] 3.3 Content-negotiated handler at `/users/:username`: HTML for browsers, `application/activity+json` for AP clients (returning the `Person` actor object). Both gated on `profile_visibility = 'public'`
- [ ] 3.4 Actor object includes `software: trails.cool` (custom AS extension) so other instances can recognize us for the trails-to-trails outbound check
- [x] 3.1 `localActorIri(username)` already exists from `social-feed`; ensure it's reused everywhere (no string concat on URLs)
- [x] 3.2 Implement `/.well-known/webfinger?resource=acct:user@domain` returning JRD with `rel="self"` actor link (gated on `profile_visibility = 'public'`)
- [x] 3.3 Content-negotiated handler at `/users/:username`: HTML for browsers, `application/activity+json` for AP clients (returning the `Person` actor object). Both gated on `profile_visibility = 'public'`
- [x] 3.4 Actor object includes `software: trails.cool` (custom AS extension) so other instances can recognize us for the trails-to-trails outbound check
> Adapted during implementation: shipped as a standard **NodeInfo** endpoint (`/.well-known/nodeinfo` + `/nodeinfo/2.1`, `software.name: trails-cool`) instead of a custom AS field — Fedify's typed vocab can't emit arbitrary actor properties, and NodeInfo is what the fediverse actually reads for software identity. `/.well-known/trails-cool` remains as the secondary discovery surface.
## 4. Inbox
- [ ] 4.1 Inbox endpoint at `/users/:username/inbox`. Verifies HTTP Signature on every request before any further processing
- [ ] 4.2 Handle `Follow`: gate on local user's `profile_visibility`, write follow row, push `Accept(Follow)` back, return 202
- [ ] 4.3 Handle `Undo(Follow)`: delete the matching row, return 202
- [ ] 4.4 Handle `Accept(Follow)`: settle our outgoing Pending row's `accepted_at`, enqueue a one-off outbox-poll for the just-accepted actor, return 202
- [ ] 4.5 Handle `Reject(Follow)`: delete our outgoing follow row, surface in user's outgoing-follows list, return 202
- [ ] 4.6 Handle every other activity type: return 202 and drop silently (logged at debug)
- [ ] 4.7 Replay protection: dedupe on activity IRI for follow-graph activities (small hot table or Redis set)
- [ ] 4.8 Per-source-instance rate limit on inbox: 60 requests / 5 min from any single host
- [x] 4.1 Inbox endpoint at `/users/:username/inbox`. Verifies HTTP Signature on every request before any further processing
- [x] 4.2 Handle `Follow`: gate on local user's `profile_visibility`, write follow row, push `Accept(Follow)` back, return 202
- [x] 4.3 Handle `Undo(Follow)`: delete the matching row, return 202
- [x] 4.4 Handle `Accept(Follow)`: settle our outgoing Pending row's `accepted_at`, enqueue a one-off outbox-poll for the just-accepted actor, return 202
- [x] 4.5 Handle `Reject(Follow)`: delete our outgoing follow row, surface in user's outgoing-follows list, return 202
> Protocol part done; the UI notice lands with the outgoing-follows list (task 6.6).
- [x] 4.6 Handle every other activity type: return 202 and drop silently (logged at debug)
- [x] 4.7 Replay protection: dedupe on activity IRI for follow-graph activities (small hot table or Redis set)
- [x] 4.8 Per-source-instance rate limit on inbox: 60 requests / 5 min from any single host
## 5. Outbox + push delivery