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);
|
||||
});
|
||||
});
|
||||
99
apps/journal/app/lib/federation.server.ts
Normal file
99
apps/journal/app/lib/federation.server.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// ActivityPub federation via Fedify. See openspec/changes/social-federation.
|
||||
//
|
||||
// Spike scope (task 1.1): a minimal Federation instance serving WebFinger
|
||||
// discovery + Person actor objects for public users. No keypairs, no inbox
|
||||
// processing, no delivery yet — those land in later tasks.
|
||||
//
|
||||
// Version pinning (task 1.2): @fedify/fedify is pinned to exactly 2.1.16.
|
||||
// The whole 2.2.x line is uninstallable from npm — each release depends on
|
||||
// @fedify/webfinger@2.2.x, which was never published (latest is 2.1.16).
|
||||
// Revisit the pin once `pnpm view @fedify/fedify dependencies` resolves.
|
||||
//
|
||||
// Mounting model: Fedify owns URL dispatch for its endpoints via
|
||||
// `federation.fetch(request)`. We delegate to it from React Router routes:
|
||||
// - /.well-known/webfinger → resource route (no component, raw Response)
|
||||
// - /users/:username → route middleware short-circuits to Fedify
|
||||
// when the request asks for an ActivityPub representation; browsers
|
||||
// fall through to the HTML profile loader (content negotiation per
|
||||
// the design decision "actor IRI is the profile URL").
|
||||
import { createFederation, MemoryKvStore, type Federation } from "@fedify/fedify";
|
||||
import { Person } from "@fedify/fedify/vocab";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { users } from "@trails-cool/db/schema/journal";
|
||||
import { getDb } from "./db.ts";
|
||||
import { getOrigin } from "./config.server.ts";
|
||||
|
||||
/**
|
||||
* Feature flag (task 1.3). All federation surfaces — WebFinger, actor
|
||||
* objects, inbox, outbox — are disabled (404) unless FEDERATION_ENABLED
|
||||
* is exactly "true". Default off so schema/code can deploy before any
|
||||
* federation traffic is possible.
|
||||
*/
|
||||
export function federationEnabled(): boolean {
|
||||
return process.env.FEDERATION_ENABLED === "true";
|
||||
}
|
||||
|
||||
/** Does the request ask for an ActivityPub representation of a resource? */
|
||||
export function wantsActivityJson(request: Request): boolean {
|
||||
const accept = request.headers.get("accept") ?? "";
|
||||
return (
|
||||
accept.includes("application/activity+json") ||
|
||||
accept.includes("application/ld+json")
|
||||
);
|
||||
}
|
||||
|
||||
let _federation: Federation<void> | null = null;
|
||||
|
||||
function buildFederation(): Federation<void> {
|
||||
const federation = createFederation<void>({
|
||||
// MemoryKvStore is fine for the spike (it only caches remote documents
|
||||
// and nonces). Replace with a Postgres-backed store before real
|
||||
// federation traffic (revisit in task 4.x).
|
||||
kv: new MemoryKvStore(),
|
||||
// Canonical origin so generated IRIs are correct behind the Caddy
|
||||
// proxy (the Node server itself only sees plain HTTP).
|
||||
origin: getOrigin(),
|
||||
});
|
||||
|
||||
federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
|
||||
const db = getDb();
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, identifier))
|
||||
.limit(1);
|
||||
// Private profiles do not federate at all: returning null makes Fedify
|
||||
// respond 404, and WebFinger lookups for this user 404 with it — no
|
||||
// leak of user existence (spec: "Private user is invisible to
|
||||
// federation").
|
||||
if (!user || user.profileVisibility !== "public") return null;
|
||||
return new Person({
|
||||
id: ctx.getActorUri(identifier),
|
||||
preferredUsername: identifier,
|
||||
name: user.displayName ?? user.username,
|
||||
summary: user.bio || undefined,
|
||||
// Human-facing profile URL — same as the actor IRI by design, but
|
||||
// declared explicitly so AP clients link browsers to the HTML view.
|
||||
url: new URL(`/users/${identifier}`, getOrigin()),
|
||||
});
|
||||
});
|
||||
|
||||
return federation;
|
||||
}
|
||||
|
||||
export function getFederation(): Federation<void> {
|
||||
_federation ??= buildFederation();
|
||||
return _federation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegate a request to Fedify's URL dispatcher. 404s when the feature
|
||||
* flag is off so disabled instances are indistinguishable from instances
|
||||
* without federation.
|
||||
*/
|
||||
export function handleFederationRequest(request: Request): Promise<Response> {
|
||||
if (!federationEnabled()) {
|
||||
return Promise.resolve(new Response("Not Found", { status: 404 }));
|
||||
}
|
||||
return getFederation().fetch(request, { contextData: undefined });
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes";
|
|||
export default [
|
||||
index("routes/home.tsx"),
|
||||
route(".well-known/trails-cool", "routes/api.well-known.trails-cool.ts"),
|
||||
route(".well-known/webfinger", "routes/api.well-known.webfinger.ts"),
|
||||
route("oauth/authorize", "routes/oauth.authorize.tsx"),
|
||||
route("oauth/token", "routes/oauth.token.ts"),
|
||||
route("auth/register", "routes/auth.register.tsx"),
|
||||
|
|
|
|||
13
apps/journal/app/routes/api.well-known.webfinger.ts
Normal file
13
apps/journal/app/routes/api.well-known.webfinger.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import type { Route } from "./+types/api.well-known.webfinger";
|
||||
import { handleFederationRequest } from "~/lib/federation.server";
|
||||
|
||||
/**
|
||||
* GET /.well-known/webfinger?resource=acct:user@domain
|
||||
*
|
||||
* ActivityPub discovery: resolves an acct: handle to the user's actor IRI.
|
||||
* Fully handled by Fedify; 404s while FEDERATION_ENABLED is off, and for
|
||||
* users whose profile_visibility is not 'public'.
|
||||
*/
|
||||
export function loader({ request }: Route.LoaderArgs) {
|
||||
return handleFederationRequest(request);
|
||||
}
|
||||
|
|
@ -4,6 +4,24 @@ import { useTranslation } from "react-i18next";
|
|||
import { ClientDate } from "~/components/ClientDate";
|
||||
import { FollowButton } from "~/components/FollowButton";
|
||||
import { loadUserProfile } from "./users.$username.server";
|
||||
import {
|
||||
federationEnabled,
|
||||
wantsActivityJson,
|
||||
handleFederationRequest,
|
||||
} from "~/lib/federation.server";
|
||||
|
||||
// Content negotiation: this URL is both the human profile (HTML) and the
|
||||
// ActivityPub actor IRI (application/activity+json). AP clients are
|
||||
// short-circuited to Fedify before the HTML loader runs; browsers fall
|
||||
// through untouched.
|
||||
export const middleware: Route.MiddlewareFunction[] = [
|
||||
async ({ request }, next) => {
|
||||
if (federationEnabled() && wantsActivityJson(request)) {
|
||||
return handleFederationRequest(request);
|
||||
}
|
||||
return next();
|
||||
},
|
||||
];
|
||||
|
||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
return data(await loadUserProfile(request, params.username));
|
||||
|
|
|
|||
|
|
@ -12,18 +12,19 @@
|
|||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fedify/fedify": "2.1.16",
|
||||
"@react-router/node": "catalog:",
|
||||
"@react-router/serve": "catalog:",
|
||||
"@sentry/node": "catalog:",
|
||||
"@sentry/react": "catalog:",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.1",
|
||||
"@simplewebauthn/server": "^13.3.0",
|
||||
"@trails-cool/api": "workspace:*",
|
||||
"@trails-cool/db": "workspace:*",
|
||||
"@trails-cool/jobs": "workspace:*",
|
||||
"@trails-cool/fit": "workspace:*",
|
||||
"@trails-cool/gpx": "workspace:*",
|
||||
"@trails-cool/i18n": "workspace:*",
|
||||
"@trails-cool/jobs": "workspace:*",
|
||||
"@trails-cool/map": "workspace:*",
|
||||
"@trails-cool/sentry-config": "workspace:*",
|
||||
"@trails-cool/types": "workspace:*",
|
||||
|
|
@ -31,7 +32,7 @@
|
|||
"drizzle-orm": "catalog:",
|
||||
"isbot": "^5.1.40",
|
||||
"jose": "^6.2.3",
|
||||
"nodemailer": "^8.0.10",
|
||||
"nodemailer": "^8.0.8",
|
||||
"pino": "^10.3.1",
|
||||
"prom-client": "^15.1.3",
|
||||
"react": "catalog:",
|
||||
|
|
|
|||
|
|
@ -2,4 +2,11 @@ import type { Config } from "@react-router/dev/config";
|
|||
|
||||
export default {
|
||||
ssr: true,
|
||||
future: {
|
||||
// Route middleware — used for ActivityPub content negotiation on
|
||||
// /users/:username (see app/lib/federation.server.ts). No loader or
|
||||
// action reads the `context` arg, so the flag's type change to
|
||||
// RouterContextProvider is a no-op for existing code.
|
||||
v8_middleware: true,
|
||||
},
|
||||
} satisfies Config;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue