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:
parent
0cb2a901fe
commit
5db983386d
3 changed files with 83 additions and 5 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
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 { randomUUID } from "node:crypto";
|
||||||
import { getDb } from "./db.ts";
|
import { getDb } from "./db.ts";
|
||||||
import { users, activities, follows } from "@trails-cool/db/schema/journal";
|
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));
|
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 () => {
|
it("lists accepted remote followers as the delivery audience", async () => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const { listAcceptedRemoteFollowers } = await import("./federation-delivery.server.ts");
|
const { listAcceptedRemoteFollowers } = await import("./federation-delivery.server.ts");
|
||||||
|
|
|
||||||
|
|
@ -27,15 +27,15 @@
|
||||||
// to wrapped types we inspect and ignore (e.g. Undo(Like)).
|
// to wrapped types we inspect and ignore (e.g. Undo(Like)).
|
||||||
import { configure, getConsoleSink } from "@logtape/logtape";
|
import { configure, getConsoleSink } from "@logtape/logtape";
|
||||||
import { createFederation, InProcessMessageQueue, type Federation } from "@fedify/fedify";
|
import { createFederation, InProcessMessageQueue, type Federation } from "@fedify/fedify";
|
||||||
import { Accept, Follow, Person, PropertyValue, Reject, Undo } from "@fedify/fedify/vocab";
|
import { Accept, Follow, Note, Person, PropertyValue, Reject, Undo } from "@fedify/fedify/vocab";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { users } from "@trails-cool/db/schema/journal";
|
import { activities, users } from "@trails-cool/db/schema/journal";
|
||||||
import { getDb } from "./db.ts";
|
import { getDb } from "./db.ts";
|
||||||
import { getOrigin } from "./config.server.ts";
|
import { getOrigin } from "./config.server.ts";
|
||||||
import { localActorIri } from "./actor-iri.ts";
|
import { localActorIri } from "./actor-iri.ts";
|
||||||
import { PostgresKvStore } from "./federation-kv.server.ts";
|
import { PostgresKvStore } from "./federation-kv.server.ts";
|
||||||
import { ensureUserKeypair, loadUserKeypair } from "./federation-keys.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 {
|
import {
|
||||||
OUTBOX_PAGE_SIZE,
|
OUTBOX_PAGE_SIZE,
|
||||||
countPublicActivities,
|
countPublicActivities,
|
||||||
|
|
@ -289,6 +289,30 @@ function buildFederation(): Federation<void> {
|
||||||
logger.warn({ err: error }, "federation: inbox listener error");
|
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
|
// Software discovery (task 3.4, adapted): NodeInfo is the standard
|
||||||
// the fediverse uses to identify server software; the trails-to-trails
|
// the fediverse uses to identify server software; the trails-to-trails
|
||||||
// outbound check (task 6.x) reads this from remote instances.
|
// outbound check (task 6.x) reads this from remote instances.
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,24 @@ import type { Route } from "./+types/activities.$id";
|
||||||
import { ClientDate } from "~/components/ClientDate";
|
import { ClientDate } from "~/components/ClientDate";
|
||||||
import { ClientMap } from "~/components/ClientMap";
|
import { ClientMap } from "~/components/ClientMap";
|
||||||
import { loadActivityDetail, activityDetailAction } from "./activities.$id.server";
|
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) {
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
return data(await loadActivityDetail(request, params.id));
|
return data(await loadActivityDetail(request, params.id));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue