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