feat(journal): inbound federation replay defense

Task group 2 of federation-hardening. The narrow inbox
(Follow/Undo/Accept/Reject) had no replay protection — only Create(Note)
did, via the activities.remote_origin_iri unique constraint. A remote
redelivering a signed follow-graph activity would re-run its side
effects.

- New `federation_processed_activities` table (activity IRI PK,
  received_at + index). Additive, so drizzle-kit push creates it; no
  hand-written migration needed.
- `federation-replay.server.ts`: `markInboundActivityProcessed` does an
  insert-or-drop (ON CONFLICT DO NOTHING RETURNING) and reports whether
  the IRI is fresh; `sweepProcessedActivities` deletes rows > 30 days old
  (signature date-freshness already rejects older replays).
- Each inbox listener drops a duplicate before side effects. The
  follow-graph handlers are idempotent, so a handler failure whose retry
  is later dropped as a duplicate can't corrupt state.
- `federation-dedup-sweep` job (daily 04:30 UTC) runs the TTL sweep.

Verified: db + journal typecheck + lint clean; drizzle-kit push creates
the table; replay integration test (fresh-vs-duplicate + 30-day sweep)
green against real Postgres; journal unit suite 355 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-07-13 21:55:44 +02:00
parent 71d92306db
commit 105659df7c
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
8 changed files with 157 additions and 4 deletions

View file

@ -424,6 +424,21 @@ export const federationKv = journalSchema.table("federation_kv", {
expiresAtIdx: index("federation_kv_expires_at_idx").on(t.expiresAt),
}));
// Inbound-activity replay defense (spec: federation-operations "Inbound
// replay defense"). One row per processed inbound activity IRI; inbox
// handlers insert-or-drop before side effects so a redelivered activity
// is a no-op. Rows older than 30 days are swept by the
// `federation-dedup-sweep` job — HTTP-signature date freshness already
// rejects older replays — so this never grows unbounded. Create(Note)
// keeps its own idempotency via activities.remote_origin_iri; this table
// covers the follow-graph activities (Follow/Undo/Accept/Reject).
export const federationProcessedActivities = journalSchema.table("federation_processed_activities", {
activityIri: text("activity_iri").primaryKey(),
receivedAt: timestamp("received_at", { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
receivedAtIdx: index("federation_processed_activities_received_at_idx").on(t.receivedAt),
}));
// Cache of remote ActivityPub actors we interact with (spec:
// social-federation). One row per actor IRI: display fields for feed
// cards, inbox/outbox URLs for delivery and polling, the public key for