@oxyhq/core 3.14.0 → 3.16.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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/index.js +3 -1
- package/dist/cjs/mixins/OxyServices.applications.js +43 -0
- package/dist/cjs/mixins/OxyServices.assets.js +14 -35
- package/dist/cjs/mixins/OxyServices.auth.js +12 -1
- package/dist/cjs/mixins/OxyServices.links.js +68 -0
- package/dist/cjs/mixins/OxyServices.nodes.js +175 -0
- package/dist/cjs/mixins/index.js +8 -0
- package/dist/cjs/utils/ssoBounce.js +48 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/mixins/OxyServices.applications.js +43 -0
- package/dist/esm/mixins/OxyServices.assets.js +14 -35
- package/dist/esm/mixins/OxyServices.auth.js +12 -1
- package/dist/esm/mixins/OxyServices.links.js +65 -0
- package/dist/esm/mixins/OxyServices.nodes.js +172 -0
- package/dist/esm/mixins/index.js +8 -0
- package/dist/esm/utils/ssoBounce.js +46 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +4 -2
- package/dist/types/mixins/OxyServices.applications.d.ts +51 -0
- package/dist/types/mixins/OxyServices.assets.d.ts +8 -29
- package/dist/types/mixins/OxyServices.auth.d.ts +8 -0
- package/dist/types/mixins/OxyServices.links.d.ts +102 -0
- package/dist/types/mixins/OxyServices.nodes.d.ts +242 -0
- package/dist/types/mixins/index.d.ts +3 -1
- package/dist/types/utils/ssoBounce.d.ts +61 -0
- package/package.json +1 -1
- package/src/index.ts +5 -0
- package/src/mixins/OxyServices.applications.ts +79 -0
- package/src/mixins/OxyServices.assets.ts +14 -44
- package/src/mixins/OxyServices.auth.ts +35 -1
- package/src/mixins/OxyServices.links.ts +103 -0
- package/src/mixins/OxyServices.nodes.ts +348 -0
- package/src/mixins/__tests__/OxyServices.links.test.ts +154 -0
- package/src/mixins/__tests__/OxyServices.nodes.test.ts +341 -0
- package/src/mixins/__tests__/commonsSignIn.test.ts +41 -16
- package/src/mixins/__tests__/connectedApps.test.ts +123 -0
- package/src/mixins/__tests__/getFileDownloadUrl.test.ts +10 -24
- package/src/mixins/index.ts +10 -0
- package/src/utils/__tests__/ssoBounce.test.ts +28 -0
- package/src/utils/ssoBounce.ts +69 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-Node Methods Mixin (self-sovereign identity layer — Fase 5 user nodes)
|
|
3
|
+
*
|
|
4
|
+
* The client surface for a user's personal data NODE — the decentralised store
|
|
5
|
+
* that holds an authentic copy of their signed-record chain. Commons drives all
|
|
6
|
+
* of this:
|
|
7
|
+
*
|
|
8
|
+
* - {@link OxyServicesNodesMixin.registerNode} registers (or re-registers) a
|
|
9
|
+
* SELF-HOSTED node. Registration is NOT a bespoke endpoint — it is a signed
|
|
10
|
+
* `type:'node'` v2 record (`collection: 'app.oxy.node'`, `rkey: 'self'`,
|
|
11
|
+
* last-writer-wins) published through the EXISTING `POST /identity/records`
|
|
12
|
+
* path; the server verifies it and materializes the operational
|
|
13
|
+
* {@link UserNodeStatus} cache as a side effect, so the registration's
|
|
14
|
+
* authority is the user's own signature, never an Oxy grant.
|
|
15
|
+
* - {@link OxyServicesNodesMixin.getMyNode} reads the caller's cached node
|
|
16
|
+
* status (`GET /nodes/me`) — the fast, stale-but-instant projection plus the
|
|
17
|
+
* live liveness badge Oxy maintains with background probes.
|
|
18
|
+
* - {@link OxyServicesNodesMixin.removeMyNode} revokes the registration
|
|
19
|
+
* (`DELETE /nodes/me`) so the node leaves the DID document and the liveness
|
|
20
|
+
* sweeps.
|
|
21
|
+
* - {@link OxyServicesNodesMixin.provisionManagedVault} asks Oxy to operate a
|
|
22
|
+
* MANAGED vault on the caller's behalf (`POST /nodes/managed`) — the
|
|
23
|
+
* "Create your vault" convenience for non-technical users (Oxy custodial-signs
|
|
24
|
+
* the node record; `managed:true, controller:'oxy'`).
|
|
25
|
+
* - {@link OxyServicesNodesMixin.notifyNodeIngest} sends an unauthenticated
|
|
26
|
+
* HINT (`POST /nodes/ingest/notify/:userId`) that a user's node has new
|
|
27
|
+
* records; the server fully re-verifies before ingesting, so the hint can
|
|
28
|
+
* never inject data.
|
|
29
|
+
*
|
|
30
|
+
* `registerNode` signs on the caller's per-subject hash chain with the on-device
|
|
31
|
+
* identity key (reusing {@link SignatureService.signRecordV2} — the same
|
|
32
|
+
* `ES256K-DER-SHA256` scheme + {@link signedRecordSigningInput} the identity and
|
|
33
|
+
* civic mixins use), so it is NATIVE-ONLY: it throws on web (where `KeyManager`
|
|
34
|
+
* has no key) and when no user is authenticated. Reading the node status,
|
|
35
|
+
* revoking, provisioning a managed vault, and sending an ingest hint are plain
|
|
36
|
+
* authenticated/public requests with no signing.
|
|
37
|
+
*
|
|
38
|
+
* The wire shapes here are API-INTERNAL (the F5 user-node surface is not yet a
|
|
39
|
+
* published `@oxyhq/contracts` schema), so {@link UserNodeStatus} mirrors the
|
|
40
|
+
* server's `serializeNode` projection exactly. Dates cross the wire as ISO
|
|
41
|
+
* strings.
|
|
42
|
+
*/
|
|
43
|
+
import type { OxyServicesBase } from '../OxyServices.base';
|
|
44
|
+
import { SignatureService } from '../crypto/signatureService';
|
|
45
|
+
import { buildUserDid } from './OxyServices.identity';
|
|
46
|
+
import { CACHE_TIMES } from './mixinHelpers';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* AtProto-style collection (NSID) for a user-node registration record — matches
|
|
50
|
+
* the server's `NODE_COLLECTION`. A user has exactly one node, so the record is
|
|
51
|
+
* keyed by the constant {@link NODE_RKEY} (last-writer-wins): re-registering
|
|
52
|
+
* over-writes the single `self` record rather than appending a second node.
|
|
53
|
+
*/
|
|
54
|
+
const NODE_COLLECTION = 'app.oxy.node';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The AtProto-style record key for the single node registration — matches the
|
|
58
|
+
* server's `NODE_RKEY`. Constant (`'self'`) because a user has one node.
|
|
59
|
+
*/
|
|
60
|
+
const NODE_RKEY = 'self';
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Cache-key prefix of every node read (`GET /nodes/me`). Swept after a
|
|
64
|
+
* register / revoke / managed-provision so a re-read reflects the new node
|
|
65
|
+
* (or its absence) instead of a stale cached one. The identity tag is a key
|
|
66
|
+
* SUFFIX, so this prefix invalidates the resource for every cached identity.
|
|
67
|
+
*/
|
|
68
|
+
const NODES_CACHE_PREFIX = 'GET:/nodes/';
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Cache-key prefix of the current user's `GET /users/me`. Swept alongside the
|
|
72
|
+
* node caches because the user's derived DID document embeds an `#oxy-node`
|
|
73
|
+
* service entry derived from the node row, so registering / revoking / managing
|
|
74
|
+
* a node changes user-facing identity state.
|
|
75
|
+
*/
|
|
76
|
+
const USERS_ME_CACHE_PREFIX = 'GET:/users/me';
|
|
77
|
+
|
|
78
|
+
/** How Oxy and the node move records: the node pulls (default), or Oxy pushes. */
|
|
79
|
+
export type UserNodeMode = 'pull' | 'push';
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Who operates the node:
|
|
83
|
+
* - `self` — the user self-hosts the node (registered by their own signed
|
|
84
|
+
* `type:'node'` record).
|
|
85
|
+
* - `oxy` — Oxy operates a MANAGED vault on the user's behalf (custodial-signed
|
|
86
|
+
* `type:'node'` record; the `controller:[OXY_DID]` model).
|
|
87
|
+
*/
|
|
88
|
+
export type UserNodeController = 'self' | 'oxy';
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Liveness badge of a node, maintained ONLY by Oxy's background probes:
|
|
92
|
+
* - `active` — the last probe reached the node's liveness manifest.
|
|
93
|
+
* - `unreachable` — the last probe failed (DNS/connect/timeout/non-2xx); the
|
|
94
|
+
* cached row is still served, only the badge changes.
|
|
95
|
+
* - `revoked` — the user removed the registration; excluded from the DID
|
|
96
|
+
* document and from liveness sweeps.
|
|
97
|
+
*/
|
|
98
|
+
export type UserNodeLivenessStatus = 'active' | 'unreachable' | 'revoked';
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The caller's registered node, as projected by the server's `serializeNode`
|
|
102
|
+
* (`GET /nodes/me`, `POST /nodes/managed`). A denormalised, fast-to-read copy of
|
|
103
|
+
* the authoritative signed `type:'node'` record plus the live liveness state Oxy
|
|
104
|
+
* maintains in the background.
|
|
105
|
+
*
|
|
106
|
+
* `mode` / `managed` / `controller` / `status` are always present (server fields
|
|
107
|
+
* with defaults); the probe/sync fields and `nodeDid` are present only once set.
|
|
108
|
+
* The `Date` fields cross the wire as ISO-8601 strings.
|
|
109
|
+
*/
|
|
110
|
+
export interface UserNodeStatus {
|
|
111
|
+
/** Optional DID the node advertises for itself (informational). */
|
|
112
|
+
nodeDid?: string;
|
|
113
|
+
/** The node's public HTTPS base URL (where its liveness manifest lives). */
|
|
114
|
+
endpoint: string;
|
|
115
|
+
/** The node's secp256k1 public key (hex) — records it signs verify against this. */
|
|
116
|
+
nodePublicKey: string;
|
|
117
|
+
/** Transport direction. `pull` (the node paces its own sync) by default. */
|
|
118
|
+
mode: UserNodeMode;
|
|
119
|
+
/** Whether Oxy operates this node on the user's behalf (managed vault). */
|
|
120
|
+
managed: boolean;
|
|
121
|
+
/** Operator of the node — `self` (user self-hosts) or `oxy` (managed vault). */
|
|
122
|
+
controller: UserNodeController;
|
|
123
|
+
/** Liveness badge — maintained only by background probes, never a read handler. */
|
|
124
|
+
status: UserNodeLivenessStatus;
|
|
125
|
+
/** Last time a probe reached the node successfully (ISO-8601). */
|
|
126
|
+
lastSeenAt?: string;
|
|
127
|
+
/** Last time a probe ran, success or failure (ISO-8601). */
|
|
128
|
+
lastProbeAt?: string;
|
|
129
|
+
/** Human-readable reason the last probe OR ingest failed (cleared on success). */
|
|
130
|
+
lastError?: string;
|
|
131
|
+
/** Last synced chain `seq` for two-way sync (advanced only by the ingest worker). */
|
|
132
|
+
cursor?: number;
|
|
133
|
+
/** Last time the ingest worker ran a pull for this node (ISO-8601). */
|
|
134
|
+
lastSyncedAt?: string;
|
|
135
|
+
/** When the node was first registered (ISO-8601). */
|
|
136
|
+
createdAt: string;
|
|
137
|
+
/** When the node row was last updated (ISO-8601). */
|
|
138
|
+
updatedAt: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Input for {@link OxyServicesNodesMixin.registerNode} — the operational facts of
|
|
143
|
+
* the user's self-hosted node that go into the signed `type:'node'` record.
|
|
144
|
+
*/
|
|
145
|
+
export interface RegisterNodeInput {
|
|
146
|
+
/** The node's public HTTPS base URL (where its liveness manifest is served). */
|
|
147
|
+
endpoint: string;
|
|
148
|
+
/** The node's secp256k1 public key (hex) — records the node signs verify against this. */
|
|
149
|
+
nodePublicKey: string;
|
|
150
|
+
/** Transport direction; defaults to `'pull'` when omitted. */
|
|
151
|
+
mode?: UserNodeMode;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Result of {@link OxyServicesNodesMixin.removeMyNode} (`DELETE /nodes/me`). */
|
|
155
|
+
export interface RemoveNodeResult {
|
|
156
|
+
/** `true` when an active registration was flipped to `revoked`. */
|
|
157
|
+
revoked: boolean;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The current chain head as returned by `GET /identity/records/:userId/chain/head`.
|
|
162
|
+
* `headRecordId` is `null` and `seq` is `-1` when the subject has no chain yet,
|
|
163
|
+
* so the next record's coordinates are always `seq: head.seq + 1` (genesis = 0)
|
|
164
|
+
* and `prev: head.headRecordId` (genesis = null).
|
|
165
|
+
*/
|
|
166
|
+
interface ChainHeadResponse {
|
|
167
|
+
headRecordId: string | null;
|
|
168
|
+
seq: number;
|
|
169
|
+
recordCount: number;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function OxyServicesNodesMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
173
|
+
return class extends Base {
|
|
174
|
+
constructor(...args: any[]) {
|
|
175
|
+
super(...(args as [any]));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Register (or re-register) the caller's SELF-HOSTED personal data node.
|
|
180
|
+
*
|
|
181
|
+
* Builds the `{ endpoint, nodePublicKey, mode }` node record, signs a v2
|
|
182
|
+
* envelope on the caller's own per-subject hash chain (fetching the current
|
|
183
|
+
* chain head first so `seq`/`prev` are never stale), and publishes it through
|
|
184
|
+
* the EXISTING `POST /identity/records` path — which verifies the signature
|
|
185
|
+
* and materializes the operational node cache as a side effect. The signed
|
|
186
|
+
* record (not this call) is the authority; re-registering over-writes the
|
|
187
|
+
* single `self` record (last-writer-wins).
|
|
188
|
+
*
|
|
189
|
+
* NATIVE-ONLY: signs with the on-device identity key (throws on web / when no
|
|
190
|
+
* identity or no authenticated user — the guard fires before any network).
|
|
191
|
+
* `mode` defaults to `'pull'`. After a successful publish the node + `/users/me`
|
|
192
|
+
* GET caches are swept, then the freshly-materialized status is returned.
|
|
193
|
+
*
|
|
194
|
+
* Throws if the chain record stored but the server skipped materialization
|
|
195
|
+
* (e.g. a malformed endpoint the server rejected) — an unexpected state rather
|
|
196
|
+
* than a silent `null`.
|
|
197
|
+
*
|
|
198
|
+
* @param input - The node's endpoint, public key, and optional transport mode.
|
|
199
|
+
*/
|
|
200
|
+
async registerNode(input: RegisterNodeInput): Promise<UserNodeStatus> {
|
|
201
|
+
try {
|
|
202
|
+
const userId = this.getCurrentUserId();
|
|
203
|
+
if (!userId) {
|
|
204
|
+
throw new Error('No authenticated user — sign in before registering a node.');
|
|
205
|
+
}
|
|
206
|
+
const subject = buildUserDid(userId);
|
|
207
|
+
const record: Record<string, unknown> = {
|
|
208
|
+
endpoint: input.endpoint,
|
|
209
|
+
nodePublicKey: input.nodePublicKey,
|
|
210
|
+
mode: input.mode ?? 'pull',
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// Fetch the caller's chain head fresh (uncached) so seq/prev are correct
|
|
214
|
+
// → no bad_seq / chain_fork — exactly as the identity/civic signers do.
|
|
215
|
+
const head = await this.makeRequest<ChainHeadResponse>(
|
|
216
|
+
'GET',
|
|
217
|
+
`/identity/records/${encodeURIComponent(userId)}/chain/head`,
|
|
218
|
+
undefined,
|
|
219
|
+
{ cache: false },
|
|
220
|
+
);
|
|
221
|
+
const envelope = await SignatureService.signRecordV2('node', subject, record, {
|
|
222
|
+
seq: head.seq + 1,
|
|
223
|
+
prev: head.headRecordId,
|
|
224
|
+
collection: NODE_COLLECTION,
|
|
225
|
+
rkey: NODE_RKEY,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
await this.makeRequest(
|
|
229
|
+
'POST',
|
|
230
|
+
'/identity/records',
|
|
231
|
+
envelope,
|
|
232
|
+
{ cache: false },
|
|
233
|
+
);
|
|
234
|
+
this._sweepNodeCaches();
|
|
235
|
+
|
|
236
|
+
const node = await this.getMyNode();
|
|
237
|
+
if (!node) {
|
|
238
|
+
throw new Error('Node registration stored but the node could not be materialized.');
|
|
239
|
+
}
|
|
240
|
+
return node;
|
|
241
|
+
} catch (error) {
|
|
242
|
+
throw this.handleError(error);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Read the caller's registered node status (`GET /nodes/me`), or `null` when
|
|
248
|
+
* the caller has no node. Auth required; short-TTL cached (the liveness badge
|
|
249
|
+
* is background-maintained) and swept after the caller's own
|
|
250
|
+
* register / revoke / managed-provision.
|
|
251
|
+
*/
|
|
252
|
+
async getMyNode(): Promise<UserNodeStatus | null> {
|
|
253
|
+
try {
|
|
254
|
+
const res = await this.makeRequest<{ node: UserNodeStatus | null }>(
|
|
255
|
+
'GET',
|
|
256
|
+
'/nodes/me',
|
|
257
|
+
undefined,
|
|
258
|
+
{ cache: true, cacheTTL: CACHE_TIMES.SHORT },
|
|
259
|
+
);
|
|
260
|
+
return res.node ?? null;
|
|
261
|
+
} catch (error) {
|
|
262
|
+
throw this.handleError(error);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Revoke the caller's node registration (`DELETE /nodes/me`). The node flips
|
|
268
|
+
* to `revoked` server-side (leaving the DID document and liveness sweeps).
|
|
269
|
+
* Auth required; the node + `/users/me` GET caches are swept on success.
|
|
270
|
+
*
|
|
271
|
+
* Maps the server's `{ success }` to the SDK's `{ revoked }` semantic.
|
|
272
|
+
*/
|
|
273
|
+
async removeMyNode(): Promise<RemoveNodeResult> {
|
|
274
|
+
try {
|
|
275
|
+
const res = await this.makeRequest<{ success: boolean }>(
|
|
276
|
+
'DELETE',
|
|
277
|
+
'/nodes/me',
|
|
278
|
+
undefined,
|
|
279
|
+
{ cache: false },
|
|
280
|
+
);
|
|
281
|
+
this._sweepNodeCaches();
|
|
282
|
+
return { revoked: res.success === true };
|
|
283
|
+
} catch (error) {
|
|
284
|
+
throw this.handleError(error);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Provision (or refresh) an Oxy-operated MANAGED vault for the caller
|
|
290
|
+
* (`POST /nodes/managed`) — the "Create your vault" convenience for
|
|
291
|
+
* non-technical users. Oxy custodial-signs the node registration onto the
|
|
292
|
+
* caller's chain and returns the materialized node (`managed:true,
|
|
293
|
+
* controller:'oxy'`). Idempotent server-side. Auth required; the owner id is
|
|
294
|
+
* resolved from the session, never the body. The node + `/users/me` GET caches
|
|
295
|
+
* are swept on success.
|
|
296
|
+
*/
|
|
297
|
+
async provisionManagedVault(): Promise<UserNodeStatus> {
|
|
298
|
+
try {
|
|
299
|
+
const res = await this.makeRequest<{ node: UserNodeStatus }>(
|
|
300
|
+
'POST',
|
|
301
|
+
'/nodes/managed',
|
|
302
|
+
undefined,
|
|
303
|
+
{ cache: false },
|
|
304
|
+
);
|
|
305
|
+
this._sweepNodeCaches();
|
|
306
|
+
return res.node;
|
|
307
|
+
} catch (error) {
|
|
308
|
+
throw this.handleError(error);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Send an ingest HINT that a user's node has new records
|
|
314
|
+
* (`POST /nodes/ingest/notify/:userId`). Unauthenticated by design and
|
|
315
|
+
* fire-and-forget on the server (it only schedules a background re-pull of the
|
|
316
|
+
* named user's OWN node, then fully re-verifies — a notify can never inject
|
|
317
|
+
* data), so this resolves once the 202 hint is accepted and returns nothing.
|
|
318
|
+
*
|
|
319
|
+
* @param userId - The user whose node may have new records. URL-encoded.
|
|
320
|
+
*/
|
|
321
|
+
async notifyNodeIngest(userId: string): Promise<void> {
|
|
322
|
+
try {
|
|
323
|
+
await this.makeRequest<{ accepted: boolean }>(
|
|
324
|
+
'POST',
|
|
325
|
+
`/nodes/ingest/notify/${encodeURIComponent(userId)}`,
|
|
326
|
+
undefined,
|
|
327
|
+
{ cache: false },
|
|
328
|
+
);
|
|
329
|
+
} catch (error) {
|
|
330
|
+
throw this.handleError(error);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Sweep the GET caches a node mutation invalidates: every node read
|
|
336
|
+
* (`GET:/nodes/`) so a re-read reflects the new node / its absence, and
|
|
337
|
+
* `/users/me` because the user's derived DID document embeds an `#oxy-node`
|
|
338
|
+
* service entry that changes on register / revoke / manage. Public rather
|
|
339
|
+
* than `private` because mixins compose into an exported anonymous class
|
|
340
|
+
* where TypeScript cannot represent a private member in the emitted
|
|
341
|
+
* declaration file (TS4094) — mirrors the civic / identity cache sweepers.
|
|
342
|
+
*/
|
|
343
|
+
_sweepNodeCaches(): void {
|
|
344
|
+
this.clearCacheByPrefix(NODES_CACHE_PREFIX);
|
|
345
|
+
this.clearCacheByPrefix(USERS_ME_CACHE_PREFIX);
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link-preview mixin tests.
|
|
3
|
+
*
|
|
4
|
+
* Stubs `makeRequest` so the tests run with no network, then asserts:
|
|
5
|
+
* - `getLinkPreview` builds `GET /links/preview?url=<percent-encoded>&wait=0|1`,
|
|
6
|
+
* percent-encoding the target URL so its `?`/`&`/`=` cannot break out of the
|
|
7
|
+
* query string, defaults `wait` to `0`, sends `wait=1` when `{ wait: true }`,
|
|
8
|
+
* reads uncached (`cache:false`), returns the preview, and surfaces errors.
|
|
9
|
+
* - `getLinkPreviews` de-duplicates input, no-ops to `{}` on empty input,
|
|
10
|
+
* chunks at 50 URLs/request (`POST /links/previews`), merges every chunk's
|
|
11
|
+
* `data` map keyed by the requested url, and surfaces a chunk failure.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { LinkPreview, LinkPreviewBatchResponse } from '@oxyhq/contracts';
|
|
15
|
+
import { OxyServices } from '../../OxyServices';
|
|
16
|
+
|
|
17
|
+
const sampleResolved: LinkPreview = {
|
|
18
|
+
url: 'https://news.example.com/a',
|
|
19
|
+
status: 'resolved',
|
|
20
|
+
title: 'Headline',
|
|
21
|
+
description: 'Lede',
|
|
22
|
+
image: 'https://cloud.oxy.so/img123',
|
|
23
|
+
siteName: 'Example News',
|
|
24
|
+
favicon: 'https://cloud.oxy.so/fav123',
|
|
25
|
+
resolvedAt: '2026-06-28T00:00:00.000Z',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe('OxyServices.links', () => {
|
|
29
|
+
let oxy: OxyServices;
|
|
30
|
+
let makeRequestSpy: jest.SpyInstance;
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
34
|
+
makeRequestSpy = jest.spyOn(oxy, 'makeRequest');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
jest.restoreAllMocks();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('getLinkPreview', () => {
|
|
42
|
+
it('percent-encodes the URL and defaults wait=0', async () => {
|
|
43
|
+
makeRequestSpy.mockResolvedValueOnce(sampleResolved);
|
|
44
|
+
|
|
45
|
+
const result = await oxy.getLinkPreview('https://news.example.com/a?b=c&d=e');
|
|
46
|
+
|
|
47
|
+
expect(result).toEqual(sampleResolved);
|
|
48
|
+
expect(makeRequestSpy).toHaveBeenCalledTimes(1);
|
|
49
|
+
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
50
|
+
'GET',
|
|
51
|
+
'/links/preview?url=https%3A%2F%2Fnews.example.com%2Fa%3Fb%3Dc%26d%3De&wait=0',
|
|
52
|
+
undefined,
|
|
53
|
+
{ cache: false },
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('sends wait=1 when opts.wait is true', async () => {
|
|
58
|
+
makeRequestSpy.mockResolvedValueOnce({ url: 'https://x.test/', status: 'pending' });
|
|
59
|
+
|
|
60
|
+
await oxy.getLinkPreview('https://x.test/', { wait: true });
|
|
61
|
+
|
|
62
|
+
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
63
|
+
'GET',
|
|
64
|
+
'/links/preview?url=https%3A%2F%2Fx.test%2F&wait=1',
|
|
65
|
+
undefined,
|
|
66
|
+
{ cache: false },
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('surfaces errors via handleError', async () => {
|
|
71
|
+
makeRequestSpy.mockRejectedValueOnce(new Error('boom'));
|
|
72
|
+
|
|
73
|
+
await expect(oxy.getLinkPreview('https://x.test/')).rejects.toThrow('boom');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('getLinkPreviews', () => {
|
|
78
|
+
it('returns {} and performs no network call for empty / whitespace input', async () => {
|
|
79
|
+
await expect(oxy.getLinkPreviews([])).resolves.toEqual({});
|
|
80
|
+
await expect(oxy.getLinkPreviews(['', ' '])).resolves.toEqual({});
|
|
81
|
+
expect(makeRequestSpy).not.toHaveBeenCalled();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('de-duplicates and sends a single chunk for <= 50 unique URLs', async () => {
|
|
85
|
+
const response: LinkPreviewBatchResponse = {
|
|
86
|
+
data: { 'https://a.test/': sampleResolved },
|
|
87
|
+
};
|
|
88
|
+
makeRequestSpy.mockResolvedValueOnce(response);
|
|
89
|
+
|
|
90
|
+
const result = await oxy.getLinkPreviews([
|
|
91
|
+
'https://a.test/',
|
|
92
|
+
'https://a.test/', // duplicate
|
|
93
|
+
' ', // dropped
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
expect(result).toEqual(response.data);
|
|
97
|
+
expect(makeRequestSpy).toHaveBeenCalledTimes(1);
|
|
98
|
+
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
99
|
+
'POST',
|
|
100
|
+
'/links/previews',
|
|
101
|
+
{ urls: ['https://a.test/'] },
|
|
102
|
+
{ cache: false },
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('chunks at 50 URLs per request and merges each chunk data map', async () => {
|
|
107
|
+
const urls = Array.from({ length: 120 }, (_, i) => `https://site.test/${i}`);
|
|
108
|
+
|
|
109
|
+
makeRequestSpy.mockImplementation(
|
|
110
|
+
async (
|
|
111
|
+
_method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
|
112
|
+
_url: string,
|
|
113
|
+
data?: { urls: string[] },
|
|
114
|
+
): Promise<LinkPreviewBatchResponse> => {
|
|
115
|
+
const chunkUrls = data?.urls ?? [];
|
|
116
|
+
const dataMap: Record<string, LinkPreview> = {};
|
|
117
|
+
for (const u of chunkUrls) {
|
|
118
|
+
dataMap[u] = { url: u, status: 'resolved', title: `t-${u}` };
|
|
119
|
+
}
|
|
120
|
+
return { data: dataMap };
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const result = await oxy.getLinkPreviews(urls);
|
|
125
|
+
|
|
126
|
+
// 120 unique URLs => 50 + 50 + 20 across three POSTs.
|
|
127
|
+
expect(makeRequestSpy).toHaveBeenCalledTimes(3);
|
|
128
|
+
const chunkSizes = makeRequestSpy.mock.calls.map((call) => (call[2] as { urls: string[] }).urls.length);
|
|
129
|
+
expect(chunkSizes).toEqual([50, 50, 20]);
|
|
130
|
+
|
|
131
|
+
// Every requested URL is present in the merged, request-keyed map.
|
|
132
|
+
expect(Object.keys(result)).toHaveLength(120);
|
|
133
|
+
expect(result['https://site.test/0']).toEqual({
|
|
134
|
+
url: 'https://site.test/0',
|
|
135
|
+
status: 'resolved',
|
|
136
|
+
title: 't-https://site.test/0',
|
|
137
|
+
});
|
|
138
|
+
expect(result['https://site.test/119']).toEqual({
|
|
139
|
+
url: 'https://site.test/119',
|
|
140
|
+
status: 'resolved',
|
|
141
|
+
title: 't-https://site.test/119',
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('surfaces a chunk failure via handleError', async () => {
|
|
146
|
+
const urls = Array.from({ length: 60 }, (_, i) => `https://site.test/${i}`);
|
|
147
|
+
makeRequestSpy
|
|
148
|
+
.mockResolvedValueOnce({ data: {} })
|
|
149
|
+
.mockRejectedValueOnce(new Error('chunk failed'));
|
|
150
|
+
|
|
151
|
+
await expect(oxy.getLinkPreviews(urls)).rejects.toThrow('chunk failed');
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
});
|