trails/apps/journal/app/lib/federation-replay.server.ts
Ullrich Schäfer 105659df7c
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>
2026-07-13 22:07:30 +02:00

50 lines
2.1 KiB
TypeScript

// Inbound-activity replay defense (spec: federation-operations "Inbound
// replay defense"). The narrow inbox processes Follow/Undo/Accept/Reject;
// none of those carried replay protection before (only Create(Note) did,
// via the activities.remote_origin_iri unique constraint). A hostile or
// buggy remote redelivering a signed activity would re-run its side
// effects. This records each processed activity IRI and lets the inbox
// handlers drop a duplicate before doing anything.
import { lt } from "drizzle-orm";
import { federationProcessedActivities } from "@trails-cool/db/schema/journal";
import { getDb } from "./db.ts";
/**
* Record an inbound activity IRI, returning whether this is the first
* time we've seen it. Insert-or-drop: on primary-key conflict no row is
* inserted and `fresh` is false, so the caller drops the activity as a
* replay before running side effects.
*
* Callers must be idempotent regardless: if a handler fails after this
* records the IRI, Fedify's retry would be dropped here as a duplicate,
* so the follow-graph handlers (recordRemoteFollow/removeRemoteFollow/
* settle/reject) are all safe to under-run.
*/
export async function markInboundActivityProcessed(
activityIri: string,
): Promise<{ fresh: boolean }> {
const db = getDb();
const inserted = await db
.insert(federationProcessedActivities)
.values({ activityIri })
.onConflictDoNothing()
.returning({ iri: federationProcessedActivities.activityIri });
return { fresh: inserted.length > 0 };
}
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
/**
* Delete processed-activity rows older than 30 days. Called by the
* `federation-dedup-sweep` job. Returns the number of rows removed.
*/
export async function sweepProcessedActivities(now: Date = new Date()): Promise<number> {
const db = getDb();
const cutoff = new Date(now.getTime() - THIRTY_DAYS_MS);
const deleted = await db
.delete(federationProcessedActivities)
.where(lt(federationProcessedActivities.receivedAt, cutoff))
.returning({ iri: federationProcessedActivities.activityIri });
return deleted.length;
}