@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.
Files changed (64) hide show
  1. package/README.md +31 -0
  2. package/dist/cjs/.tsbuildinfo +1 -1
  3. package/dist/cjs/apUri.js +4 -2
  4. package/dist/cjs/httpSignature.js +1 -1
  5. package/dist/cjs/index.js +2 -2
  6. package/dist/cjs/networkIdentity.js +3 -2
  7. package/dist/cjs/node/actorResolver.js +24 -20
  8. package/dist/cjs/node/actorRouter.js +16 -6
  9. package/dist/cjs/node/delivery.js +35 -10
  10. package/dist/cjs/node/httpCacheControl.js +11 -0
  11. package/dist/cjs/node/inboundDispatch.js +4 -3
  12. package/dist/cjs/node/index.js +8 -1
  13. package/dist/cjs/node/tcoLinks.js +97 -0
  14. package/dist/cjs/node/webfingerRouter.js +6 -4
  15. package/dist/esm/.tsbuildinfo +1 -1
  16. package/dist/esm/apUri.js +4 -2
  17. package/dist/esm/httpSignature.js +1 -1
  18. package/dist/esm/index.js +3 -3
  19. package/dist/esm/networkIdentity.js +3 -2
  20. package/dist/esm/node/actorResolver.js +24 -20
  21. package/dist/esm/node/actorRouter.js +16 -6
  22. package/dist/esm/node/delivery.js +33 -10
  23. package/dist/esm/node/httpCacheControl.js +8 -0
  24. package/dist/esm/node/inboundDispatch.js +4 -3
  25. package/dist/esm/node/index.js +6 -1
  26. package/dist/esm/node/tcoLinks.js +94 -0
  27. package/dist/esm/node/webfingerRouter.js +6 -4
  28. package/dist/types/.tsbuildinfo +1 -1
  29. package/dist/types/httpSignature.d.ts +1 -1
  30. package/dist/types/index.d.ts +3 -3
  31. package/dist/types/node/actorRouter.d.ts +2 -0
  32. package/dist/types/node/delivery.d.ts +23 -4
  33. package/dist/types/node/httpCacheControl.d.ts +2 -0
  34. package/dist/types/node/index.d.ts +6 -1
  35. package/dist/types/node/tcoLinks.d.ts +41 -0
  36. package/dist/types/node/webfingerRouter.d.ts +2 -0
  37. package/package.json +2 -3
  38. package/src/__tests__/actorCollectionCounts.test.ts +27 -18
  39. package/src/__tests__/actorObject.test.ts +27 -7
  40. package/src/__tests__/actorResolver.test.ts +37 -22
  41. package/src/__tests__/actorResolverNetworkIdentity.test.ts +73 -36
  42. package/src/__tests__/apUri.test.ts +9 -5
  43. package/src/__tests__/delivery.test.ts +165 -25
  44. package/src/__tests__/federationHost.test.ts +13 -3
  45. package/src/__tests__/httpSignature.test.ts +1 -3
  46. package/src/__tests__/inboundDispatch.test.ts +80 -30
  47. package/src/__tests__/networkIdentity.test.ts +240 -113
  48. package/src/__tests__/routers.test.ts +157 -27
  49. package/src/__tests__/tcoLinks.test.ts +21 -0
  50. package/src/actorObject.ts +8 -3
  51. package/src/apUri.ts +6 -2
  52. package/src/httpSignature.ts +1 -1
  53. package/src/index.ts +8 -3
  54. package/src/networkIdentity.ts +14 -5
  55. package/src/node/actorResolver.ts +91 -50
  56. package/src/node/actorRouter.ts +44 -14
  57. package/src/node/delivery.ts +112 -30
  58. package/src/node/httpCacheControl.ts +8 -0
  59. package/src/node/identityBridge.ts +38 -18
  60. package/src/node/inboundDispatch.ts +33 -13
  61. package/src/node/index.ts +13 -0
  62. package/src/node/signedFetch.ts +3 -1
  63. package/src/node/tcoLinks.ts +114 -0
  64. 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 urlA.protocol === urlB.protocol
