@oxy.so/federation 2.2.0 → 2.3.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 (54) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/apUri.js +4 -2
  3. package/dist/cjs/httpSignature.js +1 -1
  4. package/dist/cjs/index.js +2 -2
  5. package/dist/cjs/networkIdentity.js +3 -2
  6. package/dist/cjs/node/actorResolver.js +24 -20
  7. package/dist/cjs/node/actorRouter.js +12 -4
  8. package/dist/cjs/node/delivery.js +7 -4
  9. package/dist/cjs/node/inboundDispatch.js +4 -3
  10. package/dist/cjs/node/index.js +7 -1
  11. package/dist/cjs/node/tcoLinks.js +97 -0
  12. package/dist/esm/.tsbuildinfo +1 -1
  13. package/dist/esm/apUri.js +4 -2
  14. package/dist/esm/httpSignature.js +1 -1
  15. package/dist/esm/index.js +3 -3
  16. package/dist/esm/networkIdentity.js +3 -2
  17. package/dist/esm/node/actorResolver.js +24 -20
  18. package/dist/esm/node/actorRouter.js +12 -4
  19. package/dist/esm/node/delivery.js +7 -4
  20. package/dist/esm/node/inboundDispatch.js +4 -3
  21. package/dist/esm/node/index.js +5 -0
  22. package/dist/esm/node/tcoLinks.js +94 -0
  23. package/dist/types/.tsbuildinfo +1 -1
  24. package/dist/types/httpSignature.d.ts +1 -1
  25. package/dist/types/index.d.ts +3 -3
  26. package/dist/types/node/delivery.d.ts +2 -3
  27. package/dist/types/node/index.d.ts +5 -0
  28. package/dist/types/node/tcoLinks.d.ts +41 -0
  29. package/package.json +3 -4
  30. package/src/__tests__/actorCollectionCounts.test.ts +27 -18
  31. package/src/__tests__/actorObject.test.ts +27 -7
  32. package/src/__tests__/actorResolver.test.ts +37 -22
  33. package/src/__tests__/actorResolverNetworkIdentity.test.ts +73 -36
  34. package/src/__tests__/apUri.test.ts +9 -5
  35. package/src/__tests__/delivery.test.ts +56 -24
  36. package/src/__tests__/federationHost.test.ts +13 -3
  37. package/src/__tests__/httpSignature.test.ts +1 -3
  38. package/src/__tests__/inboundDispatch.test.ts +80 -30
  39. package/src/__tests__/networkIdentity.test.ts +240 -113
  40. package/src/__tests__/routers.test.ts +87 -27
  41. package/src/__tests__/tcoLinks.test.ts +21 -0
  42. package/src/actorObject.ts +8 -3
  43. package/src/apUri.ts +6 -2
  44. package/src/httpSignature.ts +1 -1
  45. package/src/index.ts +8 -3
  46. package/src/networkIdentity.ts +14 -5
  47. package/src/node/actorResolver.ts +91 -50
  48. package/src/node/actorRouter.ts +38 -12
  49. package/src/node/delivery.ts +55 -21
  50. package/src/node/identityBridge.ts +38 -18
  51. package/src/node/inboundDispatch.ts +33 -13
  52. package/src/node/index.ts +11 -0
  53. package/src/node/signedFetch.ts +3 -1
  54. package/src/node/tcoLinks.ts +114 -0
@@ -50,7 +50,8 @@ function makeWebfingerApp(overrides: Partial<WebfingerRouterConfig> = {}): Expre
50
50
  resolveUser: async (username) => (username === 'alice' ? { _id: 'u-alice' } : null),
51
51
  consent: {
52
52
  isSharingEnabledFromUser: () => true,
53
- getSharingStateByUsername: async (username) => (username === 'alice' ? 'enabled' : 'unknown-user'),
53
+ getSharingStateByUsername: async (username) =>
54
+ username === 'alice' ? 'enabled' : 'unknown-user',
54
55
  },
55
56
  cache: { get: async () => null, set: () => {} },
56
57
  logger: { error: () => {} },
@@ -60,10 +61,12 @@ function makeWebfingerApp(overrides: Partial<WebfingerRouterConfig> = {}): Expre
60
61
  return app;
61
62
  }
62
63
 
