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