@oxy.so/federation 1.0.0 → 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.
@@ -337,6 +337,10 @@ export declare function upstreamHandleFromProfileField(options: {
337
337
  readonly hosts: readonly string[];
338
338
  /** Fixed path segments before the handle (`bsky.app/profile/<handle>` ⇒ `['profile']`). */
339
339
  readonly pathPrefix?: readonly string[];
340
+ /** Opt-in for the exact BirdsiteLive double-HTTPS serialization defect. */
341
+ readonly repairRepeatedHttpsScheme?: boolean;
342
+ /** Require the named assertion's link to explicitly carry rel=me. */
343
+ readonly requireRelMe?: boolean;
340
344
  }): BridgeDerivation;
341
345
  /**
342
346
  * Read the upstream handle out of `alsoKnownAs` profile URLs — the shape where an
@@ -78,11 +78,31 @@ export interface FederatedActorUpsert {
78
78
  */
79
79
  networkAcct?: string;
80
80
  remoteCreatedAt?: Date;
81
- followersCount: number;
82
- followingCount: number;
83
- postsCount: number;
81
+ /**
82
+ * The remote's own totals for its `followers`, `following` and `outbox`
83
+ * collections, read from each collection's `totalItems`. Each is a
84
+ * {@link CollectionCount}, and the three states mean three different writes:
85
+ *
86
+ * - a number — the remote reported it; store it. `0` is a real zero.
87
+ * - `null` — the remote definitively does not disclose it (no collection
88
+ * advertised, a hidden collection answering 401/403, a gone one answering
89
+ * 404/410, or a collection that carries no numeric `totalItems`). Store it
90
+ * as UNKNOWN — never as `0`.
91
+ * - absent (`undefined`) — this refresh could not tell (a timeout, a network
92
+ * error, a 429/5xx, an unreadable body). The store must LEAVE the stored
93
+ * value in place, and a first insert must record it as unknown.
94
+ */
95
+ followersCount?: number | null;
96
+ followingCount?: number | null;
97
+ postsCount?: number | null;
84
98
  lastFetchedAt: Date;
85
99
  }
100
+ /**
101
+ * A remote collection's size as one refresh observed it: the reported
102
+ * `totalItems`, `null` when the remote definitively withholds it, or `undefined`
103
+ * when this attempt could not find out. See {@link FederatedActorUpsert.followersCount}.
104
+ */
105
+ export type CollectionCount = number | null | undefined;
86
106
  /** Bring-your-own-store: the AP actor cache stays in the app DB behind this adapter. */