63
- function makeActorApp(overrides: {
64
- actorConfig?: Partial<ActorRouterConfig>;
65
- onDispatch?: (activity: Record<string, unknown>, verifiedActorUri: string) => void;
66
- } = {}): Express {
64
+ function makeActorApp(
65
+ overrides: {
66
+ actorConfig?: Partial<ActorRouterConfig>;
67
+ onDispatch?: (activity: Record<string, unknown>, verifiedActorUri: string) => void;
68
+ } = {},
69
+ ): Express {
67
70
  const app = express();
68
71
  app.use(
69
72
  express.json({
@@ -80,17 +83,30 @@ function makeActorApp(overrides: {
80
83
  apContentType: 'application/activity+json',
81
84
  urls,
82
85
  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),
86
+ getPublicKey: async () => ({
87
+ keyId: `${urls.actor('alice')}#main-key`,
88
+ publicKeyPem: 'LOCALPEM',
89
+ }),
90
+ resolveUser: async (username) =>
91
+ username === 'alice'
92
+ ? { _id: 'u-alice', name: { displayName: 'Alice' }, _count: { followers: 3, following: 1 } }
93
+ : null,
85
94
  consent: {
86
95
  isSharingEnabledFromUser: () => true,
87
- getSharingStateByUsername: async (username) => (username === 'alice' ? 'enabled' : 'unknown-user'),
96
+ getSharingStateByUsername: async (username) =>
97
+ username === 'alice' ? 'enabled' : 'unknown-user',
88
98
  },
89
- buildLocalActorObject: (params) => ({ id: urls.actor(params.username), type: 'Person', name: params.displayName }),
99
+ buildLocalActorObject: (params) => ({
100
+ id: urls.actor(params.username),
101
+ type: 'Person',
102
+ name: params.displayName,
103
+ }),
90
104
  getBanner: async () => null,
91
105
  inbound: {
92
106
  fetchPublicKey: async (keyId) =>
93
- keyId === REMOTE_KEY_ID ? { publicKeyPem: REMOTE_PUBLIC_PEM, actorUri: REMOTE_ACTOR } : null,
107
+ keyId === REMOTE_KEY_ID
108
+ ? { publicKeyPem: REMOTE_PUBLIC_PEM, actorUri: REMOTE_ACTOR }
109
+ : null,
94
110
  trustForwardedHost: true,
95
111
  enqueueInboxActivity: async () => false, // force the inline dispatch path
96
112
  processInboxActivity: async (activity, verifiedActorUri) => {
@@ -108,23 +124,35 @@ function makeActorApp(overrides: {
108
124
 
109
125
  describe('webfinger router', () => {
110
126
  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' });
127
+ const res = await request(makeWebfingerApp())
128
+ .get('/.well-known/webfinger')
129
+ .query({ resource: 'acct:alice@mention.earth' });
112
130
  expect(res.status).toBe(200);
113
131
  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') });
132
+ expect(res.body.links[0]).toEqual({
133
+ rel: 'self',
134
+ type: 'application/activity+json',
135
+ href: urls.actor('alice'),
136
+ });
115
137
  });
116
138
 
117
139
  it('normalizes mixed-case acct local-parts before resolve', async () => {
118
- const resolveUser = jest.fn(async (username: string) => (username === 'alice' ? { _id: 'u-alice' } : null));
140
+ const resolveUser = jest.fn(async (username: string) =>
141
+ username === 'alice' ? { _id: 'u-alice' } : null,
142
+ );
119
143
  const app = makeWebfingerApp({ resolveUser });
120
- const res = await request(app).get('/.well-known/webfinger').query({ resource: 'acct:Alice@mention.earth' });
144
+ const res = await request(app)
145
+ .get('/.well-known/webfinger')
146
+ .query({ resource: 'acct:Alice@mention.earth' });
121
147
  expect(res.status).toBe(200);
122
148
  expect(resolveUser).toHaveBeenCalledWith('alice');
123
149
  expect(res.body.subject).toBe('acct:alice@mention.earth');
124
150
  });
125
151
 
126
152
  it('404s an unknown domain', async () => {
127
- const res = await request(makeWebfingerApp()).get('/.well-known/webfinger').query({ resource: 'acct:alice@other.example' });
153
+ const res = await request(makeWebfingerApp())
154
+ .get('/.well-known/webfinger')
155
+ .query({ resource: 'acct:alice@other.example' });
128
156
  expect(res.status).toBe(404);
129
157
  });
130
158
 
@@ -138,9 +166,14 @@ describe('webfinger router', () => {
138
166
 
139
167
  it('404s a sharing-disabled user indistinguishably', async () => {
140
168
  const app = makeWebfingerApp({
141
- consent: { isSharingEnabledFromUser: () => false, getSharingStateByUsername: async () => 'disabled' },
169
+ consent: {
170
+ isSharingEnabledFromUser: () => false,
171
+ getSharingStateByUsername: async () => 'disabled',
172
+ },
142
173
  });
143
- const res = await request(app).get('/.well-known/webfinger').query({ resource: 'acct:alice@mention.earth' });
174
+ const res = await request(app)
175
+ .get('/.well-known/webfinger')
176
+ .query({ resource: 'acct:alice@mention.earth' });
144
177
  expect(res.status).toBe(404);
145
178
  expect(res.body).toEqual({ error: 'User not found' });
146
179
  });
@@ -193,9 +226,7 @@ describe('webfinger router — the instance (server) actor', () => {
193
226
  expect(webfinger.status).toBe(200);
194
227
  expect(actor.status).toBe(200);
195
228
 
196
- const selfLink = webfinger.body.links.find(
197
- (link: { rel: string }) => link.rel === 'self',
198
- );
229
+ const selfLink = webfinger.body.links.find((link: { rel: string }) => link.rel === 'self');
199
230
  expect(selfLink).toBeDefined();
200
231
  // Positive control: neither side may be vacuously undefined.
201
232
  expect(typeof actor.body.id).toBe('string');
@@ -333,17 +364,34 @@ describe('actor router — GET /ap/users/:username', () => {
333
364
 
334
365
  describe('actor router — inbox POST', () => {
335
366
  /** 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> }> {
367
+ async function signedInbox(
368
+ path: string,
369
+ activity: Record<string, unknown>,
370
+ ): Promise<{ body: string; headers: Record<string, string> }> {
337
371
  const body = JSON.stringify(activity);
338
- const headers = await signRequest(sign, REMOTE_KEY_ID, 'POST', `https://${DOMAIN}${path}`, body);
372
+ const headers = await signRequest(
373
+ sign,
374
+ REMOTE_KEY_ID,
375
+ 'POST',
376
+ `https://${DOMAIN}${path}`,
377
+ body,
378
+ );
339
379
  return { body, headers };
340
380
  }
341
381
 
342
382
  it('accepts a validly-signed activity (202) and reaches the dispatcher — no redirect', async () => {
343
383
  const dispatched: Array<{ activity: Record<string, unknown>; verifiedActorUri: string }> = [];
344
- const app = makeActorApp({ onDispatch: (activity, verifiedActorUri) => dispatched.push({ activity, verifiedActorUri }) });
384
+ const app = makeActorApp({
385
+ onDispatch: (activity, verifiedActorUri) => dispatched.push({ activity, verifiedActorUri }),
386
+ });
345
387
 
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') };
388
+ const activity = {
389
+ '@context': 'https://www.w3.org/ns/activitystreams',
390
+ id: 'https://remote.example/f/1',
391
+ type: 'Follow',
392
+ actor: REMOTE_ACTOR,
393
+ object: urls.actor('alice'),
394
+ };
347
395
  const { body, headers } = await signedInbox('/ap/inbox', activity);
348
396
 
349
397
  const res = await request(app)
@@ -399,7 +447,9 @@ describe('actor router — inbox POST', () => {
399
447
  actorConfig: {
400
448
  inbound: {
401
449
  fetchPublicKey: async (keyId) =>
402
- keyId === REMOTE_KEY_ID ? { publicKeyPem: REMOTE_PUBLIC_PEM, actorUri: REMOTE_ACTOR } : null,
450
+ keyId === REMOTE_KEY_ID
451
+ ? { publicKeyPem: REMOTE_PUBLIC_PEM, actorUri: REMOTE_ACTOR }
452
+ : null,
403
453
  trustForwardedHost: true,
404
454
  enqueueInboxActivity: async () => false,
405
455
  processInboxActivity: dispatcher.processInboxActivity,
@@ -436,7 +486,12 @@ describe('actor router — inbox POST', () => {
436
486
  });
437
487
 
438
488
  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') };
489
+ const activity = {
490
+ id: 'https://remote.example/f/2',
491
+ type: 'Follow',
492
+ actor: 'https://evil.example/users/x',
493
+ object: urls.actor('alice'),
494
+ };
440
495
  const { body, headers } = await signedInbox('/ap/inbox', activity);
441
496
  const res = await request(makeActorApp())
442
497
  .post('/ap/inbox')
@@ -448,7 +503,12 @@ describe('actor router — inbox POST', () => {
448
503
  });
449
504
 
450
505
  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') };
506
+ const activity = {
507
+ id: 'https://remote.example/f/3',
508
+ type: 'Follow',
509
+ actor: REMOTE_ACTOR,
510
+ object: urls.actor('ghost'),
511
+ };
452
512
  const { body, headers } = await signedInbox('/ap/users/ghost/inbox', activity);
453
513
  const res = await request(makeActorApp())
454
514
  .post('/ap/users/ghost/inbox')
@@ -0,0 +1,21 @@
1
+ import { createTcoLinkExpander } from '../node/tcoLinks';
2
+ import type { SingleHopFetch } from '../node/signedFetch';
3
+
4
+ const fetchSingleHop: SingleHopFetch = async (url) => {
5
+ const location = { 'https://t.co/abc123': 'https://example.com/', 'https://t.co/self12': url }[
6
+ url
7
+ ];
8
+ return location
9
+ ? new Response(null, { status: 301, headers: { location } })
10
+ : new Response(null, { status: 404 });
11
+ };
12
+
13
+ it('replaces a t.co wrapper with its target and leaves anything else as written', async () => {
14
+ const expand = createTcoLinkExpander({ fetchSingleHop, userAgent: 'test' });
15
+
16
+ await expect(
17
+ expand('a https://t.co/abc123 b https://t.co/gone12 c https://t.co/self12 d https://bit.ly/x'),
18
+ ).resolves.toBe(
19
+ 'a https://example.com/ b https://t.co/gone12 c https://t.co/self12 d https://bit.ly/x',
20
+ );
21
+ });
@@ -257,7 +257,9 @@ function buildActorIcon(
257
257
  if (!avatar) return undefined;
258
258
  const resolved = config.media.resolveAvatar(avatar);
259
259
  if (!resolved || !isAbsoluteHttpUrl(resolved)) {
260
- config.onWarn?.(`[Federation] Omitting actor icon — avatar did not resolve to an absolute URL (ref: ${avatar})`);
260
+ config.onWarn?.(
261
+ `[Federation] Omitting actor icon — avatar did not resolve to an absolute URL (ref: ${avatar})`,
262
+ );
261
263
  return undefined;
262
264
  }
263
265
  return apImageObject(resolved);
@@ -275,7 +277,9 @@ function buildActorImage(
275
277
  if (!banner) return undefined;
276
278
  const resolved = config.media.resolveBanner(banner);
277
279
  if (!resolved || !isAbsoluteHttpUrl(resolved)) {
278
- config.onWarn?.(`[Federation] Omitting actor image — banner did not resolve to an absolute URL (ref: ${banner})`);
280
+ config.onWarn?.(
281
+ `[Federation] Omitting actor image — banner did not resolve to an absolute URL (ref: ${banner})`,
282
+ );
279
283
  return undefined;
280
284
  }
281
285
  return apImageObject(resolved);
@@ -309,7 +313,8 @@ export function normalizeAlsoKnownAs(values: readonly string[] | null | undefine
309
313
  */
310
314
  export function createLocalActorBuilder(config: LocalActorBuilderConfig): LocalActorBuilder {
311
315
  return (params: BuildLocalActorParams): Record<string, unknown> => {
312
- const { username, displayName, kind, bio, avatar, profileHeaderImage, publicKey, createdAt } = params;
316
+ const { username, displayName, kind, bio, avatar, profileHeaderImage, publicKey, createdAt } =
317
+ params;
313
318
 
314
319
  const actorObject: Record<string, unknown> = {
315
320
  id: config.urls.actor(username),
package/src/apUri.ts CHANGED
@@ -135,7 +135,9 @@ export function createDomainPolicy(config: DomainPolicyConfig): DomainPolicy {
135
135
  canonicalFederationHost(config.domain),
136
136
  canonicalFederationHost(config.actorDomain ?? config.domain),
137
137
  ]);
138
- const identityApex = config.identityApex ? canonicalFederationHost(config.identityApex) : undefined;
138
+ const identityApex = config.identityApex
139
+ ? canonicalFederationHost(config.identityApex)
140
+ : undefined;
139
141
  const blocked = new Set<string>();
140
142
  for (const d of config.blockedDomains ?? []) {
141
143
  blocked.add(canonicalFederationHost(d));
@@ -144,7 +146,9 @@ export function createDomainPolicy(config: DomainPolicyConfig): DomainPolicy {
144
146
  return {
145
147
  isBlockedDomain(domain: string): boolean {
146
148
  const d = canonicalFederationHost(domain);
147
- return localDomains.has(d) || (identityApex !== undefined && d === identityApex) || blocked.has(d);
149
+ return (
150
+ localDomains.has(d) || (identityApex !== undefined && d === identityApex) || blocked.has(d)
151
+ );
148
152
  },
149
153
  extractLocalPostId(objectUri: string): string | null {
150
154
  let parsed: URL;
@@ -16,7 +16,7 @@
16
16
  * Mention runs behind a CF-proxied apex that rewrites the origin `Host`, so it
17
17
  * passes `true`; a directly-exposed origin leaves it `false`.
18
18
  *
19
- * Lives in the isomorphic `.` entry (no Express / Mongoose): it depends only on
19
+ * Lives in the isomorphic `.` entry (no Express / database driver): it depends only on
20
20
  * the runtime `crypto` builtin (Node / Bun) and is never invoked from browser /
21
21
  * React-Native bundles — RN consumers import only the connector TYPES, which are
22
22
  * erased at compile time.
package/src/index.ts CHANGED
@@ -8,8 +8,8 @@
8
8
  * connector produces, the local-event union connectors deliver outbound, and
9
9
  * the connector interface itself.
10
10
  *
11
- * IMPORTANT: this entry is intentionally free of Mongoose / Express / React
12
- * Native so it can be imported from any Oxy app backend (and, in later phases,
11
+ * IMPORTANT: this entry is intentionally free of database drivers / Express /
12
+ * React Native so it can be imported from any Oxy app backend (and, in later phases,
13
13
  * share the pure HTTP-signature + actor-object surface with browser/isomorphic
14
14
  * callers). The runnable Express/Node engine — signed fetch, delivery transport,
15
15
  * webfinger/actor/inbox routers, remote-actor resolution — lives under the
@@ -43,7 +43,12 @@ export {
43
43
  * Domain-parameterized ActivityPub URL builders — each app instantiates them once
44
44
  * with its own `FEDERATION_DOMAIN` so every actor stays `@user@its-own-domain`.
45
45
  */
46
- export { createUrlBuilders, normalizeActorUsername, INSTANCE_ACTOR_USERNAME, type UrlBuilders } from './urls';
46
+ export {
47
+ createUrlBuilders,
48
+ normalizeActorUsername,
49
+ INSTANCE_ACTOR_USERNAME,
50
+ type UrlBuilders,
51
+ } from './urls';
47
52
 
48
53
  /**
49
54
  * Network identity: the MECHANISM for re-labelling an account republished by a
@@ -168,7 +168,8 @@ export function parseUpstreamProfileUrl(
168
168
 
169
169
  const host = canonicalFederationHost(url.hostname);
170
170
  for (const network of networks) {
171
- if (!network.profileHosts.some((allowed) => canonicalFederationHost(allowed) === host)) continue;
171
+ if (!network.profileHosts.some((allowed) => canonicalFederationHost(allowed) === host))
172
+ continue;
172
173
  const handle = profileUrlHandle(url.href, network.profileHosts, network.profilePathPrefix);
173
174
  if (handle !== undefined && handle.length > 0) return { network, handle };
174
175
  }
@@ -457,10 +458,16 @@ export function upstreamHandleFromProfileField(options: {
457
458
  // Observed bird.makeup Official assertion, 2026-09-13:
458
459
  // https://https://twitter.com/jordievole. Repair exactly one duplicated
459
460
  // HTTPS prefix, then run the ordinary host/path/credential validation.
460
- const candidateHref = options.repairRepeatedHttpsScheme && href.startsWith('https://https://')
461
- ? href.slice('https://'.length) : href;
461
+ const candidateHref =
462
+ options.repairRepeatedHttpsScheme && href.startsWith('https://https://')
463
+ ? href.slice('https://'.length)
464
+ : href;
462
465
  let handle: string | undefined;
463
- try { handle = profileUrlHandle(candidateHref, options.hosts, prefix); } catch { continue; }
466
+ try {
467
+ handle = profileUrlHandle(candidateHref, options.hosts, prefix);
468
+ } catch {
469
+ continue;
470
+ }
464
471
  if (handle !== undefined && handle.length > 0) handles.set(handle.toLowerCase(), handle);
465
472
  }
466
473
  }
@@ -681,7 +688,9 @@ export function createBridgeRelabeller(
681
688
  vouchesForNetwork: (actorHost, networkDomain) => {
682
689
  const bridge = findBridge(actorHost);
683
690
  if (!bridge) return false;
684
- return canonicalFederationHost(bridge.network.domain) === canonicalFederationHost(networkDomain);
691
+ return (
692
+ canonicalFederationHost(bridge.network.domain) === canonicalFederationHost(networkDomain)
693
+ );
685
694
  },
686
695
  deriveNetworkIdentity: (candidate) => {
687
696
  const entry = findBridge(candidate.host);