@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.
- package/README.md +31 -0
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/apUri.js +4 -2
- package/dist/cjs/httpSignature.js +1 -1
- package/dist/cjs/index.js +2 -2
- package/dist/cjs/networkIdentity.js +3 -2
- package/dist/cjs/node/actorResolver.js +24 -20
- package/dist/cjs/node/actorRouter.js +16 -6
- package/dist/cjs/node/delivery.js +35 -10
- package/dist/cjs/node/httpCacheControl.js +11 -0
- package/dist/cjs/node/inboundDispatch.js +4 -3
- package/dist/cjs/node/index.js +8 -1
- package/dist/cjs/node/tcoLinks.js +97 -0
- package/dist/cjs/node/webfingerRouter.js +6 -4
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/apUri.js +4 -2
- package/dist/esm/httpSignature.js +1 -1
- package/dist/esm/index.js +3 -3
- package/dist/esm/networkIdentity.js +3 -2
- package/dist/esm/node/actorResolver.js +24 -20
- package/dist/esm/node/actorRouter.js +16 -6
- package/dist/esm/node/delivery.js +33 -10
- package/dist/esm/node/httpCacheControl.js +8 -0
- package/dist/esm/node/inboundDispatch.js +4 -3
- package/dist/esm/node/index.js +6 -1
- package/dist/esm/node/tcoLinks.js +94 -0
- package/dist/esm/node/webfingerRouter.js +6 -4
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/httpSignature.d.ts +1 -1
- package/dist/types/index.d.ts +3 -3
- package/dist/types/node/actorRouter.d.ts +2 -0
- package/dist/types/node/delivery.d.ts +23 -4
- package/dist/types/node/httpCacheControl.d.ts +2 -0
- package/dist/types/node/index.d.ts +6 -1
- package/dist/types/node/tcoLinks.d.ts +41 -0
- package/dist/types/node/webfingerRouter.d.ts +2 -0
- package/package.json +2 -3
- package/src/__tests__/actorCollectionCounts.test.ts +27 -18
- package/src/__tests__/actorObject.test.ts +27 -7
- package/src/__tests__/actorResolver.test.ts +37 -22
- package/src/__tests__/actorResolverNetworkIdentity.test.ts +73 -36
- package/src/__tests__/apUri.test.ts +9 -5
- package/src/__tests__/delivery.test.ts +165 -25
- package/src/__tests__/federationHost.test.ts +13 -3
- package/src/__tests__/httpSignature.test.ts +1 -3
- package/src/__tests__/inboundDispatch.test.ts +80 -30
- package/src/__tests__/networkIdentity.test.ts +240 -113
- package/src/__tests__/routers.test.ts +157 -27
- package/src/__tests__/tcoLinks.test.ts +21 -0
- package/src/actorObject.ts +8 -3
- package/src/apUri.ts +6 -2
- package/src/httpSignature.ts +1 -1
- package/src/index.ts +8 -3
- package/src/networkIdentity.ts +14 -5
- package/src/node/actorResolver.ts +91 -50
- package/src/node/actorRouter.ts +44 -14
- package/src/node/delivery.ts +112 -30
- package/src/node/httpCacheControl.ts +8 -0
- package/src/node/identityBridge.ts +38 -18
- package/src/node/inboundDispatch.ts +33 -13
- package/src/node/index.ts +13 -0
- package/src/node/signedFetch.ts +3 -1
- package/src/node/tcoLinks.ts +114 -0
- 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(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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<{
|
|
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
|
|
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 {
|
|
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(
|
|
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
|
|
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) => ({
|
|
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
|
-
{
|
|
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(
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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(
|
|
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(
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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<{
|
|
38
|
-
|
|
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: {
|
|
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([
|
|
163
|
+
expect(rig.inboundAcceptedUpserts).toEqual([
|
|
164
|
+
{ localUserId: 'u-alice', remoteActorUri: REMOTE_ACTOR, activityId: 'f1' },
|
|
165
|
+
]);
|
|
149
166
|
expect(rig.acceptsSent).toEqual([
|
|
150
|
-
{
|
|
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
|
-
{
|
|
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([
|
|
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
|
-
{
|
|
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([
|
|
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([
|
|
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(
|
|
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(
|
|
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'])(
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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(
|
|
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(
|
|
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
|
|