@oxy.so/federation 2.2.1 → 2.4.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/README.md +31 -0
- 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 +16 -6
- package/dist/cjs/node/delivery.js +35 -10
- package/dist/cjs/node/httpCacheControl.js +11 -0
- package/dist/cjs/node/inboundDispatch.js +4 -3
- package/dist/cjs/node/index.js +8 -1
- package/dist/cjs/node/tcoLinks.js +97 -0
- package/dist/cjs/node/webfingerRouter.js +6 -4
- 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 +16 -6
- package/dist/esm/node/delivery.js +33 -10
- package/dist/esm/node/httpCacheControl.js +8 -0
- package/dist/esm/node/inboundDispatch.js +4 -3
- package/dist/esm/node/index.js +6 -1
- package/dist/esm/node/tcoLinks.js +94 -0
- package/dist/esm/node/webfingerRouter.js +6 -4
- 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/actorRouter.d.ts +2 -0
- package/dist/types/node/delivery.d.ts +23 -4
- package/dist/types/node/httpCacheControl.d.ts +2 -0
- package/dist/types/node/index.d.ts +6 -1
- package/dist/types/node/tcoLinks.d.ts +41 -0
- package/dist/types/node/webfingerRouter.d.ts +2 -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 +165 -25
- 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 +157 -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 +44 -14
- package/src/node/delivery.ts +112 -30
- package/src/node/httpCacheControl.ts +8 -0
- package/src/node/identityBridge.ts +38 -18
- package/src/node/inboundDispatch.ts +33 -13
- package/src/node/index.ts +13 -0
- package/src/node/signedFetch.ts +3 -1
- package/src/node/tcoLinks.ts +114 -0
- package/src/node/webfingerRouter.ts +8 -4
package/src/node/delivery.ts
CHANGED
|
@@ -38,6 +38,22 @@ const DELIVERY_RESPONSE_PREVIEW_MAX_BYTES = 1024;
|
|
|
38
38
|
/** The ActivityStreams public collection — the `to` addressee of a public activity. */
|
|
39
39
|
const AP_PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
|
|
40
40
|
|
|
41
|
+
/** Trusted sender metadata supplied by the application or its durable job. */
|
|
42
|
+
export interface DeliveryAuthorizationContext {
|
|
43
|
+
activity: Readonly<Record<string, unknown>>;
|
|
44
|
+
senderAccountId: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A definitive refusal: workers must discard, not retry or count a delivery. */
|
|
48
|
+
export class DeliveryAuthorizationDeniedError extends Error {
|
|
49
|
+
readonly code = 'DELIVERY_AUTHORIZATION_DENIED';
|
|
50
|
+
|
|
51
|
+
constructor() {
|
|
52
|
+
super('Outbound federation delivery is not authorized');
|
|
53
|
+
this.name = 'DeliveryAuthorizationDeniedError';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
41
57
|
/** Minimal logging sink the delivery service writes to. */
|
|
42
58
|
export interface DeliveryLogger {
|
|
43
59
|
debug(message: string, detail?: unknown): void;
|
|
@@ -81,7 +97,10 @@ export interface DeliverSingleHopInit {
|
|
|
81
97
|
* response WITHOUT following redirects. Mention adapts its `@oxy.so/core/server`-
|
|
82
98
|
* backed `fetchUpstreamSingleHop` into this shape.
|
|
83
99
|
*/
|
|
84
|
-
export type DeliverSingleHop = (
|
|
100
|
+
export type DeliverSingleHop = (
|
|
101
|
+
url: string,
|
|
102
|
+
init: DeliverSingleHopInit,
|
|
103
|
+
) => Promise<DeliverSingleHopResult>;
|
|
85
104
|
|
|
86
105
|
/** A durable-delivery job body (BullMQ + the fallback queue share this shape). */
|
|
87
106
|
export interface DeliveryQueueJob {
|
|
@@ -94,9 +113,8 @@ export interface DeliveryQueueJob {
|
|
|
94
113
|
* The durable-delivery fallback (written when BullMQ is unavailable).
|
|
95
114
|
*
|
|
96
115
|
* App-supplied, like every other store in this package: the engine never names
|
|
97
|
-
* a database. Mention backs it with Postgres; the method names below are
|
|
98
|
-
*
|
|
99
|
-
* adapter shape stays stable for consumers.
|
|
116
|
+
* a database. Mention backs it with Postgres; the method names below are kept
|
|
117
|
+
* stable so the adapter shape stays stable for consumers.
|
|
100
118
|
*/
|
|
101
119
|
export interface DeliveryFallbackQueue {
|
|
102
120
|
/** Insert one fallback delivery row (`queueDelivery`). */
|
|
@@ -134,7 +152,9 @@ export interface DeliveryActorStore<TActor extends DeliveryActorFields> {
|
|
|
134
152
|
/** One cached actor by uri (`resolveActorInbox` / `sendFollow` / `sendUndoFollow` / `sendAccept`). */
|
|
135
153
|
findActorByUri(uri: string): Promise<TActor | null>;
|
|
136
154
|
/** Inbox fields for many actor uris (`deliverToFollowers`, step 2). */
|
|
137
|
-
findActorInboxesByUris(
|
|
155
|
+
findActorInboxesByUris(
|
|
156
|
+
uris: string[],
|
|
157
|
+
): Promise<Array<Pick<DeliveryActorFields, 'sharedInboxUrl' | 'inboxUrl'>>>;
|
|
138
158
|
}
|
|
139
159
|
|
|
140
160
|
/** Bring-your-own-store: the AP follow records stay in the app DB behind this adapter. */
|
|
@@ -142,9 +162,16 @@ export interface DeliveryFollowStore {
|
|
|
142
162
|
/** Accepted inbound followers' remote actor uris (`deliverToFollowers`, step 1). */
|
|
143
163
|
listAcceptedInboundFollowerActorUris(localOxyUserId: string): Promise<string[]>;
|
|
144
164
|
/** Upsert an outbound pending follow with its activity id (`sendFollow`). */
|
|
145
|
-
upsertOutboundPending(
|
|
165
|
+
upsertOutboundPending(
|
|
166
|
+
localOxyUserId: string,
|
|
167
|
+
remoteActorUri: string,
|
|
168
|
+
activityId: string,
|
|
169
|
+
): Promise<void>;
|
|
146
170
|
/** The outbound follow row for `(localOxyUserId, remoteActorUri)` (`sendUndoFollow`). */
|
|
147
|
-
findOutbound(
|
|
171
|
+
findOutbound(
|
|
172
|
+
localOxyUserId: string,
|
|
173
|
+
remoteActorUri: string,
|
|
174
|
+
): Promise<{ _id: unknown; activityId?: string } | null>;
|
|
148
175
|
/** Delete a follow row by id (`sendUndoFollow`). */
|
|
149
176
|
deleteById(id: unknown): Promise<void>;
|
|
150
177
|
}
|
|
@@ -217,6 +244,13 @@ export interface DeliveryServiceConfig<TActor extends DeliveryActorFields> {
|
|
|
217
244
|
deliverSingleHop: DeliverSingleHop;
|
|
218
245
|
/** SSRF pre-check for a durable inbox enqueue (never queue a delivery to an unsafe URL). */
|
|
219
246
|
assertSafeInboxUrl(url: string): Promise<SafeUrlVerdict>;
|
|
247
|
+
/**
|
|
248
|
+
* Optional live policy check. False throws DeliveryAuthorizationDeniedError before
|
|
249
|
+
* signing or enqueueing. A thrown authority error propagates unchanged so a
|
|
250
|
+
* durable worker can retry it. Checked once per fanout, then again at delivery.
|
|
251
|
+
* Never derive the sender from the activity actor URL or its signing key.
|
|
252
|
+
*/
|
|
253
|
+
authorizeDelivery?(context: DeliveryAuthorizationContext): Promise<boolean>;
|
|
220
254
|
/** BullMQ enqueue + the durable fallback queue. */
|
|
221
255
|
transport: DeliveryTransport;
|
|
222
256
|
/** The AP actor cache store. */
|
|
@@ -295,7 +329,10 @@ export interface DeliveryService {
|
|
|
295
329
|
localUsername: string,
|
|
296
330
|
remoteActorUri: string,
|
|
297
331
|
): Promise<{ success: boolean; pending: boolean }>;
|
|
298
|
-
/**
|
|
332
|
+
/**
|
|
333
|
+
* Remove the local follow and attempt Undo delivery. True means the local
|
|
334
|
+
* relationship was removed, never that remote delivery succeeded.
|
|
335
|
+
*/
|
|
299
336
|
sendUndoFollow(
|
|
300
337
|
localOxyUserId: string,
|
|
301
338
|
localUsername: string,
|
|
@@ -333,11 +370,32 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
|
|
|
333
370
|
return host === null || config.isBlockedDomain(host);
|
|
334
371
|
}
|
|
335
372
|
|
|
373
|
+
async function authorizeDelivery(
|
|
374
|
+
activity: Record<string, unknown>,
|
|
375
|
+
senderAccountId: string,
|
|
376
|
+
): Promise<void> {
|
|
377
|
+
if (
|
|
378
|
+
config.authorizeDelivery &&
|
|
379
|
+
!(await config.authorizeDelivery({ activity, senderAccountId }))
|
|
380
|
+
) {
|
|
381
|
+
throw new DeliveryAuthorizationDeniedError();
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
336
385
|
async function deliverActivity(
|
|
337
386
|
activity: Record<string, unknown>,
|
|
338
387
|
targetInbox: string,
|
|
339
388
|
senderOxyUserId: string,
|
|
340
389
|
senderUsername: string,
|
|
390
|
+
): Promise<boolean> {
|
|
391
|
+
await authorizeDelivery(activity, senderOxyUserId);
|
|
392
|
+
return deliverAuthorizedActivity(activity, targetInbox, senderUsername);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function deliverAuthorizedActivity(
|
|
396
|
+
activity: Record<string, unknown>,
|
|
397
|
+
targetInbox: string,
|
|
398
|
+
senderUsername: string,
|
|
341
399
|
): Promise<boolean> {
|
|
342
400
|
if (isBlockedUrl(targetInbox)) {
|
|
343
401
|
logger.warn(`[FedDeliver] refusing outbound delivery to blocked inbox ${targetInbox}`);
|
|
@@ -356,7 +414,9 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
|
|
|
356
414
|
...sigHeaders,
|
|
357
415
|
};
|
|
358
416
|
|
|
359
|
-
logger.debug(
|
|
417
|
+
logger.debug(
|
|
418
|
+
`[FedDeliver] POST ${targetInbox} body=${body} sig-headers=${sigHeaders.Signature?.match(/headers="([^"]+)"/)?.[1]}`,
|
|
419
|
+
);
|
|
360
420
|
|
|
361
421
|
const { response, status } = await config.deliverSingleHop(targetInbox, {
|
|
362
422
|
method: 'POST',
|
|
@@ -372,7 +432,9 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
|
|
|
372
432
|
}
|
|
373
433
|
|
|
374
434
|
const responseBody = await readResponsePreview(response);
|
|
375
|
-
logger.debug(
|
|
435
|
+
logger.debug(
|
|
436
|
+
`Activity delivery failed to ${targetInbox}: ${status} body=${responseBody.slice(0, 500)}`,
|
|
437
|
+
);
|
|
376
438
|
return false;
|
|
377
439
|
} catch (err) {
|
|
378
440
|
logger.debug(`Activity delivery error to ${targetInbox}:`, err);
|
|
@@ -385,6 +447,7 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
|
|
|
385
447
|
targetInbox: string,
|
|
386
448
|
senderOxyUserId: string,
|
|
387
449
|
): Promise<void> {
|
|
450
|
+
await authorizeDelivery(activity, senderOxyUserId);
|
|
388
451
|
if (isBlockedUrl(targetInbox)) {
|
|
389
452
|
logger.warn(`[FedDeliver] not queueing delivery to blocked inbox ${targetInbox}`);
|
|
390
453
|
return;
|
|
@@ -402,7 +465,9 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
|
|
|
402
465
|
.enqueueDelivery({ activityJson: activity, targetInbox, senderOxyUserId })
|
|
403
466
|
.catch((err) => {
|
|
404
467
|
const message = err instanceof Error ? err.message : String(err);
|
|
405
|
-
logger.warn(
|
|
468
|
+
logger.warn(
|
|
469
|
+
`[FedDeliver] enqueue failed for ${targetInbox}, falling back to the durable queue: ${message}`,
|
|
470
|
+
);
|
|
406
471
|
return false;
|
|
407
472
|
});
|
|
408
473
|
|
|
@@ -429,6 +494,7 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
|
|
|
429
494
|
senderUsername: string,
|
|
430
495
|
options: { extraInboxes?: string[] } = {},
|
|
431
496
|
): Promise<void> {
|
|
497
|
+
await authorizeDelivery(activity, senderOxyUserId);
|
|
432
498
|
const actorUris = await config.follows.listAcceptedInboundFollowerActorUris(senderOxyUserId);
|
|
433
499
|
const actors = actorUris.length > 0 ? await config.store.findActorInboxesByUris(actorUris) : [];
|
|
434
500
|
|
|
@@ -473,12 +539,19 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
|
|
|
473
539
|
.enqueueDelivery({ activityJson: activity, targetInbox: inbox, senderOxyUserId })
|
|
474
540
|
.catch((err) => {
|
|
475
541
|
const message = err instanceof Error ? err.message : String(err);
|
|
476
|
-
logger.warn(
|
|
542
|
+
logger.warn(
|
|
543
|
+
`[FedDeliver] follower enqueue failed for ${inbox}, falling back to the durable queue: ${message}`,
|
|
544
|
+
);
|
|
477
545
|
return false;
|
|
478
546
|
});
|
|
479
547
|
|
|
480
548
|
if (!enqueued) {
|
|
481
|
-
durableFallback.push({
|
|
549
|
+
durableFallback.push({
|
|
550
|
+
activityJson: activity,
|
|
551
|
+
targetInbox: inbox,
|
|
552
|
+
senderOxyUserId,
|
|
553
|
+
nextAttemptAt: now,
|
|
554
|
+
});
|
|
482
555
|
}
|
|
483
556
|
}
|
|
484
557
|
|
|
@@ -512,7 +585,9 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
|
|
|
512
585
|
}
|
|
513
586
|
} catch (err) {
|
|
514
587
|
const message = err instanceof Error ? err.message : String(err);
|
|
515
|
-
logger.warn(
|
|
588
|
+
logger.warn(
|
|
589
|
+
`[FedSync] deferred follow delivery setup failed for ${remoteActorUri}: ${message}`,
|
|
590
|
+
);
|
|
516
591
|
}
|
|
517
592
|
})();
|
|
518
593
|
}
|
|
@@ -533,22 +608,13 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
|
|
|
533
608
|
// queue the Follow activity, then refresh the actor in the background.
|
|
534
609
|
const cached = await config.store.findActorByUri(remoteActorUri);
|
|
535
610
|
|
|
536
|
-
// Always refresh the actor in the background so its inbox/profile stay
|
|
537
|
-
// current (and so a missing actor gets resolved for delivery shortly).
|
|
538
|
-
config.actorRefresh.refreshActorInBackground(remoteActorUri, cached ?? undefined);
|
|
539
|
-
|
|
540
611
|
const canonicalUri = cached?.uri ?? remoteActorUri;
|
|
541
612
|
const localActorUri = urls.actor(localUsername);
|
|
542
613
|
// Use the actor _id when known, otherwise a stable hash of the URI so the
|
|
543
614
|
// activity ID is deterministic across retries before the actor is cached.
|
|
544
|
-
const activityIdSuffix = cached?._id
|
|
545
|
-
? String(cached._id)
|
|
546
|
-
: encodeURIComponent(canonicalUri);
|
|
615
|
+
const activityIdSuffix = cached?._id ? String(cached._id) : encodeURIComponent(canonicalUri);
|
|
547
616
|
const activityId = `${localActorUri}/follows/${activityIdSuffix}`;
|
|
548
617
|
|
|
549
|
-
// Create or update the follow record
|
|
550
|
-
await config.follows.upsertOutboundPending(localOxyUserId, canonicalUri, activityId);
|
|
551
|
-
|
|
552
618
|
const activity: Record<string, unknown> = {
|
|
553
619
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
554
620
|
id: activityId,
|
|
@@ -557,17 +623,25 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
|
|
|
557
623
|
object: canonicalUri,
|
|
558
624
|
};
|
|
559
625
|
|
|
626
|
+
// Refuse before committing an outbound follow or reporting success.
|
|
627
|
+
await authorizeDelivery(activity, localOxyUserId);
|
|
628
|
+
// A refused follow must not start remote actor discovery either.
|
|
629
|
+
config.actorRefresh.refreshActorInBackground(remoteActorUri, cached ?? undefined);
|
|
630
|
+
await config.follows.upsertOutboundPending(localOxyUserId, canonicalUri, activityId);
|
|
631
|
+
|
|
560
632
|
// If we know the inbox, attempt delivery in the background; otherwise queue
|
|
561
633
|
// for the delivery worker, which resolves the inbox once the actor lands.
|
|
562
634
|
const targetInbox = cached?.sharedInboxUrl ?? cached?.inboxUrl;
|
|
563
635
|
if (targetInbox && !isBlockedUrl(targetInbox)) {
|
|
564
|
-
void
|
|
636
|
+
void deliverAuthorizedActivity(activity, targetInbox, localUsername)
|
|
565
637
|
.then((delivered) => {
|
|
566
638
|
if (!delivered) return queueDelivery(activity, targetInbox, localOxyUserId);
|
|
567
639
|
})
|
|
568
640
|
.catch((err) => {
|
|
569
641
|
const message = err instanceof Error ? err.message : String(err);
|
|
570
|
-
logger.warn(
|
|
642
|
+
logger.warn(
|
|
643
|
+
`[FedSync] background follow delivery failed for ${canonicalUri}: ${message}`,
|
|
644
|
+
);
|
|
571
645
|
});
|
|
572
646
|
} else {
|
|
573
647
|
// No cached inbox yet — resolve the actor's inbox in the background and
|
|
@@ -623,10 +697,14 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
|
|
|
623
697
|
})
|
|
624
698
|
.catch((err) => {
|
|
625
699
|
const message = err instanceof Error ? err.message : String(err);
|
|
626
|
-
logger.warn(
|
|
700
|
+
logger.warn(
|
|
701
|
+
`[FedSync] background undo-follow delivery failed for ${remoteActorUri}: ${message}`,
|
|
702
|
+
);
|
|
627
703
|
});
|
|
628
704
|
} else if (targetInbox) {
|
|
629
|
-
logger.warn(
|
|
705
|
+
logger.warn(
|
|
706
|
+
`[FedDeliver] skipping Undo(Follow) delivery to blocked origin ${remoteActorUri}`,
|
|
707
|
+
);
|
|
630
708
|
}
|
|
631
709
|
|
|
632
710
|
return true;
|
|
@@ -649,7 +727,9 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
|
|
|
649
727
|
return;
|
|
650
728
|
}
|
|
651
729
|
if (isBlockedUrl(targetInbox) || isBlockedUrl(remoteActorUri)) {
|
|
652
|
-
logger.warn(
|
|
730
|
+
logger.warn(
|
|
731
|
+
`[FedDeliver] refusing Accept(Follow) delivery to blocked origin ${remoteActorUri}`,
|
|
732
|
+
);
|
|
653
733
|
return;
|
|
654
734
|
}
|
|
655
735
|
|
|
@@ -681,7 +761,9 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
|
|
|
681
761
|
try {
|
|
682
762
|
const user = await config.identity.resolveUserByUsername(username);
|
|
683
763
|
if (!user) {
|
|
684
|
-
logger.warn(
|
|
764
|
+
logger.warn(
|
|
765
|
+
`[FedDeliver] cannot federate actor update for ${username}: user not resolvable`,
|
|
766
|
+
);
|
|
685
767
|
return;
|
|
686
768
|
}
|
|
687
769
|
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Shared discovery config validation; zero forbids downstream response retention. */
|
|
2
|
+
export function discoveryCacheControl(value: number | undefined, defaultSeconds: number): string {
|
|
3
|
+
const seconds = value ?? defaultSeconds;
|
|
4
|
+
if (!Number.isInteger(seconds) || seconds < 0 || seconds > 86400) {
|
|
5
|
+
throw new RangeError('httpCacheTtlSeconds must be an integer from 0 to 86400');
|
|
6
|
+
}
|
|
7
|
+
return seconds === 0 ? 'no-store' : `max-age=${seconds}`;
|
|
8
|
+
}
|
|
@@ -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
|
|
@@ -70,6 +81,8 @@ export {
|
|
|
70
81
|
*/
|
|
71
82
|
export {
|
|
72
83
|
createDeliveryService,
|
|
84
|
+
DeliveryAuthorizationDeniedError,
|
|
85
|
+
type DeliveryAuthorizationContext,
|
|
73
86
|
type DeliveryService,
|
|
74
87
|
type DeliveryServiceConfig,
|
|
75
88
|
type DeliveryLogger,
|
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
|
+
}
|