feat(journal): federation outbox + push delivery to remote followers

social-federation tasks 5.1–5.6. Completes the inbound-federation
story: a Mastodon follower now receives a trails user's new public
activities in their home timeline.

Outbox (5.1/5.2):
- /users/:username/outbox — paginated OrderedCollection of public
  activities as Create(Note), newest first; unlisted/private never
  federate. Private-user 404 enforced at the route layer because
  Fedify builds collection-level responses from counter/cursors
  without consulting the page dispatcher.
- Note shape: HTML content (escaped name/description/stats + link to
  the activity page) with structured PropertyValue attachments
  (distance-m, elevation-gain-m, duration-s) — Mastodon renders the
  text, trails consumers read the structured fields. Resolves the
  design open question toward Create(Note).
- Authorized Fetch: signed and unsigned outbox fetches deliberately
  see the same (public-only) content until locked accounts exist.

Push delivery (5.3–5.6):
- createActivity / updateActivityVisibility(→public) enqueue one
  deliver-activity job per accepted remote follower; flips away from
  public and hard deletes enqueue Delete(Tombstone) retractions
  (enqueued before the row disappears).
- deliver-activity job: re-reads the row at delivery time (skips if
  gone or no longer public), resolves the recipient inbox via the
  remote_actors cache with actor-document fetch fallback (priming the
  cache), HTTP-signs via the owner's key, and POSTs. retryLimit 8 +
  exponential backoff at enqueue time; outbound paced at 1 req/s per
  remote host.
- Actor objects now advertise the outbox IRI.
- @js-temporal/polyfill added (same range Fedify uses) for published
  timestamps; Fedify's types want the global esnext.temporal namespace,
  bridged with a documented cast.

Tests: 9 unit tests for the AS mapping (escaping, stats, attachments,
published fallback, stable ids, tombstones), 4 outbox integration
tests (collection count, page shape/visibility filtering, private-404,
delivery audience query).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-06-06 15:32:52 +02:00
parent bec249f93f
commit bc233e03e5
16 changed files with 905 additions and 27 deletions

View file

@ -124,6 +124,17 @@ Inbound signature verification uses the actor's public key from their actor obje
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.
- **Outgoing activity shape: `Create(Note)` with PropertyValue attachments**
(task 5.1; resolves the open question below toward Mastodon compat). The
Note's HTML content carries name/description/stats plus a link to the
activity page; distance/elevation/duration ride along as `PropertyValue`
attachments that Mastodon ignores gracefully and trails consumers can read
without HTML parsing. GPX download links can join once activities have a
public GPX endpoint.
- **Private-user 404 for collection endpoints is enforced at the route layer**
(task 5.1): Fedify builds collection-level responses (outbox
OrderedCollection) from counter/cursors without consulting the page
dispatcher, so the dispatcher's `null` → 404 contract doesn't cover them.
## Open Questions

View file

@ -34,12 +34,14 @@
## 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)
- [x] 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)
- [x] 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)
> The two scenarios collapse to one shape — the outbox only ever contains public items, so no signature check is needed until locked accounts exist. Documented in code.
- [x] 5.3 On local activity create with `visibility = 'public'`, enqueue a `deliver-activity` pg-boss job per accepted remote follower
- [x] 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
- [x] 5.5 Per-remote-host rate limit on outbound: 1 req/sec per host
- [x] 5.6 On local activity update or delete, enqueue corresponding `Update`/`Delete` activities to followers (basic — full update/delete fan-out)
> Implemented as: visibility flip → public sends Create; flip away from public or hard delete sends Delete(Tombstone). There is no general field-edit server path today, so `Update` has no trigger yet — revisit if/when activity editing ships.
## 6. Outbound follow + trails-to-trails check