@oxy.so/federation 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +16 -0
  3. package/dist/cjs/.tsbuildinfo +1 -0
  4. package/dist/cjs/actorObject.js +216 -0
  5. package/dist/cjs/apContext.js +48 -0
  6. package/dist/cjs/apUri.js +132 -0
  7. package/dist/cjs/httpSignature.js +187 -0
  8. package/dist/cjs/index.js +99 -0
  9. package/dist/cjs/networkIdentity.js +487 -0
  10. package/dist/cjs/node/actorResolver.js +625 -0
  11. package/dist/cjs/node/actorRouter.js +307 -0
  12. package/dist/cjs/node/delivery.js +415 -0
  13. package/dist/cjs/node/identityBridge.js +133 -0
  14. package/dist/cjs/node/inboundDispatch.js +268 -0
  15. package/dist/cjs/node/index.js +63 -0
  16. package/dist/cjs/node/signedFetch.js +122 -0
  17. package/dist/cjs/node/webfingerRouter.js +166 -0
  18. package/dist/cjs/urls.js +55 -0
  19. package/dist/esm/.tsbuildinfo +1 -0
  20. package/dist/esm/actorObject.js +210 -0
  21. package/dist/esm/apContext.js +45 -0
  22. package/dist/esm/apUri.js +126 -0
  23. package/dist/esm/httpSignature.js +179 -0
  24. package/dist/esm/index.js +65 -0
  25. package/dist/esm/networkIdentity.js +472 -0
  26. package/dist/esm/node/actorResolver.js +620 -0
  27. package/dist/esm/node/actorRouter.js +304 -0
  28. package/dist/esm/node/delivery.js +412 -0
  29. package/dist/esm/node/identityBridge.js +130 -0
  30. package/dist/esm/node/inboundDispatch.js +263 -0
  31. package/dist/esm/node/index.js +51 -0
  32. package/dist/esm/node/signedFetch.js +119 -0
  33. package/dist/esm/node/webfingerRouter.js +163 -0
  34. package/dist/esm/urls.js +50 -0
  35. package/dist/types/.tsbuildinfo +1 -0
  36. package/dist/types/actorObject.d.ts +182 -0
  37. package/dist/types/apContext.d.ts +35 -0
  38. package/dist/types/apUri.d.ts +107 -0
  39. package/dist/types/httpSignature.d.ts +113 -0
  40. package/dist/types/index.d.ts +336 -0
  41. package/dist/types/networkIdentity.d.ts +509 -0
  42. package/dist/types/node/actorResolver.d.ts +287 -0
  43. package/dist/types/node/actorRouter.d.ts +108 -0
  44. package/dist/types/node/delivery.d.ts +248 -0
  45. package/dist/types/node/identityBridge.d.ts +84 -0
  46. package/dist/types/node/inboundDispatch.d.ts +156 -0
  47. package/dist/types/node/index.d.ts +51 -0
  48. package/dist/types/node/signedFetch.d.ts +74 -0
  49. package/dist/types/node/webfingerRouter.d.ts +62 -0
  50. package/dist/types/urls.d.ts +55 -0
  51. package/package.json +119 -0
  52. package/src/__tests__/actorObject.test.ts +258 -0
  53. package/src/__tests__/actorResolver.test.ts +252 -0
  54. package/src/__tests__/actorResolverNetworkIdentity.test.ts +297 -0
  55. package/src/__tests__/apUri.test.ts +53 -0
  56. package/src/__tests__/delivery.test.ts +432 -0
  57. package/src/__tests__/federationHost.test.ts +281 -0
  58. package/src/__tests__/httpSignature.test.ts +343 -0
  59. package/src/__tests__/inboundDispatch.test.ts +381 -0
  60. package/src/__tests__/index.test.ts +8 -0
  61. package/src/__tests__/networkIdentity.test.ts +525 -0
  62. package/src/__tests__/routers.test.ts +460 -0
  63. package/src/__tests__/urls.test.ts +26 -0
  64. package/src/actorObject.ts +313 -0
  65. package/src/apContext.ts +45 -0
  66. package/src/apUri.ts +161 -0
  67. package/src/httpSignature.ts +282 -0
  68. package/src/index.ts +419 -0
  69. package/src/networkIdentity.ts +731 -0
  70. package/src/node/actorResolver.ts +839 -0
  71. package/src/node/actorRouter.ts +438 -0
  72. package/src/node/delivery.ts +729 -0
  73. package/src/node/identityBridge.ts +230 -0
  74. package/src/node/inboundDispatch.ts +420 -0
  75. package/src/node/index.ts +136 -0
  76. package/src/node/signedFetch.ts +177 -0
  77. package/src/node/webfingerRouter.ts +226 -0
  78. package/src/urls.ts +71 -0
