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>
155 lines
5.4 KiB
TypeScript
155 lines
5.4 KiB
TypeScript
import { describe, it, expect, beforeAll, afterEach } from "vitest";
|
|
import { eq } from "drizzle-orm";
|
|
import { randomUUID } from "node:crypto";
|
|
import { getDb } from "./db.ts";
|
|
import { users, follows } from "@trails-cool/db/schema/journal";
|
|
import { localActorIri } from "./actor-iri.ts";
|
|
import {
|
|
recordRemoteFollow,
|
|
removeRemoteFollow,
|
|
settleOutgoingFollow,
|
|
rejectOutgoingFollow,
|
|
} from "./federation-inbox.server.ts";
|
|
|
|
// Opt-in: these talk to real Postgres. Gated by an env flag so laptop
|
|
// runs without Postgres aren't blocked. Same convention as
|
|
// follow.integration.test.ts.
|
|
const runIntegration = process.env.FEDERATION_INTEGRATION === "1";
|
|
|
|
const REMOTE_ACTOR = "https://other-trails.example/users/alice";
|
|
|
|
const createdUserIds: string[] = [];
|
|
|
|
async function makeUser(opts: { username: string; profileVisibility?: "public" | "private" }) {
|
|
const db = getDb();
|
|
const id = randomUUID();
|
|
await db.insert(users).values({
|
|
id,
|
|
email: `${opts.username}@example.test`,
|
|
username: opts.username,
|
|
domain: "test.local",
|
|
profileVisibility: opts.profileVisibility ?? "public",
|
|
});
|
|
createdUserIds.push(id);
|
|
return id;
|
|
}
|
|
|
|
describe.runIf(runIntegration)("federation inbox (integration)", () => {
|
|
beforeAll(() => {
|
|
process.env.ORIGIN ??= "http://localhost:3000";
|
|
});
|
|
|
|
afterEach(async () => {
|
|
const db = getDb();
|
|
for (const id of createdUserIds.splice(0)) {
|
|
await db.delete(follows).where(eq(follows.followedUserId, id));
|
|
await db.delete(follows).where(eq(follows.followerId, id));
|
|
await db.delete(users).where(eq(users.id, id));
|
|
}
|
|
});
|
|
|
|
it("inbound Follow on a public user records an accepted remote follow", async () => {
|
|
const username = `fed-pub-${Date.now()}`;
|
|
const userId = await makeUser({ username });
|
|
|
|
const { outcome } = await recordRemoteFollow(REMOTE_ACTOR, username);
|
|
expect(outcome).toBe("accepted");
|
|
|
|
const db = getDb();
|
|
const rows = await db.select().from(follows).where(eq(follows.followedUserId, userId));
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]!.followerActorIri).toBe(REMOTE_ACTOR);
|
|
expect(rows[0]!.followerId).toBeNull();
|
|
expect(rows[0]!.followedActorIri).toBe(localActorIri(username));
|
|
expect(rows[0]!.acceptedAt).not.toBeNull();
|
|
});
|
|
|
|
it("inbound Follow is idempotent — replays don't double-insert", async () => {
|
|
const username = `fed-replay-${Date.now()}`;
|
|
const userId = await makeUser({ username });
|
|
|
|
await recordRemoteFollow(REMOTE_ACTOR, username);
|
|
await recordRemoteFollow(REMOTE_ACTOR, username);
|
|
|
|
const db = getDb();
|
|
const rows = await db.select().from(follows).where(eq(follows.followedUserId, userId));
|
|
expect(rows).toHaveLength(1);
|
|
});
|
|
|
|
it("inbound Follow on a private user is refused with no row", async () => {
|
|
const username = `fed-priv-${Date.now()}`;
|
|
const userId = await makeUser({ username, profileVisibility: "private" });
|
|
|
|
const { outcome } = await recordRemoteFollow(REMOTE_ACTOR, username);
|
|
expect(outcome).toBe("refused");
|
|
|
|
const db = getDb();
|
|
const rows = await db.select().from(follows).where(eq(follows.followedUserId, userId));
|
|
expect(rows).toHaveLength(0);
|
|
});
|
|
|
|
it("Undo(Follow) removes the remote follow row", async () => {
|
|
const username = `fed-undo-${Date.now()}`;
|
|
const userId = await makeUser({ username });
|
|
await recordRemoteFollow(REMOTE_ACTOR, username);
|
|
|
|
await removeRemoteFollow(REMOTE_ACTOR, username);
|
|
|
|
const db = getDb();
|
|
const rows = await db.select().from(follows).where(eq(follows.followedUserId, userId));
|
|
expect(rows).toHaveLength(0);
|
|
});
|
|
|
|
it("Accept(Follow) settles a Pending outgoing follow exactly once", async () => {
|
|
const username = `fed-accept-${Date.now()}`;
|
|
const userId = await makeUser({ username });
|
|
const db = getDb();
|
|
await db.insert(follows).values({
|
|
id: randomUUID(),
|
|
followerId: userId,
|
|
followedActorIri: REMOTE_ACTOR,
|
|
acceptedAt: null,
|
|
});
|
|
|
|
const first = await settleOutgoingFollow(userId, REMOTE_ACTOR);
|
|
expect(first.settled).toBe(true);
|
|
// A replayed Accept is a no-op (row no longer Pending).
|
|
const second = await settleOutgoingFollow(userId, REMOTE_ACTOR);
|
|
expect(second.settled).toBe(false);
|
|
|
|
const rows = await db.select().from(follows).where(eq(follows.followerId, userId));
|
|
expect(rows[0]!.acceptedAt).not.toBeNull();
|
|
});
|
|
|
|
it("Reject(Follow) deletes the Pending outgoing follow", async () => {
|
|
const username = `fed-reject-${Date.now()}`;
|
|
const userId = await makeUser({ username });
|
|
const db = getDb();
|
|
await db.insert(follows).values({
|
|
id: randomUUID(),
|
|
followerId: userId,
|
|
followedActorIri: REMOTE_ACTOR,
|
|
acceptedAt: null,
|
|
});
|
|
|
|
await rejectOutgoingFollow(userId, REMOTE_ACTOR);
|
|
|
|
const rows = await db.select().from(follows).where(eq(follows.followerId, userId));
|
|
expect(rows).toHaveLength(0);
|
|
});
|
|
|
|
it("database enforces exactly-one-follower invariant", async () => {
|
|
const username = `fed-check-${Date.now()}`;
|
|
const userId = await makeUser({ username });
|
|
const db = getDb();
|
|
await expect(
|
|
db.insert(follows).values({
|
|
id: randomUUID(),
|
|
// Neither followerId nor followerActorIri — must violate the
|
|
// follows_has_follower_check constraint.
|
|
followedActorIri: localActorIri(username),
|
|
followedUserId: userId,
|
|
}),
|
|
).rejects.toThrow();
|
|
});
|
|
});
|