feat(journal): federation outbox + push delivery to remote followers
social-federation tasks 5.1–5.6. Completes the inbound-federation story: a Mastodon follower now receives a trails user's new public activities in their home timeline. Outbox (5.1/5.2): - /users/:username/outbox — paginated OrderedCollection of public activities as Create(Note), newest first; unlisted/private never federate. Private-user 404 enforced at the route layer because Fedify builds collection-level responses from counter/cursors without consulting the page dispatcher. - Note shape: HTML content (escaped name/description/stats + link to the activity page) with structured PropertyValue attachments (distance-m, elevation-gain-m, duration-s) — Mastodon renders the text, trails consumers read the structured fields. Resolves the design open question toward Create(Note). - Authorized Fetch: signed and unsigned outbox fetches deliberately see the same (public-only) content until locked accounts exist. Push delivery (5.3–5.6): - createActivity / updateActivityVisibility(→public) enqueue one deliver-activity job per accepted remote follower; flips away from public and hard deletes enqueue Delete(Tombstone) retractions (enqueued before the row disappears). - deliver-activity job: re-reads the row at delivery time (skips if gone or no longer public), resolves the recipient inbox via the remote_actors cache with actor-document fetch fallback (priming the cache), HTTP-signs via the owner's key, and POSTs. retryLimit 8 + exponential backoff at enqueue time; outbound paced at 1 req/s per remote host. - Actor objects now advertise the outbox IRI. - @js-temporal/polyfill added (same range Fedify uses) for published timestamps; Fedify's types want the global esnext.temporal namespace, bridged with a documented cast. Tests: 9 unit tests for the AS mapping (escaping, stats, attachments, published fallback, stable ids, tombstones), 4 outbox integration tests (collection count, page shape/visibility filtering, private-404, delivery audience query). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bec249f93f
commit
bc233e03e5
16 changed files with 905 additions and 27 deletions
119
apps/journal/app/jobs/deliver-activity.ts
Normal file
119
apps/journal/app/jobs/deliver-activity.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import type { JobDefinition } from "@trails-cool/jobs";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { activities } from "@trails-cool/db/schema/journal";
|
||||
import { getDb } from "../lib/db.ts";
|
||||
import { getOrigin } from "../lib/config.server.ts";
|
||||
import { getFederation } from "../lib/federation.server.ts";
|
||||
import {
|
||||
activityToCreate,
|
||||
activityToDelete,
|
||||
type FederatableActivity,
|
||||
} from "../lib/federation-objects.server.ts";
|
||||
import {
|
||||
getCachedRemoteActor,
|
||||
upsertRemoteActor,
|
||||
type DeliveryPayload,
|
||||
} from "../lib/federation-delivery.server.ts";
|
||||
import { logger } from "../lib/logger.server.ts";
|
||||
|
||||
/**
|
||||
* Outbound pacing (spec 5.5): never exceed 1 request/second per remote
|
||||
* host. In-process map is sufficient — pg-boss works this queue
|
||||
* sequentially in the single journal process.
|
||||
*/
|
||||
const lastSendPerHost = new Map<string, number>();
|
||||
const MIN_INTERVAL_MS = 1000;
|
||||
|
||||
async function paceHost(host: string): Promise<void> {
|
||||
const last = lastSendPerHost.get(host) ?? 0;
|
||||
const wait = last + MIN_INTERVAL_MS - Date.now();
|
||||
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
||||
lastSendPerHost.set(host, Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver one activity to one remote follower's inbox (spec 5.3/5.4).
|
||||
* One job per (activity, recipient) so each delivery retries with
|
||||
* exponential backoff independently (configured at enqueue time —
|
||||
* see enqueueActivityDeliveries). A thrown error marks the attempt
|
||||
* failed and pg-boss retries; exhausting the budget is the permanent
|
||||
* failure, logged by the final catch.
|
||||
*/
|
||||
export const deliverActivityJob: JobDefinition = {
|
||||
name: "deliver-activity",
|
||||
expireInSeconds: 60,
|
||||
async handler(jobs) {
|
||||
for (const job of jobs) {
|
||||
const p = job.data as DeliveryPayload;
|
||||
try {
|
||||
await deliverOne(p);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, action: p.action, objectIri: p.objectIri, recipient: p.recipientActorIri },
|
||||
"deliver-activity attempt failed (pg-boss will retry until budget exhausted)",
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
async function deliverOne(p: DeliveryPayload): Promise<void> {
|
||||
const federation = getFederation();
|
||||
const ctx = federation.createContext(new URL(getOrigin()), undefined);
|
||||
|
||||
// Build the activity to send. For `create`, re-read the row at
|
||||
// delivery time: if it was deleted or un-publicized since enqueue,
|
||||
// skip rather than leak.
|
||||
let activity;
|
||||
if (p.action === "create") {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(activities)
|
||||
.where(and(eq(activities.id, p.activityId!), eq(activities.visibility, "public")))
|
||||
.limit(1);
|
||||
if (!row) {
|
||||
logger.info({ objectIri: p.objectIri }, "deliver-activity: activity gone or non-public; skipping");
|
||||
return;
|
||||
}
|
||||
activity = activityToCreate(row as FederatableActivity, p.ownerUsername);
|
||||
} else {
|
||||
activity = activityToDelete(p.objectIri, p.ownerUsername);
|
||||
}
|
||||
|
||||
// Resolve the recipient's inbox: cached remote_actors row first,
|
||||
// actor-document fetch as fallback (which also primes the cache).
|
||||
const recipientIri = new URL(p.recipientActorIri);
|
||||
let inboxUrl: URL;
|
||||
const cached = await getCachedRemoteActor(p.recipientActorIri);
|
||||
if (cached?.inboxUrl) {
|
||||
inboxUrl = new URL(cached.inboxUrl);
|
||||
} else {
|
||||
await paceHost(recipientIri.host);
|
||||
const actor = await ctx.lookupObject(recipientIri);
|
||||
const fetchedInbox =
|
||||
actor != null && "inboxId" in actor ? (actor.inboxId as URL | null) : null;
|
||||
if (!fetchedInbox) {
|
||||
throw new Error(`deliver-activity: cannot resolve inbox for ${p.recipientActorIri}`);
|
||||
}
|
||||
inboxUrl = fetchedInbox;
|
||||
await upsertRemoteActor({
|
||||
actorIri: p.recipientActorIri,
|
||||
inboxUrl: inboxUrl.href,
|
||||
domain: recipientIri.host,
|
||||
});
|
||||
}
|
||||
|
||||
await paceHost(inboxUrl.host);
|
||||
await ctx.sendActivity(
|
||||
// Identifier and username are the same thing in our actor model.
|
||||
{ identifier: p.ownerUsername },
|
||||
{ id: recipientIri, inboxId: inboxUrl },
|
||||
activity,
|
||||
);
|
||||
logger.info(
|
||||
{ action: p.action, objectIri: p.objectIri, recipient: p.recipientActorIri },
|
||||
"deliver-activity: delivered",
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import type { Visibility } from "@trails-cool/db/schema/journal";
|
|||
import { validateGpx, writeGeom } from "./gpx-save.server.ts";
|
||||
import type { GpxData } from "./gpx-save.server.ts";
|
||||
import { enqueueOptional } from "./boss.server.ts";
|
||||
import { enqueueActivityDeliveries } from "./federation-delivery.server.ts";
|
||||
|
||||
export interface ActivityInput {
|
||||
name: string;
|
||||
|
|
@ -38,6 +39,16 @@ export async function updateActivityVisibility(
|
|||
// followers (only the first transition per activity emits).
|
||||
if (visibility === "public") {
|
||||
await enqueueOptional("notifications-fanout", { activityId: id }, { source: "updateActivityVisibility" });
|
||||
// Federation: newly-public activities push to remote followers as
|
||||
// Create(Note) (spec 5.3/5.6 — publish-on-flip is the "update"
|
||||
// remotes care about; the delivery job re-checks visibility).
|
||||
await enqueueActivityDeliveries(ownerId, id, "create");
|
||||
} else {
|
||||
// Was the activity possibly public before? Retract from remotes —
|
||||
// a Delete for an object a remote never saw is acknowledged and
|
||||
// dropped, so over-sending here is harmless and avoids tracking
|
||||
// previous visibility (spec 5.6, basic update/delete fan-out).
|
||||
await enqueueActivityDeliveries(ownerId, id, "delete");
|
||||
}
|
||||
|
||||
return true;
|
||||
|
|
@ -92,6 +103,8 @@ export async function createActivity(ownerId: string, input: ActivityInput) {
|
|||
// up-front rather than flipped later).
|
||||
if (input.visibility === "public") {
|
||||
await enqueueOptional("notifications-fanout", { activityId: id }, { source: "createActivity" });
|
||||
// Federation push delivery to accepted remote followers (spec 5.3).
|
||||
await enqueueActivityDeliveries(ownerId, id, "create");
|
||||
}
|
||||
|
||||
return id;
|
||||
|
|
@ -108,9 +121,15 @@ export async function getActivity(id: string) {
|
|||
|
||||
export async function deleteActivity(id: string, ownerId: string): Promise<boolean> {
|
||||
const db = getDb();
|
||||
const [activity] = await db.select({ id: activities.id }).from(activities)
|
||||
const [activity] = await db.select({ id: activities.id, visibility: activities.visibility }).from(activities)
|
||||
.where(and(eq(activities.id, id), eq(activities.ownerId, ownerId)));
|
||||
if (!activity) return false;
|
||||
// Enqueue the federation retraction *before* the row disappears —
|
||||
// the Delete payload carries everything it needs (object IRI +
|
||||
// owner), so it survives the deletion (spec 5.6).
|
||||
if (activity.visibility === "public") {
|
||||
await enqueueActivityDeliveries(ownerId, id, "delete");
|
||||
}
|
||||
await db.delete(activities).where(eq(activities.id, id));
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,14 @@ import { logger } from "./logger.server.ts";
|
|||
|
||||
// Structurally typed (we only need `send`) so we don't have to pull
|
||||
// pg-boss into the journal app's dep graph just for the typedef.
|
||||
export interface BossSendOptions {
|
||||
retryLimit?: number;
|
||||
retryBackoff?: boolean;
|
||||
retryDelay?: number;
|
||||
}
|
||||
|
||||
interface BossLike {
|
||||
send(queueName: string, data: unknown): Promise<string | null>;
|
||||
send(queueName: string, data: unknown, options?: BossSendOptions): Promise<string | null>;
|
||||
}
|
||||
|
||||
let _boss: BossLike | null = null;
|
||||
|
|
@ -40,10 +46,11 @@ export async function enqueueOptional(
|
|||
queue: string,
|
||||
data: unknown,
|
||||
ctx: Record<string, unknown> = {},
|
||||
options?: BossSendOptions,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const boss = getBoss();
|
||||
await boss.send(queue, data);
|
||||
await boss.send(queue, data, options);
|
||||
} catch (err) {
|
||||
logger.warn({ err, queue, ...ctx }, "boss.send failed; continuing");
|
||||
}
|
||||
|
|
|
|||
115
apps/journal/app/lib/federation-delivery.server.ts
Normal file
115
apps/journal/app/lib/federation-delivery.server.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
// Push delivery of local public activities to accepted remote
|
||||
// followers (spec: social-federation, "Push delivery on local activity
|
||||
// create"). Enqueue side lives here; the actual signed POST happens in
|
||||
// jobs/deliver-activity.ts.
|
||||
|
||||
import { and, eq, isNotNull } from "drizzle-orm";
|
||||
import { follows, remoteActors, users } from "@trails-cool/db/schema/journal";
|
||||
import { getDb } from "./db.ts";
|
||||
import { federationEnabled } from "./federation.server.ts";
|
||||
import { enqueueOptional } from "./boss.server.ts";
|
||||
import { activityObjectIri } from "./federation-objects.server.ts";
|
||||
|
||||
export type DeliveryAction = "create" | "delete";
|
||||
|
||||
export interface DeliveryPayload {
|
||||
action: DeliveryAction;
|
||||
/** Present for `create` — the job re-reads the row at delivery time. */
|
||||
activityId?: string;
|
||||
/** Object IRI; for `delete` this is all that's left of the activity. */
|
||||
objectIri: string;
|
||||
ownerUsername: string;
|
||||
recipientActorIri: string;
|
||||
}
|
||||
|
||||
/** Accepted remote followers of a local user (the delivery audience). */
|
||||
export async function listAcceptedRemoteFollowers(ownerId: string): Promise<string[]> {
|
||||
const db = getDb();
|
||||
const rows = await db
|
||||
.select({ actorIri: follows.followerActorIri })
|
||||
.from(follows)
|
||||
.where(
|
||||
and(
|
||||
eq(follows.followedUserId, ownerId),
|
||||
isNotNull(follows.followerActorIri),
|
||||
isNotNull(follows.acceptedAt),
|
||||
),
|
||||
);
|
||||
return rows.map((r) => r.actorIri).filter((iri): iri is string => iri !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan out one delivery job per accepted remote follower (spec 5.3: a
|
||||
* job per follower, each with its own retry/backoff lifecycle). No-op
|
||||
* when federation is off or the user has no remote followers — the
|
||||
* common case stays a single SELECT.
|
||||
*/
|
||||
export async function enqueueActivityDeliveries(
|
||||
ownerId: string,
|
||||
activityId: string,
|
||||
action: DeliveryAction,
|
||||
): Promise<void> {
|
||||
if (!federationEnabled()) return;
|
||||
const recipients = await listAcceptedRemoteFollowers(ownerId);
|
||||
if (recipients.length === 0) return;
|
||||
|
||||
const db = getDb();
|
||||
const [owner] = await db
|
||||
.select({ username: users.username, profileVisibility: users.profileVisibility })
|
||||
.from(users)
|
||||
.where(eq(users.id, ownerId))
|
||||
.limit(1);
|
||||
// Private profiles don't federate — suppress push delivery entirely
|
||||
// (spec 9.3: flipping to private stops federation).
|
||||
if (!owner || owner.profileVisibility !== "public") return;
|
||||
|
||||
for (const recipientActorIri of recipients) {
|
||||
const payload: DeliveryPayload = {
|
||||
action,
|
||||
activityId: action === "create" ? activityId : undefined,
|
||||
objectIri: activityObjectIri(activityId),
|
||||
ownerUsername: owner.username,
|
||||
recipientActorIri,
|
||||
};
|
||||
await enqueueOptional(
|
||||
"deliver-activity",
|
||||
payload,
|
||||
{ source: "enqueueActivityDeliveries", action },
|
||||
// Spec 5.4: exponential backoff on failure, bounded retry budget.
|
||||
// 8 retries with backoff spans roughly a day before permanent fail.
|
||||
{ retryLimit: 8, retryBackoff: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface CachedRemoteActor {
|
||||
actorIri: string;
|
||||
inboxUrl: string | null;
|
||||
}
|
||||
|
||||
/** Read the remote actor cache; null when we've never seen the actor. */
|
||||
export async function getCachedRemoteActor(actorIri: string): Promise<CachedRemoteActor | null> {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.select({ actorIri: remoteActors.actorIri, inboxUrl: remoteActors.inboxUrl })
|
||||
.from(remoteActors)
|
||||
.where(eq(remoteActors.actorIri, actorIri))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/** Upsert the fields delivery learns about a remote actor. */
|
||||
export async function upsertRemoteActor(fields: {
|
||||
actorIri: string;
|
||||
inboxUrl?: string | null;
|
||||
displayName?: string | null;
|
||||
username?: string | null;
|
||||
domain?: string | null;
|
||||
}): Promise<void> {
|
||||
const db = getDb();
|
||||
const { actorIri, ...rest } = fields;
|
||||
await db
|
||||
.insert(remoteActors)
|
||||
.values({ actorIri, ...rest })
|
||||
.onConflictDoUpdate({ target: remoteActors.actorIri, set: rest });
|
||||
}
|
||||
86
apps/journal/app/lib/federation-objects.server.test.ts
Normal file
86
apps/journal/app/lib/federation-objects.server.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
activityToNote,
|
||||
activityToCreate,
|
||||
activityToDelete,
|
||||
activityObjectIri,
|
||||
type FederatableActivity,
|
||||
} from "./federation-objects.server.ts";
|
||||
|
||||
const ACTIVITY: FederatableActivity = {
|
||||
id: "act-1",
|
||||
name: "Morning ride <3",
|
||||
description: 'Through the "forest" & hills',
|
||||
distance: 42_195,
|
||||
elevationGain: 512.4,
|
||||
duration: 2 * 3600 + 30 * 60,
|
||||
startedAt: new Date("2026-06-01T08:00:00Z"),
|
||||
createdAt: new Date("2026-06-01T12:00:00Z"),
|
||||
};
|
||||
|
||||
describe("activityToNote", () => {
|
||||
it("builds a public Note addressed at the activity page", async () => {
|
||||
const note = activityToNote(ACTIVITY, "bruno");
|
||||
expect(note.id?.href).toBe("http://localhost:3000/activities/act-1");
|
||||
expect(note.attributionId?.href).toBe("http://localhost:3000/users/bruno");
|
||||
expect(note.toIds.map((u) => u.href)).toContain(
|
||||
"https://www.w3.org/ns/activitystreams#Public",
|
||||
);
|
||||
expect(note.published?.toString()).toBe("2026-06-01T08:00:00Z");
|
||||
});
|
||||
|
||||
it("escapes HTML in user content and includes stats + link", () => {
|
||||
const note = activityToNote(ACTIVITY, "bruno");
|
||||
const content = String(note.content);
|
||||
expect(content).toContain("Morning ride <3");
|
||||
expect(content).toContain(""forest" & hills");
|
||||
expect(content).toContain("42.2 km");
|
||||
expect(content).toContain("↗ 512 m");
|
||||
expect(content).toContain("2h 30m");
|
||||
expect(content).toContain('href="http://localhost:3000/activities/act-1"');
|
||||
});
|
||||
|
||||
it("carries structured metadata as PropertyValue attachments", async () => {
|
||||
const note = activityToNote(ACTIVITY, "bruno");
|
||||
const attachments = [];
|
||||
for await (const a of note.getAttachments()) attachments.push(a);
|
||||
const byName = new Map(
|
||||
attachments.map((a) => [String((a as { name: unknown }).name), String((a as { value: unknown }).value)]),
|
||||
);
|
||||
expect(byName.get("distance-m")).toBe("42195");
|
||||
expect(byName.get("elevation-gain-m")).toBe("512");
|
||||
expect(byName.get("duration-s")).toBe("9000");
|
||||
});
|
||||
|
||||
it("omits stats it doesn't have", () => {
|
||||
const bare = activityToNote(
|
||||
{ ...ACTIVITY, distance: null, elevationGain: null, duration: null, description: null },
|
||||
"bruno",
|
||||
);
|
||||
const content = String(bare.content);
|
||||
expect(content).not.toContain("km");
|
||||
expect(content).not.toContain("↗");
|
||||
});
|
||||
|
||||
it("falls back to createdAt when startedAt is missing", () => {
|
||||
const note = activityToNote({ ...ACTIVITY, startedAt: null }, "bruno");
|
||||
expect(note.published?.toString()).toBe("2026-06-01T12:00:00Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("activityToCreate", () => {
|
||||
it("derives a stable id from the object and attributes the actor", () => {
|
||||
const create = activityToCreate(ACTIVITY, "bruno");
|
||||
expect(create.id?.href).toBe("http://localhost:3000/activities/act-1#create");
|
||||
expect(create.actorId?.href).toBe("http://localhost:3000/users/bruno");
|
||||
});
|
||||
});
|
||||
|
||||
describe("activityToDelete", () => {
|
||||
it("wraps a Tombstone for the deleted object", async () => {
|
||||
const del = activityToDelete(activityObjectIri("act-1"), "bruno");
|
||||
expect(del.actorId?.href).toBe("http://localhost:3000/users/bruno");
|
||||
const tombstone = await del.getObject();
|
||||
expect(tombstone?.id?.href).toBe("http://localhost:3000/activities/act-1");
|
||||
});
|
||||
});
|
||||
121
apps/journal/app/lib/federation-objects.server.ts
Normal file
121
apps/journal/app/lib/federation-objects.server.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
// Mapping from journal activities to ActivityStreams objects (spec:
|
||||
// social-federation, "Outbox publishes user's public activities" +
|
||||
// push delivery). One mapping, used by both the outbox dispatcher and
|
||||
// the deliver-activity job so remotes see identical shapes either way.
|
||||
//
|
||||
// Shape decision (design.md open question, resolved toward Mastodon
|
||||
// compat): plain `Create(Note)`. The Note's HTML content carries the
|
||||
// human-readable text + a link back to the activity page; structured
|
||||
// trails metadata (distance, elevation, duration) rides along as
|
||||
// PropertyValue attachments, which Mastodon ignores gracefully and
|
||||
// trails consumers can read without parsing the HTML.
|
||||
|
||||
import { Temporal as TemporalPolyfill } from "@js-temporal/polyfill";
|
||||
import { Create, Delete, Note, PropertyValue, Tombstone, PUBLIC_COLLECTION } from "@fedify/fedify/vocab";
|
||||
import { getOrigin } from "./config.server.ts";
|
||||
import { localActorIri } from "./actor-iri.ts";
|
||||
|
||||
export interface FederatableActivity {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
distance: number | null;
|
||||
elevationGain: number | null;
|
||||
duration: number | null;
|
||||
startedAt: Date | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/** Public IRI of an activity — its journal detail page. */
|
||||
export function activityObjectIri(activityId: string): string {
|
||||
return `${getOrigin()}/activities/${activityId}`;
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
function formatStats(a: FederatableActivity): string {
|
||||
const parts: string[] = [];
|
||||
if (a.distance != null) parts.push(`${(a.distance / 1000).toFixed(1)} km`);
|
||||
if (a.elevationGain != null) parts.push(`↗ ${Math.round(a.elevationGain)} m`);
|
||||
if (a.duration != null) {
|
||||
const h = Math.floor(a.duration / 3600);
|
||||
const m = Math.round((a.duration % 3600) / 60);
|
||||
parts.push(h > 0 ? `${h}h ${m}m` : `${m}m`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
// Fedify's types reference the *global* Temporal namespace
|
||||
// (esnext.temporal lib); Node 22 doesn't ship Temporal at runtime, so
|
||||
// we construct via the polyfill and bridge the nominally-distinct (but
|
||||
// structurally identical) types with a cast.
|
||||
function toInstant(d: Date): Temporal.Instant {
|
||||
return TemporalPolyfill.Instant.fromEpochMilliseconds(
|
||||
d.getTime(),
|
||||
) as unknown as Temporal.Instant;
|
||||
}
|
||||
|
||||
export function activityToNote(a: FederatableActivity, ownerUsername: string): Note {
|
||||
const objectIri = activityObjectIri(a.id);
|
||||
const stats = formatStats(a);
|
||||
const paragraphs = [
|
||||
`<p>${escapeHtml(a.name)}</p>`,
|
||||
a.description ? `<p>${escapeHtml(a.description)}</p>` : "",
|
||||
stats ? `<p>${escapeHtml(stats)}</p>` : "",
|
||||
`<p><a href="${objectIri}">${objectIri}</a></p>`,
|
||||
].filter(Boolean);
|
||||
|
||||
const attachments: PropertyValue[] = [];
|
||||
if (a.distance != null) {
|
||||
attachments.push(new PropertyValue({ name: "distance-m", value: String(Math.round(a.distance)) }));
|
||||
}
|
||||
if (a.elevationGain != null) {
|
||||
attachments.push(new PropertyValue({ name: "elevation-gain-m", value: String(Math.round(a.elevationGain)) }));
|
||||
}
|
||||
if (a.duration != null) {
|
||||
attachments.push(new PropertyValue({ name: "duration-s", value: String(a.duration) }));
|
||||
}
|
||||
|
||||
return new Note({
|
||||
id: new URL(objectIri),
|
||||
attribution: new URL(localActorIri(ownerUsername)),
|
||||
content: paragraphs.join("\n"),
|
||||
mediaType: "text/html",
|
||||
url: new URL(objectIri),
|
||||
published: toInstant(a.startedAt ?? a.createdAt),
|
||||
to: PUBLIC_COLLECTION,
|
||||
attachments,
|
||||
});
|
||||
}
|
||||
|
||||
export function activityToCreate(a: FederatableActivity, ownerUsername: string): Create {
|
||||
return new Create({
|
||||
// Stable id derived from the object so replays/dedupe work across
|
||||
// outbox pages and push deliveries alike.
|
||||
id: new URL(`${activityObjectIri(a.id)}#create`),
|
||||
actor: new URL(localActorIri(ownerUsername)),
|
||||
object: activityToNote(a, ownerUsername),
|
||||
published: toInstant(a.startedAt ?? a.createdAt),
|
||||
to: PUBLIC_COLLECTION,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retraction for a deleted (or un-publicized) activity. Carries a
|
||||
* Tombstone so remotes drop their copy (Mastodon honors Delete +
|
||||
* Tombstone).
|
||||
*/
|
||||
export function activityToDelete(objectIri: string, ownerUsername: string): Delete {
|
||||
return new Delete({
|
||||
id: new URL(`${objectIri}#delete-${Date.now()}`),
|
||||
actor: new URL(localActorIri(ownerUsername)),
|
||||
object: new Tombstone({ id: new URL(objectIri) }),
|
||||
to: PUBLIC_COLLECTION,
|
||||
});
|
||||
}
|
||||
124
apps/journal/app/lib/federation-outbox.integration.test.ts
Normal file
124
apps/journal/app/lib/federation-outbox.integration.test.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getDb } from "./db.ts";
|
||||
import { users, activities, follows } from "@trails-cool/db/schema/journal";
|
||||
|
||||
// Opt-in: talks to real Postgres (and exercises the real Fedify
|
||||
// dispatcher stack via handleFederationRequest). Same convention as the
|
||||
// other *.integration.test.ts files.
|
||||
const runIntegration = process.env.FEDERATION_INTEGRATION === "1";
|
||||
|
||||
const USERNAME = `outbox-user-${Date.now()}`;
|
||||
let userId: string;
|
||||
|
||||
async function seed() {
|
||||
const db = getDb();
|
||||
userId = randomUUID();
|
||||
await db.insert(users).values({
|
||||
id: userId,
|
||||
email: `${USERNAME}@example.test`,
|
||||
username: USERNAME,
|
||||
domain: "test.local",
|
||||
profileVisibility: "public",
|
||||
});
|
||||
// 3 public, 1 unlisted, 1 private — only the 3 public ones federate.
|
||||
const mk = (n: number, visibility: "public" | "unlisted" | "private") => ({
|
||||
id: randomUUID(),
|
||||
ownerId: userId,
|
||||
name: `Activity ${n}`,
|
||||
visibility,
|
||||
distance: 1000 * n,
|
||||
createdAt: new Date(Date.now() - n * 60_000),
|
||||
});
|
||||
await db
|
||||
.insert(activities)
|
||||
.values([mk(1, "public"), mk(2, "public"), mk(3, "public"), mk(4, "unlisted"), mk(5, "private")]);
|
||||
}
|
||||
|
||||
describe.runIf(runIntegration)("federation outbox (integration)", () => {
|
||||
beforeAll(async () => {
|
||||
process.env.FEDERATION_ENABLED = "true";
|
||||
process.env.ORIGIN ??= "http://localhost:3000";
|
||||
await seed();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const db = getDb();
|
||||
await db.delete(activities).where(eq(activities.ownerId, userId));
|
||||
await db.delete(follows).where(eq(follows.followedUserId, userId));
|
||||
await db.delete(users).where(eq(users.id, userId));
|
||||
});
|
||||
|
||||
// Mirrors routes/users.$username.outbox.ts: route-level visibility
|
||||
// gate first (Fedify's collection-level response doesn't consult the
|
||||
// page dispatcher), then delegate to Fedify.
|
||||
async function fetchOutbox(path: string): Promise<Response> {
|
||||
const { handleFederationRequest, isFederatableUser } = await import("./federation.server.ts");
|
||||
if (!(await isFederatableUser(USERNAME))) {
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
return handleFederationRequest(
|
||||
new Request(`http://localhost:3000${path}`, {
|
||||
headers: { accept: "application/activity+json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
it("serves an OrderedCollection counting only public activities", async () => {
|
||||
const res = await fetchOutbox(`/users/${USERNAME}/outbox`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.type).toBe("OrderedCollection");
|
||||
expect(body.totalItems).toBe(3);
|
||||
expect(body.first).toBeDefined();
|
||||
});
|
||||
|
||||
it("pages contain Create(Note) items, public only, newest first", async () => {
|
||||
const res = await fetchOutbox(`/users/${USERNAME}/outbox?cursor=0`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
const items = Array.isArray(body.orderedItems) ? body.orderedItems : [body.orderedItems];
|
||||
expect(items).toHaveLength(3);
|
||||
for (const item of items) {
|
||||
expect(item.type).toBe("Create");
|
||||
expect(item.object.type).toBe("Note");
|
||||
expect(item.object.attributedTo).toContain(`/users/${USERNAME}`);
|
||||
}
|
||||
const names = items.map((i: { object: { content: string } }) => i.object.content);
|
||||
expect(names[0]).toContain("Activity 1"); // newest first
|
||||
expect(names.join()).not.toContain("Activity 4"); // unlisted excluded
|
||||
expect(names.join()).not.toContain("Activity 5"); // private excluded
|
||||
});
|
||||
|
||||
it("404s the outbox of a private profile", async () => {
|
||||
const db = getDb();
|
||||
await db.update(users).set({ profileVisibility: "private" }).where(eq(users.id, userId));
|
||||
const res = await fetchOutbox(`/users/${USERNAME}/outbox`);
|
||||
expect(res.status).toBe(404);
|
||||
await db.update(users).set({ profileVisibility: "public" }).where(eq(users.id, userId));
|
||||
});
|
||||
|
||||
it("lists accepted remote followers as the delivery audience", async () => {
|
||||
const db = getDb();
|
||||
const { listAcceptedRemoteFollowers } = await import("./federation-delivery.server.ts");
|
||||
await db.insert(follows).values([
|
||||
{
|
||||
id: randomUUID(),
|
||||
followerActorIri: "https://other.example/users/accepted",
|
||||
followedActorIri: `http://localhost:3000/users/${USERNAME}`,
|
||||
followedUserId: userId,
|
||||
acceptedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: randomUUID(),
|
||||
followerActorIri: "https://other.example/users/pending",
|
||||
followedActorIri: `http://localhost:3000/users/${USERNAME}`,
|
||||
followedUserId: userId,
|
||||
acceptedAt: null,
|
||||
},
|
||||
]);
|
||||
const audience = await listAcceptedRemoteFollowers(userId);
|
||||
expect(audience).toEqual(["https://other.example/users/accepted"]);
|
||||
});
|
||||
});
|
||||
44
apps/journal/app/lib/federation-outbox.server.ts
Normal file
44
apps/journal/app/lib/federation-outbox.server.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// Outbox listing queries (spec 5.1). Offset-paged, public-only —
|
||||
// `unlisted` and `private` never federate. Separate from
|
||||
// activities.server.ts because the outbox needs raw rows (no geojson
|
||||
// batching) in a stable reverse-chronological order keyed on createdAt.
|
||||
|
||||
import { and, count, desc, eq } from "drizzle-orm";
|
||||
import { activities } from "@trails-cool/db/schema/journal";
|
||||
import { getDb } from "./db.ts";
|
||||
import type { FederatableActivity } from "./federation-objects.server.ts";
|
||||
|
||||
export const OUTBOX_PAGE_SIZE = 20;
|
||||
|
||||
export async function listPublicActivitiesPage(
|
||||
ownerId: string,
|
||||
offset: number,
|
||||
limit: number,
|
||||
): Promise<FederatableActivity[]> {
|
||||
const db = getDb();
|
||||
return db
|
||||
.select({
|
||||
id: activities.id,
|
||||
name: activities.name,
|
||||
description: activities.description,
|
||||
distance: activities.distance,
|
||||
elevationGain: activities.elevationGain,
|
||||
duration: activities.duration,
|
||||
startedAt: activities.startedAt,
|
||||
createdAt: activities.createdAt,
|
||||
})
|
||||
.from(activities)
|
||||
.where(and(eq(activities.ownerId, ownerId), eq(activities.visibility, "public")))
|
||||
.orderBy(desc(activities.createdAt))
|
||||
.offset(offset)
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
export async function countPublicActivities(ownerId: string): Promise<number> {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.select({ n: count() })
|
||||
.from(activities)
|
||||
.where(and(eq(activities.ownerId, ownerId), eq(activities.visibility, "public")));
|
||||
return row?.n ?? 0;
|
||||
}
|
||||
|
|
@ -34,6 +34,12 @@ 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 {
|
||||
OUTBOX_PAGE_SIZE,
|
||||
countPublicActivities,
|
||||
listPublicActivitiesPage,
|
||||
} from "./federation-outbox.server.ts";
|
||||
import {
|
||||
recordRemoteFollow,
|
||||
removeRemoteFollow,
|
||||
|
|
@ -117,6 +123,7 @@ function buildFederation(): Federation<void> {
|
|||
// declared explicitly so AP clients link browsers to HTML.
|
||||
url: new URL(localActorIri(identifier)),
|
||||
inbox: ctx.getInboxUri(identifier),
|
||||
outbox: ctx.getOutboxUri(identifier),
|
||||
// Public keys for inbound HTTP-Signature verification by
|
||||
// remotes (Mastodon reads publicKey; newer stacks read the
|
||||
// multikey assertionMethods).
|
||||
|
|
@ -138,6 +145,30 @@ function buildFederation(): Federation<void> {
|
|||
return pair ? [pair] : [];
|
||||
});
|
||||
|
||||
// Outbox (spec 5.1/5.2): paginated OrderedCollection of the user's
|
||||
// public activities as Create(Note). Unsigned and signed fetches see
|
||||
// the same content — the outbox only ever contains public items, so
|
||||
// Authorized Fetch adds nothing until locked accounts exist (the
|
||||
// spec's two scenarios deliberately collapse to one shape).
|
||||
federation
|
||||
.setOutboxDispatcher("/users/{identifier}/outbox", async (_ctx, identifier, cursor) => {
|
||||
const user = await findUserByUsername(identifier);
|
||||
if (!user || user.profileVisibility !== "public") return null;
|
||||
const offset = cursor === null ? 0 : Number.parseInt(cursor, 10);
|
||||
if (Number.isNaN(offset) || offset < 0) return null;
|
||||
const page = await listPublicActivitiesPage(user.id, offset, OUTBOX_PAGE_SIZE);
|
||||
return {
|
||||
items: page.map((a) => activityToCreate(a, identifier)),
|
||||
nextCursor: page.length === OUTBOX_PAGE_SIZE ? String(offset + OUTBOX_PAGE_SIZE) : null,
|
||||
};
|
||||
})
|
||||
.setCounter(async (_ctx, identifier) => {
|
||||
const user = await findUserByUsername(identifier);
|
||||
if (!user || user.profileVisibility !== "public") return null;
|
||||
return countPublicActivities(user.id);
|
||||
})
|
||||
.setFirstCursor(() => "0");
|
||||
|
||||
federation
|
||||
.setInboxListeners("/users/{identifier}/inbox")
|
||||
.on(Follow, async (ctx, follow) => {
|
||||
|
|
@ -224,6 +255,18 @@ export function getFederation(): Federation<void> {
|
|||
return _federation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this username a local user that federates (exists + public)?
|
||||
* Route-level gate for federation surfaces whose collection-level
|
||||
* responses Fedify builds without consulting the page dispatcher
|
||||
* (e.g. the outbox OrderedCollection) — private users must 404
|
||||
* everywhere, mirroring the actor object.
|
||||
*/
|
||||
export async function isFederatableUser(username: string): Promise<boolean> {
|
||||
const user = await findUserByUsername(username);
|
||||
return user !== null && user.profileVisibility === "public";
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegate a request to Fedify's URL dispatcher. 404s when the feature
|
||||
* flag is off so disabled instances are indistinguishable from instances
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export default [
|
|||
route(".well-known/nodeinfo", "routes/api.well-known.nodeinfo.ts"),
|
||||
route("nodeinfo/2.1", "routes/api.nodeinfo.ts"),
|
||||
route("users/:username/inbox", "routes/users.$username.inbox.ts"),
|
||||
route("users/:username/outbox", "routes/users.$username.outbox.ts"),
|
||||
route("oauth/authorize", "routes/oauth.authorize.tsx"),
|
||||
route("oauth/token", "routes/oauth.token.ts"),
|
||||
route("auth/register", "routes/auth.register.tsx"),
|
||||
|
|
|
|||
22
apps/journal/app/routes/users.$username.outbox.ts
Normal file
22
apps/journal/app/routes/users.$username.outbox.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type { Route } from "./+types/users.$username.outbox";
|
||||
import {
|
||||
federationEnabled,
|
||||
handleFederationRequest,
|
||||
isFederatableUser,
|
||||
} from "~/lib/federation.server";
|
||||
|
||||
/**
|
||||
* GET /users/:username/outbox — paginated OrderedCollection of the
|
||||
* user's public activities as Create(Note) (spec: social-federation,
|
||||
* "Outbox publishes user's public activities"). Served by Fedify's
|
||||
* collection dispatcher; 404s for private users (checked here because
|
||||
* Fedify builds the collection-level response from counter/cursors
|
||||
* without consulting the page dispatcher) and while FEDERATION_ENABLED
|
||||
* is off.
|
||||
*/
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
if (!federationEnabled() || !(await isFederatableUser(params.username))) {
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
return handleFederationRequest(request);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue