trails/apps/journal/app/jobs/deliver-activity.ts
Ullrich Schäfer bc233e03e5 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>
2026-06-06 15:32:52 +02:00

119 lines
4.1 KiB
TypeScript

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",
);
}