@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
@@ -2,6 +2,7 @@ import { Readable } from 'node:stream';
2
2
  import { createDomainPolicy } from '../apUri';
3
3
  import {
4
4
  createDeliveryService,
5
+ DeliveryAuthorizationDeniedError,
5
6
  type DeliveryServiceConfig,
6
7
  type DeliveryActorFields,
7
8
  type DeliveryQueueJob,
@@ -29,25 +30,36 @@ interface StoredActor extends DeliveryActorFields {
29
30
  }
30
31
 
31
32
  /** A capturing test rig: records every delivered/queued activity + enqueue jobs. */
32
- function makeRig(overrides: {
33
- actorsByUri?: Record<string, StoredActor>;
34
- followerActorUris?: string[];
35
- followerInboxes?: Record<string, { sharedInboxUrl?: string | null; inboxUrl?: string | null }>;
36
- enqueueReturns?: boolean;
37
- federationEnabled?: boolean;
38
- sharingEnabled?: boolean;
39
- assertSafeInboxUrl?: DeliveryServiceConfig<StoredActor>['assertSafeInboxUrl'];
40
- blockedHosts?: string[];
41
- } = {}) {
33
+ function makeRig(
34
+ overrides: {
35
+ actorsByUri?: Record<string, StoredActor>;
36
+ followerActorUris?: string[];
37
+ followerInboxes?: Record<string, { sharedInboxUrl?: string | null; inboxUrl?: string | null }>;
38
+ enqueueReturns?: boolean;
39
+ federationEnabled?: boolean;
40
+ sharingEnabled?: boolean;
41
+ assertSafeInboxUrl?: DeliveryServiceConfig<StoredActor>['assertSafeInboxUrl'];
42
+ blockedHosts?: string[];
43
+ authorizeDelivery?: DeliveryServiceConfig<StoredActor>['authorizeDelivery'];
44
+ } = {},
45
+ ) {
42
46
  const deliveredBodies: Array<{ url: string; body: string }> = [];
43
47
  const enqueued: DeliveryQueueJob[] = [];
44
48
  const fallbackCreates: Array<DeliveryQueueJob & { nextAttemptAt: Date }> = [];
45
49
  const fallbackInserts: Array<DeliveryQueueJob & { nextAttemptAt: Date }> = [];
46
- const upsertedFollows: Array<{ localOxyUserId: string; remoteActorUri: string; activityId: string }> = [];
50
+ const upsertedFollows: Array<{
51
+ localOxyUserId: string;
52
+ remoteActorUri: string;
53
+ activityId: string;
54
+ }> = [];
47
55
  const deletedFollowIds: unknown[] = [];
48
56
  const refreshedInBackground: string[] = [];
49
57
 
50
- const sign: HttpSignatureSigner = async () => 'TESTSIG';
58
+ const signatures: string[] = [];
59
+ const sign: HttpSignatureSigner = async (keyId) => {
60
+ signatures.push(keyId);
61
+ return 'TESTSIG';
62
+ };
51
63
  const domainPolicy =
52
64
  overrides.blockedHosts === undefined
53
65
  ? createDomainPolicy({ domain: DOMAIN })
@@ -55,6 +67,7 @@ function makeRig(overrides: {
55
67
 
56
68
  const config: DeliveryServiceConfig<StoredActor> = {
57
69
  federationEnabled: overrides.federationEnabled ?? true,
70
+ authorizeDelivery: overrides.authorizeDelivery,
58
71
  userAgent: 'Mention/mention.earth (ActivityPub)',
59
72
  apContentType: 'application/activity+json',
60
73
  keys: {
@@ -65,7 +78,10 @@ function makeRig(overrides: {
65
78
  deliverSingleHop: async (url, init): Promise<DeliverSingleHopResult> => {
66
79
  deliveredBodies.push({ url, body: init.body });
67
80
  // 202 Accepted — success path (response body destroyed, never read).
68
- return { response: Readable.from([]) as unknown as DeliverSingleHopResult['response'], status: 202 };
81
+ return {
82
+ response: Readable.from([]) as unknown as DeliverSingleHopResult['response'],
83
+ status: 202,
84
+ };
69
85
  },
70
86
  assertSafeInboxUrl: overrides.assertSafeInboxUrl ?? (async () => ({ ok: true })),
71
87
  transport: {
@@ -89,7 +105,10 @@ function makeRig(overrides: {
89
105
  findActorInboxesByUris: async (uris) =>
90
106
  uris
91
107
  .map((u) => overrides.followerInboxes?.[u])
92
- .filter((a): a is { sharedInboxUrl?: string | null; inboxUrl?: string | null } => a !== undefined),
108
+ .filter(
109
+ (a): a is { sharedInboxUrl?: string | null; inboxUrl?: string | null } =>
110
+ a !== undefined,
111
+ ),
93
112
  },
94
113
  follows: {
95
114
  listAcceptedInboundFollowerActorUris: async () => overrides.followerActorUris ?? [],
@@ -98,7 +117,9 @@ function makeRig(overrides: {
98
117
  },
99
118
  findOutbound: async (_localOxyUserId, remoteActorUri) => {
100
119
  const actor = overrides.actorsByUri?.[remoteActorUri];
101
- return actor ? { _id: actor._id, activityId: `${urls.actor('alice')}/follows/${String(actor._id)}` } : null;
120
+ return actor
121
+ ? { _id: actor._id, activityId: `${urls.actor('alice')}/follows/${String(actor._id)}` }
122
+ : null;
102
123
  },
103
124
  deleteById: async (id) => {
104
125
  deletedFollowIds.push(id);
@@ -120,13 +141,18 @@ function makeRig(overrides: {
120
141
  }),
121
142
  },
122
143
  profile: { getBanner: async () => 'https://cdn.example/banner.png' },
123
- buildLocalActorObject: (params) => ({ id: urls.actor(params.username), type: 'Person', name: params.displayName }),
144
+ buildLocalActorObject: (params) => ({
145
+ id: urls.actor(params.username),
146
+ type: 'Person',
147
+ name: params.displayName,
148
+ }),
124
149
  logger: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
125
150
  isBlockedDomain: domainPolicy.isBlockedDomain,
126
151
  };
127
152
 
128
153
  return {
129
154
  service: createDeliveryService(config),
155
+ signatures,
130
156
  deliveredBodies,
131
157
  enqueued,
132
158
  fallbackCreates,
@@ -173,7 +199,11 @@ describe('sendFollow', () => {
173
199
  expect(result).toEqual({ success: true, pending: false });
174
200
  expect(rig.refreshedInBackground).toEqual([remote]);
175
201
  expect(rig.upsertedFollows).toEqual([
176
- { localOxyUserId: 'u-alice', remoteActorUri: remote, activityId: `${urls.actor('alice')}/follows/ACTID` },
202
+ {
203
+ localOxyUserId: 'u-alice',
204
+ remoteActorUri: remote,
205
+ activityId: `${urls.actor('alice')}/follows/ACTID`,
206
+ },
177
207
  ]);
178
208
  // Byte-frozen Follow activity — the bare AS2 context (NOT the full AP_CONTEXT).
179
209
  expect(rig.deliveredBodies).toHaveLength(1);
@@ -185,7 +215,11 @@ describe('sendFollow', () => {
185
215
 
186
216
  it('short-circuits when federation is disabled', async () => {
187
217
  const rig = makeRig({ federationEnabled: false });
188
- const result = await rig.service.sendFollow('u-alice', 'alice', 'https://remote.example/users/bob');
218
+ const result = await rig.service.sendFollow(
219
+ 'u-alice',
220
+ 'alice',
221
+ 'https://remote.example/users/bob',
222
+ );
189
223
  expect(result).toEqual({ success: false, pending: false });
190
224
  expect(rig.deliveredBodies).toHaveLength(0);
191
225
  expect(rig.upsertedFollows).toHaveLength(0);
@@ -214,7 +248,9 @@ describe('sendAccept', () => {
214
248
  it('delivers the exact Accept(Follow) activity with the full AP_CONTEXT', async () => {
215
249
  const remote = 'https://remote.example/users/bob';
216
250
  const rig = makeRig({
217
- actorsByUri: { [remote]: { _id: 'A', uri: remote, inboxUrl: 'https://remote.example/inbox' } },
251
+ actorsByUri: {
252
+ [remote]: { _id: 'A', uri: remote, inboxUrl: 'https://remote.example/inbox' },
253
+ },
218
254
  });
219
255
 
220
256
  await rig.service.sendAccept('u-alice', 'alice', 'https://remote.example/follows/1', remote);
@@ -265,7 +301,9 @@ describe('sendAccept', () => {
265
301
  const remote = 'https://blocked.example/users/bob';
266
302
  const rig = makeRig({
267
303
  blockedHosts: ['blocked.example'],
268
- actorsByUri: { [remote]: { _id: 'A', uri: remote, inboxUrl: 'https://blocked.example/inbox' } },
304
+ actorsByUri: {
305
+ [remote]: { _id: 'A', uri: remote, inboxUrl: 'https://blocked.example/inbox' },
306
+ },
269
307
  });
270
308
 
271
309
  await rig.service.sendAccept('u-alice', 'alice', 'https://blocked.example/follows/1', remote);
@@ -279,7 +317,9 @@ describe('sendUndoFollow', () => {
279
317
  it('deletes the local follow, then delivers the exact Undo(Follow) with AP_CONTEXT', async () => {
280
318
  const remote = 'https://remote.example/users/bob';
281
319
  const rig = makeRig({
282
- actorsByUri: { [remote]: { _id: 'A', uri: remote, inboxUrl: 'https://remote.example/inbox' } },
320
+ actorsByUri: {
321
+ [remote]: { _id: 'A', uri: remote, inboxUrl: 'https://remote.example/inbox' },
322
+ },
283
323
  });
284
324
 
285
325
  const ok = await rig.service.sendUndoFollow('u-alice', 'alice', remote);
@@ -307,7 +347,9 @@ describe('sendUndoFollow', () => {
307
347
  const remote = 'https://blocked.example/users/bob';
308
348
  const rig = makeRig({
309
349
  blockedHosts: ['blocked.example'],
310
- actorsByUri: { [remote]: { _id: 'A', uri: remote, inboxUrl: 'https://blocked.example/inbox' } },
350
+ actorsByUri: {
351
+ [remote]: { _id: 'A', uri: remote, inboxUrl: 'https://blocked.example/inbox' },
352
+ },
311
353
  });
312
354
 
313
355
  const ok = await rig.service.sendUndoFollow('u-alice', 'alice', remote);
@@ -366,9 +408,7 @@ describe('deliverToFollowers', () => {
366
408
 
367
409
  it('skips unsafe inbox URLs instead of enqueueing or falling back', async () => {
368
410
  const unsafeGuard = jest.fn(async (url: string) =>
369
- url.includes('unsafe')
370
- ? { ok: false as const, reason: 'blocked' }
371
- : { ok: true as const },
411
+ url.includes('unsafe') ? { ok: false as const, reason: 'blocked' } : { ok: true as const },
372
412
  );
373
413
  const rig = makeRig({
374
414
  followerActorUris: ['a', 'b'],
@@ -430,3 +470,103 @@ describe('federateActorUpdate', () => {
430
470
  expect(rig.enqueued).toHaveLength(0);
431
471
  });
432
472
  });
473
+
474
+ describe('live delivery authorization', () => {
475
+ const activity = {
476
+ type: 'Create',
477
+ actor: 'https://untrusted.example/not-the-sender',
478
+ object: { type: 'Note', content: 'protected' },
479
+ };
480
+ const remoteActorUri = 'https://remote.example/users/bob';
481
+ const actorsByUri = {
482
+ [remoteActorUri]: { uri: remoteActorUri, inboxUrl: 'https://remote.example/inbox' },
483
+ };
484
+
485
+ it('uses trusted sender metadata and refuses before signing, sending or enqueueing', async () => {
486
+ const authorizeDelivery = jest.fn(async () => false);
487
+ const rig = makeRig({ authorizeDelivery });
488
+ await expect(
489
+ rig.service.deliverActivity(
490
+ activity,
491
+ 'https://remote.example/inbox',
492
+ 'canonical-sender',
493
+ 'alice',
494
+ ),
495
+ ).rejects.toBeInstanceOf(DeliveryAuthorizationDeniedError);
496
+ await expect(
497
+ rig.service.queueDelivery(activity, 'https://remote.example/inbox', 'canonical-sender'),
498
+ ).rejects.toBeInstanceOf(DeliveryAuthorizationDeniedError);
499
+ expect(authorizeDelivery).toHaveBeenCalledWith({
500
+ activity,
501
+ senderAccountId: 'canonical-sender',
502
+ });
503
+ expect(rig.signatures).toEqual([]);
504
+ expect(rig.deliveredBodies).toEqual([]);
505
+ expect(rig.enqueued).toEqual([]);
506
+ expect(rig.fallbackCreates).toEqual([]);
507
+ });
508
+
509
+ it('propagates transient authority failure without disguising it as a denial or send success', async () => {
510
+ const unavailable = new Error('policy service unavailable');
511
+ const rig = makeRig({
512
+ authorizeDelivery: async () => {
513
+ throw unavailable;
514
+ },
515
+ });
516
+ await expect(
517
+ rig.service.deliverActivity(activity, 'https://remote.example/inbox', 'sender', 'alice'),
518
+ ).rejects.toBe(unavailable);
519
+ expect(rig.signatures).toEqual([]);
520
+ expect(rig.deliveredBodies).toEqual([]);
521
+ });
522
+
523
+ it('checks internal Follow and Accept paths and does not commit a refused follow', async () => {
524
+ const rig = makeRig({ actorsByUri, authorizeDelivery: async () => false });
525
+ await expect(rig.service.sendFollow('sender', 'alice', remoteActorUri)).rejects.toBeInstanceOf(
526
+ DeliveryAuthorizationDeniedError,
527
+ );
528
+ await expect(
529
+ rig.service.sendAccept('sender', 'alice', 'follow-id', remoteActorUri),
530
+ ).rejects.toBeInstanceOf(DeliveryAuthorizationDeniedError);
531
+ await flush();
532
+ expect(rig.upsertedFollows).toEqual([]);
533
+ expect(rig.refreshedInBackground).toEqual([]);
534
+ expect(rig.signatures).toEqual([]);
535
+ expect(rig.deliveredBodies).toEqual([]);
536
+ expect(rig.enqueued).toEqual([]);
537
+ });
538
+
539
+ it('authorizes one fanout once and rechecks a durable delivery after revocation', async () => {
540
+ const authorizeDelivery = jest.fn(async () => true);
541
+ const rig = makeRig({
542
+ authorizeDelivery,
543
+ followerActorUris: ['a', 'b'],
544
+ followerInboxes: {
545
+ a: { inboxUrl: 'https://a.example/inbox' },
546
+ b: { inboxUrl: 'https://b.example/inbox' },
547
+ },
548
+ });
549
+ await rig.service.deliverToFollowers(activity, 'sender', 'alice');
550
+ expect(authorizeDelivery).toHaveBeenCalledTimes(1);
551
+ expect(rig.enqueued).toHaveLength(2);
552
+ authorizeDelivery.mockResolvedValue(false);
553
+ const job = rig.enqueued[0];
554
+ await expect(
555
+ rig.service.deliverActivity(job.activityJson, job.targetInbox, job.senderOxyUserId, 'alice'),
556
+ ).rejects.toBeInstanceOf(DeliveryAuthorizationDeniedError);
557
+ expect(rig.signatures).toEqual([]);
558
+ });
559
+
560
+ it('preserves approved adult delivery and checks a Follow once per immediate operation', async () => {
561
+ const authorizeDelivery = jest.fn(async () => true);
562
+ const rig = makeRig({ actorsByUri, authorizeDelivery });
563
+ expect(await rig.service.sendFollow('adult', 'alice', remoteActorUri)).toEqual({
564
+ success: true,
565
+ pending: false,
566
+ });
567
+ await flush();
568
+ expect(authorizeDelivery).toHaveBeenCalledTimes(1);
569
+ expect(rig.deliveredBodies).toHaveLength(1);
570
+ expect(rig.signatures).toHaveLength(1);
571
+ });
572
+ });
@@ -49,7 +49,11 @@ const CASES: ReadonlyArray<{
49
49
  }> = [
50
50
  { name: 'exact host', actorUri: 'https://spam.example/users/bob', expectedBlocked: true },
51
51
  { name: 'uppercase host', actorUri: 'https://SPAM.EXAMPLE/users/bob', expectedBlocked: true },
52
- { name: 'www. on the wire', actorUri: 'https://www.spam.example/users/bob', expectedBlocked: true },
52
+ {
53
+ name: 'www. on the wire',
54
+ actorUri: 'https://www.spam.example/users/bob',
55
+ expectedBlocked: true,
56
+ },
53
57
  {
54
58
  name: 'www. on the blocklist entry, bare on the wire',
55
59
  actorUri: 'https://spam.example/users/bob',
@@ -86,7 +90,11 @@ const CASES: ReadonlyArray<{
86
90
  entry: 'über.example',
87
91
  expectedBlocked: false,
88
92
  },
89
- { name: 'unrelated host', actorUri: 'https://mastodon.social/users/alice', expectedBlocked: false },
93
+ {
94
+ name: 'unrelated host',
95
+ actorUri: 'https://mastodon.social/users/alice',
96
+ expectedBlocked: false,
97
+ },
90
98
  {
91
99
  name: 'blocked host as a subdomain prefix',
92
100
  actorUri: 'https://spam.example.evil.test/users/bob',
@@ -227,7 +235,9 @@ describe('canonicalFederationHost', () => {
227
235
 
228
236
  it('does not convert a unicode host to its punycode wire form', () => {
229
237
  expect(canonicalFederationHost('ÜBER.example')).toBe('über.example');
230
- expect(canonicalFederationHost('über.example')).not.toBe(new URL('https://über.example').hostname);
238
+ expect(canonicalFederationHost('über.example')).not.toBe(
239
+ new URL('https://über.example').hostname,
240
+ );
231
241
  });
232
242
  });
233
243
 
@@ -117,9 +117,7 @@ const fetchPublicKey = async (keyId: string) =>
117
117
 
118
118
  /** Reproduce how Express lowercases req.headers, folding content-type as a real header. */
119
119
  function lowerHeaders(headers: Record<string, string>): Record<string, string> {
120
- const lowered = Object.fromEntries(
121
- Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]),
122
- );
120
+ const lowered = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]));
123
121
  if (lowered.digest && !lowered['content-type']) {
124
122
  lowered['content-type'] = DEFAULT_SIGNED_CONTENT_TYPE;
125
123
  }
@@ -21,21 +21,32 @@ import {
21
21
  const LOCAL_ACTOR = 'https://mention.earth/ap/users/alice';
22
22
  const REMOTE_ACTOR = 'https://remote.example/users/bob';
23
23
 
24
- function makeRig(overrides: {
25
- localUser?: { _id?: string | null; id?: string | null } | null;
26
- sharingEnabled?: boolean;
27
- followerOxyUserId?: string | null;
28
- inboundFollow?: { _id: unknown; localUserId: string } | null;
29
- actorOxyUserIdForUndo?: string | null;
30
- validate?: (activity: Record<string, unknown>) => InboundActivityValidation;
31
- blockedHosts?: string[];
32
- withMoveHandler?: boolean;
33
- } = {}) {
24
+ function makeRig(
25
+ overrides: {
26
+ localUser?: { _id?: string | null; id?: string | null } | null;
27
+ sharingEnabled?: boolean;
28
+ followerOxyUserId?: string | null;
29
+ inboundFollow?: { _id: unknown; localUserId: string } | null;
30
+ actorOxyUserIdForUndo?: string | null;
31
+ validate?: (activity: Record<string, unknown>) => InboundActivityValidation;
32
+ blockedHosts?: string[];
33
+ withMoveHandler?: boolean;
34
+ } = {},
35
+ ) {
34
36
  const moves: Array<{ activityId: string; oldActorUri: string; targetActorUri: string }> = [];
35
37
  const bridgeFollowCalls: Array<[string, string]> = [];
36
38
  const bridgeUnfollowCalls: Array<[string, string]> = [];
37
- const acceptsSent: Array<{ localOxyUserId: string; localUsername: string; followActivityId: string; remoteActorUri: string }> = [];
38
- const inboundAcceptedUpserts: Array<{ localUserId: string; remoteActorUri: string; activityId: string }> = [];
39
+ const acceptsSent: Array<{
40
+ localOxyUserId: string;
41
+ localUsername: string;
42
+ followActivityId: string;
43
+ remoteActorUri: string;
44
+ }> = [];
45
+ const inboundAcceptedUpserts: Array<{
46
+ localUserId: string;
47
+ remoteActorUri: string;
48
+ activityId: string;
49
+ }> = [];
39
50
  const deletedFollowIds: unknown[] = [];
40
51
  const contentActivities: Array<{ type: unknown; verifiedActorUri: string }> = [];
41
52
  const outboundAcceptedByActivityId: Array<[string, string]> = [];
@@ -111,7 +122,11 @@ function makeRig(overrides: {
111
122
  ...(overrides.withMoveHandler === false
112
123
  ? {}
113
124
  : {
114
- onMove: async (move: { activityId: string; oldActorUri: string; targetActorUri: string }) => {
125
+ onMove: async (move: {
126
+ activityId: string;
127
+ oldActorUri: string;
128
+ targetActorUri: string;
129
+ }) => {
115
130
  moves.push(move);
116
131
  },
117
132
  }),
@@ -145,9 +160,16 @@ describe('inbound Follow', () => {
145
160
  );
146
161
 
147
162
  expect(rig.bridgeFollowCalls).toEqual([['u-bob', 'u-alice']]);
148
- expect(rig.inboundAcceptedUpserts).toEqual([{ localUserId: 'u-alice', remoteActorUri: REMOTE_ACTOR, activityId: 'f1' }]);
163
+ expect(rig.inboundAcceptedUpserts).toEqual([
164
+ { localUserId: 'u-alice', remoteActorUri: REMOTE_ACTOR, activityId: 'f1' },
165
+ ]);
149
166
  expect(rig.acceptsSent).toEqual([
150
- { localOxyUserId: 'u-alice', localUsername: 'alice', followActivityId: 'f1', remoteActorUri: REMOTE_ACTOR },
167
+ {
168
+ localOxyUserId: 'u-alice',
169
+ localUsername: 'alice',
170
+ followActivityId: 'f1',
171
+ remoteActorUri: REMOTE_ACTOR,
172
+ },
151
173
  ]);
152
174
  expect(rig.onInboundFollowAcceptedCalls).toEqual([['u-alice', 'u-bob', REMOTE_ACTOR]]);
153
175
  });
@@ -233,20 +255,32 @@ describe('inbound Accept / Reject', () => {
233
255
  it('marks the outbound follow accepted by activity id + triggers the outbox backfill', async () => {
234
256
  const rig = makeRig();
235
257
  await rig.dispatcher.processInboxActivity(
236
- { type: 'Accept', actor: REMOTE_ACTOR, object: { type: 'Follow', id: 'https://mention.earth/ap/users/alice/follows/1' } },
258
+ {
259
+ type: 'Accept',
260
+ actor: REMOTE_ACTOR,
261
+ object: { type: 'Follow', id: 'https://mention.earth/ap/users/alice/follows/1' },
262
+ },
237
263
  REMOTE_ACTOR,
238
264
  );
239
- expect(rig.outboundAcceptedByActivityId).toEqual([[REMOTE_ACTOR, 'https://mention.earth/ap/users/alice/follows/1']]);
265
+ expect(rig.outboundAcceptedByActivityId).toEqual([
266
+ [REMOTE_ACTOR, 'https://mention.earth/ap/users/alice/follows/1'],
267
+ ]);
240
268
  expect(rig.onOutboundFollowAcceptedCalls).toEqual([REMOTE_ACTOR]);
241
269
  });
242
270
 
243
271
  it('marks accepted (string ref, then any-pending fallback)', async () => {
244
272
  const rig = makeRig();
245
273
  await rig.dispatcher.processInboxActivity(
246
- { type: 'Accept', actor: REMOTE_ACTOR, object: 'https://mention.earth/ap/users/alice/follows/1' },
274
+ {
275
+ type: 'Accept',
276
+ actor: REMOTE_ACTOR,
277
+ object: 'https://mention.earth/ap/users/alice/follows/1',
278
+ },
247
279
  REMOTE_ACTOR,
248
280
  );
249
- expect(rig.outboundAcceptedByActivityId).toEqual([[REMOTE_ACTOR, 'https://mention.earth/ap/users/alice/follows/1']]);
281
+ expect(rig.outboundAcceptedByActivityId).toEqual([
282
+ [REMOTE_ACTOR, 'https://mention.earth/ap/users/alice/follows/1'],
283
+ ]);
250
284
  });
251
285
 
252
286
  it('marks the outbound follow rejected on Reject(Follow)', async () => {
@@ -273,13 +307,18 @@ describe('Move → onMove', () => {
273
307
  it('hands a well-formed self-Move to the app', async () => {
274
308
  const rig = makeRig();
275
309
  await rig.dispatcher.processInboxActivity(move(), REMOTE_ACTOR);
276
- expect(rig.moves).toEqual([{ activityId: `${REMOTE_ACTOR}#moves/1`, oldActorUri: REMOTE_ACTOR, targetActorUri: TARGET }]);
310
+ expect(rig.moves).toEqual([
311
+ { activityId: `${REMOTE_ACTOR}#moves/1`, oldActorUri: REMOTE_ACTOR, targetActorUri: TARGET },
312
+ ]);
277
313
  expect(rig.contentActivities).toHaveLength(0);
278
314
  });
279
315
 
280
316
  it('accepts embedded object/target references', async () => {
281
317
  const rig = makeRig();
282
- await rig.dispatcher.processInboxActivity(move({ object: { id: REMOTE_ACTOR }, target: { id: TARGET } }), REMOTE_ACTOR);
318
+ await rig.dispatcher.processInboxActivity(
319
+ move({ object: { id: REMOTE_ACTOR }, target: { id: TARGET } }),
320
+ REMOTE_ACTOR,
321
+ );
283
322
  expect(rig.moves).toHaveLength(1);
284
323
  });
285
324
 
@@ -304,18 +343,23 @@ describe('Move → onMove', () => {
304
343
 
305
344
  it('drops a Move when the app registered no handler', async () => {
306
345
  const rig = makeRig({ withMoveHandler: false });
307
- await expect(rig.dispatcher.processInboxActivity(move(), REMOTE_ACTOR)).resolves.toBeUndefined();
346
+ await expect(
347
+ rig.dispatcher.processInboxActivity(move(), REMOTE_ACTOR),
348
+ ).resolves.toBeUndefined();
308
349
  expect(rig.contentActivities).toHaveLength(0);
309
350
  });
310
351
  });
311
352
 
312
353
  describe('content verbs → onContentActivity', () => {
313
- it.each(['Create', 'Announce', 'Like', 'Delete', 'Update'])('routes %s to the app', async (type) => {
314
- const rig = makeRig();
315
- await rig.dispatcher.processInboxActivity({ type, actor: REMOTE_ACTOR }, REMOTE_ACTOR);
316
- expect(rig.contentActivities).toEqual([{ type, verifiedActorUri: REMOTE_ACTOR }]);
317
- expect(rig.bridgeFollowCalls).toHaveLength(0);
318
- });
354
+ it.each(['Create', 'Announce', 'Like', 'Delete', 'Update'])(
355
+ 'routes %s to the app',
356
+ async (type) => {
357
+ const rig = makeRig();
358
+ await rig.dispatcher.processInboxActivity({ type, actor: REMOTE_ACTOR }, REMOTE_ACTOR);
359
+ expect(rig.contentActivities).toEqual([{ type, verifiedActorUri: REMOTE_ACTOR }]);
360
+ expect(rig.bridgeFollowCalls).toHaveLength(0);
361
+ },
362
+ );
319
363
 
320
364
  it('drops a malformed activity without dispatching', async () => {
321
365
  const rig = makeRig({ validate: () => ({ ok: false, summary: 'bad shape' }) });
@@ -402,7 +446,10 @@ describe('blocked-origin domain policy', () => {
402
446
 
403
447
  it('refuses the origin BEFORE the payload is parsed', async () => {
404
448
  const rig = makeRig({ blockedHosts: ['spam.example'] });
405
- await rig.dispatcher.processInboxActivity({ type: 'Create', actor: BLOCKED_ACTOR }, BLOCKED_ACTOR);
449
+ await rig.dispatcher.processInboxActivity(
450
+ { type: 'Create', actor: BLOCKED_ACTOR },
451
+ BLOCKED_ACTOR,
452
+ );
406
453
  expect(rig.validatedActivities).toHaveLength(0);
407
454
  });
408
455
 
@@ -424,7 +471,10 @@ describe('blocked-origin domain policy', () => {
424
471
 
425
472
  it('still dispatches an allowed origin while another host is blocked', async () => {
426
473
  const rig = makeRig({ blockedHosts: ['spam.example'] });
427
- await rig.dispatcher.processInboxActivity({ type: 'Create', actor: REMOTE_ACTOR }, REMOTE_ACTOR);
474
+ await rig.dispatcher.processInboxActivity(
475
+ { type: 'Create', actor: REMOTE_ACTOR },
476
+ REMOTE_ACTOR,
477
+ );
428
478
  expect(rig.contentActivities).toEqual([{ type: 'Create', verifiedActorUri: REMOTE_ACTOR }]);
429
479
  });
430
480