318
- && urlA.port === urlB.port
319
- && isSameFederationHost(urlA.hostname, urlB.hostname);
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(`[FedSync] fetchRemoteActor skipping own/blocked domain ${requestedHost} for ${currentUri}`);
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(`[FedSync] fetchRemoteActor 410 Gone for ${currentUri} — tombstoning actor`);
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(`[FedSync] fetchRemoteActor HTTP ${res.status} ${res.statusText} for ${currentUri} body=${body.slice(0, 500)}`);
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 = canonicalAcctHint
447
- || (pathUsername ? this.config.normalizeFederatedAcct(`${pathUsername}@${parsed.hostname}`) : undefined);
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(`[FedSync] fetchRemoteActor 410 Gone for resolved ${resolved} — tombstoning actor ${currentUri}`);
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
- this.config.logger.info(`[FedSync] fetchRemoteActor HTTP ${res.status} for resolved ${resolved} body=${body2.slice(0, 500)}`);
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(`[FedSync] WebFinger returned ${resolved ?? 'null'} for ${acct}`);
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(`[FedSync] fetchRemoteActor missing fields for ${currentUri}: id=${!!actor.id} inbox=${!!actor.inbox} type=${String(actor.type)} keys=${Object.keys(actor).join(',')}`);
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(`[FedSync] rejecting actor ${currentUri}: fetched URI is not authoritative for claimed id ${actorId}`);
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(`[FedSync] rejecting actor ${currentUri}: publicKey is not self-consistent for claimed id ${actorId}`);
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 = this.config.text.inlineField(actor.preferredUsername)
497
- || this.config.text.inlineField(actor.name)
498
- || 'unknown';
499
- const actorWebfinger = typeof actor.webfinger === 'string'
500
- ? this.config.normalizeFederatedAcct(actor.webfinger)
501
- : undefined;
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 = verifiedAcctHint
509
- || verifiedActorWebfinger
510
- || this.config.normalizeFederatedAcct(`${username}@${actorHost}`)
511
- || `${username.toLowerCase()}@${actorHost}`;
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(`[FedSync] fetchRemoteActor blocked domain ${domain} actorHost=${actorHost} for ${currentUri}`);
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 = typeof attRecord.value === 'string'
540
- ? this.config.text.sanitizeFieldValue(attRecord.value)
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 = typeof actor.summary === 'string' ? this.config.text.htmlToPlainText(actor.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 `&#10;` or
557
587
  // `&nbsp;` 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: typeof actor.published === 'string' ? new Date(actor.published) : undefined,
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, { forceAvatarRefresh });
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
- || localPart.length === 0
712
- || federatedUsername !== `${localPart}@${domain}`
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
- + `"${derived.federatedUsername}" is not bindable to domain "${derived.instanceDomain}"`,
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(`[FedSync] 410 Gone for ${actorUri} — no stored actor row to tombstone`);
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(`[FedSync] actor-gone report for ${actorUri} (oxyUserId ${actor.oxyUserId}) → ${outcome}`);
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(`[FedSync] getOrFetchActor refusing own/blocked domain for ${actorUri}`);
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 = !existing.lastFetchedAt || Date.now() - existing.lastFetchedAt.getTime() > ACTOR_STALE_MS;
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 = typeof lastFetchedMs === 'number'
824
- && Date.now() - lastFetchedMs < ACTOR_REFRESH_MIN_INTERVAL_MS;
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(`[FedSync] background actor refresh failed for ${actorUri}: ${message}`);
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
  }
@@ -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: { activity: Record<string, unknown>; verifiedActorUri: string }): Promise<boolean>;
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(activity: Record<string, unknown>, verifiedActorUri: string): Promise<void>;
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 { verified, actorUri, reason: signatureError } = await verifyHttpSignature(
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 = typeof activity.actor === 'string' ? activity.actor : activity.actor?.id;
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({ activity, verifiedActorUri: actorUri });
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 = direction === 'followers' ? user._count?.followers : user._count?.following;
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, direction, error: err,
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(userId, direction, offset, FOLLOW_PAGE_SIZE);
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, direction, offset, error: err,
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 = offset > 0
297
- ? `${collectionUrl(username)}?page=true&offset=${offset}`
298
- : `${collectionUrl(username)}?page=true`;
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', 'max-age=1800');
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', 'max-age=1800');
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);