@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.
- 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 +3 -4
- 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
|
@@ -314,9 +314,11 @@ function sameOriginUrl(a: string, b: string): boolean {
|
|
|
314
314
|
try {
|
|
315
315
|
const urlA = new URL(a);
|
|
316
316
|
const urlB = new URL(b);
|
|
317
|
-
return
|
|
318
|
-
|
|
319
|
-
|
|
317
|
+
return (
|
|
318
|
+
urlA.protocol === urlB.protocol &&
|
|
319
|
+
urlA.port === urlB.port &&
|
|
320
|
+
isSameFederationHost(urlA.hostname, urlB.hostname)
|
|
321
|
+
);
|
|
320
322
|
} catch {
|
|
321
323
|
return false;
|
|
322
324
|
}
|
|
@@ -387,9 +389,7 @@ export class ActorResolver<TActor extends FederatedActorRecordBase> {
|
|
|
387
389
|
try {
|
|
388
390
|
const data = await this.config.fetchWebFinger(url);
|
|
389
391
|
if (!data) return null;
|
|
390
|
-
const link = data.links?.find(
|
|
391
|
-
(l) => l.rel === 'self' && isApActorContentType(l.type),
|
|
392
|
-
);
|
|
392
|
+
const link = data.links?.find((l) => l.rel === 'self' && isApActorContentType(l.type));
|
|
393
393
|
return link?.href || null;
|
|
394
394
|
} catch (err) {
|
|
395
395
|
this.config.logger.warn(`WebFinger resolution failed for ${acct}:`, err);
|
|
@@ -419,7 +419,9 @@ export class ActorResolver<TActor extends FederatedActorRecordBase> {
|
|
|
419
419
|
// throws here and is handled by the catch below.
|
|
420
420
|
const requestedHost = new URL(currentUri).hostname.toLowerCase();
|
|
421
421
|
if (this.config.isBlockedDomain(requestedHost)) {
|
|
422
|
-
this.config.logger.info(
|
|
422
|
+
this.config.logger.info(
|
|
423
|
+
`[FedSync] fetchRemoteActor skipping own/blocked domain ${requestedHost} for ${currentUri}`,
|
|
424
|
+
);
|
|
423
425
|
return null;
|
|
424
426
|
}
|
|
425
427
|
|
|
@@ -431,20 +433,27 @@ export class ActorResolver<TActor extends FederatedActorRecordBase> {
|
|
|
431
433
|
// through to the WebFinger fallback (which recovers a STALE/wrong URI on a
|
|
432
434
|
// transient failure, not a permanent removal). Only 410 does this.
|
|
433
435
|
if (res.status === 410) {
|
|
434
|
-
this.config.logger.info(
|
|
436
|
+
this.config.logger.info(
|
|
437
|
+
`[FedSync] fetchRemoteActor 410 Gone for ${currentUri} — tombstoning actor`,
|
|
438
|
+
);
|
|
435
439
|
await this.tombstoneGoneActor(currentUri);
|
|
436
440
|
return null;
|
|
437
441
|
}
|
|
438
442
|
const body = await readBoundedResponseBody(res, ERROR_BODY_MAX_BYTES).catch(() => '');
|
|
439
|
-
this.config.logger.info(
|
|
443
|
+
this.config.logger.info(
|
|
444
|
+
`[FedSync] fetchRemoteActor HTTP ${res.status} ${res.statusText} for ${currentUri} body=${body.slice(0, 500)}`,
|
|
445
|
+
);
|
|
440
446
|
|
|
441
447
|
// If direct fetch failed, try WebFinger to resolve the canonical actor URI.
|
|
442
448
|
// Some servers (e.g., Threads) use numeric IDs in AP URIs that differ from
|
|
443
449
|
// the username-based URI we may have stored.
|
|
444
450
|
const parsed = new URL(currentUri);
|
|
445
451
|
const pathUsername = parsed.pathname.split('/').filter(Boolean).pop();
|
|
446
|
-
const acct =
|
|
447
|
-
||
|
|
452
|
+
const acct =
|
|
453
|
+
canonicalAcctHint ||
|
|
454
|
+
(pathUsername
|
|
455
|
+
? this.config.normalizeFederatedAcct(`${pathUsername}@${parsed.hostname}`)
|
|
456
|
+
: undefined);
|
|
448
457
|
if (acct) {
|
|
449
458
|
this.config.logger.info(`[FedSync] attempting WebFinger fallback for ${acct}`);
|
|
450
459
|
const resolved = await this.resolveWebFinger(acct);
|
|
@@ -457,16 +466,24 @@ export class ActorResolver<TActor extends FederatedActorRecordBase> {
|
|
|
457
466
|
// A 410 on the WebFinger-RESOLVED URI is just as definitive. Tombstone
|
|
458
467
|
// against the stored URI (not reassigned on this branch).
|
|
459
468
|
if (res.status === 410) {
|
|
460
|
-
this.config.logger.info(
|
|
469
|
+
this.config.logger.info(
|
|
470
|
+
`[FedSync] fetchRemoteActor 410 Gone for resolved ${resolved} — tombstoning actor ${currentUri}`,
|
|
471
|
+
);
|
|
461
472
|
await this.tombstoneGoneActor(currentUri);
|
|
462
473
|
return null;
|
|
463
474
|
}
|
|
464
|
-
const body2 = await readBoundedResponseBody(res, ERROR_BODY_MAX_BYTES).catch(
|
|
465
|
-
|
|
475
|
+
const body2 = await readBoundedResponseBody(res, ERROR_BODY_MAX_BYTES).catch(
|
|
476
|
+
() => '',
|
|
477
|
+
);
|
|
478
|
+
this.config.logger.info(
|
|
479
|
+
`[FedSync] fetchRemoteActor HTTP ${res.status} for resolved ${resolved} body=${body2.slice(0, 500)}`,
|
|
480
|
+
);
|
|
466
481
|
return null;
|
|
467
482
|
}
|
|
468
483
|
} else {
|
|
469
|
-
this.config.logger.info(
|
|
484
|
+
this.config.logger.info(
|
|
485
|
+
`[FedSync] WebFinger returned ${resolved ?? 'null'} for ${acct}`,
|
|
486
|
+
);
|
|
470
487
|
return null;
|
|
471
488
|
}
|
|
472
489
|
} else {
|
|
@@ -478,42 +495,53 @@ export class ActorResolver<TActor extends FederatedActorRecordBase> {
|
|
|
478
495
|
const actorId = asString(actor.id);
|
|
479
496
|
const actorInbox = asString(actor.inbox);
|
|
480
497
|
if (!actorId || !actorInbox) {
|
|
481
|
-
this.config.logger.info(
|
|
498
|
+
this.config.logger.info(
|
|
499
|
+
`[FedSync] fetchRemoteActor missing fields for ${currentUri}: id=${!!actor.id} inbox=${!!actor.inbox} type=${String(actor.type)} keys=${Object.keys(actor).join(',')}`,
|
|
500
|
+
);
|
|
482
501
|
return null;
|
|
483
502
|
}
|
|
484
503
|
|
|
485
504
|
if (!sameOriginUrl(currentUri, actorId)) {
|
|
486
|
-
this.config.logger.warn(
|
|
505
|
+
this.config.logger.warn(
|
|
506
|
+
`[FedSync] rejecting actor ${currentUri}: fetched URI is not authoritative for claimed id ${actorId}`,
|
|
507
|
+
);
|
|
487
508
|
return null;
|
|
488
509
|
}
|
|
489
510
|
|
|
490
511
|
if (!actorPublicKeyIsSelfConsistent(actor, actorId)) {
|
|
491
|
-
this.config.logger.warn(
|
|
512
|
+
this.config.logger.warn(
|
|
513
|
+
`[FedSync] rejecting actor ${currentUri}: publicKey is not self-consistent for claimed id ${actorId}`,
|
|
514
|
+
);
|
|
492
515
|
return null;
|
|
493
516
|
}
|
|
494
517
|
|
|
495
518
|
const actorHost = new URL(actorId).hostname.toLowerCase();
|
|
496
|
-
const username =
|
|
497
|
-
|
|
498
|
-
||
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
519
|
+
const username =
|
|
520
|
+
this.config.text.inlineField(actor.preferredUsername) ||
|
|
521
|
+
this.config.text.inlineField(actor.name) ||
|
|
522
|
+
'unknown';
|
|
523
|
+
const actorWebfinger =
|
|
524
|
+
typeof actor.webfinger === 'string'
|
|
525
|
+
? this.config.normalizeFederatedAcct(actor.webfinger)
|
|
526
|
+
: undefined;
|
|
502
527
|
const verifiedAcctHint = this.acctMatchesActorHost(canonicalAcctHint, actorHost)
|
|
503
528
|
? canonicalAcctHint
|
|
504
529
|
: undefined;
|
|
505
530
|
const verifiedActorWebfinger = this.acctMatchesActorHost(actorWebfinger, actorHost)
|
|
506
531
|
? actorWebfinger
|
|
507
532
|
: undefined;
|
|
508
|
-
const acct =
|
|
509
|
-
||
|
|
510
|
-
||
|
|
511
|
-
|
|
533
|
+
const acct =
|
|
534
|
+
verifiedAcctHint ||
|
|
535
|
+
verifiedActorWebfinger ||
|
|
536
|
+
this.config.normalizeFederatedAcct(`${username}@${actorHost}`) ||
|
|
537
|
+
`${username.toLowerCase()}@${actorHost}`;
|
|
512
538
|
const domain = this.config.domainFromAcct(acct) || actorHost;
|
|
513
539
|
// Re-check against the RESOLVED host/acct (post-redirect / WebFinger), which
|
|
514
540
|
// can differ from the originally-requested URI host the early guard screened.
|
|
515
541
|
if (this.config.isBlockedDomain(domain) || this.config.isBlockedDomain(actorHost)) {
|
|
516
|
-
this.config.logger.info(
|
|
542
|
+
this.config.logger.info(
|
|
543
|
+
`[FedSync] fetchRemoteActor blocked domain ${domain} actorHost=${actorHost} for ${currentUri}`,
|
|
544
|
+
);
|
|
517
545
|
return null;
|
|
518
546
|
}
|
|
519
547
|
|
|
@@ -536,9 +564,10 @@ export class ActorResolver<TActor extends FederatedActorRecordBase> {
|
|
|
536
564
|
const attRecord = asRecord(att);
|
|
537
565
|
if (!attRecord || attRecord.type !== 'PropertyValue') continue;
|
|
538
566
|
const fieldName = this.config.text.inlineField(attRecord.name);
|
|
539
|
-
const fieldValue =
|
|
540
|
-
|
|
541
|
-
|
|
567
|
+
const fieldValue =
|
|
568
|
+
typeof attRecord.value === 'string'
|
|
569
|
+
? this.config.text.sanitizeFieldValue(attRecord.value)
|
|
570
|
+
: '';
|
|
542
571
|
if (!fieldName || !fieldValue) continue;
|
|
543
572
|
fields.push({
|
|
544
573
|
name: fieldName,
|
|
@@ -552,7 +581,8 @@ export class ActorResolver<TActor extends FederatedActorRecordBase> {
|
|
|
552
581
|
const headerUrl = this.config.firstStringUrl(actor.image);
|
|
553
582
|
// `summary` is the actor's bio — a BODY, so its line breaks are the author's
|
|
554
583
|
// and must survive; `htmlToPlainText` normalizes it as multiline.
|
|
555
|
-
const summary =
|
|
584
|
+
const summary =
|
|
585
|
+
typeof actor.summary === 'string' ? this.config.text.htmlToPlainText(actor.summary) : '';
|
|
556
586
|
// The display name is one line. Entity-decode FIRST (an encoded ` ` or
|
|
557
587
|
// ` ` only becomes whitespace once decoded), THEN collapse.
|
|
558
588
|
const rawDisplayName = typeof actor.name === 'string' ? actor.name : '';
|
|
@@ -617,7 +647,8 @@ export class ActorResolver<TActor extends FederatedActorRecordBase> {
|
|
|
617
647
|
featuredTagsUrl: asString(actor.featuredTags) || undefined,
|
|
618
648
|
alsoKnownAs,
|
|
619
649
|
networkAcct: networkIdentity?.federatedUsername,
|
|
620
|
-
remoteCreatedAt:
|
|
650
|
+
remoteCreatedAt:
|
|
651
|
+
typeof actor.published === 'string' ? new Date(actor.published) : undefined,
|
|
621
652
|
// Omitted, not written as `undefined`, when this refresh could not tell:
|
|
622
653
|
// an absent key is what tells the store to keep the value it has.
|
|
623
654
|
...(followersCount !== undefined && { followersCount }),
|
|
@@ -663,7 +694,9 @@ export class ActorResolver<TActor extends FederatedActorRecordBase> {
|
|
|
663
694
|
postsCount: postsCount ?? undefined,
|
|
664
695
|
oxyUserId: fedActor.oxyUserId ?? undefined,
|
|
665
696
|
};
|
|
666
|
-
const oxyId = await this.config.identity.resolveExternalUser(normalized, {
|
|
697
|
+
const oxyId = await this.config.identity.resolveExternalUser(normalized, {
|
|
698
|
+
forceAvatarRefresh,
|
|
699
|
+
});
|
|
667
700
|
if (oxyId && fedActor.oxyUserId !== oxyId && fedActor._id != null) {
|
|
668
701
|
await this.config.store.setActorOxyUserId(fedActor._id, oxyId);
|
|
669
702
|
}
|
|
@@ -691,9 +724,7 @@ export class ActorResolver<TActor extends FederatedActorRecordBase> {
|
|
|
691
724
|
* actor. The refusal is logged: it is a bug in the app's rule, not a normal
|
|
692
725
|
* outcome, and it must not pass silently.
|
|
693
726
|
*/
|
|
694
|
-
private resolveNetworkIdentity(
|
|
695
|
-
candidate: NetworkIdentityCandidate,
|
|
696
|
-
): NetworkIdentity | undefined {
|
|
727
|
+
private resolveNetworkIdentity(candidate: NetworkIdentityCandidate): NetworkIdentity | undefined {
|
|
697
728
|
const derived = this.config.deriveNetworkIdentity?.(candidate);
|
|
698
729
|
if (!derived) return undefined;
|
|
699
730
|
|
|
@@ -707,13 +738,13 @@ export class ActorResolver<TActor extends FederatedActorRecordBase> {
|
|
|
707
738
|
const atIndex = federatedUsername.indexOf('@');
|
|
708
739
|
const localPart = atIndex > 0 ? federatedUsername.slice(0, atIndex) : '';
|
|
709
740
|
if (
|
|
710
|
-
domain.length === 0
|
|
711
|
-
|
|
712
|
-
|
|
741
|
+
domain.length === 0 ||
|
|
742
|
+
localPart.length === 0 ||
|
|
743
|
+
federatedUsername !== `${localPart}@${domain}`
|
|
713
744
|
) {
|
|
714
745
|
this.config.logger.warn(
|
|
715
|
-
`[FedSync] refusing network identity for ${candidate.actorUri}: `
|
|
716
|
-
|
|
746
|
+
`[FedSync] refusing network identity for ${candidate.actorUri}: ` +
|
|
747
|
+
`"${derived.federatedUsername}" is not bindable to domain "${derived.instanceDomain}"`,
|
|
717
748
|
);
|
|
718
749
|
return undefined;
|
|
719
750
|
}
|
|
@@ -733,7 +764,9 @@ export class ActorResolver<TActor extends FederatedActorRecordBase> {
|
|
|
733
764
|
try {
|
|
734
765
|
const actor = await this.config.store.tombstoneActor(actorUri);
|
|
735
766
|
if (!actor) {
|
|
736
|
-
this.config.logger.info(
|
|
767
|
+
this.config.logger.info(
|
|
768
|
+
`[FedSync] 410 Gone for ${actorUri} — no stored actor row to tombstone`,
|
|
769
|
+
);
|
|
737
770
|
return;
|
|
738
771
|
}
|
|
739
772
|
|
|
@@ -741,7 +774,9 @@ export class ActorResolver<TActor extends FederatedActorRecordBase> {
|
|
|
741
774
|
|
|
742
775
|
if (actor.oxyUserId) {
|
|
743
776
|
const outcome = await this.config.identity.reportActorGone(actor.oxyUserId);
|
|
744
|
-
this.config.logger.info(
|
|
777
|
+
this.config.logger.info(
|
|
778
|
+
`[FedSync] actor-gone report for ${actorUri} (oxyUserId ${actor.oxyUserId}) → ${outcome}`,
|
|
779
|
+
);
|
|
745
780
|
}
|
|
746
781
|
} catch (err) {
|
|
747
782
|
this.config.logger.warn(`[FedSync] failed to tombstone gone actor ${actorUri}:`, err);
|
|
@@ -793,12 +828,15 @@ export class ActorResolver<TActor extends FederatedActorRecordBase> {
|
|
|
793
828
|
// instance that has ever reached us), blocking its domain changed nothing. That
|
|
794
829
|
// made the blocklist inert for exactly the hosts it is added for.
|
|
795
830
|
if (this.isBlockedActorUri(actorUri)) {
|
|
796
|
-
this.config.logger.info(
|
|
831
|
+
this.config.logger.info(
|
|
832
|
+
`[FedSync] getOrFetchActor refusing own/blocked domain for ${actorUri}`,
|
|
833
|
+
);
|
|
797
834
|
return null;
|
|
798
835
|
}
|
|
799
836
|
const existing = await this.config.store.findActorByUri(actorUri);
|
|
800
837
|
if (existing) {
|
|
801
|
-
const isStale =
|
|
838
|
+
const isStale =
|
|
839
|
+
!existing.lastFetchedAt || Date.now() - existing.lastFetchedAt.getTime() > ACTOR_STALE_MS;
|
|
802
840
|
if (isStale) {
|
|
803
841
|
// Refresh in the background — never block the caller on remote I/O.
|
|
804
842
|
this.refreshActorInBackground(actorUri, existing);
|
|
@@ -820,8 +858,9 @@ export class ActorResolver<TActor extends FederatedActorRecordBase> {
|
|
|
820
858
|
|
|
821
859
|
const missingProfile = !existing || !existing.avatarUrl || !existing.headerUrl;
|
|
822
860
|
const lastFetchedMs = existing?.lastFetchedAt?.getTime();
|
|
823
|
-
const refreshedRecently =
|
|
824
|
-
|
|
861
|
+
const refreshedRecently =
|
|
862
|
+
typeof lastFetchedMs === 'number' &&
|
|
863
|
+
Date.now() - lastFetchedMs < ACTOR_REFRESH_MIN_INTERVAL_MS;
|
|
825
864
|
|
|
826
865
|
// Skip if we refreshed recently AND the cached profile is already complete.
|
|
827
866
|
if (refreshedRecently && !missingProfile) return;
|
|
@@ -835,7 +874,9 @@ export class ActorResolver<TActor extends FederatedActorRecordBase> {
|
|
|
835
874
|
await this.fetchRemoteActor(actorUri, forceAvatarRefresh, existing?.acct);
|
|
836
875
|
} catch (err) {
|
|
837
876
|
const message = err instanceof Error ? err.message : String(err);
|
|
838
|
-
this.config.logger.warn(
|
|
877
|
+
this.config.logger.warn(
|
|
878
|
+
`[FedSync] background actor refresh failed for ${actorUri}: ${message}`,
|
|
879
|
+
);
|
|
839
880
|
} finally {
|
|
840
881
|
this.inFlightActorRefreshes.delete(actorUri);
|
|
841
882
|
}
|
package/src/node/actorRouter.ts
CHANGED
|
@@ -104,9 +104,15 @@ export interface ActorRouterConfig {
|
|
|
104
104
|
/** Whether to trust `X-Forwarded-Host` when reconstructing the signed host line. */
|
|
105
105
|
trustForwardedHost: boolean;
|
|
106
106
|
/** Enqueue a verified inbound activity for async processing (false ⇒ process inline). */
|
|
107
|
-
enqueueInboxActivity(job: {
|
|
107
|
+
enqueueInboxActivity(job: {
|
|
108
|
+
activity: Record<string, unknown>;
|
|
109
|
+
verifiedActorUri: string;
|
|
110
|
+
}): Promise<boolean>;
|
|
108
111
|
/** The inbound dispatcher (the inline-fallback + post-enqueue processor). */
|
|
109
|
-
processInboxActivity(
|
|
112
|
+
processInboxActivity(
|
|
113
|
+
activity: Record<string, unknown>,
|
|
114
|
+
verifiedActorUri: string,
|
|
115
|
+
): Promise<void>;
|
|
110
116
|
};
|
|
111
117
|
/** Fetch one page of a user's Oxy follow graph (followers OR following). */
|
|
112
118
|
fetchFollowPage(
|
|
@@ -161,7 +167,11 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
161
167
|
async function handleInbox(req: InboxRequest, res: Response): Promise<Response> {
|
|
162
168
|
try {
|
|
163
169
|
// Verify HTTP signature (use originalUrl to avoid proxy path mangling).
|
|
164
|
-
const {
|
|
170
|
+
const {
|
|
171
|
+
verified,
|
|
172
|
+
actorUri,
|
|
173
|
+
reason: signatureError,
|
|
174
|
+
} = await verifyHttpSignature(
|
|
165
175
|
{
|
|
166
176
|
method: req.method,
|
|
167
177
|
path: req.originalUrl || req.path,
|
|
@@ -186,7 +196,8 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
186
196
|
}
|
|
187
197
|
|
|
188
198
|
// Verify the actor in the activity matches the signature.
|
|
189
|
-
const activityActor =
|
|
199
|
+
const activityActor =
|
|
200
|
+
typeof activity.actor === 'string' ? activity.actor : activity.actor?.id;
|
|
190
201
|
if (activityActor !== actorUri) {
|
|
191
202
|
logger.debug(`Inbox: Actor mismatch. Signed: ${actorUri}, Activity: ${activityActor}`);
|
|
192
203
|
return res.status(403).json({ error: 'Actor mismatch' });
|
|
@@ -199,7 +210,10 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
199
210
|
// is never dropped.
|
|
200
211
|
let enqueued = false;
|
|
201
212
|
try {
|
|
202
|
-
enqueued = await config.inbound.enqueueInboxActivity({
|
|
213
|
+
enqueued = await config.inbound.enqueueInboxActivity({
|
|
214
|
+
activity,
|
|
215
|
+
verifiedActorUri: actorUri,
|
|
216
|
+
});
|
|
203
217
|
} catch (err) {
|
|
204
218
|
logger.error('Failed to enqueue inbox activity — processing inline:', err);
|
|
205
219
|
enqueued = false;
|
|
@@ -243,7 +257,8 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
243
257
|
|
|
244
258
|
const userId = String(user._id || user.id);
|
|
245
259
|
|
|
246
|
-
const rawCount: unknown =
|
|
260
|
+
const rawCount: unknown =
|
|
261
|
+
direction === 'followers' ? user._count?.followers : user._count?.following;
|
|
247
262
|
const profileTotal = typeof rawCount === 'number' ? rawCount : undefined;
|
|
248
263
|
|
|
249
264
|
if (!page) {
|
|
@@ -256,7 +271,9 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
256
271
|
totalItems = (await config.fetchFollowPage(userId, direction, 0, 1)).total;
|
|
257
272
|
} catch (err) {
|
|
258
273
|
logger.warn('[Federation] follow-collection summary total lookup failed', {
|
|
259
|
-
username,
|
|
274
|
+
username,
|
|
275
|
+
direction,
|
|
276
|
+
error: err,
|
|
260
277
|
});
|
|
261
278
|
}
|
|
262
279
|
}
|
|
@@ -277,7 +294,12 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
277
294
|
let total = profileTotal ?? 0;
|
|
278
295
|
let hasMore = false;
|
|
279
296
|
try {
|
|
280
|
-
const pageResult = await config.fetchFollowPage(
|
|
297
|
+
const pageResult = await config.fetchFollowPage(
|
|
298
|
+
userId,
|
|
299
|
+
direction,
|
|
300
|
+
offset,
|
|
301
|
+
FOLLOW_PAGE_SIZE,
|
|
302
|
+
);
|
|
281
303
|
members = pageResult.members;
|
|
282
304
|
total = pageResult.total;
|
|
283
305
|
hasMore = pageResult.hasMore;
|
|
@@ -285,7 +307,10 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
285
307
|
// Fail-soft: never 500 the whole collection on an Oxy graph hiccup — serve
|
|
286
308
|
// an empty page against the best-known total rather than crashing.
|
|
287
309
|
logger.warn('[Federation] follow-collection Oxy graph list failed, serving empty page', {
|
|
288
|
-
username,
|
|
310
|
+
username,
|
|
311
|
+
direction,
|
|
312
|
+
offset,
|
|
313
|
+
error: err,
|
|
289
314
|
});
|
|
290
315
|
}
|
|
291
316
|
|
|
@@ -293,9 +318,10 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
293
318
|
.map((member) => memberActorUri(member, urls))
|
|
294
319
|
.filter((uri): uri is string => uri !== null);
|
|
295
320
|
|
|
296
|
-
const pageId =
|
|
297
|
-
|
|
298
|
-
|
|
321
|
+
const pageId =
|
|
322
|
+
offset > 0
|
|
323
|
+
? `${collectionUrl(username)}?page=true&offset=${offset}`
|
|
324
|
+
: `${collectionUrl(username)}?page=true`;
|
|
299
325
|
|
|
300
326
|
const pageResponse: Record<string, unknown> = {
|
|
301
327
|
'@context': AP_CONTEXT,
|
package/src/node/delivery.ts
CHANGED
|
@@ -81,7 +81,10 @@ export interface DeliverSingleHopInit {
|
|
|
81
81
|
* response WITHOUT following redirects. Mention adapts its `@oxy.so/core/server`-
|
|
82
82
|
* backed `fetchUpstreamSingleHop` into this shape.
|
|
83
83
|
*/
|
|
84
|
-
export type DeliverSingleHop = (
|
|
84
|
+
export type DeliverSingleHop = (
|
|
85
|
+
url: string,
|
|
86
|
+
init: DeliverSingleHopInit,
|
|
87
|
+
) => Promise<DeliverSingleHopResult>;
|
|
85
88
|
|
|
86
89
|
/** A durable-delivery job body (BullMQ + the fallback queue share this shape). */
|
|
87
90
|
export interface DeliveryQueueJob {
|
|
@@ -94,9 +97,8 @@ export interface DeliveryQueueJob {
|
|
|
94
97
|
* The durable-delivery fallback (written when BullMQ is unavailable).
|
|
95
98
|
*
|
|
96
99
|
* 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.
|
|
100
|
+
* a database. Mention backs it with Postgres; the method names below are kept
|
|
101
|
+
* stable so the adapter shape stays stable for consumers.
|
|
100
102
|
*/
|
|
101
103
|
export interface DeliveryFallbackQueue {
|
|
102
104
|
/** Insert one fallback delivery row (`queueDelivery`). */
|
|
@@ -134,7 +136,9 @@ export interface DeliveryActorStore<TActor extends DeliveryActorFields> {
|
|
|
134
136
|
/** One cached actor by uri (`resolveActorInbox` / `sendFollow` / `sendUndoFollow` / `sendAccept`). */
|
|
135
137
|
findActorByUri(uri: string): Promise<TActor | null>;
|
|
136
138
|
/** Inbox fields for many actor uris (`deliverToFollowers`, step 2). */
|
|
137
|
-
findActorInboxesByUris(
|
|
139
|
+
findActorInboxesByUris(
|
|
140
|
+
uris: string[],
|
|
141
|
+
): Promise<Array<Pick<DeliveryActorFields, 'sharedInboxUrl' | 'inboxUrl'>>>;
|
|
138
142
|
}
|
|
139
143
|
|
|
140
144
|
/** Bring-your-own-store: the AP follow records stay in the app DB behind this adapter. */
|
|
@@ -142,9 +146,16 @@ export interface DeliveryFollowStore {
|
|
|
142
146
|
/** Accepted inbound followers' remote actor uris (`deliverToFollowers`, step 1). */
|
|
143
147
|
listAcceptedInboundFollowerActorUris(localOxyUserId: string): Promise<string[]>;
|
|
144
148
|
/** Upsert an outbound pending follow with its activity id (`sendFollow`). */
|
|
145
|
-
upsertOutboundPending(
|
|
149
|
+
upsertOutboundPending(
|
|
150
|
+
localOxyUserId: string,
|
|
151
|
+
remoteActorUri: string,
|
|
152
|
+
activityId: string,
|
|
153
|
+
): Promise<void>;
|
|
146
154
|
/** The outbound follow row for `(localOxyUserId, remoteActorUri)` (`sendUndoFollow`). */
|
|
147
|
-
findOutbound(
|
|
155
|
+
findOutbound(
|
|
156
|
+
localOxyUserId: string,
|
|
157
|
+
remoteActorUri: string,
|
|
158
|
+
): Promise<{ _id: unknown; activityId?: string } | null>;
|
|
148
159
|
/** Delete a follow row by id (`sendUndoFollow`). */
|
|
149
160
|
deleteById(id: unknown): Promise<void>;
|
|
150
161
|
}
|
|
@@ -356,7 +367,9 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
|
|
|
356
367
|
...sigHeaders,
|
|
357
368
|
};
|
|
358
369
|
|
|
359
|
-
logger.debug(
|
|
370
|
+
logger.debug(
|
|
371
|
+
`[FedDeliver] POST ${targetInbox} body=${body} sig-headers=${sigHeaders.Signature?.match(/headers="([^"]+)"/)?.[1]}`,
|
|
372
|
+
);
|
|
360
373
|
|
|
361
374
|
const { response, status } = await config.deliverSingleHop(targetInbox, {
|
|
362
375
|
method: 'POST',
|
|
@@ -372,7 +385,9 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
|
|
|
372
385
|
}
|
|
373
386
|
|
|
374
387
|
const responseBody = await readResponsePreview(response);
|
|
375
|
-
logger.debug(
|
|
388
|
+
logger.debug(
|
|
389
|
+
`Activity delivery failed to ${targetInbox}: ${status} body=${responseBody.slice(0, 500)}`,
|
|
390
|
+
);
|
|
376
391
|
return false;
|
|
377
392
|
} catch (err) {
|
|
378
393
|
logger.debug(`Activity delivery error to ${targetInbox}:`, err);
|
|
@@ -402,7 +417,9 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
|
|
|
402
417
|
.enqueueDelivery({ activityJson: activity, targetInbox, senderOxyUserId })
|
|
403
418
|
.catch((err) => {
|
|
404
419
|
const message = err instanceof Error ? err.message : String(err);
|
|
405
|
-
logger.warn(
|
|
420
|
+
logger.warn(
|
|
421
|
+
`[FedDeliver] enqueue failed for ${targetInbox}, falling back to the durable queue: ${message}`,
|
|
422
|
+
);
|
|
406
423
|
return false;
|
|
407
424
|
});
|
|
408
425
|
|
|
@@ -473,12 +490,19 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
|
|
|
473
490
|
.enqueueDelivery({ activityJson: activity, targetInbox: inbox, senderOxyUserId })
|
|
474
491
|
.catch((err) => {
|
|
475
492
|
const message = err instanceof Error ? err.message : String(err);
|
|
476
|
-
logger.warn(
|
|
493
|
+
logger.warn(
|
|
494
|
+
`[FedDeliver] follower enqueue failed for ${inbox}, falling back to the durable queue: ${message}`,
|
|
495
|
+
);
|
|
477
496
|
return false;
|
|
478
497
|
});
|
|
479
498
|
|
|
480
499
|
if (!enqueued) {
|
|
481
|
-
durableFallback.push({
|
|
500
|
+
durableFallback.push({
|
|
501
|
+
activityJson: activity,
|
|
502
|
+
targetInbox: inbox,
|
|
503
|
+
senderOxyUserId,
|
|
504
|
+
nextAttemptAt: now,
|
|
505
|
+
});
|
|
482
506
|
}
|
|
483
507
|
}
|
|
484
508
|
|
|
@@ -512,7 +536,9 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
|
|
|
512
536
|
}
|
|
513
537
|
} catch (err) {
|
|
514
538
|
const message = err instanceof Error ? err.message : String(err);
|
|
515
|
-
logger.warn(
|
|
539
|
+
logger.warn(
|
|
540
|
+
`[FedSync] deferred follow delivery setup failed for ${remoteActorUri}: ${message}`,
|
|
541
|
+
);
|
|
516
542
|
}
|
|
517
543
|
})();
|
|
518
544
|
}
|
|
@@ -541,9 +567,7 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
|
|
|
541
567
|
const localActorUri = urls.actor(localUsername);
|
|
542
568
|
// Use the actor _id when known, otherwise a stable hash of the URI so the
|
|
543
569
|
// activity ID is deterministic across retries before the actor is cached.
|
|
544
|
-
const activityIdSuffix = cached?._id
|
|
545
|
-
? String(cached._id)
|
|
546
|
-
: encodeURIComponent(canonicalUri);
|
|
570
|
+
const activityIdSuffix = cached?._id ? String(cached._id) : encodeURIComponent(canonicalUri);
|
|
547
571
|
const activityId = `${localActorUri}/follows/${activityIdSuffix}`;
|
|
548
572
|
|
|
549
573
|
// Create or update the follow record
|
|
@@ -567,7 +591,9 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
|
|
|
567
591
|
})
|
|
568
592
|
.catch((err) => {
|
|
569
593
|
const message = err instanceof Error ? err.message : String(err);
|
|
570
|
-
logger.warn(
|
|
594
|
+
logger.warn(
|
|
595
|
+
`[FedSync] background follow delivery failed for ${canonicalUri}: ${message}`,
|
|
596
|
+
);
|
|
571
597
|
});
|
|
572
598
|
} else {
|
|
573
599
|
// No cached inbox yet — resolve the actor's inbox in the background and
|
|
@@ -623,10 +649,14 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
|
|
|
623
649
|
})
|
|
624
650
|
.catch((err) => {
|
|
625
651
|
const message = err instanceof Error ? err.message : String(err);
|
|
626
|
-
logger.warn(
|
|
652
|
+
logger.warn(
|
|
653
|
+
`[FedSync] background undo-follow delivery failed for ${remoteActorUri}: ${message}`,
|
|
654
|
+
);
|
|
627
655
|
});
|
|
628
656
|
} else if (targetInbox) {
|
|
629
|
-
logger.warn(
|
|
657
|
+
logger.warn(
|
|
658
|
+
`[FedDeliver] skipping Undo(Follow) delivery to blocked origin ${remoteActorUri}`,
|
|
659
|
+
);
|
|
630
660
|
}
|
|
631
661
|
|
|
632
662
|
return true;
|
|
@@ -649,7 +679,9 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
|
|
|
649
679
|
return;
|
|
650
680
|
}
|
|
651
681
|
if (isBlockedUrl(targetInbox) || isBlockedUrl(remoteActorUri)) {
|
|
652
|
-
logger.warn(
|
|
682
|
+
logger.warn(
|
|
683
|
+
`[FedDeliver] refusing Accept(Follow) delivery to blocked origin ${remoteActorUri}`,
|
|
684
|
+
);
|
|
653
685
|
return;
|
|
654
686
|
}
|
|
655
687
|
|
|
@@ -681,7 +713,9 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
|
|
|
681
713
|
try {
|
|
682
714
|
const user = await config.identity.resolveUserByUsername(username);
|
|
683
715
|
if (!user) {
|
|
684
|
-
logger.warn(
|
|
716
|
+
logger.warn(
|
|
717
|
+
`[FedDeliver] cannot federate actor update for ${username}: user not resolvable`,
|
|
718
|
+
);
|
|
685
719
|
return;
|
|
686
720
|
}
|
|
687
721
|
|