@oxy.so/federation 1.0.1 → 2.0.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.
@@ -32,6 +32,12 @@ const AP_CONTENT_TYPE = 'application/activity+json';
32
32
  /** Maximum decompressed response sizes accepted from untrusted federation hosts. */
33
33
  const ACTOR_BODY_MAX_BYTES = 1024 * 1024;
34
34
  const COLLECTION_BODY_MAX_BYTES = 64 * 1024;
35
+ /**
36
+ * Collection responses that DEFINITIVELY withhold the count: the owner hid the
37
+ * collection (401/403) or it no longer exists (404/410). Anything else non-2xx
38
+ * is a failed attempt, not an answer.
39
+ */
40
+ const COLLECTION_WITHHELD_STATUSES = new Set([401, 403, 404, 410]);
35
41
  const ERROR_BODY_MAX_BYTES = 4 * 1024;
36
42
  async function readBoundedResponseBody(res, maxBytes) {
37
43
  const contentLength = res.headers.get('content-length');
@@ -389,9 +395,11 @@ export class ActorResolver {
389
395
  alsoKnownAs,
390
396
  networkAcct: networkIdentity?.federatedUsername,
391
397
  remoteCreatedAt: typeof actor.published === 'string' ? new Date(actor.published) : undefined,
392
- followersCount,
393
- followingCount,
394
- postsCount,
398
+ // Omitted, not written as `undefined`, when this refresh could not tell:
399
+ // an absent key is what tells the store to keep the value it has.
400
+ ...(followersCount !== undefined && { followersCount }),
401
+ ...(followingCount !== undefined && { followingCount }),
402
+ ...(postsCount !== undefined && { postsCount }),
395
403
  lastFetchedAt: new Date(),
396
404
  };
397
405
  const fedActor = await this.config.store.upsertActor(actorId, update);
@@ -423,9 +431,11 @@ export class ActorResolver {
423
431
  // string away made both of those unrepresentable, so the stale text
424
432
  // survived every later refresh with nothing in the logs.
425
433
  bio: identityBio,
426
- followersCount,
427
- followingCount,
428
- postsCount,
434
+ // The identity bridge takes a number or nothing; an unknown count is
435
+ // sent as nothing rather than as a zero it would store.
436
+ followersCount: followersCount ?? undefined,
437
+ followingCount: followingCount ?? undefined,
438
+ postsCount: postsCount ?? undefined,
429
439
  oxyUserId: fedActor.oxyUserId ?? undefined,
430
440
  };
431
441
  const oxyId = await this.config.identity.resolveExternalUser(normalized, { forceAvatarRefresh });
@@ -503,19 +513,36 @@ export class ActorResolver {
503
513
  this.config.logger.warn(`[FedSync] failed to tombstone gone actor ${actorUri}:`, err);
504
514
  }
505
515
  }
506
- /** Fetch the totalItems count from an ActivityPub collection URL. */
516
+ /**
517
+ * Read an ActivityPub collection's `totalItems`.
518
+ *
519
+ * Every failure used to come back as `0`, so a follower count the remote HID,
520
+ * or one a timeout kept us from reading, was stored and shown as a real
521
+ * "0 followers" — indistinguishable from an account nobody follows. A failure
522
+ * now says which kind it is (see {@link CollectionCount}):
523
+ *
524
+ * - `null` when the answer is definitive: no collection advertised, 401/403
525
+ * (the owner hid it), 404/410 (it is gone), or a readable collection with no
526
+ * usable `totalItems` (the server does not publish the count).
527
+ * - `undefined` when this attempt simply failed — a thrown fetch (timeout,
528
+ * network, SSRF refusal), any other non-2xx (429, 5xx), or a body that could
529
+ * not be read as a JSON object. The next refresh may well succeed, so the
530
+ * caller keeps whatever it last knew rather than forgetting it.
531
+ */
507
532
  async fetchCollectionCount(url) {
508
533
  if (!url)
509
- return 0;
534
+ return null;
510
535
  try {
511
536
  const res = await this.config.signedFetch(url, AP_CONTENT_TYPE);
512
- if (!res.ok)
513
- return 0;
537
+ if (!res.ok) {
538
+ return COLLECTION_WITHHELD_STATUSES.has(res.status) ? null : undefined;
539
+ }
514
540
  const col = await readBoundedJson(res, COLLECTION_BODY_MAX_BYTES);
515
- return typeof col.totalItems === 'number' ? col.totalItems : 0;
541
+ const total = col.totalItems;
542
+ return typeof total === 'number' && Number.isSafeInteger(total) && total >= 0 ? total : null;
516
543
  }
517
544
  catch {
518
- return 0;
545
+ return undefined;
519
546
  }
520
547
  }
521
548
  /**