@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
package/dist/cjs/apUri.js CHANGED
@@ -105,7 +105,9 @@ function createDomainPolicy(config) {
105
105
  canonicalFederationHost(config.domain),
106
106
  canonicalFederationHost(config.actorDomain ?? config.domain),
107
107
  ]);
108
- const identityApex = config.identityApex ? canonicalFederationHost(config.identityApex) : undefined;
108
+ const identityApex = config.identityApex
109
+ ? canonicalFederationHost(config.identityApex)
110
+ : undefined;
109
111
  const blocked = new Set();
110
112
  for (const d of config.blockedDomains ?? []) {
111
113
  blocked.add(canonicalFederationHost(d));
@@ -113,7 +115,7 @@ function createDomainPolicy(config) {
113
115
  return {
114
116
  isBlockedDomain(domain) {
115
117
  const d = canonicalFederationHost(domain);
116
- return localDomains.has(d) || (identityApex !== undefined && d === identityApex) || blocked.has(d);
118
+ return (localDomains.has(d) || (identityApex !== undefined && d === identityApex) || blocked.has(d));
117
119
  },
118
120
  extractLocalPostId(objectUri) {
119
121
  let parsed;
@@ -17,7 +17,7 @@
17
17
  * Mention runs behind a CF-proxied apex that rewrites the origin `Host`, so it
18
18
  * passes `true`; a directly-exposed origin leaves it `false`.
19
19
  *
20
- * Lives in the isomorphic `.` entry (no Express / Mongoose): it depends only on
20
+ * Lives in the isomorphic `.` entry (no Express / database driver): it depends only on
21
21
  * the runtime `crypto` builtin (Node / Bun) and is never invoked from browser /
22
22
  * React-Native bundles — RN consumers import only the connector TYPES, which are
23
23
  * erased at compile time.
package/dist/cjs/index.js CHANGED
@@ -9,8 +9,8 @@
9
9
  * connector produces, the local-event union connectors deliver outbound, and
10
10
  * the connector interface itself.
11
11
  *
12
- * IMPORTANT: this entry is intentionally free of Mongoose / Express / React
13
- * Native so it can be imported from any Oxy app backend (and, in later phases,
12
+ * IMPORTANT: this entry is intentionally free of database drivers / Express /
13
+ * React Native so it can be imported from any Oxy app backend (and, in later phases,
14
14
  * share the pure HTTP-signature + actor-object surface with browser/isomorphic
15
15
  * callers). The runnable Express/Node engine — signed fetch, delivery transport,
16
16
  * webfinger/actor/inbox routers, remote-actor resolution — lives under the
@@ -245,7 +245,8 @@ function upstreamHandleFromProfileField(options) {
245
245
  // https://https://twitter.com/jordievole. Repair exactly one duplicated
246
246
  // HTTPS prefix, then run the ordinary host/path/credential validation.
247
247
  const candidateHref = options.repairRepeatedHttpsScheme && href.startsWith('https://https://')
248
- ? href.slice('https://'.length) : href;
248
+ ? href.slice('https://'.length)
249
+ : href;
249
250
  let handle;
250
251
  try {
251
252
  handle = profileUrlHandle(candidateHref, options.hosts, prefix);
@@ -446,7 +447,7 @@ function createBridgeRelabeller(entries) {
446
447
  const bridge = findBridge(actorHost);
447
448
  if (!bridge)
448
449
  return false;
449
- return (0, apUri_1.canonicalFederationHost)(bridge.network.domain) === (0, apUri_1.canonicalFederationHost)(networkDomain);
450
+ return ((0, apUri_1.canonicalFederationHost)(bridge.network.domain) === (0, apUri_1.canonicalFederationHost)(networkDomain));
450
451
  },
451
452
  deriveNetworkIdentity: (candidate) => {
452
453
  const entry = findBridge(candidate.host);
@@ -108,9 +108,9 @@ function sameOriginUrl(a, b) {
108
108
  try {
109
109
  const urlA = new URL(a);
110
110
  const urlB = new URL(b);
111
- return urlA.protocol === urlB.protocol
112
- && urlA.port === urlB.port
113
- && (0, apUri_1.isSameFederationHost)(urlA.hostname, urlB.hostname);
111
+ return (urlA.protocol === urlB.protocol &&
112
+ urlA.port === urlB.port &&
113
+ (0, apUri_1.isSameFederationHost)(urlA.hostname, urlB.hostname));
114
114
  }
115
115
  catch {
116
116
  return false;
@@ -230,8 +230,10 @@ class ActorResolver {
230
230
  // the username-based URI we may have stored.
231
231
  const parsed = new URL(currentUri);
232
232
  const pathUsername = parsed.pathname.split('/').filter(Boolean).pop();
233
- const acct = canonicalAcctHint
234
- || (pathUsername ? this.config.normalizeFederatedAcct(`${pathUsername}@${parsed.hostname}`) : undefined);
233
+ const acct = canonicalAcctHint ||
234
+ (pathUsername
235
+ ? this.config.normalizeFederatedAcct(`${pathUsername}@${parsed.hostname}`)
236
+ : undefined);
235
237
  if (acct) {
236
238
  this.config.logger.info(`[FedSync] attempting WebFinger fallback for ${acct}`);
237
239
  const resolved = await this.resolveWebFinger(acct);
@@ -279,9 +281,9 @@ class ActorResolver {
279
281
  return null;
280
282
  }
281
283
  const actorHost = new URL(actorId).hostname.toLowerCase();
282
- const username = this.config.text.inlineField(actor.preferredUsername)
283
- || this.config.text.inlineField(actor.name)
284
- || 'unknown';
284
+ const username = this.config.text.inlineField(actor.preferredUsername) ||
285
+ this.config.text.inlineField(actor.name) ||
286
+ 'unknown';
285
287
  const actorWebfinger = typeof actor.webfinger === 'string'
286
288
  ? this.config.normalizeFederatedAcct(actor.webfinger)
287
289
  : undefined;
@@ -291,10 +293,10 @@ class ActorResolver {
291
293
  const verifiedActorWebfinger = this.acctMatchesActorHost(actorWebfinger, actorHost)
292
294
  ? actorWebfinger
293
295
  : undefined;
294
- const acct = verifiedAcctHint
295
- || verifiedActorWebfinger
296
- || this.config.normalizeFederatedAcct(`${username}@${actorHost}`)
297
- || `${username.toLowerCase()}@${actorHost}`;
296
+ const acct = verifiedAcctHint ||
297
+ verifiedActorWebfinger ||
298
+ this.config.normalizeFederatedAcct(`${username}@${actorHost}`) ||
299
+ `${username.toLowerCase()}@${actorHost}`;
298
300
  const domain = this.config.domainFromAcct(acct) || actorHost;
299
301
  // Re-check against the RESOLVED host/acct (post-redirect / WebFinger), which
300
302
  // can differ from the originally-requested URI host the early guard screened.
@@ -442,7 +444,9 @@ class ActorResolver {
442
444
  postsCount: postsCount ?? undefined,
443
445
  oxyUserId: fedActor.oxyUserId ?? undefined,
444
446
  };
445
- const oxyId = await this.config.identity.resolveExternalUser(normalized, { forceAvatarRefresh });
447
+ const oxyId = await this.config.identity.resolveExternalUser(normalized, {
448
+ forceAvatarRefresh,
449
+ });
446
450
  if (oxyId && fedActor.oxyUserId !== oxyId && fedActor._id != null) {
447
451
  await this.config.store.setActorOxyUserId(fedActor._id, oxyId);
448
452
  }
@@ -483,11 +487,11 @@ class ActorResolver {
483
487
  // instead of needing its own clause.
484
488
  const atIndex = federatedUsername.indexOf('@');
485
489
  const localPart = atIndex > 0 ? federatedUsername.slice(0, atIndex) : '';
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}"`);
490
+ if (domain.length === 0 ||
491
+ localPart.length === 0 ||
492
+ federatedUsername !== `${localPart}@${domain}`) {
493
+ this.config.logger.warn(`[FedSync] refusing network identity for ${candidate.actorUri}: ` +
494
+ `"${derived.federatedUsername}" is not bindable to domain "${derived.instanceDomain}"`);
491
495
  return undefined;
492
496
  }
493
497
  return { federatedUsername, instanceDomain: domain, bio: derived.bio };
@@ -590,8 +594,8 @@ class ActorResolver {
590
594
  return;
591
595
  const missingProfile = !existing || !existing.avatarUrl || !existing.headerUrl;
592
596
  const lastFetchedMs = existing?.lastFetchedAt?.getTime();
593
- const refreshedRecently = typeof lastFetchedMs === 'number'
594
- && Date.now() - lastFetchedMs < ACTOR_REFRESH_MIN_INTERVAL_MS;
597
+ const refreshedRecently = typeof lastFetchedMs === 'number' &&
598
+ Date.now() - lastFetchedMs < ACTOR_REFRESH_MIN_INTERVAL_MS;
595
599
  // Skip if we refreshed recently AND the cached profile is already complete.
596
600
  if (refreshedRecently && !missingProfile)
597
601
  return;
@@ -64,7 +64,7 @@ function createActorRouter(config) {
64
64
  async function handleInbox(req, res) {
65
65
  try {
66
66
  // Verify HTTP signature (use originalUrl to avoid proxy path mangling).
67
- const { verified, actorUri, reason: signatureError } = await (0, httpSignature_1.verifyHttpSignature)({
67
+ const { verified, actorUri, reason: signatureError, } = await (0, httpSignature_1.verifyHttpSignature)({
68
68
  method: req.method,
69
69
  path: req.originalUrl || req.path,
70
70
  headers: req.headers,
@@ -94,7 +94,10 @@ function createActorRouter(config) {
94
94
  // is never dropped.
95
95
  let enqueued = false;
96
96
  try {
97
- enqueued = await config.inbound.enqueueInboxActivity({ activity, verifiedActorUri: actorUri });
97
+ enqueued = await config.inbound.enqueueInboxActivity({
98
+ activity,
99
+ verifiedActorUri: actorUri,
100
+ });
98
101
  }
99
102
  catch (err) {
100
103
  logger.error('Failed to enqueue inbox activity — processing inline:', err);
@@ -142,7 +145,9 @@ function createActorRouter(config) {
142
145
  }
143
146
  catch (err) {
144
147
  logger.warn('[Federation] follow-collection summary total lookup failed', {
145
- username, direction, error: err,
148
+ username,
149
+ direction,
150
+ error: err,
146
151
  });
147
152
  }
148
153
  }
@@ -169,7 +174,10 @@ function createActorRouter(config) {
169
174
  // Fail-soft: never 500 the whole collection on an Oxy graph hiccup — serve
170
175
  // an empty page against the best-known total rather than crashing.
171
176
  logger.warn('[Federation] follow-collection Oxy graph list failed, serving empty page', {
172
- username, direction, offset, error: err,
177
+ username,
178
+ direction,
179
+ offset,
180
+ error: err,
173
181
  });
174
182
  }
175
183
  const orderedItems = members
@@ -192,7 +192,12 @@ function createDeliveryService(config) {
192
192
  return false;
193
193
  });
194
194
  if (!enqueued) {
195
- durableFallback.push({ activityJson: activity, targetInbox: inbox, senderOxyUserId, nextAttemptAt: now });
195
+ durableFallback.push({
196
+ activityJson: activity,
197
+ targetInbox: inbox,
198
+ senderOxyUserId,
199
+ nextAttemptAt: now,
200
+ });
196
201
  }
197
202
  }
198
203
  if (durableFallback.length > 0) {
@@ -243,9 +248,7 @@ function createDeliveryService(config) {
243
248
  const localActorUri = urls.actor(localUsername);
244
249
  // Use the actor _id when known, otherwise a stable hash of the URI so the
245
250
  // activity ID is deterministic across retries before the actor is cached.
246
- const activityIdSuffix = cached?._id
247
- ? String(cached._id)
248
- : encodeURIComponent(canonicalUri);
251
+ const activityIdSuffix = cached?._id ? String(cached._id) : encodeURIComponent(canonicalUri);
249
252
  const activityId = `${localActorUri}/follows/${activityIdSuffix}`;
250
253
  // Create or update the follow record
251
254
  await config.follows.upsertOutboundPending(localOxyUserId, canonicalUri, activityId);
@@ -222,9 +222,10 @@ function createInboundDispatcher(config) {
222
222
  }
223
223
  else if (object.type === 'Follow') {
224
224
  const followActivityId = object.id;
225
- updated = typeof followActivityId === 'string' && followActivityId.length > 0
226
- ? await config.follows.markOutboundAcceptedByActivityId(actorUri, followActivityId)
227
- : await config.follows.markOutboundAcceptedAnyPending(actorUri);
225
+ updated =
226
+ typeof followActivityId === 'string' && followActivityId.length > 0
227
+ ? await config.follows.markOutboundAcceptedByActivityId(actorUri, followActivityId)
228
+ : await config.follows.markOutboundAcceptedAnyPending(actorUri);
228
229
  }
229
230
  if (updated) {
230
231
  logger.debug(`Follow accepted by ${actorUri}`);
@@ -18,9 +18,15 @@
18
18
  * actor + inbox + follow-graph Express routers.
19
19
  */
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.createActorRouter = exports.createWebfingerRouter = exports.ActorResolutionPendingError = exports.createInboundDispatcher = exports.createDeliveryService = exports.ActorResolver = exports.createActorResolver = exports.createIdentityBridge = exports.createSignedFetch = void 0;
21
+ exports.createActorRouter = exports.createWebfingerRouter = exports.ActorResolutionPendingError = exports.createInboundDispatcher = exports.createDeliveryService = exports.ActorResolver = exports.createActorResolver = exports.createIdentityBridge = exports.createTcoLinkExpander = exports.createSignedFetch = void 0;
22
22
  var signedFetch_1 = require("./signedFetch");
23
23
  Object.defineProperty(exports, "createSignedFetch", { enumerable: true, get: function () { return signedFetch_1.createSignedFetch; } });
24
+ /**
25
+ * X's `t.co` wrapper unwrapped into the link the author posted, over the same
26
+ * injected single-hop transport — for bridged X bios and posts.
27
+ */
28
+ var tcoLinks_1 = require("./tcoLinks");
29
+ Object.defineProperty(exports, "createTcoLinkExpander", { enumerable: true, get: function () { return tcoLinks_1.createTcoLinkExpander; } });
24
30
  /**
25
31
  * The actor↔Oxy-user identity bridge — the default implementation of the
26
32
  * `PUT /users/resolve` + actor-gone archive/delete seam over an injected
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ /**
3
+ * Expanding X's `t.co` link wrapper back into the link the author posted.
4
+ *
5
+ * X rewrites EVERY link in a post or bio into `https://t.co/<id>`, and a bridge
6
+ * that mirrors an X account (bird.makeup, mastox) republishes that text as it
7
+ * is: the actor `summary` and the Note `content` carry only the wrapper. The
8
+ * author never wrote `t.co`; it is X's click tracker standing in for their link,
9
+ * and an X API client never shows it because it reads the expansion beside it.
10
+ * The bridges drop that expansion, so the only place left to recover it is the
11
+ * wrapper's own redirect.
12
+ *
13
+ * Only `t.co` is unwrapped, and only ONE hop. A `bit.ly` the author chose is
14
+ * their link and stays; so does wherever their link redirects next. What is
15
+ * replaced is exactly X's wrapper, by exactly the URL it names.
16
+ *
17
+ * The transport is injected — the same SSRF-safe, IP-pinned single-hop fetch
18
+ * {@link createSignedFetch} takes — so each backend keeps its own network
19
+ * contract. A link that does not resolve (timeout, 404, a malformed or
20
+ * self-referencing `Location`) is left as `t.co`: still a working link, never a
21
+ * broken one.
22
+ */
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.createTcoLinkExpander = createTcoLinkExpander;
25
+ /**
26
+ * A `t.co` link in plain text. X ids are alphanumeric; the lookahead stops a
27
+ * longer token (`t.co/abc/def`, `t.co/abcé`) from being half-replaced.
28
+ */
29
+ const TCO_LINK = /\bhttps?:\/\/t\.co\/([A-Za-z0-9]+)(?![\w/])/gi;
30
+ const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]);
31
+ /** A link that has not answered by then is kept as `t.co`. */
32
+ const TCO_TIMEOUT_MS = 5000;
33
+ /** Bounds the requests one text can cost; the rest keep their wrapper. */
34
+ const MAX_LINKS_PER_TEXT = 10;
35
+ /** The `t.co` ids in `text`, in order of first appearance. */
36
+ function findTcoLinkIds(text) {
37
+ return [...new Set(Array.from(text.matchAll(TCO_LINK), (match) => match[1]))];
38
+ }
39
+ /** The redirect target, if it is an http(s) URL that is not another `t.co` wrapper. */
40
+ function readTarget(response, from) {
41
+ if (!REDIRECT_STATUS_CODES.has(response.status))
42
+ return null;
43
+ const location = response.headers.get('location');
44
+ if (!location)
45
+ return null;
46
+ let target;
47
+ try {
48
+ target = new URL(location, from);
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ if (target.protocol !== 'https:' && target.protocol !== 'http:')
54
+ return null;
55
+ if (target.hostname.toLowerCase() === 't.co')
56
+ return null;
57
+ return target.href;
58
+ }
59
+ function createTcoLinkExpander(config) {
60
+ const { cache } = config;
61
+ async function resolve(id) {
62
+ const cached = await cache?.get(id).catch(() => null);
63
+ if (cached)
64
+ return cached;
65
+ const from = `https://t.co/${id}`;
66
+ let target;
67
+ try {
68
+ const response = await config.fetchSingleHop(from, {
69
+ headers: { Accept: '*/*', 'User-Agent': config.userAgent },
70
+ signal: AbortSignal.timeout(TCO_TIMEOUT_MS),
71
+ headersTimeoutMs: TCO_TIMEOUT_MS,
72
+ });
73
+ target = readTarget(response, from);
74
+ await response.body?.cancel().catch(() => undefined);
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ if (target)
80
+ await cache?.set(id, target).catch(() => undefined);
81
+ return target;
82
+ }
83
+ return async function expandTcoLinks(text) {
84
+ const ids = findTcoLinkIds(text).slice(0, MAX_LINKS_PER_TEXT);
85
+ if (ids.length === 0)
86
+ return text;
87
+ const targets = new Map();
88
+ await Promise.all(ids.map(async (id) => {
89
+ const target = await resolve(id);
90
+ if (target)
91
+ targets.set(id, target);
92
+ }));
93
+ if (targets.size === 0)
94
+ return text;
95
+ return text.replace(TCO_LINK, (whole, id) => targets.get(id) ?? whole);
96
+ };
97
+ }