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
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 });
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue