@oxy.so/federation 2.2.1 → 2.4.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 (64) hide show
  1. package/README.md +31 -0
  2. package/dist/cjs/.tsbuildinfo +1 -1
  3. package/dist/cjs/apUri.js +4 -2
  4. package/dist/cjs/httpSignature.js +1 -1
  5. package/dist/cjs/index.js +2 -2
  6. package/dist/cjs/networkIdentity.js +3 -2
  7. package/dist/cjs/node/actorResolver.js +24 -20
  8. package/dist/cjs/node/actorRouter.js +16 -6
  9. package/dist/cjs/node/delivery.js +35 -10
  10. package/dist/cjs/node/httpCacheControl.js +11 -0
  11. package/dist/cjs/node/inboundDispatch.js +4 -3
  12. package/dist/cjs/node/index.js +8 -1
  13. package/dist/cjs/node/tcoLinks.js +97 -0
  14. package/dist/cjs/node/webfingerRouter.js +6 -4
  15. package/dist/esm/.tsbuildinfo +1 -1
  16. package/dist/esm/apUri.js +4 -2
  17. package/dist/esm/httpSignature.js +1 -1
  18. package/dist/esm/index.js +3 -3
  19. package/dist/esm/networkIdentity.js +3 -2
  20. package/dist/esm/node/actorResolver.js +24 -20
  21. package/dist/esm/node/actorRouter.js +16 -6
  22. package/dist/esm/node/delivery.js +33 -10
  23. package/dist/esm/node/httpCacheControl.js +8 -0
  24. package/dist/esm/node/inboundDispatch.js +4 -3
  25. package/dist/esm/node/index.js +6 -1
  26. package/dist/esm/node/tcoLinks.js +94 -0
  27. package/dist/esm/node/webfingerRouter.js +6 -4
  28. package/dist/types/.tsbuildinfo +1 -1
  29. package/dist/types/httpSignature.d.ts +1 -1
  30. package/dist/types/index.d.ts +3 -3
  31. package/dist/types/node/actorRouter.d.ts +2 -0
  32. package/dist/types/node/delivery.d.ts +23 -4
  33. package/dist/types/node/httpCacheControl.d.ts +2 -0
  34. package/dist/types/node/index.d.ts +6 -1
  35. package/dist/types/node/tcoLinks.d.ts +41 -0
  36. package/dist/types/node/webfingerRouter.d.ts +2 -0
  37. package/package.json +2 -3
  38. package/src/__tests__/actorCollectionCounts.test.ts +27 -18
  39. package/src/__tests__/actorObject.test.ts +27 -7
  40. package/src/__tests__/actorResolver.test.ts +37 -22
  41. package/src/__tests__/actorResolverNetworkIdentity.test.ts +73 -36
  42. package/src/__tests__/apUri.test.ts +9 -5
  43. package/src/__tests__/delivery.test.ts +165 -25
  44. package/src/__tests__/federationHost.test.ts +13 -3
  45. package/src/__tests__/httpSignature.test.ts +1 -3
  46. package/src/__tests__/inboundDispatch.test.ts +80 -30
  47. package/src/__tests__/networkIdentity.test.ts +240 -113
  48. package/src/__tests__/routers.test.ts +157 -27
  49. package/src/__tests__/tcoLinks.test.ts +21 -0
  50. package/src/actorObject.ts +8 -3
  51. package/src/apUri.ts +6 -2
  52. package/src/httpSignature.ts +1 -1
  53. package/src/index.ts +8 -3
  54. package/src/networkIdentity.ts +14 -5
  55. package/src/node/actorResolver.ts +91 -50
  56. package/src/node/actorRouter.ts +44 -14
  57. package/src/node/delivery.ts +112 -30
  58. package/src/node/httpCacheControl.ts +8 -0
  59. package/src/node/identityBridge.ts +38 -18
  60. package/src/node/inboundDispatch.ts +33 -13
  61. package/src/node/index.ts +13 -0
  62. package/src/node/signedFetch.ts +3 -1
  63. package/src/node/tcoLinks.ts +114 -0
  64. package/src/node/webfingerRouter.ts +8 -4
@@ -38,6 +38,22 @@ const DELIVERY_RESPONSE_PREVIEW_MAX_BYTES = 1024;
38
38
  /** The ActivityStreams public collection — the `to` addressee of a public activity. */
39
39
  const AP_PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
40
40
 
41
+ /** Trusted sender metadata supplied by the application or its durable job. */
42
+ export interface DeliveryAuthorizationContext {
43
+ activity: Readonly<Record<string, unknown>>;
44
+ senderAccountId: string;
45
+ }
46
+
47
+ /** A definitive refusal: workers must discard, not retry or count a delivery. */
48
+ export class DeliveryAuthorizationDeniedError extends Error {
49
+ readonly code = 'DELIVERY_AUTHORIZATION_DENIED';
50
+
51
+ constructor() {
52
+ super('Outbound federation delivery is not authorized');
53
+ this.name = 'DeliveryAuthorizationDeniedError';
54
+ }
55
+ }
56
+
41
57
  /** Minimal logging sink the delivery service writes to. */
