@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.
@@ -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.1",
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
+ });
@@ -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,