@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.
- package/LICENSE +675 -201
- package/NOTICE +7 -2
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/node/actorResolver.js +39 -12
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/node/actorResolver.js +39 -12
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/node/actorResolver.d.ts +39 -4
- package/dist/types/node/index.d.ts +1 -1
- package/package.json +3 -3
- package/src/__tests__/actorCollectionCounts.test.ts +172 -0
- package/src/node/actorResolver.ts +64 -15
- package/src/node/index.ts +1 -0
|
@@ -78,11 +78,31 @@ export interface FederatedActorUpsert {
|
|
|
78
78
|
*/
|
|
79
79
|
networkAcct?: string;
|
|
80
80
|
remoteCreatedAt?: Date;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
/**
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
/**
|
|
721
|
-
|
|
722
|
-
|
|
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)
|
|
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
|
-
|
|
775
|
+
const total = col.totalItems;
|
|
776
|
+
return typeof total === 'number' && Number.isSafeInteger(total) && total >= 0 ? total : null;
|
|
728
777
|
} catch {
|
|
729
|
-
return
|
|
778
|
+
return undefined;
|
|
730
779
|
}
|
|
731
780
|
}
|
|
732
781
|
|