42
58
  export interface DeliveryLogger {
43
59
  debug(message: string, detail?: unknown): void;
@@ -81,7 +97,10 @@ export interface DeliverSingleHopInit {
81
97
  * response WITHOUT following redirects. Mention adapts its `@oxy.so/core/server`-
82
98
  * backed `fetchUpstreamSingleHop` into this shape.
83
99
  */
84
- export type DeliverSingleHop = (url: string, init: DeliverSingleHopInit) => Promise<DeliverSingleHopResult>;
100
+ export type DeliverSingleHop = (
101
+ url: string,
102
+ init: DeliverSingleHopInit,
103
+ ) => Promise<DeliverSingleHopResult>;
85
104
 
86
105
  /** A durable-delivery job body (BullMQ + the fallback queue share this shape). */
87
106
  export interface DeliveryQueueJob {
@@ -94,9 +113,8 @@ export interface DeliveryQueueJob {
94
113
  * The durable-delivery fallback (written when BullMQ is unavailable).
95
114
  *
96
115
  * 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.
116
+ * a database. Mention backs it with Postgres; the method names below are kept
117
+ * stable so the adapter shape stays stable for consumers.
100
118
  */
101
119
  export interface DeliveryFallbackQueue {
102
120
  /** Insert one fallback delivery row (`queueDelivery`). */
@@ -134,7 +152,9 @@ export interface DeliveryActorStore<TActor extends DeliveryActorFields> {
134
152
  /** One cached actor by uri (`resolveActorInbox` / `sendFollow` / `sendUndoFollow` / `sendAccept`). */
135
153
  findActorByUri(uri: string): Promise<TActor | null>;
136
154
  /** Inbox fields for many actor uris (`deliverToFollowers`, step 2). */
137
- findActorInboxesByUris(uris: string[]): Promise<Array<Pick<DeliveryActorFields, 'sharedInboxUrl' | 'inboxUrl'>>>;
155
+ findActorInboxesByUris(
156
+ uris: string[],
157
+ ): Promise<Array<Pick<DeliveryActorFields, 'sharedInboxUrl' | 'inboxUrl'>>>;
138
158
  }
139
159
 
140
160
  /** Bring-your-own-store: the AP follow records stay in the app DB behind this adapter. */
@@ -142,9 +162,16 @@ export interface DeliveryFollowStore {
142
162
  /** Accepted inbound followers' remote actor uris (`deliverToFollowers`, step 1). */
143
163
  listAcceptedInboundFollowerActorUris(localOxyUserId: string): Promise<string[]>;
144
164
  /** Upsert an outbound pending follow with its activity id (`sendFollow`). */
145
- upsertOutboundPending(localOxyUserId: string, remoteActorUri: string, activityId: string): Promise<void>;
165
+ upsertOutboundPending(
166
+ localOxyUserId: string,
167
+ remoteActorUri: string,
168
+ activityId: string,
169
+ ): Promise<void>;
146
170
  /** The outbound follow row for `(localOxyUserId, remoteActorUri)` (`sendUndoFollow`). */
147
- findOutbound(localOxyUserId: string, remoteActorUri: string): Promise<{ _id: unknown; activityId?: string } | null>;
171
+ findOutbound(
172
+ localOxyUserId: string,
173
+ remoteActorUri: string,
174
+ ): Promise<{ _id: unknown; activityId?: string } | null>;
148
175
  /** Delete a follow row by id (`sendUndoFollow`). */
149
176
  deleteById(id: unknown): Promise<void>;
150
177
  }
@@ -217,6 +244,13 @@ export interface DeliveryServiceConfig<TActor extends DeliveryActorFields> {
217
244
  deliverSingleHop: DeliverSingleHop;
218
245
  /** SSRF pre-check for a durable inbox enqueue (never queue a delivery to an unsafe URL). */
219
246
  assertSafeInboxUrl(url: string): Promise<SafeUrlVerdict>;
247
+ /**
248
+ * Optional live policy check. False throws DeliveryAuthorizationDeniedError before
249
+ * signing or enqueueing. A thrown authority error propagates unchanged so a
250
+ * durable worker can retry it. Checked once per fanout, then again at delivery.
251
+ * Never derive the sender from the activity actor URL or its signing key.
252
+ */
253
+ authorizeDelivery?(context: DeliveryAuthorizationContext): Promise<boolean>;
220
254
  /** BullMQ enqueue + the durable fallback queue. */
221
255
  transport: DeliveryTransport;
222
256
  /** The AP actor cache store. */
@@ -295,7 +329,10 @@ export interface DeliveryService {
295
329
  localUsername: string,
296
330
  remoteActorUri: string,
297
331
  ): Promise<{ success: boolean; pending: boolean }>;
298
- /** Send an Undo(Follow) to a remote actor (removes the local follow first). */
332
+ /**
333
+ * Remove the local follow and attempt Undo delivery. True means the local
334
+ * relationship was removed, never that remote delivery succeeded.
335
+ */
299
336
  sendUndoFollow(
300
337
  localOxyUserId: string,
301
338
  localUsername: string,
@@ -333,11 +370,32 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
333
370
  return host === null || config.isBlockedDomain(host);
334
371
  }
335
372
 
373
+ async function authorizeDelivery(
374
+ activity: Record<string, unknown>,
375
+ senderAccountId: string,
376
+ ): Promise<void> {
377
+ if (
378
+ config.authorizeDelivery &&
379
+ !(await config.authorizeDelivery({ activity, senderAccountId }))
380
+ ) {
381
+ throw new DeliveryAuthorizationDeniedError();
382
+ }
383
+ }
384
+
336
385
  async function deliverActivity(
337
386
  activity: Record<string, unknown>,
338
387
  targetInbox: string,
339
388
  senderOxyUserId: string,
340
389
  senderUsername: string,
390
+ ): Promise<boolean> {
391
+ await authorizeDelivery(activity, senderOxyUserId);
392
+ return deliverAuthorizedActivity(activity, targetInbox, senderUsername);
393
+ }
394
+
395
+ async function deliverAuthorizedActivity(
396
+ activity: Record<string, unknown>,
397
+ targetInbox: string,
398
+ senderUsername: string,
341
399
  ): Promise<boolean> {
342
400
  if (isBlockedUrl(targetInbox)) {
343
401
  logger.warn(`[FedDeliver] refusing outbound delivery to blocked inbox ${targetInbox}`);
@@ -356,7 +414,9 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
356
414
  ...sigHeaders,
357
415
  };
358
416
 
359
- logger.debug(`[FedDeliver] POST ${targetInbox} body=${body} sig-headers=${sigHeaders.Signature?.match(/headers="([^"]+)"/)?.[1]}`);
417
+ logger.debug(
418
+ `[FedDeliver] POST ${targetInbox} body=${body} sig-headers=${sigHeaders.Signature?.match(/headers="([^"]+)"/)?.[1]}`,
419
+ );
360
420
 
361
421
  const { response, status } = await config.deliverSingleHop(targetInbox, {
362
422
  method: 'POST',
@@ -372,7 +432,9 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
372
432
  }
373
433
 
374
434
  const responseBody = await readResponsePreview(response);
375
- logger.debug(`Activity delivery failed to ${targetInbox}: ${status} body=${responseBody.slice(0, 500)}`);
435
+ logger.debug(
436
+ `Activity delivery failed to ${targetInbox}: ${status} body=${responseBody.slice(0, 500)}`,
437
+ );
376
438
  return false;
377
439
  } catch (err) {
378
440
  logger.debug(`Activity delivery error to ${targetInbox}:`, err);
@@ -385,6 +447,7 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
385
447
  targetInbox: string,
386
448
  senderOxyUserId: string,
387
449
  ): Promise<void> {
450
+ await authorizeDelivery(activity, senderOxyUserId);
388
451
  if (isBlockedUrl(targetInbox)) {
389
452
  logger.warn(`[FedDeliver] not queueing delivery to blocked inbox ${targetInbox}`);
390
453
  return;
@@ -402,7 +465,9 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
402
465
  .enqueueDelivery({ activityJson: activity, targetInbox, senderOxyUserId })
403
466
  .catch((err) => {
404
467
  const message = err instanceof Error ? err.message : String(err);
405
- logger.warn(`[FedDeliver] enqueue failed for ${targetInbox}, falling back to the durable queue: ${message}`);
468
+ logger.warn(
469
+ `[FedDeliver] enqueue failed for ${targetInbox}, falling back to the durable queue: ${message}`,
470
+ );
406
471
  return false;
407
472
  });
408
473
 
@@ -429,6 +494,7 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
429
494
  senderUsername: string,
430
495
  options: { extraInboxes?: string[] } = {},
431
496
  ): Promise<void> {
497
+ await authorizeDelivery(activity, senderOxyUserId);
432
498
  const actorUris = await config.follows.listAcceptedInboundFollowerActorUris(senderOxyUserId);
433
499
  const actors = actorUris.length > 0 ? await config.store.findActorInboxesByUris(actorUris) : [];
434
500
 
@@ -473,12 +539,19 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
473
539
  .enqueueDelivery({ activityJson: activity, targetInbox: inbox, senderOxyUserId })
474
540
  .catch((err) => {
475
541
  const message = err instanceof Error ? err.message : String(err);
476
- logger.warn(`[FedDeliver] follower enqueue failed for ${inbox}, falling back to the durable queue: ${message}`);
542
+ logger.warn(
543
+ `[FedDeliver] follower enqueue failed for ${inbox}, falling back to the durable queue: ${message}`,
544
+ );
477
545
  return false;
478
546
  });
479
547
 
480
548
  if (!enqueued) {
481
- durableFallback.push({ activityJson: activity, targetInbox: inbox, senderOxyUserId, nextAttemptAt: now });
549
+ durableFallback.push({
550
+ activityJson: activity,
551
+ targetInbox: inbox,
552
+ senderOxyUserId,
553
+ nextAttemptAt: now,
554
+ });
482
555
  }
483
556
  }
484
557
 
@@ -512,7 +585,9 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
512
585
  }
513
586
  } catch (err) {
514
587
  const message = err instanceof Error ? err.message : String(err);
515
- logger.warn(`[FedSync] deferred follow delivery setup failed for ${remoteActorUri}: ${message}`);
588
+ logger.warn(
589
+ `[FedSync] deferred follow delivery setup failed for ${remoteActorUri}: ${message}`,
590
+ );
516
591
  }
517
592
  })();
518
593
  }
@@ -533,22 +608,13 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
533
608
  // queue the Follow activity, then refresh the actor in the background.
534
609
  const cached = await config.store.findActorByUri(remoteActorUri);
535
610
 
536
- // Always refresh the actor in the background so its inbox/profile stay
537
- // current (and so a missing actor gets resolved for delivery shortly).
538
- config.actorRefresh.refreshActorInBackground(remoteActorUri, cached ?? undefined);
539
-
540
611
  const canonicalUri = cached?.uri ?? remoteActorUri;
541
612
  const localActorUri = urls.actor(localUsername);
542
613
  // Use the actor _id when known, otherwise a stable hash of the URI so the
543
614
  // activity ID is deterministic across retries before the actor is cached.
544
- const activityIdSuffix = cached?._id
545
- ? String(cached._id)
546
- : encodeURIComponent(canonicalUri);
615
+ const activityIdSuffix = cached?._id ? String(cached._id) : encodeURIComponent(canonicalUri);
547
616
  const activityId = `${localActorUri}/follows/${activityIdSuffix}`;
548
617
 
549
- // Create or update the follow record
550
- await config.follows.upsertOutboundPending(localOxyUserId, canonicalUri, activityId);
551
-
552
618
  const activity: Record<string, unknown> = {
553
619
  '@context': 'https://www.w3.org/ns/activitystreams',
554
620
  id: activityId,
@@ -557,17 +623,25 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
557
623
  object: canonicalUri,
558
624
  };
559
625
 
626
+ // Refuse before committing an outbound follow or reporting success.
627
+ await authorizeDelivery(activity, localOxyUserId);
628
+ // A refused follow must not start remote actor discovery either.
629
+ config.actorRefresh.refreshActorInBackground(remoteActorUri, cached ?? undefined);
630
+ await config.follows.upsertOutboundPending(localOxyUserId, canonicalUri, activityId);
631
+
560
632
  // If we know the inbox, attempt delivery in the background; otherwise queue
561
633
  // for the delivery worker, which resolves the inbox once the actor lands.
562
634
  const targetInbox = cached?.sharedInboxUrl ?? cached?.inboxUrl;
563
635
  if (targetInbox && !isBlockedUrl(targetInbox)) {
564
- void deliverActivity(activity, targetInbox, localOxyUserId, localUsername)
636
+ void deliverAuthorizedActivity(activity, targetInbox, localUsername)
565
637
  .then((delivered) => {
566
638
  if (!delivered) return queueDelivery(activity, targetInbox, localOxyUserId);
567
639
  })
568
640
  .catch((err) => {
569
641
  const message = err instanceof Error ? err.message : String(err);
570
- logger.warn(`[FedSync] background follow delivery failed for ${canonicalUri}: ${message}`);
642
+ logger.warn(
643
+ `[FedSync] background follow delivery failed for ${canonicalUri}: ${message}`,
644
+ );
571
645
  });
572
646
  } else {
573
647
  // No cached inbox yet — resolve the actor's inbox in the background and
@@ -623,10 +697,14 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
623
697
  })
624
698
  .catch((err) => {
625
699
  const message = err instanceof Error ? err.message : String(err);
626
- logger.warn(`[FedSync] background undo-follow delivery failed for ${remoteActorUri}: ${message}`);
700
+ logger.warn(
701
+ `[FedSync] background undo-follow delivery failed for ${remoteActorUri}: ${message}`,
702
+ );
627
703
  });
628
704
  } else if (targetInbox) {
629
- logger.warn(`[FedDeliver] skipping Undo(Follow) delivery to blocked origin ${remoteActorUri}`);
705
+ logger.warn(
706
+ `[FedDeliver] skipping Undo(Follow) delivery to blocked origin ${remoteActorUri}`,
707
+ );
630
708
  }
631
709
 
632
710
  return true;
@@ -649,7 +727,9 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
649
727
  return;
650
728
  }
651
729
  if (isBlockedUrl(targetInbox) || isBlockedUrl(remoteActorUri)) {
652
- logger.warn(`[FedDeliver] refusing Accept(Follow) delivery to blocked origin ${remoteActorUri}`);
730
+ logger.warn(
731
+ `[FedDeliver] refusing Accept(Follow) delivery to blocked origin ${remoteActorUri}`,
732
+ );
653
733
  return;
654
734
  }
655
735
 
@@ -681,7 +761,9 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
681
761
  try {
682
762
  const user = await config.identity.resolveUserByUsername(username);
683
763
  if (!user) {
684
- logger.warn(`[FedDeliver] cannot federate actor update for ${username}: user not resolvable`);
764
+ logger.warn(
765
+ `[FedDeliver] cannot federate actor update for ${username}: user not resolvable`,
766
+ );
685
767
  return;
686
768
  }
687
769
 
@@ -0,0 +1,8 @@
1
+ /** Shared discovery config validation; zero forbids downstream response retention. */
2
+ export function discoveryCacheControl(value: number | undefined, defaultSeconds: number): string {
3
+ const seconds = value ?? defaultSeconds;
4
+ if (!Number.isInteger(seconds) || seconds < 0 || seconds > 86400) {
5
+ throw new RangeError('httpCacheTtlSeconds must be an integer from 0 to 86400');
6
+ }
7
+ return seconds === 0 ? 'no-store' : `max-age=${seconds}`;
8
+ }
@@ -24,7 +24,11 @@ import type { NormalizedExternalActor } from '../index';
24
24
  export type ServiceRequestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
25
25
 
26
26
  /** The service-scoped request transport oxy-api calls go through (`{ data }`-unwrapped). */
27
- export type ServiceRequest = <T>(method: ServiceRequestMethod, path: string, body?: unknown) => Promise<T>;
27
+ export type ServiceRequest = <T>(
28
+ method: ServiceRequestMethod,
29
+ path: string,
30
+ body?: unknown,
31
+ ) => Promise<T>;
28
32
 
29
33
  /** Minimal logging sink the identity bridge writes to. */
30
34
  export interface IdentityBridgeLogger {
@@ -183,15 +187,21 @@ export function createIdentityBridge(config: IdentityBridgeConfig): IdentityBrid
183
187
  const httpStatus = getErrorStatus(error);
184
188
  const reason = getErrorMessage(error);
185
189
  if (isPermanentClientError(httpStatus)) {
186
- config.logger.warn(`[Federation] actor-gone report for ${id} rejected (HTTP ${httpStatus}, permanent)`, {
187
- reason,
188
- });
190
+ config.logger.warn(
191
+ `[Federation] actor-gone report for ${id} rejected (HTTP ${httpStatus}, permanent)`,
192
+ {
193
+ reason,
194
+ },
195
+ );
189
196
  return 'skipped';
190
197
  }
191
- config.logger.warn(`[Federation] actor-gone report for ${id} failed transiently; leaving for retry`, {
192
- status: httpStatus,
193
- reason,
194
- });
198
+ config.logger.warn(
199
+ `[Federation] actor-gone report for ${id} failed transiently; leaving for retry`,
200
+ {
201
+ status: httpStatus,
202
+ reason,
203
+ },
204
+ );
195
205
  return 'failed';
196
206
  }
197
207
  },
@@ -201,9 +211,13 @@ export function createIdentityBridge(config: IdentityBridgeConfig): IdentityBrid
201
211
  if (!id) return 'skipped';
202
212
 
203
213
  try {
204
- const data = await config.makeServiceRequest<ActorDeleteResponse>('POST', ACTOR_DELETE_PATH, {
205
- oxyUserId: id,
206
- });
214
+ const data = await config.makeServiceRequest<ActorDeleteResponse>(
215
+ 'POST',
216
+ ACTOR_DELETE_PATH,
217
+ {
218
+ oxyUserId: id,
219
+ },
220
+ );
207
221
  const deleted = data?.deleted === true;
208
222
  config.logger.info(
209
223
  `[Federation] oxy-api ${deleted ? 'hard-deleted' : 'found no'} identity for gone actor ${id}`,
@@ -214,15 +228,21 @@ export function createIdentityBridge(config: IdentityBridgeConfig): IdentityBrid
214
228
  const httpStatus = getErrorStatus(error);
215
229
  const reason = getErrorMessage(error);
216
230
  if (isPermanentClientError(httpStatus)) {
217
- config.logger.warn(`[Federation] actor-delete for ${id} rejected (HTTP ${httpStatus}, permanent)`, {
218
- reason,
219
- });
231
+ config.logger.warn(
232
+ `[Federation] actor-delete for ${id} rejected (HTTP ${httpStatus}, permanent)`,
233
+ {
234
+ reason,
235
+ },
236
+ );
220
237
  return 'skipped';
221
238
  }
222
- config.logger.warn(`[Federation] actor-delete for ${id} failed transiently; leaving for retry`, {
223
- status: httpStatus,
224
- reason,
225
- });
239
+ config.logger.warn(
240
+ `[Federation] actor-delete for ${id} failed transiently; leaving for retry`,
241
+ {
242
+ status: httpStatus,
243
+ reason,
244
+ },
245
+ );
226
246
  return 'failed';
227
247
  }
228
248
  },
@@ -64,9 +64,7 @@ export interface InboundDispatcherLogger {
64
64
  * compact failure summary. The app owns the validation (its zod schemas); the
65
65
  * engine owns the drop-with-warn behaviour so every app logs identically.
66
66
  */
67
- export type InboundActivityValidation =
68
- | { ok: true; type: string }
69
- | { ok: false; summary: string };
67
+ export type InboundActivityValidation = { ok: true; type: string } | { ok: false; summary: string };
70
68
 
71
69
  /** The Oxy-user fields the engine reads off a resolved local user (Follow target). */
72
70
  export interface InboundLocalUser {
@@ -99,9 +97,16 @@ export interface InboundActorResolver {
99
97
  /** Bring-your-own-store: the AP follow records + actor cache reads the follow verbs need. */
100
98
  export interface InboundFollowStore {
101
99
  /** `handleIncomingFollow`: upsert the accepted inbound follow row. */
102
- upsertInboundAccepted(localUserId: string, remoteActorUri: string, activityId: string): Promise<void>;
100
+ upsertInboundAccepted(
101
+ localUserId: string,
102
+ remoteActorUri: string,
103
+ activityId: string,
104
+ ): Promise<void>;
103
105
  /** `handleUndo(Follow)`: the inbound follow row (scoped by localUserId when known). */
104
- findInboundFollow(remoteActorUri: string, localUserId?: string): Promise<{ _id: unknown; localUserId: string } | null>;
106
+ findInboundFollow(
107
+ remoteActorUri: string,
108
+ localUserId?: string,
109
+ ): Promise<{ _id: unknown; localUserId: string } | null>;
105
110
  /** `handleUndo(Follow)`: delete a follow row by id. */
106
111
  deleteFollowById(id: unknown): Promise<void>;
107
112
  /** `handleUndo(Follow)`: the follower actor's cached Oxy user id (for `bridgeUnfollow`). */
@@ -154,7 +159,11 @@ export interface InboundDispatcherConfig {
154
159
  * NEVER throws (it handles its own errors); a failure must not fail (and thus
155
160
  * retry) the inbox activity. Absent ⇒ no notification.
156
161
  */
157
- onInboundFollowAccepted?(localUserId: string, followerOxyUserId: string, actorUri: string): Promise<void>;
162
+ onInboundFollowAccepted?(
163
+ localUserId: string,
164
+ followerOxyUserId: string,
165
+ actorUri: string,
166
+ ): Promise<void>;
158
167
  /**
159
168
  * Best-effort: backfill the newly-followed remote actor's recent posts after an
160
169
  * outbound Follow was Accepted. NEVER throws. Absent ⇒ no backfill.
@@ -248,7 +257,10 @@ function objectTargetUri(object: unknown): string | undefined {
248
257
  export function createInboundDispatcher(config: InboundDispatcherConfig): InboundDispatcher {
249
258
  const { logger } = config;
250
259
 
251
- async function handleIncomingFollow(activity: Record<string, unknown>, actorUri: string): Promise<void> {
260
+ async function handleIncomingFollow(
261
+ activity: Record<string, unknown>,
262
+ actorUri: string,
263
+ ): Promise<void> {
252
264
  const targetActorUri = objectTargetUri(activity.object);
253
265
  if (!targetActorUri) return;
254
266
 
@@ -314,7 +326,10 @@ export function createInboundDispatcher(config: InboundDispatcherConfig): Inboun
314
326
  logger.info(`Accepted follow from ${actorUri} to ${username}`);
315
327
  }
316
328
 
317
- async function handleUndoFollow(object: Record<string, unknown>, actorUri: string): Promise<void> {
329
+ async function handleUndoFollow(
330
+ object: Record<string, unknown>,
331
+ actorUri: string,
332
+ ): Promise<void> {
318
333
  const targetActorUri = objectTargetUri(object.object);
319
334
  const match = targetActorUri?.match(/\/ap\/users\/([^/]+)$/);
320
335
  let localUserId: string | undefined;
@@ -374,9 +389,10 @@ export function createInboundDispatcher(config: InboundDispatcherConfig): Inboun
374
389
  }
375
390
  } else if ((object as { type?: unknown }).type === 'Follow') {
376
391
  const followActivityId = (object as { id?: unknown }).id;
377
- updated = typeof followActivityId === 'string' && followActivityId.length > 0
378
- ? await config.follows.markOutboundAcceptedByActivityId(actorUri, followActivityId)
379
- : await config.follows.markOutboundAcceptedAnyPending(actorUri);
392
+ updated =
393
+ typeof followActivityId === 'string' && followActivityId.length > 0
394
+ ? await config.follows.markOutboundAcceptedByActivityId(actorUri, followActivityId)
395
+ : await config.follows.markOutboundAcceptedAnyPending(actorUri);
380
396
  }
381
397
 
382
398
  if (updated) {
@@ -394,7 +410,8 @@ export function createInboundDispatcher(config: InboundDispatcherConfig): Inboun
394
410
 
395
411
  const objectType = typeof object === 'string' ? null : (object as { type?: unknown }).type;
396
412
  if (objectType === 'Follow') {
397
- const followActivityId = typeof object === 'object' ? (object as { id?: unknown }).id : undefined;
413
+ const followActivityId =
414
+ typeof object === 'object' ? (object as { id?: unknown }).id : undefined;
398
415
  await config.follows.markOutboundRejected(
399
416
  actorUri,
400
417
  typeof followActivityId === 'string' ? followActivityId : undefined,
@@ -403,7 +420,10 @@ export function createInboundDispatcher(config: InboundDispatcherConfig): Inboun
403
420
  }
404
421
  }
405
422
 
406
- async function processInboxActivity(activity: Record<string, unknown>, verifiedActorUri: string): Promise<void> {
423
+ async function processInboxActivity(
424
+ activity: Record<string, unknown>,
425
+ verifiedActorUri: string,
426
+ ): Promise<void> {
407
427
  // Instance domain policy, FIRST — before the payload is even parsed.
408
428
  //
409
429
  // This is the single chokepoint for inbound federation: every transport (the
package/src/node/index.ts CHANGED
@@ -26,6 +26,17 @@ export {
26
26
  type SignedFetchLogger,
27
27
  } from './signedFetch';
28
28
 
29
+ /**
30
+ * X's `t.co` wrapper unwrapped into the link the author posted, over the same
31
+ * injected single-hop transport — for bridged X bios and posts.
32
+ */
33
+ export {
34
+ createTcoLinkExpander,
35
+ type TcoLinkExpander,
36
+ type TcoLinkCache,
37
+ type CreateTcoLinkExpanderConfig,
38
+ } from './tcoLinks';
39
+
29
40
  /**
30
41
  * The actor↔Oxy-user identity bridge — the default implementation of the
31
42
  * `PUT /users/resolve` + actor-gone archive/delete seam over an injected
@@ -70,6 +81,8 @@ export {
70
81
  */
71
82
  export {
72
83
  createDeliveryService,
84
+ DeliveryAuthorizationDeniedError,
85
+ type DeliveryAuthorizationContext,
73
86
  type DeliveryService,
74
87
  type DeliveryServiceConfig,
75
88
  type DeliveryLogger,
@@ -156,7 +156,9 @@ export function createSignedFetch(config: CreateSignedFetchConfig): SignedFetch
156
156
  // resources. Retry from the post-redirect URL so we don't restart a chain that
157
157
  // already landed on the failing hop.
158
158
  if (res.status >= 500) {
159
- config.logger?.info(`[FedSync] signedFetch got ${res.status} for ${finalUrl}, retrying unsigned`);
159
+ config.logger?.info(
160
+ `[FedSync] signedFetch got ${res.status} for ${finalUrl}, retrying unsigned`,
161
+ );
160
162
  return fetchFollowingRedirects(finalUrl, false).then(({ res: unsignedRes }) => unsignedRes);
161
163
  }
162
164
 
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Expanding X's `t.co` link wrapper back into the link the author posted.
3
+ *
4
+ * X rewrites EVERY link in a post or bio into `https://t.co/<id>`, and a bridge
5
+ * that mirrors an X account (bird.makeup, mastox) republishes that text as it
6
+ * is: the actor `summary` and the Note `content` carry only the wrapper. The
7
+ * author never wrote `t.co`; it is X's click tracker standing in for their link,
8
+ * and an X API client never shows it because it reads the expansion beside it.
9
+ * The bridges drop that expansion, so the only place left to recover it is the
10
+ * wrapper's own redirect.
11
+ *
12
+ * Only `t.co` is unwrapped, and only ONE hop. A `bit.ly` the author chose is
13
+ * their link and stays; so does wherever their link redirects next. What is
14
+ * replaced is exactly X's wrapper, by exactly the URL it names.
15
+ *
16
+ * The transport is injected — the same SSRF-safe, IP-pinned single-hop fetch
17
+ * {@link createSignedFetch} takes — so each backend keeps its own network
18
+ * contract. A link that does not resolve (timeout, 404, a malformed or
19
+ * self-referencing `Location`) is left as `t.co`: still a working link, never a
20
+ * broken one.
21
+ */
22
+
23
+ import type { SingleHopFetch } from './signedFetch';
24
+
25
+ /**
26
+ * A `t.co` link in plain text. X ids are alphanumeric; the lookahead stops a
27
+ * longer token (`t.co/abc/def`, `t.co/abcé`) from being half-replaced.
28
+ */
29
+ const TCO_LINK = /\bhttps?:\/\/t\.co\/([A-Za-z0-9]+)(?![\w/])/gi;
30
+ const REDIRECT_STATUS_CODES: ReadonlySet<number> = new Set([301, 302, 303, 307, 308]);
31
+ /** A link that has not answered by then is kept as `t.co`. */
32
+ const TCO_TIMEOUT_MS = 5_000;
33
+ /** Bounds the requests one text can cost; the rest keep their wrapper. */
34
+ const MAX_LINKS_PER_TEXT = 10;
35
+
36
+ /**
37
+ * A store of resolved wrappers, keyed by the `t.co` id. A wrapper never changes
38
+ * what it points to, so an entry never goes stale. Failures are not cached.
39
+ */
40
+ export interface TcoLinkCache {
41
+ get(id: string): Promise<string | null | undefined>;
42
+ set(id: string, target: string): Promise<void>;
43
+ }
44
+
45
+ /** Adapters a {@link TcoLinkExpander} is built from. */
46
+ export interface CreateTcoLinkExpanderConfig {
47
+ /** SSRF-safe single-hop transport (does NOT follow redirects). */
48
+ fetchSingleHop: SingleHopFetch;
49
+ /** User-Agent presented to t.co. A browser UA gets an HTML page instead of the 301. */
50
+ userAgent: string;
51
+ cache?: TcoLinkCache;
52
+ }
53
+
54
+ /** Replace every `t.co` link in `text` with the URL it redirects to. */
55
+ export type TcoLinkExpander = (text: string) => Promise<string>;
56
+
57
+ /** The `t.co` ids in `text`, in order of first appearance. */
58
+ function findTcoLinkIds(text: string): string[] {
59
+ return [...new Set(Array.from(text.matchAll(TCO_LINK), (match) => match[1]))];
60
+ }
61
+
62
+ /** The redirect target, if it is an http(s) URL that is not another `t.co` wrapper. */
63
+ function readTarget(response: Response, from: string): string | null {
64
+ if (!REDIRECT_STATUS_CODES.has(response.status)) return null;
65
+ const location = response.headers.get('location');
66
+ if (!location) return null;
67
+ let target: URL;
68
+ try {
69
+ target = new URL(location, from);
70
+ } catch {
71
+ return null;
72
+ }
73
+ if (target.protocol !== 'https:' && target.protocol !== 'http:') return null;
74
+ if (target.hostname.toLowerCase() === 't.co') return null;
75
+ return target.href;
76
+ }
77
+
78
+ export function createTcoLinkExpander(config: CreateTcoLinkExpanderConfig): TcoLinkExpander {
79
+ const { cache } = config;
80
+
81
+ async function resolve(id: string): Promise<string | null> {
82
+ const cached = await cache?.get(id).catch(() => null);
83
+ if (cached) return cached;
84
+ const from = `https://t.co/${id}`;
85
+ let target: string | null;
86
+ try {
87
+ const response = await config.fetchSingleHop(from, {
88
+ headers: { Accept: '*/*', 'User-Agent': config.userAgent },
89
+ signal: AbortSignal.timeout(TCO_TIMEOUT_MS),
90
+ headersTimeoutMs: TCO_TIMEOUT_MS,
91
+ });
92
+ target = readTarget(response, from);
93
+ await response.body?.cancel().catch(() => undefined);
94
+ } catch {
95
+ return null;
96
+ }
97
+ if (target) await cache?.set(id, target).catch(() => undefined);
98
+ return target;
99
+ }
100
+
101
+ return async function expandTcoLinks(text: string): Promise<string> {
102
+ const ids = findTcoLinkIds(text).slice(0, MAX_LINKS_PER_TEXT);
103
+ if (ids.length === 0) return text;
104
+ const targets = new Map<string, string>();
105
+ await Promise.all(
106
+ ids.map(async (id) => {
107
+ const target = await resolve(id);
108
+ if (target) targets.set(id, target);
109
+ }),
110
+ );
111
+ if (targets.size === 0) return text;
112
+ return text.replace(TCO_LINK, (whole, id: string) => targets.get(id) ?? whole);
113
+ };
114
+ }