@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.
- package/LICENSE +202 -0
- package/NOTICE +16 -0
- package/dist/cjs/.tsbuildinfo +1 -0
- package/dist/cjs/actorObject.js +216 -0
- package/dist/cjs/apContext.js +48 -0
- package/dist/cjs/apUri.js +132 -0
- package/dist/cjs/httpSignature.js +187 -0
- package/dist/cjs/index.js +99 -0
- package/dist/cjs/networkIdentity.js +487 -0
- package/dist/cjs/node/actorResolver.js +625 -0
- package/dist/cjs/node/actorRouter.js +307 -0
- package/dist/cjs/node/delivery.js +415 -0
- package/dist/cjs/node/identityBridge.js +133 -0
- package/dist/cjs/node/inboundDispatch.js +268 -0
- package/dist/cjs/node/index.js +63 -0
- package/dist/cjs/node/signedFetch.js +122 -0
- package/dist/cjs/node/webfingerRouter.js +166 -0
- package/dist/cjs/urls.js +55 -0
- package/dist/esm/.tsbuildinfo +1 -0
- package/dist/esm/actorObject.js +210 -0
- package/dist/esm/apContext.js +45 -0
- package/dist/esm/apUri.js +126 -0
- package/dist/esm/httpSignature.js +179 -0
- package/dist/esm/index.js +65 -0
- package/dist/esm/networkIdentity.js +472 -0
- package/dist/esm/node/actorResolver.js +620 -0
- package/dist/esm/node/actorRouter.js +304 -0
- package/dist/esm/node/delivery.js +412 -0
- package/dist/esm/node/identityBridge.js +130 -0
- package/dist/esm/node/inboundDispatch.js +263 -0
- package/dist/esm/node/index.js +51 -0
- package/dist/esm/node/signedFetch.js +119 -0
- package/dist/esm/node/webfingerRouter.js +163 -0
- package/dist/esm/urls.js +50 -0
- package/dist/types/.tsbuildinfo +1 -0
- package/dist/types/actorObject.d.ts +182 -0
- package/dist/types/apContext.d.ts +35 -0
- package/dist/types/apUri.d.ts +107 -0
- package/dist/types/httpSignature.d.ts +113 -0
- package/dist/types/index.d.ts +336 -0
- package/dist/types/networkIdentity.d.ts +509 -0
- package/dist/types/node/actorResolver.d.ts +287 -0
- package/dist/types/node/actorRouter.d.ts +108 -0
- package/dist/types/node/delivery.d.ts +248 -0
- package/dist/types/node/identityBridge.d.ts +84 -0
- package/dist/types/node/inboundDispatch.d.ts +156 -0
- package/dist/types/node/index.d.ts +51 -0
- package/dist/types/node/signedFetch.d.ts +74 -0
- package/dist/types/node/webfingerRouter.d.ts +62 -0
- package/dist/types/urls.d.ts +55 -0
- package/package.json +119 -0
- package/src/__tests__/actorObject.test.ts +258 -0
- package/src/__tests__/actorResolver.test.ts +252 -0
- package/src/__tests__/actorResolverNetworkIdentity.test.ts +297 -0
- package/src/__tests__/apUri.test.ts +53 -0
- package/src/__tests__/delivery.test.ts +432 -0
- package/src/__tests__/federationHost.test.ts +281 -0
- package/src/__tests__/httpSignature.test.ts +343 -0
- package/src/__tests__/inboundDispatch.test.ts +381 -0
- package/src/__tests__/index.test.ts +8 -0
- package/src/__tests__/networkIdentity.test.ts +525 -0
- package/src/__tests__/routers.test.ts +460 -0
- package/src/__tests__/urls.test.ts +26 -0
- package/src/actorObject.ts +313 -0
- package/src/apContext.ts +45 -0
- package/src/apUri.ts +161 -0
- package/src/httpSignature.ts +282 -0
- package/src/index.ts +419 -0
- package/src/networkIdentity.ts +731 -0
- package/src/node/actorResolver.ts +839 -0
- package/src/node/actorRouter.ts +438 -0
- package/src/node/delivery.ts +729 -0
- package/src/node/identityBridge.ts +230 -0
- package/src/node/inboundDispatch.ts +420 -0
- package/src/node/index.ts +136 -0
- package/src/node/signedFetch.ts +177 -0
- package/src/node/webfingerRouter.ts +226 -0
- package/src/urls.ts +71 -0
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
import { Readable } from 'node:stream';
|
|
2
|
+
import { createDomainPolicy } from '../apUri';
|
|
3
|
+
import {
|
|
4
|
+
createDeliveryService,
|
|
5
|
+
type DeliveryServiceConfig,
|
|
6
|
+
type DeliveryActorFields,
|
|
7
|
+
type DeliveryQueueJob,
|
|
8
|
+
type DeliverSingleHopResult,
|
|
9
|
+
} from '../node/delivery';
|
|
10
|
+
import { createUrlBuilders } from '../urls';
|
|
11
|
+
import { AP_CONTEXT } from '../apContext';
|
|
12
|
+
import type { HttpSignatureSigner } from '../httpSignature';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Phase 4a delivery + follow-lifecycle proof.
|
|
16
|
+
*
|
|
17
|
+
* The follow-protocol activities (Follow / Accept / Undo(Follow)) and the
|
|
18
|
+
* shared-inbox fan-out dedup are load-bearing bytes on the wire, so this suite
|
|
19
|
+
* locks their exact shape + the `@context` each one carries (a bare AS2 string for
|
|
20
|
+
* Follow; the full quote/poll `AP_CONTEXT` for Undo/Accept/Update) and the
|
|
21
|
+
* dedup/fallback behaviour of `deliverToFollowers`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const DOMAIN = 'mention.earth';
|
|
25
|
+
const urls = createUrlBuilders(DOMAIN);
|
|
26
|
+
|
|
27
|
+
interface StoredActor extends DeliveryActorFields {
|
|
28
|
+
uri: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 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
|
+
} = {}) {
|
|
42
|
+
const deliveredBodies: Array<{ url: string; body: string }> = [];
|
|
43
|
+
const enqueued: DeliveryQueueJob[] = [];
|
|
44
|
+
const fallbackCreates: Array<DeliveryQueueJob & { nextAttemptAt: Date }> = [];
|
|
45
|
+
const fallbackInserts: Array<DeliveryQueueJob & { nextAttemptAt: Date }> = [];
|
|
46
|
+
const upsertedFollows: Array<{ localOxyUserId: string; remoteActorUri: string; activityId: string }> = [];
|
|
47
|
+
const deletedFollowIds: unknown[] = [];
|
|
48
|
+
const refreshedInBackground: string[] = [];
|
|
49
|
+
|
|
50
|
+
const sign: HttpSignatureSigner = async () => 'TESTSIG';
|
|
51
|
+
const domainPolicy =
|
|
52
|
+
overrides.blockedHosts === undefined
|
|
53
|
+
? createDomainPolicy({ domain: DOMAIN })
|
|
54
|
+
: createDomainPolicy({ domain: DOMAIN, blockedDomains: overrides.blockedHosts });
|
|
55
|
+
|
|
56
|
+
const config: DeliveryServiceConfig<StoredActor> = {
|
|
57
|
+
federationEnabled: overrides.federationEnabled ?? true,
|
|
58
|
+
userAgent: 'Mention/mention.earth (ActivityPub)',
|
|
59
|
+
apContentType: 'application/activity+json',
|
|
60
|
+
keys: {
|
|
61
|
+
getPublicKey: async () => ({ keyId: `${urls.actor('alice')}#main-key`, publicKeyPem: 'PEM' }),
|
|
62
|
+
sign,
|
|
63
|
+
},
|
|
64
|
+
urls,
|
|
65
|
+
deliverSingleHop: async (url, init): Promise<DeliverSingleHopResult> => {
|
|
66
|
+
deliveredBodies.push({ url, body: init.body });
|
|
67
|
+
// 202 Accepted — success path (response body destroyed, never read).
|
|
68
|
+
return { response: Readable.from([]) as unknown as DeliverSingleHopResult['response'], status: 202 };
|
|
69
|
+
},
|
|
70
|
+
assertSafeInboxUrl: overrides.assertSafeInboxUrl ?? (async () => ({ ok: true })),
|
|
71
|
+
transport: {
|
|
72
|
+
enqueueDelivery: async (job) => {
|
|
73
|
+
enqueued.push(job);
|
|
74
|
+
return overrides.enqueueReturns ?? true;
|
|
75
|
+
},
|
|
76
|
+
fallbackQueue: {
|
|
77
|
+
create: async (job) => {
|
|
78
|
+
fallbackCreates.push(job);
|
|
79
|
+
return job;
|
|
80
|
+
},
|
|
81
|
+
insertMany: async (jobs) => {
|
|
82
|
+
fallbackInserts.push(...jobs);
|
|
83
|
+
return jobs;
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
store: {
|
|
88
|
+
findActorByUri: async (uri) => overrides.actorsByUri?.[uri] ?? null,
|
|
89
|
+
findActorInboxesByUris: async (uris) =>
|
|
90
|
+
uris
|
|
91
|
+
.map((u) => overrides.followerInboxes?.[u])
|
|
92
|
+
.filter((a): a is { sharedInboxUrl?: string | null; inboxUrl?: string | null } => a !== undefined),
|
|
93
|
+
},
|
|
94
|
+
follows: {
|
|
95
|
+
listAcceptedInboundFollowerActorUris: async () => overrides.followerActorUris ?? [],
|
|
96
|
+
upsertOutboundPending: async (localOxyUserId, remoteActorUri, activityId) => {
|
|
97
|
+
upsertedFollows.push({ localOxyUserId, remoteActorUri, activityId });
|
|
98
|
+
},
|
|
99
|
+
findOutbound: async (_localOxyUserId, remoteActorUri) => {
|
|
100
|
+
const actor = overrides.actorsByUri?.[remoteActorUri];
|
|
101
|
+
return actor ? { _id: actor._id, activityId: `${urls.actor('alice')}/follows/${String(actor._id)}` } : null;
|
|
102
|
+
},
|
|
103
|
+
deleteById: async (id) => {
|
|
104
|
+
deletedFollowIds.push(id);
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
actorRefresh: {
|
|
108
|
+
refreshActorInBackground: (uri) => {
|
|
109
|
+
refreshedInBackground.push(uri);
|
|
110
|
+
},
|
|
111
|
+
fetchRemoteActor: async (uri) => overrides.actorsByUri?.[uri] ?? null,
|
|
112
|
+
},
|
|
113
|
+
consent: { isSharingEnabled: async () => overrides.sharingEnabled ?? true },
|
|
114
|
+
identity: {
|
|
115
|
+
resolveUserByUsername: async () => ({
|
|
116
|
+
name: { displayName: 'Alice' },
|
|
117
|
+
bio: 'hi',
|
|
118
|
+
avatar: 'https://cdn.example/a.png',
|
|
119
|
+
createdAt: '2020-01-01T00:00:00.000Z',
|
|
120
|
+
}),
|
|
121
|
+
},
|
|
122
|
+
profile: { getBanner: async () => 'https://cdn.example/banner.png' },
|
|
123
|
+
buildLocalActorObject: (params) => ({ id: urls.actor(params.username), type: 'Person', name: params.displayName }),
|
|
124
|
+
logger: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
|
|
125
|
+
isBlockedDomain: domainPolicy.isBlockedDomain,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
service: createDeliveryService(config),
|
|
130
|
+
deliveredBodies,
|
|
131
|
+
enqueued,
|
|
132
|
+
fallbackCreates,
|
|
133
|
+
fallbackInserts,
|
|
134
|
+
upsertedFollows,
|
|
135
|
+
deletedFollowIds,
|
|
136
|
+
refreshedInBackground,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Flush pending microtasks so a fire-and-forget `void deliver(...)` runs. */
|
|
141
|
+
const flush = () => new Promise<void>((resolve) => setImmediate(resolve));
|
|
142
|
+
|
|
143
|
+
const RealDate = Date;
|
|
144
|
+
beforeAll(() => {
|
|
145
|
+
const FIXED_MS = RealDate.parse('2026-07-16T12:00:00.000Z');
|
|
146
|
+
class FixedDate extends RealDate {
|
|
147
|
+
constructor(...args: ConstructorParameters<typeof Date>) {
|
|
148
|
+
if (args.length === 0) super(FIXED_MS);
|
|
149
|
+
else super(...args);
|
|
150
|
+
}
|
|
151
|
+
static now(): number {
|
|
152
|
+
return FIXED_MS;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
(globalThis as { Date: typeof Date }).Date = FixedDate as unknown as typeof Date;
|
|
156
|
+
});
|
|
157
|
+
afterAll(() => {
|
|
158
|
+
(globalThis as { Date: typeof Date }).Date = RealDate;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('sendFollow', () => {
|
|
162
|
+
it('delivers the exact Follow activity (bare AS2 @context) and records the outbound follow', async () => {
|
|
163
|
+
const remote = 'https://remote.example/users/bob';
|
|
164
|
+
const rig = makeRig({
|
|
165
|
+
actorsByUri: {
|
|
166
|
+
[remote]: { _id: 'ACTID', uri: remote, inboxUrl: 'https://remote.example/inbox' },
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const result = await rig.service.sendFollow('u-alice', 'alice', remote);
|
|
171
|
+
await flush();
|
|
172
|
+
|
|
173
|
+
expect(result).toEqual({ success: true, pending: false });
|
|
174
|
+
expect(rig.refreshedInBackground).toEqual([remote]);
|
|
175
|
+
expect(rig.upsertedFollows).toEqual([
|
|
176
|
+
{ localOxyUserId: 'u-alice', remoteActorUri: remote, activityId: `${urls.actor('alice')}/follows/ACTID` },
|
|
177
|
+
]);
|
|
178
|
+
// Byte-frozen Follow activity — the bare AS2 context (NOT the full AP_CONTEXT).
|
|
179
|
+
expect(rig.deliveredBodies).toHaveLength(1);
|
|
180
|
+
expect(rig.deliveredBodies[0].url).toBe('https://remote.example/inbox');
|
|
181
|
+
expect(rig.deliveredBodies[0].body).toBe(
|
|
182
|
+
`{"@context":"https://www.w3.org/ns/activitystreams","id":"${urls.actor('alice')}/follows/ACTID","type":"Follow","actor":"${urls.actor('alice')}","object":"${remote}"}`,
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('short-circuits when federation is disabled', async () => {
|
|
187
|
+
const rig = makeRig({ federationEnabled: false });
|
|
188
|
+
const result = await rig.service.sendFollow('u-alice', 'alice', 'https://remote.example/users/bob');
|
|
189
|
+
expect(result).toEqual({ success: false, pending: false });
|
|
190
|
+
expect(rig.deliveredBodies).toHaveLength(0);
|
|
191
|
+
expect(rig.upsertedFollows).toHaveLength(0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('refuses to deliver Follow to a blocked origin even when the inbox is cached', async () => {
|
|
195
|
+
const remote = 'https://blocked.example/users/bob';
|
|
196
|
+
const rig = makeRig({
|
|
197
|
+
blockedHosts: ['blocked.example'],
|
|
198
|
+
actorsByUri: {
|
|
199
|
+
[remote]: { _id: 'ACTID', uri: remote, inboxUrl: 'https://blocked.example/inbox' },
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const result = await rig.service.sendFollow('u-alice', 'alice', remote);
|
|
204
|
+
await flush();
|
|
205
|
+
|
|
206
|
+
expect(result).toEqual({ success: false, pending: false });
|
|
207
|
+
expect(rig.upsertedFollows).toHaveLength(0);
|
|
208
|
+
expect(rig.deliveredBodies).toHaveLength(0);
|
|
209
|
+
expect(rig.enqueued).toHaveLength(0);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe('sendAccept', () => {
|
|
214
|
+
it('delivers the exact Accept(Follow) activity with the full AP_CONTEXT', async () => {
|
|
215
|
+
const remote = 'https://remote.example/users/bob';
|
|
216
|
+
const rig = makeRig({
|
|
217
|
+
actorsByUri: { [remote]: { _id: 'A', uri: remote, inboxUrl: 'https://remote.example/inbox' } },
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await rig.service.sendAccept('u-alice', 'alice', 'https://remote.example/follows/1', remote);
|
|
221
|
+
|
|
222
|
+
expect(rig.deliveredBodies).toHaveLength(1);
|
|
223
|
+
const activity = JSON.parse(rig.deliveredBodies[0].body);
|
|
224
|
+
expect(activity).toEqual({
|
|
225
|
+
'@context': AP_CONTEXT,
|
|
226
|
+
id: `${urls.actor('alice')}/accepts/${Date.now()}`,
|
|
227
|
+
type: 'Accept',
|
|
228
|
+
actor: urls.actor('alice'),
|
|
229
|
+
object: {
|
|
230
|
+
id: 'https://remote.example/follows/1',
|
|
231
|
+
type: 'Follow',
|
|
232
|
+
actor: remote,
|
|
233
|
+
object: urls.actor('alice'),
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('is a no-op when the target actor has no inbox', async () => {
|
|
239
|
+
const remote = 'https://remote.example/users/bob';
|
|
240
|
+
const rig = makeRig({ actorsByUri: { [remote]: { _id: 'A', uri: remote } } });
|
|
241
|
+
await rig.service.sendAccept('u-alice', 'alice', 'f1', remote);
|
|
242
|
+
expect(rig.deliveredBodies).toHaveLength(0);
|
|
243
|
+
expect(rig.enqueued).toHaveLength(0);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('delivers Accept to sharedInboxUrl when inboxUrl is absent', async () => {
|
|
247
|
+
const remote = 'https://remote.example/users/bob';
|
|
248
|
+
const rig = makeRig({
|
|
249
|
+
actorsByUri: {
|
|
250
|
+
[remote]: {
|
|
251
|
+
_id: 'A',
|
|
252
|
+
uri: remote,
|
|
253
|
+
sharedInboxUrl: 'https://remote.example/shared-inbox',
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
await rig.service.sendAccept('u-alice', 'alice', 'https://remote.example/follows/1', remote);
|
|
259
|
+
|
|
260
|
+
expect(rig.deliveredBodies).toHaveLength(1);
|
|
261
|
+
expect(rig.deliveredBodies[0].url).toBe('https://remote.example/shared-inbox');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('skips Accept delivery to a blocked origin', async () => {
|
|
265
|
+
const remote = 'https://blocked.example/users/bob';
|
|
266
|
+
const rig = makeRig({
|
|
267
|
+
blockedHosts: ['blocked.example'],
|
|
268
|
+
actorsByUri: { [remote]: { _id: 'A', uri: remote, inboxUrl: 'https://blocked.example/inbox' } },
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
await rig.service.sendAccept('u-alice', 'alice', 'https://blocked.example/follows/1', remote);
|
|
272
|
+
|
|
273
|
+
expect(rig.deliveredBodies).toHaveLength(0);
|
|
274
|
+
expect(rig.enqueued).toHaveLength(0);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe('sendUndoFollow', () => {
|
|
279
|
+
it('deletes the local follow, then delivers the exact Undo(Follow) with AP_CONTEXT', async () => {
|
|
280
|
+
const remote = 'https://remote.example/users/bob';
|
|
281
|
+
const rig = makeRig({
|
|
282
|
+
actorsByUri: { [remote]: { _id: 'A', uri: remote, inboxUrl: 'https://remote.example/inbox' } },
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const ok = await rig.service.sendUndoFollow('u-alice', 'alice', remote);
|
|
286
|
+
await flush();
|
|
287
|
+
|
|
288
|
+
expect(ok).toBe(true);
|
|
289
|
+
expect(rig.deletedFollowIds).toEqual(['A']);
|
|
290
|
+
expect(rig.deliveredBodies).toHaveLength(1);
|
|
291
|
+
const activity = JSON.parse(rig.deliveredBodies[0].body);
|
|
292
|
+
expect(activity).toEqual({
|
|
293
|
+
'@context': AP_CONTEXT,
|
|
294
|
+
id: `${urls.actor('alice')}/follows/A/undo`,
|
|
295
|
+
type: 'Undo',
|
|
296
|
+
actor: urls.actor('alice'),
|
|
297
|
+
object: {
|
|
298
|
+
id: `${urls.actor('alice')}/follows/A`,
|
|
299
|
+
type: 'Follow',
|
|
300
|
+
actor: urls.actor('alice'),
|
|
301
|
+
object: remote,
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('removes the local follow but skips Undo delivery to a blocked origin', async () => {
|
|
307
|
+
const remote = 'https://blocked.example/users/bob';
|
|
308
|
+
const rig = makeRig({
|
|
309
|
+
blockedHosts: ['blocked.example'],
|
|
310
|
+
actorsByUri: { [remote]: { _id: 'A', uri: remote, inboxUrl: 'https://blocked.example/inbox' } },
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const ok = await rig.service.sendUndoFollow('u-alice', 'alice', remote);
|
|
314
|
+
await flush();
|
|
315
|
+
|
|
316
|
+
expect(ok).toBe(true);
|
|
317
|
+
expect(rig.deletedFollowIds).toEqual(['A']);
|
|
318
|
+
expect(rig.deliveredBodies).toHaveLength(0);
|
|
319
|
+
expect(rig.enqueued).toHaveLength(0);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
describe('deliverToFollowers', () => {
|
|
324
|
+
it('dedupes by shared inbox across followers + extraInboxes', async () => {
|
|
325
|
+
const shared = 'https://shared.example/inbox';
|
|
326
|
+
const rig = makeRig({
|
|
327
|
+
followerActorUris: ['a', 'b', 'c'],
|
|
328
|
+
followerInboxes: {
|
|
329
|
+
a: { sharedInboxUrl: shared },
|
|
330
|
+
b: { sharedInboxUrl: shared }, // same shared inbox → deduped
|
|
331
|
+
c: { inboxUrl: 'https://c.example/inbox' },
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
await rig.service.deliverToFollowers({ id: 'act1', type: 'Create' }, 'u-alice', 'alice', {
|
|
336
|
+
// The first coincides with a follower's shared inbox (deduped); the second is new.
|
|
337
|
+
extraInboxes: [shared, 'https://parent.example/inbox'],
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
expect(rig.enqueued.map((j) => j.targetInbox)).toEqual([
|
|
341
|
+
shared,
|
|
342
|
+
'https://c.example/inbox',
|
|
343
|
+
'https://parent.example/inbox',
|
|
344
|
+
]);
|
|
345
|
+
expect(rig.fallbackInserts).toHaveLength(0);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('falls back to the durable queue when BullMQ is unavailable', async () => {
|
|
349
|
+
const rig = makeRig({
|
|
350
|
+
enqueueReturns: false,
|
|
351
|
+
followerActorUris: ['a'],
|
|
352
|
+
followerInboxes: { a: { inboxUrl: 'https://a.example/inbox' } },
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
await rig.service.deliverToFollowers({ id: 'act1', type: 'Create' }, 'u-alice', 'alice');
|
|
356
|
+
|
|
357
|
+
expect(rig.fallbackInserts.map((j) => j.targetInbox)).toEqual(['https://a.example/inbox']);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('does nothing when there are no inboxes', async () => {
|
|
361
|
+
const rig = makeRig({ followerActorUris: [] });
|
|
362
|
+
await rig.service.deliverToFollowers({ id: 'act1', type: 'Create' }, 'u-alice', 'alice');
|
|
363
|
+
expect(rig.enqueued).toHaveLength(0);
|
|
364
|
+
expect(rig.fallbackInserts).toHaveLength(0);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('skips unsafe inbox URLs instead of enqueueing or falling back', async () => {
|
|
368
|
+
const unsafeGuard = jest.fn(async (url: string) =>
|
|
369
|
+
url.includes('unsafe')
|
|
370
|
+
? { ok: false as const, reason: 'blocked' }
|
|
371
|
+
: { ok: true as const },
|
|
372
|
+
);
|
|
373
|
+
const rig = makeRig({
|
|
374
|
+
followerActorUris: ['a', 'b'],
|
|
375
|
+
followerInboxes: {
|
|
376
|
+
a: { inboxUrl: 'https://unsafe.example/inbox' },
|
|
377
|
+
b: { inboxUrl: 'https://safe.example/inbox' },
|
|
378
|
+
},
|
|
379
|
+
enqueueReturns: false,
|
|
380
|
+
assertSafeInboxUrl: unsafeGuard,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
await rig.service.deliverToFollowers({ id: 'act1', type: 'Create' }, 'u-alice', 'alice');
|
|
384
|
+
|
|
385
|
+
expect(unsafeGuard).toHaveBeenCalledTimes(2);
|
|
386
|
+
expect(rig.enqueued.map((j) => j.targetInbox)).toEqual(['https://safe.example/inbox']);
|
|
387
|
+
expect(rig.fallbackInserts.map((j) => j.targetInbox)).toEqual(['https://safe.example/inbox']);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('skips follower fan-out to blocked inboxes', async () => {
|
|
391
|
+
const rig = makeRig({
|
|
392
|
+
blockedHosts: ['blocked.example'],
|
|
393
|
+
followerActorUris: ['a', 'b'],
|
|
394
|
+
followerInboxes: {
|
|
395
|
+
a: { inboxUrl: 'https://blocked.example/inbox' },
|
|
396
|
+
b: { inboxUrl: 'https://safe.example/inbox' },
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
await rig.service.deliverToFollowers({ id: 'act1', type: 'Create' }, 'u-alice', 'alice');
|
|
401
|
+
|
|
402
|
+
expect(rig.enqueued.map((j) => j.targetInbox)).toEqual(['https://safe.example/inbox']);
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
describe('federateActorUpdate', () => {
|
|
407
|
+
it('rebroadcasts the actor as an Update(Person) with AP_CONTEXT to followers', async () => {
|
|
408
|
+
const shared = 'https://shared.example/inbox';
|
|
409
|
+
const rig = makeRig({
|
|
410
|
+
followerActorUris: ['a'],
|
|
411
|
+
followerInboxes: { a: { sharedInboxUrl: shared } },
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
await rig.service.federateActorUpdate('u-alice', 'alice');
|
|
415
|
+
|
|
416
|
+
expect(rig.enqueued).toHaveLength(1);
|
|
417
|
+
const activity = rig.enqueued[0].activityJson as Record<string, unknown>;
|
|
418
|
+
expect(activity['@context']).toEqual(AP_CONTEXT);
|
|
419
|
+
expect(activity.type).toBe('Update');
|
|
420
|
+
expect(activity.actor).toBe(urls.actor('alice'));
|
|
421
|
+
expect(activity.id).toBe(`${urls.actor('alice')}#updates/${Date.now()}`);
|
|
422
|
+
expect(activity.to).toEqual(['https://www.w3.org/ns/activitystreams#Public']);
|
|
423
|
+
expect(activity.cc).toEqual([`${urls.actor('alice')}/followers`]);
|
|
424
|
+
expect(activity.object).toEqual({ id: urls.actor('alice'), type: 'Person', name: 'Alice' });
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('is gated on the sharing-consent flag', async () => {
|
|
428
|
+
const rig = makeRig({ sharingEnabled: false, followerActorUris: ['a'] });
|
|
429
|
+
await rig.service.federateActorUpdate('u-alice', 'alice');
|
|
430
|
+
expect(rig.enqueued).toHaveLength(0);
|
|
431
|
+
});
|
|
432
|
+
});
|