feat(journal): Fedify spike — WebFinger + actor objects behind FEDERATION_ENABLED
social-federation tasks 1.1–1.3: - Add @fedify/fedify pinned to exactly 2.1.16: every 2.2.x release depends on @fedify/webfinger@2.2.x which was never published to npm, so 2.1.16 is the newest installable version. - app/lib/federation.server.ts: Federation instance with an actor dispatcher serving Person objects for public users; private and unknown users 404 (no existence leak). MemoryKvStore for now. - /.well-known/webfinger resource route delegating to federation.fetch. - ActivityPub content negotiation on /users/:username via route middleware (future.v8_middleware — no loader uses the context arg, so the flag is a no-op for existing code). - FEDERATION_ENABLED env flag (default off) gating every federation surface. - Unit tests exercise the Fedify dispatcher as a remote AP client: WebFinger resolution, actor fetch with Mastodon's Accept header, private-user 404s, flag-off 404s. Spike verdict: Fedify fits — URL dispatch, JRD/AP serialization, and visibility gating all work through framework routes without a custom server layer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8638c2fdfa
commit
4ef86e4dc2
9 changed files with 518 additions and 13 deletions
129
apps/journal/app/lib/federation.server.test.ts
Normal file
129
apps/journal/app/lib/federation.server.test.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Spike validation (social-federation task 1.1): exercise Fedify's URL
|
||||
// dispatcher the way a remote AP client would — WebFinger lookup, then
|
||||
// actor fetch with an ActivityPub Accept header — without a running
|
||||
// server or database.
|
||||
|
||||
const dbUsers: Array<Record<string, unknown>> = [];
|
||||
|
||||
vi.mock("./db.ts", () => ({
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: async () => dbUsers,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { handleFederationRequest, wantsActivityJson } = await import(
|
||||
"./federation.server.ts"
|
||||
);
|
||||
|
||||
const PUBLIC_USER = {
|
||||
id: "u1",
|
||||
username: "bruno",
|
||||
displayName: "Bruno",
|
||||
bio: "Riding bikes",
|
||||
domain: "localhost",
|
||||
profileVisibility: "public",
|
||||
};
|
||||
|
||||
function webfingerRequest(handle: string): Request {
|
||||
return new Request(
|
||||
`http://localhost:3000/.well-known/webfinger?resource=${encodeURIComponent(handle)}`,
|
||||
{ headers: { accept: "application/jrd+json" } },
|
||||
);
|
||||
}
|
||||
|
||||
function actorRequest(username: string): Request {
|
||||
return new Request(`http://localhost:3000/users/${username}`, {
|
||||
headers: { accept: "application/activity+json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.FEDERATION_ENABLED = "true";
|
||||
dbUsers.length = 0;
|
||||
});
|
||||
|
||||
describe("federation flag", () => {
|
||||
it("404s every federation request when FEDERATION_ENABLED is off", async () => {
|
||||
process.env.FEDERATION_ENABLED = "false";
|
||||
dbUsers.push(PUBLIC_USER);
|
||||
const res = await handleFederationRequest(webfingerRequest("acct:bruno@localhost:3000"));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebFinger", () => {
|
||||
it("resolves a public user's handle to their actor IRI", async () => {
|
||||
dbUsers.push(PUBLIC_USER);
|
||||
const res = await handleFederationRequest(webfingerRequest("acct:bruno@localhost:3000"));
|
||||
expect(res.status).toBe(200);
|
||||
const jrd = await res.json();
|
||||
expect(jrd.subject).toBe("acct:bruno@localhost:3000");
|
||||
const self = jrd.links.find((l: { rel: string }) => l.rel === "self");
|
||||
expect(self.href).toBe("http://localhost:3000/users/bruno");
|
||||
expect(self.type).toContain("application/activity+json");
|
||||
});
|
||||
|
||||
it("404s for a private user without leaking existence", async () => {
|
||||
dbUsers.push({ ...PUBLIC_USER, profileVisibility: "private" });
|
||||
const res = await handleFederationRequest(webfingerRequest("acct:bruno@localhost:3000"));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("404s for an unknown user", async () => {
|
||||
const res = await handleFederationRequest(webfingerRequest("acct:ghost@localhost:3000"));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("actor object", () => {
|
||||
it("serves a Person actor for a public user", async () => {
|
||||
dbUsers.push(PUBLIC_USER);
|
||||
const res = await handleFederationRequest(actorRequest("bruno"));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toContain("application/activity+json");
|
||||
const actor = await res.json();
|
||||
expect(actor.type).toBe("Person");
|
||||
expect(actor.id).toBe("http://localhost:3000/users/bruno");
|
||||
expect(actor.preferredUsername).toBe("bruno");
|
||||
expect(actor.name).toBe("Bruno");
|
||||
expect(actor.summary).toBe("Riding bikes");
|
||||
});
|
||||
|
||||
it("404s the actor for a private user", async () => {
|
||||
dbUsers.push({ ...PUBLIC_USER, profileVisibility: "private" });
|
||||
const res = await handleFederationRequest(actorRequest("bruno"));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wantsActivityJson", () => {
|
||||
it("matches AP client Accept headers, not browsers", () => {
|
||||
expect(wantsActivityJson(actorRequest("bruno"))).toBe(true);
|
||||
expect(
|
||||
wantsActivityJson(
|
||||
new Request("http://localhost:3000/users/bruno", {
|
||||
// Mastodon's exact Accept header
|
||||
headers: {
|
||||
accept:
|
||||
'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
wantsActivityJson(
|
||||
new Request("http://localhost:3000/users/bruno", {
|
||||
headers: { accept: "text/html,application/xhtml+xml" },
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue