trails/apps/journal/app/lib/federation.server.ts
Ullrich Schäfer 4ef86e4dc2 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>
2026-06-06 14:15:27 +02:00

99 lines
4 KiB
TypeScript

// 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 });
}