8.1/8.2: listSocialFeed is now a UNION ALL of local and remote branches, sorted on COALESCE(remote_published_at, created_at): - local rows: visibility='public' from accepted local follows — and the previously missing accepted_at filter is added (the spec's 'Pending follows contribute nothing' scenario) - remote rows: gated structurally by joining the viewer's OWN accepted follow against the originating actor — which is exactly the followers-only audience rule (a row reaches only viewers whose follow brought it in); attribution from the remote_actors cache; cards link outward to the canonical origin page (no local detail page for remote rows) 9.1: annotated — local Pending button shipped with locked accounts; remote Pending lives on /follows/outgoing. 9.2: already enforced + tested since §3 (actor/webfinger 404). 9.3 hardening: deliver-activity now re-checks the OWNER's profile visibility at send time, closing the enqueue→delivery flip window (enqueue-side and inbound-side gates already existed). Integration tests: the §8 audience-leak guard (A sees followers-only, B does not), public remote attribution + outward link, pending contributes nothing, mixed local/remote COALESCE ordering. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
130 lines
4.6 KiB
TypeScript
130 lines
4.6 KiB
TypeScript
import type { JobDefinition } from "@trails-cool/jobs";
|
|
import { and, eq } from "drizzle-orm";
|
|
import { activities, users } 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;
|
|
}
|
|
// Spec 9.3: flipping the profile to private stops federation — also
|
|
// for deliveries already enqueued when the flip happened.
|
|
const [owner] = await db
|
|
.select({ profileVisibility: users.profileVisibility })
|
|
.from(users)
|
|
.where(eq(users.username, p.ownerUsername))
|
|
.limit(1);
|
|
if (!owner || owner.profileVisibility !== "public") {
|
|
logger.info({ objectIri: p.objectIri }, "deliver-activity: owner no longer 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",
|
|
);
|
|
}
|