@oxy.so/federation 2.2.1 → 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 +2 -3
  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
package/dist/esm/apUri.js CHANGED
@@ -99,7 +99,9 @@ export function createDomainPolicy(config) {
99
99
  canonicalFederationHost(config.domain),
100
100
  canonicalFederationHost(config.actorDomain ?? config.domain),
101
101
  ]);
102
- const identityApex = config.identityApex ? canonicalFederationHost(config.identityApex) : undefined;
102
+ const identityApex = config.identityApex
103
+ ? canonicalFederationHost(config.identityApex)
104
+ : undefined;
103
105
  const blocked = new Set();
104
106
  for (const d of config.blockedDomains ?? []) {
105
107
  blocked.add(canonicalFederationHost(d));
@@ -107,7 +109,7 @@ export function createDomainPolicy(config) {
107
109
  return {
108
110
  isBlockedDomain(domain) {
109
111
  const d = canonicalFederationHost(domain);
110
- return localDomains.has(d) || (identityApex !== undefined && d === identityApex) || blocked.has(d);
112
+ return (localDomains.has(d) || (identityApex !== undefined && d === identityApex) || blocked.has(d));
111
113
  },
112
114
  extractLocalPostId(objectUri) {
113
115
  let parsed;
@@ -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/dist/esm/index.js 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
@@ -30,7 +30,7 @@ export { signRequest, verifyHttpSignature, HTTP_SIGNATURE_ALGORITHM, DEFAULT_SIG
30
30
  * Domain-parameterized ActivityPub URL builders — each app instantiates them once
31
31
  * with its own `FEDERATION_DOMAIN` so every actor stays `@user@its-own-domain`.
32
32
  */
33
- export { createUrlBuilders, normalizeActorUsername, INSTANCE_ACTOR_USERNAME } from './urls.js';
33
+ export { createUrlBuilders, normalizeActorUsername, INSTANCE_ACTOR_USERNAME, } from './urls.js';
34
34
  /**
35
35
  * Network identity: the MECHANISM for re-labelling an account republished by a
36
36
  * bridge onto the network it actually came from, plus the network vocabulary and
@@ -230,7 +230,8 @@ export function upstreamHandleFromProfileField(options) {
230
230
  // https://https://twitter.com/jordievole. Repair exactly one duplicated
231
231
  // HTTPS prefix, then run the ordinary host/path/credential validation.
232
232
  const candidateHref = options.repairRepeatedHttpsScheme && href.startsWith('https://https://')
233
- ? href.slice('https://'.length) : href;
233
+ ? href.slice('https://'.length)
234
+ : href;
234
235
  let handle;
235
236
  try {
236
237
  handle = profileUrlHandle(candidateHref, options.hosts, prefix);
@@ -431,7 +432,7 @@ export function createBridgeRelabeller(entries) {
431
432
  const bridge = findBridge(actorHost);
432
433
  if (!bridge)
433
434
  return false;
434
- return canonicalFederationHost(bridge.network.domain) === canonicalFederationHost(networkDomain);
435
+ return (canonicalFederationHost(bridge.network.domain) === canonicalFederationHost(networkDomain));
435
436
  },
436
437
  deriveNetworkIdentity: (candidate) => {
437
438
  const entry = findBridge(candidate.host);
@@ -104,9 +104,9 @@ function sameOriginUrl(a, b) {
104
104
  try {
105
105
  const urlA = new URL(a);
106
106
  const urlB = new URL(b);
107
- return urlA.protocol === urlB.protocol
108
- && urlA.port === urlB.port
109
- && isSameFederationHost(urlA.hostname, urlB.hostname);
107
+ return (urlA.protocol === urlB.protocol &&
108
+ urlA.port === urlB.port &&
109
+ isSameFederationHost(urlA.hostname, urlB.hostname));
110
110
  }
111
111
  catch {
112
112
  return false;
@@ -226,8 +226,10 @@ export class ActorResolver {
226
226
  // the username-based URI we may have stored.
227
227
  const parsed = new URL(currentUri);
228
228
  const pathUsername = parsed.pathname.split('/').filter(Boolean).pop();
229
- const acct = canonicalAcctHint
230
- || (pathUsername ? this.config.normalizeFederatedAcct(`${pathUsername}@${parsed.hostname}`) : undefined);
229
+ const acct = canonicalAcctHint ||
230
+ (pathUsername
231
+ ? this.config.normalizeFederatedAcct(`${pathUsername}@${parsed.hostname}`)
232
+ : undefined);
231
233
  if (acct) {
232
234
  this.config.logger.info(`[FedSync] attempting WebFinger fallback for ${acct}`);
233
235
  const resolved = await this.resolveWebFinger(acct);
@@ -275,9 +277,9 @@ export class ActorResolver {
275
277
  return null;
276
278
  }
277
279
  const actorHost = new URL(actorId).hostname.toLowerCase();
278
- const username = this.config.text.inlineField(actor.preferredUsername)
279
- || this.config.text.inlineField(actor.name)
280
- || 'unknown';
280
+ const username = this.config.text.inlineField(actor.preferredUsername) ||
281
+ this.config.text.inlineField(actor.name) ||
282
+ 'unknown';
281
283
  const actorWebfinger = typeof actor.webfinger === 'string'
282
284
  ? this.config.normalizeFederatedAcct(actor.webfinger)
283
285
  : undefined;
@@ -287,10 +289,10 @@ export class ActorResolver {
287
289
  const verifiedActorWebfinger = this.acctMatchesActorHost(actorWebfinger, actorHost)
288
290
  ? actorWebfinger
289
291
  : undefined;
290
- const acct = verifiedAcctHint
291
- || verifiedActorWebfinger
292
- || this.config.normalizeFederatedAcct(`${username}@${actorHost}`)
293
- || `${username.toLowerCase()}@${actorHost}`;
292
+ const acct = verifiedAcctHint ||
293
+ verifiedActorWebfinger ||
294
+ this.config.normalizeFederatedAcct(`${username}@${actorHost}`) ||
295
+ `${username.toLowerCase()}@${actorHost}`;
294
296
  const domain = this.config.domainFromAcct(acct) || actorHost;
295
297
  // Re-check against the RESOLVED host/acct (post-redirect / WebFinger), which
296
298
  // can differ from the originally-requested URI host the early guard screened.
@@ -438,7 +440,9 @@ export class ActorResolver {
438
440
  postsCount: postsCount ?? undefined,
439
441
  oxyUserId: fedActor.oxyUserId ?? undefined,
440
442
  };
441
- const oxyId = await this.config.identity.resolveExternalUser(normalized, { forceAvatarRefresh });
443
+ const oxyId = await this.config.identity.resolveExternalUser(normalized, {
444
+ forceAvatarRefresh,
445
+ });
442
446
  if (oxyId && fedActor.oxyUserId !== oxyId && fedActor._id != null) {
443
447
  await this.config.store.setActorOxyUserId(fedActor._id, oxyId);
444
448
  }
@@ -479,11 +483,11 @@ export class ActorResolver {
479
483
  // instead of needing its own clause.
480
484
  const atIndex = federatedUsername.indexOf('@');
481
485
  const localPart = atIndex > 0 ? federatedUsername.slice(0, atIndex) : '';
482
- if (domain.length === 0
483
- || localPart.length === 0
484
- || federatedUsername !== `${localPart}@${domain}`) {
485
- this.config.logger.warn(`[FedSync] refusing network identity for ${candidate.actorUri}: `
486
- + `"${derived.federatedUsername}" is not bindable to domain "${derived.instanceDomain}"`);
486
+ if (domain.length === 0 ||
487
+ localPart.length === 0 ||
488
+ federatedUsername !== `${localPart}@${domain}`) {
489
+ this.config.logger.warn(`[FedSync] refusing network identity for ${candidate.actorUri}: ` +
490
+ `"${derived.federatedUsername}" is not bindable to domain "${derived.instanceDomain}"`);
487
491
  return undefined;
488
492
  }
489
493
  return { federatedUsername, instanceDomain: domain, bio: derived.bio };
@@ -586,8 +590,8 @@ export class ActorResolver {
586
590
  return;
587
591
  const missingProfile = !existing || !existing.avatarUrl || !existing.headerUrl;
588
592
  const lastFetchedMs = existing?.lastFetchedAt?.getTime();
589
- const refreshedRecently = typeof lastFetchedMs === 'number'
590
- && Date.now() - lastFetchedMs < ACTOR_REFRESH_MIN_INTERVAL_MS;
593
+ const refreshedRecently = typeof lastFetchedMs === 'number' &&
594
+ Date.now() - lastFetchedMs < ACTOR_REFRESH_MIN_INTERVAL_MS;
591
595
  // Skip if we refreshed recently AND the cached profile is already complete.
592
596
  if (refreshedRecently && !missingProfile)
593
597
  return;
@@ -61,7 +61,7 @@ export function createActorRouter(config) {
61
61
  async function handleInbox(req, res) {
62
62
  try {
63
63
  // Verify HTTP signature (use originalUrl to avoid proxy path mangling).
64
- const { verified, actorUri, reason: signatureError } = await verifyHttpSignature({
64
+ const { verified, actorUri, reason: signatureError, } = await verifyHttpSignature({
65
65
  method: req.method,
66
66
  path: req.originalUrl || req.path,
67
67
  headers: req.headers,
@@ -91,7 +91,10 @@ export function createActorRouter(config) {
91
91
  // is never dropped.
92
92
  let enqueued = false;
93
93
  try {
94
- enqueued = await config.inbound.enqueueInboxActivity({ activity, verifiedActorUri: actorUri });
94
+ enqueued = await config.inbound.enqueueInboxActivity({
95
+ activity,
96
+ verifiedActorUri: actorUri,
97
+ });
95
98
  }
96
99
  catch (err) {
97
100
  logger.error('Failed to enqueue inbox activity — processing inline:', err);
@@ -139,7 +142,9 @@ export function createActorRouter(config) {
139
142
  }
140
143
  catch (err) {
141
144
  logger.warn('[Federation] follow-collection summary total lookup failed', {
142
- username, direction, error: err,
145
+ username,
146
+ direction,
147
+ error: err,
143
148
  });
144
149
  }
145
150
  }
@@ -166,7 +171,10 @@ export function createActorRouter(config) {
166
171
  // Fail-soft: never 500 the whole collection on an Oxy graph hiccup — serve
167
172
  // an empty page against the best-known total rather than crashing.
168
173
  logger.warn('[Federation] follow-collection Oxy graph list failed, serving empty page', {
169
- username, direction, offset, error: err,
174
+ username,
175
+ direction,
176
+ offset,
177
+ error: err,
170
178
  });
171
179
  }
172
180
  const orderedItems = members
@@ -189,7 +189,12 @@ export function createDeliveryService(config) {
189
189
  return false;
190
190
  });
191
191
  if (!enqueued) {
192
- durableFallback.push({ activityJson: activity, targetInbox: inbox, senderOxyUserId, nextAttemptAt: now });
192
+ durableFallback.push({
193
+ activityJson: activity,
194
+ targetInbox: inbox,
195
+ senderOxyUserId,
196
+ nextAttemptAt: now,
197
+ });
193
198
  }
194
199
  }
195
200
  if (durableFallback.length > 0) {
@@ -240,9 +245,7 @@ export function createDeliveryService(config) {
240
245
  const localActorUri = urls.actor(localUsername);
241
246
  // Use the actor _id when known, otherwise a stable hash of the URI so the
242
247
  // activity ID is deterministic across retries before the actor is cached.
243
- const activityIdSuffix = cached?._id
244
- ? String(cached._id)
245
- : encodeURIComponent(canonicalUri);
248
+ const activityIdSuffix = cached?._id ? String(cached._id) : encodeURIComponent(canonicalUri);
246
249
  const activityId = `${localActorUri}/follows/${activityIdSuffix}`;
247
250
  // Create or update the follow record
248
251
  await config.follows.upsertOutboundPending(localOxyUserId, canonicalUri, activityId);
@@ -217,9 +217,10 @@ export function createInboundDispatcher(config) {
217
217
  }
218
218
  else if (object.type === 'Follow') {
219
219
  const followActivityId = object.id;
220
- updated = typeof followActivityId === 'string' && followActivityId.length > 0
221
- ? await config.follows.markOutboundAcceptedByActivityId(actorUri, followActivityId)
222
- : await config.follows.markOutboundAcceptedAnyPending(actorUri);
220
+ updated =
221
+ typeof followActivityId === 'string' && followActivityId.length > 0
222
+ ? await config.follows.markOutboundAcceptedByActivityId(actorUri, followActivityId)
223
+ : await config.follows.markOutboundAcceptedAnyPending(actorUri);
223
224
  }
224
225
  if (updated) {
225
226
  logger.debug(`Follow accepted by ${actorUri}`);
@@ -17,6 +17,11 @@
17
17
  * actor + inbox + follow-graph Express routers.
18
18
  */
19
19
  export { createSignedFetch, } from './signedFetch.js';
20
+ /**
21
+ * X's `t.co` wrapper unwrapped into the link the author posted, over the same
22
+ * injected single-hop transport — for bridged X bios and posts.
23
+ */
24
+ export { createTcoLinkExpander, } from './tcoLinks.js';
20
25
  /**
21
26
  * The actor↔Oxy-user identity bridge — the default implementation of the
22
27
  * `PUT /users/resolve` + actor-gone archive/delete seam over an injected
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Expanding X's `t.co` link wrapper back into the link the author posted.
3
+ *
4
+ * X rewrites EVERY link in a post or bio into `https://t.co/<id>`, and a bridge
5
+ * that mirrors an X account (bird.makeup, mastox) republishes that text as it
6
+ * is: the actor `summary` and the Note `content` carry only the wrapper. The
7
+ * author never wrote `t.co`; it is X's click tracker standing in for their link,
8
+ * and an X API client never shows it because it reads the expansion beside it.
9
+ * The bridges drop that expansion, so the only place left to recover it is the
10
+ * wrapper's own redirect.
11
+ *
12
+ * Only `t.co` is unwrapped, and only ONE hop. A `bit.ly` the author chose is
13
+ * their link and stays; so does wherever their link redirects next. What is
14
+ * replaced is exactly X's wrapper, by exactly the URL it names.
15
+ *
16
+ * The transport is injected — the same SSRF-safe, IP-pinned single-hop fetch
17
+ * {@link createSignedFetch} takes — so each backend keeps its own network
18
+ * contract. A link that does not resolve (timeout, 404, a malformed or
19
+ * self-referencing `Location`) is left as `t.co`: still a working link, never a
20
+ * broken one.
21
+ */
22
+ /**
23
+ * A `t.co` link in plain text. X ids are alphanumeric; the lookahead stops a
24
+ * longer token (`t.co/abc/def`, `t.co/abcé`) from being half-replaced.
25
+ */
26
+ const TCO_LINK = /\bhttps?:\/\/t\.co\/([A-Za-z0-9]+)(?![\w/])/gi;
27
+ const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]);
28
+ /** A link that has not answered by then is kept as `t.co`. */
29
+ const TCO_TIMEOUT_MS = 5000;
30
+ /** Bounds the requests one text can cost; the rest keep their wrapper. */
31
+ const MAX_LINKS_PER_TEXT = 10;
32
+ /** The `t.co` ids in `text`, in order of first appearance. */
33
+ function findTcoLinkIds(text) {
34
+ return [...new Set(Array.from(text.matchAll(TCO_LINK), (match) => match[1]))];
35
+ }
36
+ /** The redirect target, if it is an http(s) URL that is not another `t.co` wrapper. */
37
+ function readTarget(response, from) {
38
+ if (!REDIRECT_STATUS_CODES.has(response.status))
39
+ return null;
40
+ const location = response.headers.get('location');
41
+ if (!location)
42
+ return null;
43
+ let target;
44
+ try {
45
+ target = new URL(location, from);
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ if (target.protocol !== 'https:' && target.protocol !== 'http:')
51
+ return null;
52
+ if (target.hostname.toLowerCase() === 't.co')
53
+ return null;
54
+ return target.href;
55
+ }
56
+ export function createTcoLinkExpander(config) {
57
+ const { cache } = config;
58
+ async function resolve(id) {
59
+ const cached = await cache?.get(id).catch(() => null);
60
+ if (cached)
61
+ return cached;
62
+ const from = `https://t.co/${id}`;
63
+ let target;
64
+ try {
65
+ const response = await config.fetchSingleHop(from, {
66
+ headers: { Accept: '*/*', 'User-Agent': config.userAgent },
67
+ signal: AbortSignal.timeout(TCO_TIMEOUT_MS),
68
+ headersTimeoutMs: TCO_TIMEOUT_MS,
69
+ });
70
+ target = readTarget(response, from);
71
+ await response.body?.cancel().catch(() => undefined);
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ if (target)
77
+ await cache?.set(id, target).catch(() => undefined);
78
+ return target;
79
+ }
80
+ return async function expandTcoLinks(text) {
81
+ const ids = findTcoLinkIds(text).slice(0, MAX_LINKS_PER_TEXT);
82
+ if (ids.length === 0)
83
+ return text;
84
+ const targets = new Map();
85
+ await Promise.all(ids.map(async (id) => {
86
+ const target = await resolve(id);
87
+ if (target)
88
+ targets.set(id, target);
89
+ }));
90
+ if (targets.size === 0)
91
+ return text;
92
+ return text.replace(TCO_LINK, (whole, id) => targets.get(id) ?? whole);
93
+ };
94
+ }