From 5db983386d0a86d4fc694802cb12268e5931c60e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 6 Jun 2026 23:18:30 +0200 Subject: [PATCH] feat(journal): dereferenceable Note objects at /activities/:id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Notes we emit (outbox + push delivery) use /activities/{id} as their id, but that URL only served HTML — so Mastodon's search-fetch of an activity URL failed, and strict instances that re-fetch pushed objects to verify them would drop our deliveries. - Fedify object dispatcher for Note at /activities/{id}: serves the same activityToNote mapping used everywhere else; 404 unless the activity is public AND the owner's profile is public (private owners don't federate, mirroring the actor). - Content-negotiation middleware on the activity detail route, same pattern as the actor route: AP Accept headers short-circuit to Fedify, browsers get HTML. Found during the staging soak (Mastodon showed the outbox post count but couldn't fetch the posts). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../lib/federation-outbox.integration.test.ts | 38 ++++++++++++++++++- apps/journal/app/lib/federation.server.ts | 32 ++++++++++++++-- apps/journal/app/routes/activities.$id.tsx | 18 +++++++++ 3 files changed, 83 insertions(+), 5 deletions(-) diff --git a/apps/journal/app/lib/federation-outbox.integration.test.ts b/apps/journal/app/lib/federation-outbox.integration.test.ts index 1fb55c9..da52de4 100644 --- a/apps/journal/app/lib/federation-outbox.integration.test.ts +++ b/apps/journal/app/lib/federation-outbox.integration.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { randomUUID } from "node:crypto"; import { getDb } from "./db.ts"; import { users, activities, follows } from "@trails-cool/db/schema/journal"; @@ -99,6 +99,42 @@ describe.runIf(runIntegration)("federation outbox (integration)", () => { await db.update(users).set({ profileVisibility: "public" }).where(eq(users.id, userId)); }); + it("serves a public activity's Note at its IRI (dereferenceable)", async () => { + const { handleFederationRequest } = await import("./federation.server.ts"); + const db = getDb(); + const [row] = await db + .select({ id: activities.id }) + .from(activities) + .where(and(eq(activities.ownerId, userId), eq(activities.visibility, "public"))) + .limit(1); + const res = await handleFederationRequest( + new Request(`http://localhost:3000/activities/${row!.id}`, { + headers: { accept: "application/activity+json" }, + }), + ); + expect(res.status).toBe(200); + const note = await res.json(); + expect(note.type).toBe("Note"); + expect(note.id).toBe(`http://localhost:3000/activities/${row!.id}`); + expect(note.attributedTo).toContain(`/users/${USERNAME}`); + }); + + it("404s the Note of a private activity", async () => { + const { handleFederationRequest } = await import("./federation.server.ts"); + const db = getDb(); + const [priv] = await db + .select({ id: activities.id }) + .from(activities) + .where(and(eq(activities.ownerId, userId), eq(activities.visibility, "private"))) + .limit(1); + const res = await handleFederationRequest( + new Request(`http://localhost:3000/activities/${priv!.id}`, { + headers: { accept: "application/activity+json" }, + }), + ); + expect(res.status).toBe(404); + }); + it("lists accepted remote followers as the delivery audience", async () => { const db = getDb(); const { listAcceptedRemoteFollowers } = await import("./federation-delivery.server.ts"); diff --git a/apps/journal/app/lib/federation.server.ts b/apps/journal/app/lib/federation.server.ts index 761f93b..6cb971e 100644 --- a/apps/journal/app/lib/federation.server.ts +++ b/apps/journal/app/lib/federation.server.ts @@ -27,15 +27,15 @@ // to wrapped types we inspect and ignore (e.g. Undo(Like)). import { configure, getConsoleSink } from "@logtape/logtape"; import { createFederation, InProcessMessageQueue, type Federation } from "@fedify/fedify"; -import { Accept, Follow, Person, PropertyValue, Reject, Undo } from "@fedify/fedify/vocab"; -import { eq } from "drizzle-orm"; -import { users } from "@trails-cool/db/schema/journal"; +import { Accept, Follow, Note, Person, PropertyValue, Reject, Undo } from "@fedify/fedify/vocab"; +import { and, eq } from "drizzle-orm"; +import { activities, users } from "@trails-cool/db/schema/journal"; import { getDb } from "./db.ts"; import { getOrigin } from "./config.server.ts"; import { localActorIri } from "./actor-iri.ts"; import { PostgresKvStore } from "./federation-kv.server.ts"; import { ensureUserKeypair, loadUserKeypair } from "./federation-keys.server.ts"; -import { activityToCreate } from "./federation-objects.server.ts"; +import { activityToCreate, activityToNote } from "./federation-objects.server.ts"; import { OUTBOX_PAGE_SIZE, countPublicActivities, @@ -289,6 +289,30 @@ function buildFederation(): Federation { logger.warn({ err: error }, "federation: inbox listener error"); }); + // Dereferenceable Note objects: the Notes we emit (outbox + push + // delivery) use /activities/{id} as their id, so that IRI must serve + // the Note as activity+json. Mastodon's search-fetch of an activity + // URL and strict instances that re-fetch pushed objects both rely on + // this. Browsers keep getting HTML — the activities.$id route + // middleware short-circuits AP requests here. + federation.setObjectDispatcher(Note, "/activities/{id}", async (_ctx, values) => { + const db = getDb(); + const [row] = await db + .select() + .from(activities) + .where(and(eq(activities.id, values.id), eq(activities.visibility, "public"))) + .limit(1); + if (!row) return null; + const [owner] = await db + .select({ username: users.username, profileVisibility: users.profileVisibility }) + .from(users) + .where(eq(users.id, row.ownerId)) + .limit(1); + // Private owners don't federate — their content 404s like their actor. + if (!owner || owner.profileVisibility !== "public") return null; + return activityToNote(row, owner.username); + }); + // Software discovery (task 3.4, adapted): NodeInfo is the standard // the fediverse uses to identify server software; the trails-to-trails // outbound check (task 6.x) reads this from remote instances. diff --git a/apps/journal/app/routes/activities.$id.tsx b/apps/journal/app/routes/activities.$id.tsx index fa189dd..37220d0 100644 --- a/apps/journal/app/routes/activities.$id.tsx +++ b/apps/journal/app/routes/activities.$id.tsx @@ -4,6 +4,24 @@ import type { Route } from "./+types/activities.$id"; import { ClientDate } from "~/components/ClientDate"; import { ClientMap } from "~/components/ClientMap"; import { loadActivityDetail, activityDetailAction } from "./activities.$id.server"; +import { + federationEnabled, + wantsActivityJson, + handleFederationRequest, +} from "~/lib/federation.server"; + +// Content negotiation: this URL doubles as the ActivityPub Note IRI for +// the activity (the id our outbox/push deliveries emit). AP clients get +// the Note object from Fedify's object dispatcher; browsers fall through +// to the HTML page. Same pattern as the actor route (users.$username). +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 loadActivityDetail(request, params.id));