trails/apps/journal/app/lib/federation-outbox.server.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

44 lines
1.5 KiB
TypeScript

// 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;
}