trails/openspec/changes/social-federation/proposal.md
Ullrich Schäfer b17685d58c 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>
2026-06-06 14:33:43 +02:00

7.4 KiB

Why

social-feed shipped local-only follows + /feed with a forward-compatible schema, but trails.cool's federation pitch — talking to other ActivityPub servers — is currently aspirational. To deliver on it, we need to actually integrate ActivityPub: actor objects, WebFinger, HTTP-signed inbox/outbox, and per-user keypairs.

The first concrete user-visible win is inbound federation: anyone with a Mastodon (or other ActivityPub-compatible) account can follow a trails.cool user and see their public activities in their home timeline. That gives our users immediate reach into the existing fediverse without us having to recruit them onto trails.cool.

Outbound federation is intentionally narrower: trails users can follow other trails instances. We don't need to follow Mastodon users for the v1 of federation — keeping outbound trails-to-trails sidesteps having to robustly parse Mastodon's full activity vocabulary (Notes with attachments, polls, mentions, reblogs, content warnings, …). It also means our "remote outbox" parser only ever has to consume our own activity shape.

What Changes

  • Adopt Fedify (or the closest equivalent we evaluate) as the ActivityPub library on the Journal. No new heavy dependency surface beyond it.
  • Generate a per-user RSA (or Ed25519) keypair at registration; store securely. Existing users get keypairs backfilled at deploy time.
  • Serve a WebFinger endpoint at /.well-known/webfinger resolving acct:user@domain to the user's actor IRI.
  • Serve a per-user Person actor object at https://{DOMAIN}/users/:username (the same URL the human profile renders at — content negotiation by Accept header). The actor object SHALL be served only when profile_visibility = 'public'; private profiles 404 the actor too.
  • Serve a per-user inbox at https://{DOMAIN}/users/:username/inbox. The inbox accepts a small fixed set of activities and rejects everything else (no Create, no Like, no Announce in v1):
    • Follow from any AP-compatible remote — auto-Accept if our user is profile_visibility = 'public'.
    • Undo(Follow) — remove the follow row.
    • Accept(Follow) — settle our own outgoing Pending follow.
    • Reject(Follow) — delete our own outgoing follow row.
  • Serve a per-user outbox at https://{DOMAIN}/users/:username/outbox, paginated, listing the user's public activities as Create(Note) (or a custom AS extension type — design decision deferred). All outbox responses honor signed-fetch challenges.
  • Push delivery on local activity create: when a local user with profile_visibility = 'public' posts a public activity, fan out a Create activity to every accepted remote follower's inbox via signed POST.
  • Outbound follows trails-to-trails only: a follow originated from a trails user against a remote IRI is allowed only if the remote actor self-identifies as a trails.cool instance (e.g. by hosting /.well-known/trails-cool or a recognizable software field on the actor object). Remote-actor IRIs that fail this check are refused at the API layer with a clear "outbound federation is currently trails-to-trails only" message.
  • Outbox-poll ingestion for remote trails actors we follow: signed GETs against their outbox, store new activities locally for feed display. (Asymmetric with inbound: we don't expect Mastodon to push us anything, and we don't poll Mastodon outboxes.)
  • Audience-aware storage: extend journal.activities with remote_origin_iri, remote_actor_iri, audience (public | followers-only) so followers-only content from a remote trails actor only reaches the local follower whose follow brought it in.
  • Remote actor cache: a remote_actors table for display name, avatar, outbox URL, public key — refreshed during outbox polls so feed cards don't re-fetch on every render.
  • Update privacy manifest to document the federation footprint: which remote inboxes our outbox delivers to, what data is exposed in the actor object (display name, avatar, public key), and the per-instance log of incoming/outgoing federation requests.

Capabilities

New Capabilities

  • social-federation: ActivityPub integration — actor objects, WebFinger, signed inbox/outbox, push delivery, remote outbox polling, and the trails-to-trails outbound restriction.

Modified Capabilities

  • social-follows: extend follows to remote actor IRIs (the schema column already supports this), add Pending lifecycle for outbound trails-to-trails follows awaiting Accept, extend the social feed query to include audience-aware remote activities.
  • public-profiles: gate the actor object endpoint on profile_visibility = 'public'; add a Pending state to the Follow button when an outgoing follow is awaiting Accept.
  • infrastructure: add Fedify dependency, document key-management runbook, document the federation outbox/inbox endpoints in the deployment doc.
  • security-hardening: HTTP Signatures on all outgoing federation requests, signature verification on all inbound, signed-fetch (Authorized Fetch) policy.

Impact

  • Code: Fedify integration on the Journal, per-user keypair generation + storage, inbox + outbox + WebFinger + actor-object route handlers, push-delivery worker (pg-boss job per outgoing activity), outbox-poll worker (pg-boss recurring job), trails-instance discovery helper for the trails-to-trails outbound check, audience-aware storage on journal.activities, remote_actors cache table.
  • API: 4 new public-internet endpoints per user (/.well-known/webfinger, /users/:username content-negotiated for AP, /users/:username/inbox, /users/:username/outbox).
  • 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, 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):
    • Outbound from trails to Mastodon (fully bidirectional with non-trails servers). Adds robust handling of arbitrary AP vocabulary; revisit once trails-to-trails is stable and we have user demand.
    • Locked local accounts (locked-local-accounts) — that change adds the third profile state and the manual-approve UX.
    • Content types beyond Create(Note): Like, Announce (boost), reply threading.