@oxy.so/federation 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +16 -0
  3. package/dist/cjs/.tsbuildinfo +1 -0
  4. package/dist/cjs/actorObject.js +216 -0
  5. package/dist/cjs/apContext.js +48 -0
  6. package/dist/cjs/apUri.js +132 -0
  7. package/dist/cjs/httpSignature.js +187 -0
  8. package/dist/cjs/index.js +99 -0
  9. package/dist/cjs/networkIdentity.js +487 -0
  10. package/dist/cjs/node/actorResolver.js +625 -0
  11. package/dist/cjs/node/actorRouter.js +307 -0
  12. package/dist/cjs/node/delivery.js +415 -0
  13. package/dist/cjs/node/identityBridge.js +133 -0
  14. package/dist/cjs/node/inboundDispatch.js +268 -0
  15. package/dist/cjs/node/index.js +63 -0
  16. package/dist/cjs/node/signedFetch.js +122 -0
  17. package/dist/cjs/node/webfingerRouter.js +166 -0
  18. package/dist/cjs/urls.js +55 -0
  19. package/dist/esm/.tsbuildinfo +1 -0
  20. package/dist/esm/actorObject.js +210 -0
  21. package/dist/esm/apContext.js +45 -0
  22. package/dist/esm/apUri.js +126 -0
  23. package/dist/esm/httpSignature.js +179 -0
  24. package/dist/esm/index.js +65 -0
  25. package/dist/esm/networkIdentity.js +472 -0
  26. package/dist/esm/node/actorResolver.js +620 -0
  27. package/dist/esm/node/actorRouter.js +304 -0
  28. package/dist/esm/node/delivery.js +412 -0
  29. package/dist/esm/node/identityBridge.js +130 -0
  30. package/dist/esm/node/inboundDispatch.js +263 -0
  31. package/dist/esm/node/index.js +51 -0
  32. package/dist/esm/node/signedFetch.js +119 -0
  33. package/dist/esm/node/webfingerRouter.js +163 -0
  34. package/dist/esm/urls.js +50 -0
  35. package/dist/types/.tsbuildinfo +1 -0
  36. package/dist/types/actorObject.d.ts +182 -0
  37. package/dist/types/apContext.d.ts +35 -0
  38. package/dist/types/apUri.d.ts +107 -0
  39. package/dist/types/httpSignature.d.ts +113 -0
  40. package/dist/types/index.d.ts +336 -0
  41. package/dist/types/networkIdentity.d.ts +509 -0
  42. package/dist/types/node/actorResolver.d.ts +287 -0
  43. package/dist/types/node/actorRouter.d.ts +108 -0
  44. package/dist/types/node/delivery.d.ts +248 -0
  45. package/dist/types/node/identityBridge.d.ts +84 -0
  46. package/dist/types/node/inboundDispatch.d.ts +156 -0
  47. package/dist/types/node/index.d.ts +51 -0
  48. package/dist/types/node/signedFetch.d.ts +74 -0
  49. package/dist/types/node/webfingerRouter.d.ts +62 -0
  50. package/dist/types/urls.d.ts +55 -0
  51. package/package.json +119 -0
  52. package/src/__tests__/actorObject.test.ts +258 -0
  53. package/src/__tests__/actorResolver.test.ts +252 -0
  54. package/src/__tests__/actorResolverNetworkIdentity.test.ts +297 -0
  55. package/src/__tests__/apUri.test.ts +53 -0
  56. package/src/__tests__/delivery.test.ts +432 -0
  57. package/src/__tests__/federationHost.test.ts +281 -0
  58. package/src/__tests__/httpSignature.test.ts +343 -0
  59. package/src/__tests__/inboundDispatch.test.ts +381 -0
  60. package/src/__tests__/index.test.ts +8 -0
  61. package/src/__tests__/networkIdentity.test.ts +525 -0
  62. package/src/__tests__/routers.test.ts +460 -0
  63. package/src/__tests__/urls.test.ts +26 -0
  64. package/src/actorObject.ts +313 -0
  65. package/src/apContext.ts +45 -0
  66. package/src/apUri.ts +161 -0
  67. package/src/httpSignature.ts +282 -0
  68. package/src/index.ts +419 -0
  69. package/src/networkIdentity.ts +731 -0
  70. package/src/node/actorResolver.ts +839 -0
  71. package/src/node/actorRouter.ts +438 -0
  72. package/src/node/delivery.ts +729 -0
  73. package/src/node/identityBridge.ts +230 -0
  74. package/src/node/inboundDispatch.ts +420 -0
  75. package/src/node/index.ts +136 -0
  76. package/src/node/signedFetch.ts +177 -0
  77. package/src/node/webfingerRouter.ts +226 -0
  78. package/src/urls.ts +71 -0
