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