@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
@@ -24,7 +24,11 @@ import type { NormalizedExternalActor } from '../index';
24
24
  export type ServiceRequestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
25
25
 
26
26
  /** The service-scoped request transport oxy-api calls go through (`{ data }`-unwrapped). */
27
- export type ServiceRequest = <T>(method: ServiceRequestMethod, path: string, body?: unknown) => Promise<T>;
27
+ export type ServiceRequest = <T>(
28
+ method: ServiceRequestMethod,
29
+ path: string,
30
+ body?: unknown,
31
+ ) => Promise<T>;
28
32
 
29
33
  /** Minimal logging sink the identity bridge writes to. */
30
34
  export interface IdentityBridgeLogger {
@@ -183,15 +187,21 @@ export function createIdentityBridge(config: IdentityBridgeConfig): IdentityBrid
183
187
  const httpStatus = getErrorStatus(error);
184
188
  const reason = getErrorMessage(error);
185
189
  if (isPermanentClientError(httpStatus)) {
186
- config.logger.warn(`[Federation] actor-gone report for ${id} rejected (HTTP ${httpStatus}, permanent)`, {
187
- reason,
188
- });
190
+ config.logger.warn(
191
+ `[Federation] actor-gone report for ${id} rejected (HTTP ${httpStatus}, permanent)`,
192
+ {
193
+ reason,
194
+ },
195
+ );
189
196
  return 'skipped';
190
197
  }
191
- config.logger.warn(`[Federation] actor-gone report for ${id} failed transiently; leaving for retry`, {
192
- status: httpStatus,
193
- reason,
194
- });
198
+ config.logger.warn(
199
+ `[Federation] actor-gone report for ${id} failed transiently; leaving for retry`,
200
+ {
201
+ status: httpStatus,
202
+ reason,
203
+ },
204
+ );
195
205
  return 'failed';
196
206
  }
197
207
  },
@@ -201,9 +211,13 @@ export function createIdentityBridge(config: IdentityBridgeConfig): IdentityBrid
201
211
  if (!id) return 'skipped';
202
212
 
203
213
  try {
204
- const data = await config.makeServiceRequest<ActorDeleteResponse>('POST', ACTOR_DELETE_PATH, {
205
- oxyUserId: id,
206
- });
214
+ const data = await config.makeServiceRequest<ActorDeleteResponse>(
215
+ 'POST',
216
+ ACTOR_DELETE_PATH,
217
+ {
218
+ oxyUserId: id,
219
+ },
220
+ );
207
221
  const deleted = data?.deleted === true;
208
222
  config.logger.info(
209
223
  `[Federation] oxy-api ${deleted ? 'hard-deleted' : 'found no'} identity for gone actor ${id}`,
@@ -214,15 +228,21 @@ export function createIdentityBridge(config: IdentityBridgeConfig): IdentityBrid
214
228
  const httpStatus = getErrorStatus(error);
215
229
  const reason = getErrorMessage(error);
216
230
  if (isPermanentClientError(httpStatus)) {
217
- config.logger.warn(`[Federation] actor-delete for ${id} rejected (HTTP ${httpStatus}, permanent)`, {
218
- reason,
219
- });
231
+ config.logger.warn(
232
+ `[Federation] actor-delete for ${id} rejected (HTTP ${httpStatus}, permanent)`,
233
+ {
234
+ reason,
235
+ },
236
+ );
220
237
  return 'skipped';
221
238
  }
222
- config.logger.warn(`[Federation] actor-delete for ${id} failed transiently; leaving for retry`, {
223
- status: httpStatus,
224
- reason,
225
- });
239
+ config.logger.warn(
240
+ `[Federation] actor-delete for ${id} failed transiently; leaving for retry`,
241
+ {
242
+ status: httpStatus,
243
+ reason,
244
+ },
245
+ );
226
246
  return 'failed';
227
247
  }
228
248
  },
@@ -64,9 +64,7 @@ export interface InboundDispatcherLogger {
64
64
  * compact failure summary. The app owns the validation (its zod schemas); the
65
65
  * engine owns the drop-with-warn behaviour so every app logs identically.
66
66
  */
67
- export type InboundActivityValidation =
68
- | { ok: true; type: string }
69
- | { ok: false; summary: string };
67
+ export type InboundActivityValidation = { ok: true; type: string } | { ok: false; summary: string };
70
68
 
71
69
  /** The Oxy-user fields the engine reads off a resolved local user (Follow target). */
72
70
  export interface InboundLocalUser {
@@ -99,9 +97,16 @@ export interface InboundActorResolver {
99
97
  /** Bring-your-own-store: the AP follow records + actor cache reads the follow verbs need. */
100
98
  export interface InboundFollowStore {
101
99
  /** `handleIncomingFollow`: upsert the accepted inbound follow row. */
102
- upsertInboundAccepted(localUserId: string, remoteActorUri: string, activityId: string): Promise<void>;
100
+ upsertInboundAccepted(
101
+ localUserId: string,
102
+ remoteActorUri: string,
103
+ activityId: string,
104
+ ): Promise<void>;
103
105
  /** `handleUndo(Follow)`: the inbound follow row (scoped by localUserId when known). */
104
- findInboundFollow(remoteActorUri: string, localUserId?: string): Promise<{ _id: unknown; localUserId: string } | null>;
106
+ findInboundFollow(
107
+ remoteActorUri: string,
108
+ localUserId?: string,
109
+ ): Promise<{ _id: unknown; localUserId: string } | null>;
105
110
  /** `handleUndo(Follow)`: delete a follow row by id. */
106
111
  deleteFollowById(id: unknown): Promise<void>;
107
112
  /** `handleUndo(Follow)`: the follower actor's cached Oxy user id (for `bridgeUnfollow`). */
@@ -154,7 +159,11 @@ export interface InboundDispatcherConfig {
154
159
  * NEVER throws (it handles its own errors); a failure must not fail (and thus
155
160
  * retry) the inbox activity. Absent ⇒ no notification.
156
161
  */
157
- onInboundFollowAccepted?(localUserId: string, followerOxyUserId: string, actorUri: string): Promise<void>;
162
+ onInboundFollowAccepted?(
163
+ localUserId: string,
164
+ followerOxyUserId: string,
165
+ actorUri: string,
166
+ ): Promise<void>;
158
167
  /**
159
168
  * Best-effort: backfill the newly-followed remote actor's recent posts after an
160
169
  * outbound Follow was Accepted. NEVER throws. Absent ⇒ no backfill.
@@ -248,7 +257,10 @@ function objectTargetUri(object: unknown): string | undefined {
248
257
  export function createInboundDispatcher(config: InboundDispatcherConfig): InboundDispatcher {
249
258
  const { logger } = config;
250
259
 
251
- async function handleIncomingFollow(activity: Record<string, unknown>, actorUri: string): Promise<void> {
260
+ async function handleIncomingFollow(
261
+ activity: Record<string, unknown>,
262
+ actorUri: string,
263
+ ): Promise<void> {
252
264
  const targetActorUri = objectTargetUri(activity.object);
253
265
  if (!targetActorUri) return;
254
266
 
@@ -314,7 +326,10 @@ export function createInboundDispatcher(config: InboundDispatcherConfig): Inboun
314
326
  logger.info(`Accepted follow from ${actorUri} to ${username}`);
315
327
  }
316
328
 
317
- async function handleUndoFollow(object: Record<string, unknown>, actorUri: string): Promise<void> {
329
+ async function handleUndoFollow(
330
+ object: Record<string, unknown>,
331
+ actorUri: string,
332
+ ): Promise<void> {
318
333
  const targetActorUri = objectTargetUri(object.object);
319
334
  const match = targetActorUri?.match(/\/ap\/users\/([^/]+)$/);
320
335
  let localUserId: string | undefined;
@@ -374,9 +389,10 @@ export function createInboundDispatcher(config: InboundDispatcherConfig): Inboun
374
389
  }
375
390
  } else if ((object as { type?: unknown }).type === 'Follow') {
376
391
  const followActivityId = (object as { id?: unknown }).id;
377
- updated = typeof followActivityId === 'string' && followActivityId.length > 0
378
- ? await config.follows.markOutboundAcceptedByActivityId(actorUri, followActivityId)
379
- : await config.follows.markOutboundAcceptedAnyPending(actorUri);
392
+ updated =
393
+ typeof followActivityId === 'string' && followActivityId.length > 0
394
+ ? await config.follows.markOutboundAcceptedByActivityId(actorUri, followActivityId)
395
+ : await config.follows.markOutboundAcceptedAnyPending(actorUri);
380
396
  }
381
397
 
382
398
  if (updated) {
@@ -394,7 +410,8 @@ export function createInboundDispatcher(config: InboundDispatcherConfig): Inboun
394
410
 
395
411
  const objectType = typeof object === 'string' ? null : (object as { type?: unknown }).type;
396
412
  if (objectType === 'Follow') {
397
- const followActivityId = typeof object === 'object' ? (object as { id?: unknown }).id : undefined;
413
+ const followActivityId =
414
+ typeof object === 'object' ? (object as { id?: unknown }).id : undefined;
398
415
  await config.follows.markOutboundRejected(
399
416
  actorUri,
400
417
  typeof followActivityId === 'string' ? followActivityId : undefined,
@@ -403,7 +420,10 @@ export function createInboundDispatcher(config: InboundDispatcherConfig): Inboun
403
420
  }
404
421
  }
405
422
 
406
- async function processInboxActivity(activity: Record<string, unknown>, verifiedActorUri: string): Promise<void> {
423
+ async function processInboxActivity(
424
+ activity: Record<string, unknown>,
425
+ verifiedActorUri: string,
426
+ ): Promise<void> {
407
427
  // Instance domain policy, FIRST — before the payload is even parsed.
408
428
  //
409
429
  // This is the single chokepoint for inbound federation: every transport (the
package/src/node/index.ts CHANGED
@@ -26,6 +26,17 @@ export {
26
26
  type SignedFetchLogger,
27
27
  } from './signedFetch';
28
28
 
29
+ /**
30
+ * X's `t.co` wrapper unwrapped into the link the author posted, over the same
31
+ * injected single-hop transport — for bridged X bios and posts.
32
+ */
33
+ export {
34
+ createTcoLinkExpander,
35
+ type TcoLinkExpander,
36
+ type TcoLinkCache,
37
+ type CreateTcoLinkExpanderConfig,
38
+ } from './tcoLinks';
39
+
29
40
  /**
30
41
  * The actor↔Oxy-user identity bridge — the default implementation of the
31
42
  * `PUT /users/resolve` + actor-gone archive/delete seam over an injected
@@ -156,7 +156,9 @@ export function createSignedFetch(config: CreateSignedFetchConfig): SignedFetch
156
156
  // resources. Retry from the post-redirect URL so we don't restart a chain that
157
157
  // already landed on the failing hop.
158
158
  if (res.status >= 500) {
159
- config.logger?.info(`[FedSync] signedFetch got ${res.status} for ${finalUrl}, retrying unsigned`);
159
+ config.logger?.info(
160
+ `[FedSync] signedFetch got ${res.status} for ${finalUrl}, retrying unsigned`,
161
+ );
160
162
  return fetchFollowingRedirects(finalUrl, false).then(({ res: unsignedRes }) => unsignedRes);
161
163
  }
162
164
 
@@ -0,0 +1,114 @@
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
+ import type { SingleHopFetch } from './signedFetch';
24
+
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: ReadonlySet<number> = 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 = 5_000;
33
+ /** Bounds the requests one text can cost; the rest keep their wrapper. */
34
+ const MAX_LINKS_PER_TEXT = 10;
35
+
36
+ /**
37
+ * A store of resolved wrappers, keyed by the `t.co` id. A wrapper never changes
38
+ * what it points to, so an entry never goes stale. Failures are not cached.
39
+ */
40
+ export interface TcoLinkCache {
41
+ get(id: string): Promise<string | null | undefined>;
42
+ set(id: string, target: string): Promise<void>;
43
+ }
44
+
45
+ /** Adapters a {@link TcoLinkExpander} is built from. */
46
+ export interface CreateTcoLinkExpanderConfig {
47
+ /** SSRF-safe single-hop transport (does NOT follow redirects). */
48
+ fetchSingleHop: SingleHopFetch;
49
+ /** User-Agent presented to t.co. A browser UA gets an HTML page instead of the 301. */
50
+ userAgent: string;
51
+ cache?: TcoLinkCache;
52
+ }
53
+
54
+ /** Replace every `t.co` link in `text` with the URL it redirects to. */
55
+ export type TcoLinkExpander = (text: string) => Promise<string>;
56
+
57
+ /** The `t.co` ids in `text`, in order of first appearance. */
58
+ function findTcoLinkIds(text: string): string[] {
59
+ return [...new Set(Array.from(text.matchAll(TCO_LINK), (match) => match[1]))];
60
+ }
61
+
62
+ /** The redirect target, if it is an http(s) URL that is not another `t.co` wrapper. */
63
+ function readTarget(response: Response, from: string): string | null {
64
+ if (!REDIRECT_STATUS_CODES.has(response.status)) return null;
65
+ const location = response.headers.get('location');
66
+ if (!location) return null;
67
+ let target: URL;
68
+ try {
69
+ target = new URL(location, from);
70
+ } catch {
71
+ return null;
72
+ }
73
+ if (target.protocol !== 'https:' && target.protocol !== 'http:') return null;
74
+ if (target.hostname.toLowerCase() === 't.co') return null;
75
+ return target.href;
76
+ }
77
+
78
+ export function createTcoLinkExpander(config: CreateTcoLinkExpanderConfig): TcoLinkExpander {
79
+ const { cache } = config;
80
+
81
+ async function resolve(id: string): Promise<string | null> {
82
+ const cached = await cache?.get(id).catch(() => null);
83
+ if (cached) return cached;
84
+ const from = `https://t.co/${id}`;
85
+ let target: string | null;
86
+ try {
87
+ const response = await config.fetchSingleHop(from, {
88
+ headers: { Accept: '*/*', 'User-Agent': config.userAgent },
89
+ signal: AbortSignal.timeout(TCO_TIMEOUT_MS),
90
+ headersTimeoutMs: TCO_TIMEOUT_MS,
91
+ });
92
+ target = readTarget(response, from);
93
+ await response.body?.cancel().catch(() => undefined);
94
+ } catch {
95
+ return null;
96
+ }
97
+ if (target) await cache?.set(id, target).catch(() => undefined);
98
+ return target;
99
+ }
100
+
101
+ return async function expandTcoLinks(text: string): Promise<string> {
102
+ const ids = findTcoLinkIds(text).slice(0, MAX_LINKS_PER_TEXT);
103
+ if (ids.length === 0) return text;
104
+ const targets = new Map<string, string>();
105
+ await Promise.all(
106
+ ids.map(async (id) => {
107
+ const target = await resolve(id);
108
+ if (target) targets.set(id, target);
109
+ }),
110
+ );
111
+ if (targets.size === 0) return text;
112
+ return text.replace(TCO_LINK, (whole, id: string) => targets.get(id) ?? whole);
113
+ };
114
+ }