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:
parent
9a90b6db55
commit
b17685d58c
18 changed files with 804 additions and 61 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue