feat(journal): federation inbox — Mastodon follows land here

social-federation tasks 3.1–3.4, 4.1–4.8. With this, a Mastodon user
can follow a public trails user: WebFinger → actor fetch → signed
Follow → recorded + Accept(Follow) pushed back.

Identity surface (section 3):
- Actor objects now carry the user's public key (publicKey +
  assertionMethods via Fedify key pairs dispatcher; keys generated
  lazily as a fallback to the backfill) and an inbox IRI; url uses
  localActorIri (3.1).
- Software discovery shipped as standard NodeInfo
  (/.well-known/nodeinfo + /nodeinfo/2.1, software.name trails-cool)
  instead of the originally-sketched custom AS actor field — Fedify's
  typed vocab can't emit arbitrary actor props and NodeInfo is what
  the fediverse reads. Artifacts updated accordingly (3.4).

Inbox (section 4):
- /users/:username/inbox resource route; HTTP Signatures verified by
  Fedify before any listener runs (4.1). Rate-limited 60 req/5 min per
  source instance (host from Signature keyId) BEFORE verification so
  hostile instances can't burn CPU on key fetches (4.8).
- Listeners: Follow → auto-accept for public profiles + Accept pushed
  back (4.2); Undo(Follow) → row removed (4.3); Accept(Follow) →
  Pending settled + first outbox poll enqueued (4.4); Reject(Follow) →
  Pending dropped (4.5; UI notice deferred to 6.6). Unhandled types
  are acknowledged + dropped by Fedify (4.6).
- Replay protection via Fedify's KvStore, now Postgres-backed
  (journal.federation_kv + daily sweep job) so dedupe survives
  restarts (4.7).

Schema (discovered requirement):
- follows.follower_id relaxed to nullable + follows.follower_actor_iri
  for inbound remote followers — the proposal's 'follows is already
  federation-ready' only held for outbound. Check constraint enforces
  exactly one follower identity; partial unique index dedupes remote
  follows. Notification fan-out + approve flow now filter local
  followers explicitly. Design/proposal updated.

Tests: 7 inbox integration tests (accept/refuse/idempotence/undo/
settle/reject/check-constraint), 6 KvStore integration tests, 2 unit
tests for source-host extraction. All against real Postgres, gated on
FEDERATION_INTEGRATION=1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-06-06 14:33:43 +02:00
parent 9a90b6db55
commit b17685d58c
18 changed files with 804 additions and 61 deletions

View file

@ -10,6 +10,7 @@ import {
customType,
uniqueIndex,
index,
check,
} from "drizzle-orm/pg-core";
const bytea = customType<{ data: Buffer }>({
@ -330,18 +331,44 @@ export const importBatches = journalSchema.table("import_batches", {
export const follows = journalSchema.table("follows", {
id: text("id").primaryKey(),
followerId: text("follower_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// Local follower. NULL for inbound federated follows (a remote actor
// following a local user), where `followerActorIri` identifies the
// follower instead. Exactly one of the two is set (check constraint).
followerId: text("follower_id").references(() => users.id, { onDelete: "cascade" }),
// Remote follower's actor IRI (spec: social-federation, inbound
// Follow). NULL for local-origin follows.
followerActorIri: text("follower_actor_iri"),
followedActorIri: text("followed_actor_iri").notNull(),
followedUserId: text("followed_user_id").references(() => users.id, { onDelete: "cascade" }),
acceptedAt: timestamp("accepted_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
followerActorUnique: uniqueIndex("follows_follower_actor_unique").on(t.followerId, t.followedActorIri),
// Dedupe inbound remote follows: one row per (remote follower, local
// followed user). Partial — local-origin rows have a NULL IRI here.
remoteFollowerUnique: uniqueIndex("follows_remote_follower_unique")
.on(t.followerActorIri, t.followedUserId)
.where(sql`${t.followerActorIri} IS NOT NULL`),
followerCreatedIdx: index("follows_follower_created_idx").on(t.followerId, t.createdAt.desc()),
followedActorIdx: index("follows_followed_actor_idx").on(t.followedActorIri),
followedUserIdx: index("follows_followed_user_idx").on(t.followedUserId),
hasFollowerCheck: check(
"follows_has_follower_check",
sql`(${t.followerId} IS NOT NULL) <> (${t.followerActorIri} IS NOT NULL)`,
),
}));
// Fedify KvStore backing table (inbox replay protection, remote
// document / key caches). Keys are JSON-serialized KvKey arrays;
// values arbitrary JSON. Expired rows are filtered at read time and
// swept by the `federation-kv-sweep` job.
export const federationKv = journalSchema.table("federation_kv", {
key: text("key").primaryKey(),
value: jsonb("value").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
expiresAtIdx: index("federation_kv_expires_at_idx").on(t.expiresAt),
}));
// Cache of remote ActivityPub actors we interact with (spec: