Follow-up to social-feed. Adds real ActivityPub: per-user actor objects, WebFinger, signed inbox/outbox, push delivery on local activity create, signed outbox-poll for remote trails actors. Asymmetric scope keeps v1 small: - Inbound: anyone (Mastodon, Pleroma, …) can follow a trails user and receive their public activities via push delivery. - Outbound: trails users can follow other *trails* instances only. Following Mastodon/Pleroma/Misskey is refused at the API layer with a clear "v1 limitation" message. This avoids the engineering tar pit of robustly parsing arbitrary AP vocabulary (Notes with attachments, polls, mentions, content warnings, threads) while still delivering the core federation pitch. Capabilities: - New: social-federation - Modified: social-follows (remote follows + Pending lifecycle) - Modified: public-profiles (Pending button state, actor object gate) - Modified: infrastructure (Fedify dep, key management runbook) - Modified: security-hardening (HTTP Signatures, signed-fetch policy) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7 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/webfingerresolvingacct:user@domainto the user's actor IRI. - Serve a per-user
Personactor object athttps://{DOMAIN}/users/:username(the same URL the human profile renders at — content negotiation byAcceptheader). The actor object SHALL be served only whenprofile_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 (noCreate, noLike, noAnnouncein v1):Followfrom any AP-compatible remote — auto-Acceptif our user isprofile_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'spublicactivities asCreate(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 apublicactivity, fan out aCreateactivity 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-coolor 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.activitieswithremote_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_actorstable 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 awaitingAccept, extend the social feed query to include audience-aware remote activities.public-profiles: gate the actor object endpoint onprofile_visibility = 'public'; add a Pending state to the Follow button when an outgoing follow is awaitingAccept.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_actorscache table. - API: 4 new public-internet endpoints per user (
/.well-known/webfinger,/users/:usernamecontent-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_actorstable,activities.remote_origin_iri/remote_actor_iri/audience, no changes tofollows(already federation-ready). - 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.