@@ -0,0 +1,460 @@
1
+ import crypto from 'node:crypto';
2
+ import express, { type Express } from 'express';
3
+ import request from 'supertest';
4
+ import { createDomainPolicy } from '../apUri';
5
+ import { createInboundDispatcher } from '../node/inboundDispatch';
6
+ import { createWebfingerRouter, type WebfingerRouterConfig } from '../node/webfingerRouter';
7
+ import { createActorRouter, type ActorRouterConfig } from '../node/actorRouter';
8
+ import { createUrlBuilders } from '../urls';
9
+ import { signRequest, type HttpSignatureSigner } from '../httpSignature';
10
+
11
+ /**
12
+ * Phase 4b router proof (mount-order tripwire + consent gate + inbox verify).
13
+ *
14
+ * The AP endpoint paths MUST serve 200 DIRECTLY — a 301/302 kills Mastodon's
15
+ * inbox POST deliveries — so this asserts the actor GET + inbox POST resolve
16
+ * without a redirect, that the fediverse-sharing gate 404s an OFF/unknown user
17
+ * indistinguishably, and that a validly-signed inbox POST returns 202 and reaches
18
+ * the dispatcher (proving the HTTP-signature verify wiring end to end).
19
+ */
20
+
21
+ const DOMAIN = 'mention.earth';
22
+ const urls = createUrlBuilders(DOMAIN);
23
+
24
+ // A fixed throwaway RSA keypair for the inbox signature round-trip.
25
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
26
+ const REMOTE_ACTOR = 'https://remote.example/users/bob';
27
+ const REMOTE_KEY_ID = `${REMOTE_ACTOR}#main-key`;
28
+ const REMOTE_PUBLIC_PEM = publicKey.export({ type: 'spki', format: 'pem' }).toString();
29
+
30
+ const sign: HttpSignatureSigner = async (_keyId, signingString) => {
31
+ const s = crypto.createSign('sha256');
32
+ s.update(signingString);
33
+ s.end();
34
+ return s.sign(privateKey, 'base64');
35
+ };
36
+
37
+ function wantsActivityPub(accept: string | string[] | undefined): boolean {
38
+ if (!accept) return false;
39
+ const value = Array.isArray(accept) ? accept.join(',') : accept;
40
+ const lower = value.toLowerCase();
41
+ return lower.includes('activity+json') || lower.includes('ld+json');
42
+ }
43
+
44
+ function makeWebfingerApp(overrides: Partial<WebfingerRouterConfig> = {}): Express {
45
+ const app = express();
46
+ const router = createWebfingerRouter({
47
+ domain: DOMAIN,
48
+ federationEnabled: true,
49
+ urls,
50
+ resolveUser: async (username) => (username === 'alice' ? { _id: 'u-alice' } : null),
51
+ consent: {
52
+ isSharingEnabledFromUser: () => true,
53
+ getSharingStateByUsername: async (username) => (username === 'alice' ? 'enabled' : 'unknown-user'),
54
+ },
55
+ cache: { get: async () => null, set: () => {} },
56
+ logger: { error: () => {} },
57
+ ...overrides,
58
+ });
59
+ app.use('/.well-known', router);
60
+ return app;
61
+ }
62
+
63
+ function makeActorApp(overrides: {
64
+ actorConfig?: Partial<ActorRouterConfig>;
65
+ onDispatch?: (activity: Record<string, unknown>, verifiedActorUri: string) => void;
66
+ } = {}): Express {
67
+ const app = express();
68
+ app.use(
69
+ express.json({
70
+ type: () => true,
71
+ verify: (req, _res, buf) => {
72
+ (req as express.Request & { rawBody?: string }).rawBody = buf.toString('utf8');
73
+ },
74
+ }),
75
+ );
76
+
77
+ const config: ActorRouterConfig = {
78
+ domain: DOMAIN,
79
+ federationEnabled: true,
80
+ apContentType: 'application/activity+json',
81
+ urls,
82
+ wantsActivityPub,
83
+ getPublicKey: async () => ({ keyId: `${urls.actor('alice')}#main-key`, publicKeyPem: 'LOCALPEM' }),
84
+ resolveUser: async (username) => (username === 'alice' ? { _id: 'u-alice', name: { displayName: 'Alice' }, _count: { followers: 3, following: 1 } } : null),
85
+ consent: {
86
+ isSharingEnabledFromUser: () => true,
87
+ getSharingStateByUsername: async (username) => (username === 'alice' ? 'enabled' : 'unknown-user'),
88
+ },
89
+ buildLocalActorObject: (params) => ({ id: urls.actor(params.username), type: 'Person', name: params.displayName }),
90
+ getBanner: async () => null,
91
+ inbound: {
92
+ fetchPublicKey: async (keyId) =>
93
+ keyId === REMOTE_KEY_ID ? { publicKeyPem: REMOTE_PUBLIC_PEM, actorUri: REMOTE_ACTOR } : null,
94
+ trustForwardedHost: true,
95
+ enqueueInboxActivity: async () => false, // force the inline dispatch path
96
+ processInboxActivity: async (activity, verifiedActorUri) => {
97
+ overrides.onDispatch?.(activity, verifiedActorUri);
98
+ },
99
+ },
100
+ fetchFollowPage: async () => ({ members: [], total: 3, hasMore: false }),
101
+ logger: { debug: () => {}, warn: () => {}, error: () => {} },
102
+ ...overrides.actorConfig,
103
+ };
104
+
105
+ app.use('/ap', createActorRouter(config));
106
+ return app;
107
+ }
108
+
109
+ describe('webfinger router', () => {
110
+ it('resolves acct:alice@mention.earth to the actor self link (200)', async () => {
111
+ const res = await request(makeWebfingerApp()).get('/.well-known/webfinger').query({ resource: 'acct:alice@mention.earth' });
112
+ expect(res.status).toBe(200);
113
+ expect(res.body.subject).toBe('acct:alice@mention.earth');
114
+ expect(res.body.links[0]).toEqual({ rel: 'self', type: 'application/activity+json', href: urls.actor('alice') });
115
+ });
116
+
117
+ it('normalizes mixed-case acct local-parts before resolve', async () => {
118
+ const resolveUser = jest.fn(async (username: string) => (username === 'alice' ? { _id: 'u-alice' } : null));
119
+ const app = makeWebfingerApp({ resolveUser });
120
+ const res = await request(app).get('/.well-known/webfinger').query({ resource: 'acct:Alice@mention.earth' });
121
+ expect(res.status).toBe(200);
122
+ expect(resolveUser).toHaveBeenCalledWith('alice');
123
+ expect(res.body.subject).toBe('acct:alice@mention.earth');
124
+ });
125
+
126
+ it('404s an unknown domain', async () => {
127
+ const res = await request(makeWebfingerApp()).get('/.well-known/webfinger').query({ resource: 'acct:alice@other.example' });
128
+ expect(res.status).toBe(404);
129
+ });
130
+
131
+ it('accepts acct domain with www. prefix when config.domain is bare', async () => {
132
+ const res = await request(makeWebfingerApp())
133
+ .get('/.well-known/webfinger')
134
+ .query({ resource: 'acct:alice@www.mention.earth' });
135
+ expect(res.status).toBe(200);
136
+ expect(res.body.links[0].href).toBe(urls.actor('alice'));
137
+ });
138
+
139
+ it('404s a sharing-disabled user indistinguishably', async () => {
140
+ const app = makeWebfingerApp({
141
+ consent: { isSharingEnabledFromUser: () => false, getSharingStateByUsername: async () => 'disabled' },
142
+ });
143
+ const res = await request(app).get('/.well-known/webfinger').query({ resource: 'acct:alice@mention.earth' });
144
+ expect(res.status).toBe(404);
145
+ expect(res.body).toEqual({ error: 'User not found' });
146
+ });
147
+
148
+ it('serves host-meta with the LRDD template (200, no redirect)', async () => {
149
+ const res = await request(makeWebfingerApp()).get('/.well-known/host-meta');
150
+ expect(res.status).toBe(200);
151
+ expect(res.text).toContain('https://mention.earth/.well-known/webfinger?resource={uri}');
152
+ });
153
+ });
154
+
155
+ /**
156
+ * The instance (server) actor MUST be WebFinger-resolvable, not merely servable
157
+ * as an actor.
158
+ *
159
+ * Mastodon's `FetchRemoteKeyService#find_actor` calls `FetchRemoteActorService`
160
+ * WITHOUT `only_key:`, and that service runs `check_webfinger!` unconditionally
161
+ * — so a 404 here raises `Webfinger::Error`, yields a nil account, and every
162
+ * secure-mode instance 401s EVERY signed GET we make (outbox sync, remote actor
163
+ * fetch, quoted/boosted note import). Inbound delivery and outbound POST use the
164
+ * USER key, whose WebFinger does loop back, which is why the outage is silent.
165
+ */
166
+ describe('webfinger router — the instance (server) actor', () => {
167
+ it('resolves acct:instance@mention.earth (200) — it is not an Oxy user', async () => {
168
+ const res = await request(makeWebfingerApp())
169
+ .get('/.well-known/webfinger')
170
+ .query({ resource: 'acct:instance@mention.earth' });
171
+
172
+ expect(res.status).toBe(200);
173
+ expect(res.headers['content-type']).toContain('application/jrd+json');
174
+ expect(res.body.subject).toBe('acct:instance@mention.earth');
175
+ expect(res.body.links).toContainEqual({
176
+ rel: 'self',
177
+ type: 'application/activity+json',
178
+ href: urls.actor('instance'),
179
+ });
180
+ });
181
+
182
+ it("self href EQUALS the actor route's advertised id — the thing Mastodon compares", async () => {
183
+ // Both sides are read off REAL responses from the two routers, never rebuilt
184
+ // here: two separately-constructed strings that happen to match today is
185
+ // exactly the regression this asserts against.
186
+ const webfinger = await request(makeWebfingerApp())
187
+ .get('/.well-known/webfinger')
188
+ .query({ resource: 'acct:instance@mention.earth' });
189
+ const actor = await request(makeActorApp())
190
+ .get('/ap/users/instance')
191
+ .set('Accept', 'application/activity+json');
192
+
193
+ expect(webfinger.status).toBe(200);
194
+ expect(actor.status).toBe(200);
195
+
196
+ const selfLink = webfinger.body.links.find(
197
+ (link: { rel: string }) => link.rel === 'self',
198
+ );
199
+ expect(selfLink).toBeDefined();
200
+ // Positive control: neither side may be vacuously undefined.
201
+ expect(typeof actor.body.id).toBe('string');
202
+ expect(actor.body.id.length).toBeGreaterThan(0);
203
+ expect(selfLink.href).toBe(actor.body.id);
204
+ // ...and the actor really is the server actor, not a Person that happened to match.
205
+ expect(actor.body.type).toBe('Application');
206
+ });
207
+
208
+ it('bypasses resolveUser and the sharing-consent gate entirely', async () => {
209
+ const resolveUser = jest.fn(async () => null);
210
+ const getSharingStateByUsername = jest.fn(async () => 'unknown-user' as const);
211
+ const app = makeWebfingerApp({
212
+ resolveUser,
213
+ consent: { isSharingEnabledFromUser: () => false, getSharingStateByUsername },
214
+ });
215
+
216
+ const res = await request(app)
217
+ .get('/.well-known/webfinger')
218
+ .query({ resource: 'acct:instance@mention.earth' });
219
+
220
+ // Under the pre-fix code this is the live 404: resolveUser asks Oxy for a
221
+ // user named `instance`, gets null, and the route 404s.
222
+ expect(res.status).toBe(200);
223
+ expect(resolveUser).not.toHaveBeenCalled();
224
+ expect(getSharingStateByUsername).not.toHaveBeenCalled();
225
+ });
226
+
227
+ it('does not read or write the app JRD cache for the static instance document', async () => {
228
+ const get = jest.fn(async () => null);
229
+ const set = jest.fn(() => {});
230
+ const res = await request(makeWebfingerApp({ cache: { get, set } }))
231
+ .get('/.well-known/webfinger')
232
+ .query({ resource: 'acct:instance@mention.earth' });
233
+
234
+ expect(res.status).toBe(200);
235
+ expect(get).not.toHaveBeenCalled();
236
+ expect(set).not.toHaveBeenCalled();
237
+ });
238
+
239
+ it.each([
240
+ ['acct:instance@mention.earth', 'bare'],
241
+ ['acct:Instance@mention.earth', 'mixed-case local-part'],
242
+ ['acct:INSTANCE@mention.earth', 'upper-case local-part'],
243
+ ['acct: instance @mention.earth', 'padded local-part'],
244
+ ['acct:instance@www.mention.earth', 'www. host prefix'],
245
+ ['acct:instance@MENTION.EARTH', 'upper-case host'],
246
+ ])('accepts %s (%s) — every spelling the router already handles for users', async (resource) => {
247
+ const res = await request(makeWebfingerApp()).get('/.well-known/webfinger').query({ resource });
248
+ expect(res.status).toBe(200);
249
+ expect(res.body.subject).toBe('acct:instance@mention.earth');
250
+ expect(res.body.links[0].href).toBe(urls.actor('instance'));
251
+ });
252
+
253
+ it('still 404s the instance acct on a foreign domain', async () => {
254
+ const res = await request(makeWebfingerApp())
255
+ .get('/.well-known/webfinger')
256
+ .query({ resource: 'acct:instance@other.example' });
257
+ expect(res.status).toBe(404);
258
+ });
259
+
260
+ /**
261
+ * The floor under every assertion above: an ORDINARY username this instance
262
+ * cannot resolve still 404s, on the right domain, past every early return.
263
+ *
264
+ * Deliberately NOT redundant with the foreign-domain and sharing-disabled
265
+ * cases, and measured rather than assumed. Those two exit BEFORE the resolve —
266
+ * the first on the host compare, the second on a user that resolves and is
267
+ * then gated — so neither one ever reaches `if (!user)`. Until this test
268
+ * existed nothing in the suite did: replacing that 404 with a JRD naming
269
+ * `urls.actor(username)` left all 210 tests green while the router advertised
270
+ * an actor for every name anyone cared to ask about, which is a worse bug than
271
+ * the one the instance branch was added to fix.
272
+ *
273
+ * It must stay INSENSITIVE to the instance branch (deleting the branch leaves
274
+ * this green and turns ten neighbours red) — that asymmetry is what makes it a
275
+ * control rather than another copy of them.
276
+ */
277
+ it('404s an ordinary unresolvable username — the instance branch is not blanket', async () => {
278
+ const resolveUser = jest.fn(async () => null);
279
+ const res = await request(makeWebfingerApp({ resolveUser }))
280
+ .get('/.well-known/webfinger')
281
+ .query({ resource: 'acct:ghost@mention.earth' });
282
+
283
+ expect(res.status).toBe(404);
284
+ expect(res.body).toEqual({ error: 'User not found' });
285
+ // Positive control: the request really did travel past the domain check and
286
+ // the instance branch to the resolve. Without this, a router that 404'd for
287
+ // some earlier reason would satisfy the assertions above and this test would
288
+ // be measuring nothing.
289
+ expect(resolveUser).toHaveBeenCalledWith('ghost');
290
+ });
291
+
292
+ it('404s the instance acct when federation is disabled', async () => {
293
+ const res = await request(makeWebfingerApp({ federationEnabled: false }))
294
+ .get('/.well-known/webfinger')
295
+ .query({ resource: 'acct:instance@mention.earth' });
296
+ expect(res.status).toBe(404);
297
+ });
298
+ });
299
+
300
+ describe('actor router — GET /ap/users/:username', () => {
301
+ it('serves the actor as 200 (NOT a redirect) for an AP Accept', async () => {
302
+ const res = await request(makeActorApp())
303
+ .get('/ap/users/alice')
304
+ .set('Accept', 'application/activity+json');
305
+ expect(res.status).toBe(200);
306
+ expect(res.body['@context']).toBeDefined();
307
+ expect(res.body.id).toBe(urls.actor('alice'));
308
+ expect(res.body.name).toBe('Alice');
309
+ });
310
+
311
+ it('serves the instance actor as 200', async () => {
312
+ const res = await request(makeActorApp())
313
+ .get('/ap/users/instance')
314
+ .set('Accept', 'application/activity+json');
315
+ expect(res.status).toBe(200);
316
+ expect(res.body.type).toBe('Application');
317
+ expect(res.body.id).toBe(urls.actor('instance'));
318
+ });
319
+
320
+ it('redirects a browser (non-AP Accept) to the profile — a GET-only 302', async () => {
321
+ const res = await request(makeActorApp()).get('/ap/users/alice').set('Accept', 'text/html');
322
+ expect(res.status).toBe(302);
323
+ expect(res.headers.location).toBe('https://mention.earth/@alice');
324
+ });
325
+
326
+ it('404s a sharing-disabled/unknown user', async () => {
327
+ const res = await request(makeActorApp())
328
+ .get('/ap/users/ghost')
329
+ .set('Accept', 'application/activity+json');
330
+ expect(res.status).toBe(404);
331
+ });
332
+ });
333
+
334
+ describe('actor router — inbox POST', () => {
335
+ /** Build a validly-signed inbox request body + headers for `activity`. */
336
+ async function signedInbox(path: string, activity: Record<string, unknown>): Promise<{ body: string; headers: Record<string, string> }> {
337
+ const body = JSON.stringify(activity);
338
+ const headers = await signRequest(sign, REMOTE_KEY_ID, 'POST', `https://${DOMAIN}${path}`, body);
339
+ return { body, headers };
340
+ }
341
+
342
+ it('accepts a validly-signed activity (202) and reaches the dispatcher — no redirect', async () => {
343
+ const dispatched: Array<{ activity: Record<string, unknown>; verifiedActorUri: string }> = [];
344
+ const app = makeActorApp({ onDispatch: (activity, verifiedActorUri) => dispatched.push({ activity, verifiedActorUri }) });
345
+
346
+ const activity = { '@context': 'https://www.w3.org/ns/activitystreams', id: 'https://remote.example/f/1', type: 'Follow', actor: REMOTE_ACTOR, object: urls.actor('alice') };
347
+ const { body, headers } = await signedInbox('/ap/inbox', activity);
348
+
349
+ const res = await request(app)
350
+ .post('/ap/inbox')
351
+ .set('Content-Type', 'application/activity+json')
352
+ // The apex is CF-proxied → ALB → backend; Mastodon signs over the apex host
353
+ // and forwards it here (supertest's real Host is localhost).
354
+ .set('X-Forwarded-Host', DOMAIN)
355
+ .set(headers)
356
+ .send(body);
357
+
358
+ expect(res.status).toBe(202);
359
+ expect(dispatched).toHaveLength(1);
360
+ expect(dispatched[0].verifiedActorUri).toBe(REMOTE_ACTOR);
361
+ expect(dispatched[0].activity.type).toBe('Follow');
362
+ });
363
+
364
+ it('accepts a signed POST from a blocked origin (202) but the real dispatcher drops it', async () => {
365
+ const domainPolicy = createDomainPolicy({ domain: DOMAIN, blockedDomains: ['remote.example'] });
366
+ const contentActivities: Array<{ type: unknown; verifiedActorUri: string }> = [];
367
+ const dispatcher = createInboundDispatcher({
368
+ isBlockedDomain: domainPolicy.isBlockedDomain,
369
+ validateActivity: (activity) => {
370
+ const type = typeof activity.type === 'string' ? activity.type : undefined;
371
+ return type ? { ok: true, type } : { ok: false, summary: 'no type' };
372
+ },
373
+ identity: {
374
+ resolveUserByUsername: async () => ({ _id: 'u-alice' }),
375
+ bridgeFollow: async () => {},
376
+ bridgeUnfollow: async () => {},
377
+ },
378
+ consent: { isSharingEnabledFromUser: () => true },
379
+ actorResolver: { getOrFetchActor: async () => ({ oxyUserId: 'u-bob' }) },
380
+ follows: {
381
+ upsertInboundAccepted: async () => {},
382
+ findInboundFollow: async () => null,
383
+ deleteFollowById: async () => {},
384
+ findActorOxyUserId: async () => null,
385
+ markOutboundAcceptedByActivityId: async () => true,
386
+ markOutboundAcceptedAnyPending: async () => true,
387
+ markOutboundRejected: async () => {},
388
+ },
389
+ delivery: { sendAccept: async () => {} },
390
+ onInboundFollowAccepted: async () => {},
391
+ onOutboundFollowAccepted: async () => {},
392
+ onContentActivity: async (activity, verifiedActorUri) => {
393
+ contentActivities.push({ type: activity.type, verifiedActorUri });
394
+ },
395
+ logger: { debug: () => {}, info: () => {}, warn: () => {} },
396
+ });
397
+
398
+ const app = makeActorApp({
399
+ actorConfig: {
400
+ inbound: {
401
+ fetchPublicKey: async (keyId) =>
402
+ keyId === REMOTE_KEY_ID ? { publicKeyPem: REMOTE_PUBLIC_PEM, actorUri: REMOTE_ACTOR } : null,
403
+ trustForwardedHost: true,
404
+ enqueueInboxActivity: async () => false,
405
+ processInboxActivity: dispatcher.processInboxActivity,
406
+ },
407
+ },
408
+ });
409
+
410
+ const activity = {
411
+ '@context': 'https://www.w3.org/ns/activitystreams',
412
+ id: 'https://remote.example/f/blocked',
413
+ type: 'Create',
414
+ actor: REMOTE_ACTOR,
415
+ object: { type: 'Note', content: 'blocked' },
416
+ };
417
+ const { body, headers } = await signedInbox('/ap/inbox', activity);
418
+
419
+ const res = await request(app)
420
+ .post('/ap/inbox')
421
+ .set('Content-Type', 'application/activity+json')
422
+ .set('X-Forwarded-Host', DOMAIN)
423
+ .set(headers)
424
+ .send(body);
425
+
426
+ expect(res.status).toBe(202);
427
+ expect(contentActivities).toHaveLength(0);
428
+ });
429
+
430
+ it('rejects an unsigned inbox POST with 401', async () => {
431
+ const res = await request(makeActorApp())
432
+ .post('/ap/inbox')
433
+ .set('Content-Type', 'application/activity+json')
434
+ .send(JSON.stringify({ type: 'Follow', actor: REMOTE_ACTOR }));
435
+ expect(res.status).toBe(401);
436
+ });
437
+
438
+ it('rejects an actor mismatch with 403', async () => {
439
+ const activity = { id: 'https://remote.example/f/2', type: 'Follow', actor: 'https://evil.example/users/x', object: urls.actor('alice') };
440
+ const { body, headers } = await signedInbox('/ap/inbox', activity);
441
+ const res = await request(makeActorApp())
442
+ .post('/ap/inbox')
443
+ .set('Content-Type', 'application/activity+json')
444
+ .set('X-Forwarded-Host', DOMAIN)
445
+ .set(headers)
446
+ .send(body);
447
+ expect(res.status).toBe(403);
448
+ });
449
+
450
+ it('404s a user inbox for a sharing-disabled user (before signature verify)', async () => {
451
+ const activity = { id: 'https://remote.example/f/3', type: 'Follow', actor: REMOTE_ACTOR, object: urls.actor('ghost') };
452
+ const { body, headers } = await signedInbox('/ap/users/ghost/inbox', activity);
453
+ const res = await request(makeActorApp())
454
+ .post('/ap/users/ghost/inbox')
455
+ .set('Content-Type', 'application/activity+json')
456
+ .set(headers)
457
+ .send(body);
458
+ expect(res.status).toBe(404);
459
+ });
460
+ });
@@ -0,0 +1,26 @@
1
+ import { INSTANCE_ACTOR_USERNAME, createUrlBuilders, normalizeActorUsername } from '../urls';
2
+
3
+ describe('normalizeActorUsername', () => {
4
+ it('trims and lowercases mixed-case usernames', () => {
5
+ expect(normalizeActorUsername(' Alice ')).toBe('alice');
6
+ });
7
+ });
8
+
9
+ describe('INSTANCE_ACTOR_USERNAME', () => {
10
+ // The literal is a WIRE contract: remote instances have already cached
11
+ // `acct:instance@<domain>` and the actor uri built from it. Changing it
12
+ // silently breaks every peer that resolved us before the change.
13
+ it('is the reserved local-part `instance`', () => {
14
+ expect(INSTANCE_ACTOR_USERNAME).toBe('instance');
15
+ });
16
+
17
+ it('survives acct local-part normalization unchanged', () => {
18
+ expect(normalizeActorUsername(INSTANCE_ACTOR_USERNAME)).toBe(INSTANCE_ACTOR_USERNAME);
19
+ });
20
+
21
+ it('builds the server actor uri under the actor domain', () => {
22
+ expect(createUrlBuilders('mention.earth').actor(INSTANCE_ACTOR_USERNAME)).toBe(
23
+ 'https://mention.earth/ap/users/instance',
24
+ );
25
+ });
26
+ });