docs(openspec): archive social-federation; sync delta specs into main
The change shipped end-to-end (58/58 tasks; live on trails.cool since 2026-06-07, soaked against Mastodon and flagship⇄staging). Delta specs synced into the mainline: - specs/social-federation/spec.md created (8 requirements: actor objects + WebFinger, signing keypairs, narrow inbox, outbox, push delivery, trails-only outbound, poll ingestion, audience filtering) - specs/social-follows/spec.md: social-activity-feed requirement updated for remote rows + audience gating; pending lifecycle for outbound remote follows added - specs/public-profiles/spec.md: Pending follow-button state added openspec validate --all: green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4b342ad91b
commit
d87a1e30c2
10 changed files with 147 additions and 24 deletions
|
|
@ -63,3 +63,10 @@ Every user SHALL have an explicit `profile_visibility` of `public` or `private`.
|
|||
- **WHEN** a previously-private user switches `profile_visibility` to `public` and saves
|
||||
- **THEN** their `/users/:username` renders the full profile to anyone again, and any incoming follows auto-accept going forward
|
||||
|
||||
### 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
|
||||
|
||||
|
|
|
|||
109
openspec/specs/social-federation/spec.md
Normal file
109
openspec/specs/social-federation/spec.md
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# social-federation Specification
|
||||
|
||||
## Purpose
|
||||
ActivityPub federation for the Journal, scoped to the follow graph between trails.cool instances: per-user actor objects with WebFinger discovery, per-user signing keypairs, a narrow follow-only inbox, an outbox of public activities, push delivery to remote followers on local activity create, outbound follows restricted to other trails instances, outbox-poll ingestion of remote trails activities, and audience-aware feed filtering. Local follow lifecycle and the feed surface itself live in `social-follows`; profile pages live in `public-profiles`.
|
||||
|
||||
## 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
|
||||
|
|
@ -82,35 +82,23 @@ A signed-in user SHALL have an actionable surface listing every Pending follow r
|
|||
- **THEN** they are redirected to `/auth/login`
|
||||
|
||||
### Requirement: Social activity feed
|
||||
The Journal SHALL expose a feed at `/feed`, visible only to signed-in users, with two views selectable via `?view=`: **Followed** (default; public activities from the local users they follow with an accepted relation) and **Public** (instance-wide public activities — see `activity-feed` spec, "Instance-wide public activity feed"). The page SHALL render a tab/toggle at the top so the viewer can switch between the two views. `private` and `unlisted` activities SHALL NOT appear in either view. Pending follows SHALL NOT contribute content to the Followed view.
|
||||
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: Followed view aggregates accepted-followed users' public activities
|
||||
- **WHEN** a signed-in user with one or more accepted follows loads `/feed` (or `/feed?view=followed`)
|
||||
- **THEN** the Followed view is selected and shows the most recent public activities across all accepted-followed users, reverse-chronological, up to 50 per page, with owner attribution, distance, date, and a map thumbnail
|
||||
#### 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: Public view aggregates instance-wide public activities
|
||||
- **WHEN** a signed-in user loads `/feed?view=public`
|
||||
- **THEN** the Public view is selected and shows the most recent public activities across the entire instance, reverse-chronological, up to 50 per page, regardless of follow state
|
||||
#### 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: Pending follows don't contribute to the Followed view
|
||||
- **WHEN** a signed-in user has only Pending follows (no acceptance yet) and loads the Followed view
|
||||
- **THEN** the view renders the empty state — no Pending-target content is fetched or shown
|
||||
|
||||
#### Scenario: Empty Followed view links to the Public view
|
||||
#### Scenario: Empty feed state
|
||||
- **WHEN** a signed-in user with zero accepted follows loads `/feed`
|
||||
- **THEN** the Followed view shows an empty-state message and an in-page link to switch to the Public view (`?view=public`), so they can browse the instance without leaving `/feed`
|
||||
- **THEN** the page shows an empty-state message pointing them to follow someone
|
||||
|
||||
#### Scenario: Empty Public view
|
||||
- **WHEN** the instance has zero public activities and a signed-in user loads `/feed?view=public`
|
||||
- **THEN** the Public view shows an empty-state message; no link to elsewhere is required
|
||||
|
||||
#### Scenario: Unrecognized view value falls back to Followed
|
||||
- **WHEN** a signed-in user loads `/feed?view=<anything-other-than-public>`
|
||||
- **THEN** the loader treats the request as the Followed view (default) without raising an error — the parameter is opaque from the client's perspective
|
||||
|
||||
#### Scenario: Logged-out visitor cannot access the feed
|
||||
- **WHEN** an unauthenticated visitor requests `/feed` (any view)
|
||||
- **THEN** they are redirected to `/auth/login`
|
||||
#### 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
|
||||
|
||||
### Requirement: Schema is forward-compatible with federation
|
||||
The `follows` table SHALL key the followed side by an `actor_iri TEXT` column (not a plain user FK), so the `social-federation` change can store remote IRIs in the same column without migration. Local follows SHALL populate `actor_iri` with the local user's canonical actor IRI (`https://{DOMAIN}/users/{username}`).
|
||||
|
|
@ -151,3 +139,22 @@ The follow lifecycle SHALL produce notifications for the recipient of the social
|
|||
#### Scenario: Unfollow does not notify
|
||||
- **WHEN** a follower unfollows or cancels a Pending request
|
||||
- **THEN** no notification is created on the followed side
|
||||
|
||||
### 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue