feat(journal): dereferenceable Note objects at /activities/:id

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) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-06-06 23:18:30 +02:00
parent 0cb2a901fe
commit 5db983386d
3 changed files with 83 additions and 5 deletions

View file

@ -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");

View file

@ -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<void> {
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.

View file

@ -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));