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:
parent
9a90b6db55
commit
b17685d58c
18 changed files with 804 additions and 61 deletions
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue