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>
119 lines
3.9 KiB
TypeScript
119 lines
3.9 KiB
TypeScript
// Domain logic behind the federation inbox (spec: social-federation,
|
|
// "Narrow inbox — follow-graph activities only"). Pure DB operations,
|
|
// kept separate from the Fedify listener wiring in federation.server.ts
|
|
// so they're directly unit/integration-testable without HTTP signatures.
|
|
|
|
import { randomUUID } from "node:crypto";
|
|
import { and, eq, isNull } from "drizzle-orm";
|
|
import { users, follows } from "@trails-cool/db/schema/journal";
|
|
import { getDb } from "./db.ts";
|
|
import { localActorIri } from "./actor-iri.ts";
|
|
import { logger } from "./logger.server.ts";
|
|
|
|
/**
|
|
* Inbound `Follow` from a remote actor targeting a local user.
|
|
* Auto-accepts for public profiles (records the follow row); private
|
|
* profiles refuse without leaking existence — the caller maps `refused`
|
|
* to the same 404 the actor endpoint serves.
|
|
*
|
|
* Idempotent: a duplicate Follow from the same actor upserts onto the
|
|
* `(follower_actor_iri, followed_user_id)` partial unique index, so
|
|
* replays and Mastodon's retry storms cannot double-insert.
|
|
*/
|
|
export async function recordRemoteFollow(
|
|
remoteActorIri: string,
|
|
localUsername: string,
|
|
): Promise<{ outcome: "accepted" | "refused" }> {
|
|
const db = getDb();
|
|
const [user] = await db
|
|
.select({ id: users.id, profileVisibility: users.profileVisibility })
|
|
.from(users)
|
|
.where(eq(users.username, localUsername))
|
|
.limit(1);
|
|
if (!user || user.profileVisibility !== "public") {
|
|
return { outcome: "refused" };
|
|
}
|
|
await db
|
|
.insert(follows)
|
|
.values({
|
|
id: randomUUID(),
|
|
followerActorIri: remoteActorIri,
|
|
followedActorIri: localActorIri(localUsername),
|
|
followedUserId: user.id,
|
|
// Auto-accept: public profiles accept every follow (locked
|
|
// accounts are a later change).
|
|
acceptedAt: new Date(),
|
|
})
|
|
.onConflictDoNothing();
|
|
logger.info({ remoteActorIri, localUsername }, "federation: inbound follow accepted");
|
|
return { outcome: "accepted" };
|
|
}
|
|
|
|
/** Inbound `Undo(Follow)`: remove the remote actor's follow row. */
|
|
export async function removeRemoteFollow(
|
|
remoteActorIri: string,
|
|
localUsername: string,
|
|
): Promise<void> {
|
|
const db = getDb();
|
|
const deleted = await db
|
|
.delete(follows)
|
|
.where(
|
|
and(
|
|
eq(follows.followerActorIri, remoteActorIri),
|
|
eq(follows.followedActorIri, localActorIri(localUsername)),
|
|
),
|
|
)
|
|
.returning({ id: follows.id });
|
|
if (deleted.length > 0) {
|
|
logger.info({ remoteActorIri, localUsername }, "federation: inbound follow undone");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Inbound `Accept(Follow)`: a remote instance accepted our local user's
|
|
* outgoing follow — settle the Pending row. Returns the settled row's
|
|
* remote actor IRI so the caller can enqueue the first outbox poll
|
|
* (spec: "First poll triggered immediately on accepted follow").
|
|
*/
|
|
export async function settleOutgoingFollow(
|
|
localUserId: string,
|
|
remoteActorIri: string,
|
|
): Promise<{ settled: boolean }> {
|
|
const db = getDb();
|
|
const updated = await db
|
|
.update(follows)
|
|
.set({ acceptedAt: new Date() })
|
|
.where(
|
|
and(
|
|
eq(follows.followerId, localUserId),
|
|
eq(follows.followedActorIri, remoteActorIri),
|
|
isNull(follows.acceptedAt),
|
|
),
|
|
)
|
|
.returning({ id: follows.id });
|
|
if (updated.length > 0) {
|
|
logger.info({ localUserId, remoteActorIri }, "federation: outgoing follow accepted");
|
|
}
|
|
return { settled: updated.length > 0 };
|
|
}
|
|
|
|
/** Inbound `Reject(Follow)`: the remote refused — drop our Pending row. */
|
|
export async function rejectOutgoingFollow(
|
|
localUserId: string,
|
|
remoteActorIri: string,
|
|
): Promise<void> {
|
|
const db = getDb();
|
|
const deleted = await db
|
|
.delete(follows)
|
|
.where(
|
|
and(
|
|
eq(follows.followerId, localUserId),
|
|
eq(follows.followedActorIri, remoteActorIri),
|
|
isNull(follows.acceptedAt),
|
|
),
|
|
)
|
|
.returning({ id: follows.id });
|
|
if (deleted.length > 0) {
|
|
logger.info({ localUserId, remoteActorIri }, "federation: outgoing follow rejected by remote");
|
|
}
|
|
}
|