@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
|
@@ -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
|
@@ -23,6 +23,7 @@ import { Router, type Request, type Response } from 'express';
|
|
|
23
23
|
import type { AccountKind } from '@oxy.so/contracts';
|
|
24
24
|
import type { User } from '@oxy.so/core';
|
|
25
25
|
import { AP_CONTEXT } from '../apContext';
|
|
26
|
+
import { discoveryCacheControl } from './httpCacheControl';
|
|
26
27
|
import { verifyHttpSignature } from '../httpSignature';
|
|
27
28
|
import type { UrlBuilders } from '../urls';
|
|
28
29
|
import { INSTANCE_ACTOR_USERNAME, normalizeActorUsername } from '../urls';
|
|
@@ -104,9 +105,15 @@ export interface ActorRouterConfig {
|
|
|
104
105
|
/** Whether to trust `X-Forwarded-Host` when reconstructing the signed host line. */
|
|
105
106
|
trustForwardedHost: boolean;
|
|
106
107
|
/** Enqueue a verified inbound activity for async processing (false ⇒ process inline). */
|
|
107
|
-
enqueueInboxActivity(job: {
|
|
108
|
+
enqueueInboxActivity(job: {
|
|
109
|
+
activity: Record<string, unknown>;
|
|
110
|
+
verifiedActorUri: string;
|
|
111
|
+
}): Promise<boolean>;
|
|
108
112
|
/** The inbound dispatcher (the inline-fallback + post-enqueue processor). */
|
|
109
|
-
processInboxActivity(
|
|
113
|
+
processInboxActivity(
|
|
114
|
+
activity: Record<string, unknown>,
|
|
115
|
+
verifiedActorUri: string,
|
|
116
|
+
): Promise<void>;
|
|
110
117
|
};
|
|
111
118
|
/** Fetch one page of a user's Oxy follow graph (followers OR following). */
|
|
112
119
|
fetchFollowPage(
|
|
@@ -115,6 +122,8 @@ export interface ActorRouterConfig {
|
|
|
115
122
|
offset: number,
|
|
116
123
|
limit: number,
|
|
117
124
|
): Promise<FollowPage>;
|
|
125
|
+
/** Actor HTTP response TTL in whole seconds, 0..86400. Defaults to 1800; 0 sends no-store. */
|
|
126
|
+
httpCacheTtlSeconds?: number;
|
|
118
127
|
/** Diagnostics sink. */
|
|
119
128
|
logger: ActorRouterLogger;
|
|
120
129
|
}
|
|
@@ -152,6 +161,7 @@ function parseFollowOffset(raw: unknown): number {
|
|
|
152
161
|
export function createActorRouter(config: ActorRouterConfig): Router {
|
|
153
162
|
const router = Router();
|
|
154
163
|
const { urls, domain, apContentType, logger } = config;
|
|
164
|
+
const httpCacheControl = discoveryCacheControl(config.httpCacheTtlSeconds, 1800);
|
|
155
165
|
|
|
156
166
|
function wantsActivityPub(req: Request): boolean {
|
|
157
167
|
return config.wantsActivityPub(req.headers.accept);
|
|
@@ -161,7 +171,11 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
161
171
|
async function handleInbox(req: InboxRequest, res: Response): Promise<Response> {
|
|
162
172
|
try {
|
|
163
173
|
// Verify HTTP signature (use originalUrl to avoid proxy path mangling).
|
|
164
|
-
const {
|
|
174
|
+
const {
|
|
175
|
+
verified,
|
|
176
|
+
actorUri,
|
|
177
|
+
reason: signatureError,
|
|
178
|
+
} = await verifyHttpSignature(
|
|
165
179
|
{
|
|
166
180
|
method: req.method,
|
|
167
181
|
path: req.originalUrl || req.path,
|
|
@@ -186,7 +200,8 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
186
200
|
}
|
|
187
201
|
|
|
188
202
|
// Verify the actor in the activity matches the signature.
|
|
189
|
-
const activityActor =
|
|
203
|
+
const activityActor =
|
|
204
|
+
typeof activity.actor === 'string' ? activity.actor : activity.actor?.id;
|
|
190
205
|
if (activityActor !== actorUri) {
|
|
191
206
|
logger.debug(`Inbox: Actor mismatch. Signed: ${actorUri}, Activity: ${activityActor}`);
|
|
192
207
|
return res.status(403).json({ error: 'Actor mismatch' });
|
|
@@ -199,7 +214,10 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
199
214
|
// is never dropped.
|
|
200
215
|
let enqueued = false;
|
|
201
216
|
try {
|
|
202
|
-
enqueued = await config.inbound.enqueueInboxActivity({
|
|
217
|
+
enqueued = await config.inbound.enqueueInboxActivity({
|
|
218
|
+
activity,
|
|
219
|
+
verifiedActorUri: actorUri,
|
|
220
|
+
});
|
|
203
221
|
} catch (err) {
|
|
204
222
|
logger.error('Failed to enqueue inbox activity — processing inline:', err);
|
|
205
223
|
enqueued = false;
|
|
@@ -243,7 +261,8 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
243
261
|
|
|
244
262
|
const userId = String(user._id || user.id);
|
|
245
263
|
|
|
246
|
-
const rawCount: unknown =
|
|
264
|
+
const rawCount: unknown =
|
|
265
|
+
direction === 'followers' ? user._count?.followers : user._count?.following;
|
|
247
266
|
const profileTotal = typeof rawCount === 'number' ? rawCount : undefined;
|
|
248
267
|
|
|
249
268
|
if (!page) {
|
|
@@ -256,7 +275,9 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
256
275
|
totalItems = (await config.fetchFollowPage(userId, direction, 0, 1)).total;
|
|
257
276
|
} catch (err) {
|
|
258
277
|
logger.warn('[Federation] follow-collection summary total lookup failed', {
|
|
259
|
-
username,
|
|
278
|
+
username,
|
|
279
|
+
direction,
|
|
280
|
+
error: err,
|
|
260
281
|
});
|
|
261
282
|
}
|
|
262
283
|
}
|
|
@@ -277,7 +298,12 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
277
298
|
let total = profileTotal ?? 0;
|
|
278
299
|
let hasMore = false;
|
|
279
300
|
try {
|
|
280
|
-
const pageResult = await config.fetchFollowPage(
|
|
301
|
+
const pageResult = await config.fetchFollowPage(
|
|
302
|
+
userId,
|
|
303
|
+
direction,
|
|
304
|
+
offset,
|
|
305
|
+
FOLLOW_PAGE_SIZE,
|
|
306
|
+
);
|
|
281
307
|
members = pageResult.members;
|
|
282
308
|
total = pageResult.total;
|
|
283
309
|
hasMore = pageResult.hasMore;
|
|
@@ -285,7 +311,10 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
285
311
|
// Fail-soft: never 500 the whole collection on an Oxy graph hiccup — serve
|
|
286
312
|
// an empty page against the best-known total rather than crashing.
|
|
287
313
|
logger.warn('[Federation] follow-collection Oxy graph list failed, serving empty page', {
|
|
288
|
-
username,
|
|
314
|
+
username,
|
|
315
|
+
direction,
|
|
316
|
+
offset,
|
|
317
|
+
error: err,
|
|
289
318
|
});
|
|
290
319
|
}
|
|
291
320
|
|
|
@@ -293,9 +322,10 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
293
322
|
.map((member) => memberActorUri(member, urls))
|
|
294
323
|
.filter((uri): uri is string => uri !== null);
|
|
295
324
|
|
|
296
|
-
const pageId =
|
|
297
|
-
|
|
298
|
-
|
|
325
|
+
const pageId =
|
|
326
|
+
offset > 0
|
|
327
|
+
? `${collectionUrl(username)}?page=true&offset=${offset}`
|
|
328
|
+
: `${collectionUrl(username)}?page=true`;
|
|
299
329
|
|
|
300
330
|
const pageResponse: Record<string, unknown> = {
|
|
301
331
|
'@context': AP_CONTEXT,
|
|
@@ -357,7 +387,7 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
357
387
|
},
|
|
358
388
|
};
|
|
359
389
|
res.set('Content-Type', apContentType);
|
|
360
|
-
res.set('Cache-Control',
|
|
390
|
+
res.set('Cache-Control', httpCacheControl);
|
|
361
391
|
return res.json(actorObject);
|
|
362
392
|
}
|
|
363
393
|
|
|
@@ -398,7 +428,7 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
398
428
|
});
|
|
399
429
|
|
|
400
430
|
res.set('Content-Type', apContentType);
|
|
401
|
-
res.set('Cache-Control',
|
|
431
|
+
res.set('Cache-Control', httpCacheControl);
|
|
402
432
|
return res.json({ '@context': AP_CONTEXT, ...actorObject });
|
|
403
433
|
} catch (err) {
|
|
404
434
|
logger.error('Actor endpoint error:', err);
|