Propose: social-federation (ActivityPub via Fedify)
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>
This commit is contained in:
parent
172d980a24
commit
bc6059ac77
7 changed files with 416 additions and 0 deletions
2
openspec/changes/social-federation/.openspec.yaml
Normal file
2
openspec/changes/social-federation/.openspec.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-04-25
|
||||
113
openspec/changes/social-federation/design.md
Normal file
113
openspec/changes/social-federation/design.md
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
## Context
|
||||
|
||||
After `social-feed` lands, the Journal has follows + a `/feed` view + explicit `profile_visibility`, all local-only. The schema (`follows.followed_actor_iri`, `users.profile_visibility`) is already shaped for federation but no federation code exists.
|
||||
|
||||
This change adds ActivityPub. The asymmetric scope (inbound from anyone; outbound to trails-only) is a deliberate constraint to keep the parser surface small while still delivering the user-visible win of "Mastodon can follow me."
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- A Mastodon (or any AP-compatible) user can follow a trails.cool user and see their public activities in their home timeline.
|
||||
- A trails.cool user can follow another trails.cool user on a different trails.cool instance, see their public activities in `/feed`, and unfollow cleanly.
|
||||
- All federation traffic is HTTP-signed; signatures are verified on inbound; private keys are stored encrypted at rest.
|
||||
- Private profiles do not federate at all — actor IRI 404s, no inbox accepts follows.
|
||||
- Inbox is narrow: only `Follow`, `Undo(Follow)`, `Accept(Follow)`, `Reject(Follow)`. Everything else is rejected.
|
||||
|
||||
**Non-Goals:**
|
||||
- Outbound follows of non-trails ActivityPub servers (Mastodon, Pleroma, Misskey, etc). Tracked as a later change.
|
||||
- Inbound `Create`, `Like`, `Announce`, `Update`, `Delete` activities — anything other than the four follow-graph activities. We do not consume content via push.
|
||||
- Content types beyond `Create(Note)` for outgoing. Replies, boosts, reactions: deferred.
|
||||
- Locked local accounts (`locked-local-accounts`).
|
||||
- Manually-driven federation (admin "broadcast" tools, federation-block lists, etc) — out of scope; can be added once basic federation works.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision: Library — Fedify (assumed; revisit during implementation)
|
||||
|
||||
Pick **Fedify** as the ActivityPub library. It owns: actor objects, WebFinger, HTTP Signatures, inbox dispatch, outbox composition, document loader, key management primitives. Mature, TypeScript-native, used by other small fediverse projects.
|
||||
|
||||
**Why:** rolling our own AP wire layer is multiple weeks of careful spec-reading + testing. Fedify lets us own only the *trails-specific* parts (which activities to dispatch, how to map our `activities` table to AS objects, how to gate visibility).
|
||||
|
||||
**Risk:** if Fedify falls short on something specific (e.g., we want a custom outbox-pagination format that doesn't fit), we reach into its lower-level primitives or augment it. We don't fork.
|
||||
|
||||
**Alternative considered:** `activitypub-express` (Node middleware), or rolling our own. Both reject for time-to-ship reasons; revisit only if Fedify proves limiting.
|
||||
|
||||
### Decision: Identity model — actor IRI is `https://{DOMAIN}/users/:username`, content-negotiated
|
||||
|
||||
The same URL serves the human profile (HTML) and the actor object (`application/activity+json`). Mastodon does this; it keeps URLs canonical and shareable.
|
||||
|
||||
`/.well-known/webfinger?resource=acct:user@domain` returns the actor IRI in the standard JRD format.
|
||||
|
||||
### Decision: Per-user keypairs, encrypted at rest
|
||||
|
||||
Generate an RSA 2048 keypair per user at registration (or backfilled at deploy for existing users). Public key sits on the actor object, available to any inbound signature verifier. Private key encrypted with a server-side key (env var `FEDERATION_KEY_ENCRYPTION_KEY`, SOPS-managed) and stored on `users.private_key_encrypted`.
|
||||
|
||||
**Why per-user, not per-instance:** ActivityPub binds signatures to actor identity. Every `Follow` we emit is signed by the *user* who initiated it, not the instance. Per-user keys are required by the protocol and standard practice.
|
||||
|
||||
**Risk:** rotating an encryption key requires a backfill. Documented in the deployment runbook.
|
||||
|
||||
### Decision: Inbound — accept everyone; reject everything but follow-graph activities
|
||||
|
||||
Our inbox responds 202 Accepted to:
|
||||
- `Follow` (from anywhere) → if our user is `profile_visibility = 'public'`, record the row, push back `Accept(Follow)`. Otherwise drop silently (the actor IRI 404s already so well-behaved remotes don't try).
|
||||
- `Undo(Follow)` → delete the row.
|
||||
- `Accept(Follow)` → settle our outgoing Pending row's `accepted_at`.
|
||||
- `Reject(Follow)` → delete our outgoing Pending row.
|
||||
|
||||
Every other activity type returns 202 Accepted (per AP recommendation — never 4xx on inbound to avoid breaking remotes' delivery state) but is dropped without action.
|
||||
|
||||
**Why narrow inbox:** content via push is the lion's share of attack surface and complexity in fediverse software. Outbox polling for remote trails actors gives us content delivery without exposing our inbox to arbitrary `Create`s. We reconsider once we want to follow non-trails actors.
|
||||
|
||||
### Decision: Outbound — trails-to-trails only, gated at the API layer
|
||||
|
||||
Before allowing an outbound follow, verify the target IRI's host runs trails.cool. The check:
|
||||
1. Resolve the target's actor object via WebFinger or direct IRI fetch.
|
||||
2. Look at the actor's `software` field (a custom AS extension trails.cool will publish) or fall back to fetching `/.well-known/trails-cool` and confirming the response shape.
|
||||
3. If neither matches: refuse the follow at the API layer with a clear UI error and link to docs explaining the current limitation.
|
||||
|
||||
**Why:** ingesting arbitrary Mastodon/Pleroma/Misskey activity shapes (Notes with attachments, polls, mentions, content warnings) is its own engineering project. Trails-to-trails means we control both shapes and can move fast on getting cross-instance trails.cool working without slipping into a generic-fediverse compatibility tar pit. We document the limitation prominently.
|
||||
|
||||
**Alternative considered:** allow any outbound, store a "we don't know how to render this remote actor's activities" placeholder. Rejected because it sets a low expectation and produces a worse first-time experience.
|
||||
|
||||
### Decision: Push delivery for local-originated content; pull for remote-originated
|
||||
|
||||
When a local user posts a public activity, enqueue `Create(Note)` deliveries to every accepted remote follower's inbox (a pg-boss job per delivery, with retry + backoff). When a remote trails actor we follow posts, we pull on a schedule — same pattern proposed in `social-feed` design before the trim.
|
||||
|
||||
**Why this split:** push outbound matches expectation for inbound followers (Mastodon expects to receive Creates). Pull inbound (when trails follows trails) keeps us outage-tolerant and avoids accepting arbitrary inbox `Create`s. Tested asymmetry that gives us federation reach without inbox content surface.
|
||||
|
||||
### Decision: Audience-aware activity cache, single row per remote activity
|
||||
|
||||
When we fetch a remote trails actor's outbox, store each activity once in `journal.activities` with `remote_origin_iri` (unique), `remote_actor_iri`, and `audience`. The feed query filters at read time so a `followers-only` remote activity only reaches the local user whose follow brought it in.
|
||||
|
||||
(This is the same model proposed and trimmed from `social-feed`. Lands here when it's actually needed.)
|
||||
|
||||
### Decision: HTTP Signatures + Authorized Fetch
|
||||
|
||||
All outbound POSTs to remote inboxes are HTTP-signed using the originating local user's key. All outbound GETs to remote outboxes are also signed (Authorized Fetch / Mastodon "secure mode") — this is required to receive a remote locked actor's followers-only items, and is good hygiene against rate-limit dragnets that block unsigned scrapers.
|
||||
|
||||
Inbound signature verification uses the actor's public key from their actor object, fetched via Fedify's document loader and cached in `remote_actors`.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Public inbox is the highest-risk endpoint we'll have shipped.** → Narrow it (4 activity types only), rate-limit aggressively per source instance, log everything, monitor for abuse patterns. Land behind a feature flag for staged rollout.
|
||||
- **Outbound trails-to-trails restriction is unusual** and will get flagged by fediverse veterans. → Document prominently, frame as "v1; broader outbound coming after first soak". Provide a clear path: "to follow a Mastodon user, ask them to mirror to trails.cool, or wait for v2."
|
||||
- **Key rotation is a real operational chore** if `FEDERATION_KEY_ENCRYPTION_KEY` ever needs rotation. → Runbook + scripted re-encrypt; not a recurring concern but documented.
|
||||
- **Remote outbox spam** if a popular trails account has thousands of followers polling. → We're the *poller* in this asymmetric model; we don't have a "thousands of remotes polling us" problem. We can rate-limit ourselves.
|
||||
- **Replay attacks on inbox** via signed activities replayed by a third party. → HTTP Signatures include a `(request-target)` and date; Fedify rejects replays per the spec.
|
||||
- **Marketing copy currently overstates federation** ("Federated by design — Follow friends anywhere"). → Holds true once this lands inbound from anyone + outbound trails-to-trails. Soften copy in the meantime; restore on launch.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Land schema additively: `users.public_key`, `users.private_key_encrypted`, `remote_actors` table, `activities.remote_origin_iri/remote_actor_iri/audience`. Generate keypairs for existing users in a one-shot pg-boss job.
|
||||
2. Bring up Fedify behind a feature flag (`FEDERATION_ENABLED` env var, default off) so we can deploy code without exposing endpoints.
|
||||
3. Soak inbound only on the flagship: enable WebFinger + actor objects + inbox; do not enable outbound delivery yet. Verify Mastodon can fetch our actor + follow + receive Accept.
|
||||
4. Soak push delivery on the flagship: enable `Create(Note)` outbound when local users post publicly. Verify a Mastodon follower receives the activity.
|
||||
5. Soak outbound trails-to-trails: bring up a second trails instance (staging or self-host), follow across, verify both directions.
|
||||
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.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Custom AS extension for activity-type vs. plain `Create(Note)`? Mastodon will only render Notes; an extension type means non-trails clients see nothing useful. Lean toward `Create(Note)` with structured metadata in `attachment` so Mastodon shows the text + GPX link, and trails clients consuming the outbox can read the structured fields.
|
||||
- Per-instance inbox vs per-user inbox? Mastodon supports a "shared inbox" optimization. Worth doing for delivery efficiency once we have any volume; v1 can use per-user inboxes only.
|
||||
- How does the trails-to-trails check interact with self-hosters who fork or rebrand? Probably the `software` field is `"trails.cool"` (or a derivative we list) and the `/.well-known/trails-cool` endpoint is the authoritative discovery. Tasks phase decides the exact shape.
|
||||
54
openspec/changes/social-federation/proposal.md
Normal file
54
openspec/changes/social-federation/proposal.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
## 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`, no changes to `follows` (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.
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Pending state on Follow button
|
||||
When a signed-in viewer has an outgoing follow against a profile that is `accepted_at IS NULL` (awaiting `Accept(Follow)` from the remote), the Follow button SHALL render in a Pending state with a cancel option, distinct from both the unfollowed and followed states.
|
||||
|
||||
#### Scenario: Pending state visible
|
||||
- **WHEN** a signed-in user has just initiated a follow against a remote trails actor's profile
|
||||
- **THEN** the profile page renders a "Pending" indicator with a cancel-request control instead of the Follow or Unfollow button
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Per-user actor objects with WebFinger discovery
|
||||
The Journal SHALL serve an ActivityPub `Person` actor object at the user's canonical URL for any user with `profile_visibility = 'public'`, and SHALL serve a WebFinger endpoint at `/.well-known/webfinger` resolving `acct:user@domain` to that actor IRI.
|
||||
|
||||
#### Scenario: Public user has a discoverable actor
|
||||
- **WHEN** a remote client GETs `/.well-known/webfinger?resource=acct:bruno@trails.cool`
|
||||
- **THEN** the response is a JRD object with a `links` array including `rel="self"` pointing at `https://trails.cool/users/bruno` (the actor IRI)
|
||||
|
||||
#### Scenario: Public user actor object resolves
|
||||
- **WHEN** a remote client GETs `https://{DOMAIN}/users/bruno` with `Accept: application/activity+json`
|
||||
- **THEN** the response is a `Person` actor object including the user's display name, public key, inbox IRI, outbox IRI, and `software` field identifying the instance as trails.cool
|
||||
|
||||
#### Scenario: Private user is invisible to federation
|
||||
- **WHEN** any federation request resolves a user whose `profile_visibility = 'private'` — WebFinger lookup, actor IRI fetch, or follow attempt
|
||||
- **THEN** every endpoint returns HTTP 404 with no leak of user existence
|
||||
|
||||
### Requirement: Per-user signing keypairs
|
||||
Every local user SHALL have an asymmetric keypair (RSA 2048 or Ed25519). The public key SHALL be embedded in the actor object. The private key SHALL be encrypted at rest using a server-managed encryption key. New users SHALL get keys at registration; existing users SHALL be backfilled at deploy.
|
||||
|
||||
#### Scenario: Outgoing activity is signed with the user's key
|
||||
- **WHEN** a local user originates a federation activity (Follow, Accept, Create, etc) that is delivered to a remote inbox
|
||||
- **THEN** the HTTP request carries an HTTP Signature header signed with that user's private key, identifying the user's `keyId` so the remote can verify
|
||||
|
||||
#### Scenario: Existing-user backfill at deploy
|
||||
- **WHEN** the federation feature flag is first enabled on an instance with pre-existing users
|
||||
- **THEN** a one-shot job generates keypairs for every user lacking one before any federation traffic is permitted
|
||||
|
||||
### Requirement: Narrow inbox — follow-graph activities only
|
||||
The user inbox at `https://{DOMAIN}/users/:username/inbox` SHALL accept and process only `Follow`, `Undo(Follow)`, `Accept(Follow)`, and `Reject(Follow)` activities. Any other activity type SHALL be acknowledged with HTTP 202 and dropped without processing.
|
||||
|
||||
#### Scenario: Inbound Follow auto-accepts for public users
|
||||
- **WHEN** a signed `Follow` from a remote actor targets a local user with `profile_visibility = 'public'`
|
||||
- **THEN** the inbox records the follow row with the remote actor as follower, delivers `Accept(Follow)` back, and returns HTTP 202
|
||||
|
||||
#### Scenario: Inbound Create is dropped silently
|
||||
- **WHEN** a signed `Create(Note)` is POSTed to a local user's inbox
|
||||
- **THEN** the response is HTTP 202 but no row is created in `activities`, no row in `follows`; the activity is logged at debug level and discarded
|
||||
|
||||
#### Scenario: Inbound Follow to a private user is rejected
|
||||
- **WHEN** a `Follow` targets a local user whose `profile_visibility = 'private'`
|
||||
- **THEN** the inbox returns HTTP 404 (matching the actor's own 404) and no row is created
|
||||
|
||||
#### Scenario: Replay-protected
|
||||
- **WHEN** the same signed activity is delivered twice within the signature's validity window
|
||||
- **THEN** the second delivery is dropped (idempotent on activity IRI) and returns HTTP 202
|
||||
|
||||
### Requirement: Outbox publishes user's public activities
|
||||
The Journal SHALL serve a paginated outbox at `https://{DOMAIN}/users/:username/outbox` for any user with `profile_visibility = 'public'`, listing the user's `public` activities as `Create(Note)` activities (or a documented AS extension type).
|
||||
|
||||
#### Scenario: Outbox lists public activities
|
||||
- **WHEN** a remote client GETs an outbox URL with a valid HTTP Signature
|
||||
- **THEN** the response is an `OrderedCollection` (or paginated collection page) of the user's `public` activities, most recent first
|
||||
|
||||
#### Scenario: Outbox excludes private and unlisted
|
||||
- **WHEN** the outbox is fetched
|
||||
- **THEN** the response includes only activities with `visibility = 'public'`; `unlisted` and `private` activities never appear
|
||||
|
||||
### Requirement: Push delivery on local activity create
|
||||
The Journal SHALL deliver a `Create(Note)` activity to every accepted remote follower's inbox when a local user with `profile_visibility = 'public'` creates a new `public` activity.
|
||||
|
||||
#### Scenario: New public activity fans out
|
||||
- **WHEN** a local user with N accepted remote followers creates a new public activity
|
||||
- **THEN** N delivery jobs are enqueued (one per follower's inbox), each retrying with exponential backoff on 5xx, giving up after a documented retry budget
|
||||
|
||||
#### Scenario: Rate-limited per remote host
|
||||
- **WHEN** multiple deliveries target the same remote host
|
||||
- **THEN** they are rate-limited so we never exceed 1 request per second per remote host (configurable; chosen for safety, not throughput)
|
||||
|
||||
### Requirement: Outbound follows restricted to other trails instances
|
||||
The Journal SHALL accept outbound follow requests against remote actor IRIs only when the target host self-identifies as a trails.cool instance. Follows targeting Mastodon, Pleroma, or other non-trails ActivityPub servers SHALL be refused at the API layer with a clear error and a link to the documented v1 limitation.
|
||||
|
||||
#### Scenario: Follow another trails instance
|
||||
- **WHEN** a local user follows `@alice@other-trails.example` and the remote actor's `software` field declares `trails.cool`
|
||||
- **THEN** the follow row is created with `accepted_at = NULL` (Pending), a signed `Follow` is delivered to the remote inbox, and the button shows Pending
|
||||
|
||||
#### Scenario: Refuse to follow a Mastodon user
|
||||
- **WHEN** a local user attempts to follow `@alice@mastodon.social`
|
||||
- **THEN** the API returns 4xx with an error message explaining "outbound federation to non-trails instances isn't supported yet" and links to the project documentation
|
||||
|
||||
### Requirement: Outbox-poll ingestion of remote trails activities
|
||||
The Journal SHALL periodically GET the outbox of every remote trails actor that at least one local user follows with `accepted_at IS NOT NULL`, store new activities locally for feed display, and rate-limit fetches per remote host.
|
||||
|
||||
#### Scenario: Poll cadence and scope
|
||||
- **WHEN** the scheduled outbox-poll job runs
|
||||
- **THEN** it fetches at most the 50 most recent items per remote actor, stores any new rows tagged with their audience, and skips actors polled within the last hour
|
||||
|
||||
#### Scenario: Polls are signed
|
||||
- **WHEN** the poller fetches a remote outbox
|
||||
- **THEN** the GET request carries an HTTP Signature using the actor key of one of the local users who follow the remote actor
|
||||
|
||||
#### Scenario: First poll triggered immediately on accepted follow
|
||||
- **WHEN** a follow row transitions to `accepted_at IS NOT NULL`
|
||||
- **THEN** an immediate one-off outbox-poll is enqueued for that specific actor
|
||||
|
||||
#### Scenario: Respect remote rate limiting
|
||||
- **WHEN** a remote instance returns `429` or `Retry-After`
|
||||
- **THEN** the poller backs off the entire host (not just the actor) for the indicated duration
|
||||
|
||||
### Requirement: Audience-aware feed filtering
|
||||
Activities cached from remote trails actors SHALL be tagged with their audience (`public` or `followers-only`). The social feed query SHALL return `followers-only` activities only to the specific local user who holds an accepted follow against the originating remote actor.
|
||||
|
||||
#### Scenario: Followers-only remote content reaches only the right viewer
|
||||
- **WHEN** a remote actor publishes a followers-only activity, two local users A and B both have rows in the activity cache for that actor, but only A holds an accepted follow against the actor
|
||||
- **THEN** A sees the activity in `/feed` and B does not, even though the row exists in our database
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Pending lifecycle for outbound trails-to-trails follows
|
||||
A follow row originated from a local user against a remote *trails* actor SHALL be created with `accepted_at = NULL` (Pending) until the remote's `Accept(Follow)` activity arrives at our inbox. The local user SHALL see the Pending state on the profile page and SHALL be able to cancel a Pending request, which deletes the row and delivers `Undo(Follow)` to the remote inbox.
|
||||
|
||||
#### Scenario: Outbound follow enters Pending
|
||||
- **WHEN** a local user follows `@alice@other-trails.example`
|
||||
- **THEN** the follow row is created with `accepted_at = NULL`, a signed `Follow` is delivered to the remote inbox, and the profile button shows Pending
|
||||
|
||||
#### Scenario: Pending → Accepted
|
||||
- **WHEN** the remote inbox returns `Accept(Follow)` for our outgoing Follow
|
||||
- **THEN** `accepted_at` is set to `now()`, an immediate one-off outbox-poll is enqueued for that actor, and the profile button transitions to Unfollow
|
||||
|
||||
#### Scenario: Pending → Rejected
|
||||
- **WHEN** the remote inbox returns `Reject(Follow)` for our outgoing Follow
|
||||
- **THEN** the follow row is deleted and a small UI notice is surfaced on the user's outgoing-follows list
|
||||
|
||||
#### Scenario: Cancel a Pending follow
|
||||
- **WHEN** the follower cancels a Pending request from the outgoing-follows list
|
||||
- **THEN** the follow row is deleted and an `Undo(Follow)` activity is delivered to the remote inbox
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Social activity feed
|
||||
The Journal SHALL expose a feed at `/feed`, visible only to signed-in users, listing activities from the users they follow with an accepted follow. The feed SHALL include `public` activities from any followed actor (local or remote) and SHALL include `followers-only` activities from a remote actor only for the specific local viewer who holds an accepted follow against that actor. `private` and (local) `unlisted` activities SHALL NOT appear regardless of follow state.
|
||||
|
||||
#### Scenario: Feed aggregates local + remote public activities
|
||||
- **WHEN** a signed-in user with one or more accepted follows (mix of local + remote trails) loads `/feed`
|
||||
- **THEN** the page shows the most recent public activities across all followed actors, reverse-chronological, up to 50 per page
|
||||
|
||||
#### Scenario: Followers-only remote content reaches only the right viewer
|
||||
- **WHEN** two local users A and B exist; only A holds an accepted follow against remote actor X; X publishes a followers-only activity that lands in our cache via A's poll
|
||||
- **THEN** A sees the activity in `/feed` and B does not
|
||||
|
||||
#### Scenario: Empty feed state
|
||||
- **WHEN** a signed-in user with zero accepted follows loads `/feed`
|
||||
- **THEN** the page shows an empty-state message pointing them to follow someone
|
||||
|
||||
#### Scenario: Pending follows do not contribute to the feed
|
||||
- **WHEN** a signed-in user has only Pending outgoing follows
|
||||
- **THEN** the feed renders the empty state; no remote content is fetched or shown for that follow until `Accept(Follow)` lands
|
||||
93
openspec/changes/social-federation/tasks.md
Normal file
93
openspec/changes/social-federation/tasks.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
## 1. Library + dependencies
|
||||
|
||||
- [ ] 1.1 Spike Fedify on a branch: import, mount minimal actor object endpoint for one user, validate WebFinger lookup from a remote AP client. Confirm fit. If unfit, document why and pivot to alternative
|
||||
- [ ] 1.2 Add Fedify (or chosen lib) to `apps/journal/package.json`; document version pinning rationale
|
||||
- [ ] 1.3 Add `FEDERATION_ENABLED` env var (default `false`) and a thin runtime feature flag the rest of the change reads from
|
||||
|
||||
## 2. Schema
|
||||
|
||||
- [ ] 2.1 Add `users.public_key TEXT NULL`, `users.private_key_encrypted TEXT NULL` (encrypted with `FEDERATION_KEY_ENCRYPTION_KEY`)
|
||||
- [ ] 2.2 Add `remote_actors` table: `actor_iri PK`, `display_name`, `username`, `domain`, `avatar_url`, `inbox_url`, `outbox_url`, `public_key`, `software` (the discovery field), `last_polled_at`, `created_at`
|
||||
- [ ] 2.3 Extend `journal.activities` with `remote_origin_iri TEXT NULL UNIQUE`, `remote_actor_iri TEXT NULL`, `audience TEXT NOT NULL DEFAULT 'public'`
|
||||
- [ ] 2.4 Run `pnpm db:push` locally and confirm migration is clean
|
||||
- [ ] 2.5 One-shot pg-boss job `backfill-user-keypairs` that generates a keypair for every existing user lacking one. Runs once at deploy when feature flag flips on
|
||||
|
||||
## 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
|
||||
|
||||
## 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
|
||||
|
||||
## 5. Outbox + push delivery
|
||||
|
||||
- [ ] 5.1 Outbox endpoint at `/users/:username/outbox`. Returns paginated `OrderedCollection` of the user's `public` activities as `Create(Note)` (with structured trails-specific metadata in `attachment` so Mastodon shows text + GPX link, and trails consumers can read distance/elevation)
|
||||
- [ ] 5.2 Honor signed-fetch (Authorized Fetch) on outbox GETs. Unsigned requests get a public-only subset; signed requests get the same (locked accounts is later-change territory)
|
||||
- [ ] 5.3 On local activity create with `visibility = 'public'`, enqueue a `deliver-activity` pg-boss job per accepted remote follower
|
||||
- [ ] 5.4 `deliver-activity` job: HTTP-sign + POST `Create(Note)` to follower inbox; retry with exponential backoff on 5xx; permanent-fail after retry budget; log final outcome
|
||||
- [ ] 5.5 Per-remote-host rate limit on outbound: 1 req/sec per host
|
||||
- [ ] 5.6 On local activity update or delete, enqueue corresponding `Update`/`Delete` activities to followers (basic — full update/delete fan-out)
|
||||
|
||||
## 6. Outbound follow + trails-to-trails check
|
||||
|
||||
- [ ] 6.1 Update `followUser` to allow remote IRIs; before creating, fetch the remote actor object and inspect `software`/discovery endpoint
|
||||
- [ ] 6.2 Implement `/.well-known/trails-cool` endpoint on our side (publishes our software identity) so other trails instances recognize us
|
||||
- [ ] 6.3 If the discovery check fails, return 4xx with a clear "outbound federation to non-trails instances isn't supported yet" message + docs link
|
||||
- [ ] 6.4 If the check passes, write follow row with `accepted_at = NULL`, push `Follow` activity to remote inbox
|
||||
- [ ] 6.5 UI: "Pending" button state on profile (replaces Follow/Unfollow) when `accepted_at IS NULL`
|
||||
- [ ] 6.6 Outgoing-follows page or section listing Pending requests with cancel control (`Undo(Follow)` + delete row)
|
||||
|
||||
## 7. Outbox-poll ingestion
|
||||
|
||||
- [ ] 7.1 pg-boss recurring job `poll-remote-outboxes` (cron every 5 min): iterate distinct accepted remote actor IRIs in `follows` where `remote_actors.last_polled_at < now() - interval '1 hour'`; for each, signed GET on the outbox using one of the local accepted-followers' keys
|
||||
- [ ] 7.2 Insert returned activities into `journal.activities` with `remote_origin_iri`, `remote_actor_iri`, `audience`. `ON CONFLICT (remote_origin_iri) DO NOTHING` for replay safety. Stop early on streak of conflicts
|
||||
- [ ] 7.3 Refresh `remote_actors` cache row on each poll (display name, avatar, outbox URL, public key)
|
||||
- [ ] 7.4 Per-remote-host rate limit (1 req / 5 sec); honor `Retry-After` / `429`; back off the host
|
||||
- [ ] 7.5 First-poll trigger on `accepted_at` transition (already enqueued from 4.4)
|
||||
|
||||
## 8. Feed query update
|
||||
|
||||
- [ ] 8.1 Update `listSocialFeed` to include audience-aware filtering: include `(audience='public' OR (audience='followers-only' AND f.followed_actor_iri = a.remote_actor_iri))` predicate
|
||||
- [ ] 8.2 Update feed sort to use `COALESCE(created_at, remote_created_at)` for mixed local/remote rows
|
||||
|
||||
## 9. Profile page updates
|
||||
|
||||
- [ ] 9.1 Pending state on Follow button (distinct from Follow/Unfollow)
|
||||
- [ ] 9.2 Federation gate: actor object endpoint returns 404 if `profile_visibility = 'private'`, mirroring the human profile gate
|
||||
- [ ] 9.3 When a user flips to `private`, federation must stop: outgoing `Accept(Follow)` rows where they're the followed party are no longer honored, push delivery is suppressed, and inbound Follows return 404. (Existing accepted follow rows on the remote side are not actively reaped — remotes will discover via their next outbox poll returning 404 / via a broadcast `Update` of the actor — pick the right approach during implementation)
|
||||
|
||||
## 10. Privacy + docs
|
||||
|
||||
- [ ] 10.1 Update privacy manifest: federation traffic, remote actor cache, what data is exposed in the actor object (display name, avatar, public key)
|
||||
- [ ] 10.2 `docs/deployment.md`: federation runbook — feature flag, key rotation procedure, abuse monitoring, troubleshooting
|
||||
- [ ] 10.3 Soften the Journal home "Federated by design" blurb on flagship now (or align it to "ActivityPub federation: inbound + trails-to-trails outbound" once this lands)
|
||||
|
||||
## 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
|
||||
|
||||
## 12. Rollout
|
||||
|
||||
- [ ] 12.1 Schema lands additively behind `FEDERATION_ENABLED=false` (no traffic, no risk)
|
||||
- [ ] 12.2 Soak inbound only on flagship — enable WebFinger + actor + inbox; verify Mastodon can fetch + follow + receive Accept
|
||||
- [ ] 12.3 Soak push delivery — local public activity reaches a Mastodon follower's home timeline
|
||||
- [ ] 12.4 Soak outbound trails-to-trails — bring up second trails instance (staging or self-host), follow across, both directions verified
|
||||
- [ ] 12.5 Flip flag on; restore home marketing copy; document in deployment runbook
|
||||
- [ ] 12.6 Rollback: flag off (instant) or revert PR. Existing `follows` rows stay intact
|
||||
Loading…
Add table
Add a link
Reference in a new issue