87
107
  export interface FederatedActorStore<TActor extends FederatedActorRecordBase> {
88
108
  /** Look up a cached actor by its protocol URI. */
@@ -245,7 +265,22 @@ export declare class ActorResolver<TActor extends FederatedActorRecordBase> {
245
265
  * allowed to throw out of the caller. Idempotent.
246
266
  */
247
267
  tombstoneGoneActor(actorUri: string): Promise<void>;
248
- /** Fetch the totalItems count from an ActivityPub collection URL. */
268
+ /**
269
+ * Read an ActivityPub collection's `totalItems`.
270
+ *
271
+ * Every failure used to come back as `0`, so a follower count the remote HID,
272
+ * or one a timeout kept us from reading, was stored and shown as a real
273
+ * "0 followers" — indistinguishable from an account nobody follows. A failure
274
+ * now says which kind it is (see {@link CollectionCount}):
275
+ *
276
+ * - `null` when the answer is definitive: no collection advertised, 401/403
277
+ * (the owner hid it), 404/410 (it is gone), or a readable collection with no
278
+ * usable `totalItems` (the server does not publish the count).
279
+ * - `undefined` when this attempt simply failed — a thrown fetch (timeout,
280
+ * network, SSRF refusal), any other non-2xx (429, 5xx), or a body that could
281
+ * not be read as a JSON object. The next refresh may well succeed, so the
282
+ * caller keeps whatever it last knew rather than forgetting it.
283
+ */
249
284
  private fetchCollectionCount;
250
285
  /**
251
286
  * Get a cached actor or fetch if missing/stale (>24h).
@@ -28,7 +28,7 @@ export { createIdentityBridge, type IdentityBridge, type IdentityBridgeConfig, t
28
28
  * 410-Gone tombstone) over a bring-your-own-store adapter, the identity bridge,
29
29
  * and injected transports + text normalization.
30
30
  */
31
- export { createActorResolver, ActorResolver, type ActorResolverConfig, type ActorResolverIdentity, type ActorResolverLogger, type ActorTextAdapter, type FederatedActorStore, type FederatedActorRecordBase, type FederatedActorUpsert, type FederatedActorField, type WebFingerFetch, type WebFingerJrd, } from './actorResolver';
31
+ export { createActorResolver, ActorResolver, type ActorResolverConfig, type ActorResolverIdentity, type ActorResolverLogger, type ActorTextAdapter, type FederatedActorStore, type CollectionCount, type FederatedActorRecordBase, type FederatedActorUpsert, type FederatedActorField, type WebFingerFetch, type WebFingerJrd, } from './actorResolver';
32
32
  /**
33
33
  * Outbound activity delivery + the follow lifecycle (Follow / Undo(Follow) /
34
34
  * Accept(Follow)) + the `Update(Person)` actor rebroadcast, over injected key
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/federation",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "description": "Oxy Federation — the app-agnostic ActivityPub identity + follow engine substrate: the network-connector contract, normalized cross-network DTOs, HTTP signatures, actor resolution, the outbound delivery transport + follow lifecycle, the inbound dispatcher, and the webfinger/actor/inbox Express routers. Domain-parameterized so every Oxy app backend federates under its own domain.",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -61,13 +61,13 @@
61
61
  "directory": "packages/federation"
62
62
  },
63
63
  "author": "OxyHQ",
64
- "license": "Apache-2.0",
64
+ "license": "SEE LICENSE IN LICENSE",
65
65
  "homepage": "https://oxy.so",
66
66
  "engines": {
67
67
  "node": ">=18.0.0"
68
68
  },
69
69
  "scripts": {
70
- "build": "bun run --filter @oxy.so/contracts build && bun run --filter @oxy.so/protocol build && bun run --filter @oxy.so/core build && bun run build:cjs && bun run build:esm && bun run build:types",
70
+ "build": "node ../core/scripts/build-workspace-deps.mjs @oxy.so/contracts @oxy.so/protocol @oxy.so/core && bun run build:cjs && bun run build:esm && bun run build:types",
71
71
  "build:cjs": "tsc -p tsconfig.cjs.json",
72
72
  "build:esm": "tsc -p tsconfig.esm.json && node scripts/fix-esm-imports.mjs",
73
73
  "build:types": "tsc -p tsconfig.types.json",
@@ -0,0 +1,172 @@
1
+ import type { NormalizedExternalActor } from '../index';
2
+ import {
3
+ createActorResolver,
4
+ type ActorResolverConfig,
5
+ type FederatedActorRecordBase,
6
+ type FederatedActorUpsert,
7
+ } from '../node/actorResolver';
8
+
9
+ /**
10
+ * A remote collection count that could not be read is UNKNOWN, not zero.
11
+ *
12
+ * `fetchCollectionCount` used to turn every failure — a hidden collection's 403,
13
+ * a timeout, a body with no `totalItems` — into `0`, so the store recorded and
14
+ * the profile showed "0 followers" for accounts with thousands. These pin the
15
+ * three outcomes apart: a reported number (including a real 0), a definitive
16
+ * `null`, and an ABSENT key for a failed attempt, which is what tells the store
17
+ * to keep the value it already has.
18
+ */
19
+
20
+ interface TestActor extends FederatedActorRecordBase {
21
+ uri: string;
22
+ }
23
+
24
+ const ACTOR = 'https://remote.example/users/bob';
25
+ const FOLLOWERS = 'https://remote.example/users/bob/followers';
26
+ const FOLLOWING = 'https://remote.example/users/bob/following';
27
+ const OUTBOX = 'https://remote.example/users/bob/outbox';
28
+
29
+ type CollectionReply = Response | (() => Promise<Response>);
30
+
31
+ function actorDocument(overrides: Record<string, unknown> = {}): string {
32
+ return JSON.stringify({
33
+ id: ACTOR,
34
+ type: 'Person',
35
+ inbox: 'https://remote.example/users/bob/inbox',
36
+ preferredUsername: 'bob',
37
+ followers: FOLLOWERS,
38
+ following: FOLLOWING,
39
+ outbox: OUTBOX,
40
+ ...overrides,
41
+ });
42
+ }
43
+
44
+ function collection(totalItems: unknown): Response {
45
+ return new Response(JSON.stringify({ type: 'OrderedCollection', totalItems }));
46
+ }
47
+
48
+ async function resolveWith(replies: {
49
+ actor?: string;
50
+ followers?: CollectionReply;
51
+ following?: CollectionReply;
52
+ outbox?: CollectionReply;
53
+ }): Promise<{ upserts: FederatedActorUpsert[]; bridged: NormalizedExternalActor[] }> {
54
+ const upserts: FederatedActorUpsert[] = [];
55
+ const bridged: NormalizedExternalActor[] = [];
56
+ const reply = (r: CollectionReply | undefined): Promise<Response> => {
57
+ if (!r) return Promise.resolve(collection(7));
58
+ return typeof r === 'function' ? r() : Promise.resolve(r);
59
+ };
60
+
61
+ const config: ActorResolverConfig<TestActor> = {
62
+ federationEnabled: true,
63
+ signedFetch: async (url) => {
64
+ if (url === ACTOR) return new Response(replies.actor ?? actorDocument());
65
+ if (url === FOLLOWERS) return reply(replies.followers);
66
+ if (url === FOLLOWING) return reply(replies.following);
67
+ if (url === OUTBOX) return reply(replies.outbox);
68
+ return new Response(null, { status: 404 });
69
+ },
70
+ fetchWebFinger: async () => null,
71
+ isBlockedDomain: () => false,
72
+ normalizeFederatedAcct: (acct) => acct,
73
+ domainFromAcct: (acct) => acct.split('@')[1],
74
+ firstStringUrl: () => undefined,
75
+ store: {
76
+ findActorByUri: async () => null,
77
+ upsertActor: async (uri, update) => {
78
+ upserts.push(update);
79
+ return { _id: 'row-1', uri };
80
+ },
81
+ findActorByPublicKeyId: async () => null,
82
+ setActorOxyUserId: async () => {},
83
+ tombstoneActor: async () => null,
84
+ },
85
+ identity: {
86
+ resolveExternalUser: async (actor) => {
87
+ bridged.push(actor);
88
+ return null;
89
+ },
90
+ reportActorGone: async () => 'archived',
91
+ },
92
+ text: {
93
+ inlineField: (value) => (typeof value === 'string' ? value : ''),
94
+ inlineDisplayName: (raw) => raw,
95
+ sanitizeFieldValue: (html) => html,
96
+ htmlToPlainText: (html) => html,
97
+ },
98
+ logger: { info: () => {}, warn: () => {} },
99
+ };
100
+
101
+ await createActorResolver(config).fetchRemoteActor(ACTOR);
102
+ return { upserts, bridged };
103
+ }
104
+
105
+ describe('fetchRemoteActor — remote collection counts', () => {
106
+ it('stores reported totals, and a real 0 stays 0', async () => {
107
+ const { upserts } = await resolveWith({
108
+ followers: collection(1234),
109
+ following: collection(0),
110
+ outbox: collection(56),
111
+ });
112
+
113
+ expect(upserts).toHaveLength(1);
114
+ expect(upserts[0]).toMatchObject({ followersCount: 1234, followingCount: 0, postsCount: 56 });
115
+ });
116
+
117
+ it.each([401, 403, 404, 410])('records a collection that answers %i as unknown (null), not 0', async (status) => {
118
+ const { upserts } = await resolveWith({
119
+ followers: new Response(null, { status }),
120
+ following: collection(3),
121
+ });
122
+
123
+ expect(upserts[0]?.followersCount).toBeNull();
124
+ expect(upserts[0]?.followingCount).toBe(3);
125
+ });
126
+
127
+ it.each([
128
+ ['missing', undefined],
129
+ ['a string', '1234'],
130
+ ['negative', -1],
131
+ ['fractional', 1.5],
132
+ ])('records a collection whose totalItems is %s as unknown (null)', async (_label, totalItems) => {
133
+ const { upserts } = await resolveWith({ followers: collection(totalItems) });
134
+
135
+ expect(upserts[0]?.followersCount).toBeNull();
136
+ });
137
+
138
+ it('records an actor that advertises no collection as unknown (null)', async () => {
139
+ const { upserts } = await resolveWith({
140
+ actor: actorDocument({ followers: undefined, following: undefined }),
141
+ });
142
+
143
+ expect(upserts[0]?.followersCount).toBeNull();
144
+ expect(upserts[0]?.followingCount).toBeNull();
145
+ });
146
+
147
+ it.each([
148
+ ['a timeout', () => Promise.reject(new Error('The operation was aborted due to timeout'))],
149
+ ['a 500', () => Promise.resolve(new Response(null, { status: 500 }))],
150
+ ['a 429', () => Promise.resolve(new Response(null, { status: 429 }))],
151
+ ['malformed JSON', () => Promise.resolve(new Response('{"totalItems": 12'))],
152
+ ['an empty body', () => Promise.resolve(new Response(''))],
153
+ ] as const)('OMITS a count whose fetch failed with %s, so a known value is kept', async (_label, followers) => {
154
+ const { upserts } = await resolveWith({ followers, following: collection(9) });
155
+
156
+ expect(upserts[0]).not.toHaveProperty('followersCount');
157
+ expect(upserts[0]?.followingCount).toBe(9);
158
+ });
159
+
160
+ it('never hands the identity bridge a zero it did not read', async () => {
161
+ const { bridged } = await resolveWith({
162
+ followers: new Response(null, { status: 403 }),
163
+ following: () => Promise.reject(new Error('timeout')),
164
+ outbox: collection(0),
165
+ });
166
+
167
+ expect(bridged).toHaveLength(1);
168
+ expect(bridged[0]?.followersCount).toBeUndefined();
169
+ expect(bridged[0]?.followingCount).toBeUndefined();
170
+ expect(bridged[0]?.postsCount).toBe(0);
171
+ });
172
+ });
@@ -61,6 +61,32 @@ function candidate(overrides: Partial<NetworkIdentityCandidate> = {}): NetworkId
61
61
  }
62
62
 
63
63
  describe('createBridgeRelabeller', () => {
64
+ it('refuses contradictory accepted profile assertions regardless of their order', () => {
65
+ const derive = upstreamHandleFromProfileField({ fieldName: 'Official', hosts: ['x.com'], requireRelMe: true });
66
+ const fields = ['alice', 'bob'].map(handle => ({ name: 'Official', value: `<a href="https://x.com/${handle}" rel="me">Official</a>` }));
67
+ expect(derive(candidate({ fields }))).toBeUndefined();
68
+ expect(derive(candidate({ fields: [...fields].reverse() }))).toBeUndefined();
69
+ expect(derive(candidate({ fields: [fields[0], fields[0]] }))).toBe('alice');
70
+ expect(derive(candidate({ fields: [{ name: 'Official', value: '<a href="https://x.com/alice">Official</a>' }] }))).toBeUndefined();
71
+ });
72
+ it('repairs the observed double-HTTPS Official assertion only with the reviewed opt-in', () => {
73
+ const actor = candidate({ fields: [{ name: 'Official', value: '<a href="https://https://twitter.com/jordievole" rel="me">Official</a>' }] });
74
+ expect(upstreamHandleFromProfileField({ fieldName: 'Official', hosts: ['twitter.com'] })(actor)).toBeUndefined();
75
+ expect(upstreamHandleFromProfileField({ fieldName: 'Official', hosts: ['twitter.com'], repairRepeatedHttpsScheme: true })(actor)).toBe('jordievole');
76
+ });
77
+
78
+ it.each([
79
+ 'https://https://attacker.example/jordievole',
80
+ 'https://https://twitter.com@attacker.example/jordievole',
81
+ 'https://https://attacker@twitter.com/jordievole',
82
+ 'https://https://twitter.com:444/jordievole',
83
+ 'https://https://https://twitter.com/jordievole',
84
+ 'http://https://twitter.com/jordievole',
85
+ 'https://https://twitter.com/i/status/123',
86
+ ])('does not widen the repair into an attribution for %s', href => {
87
+ const actor = candidate({ fields: [{ name: 'Official', value: `<a href="${href}" rel="me">Official</a>` }] });
88
+ expect(upstreamHandleFromProfileField({ fieldName: 'Official', hosts: ['twitter.com'], repairRepeatedHttpsScheme: true })(actor)).toBeUndefined();
89
+ });
64
90
  it('re-labels an actor onto the network its bridge mirrors', () => {
65
91
  const identity = createBridgeRelabeller([entry()]).deriveNetworkIdentity(candidate());
66
92
  expect(identity?.federatedUsername).toBe('wired@x.com');
@@ -164,6 +164,7 @@ export function parseUpstreamProfileUrl(
164
164
  return undefined;
165
165
  }
166
166
  if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined;
167
+ if (url.username || url.password || url.port) return undefined;
167
168
 
168
169
  const host = canonicalFederationHost(url.hostname);
169
170
  for (const network of networks) {
@@ -397,6 +398,7 @@ function profileUrlHandle(
397
398
  return undefined;
398
399
  }
399
400
  if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined;
401
+ if (url.username || url.password || url.port) return undefined;
400
402
  const host = canonicalFederationHost(url.hostname);
401
403
  if (!allowedHosts.some((allowed) => canonicalFederationHost(allowed) === host)) return undefined;
402
404
 
@@ -409,8 +411,17 @@ function profileUrlHandle(
409
411
  }
410
412
 
411
413
  /** Every `href="…"` in a sanitized field value, in document order. */
412
- function fieldHrefs(value: string): string[] {
414
+ function fieldHrefs(value: string, requireRelMe = false): string[] {
413
415
  const hrefs: string[] = [];
416
+ if (requireRelMe) {
417
+ for (const anchor of value.matchAll(/<a\b([^>]*)>/gi)) {
418
+ const rel = /\brel\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(anchor[1]);
419
+ if (!(rel?.[1] ?? rel?.[2] ?? '').toLowerCase().split(/\s+/).includes('me')) continue;
420
+ const href = /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(anchor[1]);
421
+ if (href) hrefs.push(href[1] ?? href[2]);
422
+ }
423
+ return hrefs;
424
+ }
414
425
  const pattern = /href="([^"]*)"/gi;
415
426
  let match = pattern.exec(value);
416
427
  while (match !== null) {
@@ -431,18 +442,30 @@ export function upstreamHandleFromProfileField(options: {
431
442
  readonly hosts: readonly string[];
432
443
  /** Fixed path segments before the handle (`bsky.app/profile/<handle>` ⇒ `['profile']`). */
433
444
  readonly pathPrefix?: readonly string[];
445
+ /** Opt-in for the exact BirdsiteLive double-HTTPS serialization defect. */
446
+ readonly repairRepeatedHttpsScheme?: boolean;
447
+ /** Require the named assertion's link to explicitly carry rel=me. */
448
+ readonly requireRelMe?: boolean;
434
449
  }): BridgeDerivation {
435
450
  const wanted = options.fieldName.toLowerCase();
436
451
  const prefix = options.pathPrefix ?? [];
437
452
  return (candidate) => {
453
+ const handles = new Map<string, string>();
438
454
  for (const field of candidate.fields) {
439
455
  if (field.name.trim().toLowerCase() !== wanted) continue;
440
- for (const href of fieldHrefs(field.value)) {
441
- const handle = profileUrlHandle(href, options.hosts, prefix);
442
- if (handle !== undefined && handle.length > 0) return handle;
456
+ for (const href of fieldHrefs(field.value, options.requireRelMe)) {
457
+ // Observed bird.makeup Official assertion, 2026-09-13:
458
+ // https://https://twitter.com/jordievole. Repair exactly one duplicated
459
+ // HTTPS prefix, then run the ordinary host/path/credential validation.
460
+ const candidateHref = options.repairRepeatedHttpsScheme && href.startsWith('https://https://')
461
+ ? href.slice('https://'.length) : href;
462
+ let handle: string | undefined;
463
+ try { handle = profileUrlHandle(candidateHref, options.hosts, prefix); } catch { continue; }
464
+ if (handle !== undefined && handle.length > 0) handles.set(handle.toLowerCase(), handle);
443
465
  }
444
466
  }
445
- return undefined;
467
+ // Conflicting source assertions cannot be settled by document order.
468
+ return handles.size === 1 ? handles.values().next().value : undefined;
446
469
  };
447
470
  }
448
471
 
@@ -45,6 +45,12 @@ const AP_CONTENT_TYPE = 'application/activity+json';
45
45
  /** Maximum decompressed response sizes accepted from untrusted federation hosts. */
46
46
  const ACTOR_BODY_MAX_BYTES = 1024 * 1024;
47
47
  const COLLECTION_BODY_MAX_BYTES = 64 * 1024;
48
+ /**
49
+ * Collection responses that DEFINITIVELY withhold the count: the owner hid the
50
+ * collection (401/403) or it no longer exists (404/410). Anything else non-2xx
51
+ * is a failed attempt, not an answer.
52
+ */
53
+ const COLLECTION_WITHHELD_STATUSES: ReadonlySet<number> = new Set([401, 403, 404, 410]);
48
54
  const ERROR_BODY_MAX_BYTES = 4 * 1024;
49
55
 
50
56
  async function readBoundedResponseBody(res: Response, maxBytes: number): Promise<string> {
@@ -157,12 +163,33 @@ export interface FederatedActorUpsert {
157
163
  */
158
164
  networkAcct?: string;
159
165
  remoteCreatedAt?: Date;
160
- followersCount: number;
161
- followingCount: number;
162
- postsCount: number;
166
+ /**
167
+ * The remote's own totals for its `followers`, `following` and `outbox`
168
+ * collections, read from each collection's `totalItems`. Each is a
169
+ * {@link CollectionCount}, and the three states mean three different writes:
170
+ *
171
+ * - a number — the remote reported it; store it. `0` is a real zero.
172
+ * - `null` — the remote definitively does not disclose it (no collection
173
+ * advertised, a hidden collection answering 401/403, a gone one answering
174
+ * 404/410, or a collection that carries no numeric `totalItems`). Store it
175
+ * as UNKNOWN — never as `0`.
176
+ * - absent (`undefined`) — this refresh could not tell (a timeout, a network
177
+ * error, a 429/5xx, an unreadable body). The store must LEAVE the stored
178
+ * value in place, and a first insert must record it as unknown.
179
+ */
180
+ followersCount?: number | null;
181
+ followingCount?: number | null;
182
+ postsCount?: number | null;
163
183
  lastFetchedAt: Date;
164
184
  }
165
185
 
186
+ /**
187
+ * A remote collection's size as one refresh observed it: the reported
188
+ * `totalItems`, `null` when the remote definitively withholds it, or `undefined`
189
+ * when this attempt could not find out. See {@link FederatedActorUpsert.followersCount}.
190
+ */
191
+ export type CollectionCount = number | null | undefined;
192
+
166
193
  /** Bring-your-own-store: the AP actor cache stays in the app DB behind this adapter. */
167
194
  export interface FederatedActorStore<TActor extends FederatedActorRecordBase> {
168
195
  /** Look up a cached actor by its protocol URI. */
@@ -591,9 +618,11 @@ export class ActorResolver<TActor extends FederatedActorRecordBase> {
591
618
  alsoKnownAs,
592
619
  networkAcct: networkIdentity?.federatedUsername,
593
620
  remoteCreatedAt: typeof actor.published === 'string' ? new Date(actor.published) : undefined,
594
- followersCount,
595
- followingCount,
596
- postsCount,
621
+ // Omitted, not written as `undefined`, when this refresh could not tell:
622
+ // an absent key is what tells the store to keep the value it has.
623
+ ...(followersCount !== undefined && { followersCount }),
624
+ ...(followingCount !== undefined && { followingCount }),
625
+ ...(postsCount !== undefined && { postsCount }),
597
626
  lastFetchedAt: new Date(),
598
627
  };
599
628
 
@@ -627,9 +656,11 @@ export class ActorResolver<TActor extends FederatedActorRecordBase> {
627
656
  // string away made both of those unrepresentable, so the stale text
628
657
  // survived every later refresh with nothing in the logs.
629
658
  bio: identityBio,
630
- followersCount,
631
- followingCount,
632
- postsCount,
659
+ // The identity bridge takes a number or nothing; an unknown count is
660
+ // sent as nothing rather than as a zero it would store.
661
+ followersCount: followersCount ?? undefined,
662
+ followingCount: followingCount ?? undefined,
663
+ postsCount: postsCount ?? undefined,
633
664
  oxyUserId: fedActor.oxyUserId ?? undefined,
634
665
  };
635
666
  const oxyId = await this.config.identity.resolveExternalUser(normalized, { forceAvatarRefresh });
@@ -717,16 +748,34 @@ export class ActorResolver<TActor extends FederatedActorRecordBase> {
717
748
  }
718
749
  }
719
750
 
720
- /** Fetch the totalItems count from an ActivityPub collection URL. */
721
- private async fetchCollectionCount(url?: string): Promise<number> {
722
- if (!url) return 0;
751
+ /**
752
+ * Read an ActivityPub collection's `totalItems`.
753
+ *
754
+ * Every failure used to come back as `0`, so a follower count the remote HID,
755
+ * or one a timeout kept us from reading, was stored and shown as a real
756
+ * "0 followers" — indistinguishable from an account nobody follows. A failure
757
+ * now says which kind it is (see {@link CollectionCount}):
758
+ *
759
+ * - `null` when the answer is definitive: no collection advertised, 401/403
760
+ * (the owner hid it), 404/410 (it is gone), or a readable collection with no
761
+ * usable `totalItems` (the server does not publish the count).
762
+ * - `undefined` when this attempt simply failed — a thrown fetch (timeout,
763
+ * network, SSRF refusal), any other non-2xx (429, 5xx), or a body that could
764
+ * not be read as a JSON object. The next refresh may well succeed, so the
765
+ * caller keeps whatever it last knew rather than forgetting it.
766
+ */
767
+ private async fetchCollectionCount(url?: string): Promise<CollectionCount> {
768
+ if (!url) return null;
723
769
  try {
724
770
  const res = await this.config.signedFetch(url, AP_CONTENT_TYPE);
725
- if (!res.ok) return 0;
771
+ if (!res.ok) {
772
+ return COLLECTION_WITHHELD_STATUSES.has(res.status) ? null : undefined;
773
+ }
726
774
  const col = await readBoundedJson(res, COLLECTION_BODY_MAX_BYTES);
727
- return typeof col.totalItems === 'number' ? col.totalItems : 0;
775
+ const total = col.totalItems;
776
+ return typeof total === 'number' && Number.isSafeInteger(total) && total >= 0 ? total : null;
728
777
  } catch {
729
- return 0;
778
+ return undefined;
730
779
  }
731
780
  }
732
781
 
package/src/node/index.ts CHANGED
@@ -55,6 +55,7 @@ export {
55
55
  type ActorResolverLogger,
56
56
  type ActorTextAdapter,
57
57
  type FederatedActorStore,
58
+ type CollectionCount,
58
59
  type FederatedActorRecordBase,
59
60
  type FederatedActorUpsert,
60
61
  type FederatedActorField,