@@ -0,0 +1,729 @@
1
+ /**
2
+ * Outbound activity delivery + the follow lifecycle (Follow / Undo(Follow) /
3
+ * Accept(Follow)) and the `Update(Person)` actor rebroadcast.
4
+ *
5
+ * The delivery TRANSPORT (sign → SSRF-safe POST → BullMQ / durable fallback queue),
6
+ * the shared-inbox dedup fan-out, and the follow-protocol activity shapes are the
7
+ * SAME across every Oxy app, so they live here — behaviour-identical to Mention's
8
+ * former `FollowService` delivery half. Everything app-specific is injected:
9
+ *
10
+ * - private-key CUSTODY stays behind the {@link DeliveryKeys} adapter (Mention:
11
+ * oxy-api `/federation/sign` + `/federation/public-key`); the key never enters
12
+ * this package,
13
+ * - the SSRF-safe single-hop POST + the BullMQ enqueue + the durable
14
+ * fallback are the {@link DeliveryTransport}, so the delivery policy stays in
15
+ * one place (Mention's `fetchUpstreamSingleHop` + `FederationDeliveryQueue`),
16
+ * - the AP-specific `FederatedActor` / `FederatedFollow` rows stay in the app DB
17
+ * behind the {@link DeliveryActorStore} / {@link DeliveryFollowStore} adapters
18
+ * ("bring your own store" — no data move),
19
+ * - the actor cache refresh (for a follow whose target inbox is not yet known),
20
+ * the consent gate, the actor-profile resolver, the banner, and the local-actor
21
+ * builder are all injected.
22
+ *
23
+ * The CONTENT federate methods (build the Note / boost / like) STAY in the app and
24
+ * call `deliverToFollowers` / `deliverActivity` / `queueDelivery` here.
25
+ */
26
+
27
+ import type { AccountKind } from '@oxy.so/contracts';
28
+ import { AP_CONTEXT } from '../apContext';
29
+ import { signRequest, type HttpSignatureSigner } from '../httpSignature';
30
+ import type { UrlBuilders } from '../urls';
31
+ import type { LocalActorBuilder } from '../actorObject';
32
+
33
+ /** Total time budget for a single delivery POST (connect + response headers). */
34
+ const DELIVER_ACTIVITY_TIMEOUT_MS = 15000;
35
+ /** How many bytes of a failed-delivery response body are read for the debug log. */
36
+ const DELIVERY_RESPONSE_PREVIEW_MAX_BYTES = 1024;
37
+
38
+ /** The ActivityStreams public collection — the `to` addressee of a public activity. */
39
+ const AP_PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
40
+
41
+ /** Minimal logging sink the delivery service writes to. */
42
+ export interface DeliveryLogger {
43
+ debug(message: string, detail?: unknown): void;
44
+ info(message: string): void;
45
+ warn(message: string): void;
46
+ error(message: string, detail?: unknown): void;
47
+ }
48
+
49
+ /**
50
+ * Private-key custody for outbound signing. The private key NEVER enters this
51
+ * package — `getPublicKey` returns only the actor's public keyId (to name the
52
+ * signature) and `sign` delegates the RSA-SHA256 signing (Mention → oxy-api).
53
+ */
54
+ export interface DeliveryKeys {
55
+ getPublicKey(username: string): Promise<{ keyId: string; publicKeyPem: string }>;
56
+ sign: HttpSignatureSigner;
57
+ }
58
+
59
+ /** A bounded, destroyable byte stream — the raw single-hop delivery response body. */
60
+ export interface DeliveryResponseStream extends AsyncIterable<Buffer | Uint8Array> {
61
+ destroy(): void;
62
+ }
63
+
64
+ /** The result of one SSRF-safe single-hop delivery POST (redirects NOT followed). */
65
+ export interface DeliverSingleHopResult {
66
+ response: DeliveryResponseStream;
67
+ status: number;
68
+ }
69
+
70
+ /** Per-request options handed to the injected single-hop delivery transport. */
71
+ export interface DeliverSingleHopInit {
72
+ method: 'POST';
73
+ headers: Record<string, string>;
74
+ body: string;
75
+ signal: AbortSignal;
76
+ headersTimeoutMs: number;
77
+ }
78
+
79
+ /**
80
+ * An SSRF-safe single-hop POST: validates + IP-pins the URL and returns the raw
81
+ * response WITHOUT following redirects. Mention adapts its `@oxy.so/core/server`-
82
+ * backed `fetchUpstreamSingleHop` into this shape.
83
+ */
84
+ export type DeliverSingleHop = (url: string, init: DeliverSingleHopInit) => Promise<DeliverSingleHopResult>;
85
+
86
+ /** A durable-delivery job body (BullMQ + the fallback queue share this shape). */
87
+ export interface DeliveryQueueJob {
88
+ activityJson: Record<string, unknown>;
89
+ targetInbox: string;
90
+ senderOxyUserId: string;
91
+ }
92
+
93
+ /**
94
+ * The durable-delivery fallback (written when BullMQ is unavailable).
95
+ *
96
+ * App-supplied, like every other store in this package: the engine never names
97
+ * a database. Mention backs it with Postgres; the method names below are the
98
+ * ones its original Mongoose collection exposed and are kept only so the
99
+ * adapter shape stays stable for consumers.
100
+ */
101
+ export interface DeliveryFallbackQueue {
102
+ /** Insert one fallback delivery row (`queueDelivery`). */
103
+ create(job: DeliveryQueueJob & { nextAttemptAt: Date }): Promise<unknown>;
104
+ /** Insert many fallback delivery rows in one write (`deliverToFollowers`). */
105
+ insertMany(jobs: Array<DeliveryQueueJob & { nextAttemptAt: Date }>): Promise<unknown>;
106
+ }
107
+
108
+ /** The delivery transport: BullMQ enqueue with a durable app-supplied fallback. */
109
+ export interface DeliveryTransport {
110
+ /**
111
+ * Enqueue one durable delivery. Resolves `false` when the queue is unavailable
112
+ * (Redis not configured) so the engine falls back to {@link DeliveryFallbackQueue}.
113
+ * May REJECT (the engine treats a rejection as `false` and falls back).
114
+ */
115
+ enqueueDelivery(job: DeliveryQueueJob): Promise<boolean>;
116
+ fallbackQueue: DeliveryFallbackQueue;
117
+ }
118
+
119
+ /**
120
+ * The delivery-relevant fields of a stored remote actor. `TActor` (the app's own
121
+ * `FederatedActor` shape) extends this; the engine reads only these fields and
122
+ * hands the FULL record back to `actorRefresh.refreshActorInBackground`.
123
+ */
124
+ export interface DeliveryActorFields {
125
+ _id?: unknown;
126
+ uri: string;
127
+ sharedInboxUrl?: string | null;
128
+ inboxUrl?: string | null;
129
+ manuallyApprovesFollowers?: boolean;
130
+ }
131
+
132
+ /** Bring-your-own-store: the AP actor cache stays in the app DB behind this adapter. */
133
+ export interface DeliveryActorStore<TActor extends DeliveryActorFields> {
134
+ /** One cached actor by uri (`resolveActorInbox` / `sendFollow` / `sendUndoFollow` / `sendAccept`). */
135
+ findActorByUri(uri: string): Promise<TActor | null>;
136
+ /** Inbox fields for many actor uris (`deliverToFollowers`, step 2). */
137
+ findActorInboxesByUris(uris: string[]): Promise<Array<Pick<DeliveryActorFields, 'sharedInboxUrl' | 'inboxUrl'>>>;
138
+ }
139
+
140
+ /** Bring-your-own-store: the AP follow records stay in the app DB behind this adapter. */
141
+ export interface DeliveryFollowStore {
142
+ /** Accepted inbound followers' remote actor uris (`deliverToFollowers`, step 1). */
143
+ listAcceptedInboundFollowerActorUris(localOxyUserId: string): Promise<string[]>;
144
+ /** Upsert an outbound pending follow with its activity id (`sendFollow`). */
145
+ upsertOutboundPending(localOxyUserId: string, remoteActorUri: string, activityId: string): Promise<void>;
146
+ /** The outbound follow row for `(localOxyUserId, remoteActorUri)` (`sendUndoFollow`). */
147
+ findOutbound(localOxyUserId: string, remoteActorUri: string): Promise<{ _id: unknown; activityId?: string } | null>;
148
+ /** Delete a follow row by id (`sendUndoFollow`). */
149
+ deleteById(id: unknown): Promise<void>;
150
+ }
151
+
152
+ /** The actor-cache refresh the follow path uses when a target inbox is not yet known. */
153
+ export interface DeliveryActorRefresh<TActor extends DeliveryActorFields> {
154
+ /** Fire-and-forget full-actor refresh (keeps a followee's inbox/profile current). */
155
+ refreshActorInBackground(actorUri: string, existing?: TActor): void;
156
+ /** Blocking actor fetch to resolve an inbox when none is cached (`queueFollowOnceActorKnown`). */
157
+ fetchRemoteActor(actorUri: string): Promise<TActor | null>;
158
+ }
159
+
160
+ /** The consent gate — only `federateActorUpdate` gates outbound delivery on it. */
161
+ export interface DeliveryConsent {
162
+ isSharingEnabled(oxyUserId: string): Promise<boolean>;
163
+ }
164
+
165
+ /** The Oxy profile fields the actor `Update` rebroadcast reads. */
166
+ export interface DeliveryActorProfile {
167
+ name?: { displayName?: string | null } | null;
168
+ bio?: string | null;
169
+ avatar?: string | null;
170
+ createdAt?: string | null;
171
+ /**
172
+ * Account-graph classification — decides the actor `type`. It MUST travel with
173
+ * the rebroadcast: an `Update` carrying a different `type` from the one the GET
174
+ * route serves is exactly the drift the shared builder exists to prevent, and
175
+ * on a type change it is also the migration vehicle (a follower's instance
176
+ * adopts the new type from this push rather than waiting out its own actor
177
+ * staleness window).
178
+ */
179
+ kind?: AccountKind | null;
180
+ }
181
+
182
+ /** Resolve a local username to its Oxy profile (for the `Update(Person)` rebroadcast). */
183
+ export interface DeliveryIdentity {
184
+ resolveUserByUsername(username: string): Promise<DeliveryActorProfile | null>;
185
+ }
186
+
187
+ /** The app-owned profile banner (Mention: `UserSettings.profileHeaderImage`). */
188
+ export interface DeliveryProfile {
189
+ getBanner(oxyUserId: string): Promise<string | null>;
190
+ }
191
+
192
+ /** The result shape of the injected SSRF pre-check for a durable inbox enqueue. */
193
+ export interface SafeUrlVerdict {
194
+ ok: boolean;
195
+ reason?: string;
196
+ }
197
+
198
+ /** Adapters + config a {@link DeliveryService} is built from. */
199
+ export interface DeliveryServiceConfig<TActor extends DeliveryActorFields> {
200
+ /** Whether federation is enabled (gates `sendFollow`/`sendUndoFollow`/`federateActorUpdate`). */
201
+ federationEnabled: boolean;
202
+ /** User-Agent presented to remote inboxes. */
203
+ userAgent: string;
204
+ /** The AP content type (`application/activity+json`) used for delivery headers. */
205
+ apContentType: string;
206
+ /** Private-key custody + public keyId lookup. */
207
+ keys: DeliveryKeys;
208
+ /** Per-instance URL builders (actor URL for the follow/update activities). */
209
+ urls: UrlBuilders;
210
+ /** SSRF-safe single-hop delivery POST (does NOT follow redirects). */
211
+ deliverSingleHop: DeliverSingleHop;
212
+ /** SSRF pre-check for a durable inbox enqueue (never queue a delivery to an unsafe URL). */
213
+ assertSafeInboxUrl(url: string): Promise<SafeUrlVerdict>;
214
+ /** BullMQ enqueue + the durable fallback queue. */
215
+ transport: DeliveryTransport;
216
+ /** The AP actor cache store. */
217
+ store: DeliveryActorStore<TActor>;
218
+ /** The AP follow-record store. */
219
+ follows: DeliveryFollowStore;
220
+ /** Actor-cache refresh for the follow path. */
221
+ actorRefresh: DeliveryActorRefresh<TActor>;
222
+ /** The fediverse-sharing consent gate (used by `federateActorUpdate` only). */
223
+ consent: DeliveryConsent;
224
+ /** Resolve a local username to its Oxy profile (`federateActorUpdate`). */
225
+ identity: DeliveryIdentity;
226
+ /** App-owned profile banner (`federateActorUpdate`). */
227
+ profile: DeliveryProfile;
228
+ /** The single local-actor builder (shared with the actor GET route). */
229
+ buildLocalActorObject: LocalActorBuilder;
230
+ /** Diagnostics sink. */
231
+ logger: DeliveryLogger;
232
+ /**
233
+ * The app's per-instance domain policy (`DomainPolicy.isBlockedDomain`) — the
234
+ * same predicate inbound dispatch and actor resolution use. Outbound delivery
235
+ * must refuse blocked origins symmetrically: a domain blocked inbound must not
236
+ * keep receiving Follow/Undo/Accept or follower fan-out via a cached inbox.
237
+ */
238
+ isBlockedDomain(host: string): boolean;
239
+ }
240
+
241
+ /** Read a bounded prefix of a failed-delivery response body for the debug log. */
242
+ async function readResponsePreview(response: DeliveryResponseStream): Promise<string> {
243
+ const chunks: Buffer[] = [];
244
+ let totalBytes = 0;
245
+
246
+ try {
247
+ for await (const chunk of response) {
248
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
249
+ totalBytes += buffer.byteLength;
250
+ chunks.push(buffer);
251
+ if (totalBytes >= DELIVERY_RESPONSE_PREVIEW_MAX_BYTES) break;
252
+ }
253
+ } catch {
254
+ return '';
255
+ } finally {
256
+ response.destroy();
257
+ }
258
+
259
+ return Buffer.concat(chunks).toString('utf8', 0, DELIVERY_RESPONSE_PREVIEW_MAX_BYTES);
260
+ }
261
+
262
+ /** The outbound delivery + follow-lifecycle service. */
263
+ export interface DeliveryService {
264
+ /** Deliver an activity to one remote inbox, signed with the sender's key. */
265
+ deliverActivity(
266
+ activity: Record<string, unknown>,
267
+ targetInbox: string,
268
+ senderOxyUserId: string,
269
+ senderUsername: string,
270
+ ): Promise<boolean>;
271
+ /** Queue one activity for durable delivery (BullMQ, fallback queue). */
272
+ queueDelivery(
273
+ activity: Record<string, unknown>,
274
+ targetInbox: string,
275
+ senderOxyUserId: string,
276
+ ): Promise<void>;
277
+ /** Resolve a remote actor's delivery inbox (shared preferred) from the store. */
278
+ resolveActorInbox(actorUri: string | undefined): Promise<string | undefined>;
279
+ /** Deliver to all accepted inbound followers plus `options.extraInboxes` (deduped by shared inbox). */
280
+ deliverToFollowers(
281
+ activity: Record<string, unknown>,
282
+ senderOxyUserId: string,
283
+ senderUsername: string,
284
+ options?: { extraInboxes?: string[] },
285
+ ): Promise<void>;
286
+ /** Send a Follow activity to a remote actor (records the outbound follow, delivers/queues). */
287
+ sendFollow(
288
+ localOxyUserId: string,
289
+ localUsername: string,
290
+ remoteActorUri: string,
291
+ ): Promise<{ success: boolean; pending: boolean }>;
292
+ /** Send an Undo(Follow) to a remote actor (removes the local follow first). */
293
+ sendUndoFollow(
294
+ localOxyUserId: string,
295
+ localUsername: string,
296
+ remoteActorUri: string,
297
+ ): Promise<boolean>;
298
+ /** Send an Accept(Follow) back to a remote actor. */
299
+ sendAccept(
300
+ localOxyUserId: string,
301
+ localUsername: string,
302
+ followActivityId: string,
303
+ remoteActorUri: string,
304
+ ): Promise<void>;
305
+ /** Rebroadcast the FULL actor document as an Update(Person) to remote followers. */
306
+ federateActorUpdate(actorOxyUserId: string, username: string): Promise<void>;
307
+ }
308
+
309
+ /** Build the outbound delivery + follow-lifecycle service from an app's adapters. */
310
+ export function createDeliveryService<TActor extends DeliveryActorFields>(
311
+ config: DeliveryServiceConfig<TActor>,
312
+ ): DeliveryService {
313
+ const { logger, urls } = config;
314
+
315
+ /** Lowercased host of an absolute URL, or null when unparseable (fails closed). */
316
+ function urlHost(url: string): string | null {
317
+ try {
318
+ return new URL(url).hostname.toLowerCase();
319
+ } catch {
320
+ return null;
321
+ }
322
+ }
323
+
324
+ /** True when `url`'s host is missing or on the blocked-domain policy. */
325
+ function isBlockedUrl(url: string): boolean {
326
+ const host = urlHost(url);
327
+ return host === null || config.isBlockedDomain(host);
328
+ }
329
+
330
+ async function deliverActivity(
331
+ activity: Record<string, unknown>,
332
+ targetInbox: string,
333
+ senderOxyUserId: string,
334
+ senderUsername: string,
335
+ ): Promise<boolean> {
336
+ if (isBlockedUrl(targetInbox)) {
337
+ logger.warn(`[FedDeliver] refusing outbound delivery to blocked inbox ${targetInbox}`);
338
+ return false;
339
+ }
340
+ try {
341
+ const { keyId } = await config.keys.getPublicKey(senderUsername);
342
+ const body = JSON.stringify(activity);
343
+ const sigHeaders = await signRequest(config.keys.sign, keyId, 'POST', targetInbox, body);
344
+
345
+ const allHeaders: Record<string, string> = {
346
+ 'Content-Type': config.apContentType,
347
+ 'Content-Length': String(Buffer.byteLength(body, 'utf-8')),
348
+ 'User-Agent': config.userAgent,
349
+ Accept: config.apContentType,
350
+ ...sigHeaders,
351
+ };
352
+
353
+ logger.debug(`[FedDeliver] POST ${targetInbox} body=${body} sig-headers=${sigHeaders.Signature?.match(/headers="([^"]+)"/)?.[1]}`);
354
+
355
+ const { response, status } = await config.deliverSingleHop(targetInbox, {
356
+ method: 'POST',
357
+ headers: allHeaders,
358
+ body,
359
+ signal: AbortSignal.timeout(DELIVER_ACTIVITY_TIMEOUT_MS),
360
+ headersTimeoutMs: DELIVER_ACTIVITY_TIMEOUT_MS,
361
+ });
362
+
363
+ if ((status >= 200 && status < 300) || status === 202) {
364
+ response.destroy();
365
+ return true;
366
+ }
367
+
368
+ const responseBody = await readResponsePreview(response);
369
+ logger.debug(`Activity delivery failed to ${targetInbox}: ${status} body=${responseBody.slice(0, 500)}`);
370
+ return false;
371
+ } catch (err) {
372
+ logger.debug(`Activity delivery error to ${targetInbox}:`, err);
373
+ return false;
374
+ }
375
+ }
376
+
377
+ async function queueDelivery(
378
+ activity: Record<string, unknown>,
379
+ targetInbox: string,
380
+ senderOxyUserId: string,
381
+ ): Promise<void> {
382
+ if (isBlockedUrl(targetInbox)) {
383
+ logger.warn(`[FedDeliver] not queueing delivery to blocked inbox ${targetInbox}`);
384
+ return;
385
+ }
386
+ // Defense-in-depth: never enqueue a durable delivery to an unsafe inbox URL.
387
+ // The per-send POST is already SSRF-pinned, but a blocked URL would otherwise
388
+ // sit in the queue and be retried forever.
389
+ const guard = await config.assertSafeInboxUrl(targetInbox);
390
+ if (!guard.ok) {
391
+ logger.warn(`[FedDeliver] not queueing unsafe inbox URL ${targetInbox}: ${guard.reason}`);
392
+ return;
393
+ }
394
+
395
+ const enqueued = await config.transport
396
+ .enqueueDelivery({ activityJson: activity, targetInbox, senderOxyUserId })
397
+ .catch((err) => {
398
+ const message = err instanceof Error ? err.message : String(err);
399
+ logger.warn(`[FedDeliver] enqueue failed for ${targetInbox}, falling back to the durable queue: ${message}`);
400
+ return false;
401
+ });
402
+
403
+ if (enqueued) return;
404
+
405
+ await config.transport.fallbackQueue.create({
406
+ activityJson: activity,
407
+ targetInbox,
408
+ senderOxyUserId,
409
+ nextAttemptAt: new Date(),
410
+ });
411
+ }
412
+
413
+ async function resolveActorInbox(actorUri: string | undefined): Promise<string | undefined> {
414
+ if (!actorUri) return undefined;
415
+ const actor = await config.store.findActorByUri(actorUri);
416
+ if (!actor) return undefined;
417
+ return actor.sharedInboxUrl ?? actor.inboxUrl ?? undefined;
418
+ }
419
+
420
+ async function deliverToFollowers(
421
+ activity: Record<string, unknown>,
422
+ senderOxyUserId: string,
423
+ senderUsername: string,
424
+ options: { extraInboxes?: string[] } = {},
425
+ ): Promise<void> {
426
+ const actorUris = await config.follows.listAcceptedInboundFollowerActorUris(senderOxyUserId);
427
+ const actors = actorUris.length > 0 ? await config.store.findActorInboxesByUris(actorUris) : [];
428
+
429
+ // Group by shared inbox to avoid duplicate deliveries. Follower inboxes
430
+ // first, then the explicit targets — the shared `seen` set dedupes an
431
+ // explicit inbox that an instance already receives as a follower.
432
+ const seen = new Set<string>();
433
+ const inboxes: string[] = [];
434
+ for (const actor of actors) {
435
+ const inbox = actor.sharedInboxUrl || actor.inboxUrl;
436
+ if (inbox && !seen.has(inbox)) {
437
+ seen.add(inbox);
438
+ inboxes.push(inbox);
439
+ }
440
+ }
441
+ for (const inbox of options.extraInboxes ?? []) {
442
+ if (inbox && !seen.has(inbox)) {
443
+ seen.add(inbox);
444
+ inboxes.push(inbox);
445
+ }
446
+ }
447
+ if (inboxes.length === 0) return;
448
+
449
+ // Durable path: enqueue one BullMQ delivery per shared inbox (deduped per
450
+ // inbox + activity id). When the queue is unavailable fall back to a single
451
+ // batch insert into the durable queue for the inboxes that were not enqueued.
452
+ const now = new Date();
453
+ const durableFallback: Array<DeliveryQueueJob & { nextAttemptAt: Date }> = [];
454
+
455
+ for (const inbox of inboxes) {
456
+ if (isBlockedUrl(inbox)) {
457
+ logger.warn(`[FedDeliver] not queueing delivery to blocked inbox ${inbox}`);
458
+ continue;
459
+ }
460
+ const guard = await config.assertSafeInboxUrl(inbox);
461
+ if (!guard.ok) {
462
+ logger.warn(`[FedDeliver] not queueing unsafe inbox URL ${inbox}: ${guard.reason}`);
463
+ continue;
464
+ }
465
+
466
+ const enqueued = await config.transport
467
+ .enqueueDelivery({ activityJson: activity, targetInbox: inbox, senderOxyUserId })
468
+ .catch((err) => {
469
+ const message = err instanceof Error ? err.message : String(err);
470
+ logger.warn(`[FedDeliver] follower enqueue failed for ${inbox}, falling back to the durable queue: ${message}`);
471
+ return false;
472
+ });
473
+
474
+ if (!enqueued) {
475
+ durableFallback.push({ activityJson: activity, targetInbox: inbox, senderOxyUserId, nextAttemptAt: now });
476
+ }
477
+ }
478
+
479
+ if (durableFallback.length > 0) {
480
+ await config.transport.fallbackQueue.insertMany(durableFallback);
481
+ }
482
+ }
483
+
484
+ /**
485
+ * Resolve the target actor's inbox in the background and queue the Follow
486
+ * activity for delivery once known. Fire-and-forget: returns synchronously and
487
+ * never blocks the caller on remote I/O.
488
+ */
489
+ function queueFollowOnceActorKnown(
490
+ activity: Record<string, unknown>,
491
+ canonicalUri: string,
492
+ localOxyUserId: string,
493
+ remoteActorUri: string,
494
+ ): void {
495
+ void (async () => {
496
+ try {
497
+ let actor = await config.store.findActorByUri(canonicalUri);
498
+ if (!actor?.inboxUrl) {
499
+ actor = await config.actorRefresh.fetchRemoteActor(remoteActorUri);
500
+ }
501
+ const inbox = actor?.sharedInboxUrl ?? actor?.inboxUrl;
502
+ if (inbox && !isBlockedUrl(inbox) && !isBlockedUrl(remoteActorUri)) {
503
+ await queueDelivery(activity, inbox, localOxyUserId);
504
+ } else {
505
+ logger.warn(`[FedSync] could not resolve inbox to deliver Follow to ${remoteActorUri}`);
506
+ }
507
+ } catch (err) {
508
+ const message = err instanceof Error ? err.message : String(err);
509
+ logger.warn(`[FedSync] deferred follow delivery setup failed for ${remoteActorUri}: ${message}`);
510
+ }
511
+ })();
512
+ }
513
+
514
+ async function sendFollow(
515
+ localOxyUserId: string,
516
+ localUsername: string,
517
+ remoteActorUri: string,
518
+ ): Promise<{ success: boolean; pending: boolean }> {
519
+ if (!config.federationEnabled) return { success: false, pending: false };
520
+ if (isBlockedUrl(remoteActorUri)) {
521
+ logger.warn(`[FedDeliver] refusing outbound Follow to blocked origin ${remoteActorUri}`);
522
+ return { success: false, pending: false };
523
+ }
524
+
525
+ // Never block the follow request on a remote actor fetch. Use whatever is
526
+ // cached; if the actor is unknown locally we still record the follow and
527
+ // queue the Follow activity, then refresh the actor in the background.
528
+ const cached = await config.store.findActorByUri(remoteActorUri);
529
+
530
+ // Always refresh the actor in the background so its inbox/profile stay
531
+ // current (and so a missing actor gets resolved for delivery shortly).
532
+ config.actorRefresh.refreshActorInBackground(remoteActorUri, cached ?? undefined);
533
+
534
+ const canonicalUri = cached?.uri ?? remoteActorUri;
535
+ const localActorUri = urls.actor(localUsername);
536
+ // Use the actor _id when known, otherwise a stable hash of the URI so the
537
+ // activity ID is deterministic across retries before the actor is cached.
538
+ const activityIdSuffix = cached?._id
539
+ ? String(cached._id)
540
+ : encodeURIComponent(canonicalUri);
541
+ const activityId = `${localActorUri}/follows/${activityIdSuffix}`;
542
+
543
+ // Create or update the follow record
544
+ await config.follows.upsertOutboundPending(localOxyUserId, canonicalUri, activityId);
545
+
546
+ const activity: Record<string, unknown> = {
547
+ '@context': 'https://www.w3.org/ns/activitystreams',
548
+ id: activityId,
549
+ type: 'Follow',
550
+ actor: localActorUri,
551
+ object: canonicalUri,
552
+ };
553
+
554
+ // If we know the inbox, attempt delivery in the background; otherwise queue
555
+ // for the delivery worker, which resolves the inbox once the actor lands.
556
+ const targetInbox = cached?.sharedInboxUrl ?? cached?.inboxUrl;
557
+ if (targetInbox && !isBlockedUrl(targetInbox)) {
558
+ void deliverActivity(activity, targetInbox, localOxyUserId, localUsername)
559
+ .then((delivered) => {
560
+ if (!delivered) return queueDelivery(activity, targetInbox, localOxyUserId);
561
+ })
562
+ .catch((err) => {
563
+ const message = err instanceof Error ? err.message : String(err);
564
+ logger.warn(`[FedSync] background follow delivery failed for ${canonicalUri}: ${message}`);
565
+ });
566
+ } else {
567
+ // No cached inbox yet — resolve the actor's inbox in the background and
568
+ // queue the Follow for delivery once known. Reports success optimistically;
569
+ // the delivery worker retries the queued delivery. Never blocks the caller.
570
+ queueFollowOnceActorKnown(activity, canonicalUri, localOxyUserId, remoteActorUri);
571
+ }
572
+
573
+ return { success: true, pending: cached?.manuallyApprovesFollowers ?? false };
574
+ }
575
+
576
+ async function sendUndoFollow(
577
+ localOxyUserId: string,
578
+ localUsername: string,
579
+ remoteActorUri: string,
580
+ ): Promise<boolean> {
581
+ if (!config.federationEnabled) return false;
582
+
583
+ const follow = await config.follows.findOutbound(localOxyUserId, remoteActorUri);
584
+ if (!follow) return false;
585
+
586
+ const actor = await config.store.findActorByUri(remoteActorUri);
587
+ if (!actor) return false;
588
+
589
+ const localActorUri = urls.actor(localUsername);
590
+
591
+ const activity: Record<string, unknown> = {
592
+ '@context': AP_CONTEXT,
593
+ id: `${localActorUri}/follows/${actor._id}/undo`,
594
+ type: 'Undo',
595
+ actor: localActorUri,
596
+ object: {
597
+ id: follow.activityId,
598
+ type: 'Follow',
599
+ actor: localActorUri,
600
+ object: remoteActorUri,
601
+ },
602
+ };
603
+
604
+ // Remove the local follow immediately so the unfollow reflects in the UI,
605
+ // then deliver the Undo in the background — never block the request on the
606
+ // remote POST.
607
+ await config.follows.deleteById(follow._id);
608
+
609
+ // `inboxUrl` is schema-optional (atproto actors have none); an AP actor we
610
+ // are sending Undo(Follow) to always has one. When neither inbox is known the
611
+ // local follow is already removed — just skip the outbound delivery.
612
+ const targetInbox = actor.sharedInboxUrl ?? actor.inboxUrl;
613
+ if (targetInbox && !isBlockedUrl(targetInbox) && !isBlockedUrl(remoteActorUri)) {
614
+ void deliverActivity(activity, targetInbox, localOxyUserId, localUsername)
615
+ .then((delivered) => {
616
+ if (!delivered) return queueDelivery(activity, targetInbox, localOxyUserId);
617
+ })
618
+ .catch((err) => {
619
+ const message = err instanceof Error ? err.message : String(err);
620
+ logger.warn(`[FedSync] background undo-follow delivery failed for ${remoteActorUri}: ${message}`);
621
+ });
622
+ } else if (targetInbox) {
623
+ logger.warn(`[FedDeliver] skipping Undo(Follow) delivery to blocked origin ${remoteActorUri}`);
624
+ }
625
+
626
+ return true;
627
+ }
628
+
629
+ async function sendAccept(
630
+ localOxyUserId: string,
631
+ localUsername: string,
632
+ followActivityId: string,
633
+ remoteActorUri: string,
634
+ ): Promise<void> {
635
+ const actor = await config.store.findActorByUri(remoteActorUri);
636
+ if (!actor) return;
637
+ // `inboxUrl` is schema-optional (atproto actors have none); an AP actor we
638
+ // are sending Accept(Follow) to always has one. When neither inbox is known the
639
+ // local follow is already removed — just skip the outbound delivery.
640
+ const targetInbox = actor.sharedInboxUrl ?? actor.inboxUrl;
641
+ if (!targetInbox) {
642
+ logger.warn(`[FedSync] cannot send Accept(Follow) to ${remoteActorUri}: actor has no inbox`);
643
+ return;
644
+ }
645
+ if (isBlockedUrl(targetInbox) || isBlockedUrl(remoteActorUri)) {
646
+ logger.warn(`[FedDeliver] refusing Accept(Follow) delivery to blocked origin ${remoteActorUri}`);
647
+ return;
648
+ }
649
+
650
+ const localActorUri = urls.actor(localUsername);
651
+
652
+ const activity: Record<string, unknown> = {
653
+ '@context': AP_CONTEXT,
654
+ id: `${localActorUri}/accepts/${Date.now()}`,
655
+ type: 'Accept',
656
+ actor: localActorUri,
657
+ object: {
658
+ id: followActivityId,
659
+ type: 'Follow',
660
+ actor: remoteActorUri,
661
+ object: localActorUri,
662
+ },
663
+ };
664
+
665
+ const delivered = await deliverActivity(activity, targetInbox, localOxyUserId, localUsername);
666
+ if (!delivered) {
667
+ await queueDelivery(activity, targetInbox, localOxyUserId);
668
+ }
669
+ }
670
+
671
+ async function federateActorUpdate(actorOxyUserId: string, username: string): Promise<void> {
672
+ if (!config.federationEnabled) return;
673
+ if (!(await config.consent.isSharingEnabled(actorOxyUserId))) return;
674
+
675
+ try {
676
+ const user = await config.identity.resolveUserByUsername(username);
677
+ if (!user) {
678
+ logger.warn(`[FedDeliver] cannot federate actor update for ${username}: user not resolvable`);
679
+ return;
680
+ }
681
+
682
+ const publicKey = await config.keys.getPublicKey(username);
683
+ const profileHeaderImage = await config.profile.getBanner(actorOxyUserId);
684
+
685
+ // Canonical display name is owned by the Oxy API; fall back to the handle
686
+ // when absent (never recompose from name parts).
687
+ const displayName = user.name?.displayName || username;
688
+
689
+ const actorObject = config.buildLocalActorObject({
690
+ username,
691
+ displayName,
692
+ kind: user.kind,
693
+ bio: user.bio,
694
+ avatar: user.avatar,
695
+ profileHeaderImage,
696
+ publicKey,
697
+ createdAt: user.createdAt,
698
+ });
699
+
700
+ const actor = urls.actor(username);
701
+ const now = new Date();
702
+ const activity: Record<string, unknown> = {
703
+ '@context': AP_CONTEXT,
704
+ id: `${actor}#updates/${now.getTime()}`,
705
+ type: 'Update',
706
+ actor,
707
+ updated: now.toISOString(),
708
+ to: [AP_PUBLIC],
709
+ cc: [`${actor}/followers`],
710
+ object: actorObject,
711
+ };
712
+
713
+ await deliverToFollowers(activity, actorOxyUserId, username);
714
+ } catch (err) {
715
+ logger.error('Failed to federate actor update:', err);
716
+ }
717
+ }
718
+
719
+ return {
720
+ deliverActivity,
721
+ queueDelivery,
722
+ resolveActorInbox,
723
+ deliverToFollowers,
724
+ sendFollow,
725
+ sendUndoFollow,
726
+ sendAccept,
727
+ federateActorUpdate,
728
+ };
729
+ }