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:
parent
71d92306db
commit
105659df7c
8 changed files with 157 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue