@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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/apUri.js +4 -2
- package/dist/cjs/httpSignature.js +1 -1
- package/dist/cjs/index.js +2 -2
- package/dist/cjs/networkIdentity.js +3 -2
- package/dist/cjs/node/actorResolver.js +24 -20
- package/dist/cjs/node/actorRouter.js +12 -4
- package/dist/cjs/node/delivery.js +7 -4
- package/dist/cjs/node/inboundDispatch.js +4 -3
- package/dist/cjs/node/index.js +7 -1
- package/dist/cjs/node/tcoLinks.js +97 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/apUri.js +4 -2
- package/dist/esm/httpSignature.js +1 -1
- package/dist/esm/index.js +3 -3
- package/dist/esm/networkIdentity.js +3 -2
- package/dist/esm/node/actorResolver.js +24 -20
- package/dist/esm/node/actorRouter.js +12 -4
- package/dist/esm/node/delivery.js +7 -4
- package/dist/esm/node/inboundDispatch.js +4 -3
- package/dist/esm/node/index.js +5 -0
- package/dist/esm/node/tcoLinks.js +94 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/httpSignature.d.ts +1 -1
- package/dist/types/index.d.ts +3 -3
- package/dist/types/node/delivery.d.ts +2 -3
- package/dist/types/node/index.d.ts +5 -0
- package/dist/types/node/tcoLinks.d.ts +41 -0
- package/package.json +2 -3
- package/src/__tests__/actorCollectionCounts.test.ts +27 -18
- package/src/__tests__/actorObject.test.ts +27 -7
- package/src/__tests__/actorResolver.test.ts +37 -22
- package/src/__tests__/actorResolverNetworkIdentity.test.ts +73 -36
- package/src/__tests__/apUri.test.ts +9 -5
- package/src/__tests__/delivery.test.ts +56 -24
- package/src/__tests__/federationHost.test.ts +13 -3
- package/src/__tests__/httpSignature.test.ts +1 -3
- package/src/__tests__/inboundDispatch.test.ts +80 -30
- package/src/__tests__/networkIdentity.test.ts +240 -113
- package/src/__tests__/routers.test.ts +87 -27
- package/src/__tests__/tcoLinks.test.ts +21 -0
- package/src/actorObject.ts +8 -3
- package/src/apUri.ts +6 -2
- package/src/httpSignature.ts +1 -1
- package/src/index.ts +8 -3
- package/src/networkIdentity.ts +14 -5
- package/src/node/actorResolver.ts +91 -50
- package/src/node/actorRouter.ts +38 -12
- package/src/node/delivery.ts +55 -21
- package/src/node/identityBridge.ts +38 -18
- package/src/node/inboundDispatch.ts +33 -13
- package/src/node/index.ts +11 -0
- package/src/node/signedFetch.ts +3 -1
- 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>(
|
|
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(
|
|
187
|
-
|
|
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(
|
|
192
|
-
|
|
193
|
-
|
|
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>(
|
|
205
|
-
|
|
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(
|
|
218
|
-
|
|
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(
|
|
223
|
-
|
|
224
|
-
|
|
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(
|
|
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(
|
|
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?(
|
|
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(
|
|
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(
|
|
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 =
|
|
378
|
-
|
|
379
|
-
|
|
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 =
|
|
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(
|
|
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
|
package/src/node/signedFetch.ts
CHANGED
|
@@ -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(
|
|
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
|
+
}
|