@calimero-network/mero-js 29.14.0 → 29.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/README.md +36 -1
- package/dist/cloud-v2/approvals.d.ts +39 -2
- package/dist/cloud-v2/approvals.js +5 -1
- package/dist/cloud-v2/client.d.ts +25 -132
- package/dist/cloud-v2/client.js +48 -455
- package/dist/cloud-v2/errors.d.ts +18 -0
- package/dist/cloud-v2/errors.js +16 -0
- package/dist/cloud-v2/index.d.ts +2 -2
- package/dist/cloud-v2/index.js +1 -1
- package/dist/index.browser.mjs +5 -5
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +523 -386
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/index.mjs +523 -386
- package/dist/index.mjs.map +4 -4
- package/dist/runtime/index.d.ts +10 -0
- package/dist/runtime/index.js +21 -0
- package/dist/runtime/transport.d.ts +311 -0
- package/dist/runtime/transport.js +576 -0
- package/dist/runtime.browser.mjs +67 -0
- package/dist/runtime.cjs +67 -0
- package/dist/runtime.mjs +67 -0
- package/package.json +9 -2
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runtime every route of a Calimero node is sent through: the part of
|
|
3
|
+
* this client that is not a route.
|
|
4
|
+
*
|
|
5
|
+
* A route layer (the hand-written {@link CalimeroClient} today; one generated
|
|
6
|
+
* from the node's OpenAPI document later) knows a method, a path and what the
|
|
7
|
+
* route takes and answers. Everything else is here, behind one call,
|
|
8
|
+
* {@link Transport.request}:
|
|
9
|
+
*
|
|
10
|
+
* - the sealed channel to each node, opened only after the node's attestation
|
|
11
|
+
* passed the caller's policy (`../sealed`, `../cloud-v2/attestation`);
|
|
12
|
+
* - the credential (an API key or a session), and the session's refresh when a
|
|
13
|
+
* node answers `401` to it;
|
|
14
|
+
* - moving to another node of the cluster on `421 WRONG_NODE`, and failing
|
|
15
|
+
* over when one stays unavailable to a call or a view (D70 retries);
|
|
16
|
+
* - the `412 CONFIRM_REQUIRED` step-up: the passkey's assertion over the
|
|
17
|
+
* node's challenge, sent with the same body again;
|
|
18
|
+
* - error mapping (a refusal is a typed {@link CalimeroError}) and
|
|
19
|
+
* server-sent event decoding.
|
|
20
|
+
*
|
|
21
|
+
* Paths are relative to the node (`apps/{app}/intents`, a leading `/` is
|
|
22
|
+
* dropped); {@link routePath} fills a path template with encoded segments.
|
|
23
|
+
*/
|
|
24
|
+
import { SealedTransportError, createSealedFetch } from '../sealed/sealed.js';
|
|
25
|
+
import { fetchAttestedKey } from '../cloud-v2/attestation.js';
|
|
26
|
+
import { orderNodes, readDirectory } from '../cloud-v2/directory.js';
|
|
27
|
+
import { CalimeroError, ConfirmCancelled, ConfirmPromptBlocked, ConfirmRequired, LoginRequired, UnavailableError, WrongNode, errorFrom, } from '../cloud-v2/errors.js';
|
|
28
|
+
import { b64url, fromB64url } from '../cloud-v2/passkey.js';
|
|
29
|
+
export const DEFAULT_RETRY = { retries: 6, baseDelayMs: 200, maxDelayMs: 2000 };
|
|
30
|
+
/** The backoff before retry `retry` (0 for the first): `baseDelayMs` doubling, at most `maxDelayMs`. */
|
|
31
|
+
export function retryDelayMs(retry, options) {
|
|
32
|
+
return Math.min(options.baseDelayMs * 2 ** retry, options.maxDelayMs);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The user verification a confirmation asks the passkey for: what the node's
|
|
36
|
+
* `412` details name, if it names one WebAuthn knows, else `fallback` (the
|
|
37
|
+
* flow's own requirement: `required` for a step-up, which the node takes only
|
|
38
|
+
* with the user verified; `preferred` for the sign-up confirmation, which it
|
|
39
|
+
* takes with the user present).
|
|
40
|
+
*/
|
|
41
|
+
function userVerificationOf(named, fallback) {
|
|
42
|
+
return named === 'required' || named === 'preferred' || named === 'discouraged' ? named : fallback;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* What a failed confirmation prompt means to the caller. The browser or a
|
|
46
|
+
* password manager refusing to show the prompt (the page not focused because
|
|
47
|
+
* an extension's popup still holds focus, or another prompt still open) is
|
|
48
|
+
* {@link ConfirmPromptBlocked}; the user dismissing it (`NotAllowedError`, or
|
|
49
|
+
* `AbortError` when it was aborted) is {@link ConfirmCancelled}; anything
|
|
50
|
+
* else is a real failure, passed through.
|
|
51
|
+
*/
|
|
52
|
+
function promptFailure(error) {
|
|
53
|
+
const e = error;
|
|
54
|
+
const name = e?.name;
|
|
55
|
+
const message = typeof e?.message === 'string' ? e.message : '';
|
|
56
|
+
if (name === 'InvalidStateError' || /not focused|already pending/i.test(message)) {
|
|
57
|
+
return new ConfirmPromptBlocked(error);
|
|
58
|
+
}
|
|
59
|
+
if (name === 'NotAllowedError' || name === 'AbortError')
|
|
60
|
+
return new ConfirmCancelled(error);
|
|
61
|
+
return error;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The node refuses an envelope past its 64 MiB sealed cap with `413` in the
|
|
65
|
+
* clear, before opening it, so the refusal arrives as the transport's. To the
|
|
66
|
+
* caller it is the API's `TOO_LARGE`, as the Rust client reports it.
|
|
67
|
+
*/
|
|
68
|
+
function sealedError(error) {
|
|
69
|
+
if (error instanceof SealedTransportError && error.status === 413) {
|
|
70
|
+
return errorFrom(413, {
|
|
71
|
+
error: 'TOO_LARGE',
|
|
72
|
+
message: error.message.replace(/^Sealed request refused: /, ''),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return error;
|
|
76
|
+
}
|
|
77
|
+
/** Read a refused response's body and map it ({@link errorFrom}). */
|
|
78
|
+
export async function refusal(response) {
|
|
79
|
+
const text = await response.text().catch(() => '');
|
|
80
|
+
let body = {};
|
|
81
|
+
try {
|
|
82
|
+
body = text ? JSON.parse(text) : {};
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
body = { message: text };
|
|
86
|
+
}
|
|
87
|
+
return errorFrom(response.status, body, response.headers.get('retry-after'));
|
|
88
|
+
}
|
|
89
|
+
/** Server-sent event frames from a response body: `{ id, kind, data }` each, `data` parsed as JSON when it is. */
|
|
90
|
+
export async function* sseFrames(body) {
|
|
91
|
+
const reader = body.getReader();
|
|
92
|
+
const decoder = new TextDecoder();
|
|
93
|
+
let buffer = '';
|
|
94
|
+
for (;;) {
|
|
95
|
+
const { value, done } = await reader.read();
|
|
96
|
+
if (done)
|
|
97
|
+
return;
|
|
98
|
+
buffer += decoder.decode(value, { stream: true });
|
|
99
|
+
let cut;
|
|
100
|
+
while ((cut = buffer.indexOf('\n\n')) >= 0) {
|
|
101
|
+
const frame = buffer.slice(0, cut);
|
|
102
|
+
buffer = buffer.slice(cut + 2);
|
|
103
|
+
let id = '';
|
|
104
|
+
let kind = '';
|
|
105
|
+
let data = '';
|
|
106
|
+
for (const line of frame.split('\n')) {
|
|
107
|
+
if (line.startsWith('id:'))
|
|
108
|
+
id = line.slice(3).trim();
|
|
109
|
+
else if (line.startsWith('event:'))
|
|
110
|
+
kind = line.slice(6).trim();
|
|
111
|
+
else if (line.startsWith('data:'))
|
|
112
|
+
data += line.slice(5).trim();
|
|
113
|
+
}
|
|
114
|
+
if (!kind && !data)
|
|
115
|
+
continue;
|
|
116
|
+
let parsed = data;
|
|
117
|
+
try {
|
|
118
|
+
parsed = JSON.parse(data);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
/* plain text */
|
|
122
|
+
}
|
|
123
|
+
yield { id, kind, data: parsed };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* A key for a call the caller gave none, made once so every retry shares it:
|
|
129
|
+
* `node:c-` and 16 random bytes in base64url without padding, as the Rust
|
|
130
|
+
* client mints it. It only has to outlive this client's own retries, so it
|
|
131
|
+
* takes the node's short-lived class: a key starting `node:` is kept 10
|
|
132
|
+
* minutes, a caller's own key 24 hours.
|
|
133
|
+
* `getRandomValues`, not `randomUUID`, which a page outside a secure context lacks.
|
|
134
|
+
*/
|
|
135
|
+
export function newIdempotencyKey() {
|
|
136
|
+
return `node:c-${b64url(crypto.getRandomValues(new Uint8Array(16)))}`;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* `template` (`apps/{app}/blobs/{id}`) with each `{name}` replaced by
|
|
140
|
+
* `params[name]`, encoded as one path segment so an id cannot reach another
|
|
141
|
+
* route. A name `params` lacks throws.
|
|
142
|
+
*/
|
|
143
|
+
export function routePath(template, params = {}) {
|
|
144
|
+
return template.replace(/\{([^}]+)\}/g, (_, name) => {
|
|
145
|
+
const value = params[name];
|
|
146
|
+
if (value === undefined)
|
|
147
|
+
throw new Error(`no value for path parameter ${name} of ${template}`);
|
|
148
|
+
return encodeURIComponent(String(value));
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
/** `query` as a search string (`?a=1&b=2`), or `''` when it sets nothing. */
|
|
152
|
+
export function queryString(query) {
|
|
153
|
+
if (!query)
|
|
154
|
+
return '';
|
|
155
|
+
const search = new URLSearchParams();
|
|
156
|
+
for (const [name, value] of Object.entries(query)) {
|
|
157
|
+
for (const one of Array.isArray(value) ? value : [value]) {
|
|
158
|
+
if (one !== undefined && one !== null)
|
|
159
|
+
search.append(name, String(one));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const text = search.toString();
|
|
163
|
+
return text ? `?${text}` : '';
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* The ceremonies that mint a session, which {@link NodeTransport} never
|
|
167
|
+
* repeats by itself: a sign-in or sign-up is started again by its caller (with
|
|
168
|
+
* the passkey), and a refresh moves through the nodes in
|
|
169
|
+
* {@link NodeTransport.refresh}. A node answers them `421 WRONG_NODE` before
|
|
170
|
+
* it spends the challenge or the refresh token (core #4774).
|
|
171
|
+
*/
|
|
172
|
+
function ceremony(path) {
|
|
173
|
+
return path === 'auth/refresh' || path.startsWith('auth/passkey/') || RECOVERY_CEREMONIES.has(path);
|
|
174
|
+
}
|
|
175
|
+
/** The signed-out halves of an account recovery: a `401` there is the code's, not a session's. */
|
|
176
|
+
const RECOVERY_CEREMONIES = new Set(['auth/recovery/begin', 'auth/recovery/start', 'auth/recovery/finish']);
|
|
177
|
+
/** The route path a request names: no leading `/`. */
|
|
178
|
+
function relative(path) {
|
|
179
|
+
return path.replace(/^\/+/, '');
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The {@link Transport} to one Calimero cluster: its nodes, each attested
|
|
183
|
+
* under one policy before its first request, the credential, and the session's
|
|
184
|
+
* refresh. {@link CalimeroClient} is built on one (`client.transport`); a
|
|
185
|
+
* generated route layer takes one too.
|
|
186
|
+
*/
|
|
187
|
+
export class NodeTransport {
|
|
188
|
+
constructor(options) {
|
|
189
|
+
/** The one in use: an index into {@link nodeUrls}. */
|
|
190
|
+
this.at = 0;
|
|
191
|
+
/** A sealed fetch per node, each attesting its own node on first use. */
|
|
192
|
+
this.channels = new Map();
|
|
193
|
+
/** Rejections of the underlying `fetch` itself: the node could not be reached or dropped the connection. */
|
|
194
|
+
this.networkFailures = new WeakSet();
|
|
195
|
+
const trim = (url) => url.replace(/\/+$/, '');
|
|
196
|
+
this.nodeUrls = [...new Set([options.baseUrl, ...(options.nodes ?? [])].map(trim))];
|
|
197
|
+
this.policy = options.policy;
|
|
198
|
+
this.retry = { ...DEFAULT_RETRY, ...options.retry };
|
|
199
|
+
const given = options.fetch ?? ((input, init) => fetch(input, init));
|
|
200
|
+
// `fetch` rejects with a `TypeError` when it gets no answer: the connection
|
|
201
|
+
// was refused, reset or dropped before the response. The sealed fetch passes
|
|
202
|
+
// that rejection on unchanged, so it is recognised here, at its source, and
|
|
203
|
+
// not by its type alone (a bad URL is a `TypeError` too, and is not retried).
|
|
204
|
+
const raw = async (input, init) => {
|
|
205
|
+
try {
|
|
206
|
+
return await given(input, init);
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
if (error instanceof TypeError)
|
|
210
|
+
this.networkFailures.add(error);
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
this.raw = raw;
|
|
215
|
+
}
|
|
216
|
+
/** Re-read the node list from `source` when it is due, and after a failover. */
|
|
217
|
+
useDirectory(source) {
|
|
218
|
+
this.source = source;
|
|
219
|
+
}
|
|
220
|
+
/** The node requests go to now: `baseUrl`, until a `421 WRONG_NODE` or a failover moved on. */
|
|
221
|
+
get nodeUrl() {
|
|
222
|
+
return this.nodeUrls[this.at];
|
|
223
|
+
}
|
|
224
|
+
/** Every node this transport may use, in the order it moves through them. */
|
|
225
|
+
get nodes() {
|
|
226
|
+
return [...this.nodeUrls];
|
|
227
|
+
}
|
|
228
|
+
/** Authenticate every request with an API key. */
|
|
229
|
+
useApiKey(key) {
|
|
230
|
+
this.auth = key;
|
|
231
|
+
this.current = undefined;
|
|
232
|
+
}
|
|
233
|
+
/** Authenticate every request with `session`, refreshed when a node answers `401` to it. */
|
|
234
|
+
useSession(session) {
|
|
235
|
+
this.auth = session.sessionToken;
|
|
236
|
+
this.current = session;
|
|
237
|
+
}
|
|
238
|
+
/** Drop the credential: no request after this carries it. */
|
|
239
|
+
signOut() {
|
|
240
|
+
this.auth = undefined;
|
|
241
|
+
this.current = undefined;
|
|
242
|
+
}
|
|
243
|
+
get session() {
|
|
244
|
+
return this.current;
|
|
245
|
+
}
|
|
246
|
+
/** The sealed channel to `url`, attested under the policy before its first request. */
|
|
247
|
+
channel(url) {
|
|
248
|
+
let sealed = this.channels.get(url);
|
|
249
|
+
if (!sealed) {
|
|
250
|
+
sealed = createSealedFetch({
|
|
251
|
+
baseUrl: url,
|
|
252
|
+
transportPublicKey: () => fetchAttestedKey(url, this.policy, this.raw),
|
|
253
|
+
fetch: this.raw,
|
|
254
|
+
});
|
|
255
|
+
this.channels.set(url, sealed);
|
|
256
|
+
}
|
|
257
|
+
return sealed;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Move on from node `from` (the one that answered `421 WRONG_NODE`) to the
|
|
261
|
+
* next one. False when there is no other. A request that saw the same node
|
|
262
|
+
* refuse after another request moved on already does not move again.
|
|
263
|
+
*/
|
|
264
|
+
moveOn(from) {
|
|
265
|
+
if (this.nodeUrls.length < 2)
|
|
266
|
+
return false;
|
|
267
|
+
if (this.nodeUrl === from)
|
|
268
|
+
this.at = (this.at + 1) % this.nodeUrls.length;
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Fail over from node `from`, where a request's retries ran out, to the next
|
|
273
|
+
* node it has not `tried`. False when it has tried them all. A request whose
|
|
274
|
+
* node another request moved away from already goes to that node, if untried.
|
|
275
|
+
*/
|
|
276
|
+
failOver(from, tried) {
|
|
277
|
+
if (this.nodeUrl !== from && !tried.has(this.nodeUrl))
|
|
278
|
+
return true;
|
|
279
|
+
const count = this.nodeUrls.length;
|
|
280
|
+
for (let step = 1; step < count; step++) {
|
|
281
|
+
const next = (this.at + step) % count;
|
|
282
|
+
if (!tried.has(this.nodeUrls[next])) {
|
|
283
|
+
this.at = next;
|
|
284
|
+
this.reread();
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Read the node list again from the directory, in the background: the node
|
|
292
|
+
* in use stays first, then the directory's order; a node no longer listed is
|
|
293
|
+
* dropped. A failed read is ignored. One read at a time.
|
|
294
|
+
*/
|
|
295
|
+
reread() {
|
|
296
|
+
const source = this.source;
|
|
297
|
+
if (!source || source.reading)
|
|
298
|
+
return;
|
|
299
|
+
source.reading = readDirectory(source.directory, source.cluster, this.raw)
|
|
300
|
+
.then((listing) => {
|
|
301
|
+
const current = this.nodeUrl;
|
|
302
|
+
const listed = orderNodes(listing.nodes, source.zone, source.random);
|
|
303
|
+
this.nodeUrls = [current, ...listed.filter((url) => url !== current)];
|
|
304
|
+
this.at = 0;
|
|
305
|
+
source.due = Date.now() + listing.refreshAfterSeconds * 1000;
|
|
306
|
+
})
|
|
307
|
+
.catch(() => undefined)
|
|
308
|
+
.finally(() => {
|
|
309
|
+
source.reading = undefined;
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Send one request to the node in use, with the credential held now, and
|
|
314
|
+
* answer the response, whatever its status. {@link send} judges it.
|
|
315
|
+
*/
|
|
316
|
+
async once(method, path, options) {
|
|
317
|
+
const headers = {};
|
|
318
|
+
if (this.auth)
|
|
319
|
+
headers.authorization = `Bearer ${this.auth}`;
|
|
320
|
+
let payload;
|
|
321
|
+
if (options.bytes) {
|
|
322
|
+
headers['content-type'] = options.contentType ?? 'application/wasm';
|
|
323
|
+
payload = options.bytes;
|
|
324
|
+
}
|
|
325
|
+
else if (options.body !== undefined) {
|
|
326
|
+
headers['content-type'] = 'application/json';
|
|
327
|
+
payload = JSON.stringify(options.body);
|
|
328
|
+
}
|
|
329
|
+
if (this.source && Date.now() >= this.source.due)
|
|
330
|
+
this.reread();
|
|
331
|
+
const url = this.nodeUrl;
|
|
332
|
+
try {
|
|
333
|
+
return await this.channel(url)(`${url}/${path}${queryString(options.query)}`, {
|
|
334
|
+
method,
|
|
335
|
+
headers,
|
|
336
|
+
body: payload,
|
|
337
|
+
signal: options.signal,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
throw sealedError(error);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* {@link once}, answering what a lost issuing node or a node on the wrong
|
|
346
|
+
* release does to a session: a successful response, or the refusal as a
|
|
347
|
+
* typed error ({@link errorFrom}).
|
|
348
|
+
*
|
|
349
|
+
* - `401 UNAUTHENTICATED` to a request that carried a session: the node that
|
|
350
|
+
* signed the session may be gone, replaced, or out of reach of the node
|
|
351
|
+
* asked, which any node of the cluster answers with a refresh. The
|
|
352
|
+
* transport refreshes once, shared by every request that needs it at the
|
|
353
|
+
* same time ({@link refresh}), and repeats the request once. A refresh that
|
|
354
|
+
* is refused surfaces as {@link LoginRequired}.
|
|
355
|
+
* - `421 WRONG_NODE`: this node's release may not serve the account. The
|
|
356
|
+
* transport moves to the next node it knows and repeats the request once;
|
|
357
|
+
* with none, the {@link WrongNode} reaches the caller.
|
|
358
|
+
*
|
|
359
|
+
* Repeating is safe even for a request that is not idempotent: the node
|
|
360
|
+
* answers `401` and `421` when it judges the credential, before it reads what
|
|
361
|
+
* the request asks, so the first attempt ran nothing. The exceptions are the
|
|
362
|
+
* ceremonies that mint a session ({@link ceremony}), never repeated here.
|
|
363
|
+
*/
|
|
364
|
+
async send(method, path, options) {
|
|
365
|
+
let refreshed = false;
|
|
366
|
+
let moved = false;
|
|
367
|
+
for (;;) {
|
|
368
|
+
const [token, at] = [this.auth, this.nodeUrl];
|
|
369
|
+
const response = await this.once(method, path, options);
|
|
370
|
+
if (response.ok)
|
|
371
|
+
return response;
|
|
372
|
+
const error = await refusal(response);
|
|
373
|
+
if (error instanceof WrongNode && !moved) {
|
|
374
|
+
moved = true;
|
|
375
|
+
if (this.moveOn(at) && !ceremony(path))
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
else if (error instanceof LoginRequired && !refreshed && this.mayRefresh(token, path)) {
|
|
379
|
+
refreshed = true;
|
|
380
|
+
await this.renew(token);
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
throw error;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/** Whether a `401` to a request sent with `token` is one a refresh may answer. */
|
|
387
|
+
mayRefresh(token, path) {
|
|
388
|
+
return token !== undefined && this.current !== undefined && !ceremony(path);
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* A fresh session in place of the one `token` belonged to: by the refresh
|
|
392
|
+
* another request already made, or by one this request starts.
|
|
393
|
+
*/
|
|
394
|
+
async renew(token) {
|
|
395
|
+
if (this.auth !== token)
|
|
396
|
+
return;
|
|
397
|
+
await this.refresh();
|
|
398
|
+
}
|
|
399
|
+
async read(method, path, options) {
|
|
400
|
+
const response = await this.send(method, path, options);
|
|
401
|
+
const text = await response.text();
|
|
402
|
+
return (text ? JSON.parse(text) : {});
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* {@link read} for a call or a view (D70): while the node answers 503 or
|
|
406
|
+
* cannot be reached, the same request, with the same idempotency key, is sent
|
|
407
|
+
* again after {@link retryDelayMs} (or the pause the node asked for, if
|
|
408
|
+
* longer), at most `retries` times. Every other answer is final.
|
|
409
|
+
*
|
|
410
|
+
* When a node's retries run out, the request fails over to the next node the
|
|
411
|
+
* transport knows and its retries start again there, with the same key: the
|
|
412
|
+
* key is kept in the app's replicated log, so the cluster answers it once
|
|
413
|
+
* whichever node it reaches. Each node is tried once per request. The last
|
|
414
|
+
* failure surfaces as {@link UnavailableError}: the node's 503, or reason
|
|
415
|
+
* `network` for a connection that failed, whose `committed` is `'unknown'`.
|
|
416
|
+
*
|
|
417
|
+
* `once` sends the request exactly once, to the node in use (a confirmed
|
|
418
|
+
* attempt, whose challenge is single-use).
|
|
419
|
+
*/
|
|
420
|
+
async retried(method, path, options, once) {
|
|
421
|
+
const retries = once ? 0 : this.retry.retries;
|
|
422
|
+
const tried = new Set();
|
|
423
|
+
for (;;) {
|
|
424
|
+
let unavailable;
|
|
425
|
+
let node = this.nodeUrl;
|
|
426
|
+
for (let attempt = 0;; attempt++) {
|
|
427
|
+
node = this.nodeUrl;
|
|
428
|
+
tried.add(node);
|
|
429
|
+
try {
|
|
430
|
+
return await this.read(method, path, options);
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
if (error instanceof UnavailableError)
|
|
434
|
+
unavailable = error;
|
|
435
|
+
else if (error instanceof TypeError && this.networkFailures.has(error)) {
|
|
436
|
+
unavailable = new UnavailableError('NETWORK', `the node could not be reached: ${error.message}`, {}, undefined, error);
|
|
437
|
+
}
|
|
438
|
+
else
|
|
439
|
+
throw error;
|
|
440
|
+
}
|
|
441
|
+
if (attempt >= retries)
|
|
442
|
+
break;
|
|
443
|
+
const backoff = retryDelayMs(attempt, this.retry);
|
|
444
|
+
await new Promise((resolve) => setTimeout(resolve, Math.max(backoff, (unavailable.retryAfter ?? 0) * 1000)));
|
|
445
|
+
}
|
|
446
|
+
if (once || !this.failOver(node, tried))
|
|
447
|
+
throw unavailable;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* One JSON request, retried as `options.retry` says. The confirmed attempt
|
|
452
|
+
* of a step-up (`body.confirm` set) goes out exactly once.
|
|
453
|
+
*/
|
|
454
|
+
attempt(method, path, options) {
|
|
455
|
+
if (!options.retry)
|
|
456
|
+
return this.read(method, path, options);
|
|
457
|
+
const confirming = options.body?.confirm !== undefined;
|
|
458
|
+
return this.retried(method, path, options, options.retry === 'once' || confirming);
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* {@link attempt}, answering a `412 CONFIRM_REQUIRED` with `passkey`'s
|
|
462
|
+
* assertion over the node's challenge: the same body again, with `confirm`.
|
|
463
|
+
* Without a passkey the `412` reaches the caller as {@link ConfirmRequired}.
|
|
464
|
+
*/
|
|
465
|
+
async confirmed(method, path, options) {
|
|
466
|
+
const { passkey } = options;
|
|
467
|
+
const signal = options.promptSignal ?? options.signal;
|
|
468
|
+
try {
|
|
469
|
+
return await this.attempt(method, path, options);
|
|
470
|
+
}
|
|
471
|
+
catch (error) {
|
|
472
|
+
if (!(error instanceof ConfirmRequired) || !passkey)
|
|
473
|
+
throw error;
|
|
474
|
+
const d = error.details;
|
|
475
|
+
let assertion;
|
|
476
|
+
try {
|
|
477
|
+
// A step-up is taken only with the user verified (PIN or biometric);
|
|
478
|
+
// `preferred` lets a password manager skip it, and the node then
|
|
479
|
+
// refuses. Only the sign-up confirmation passes `preferred`. A
|
|
480
|
+
// requirement the node names itself wins.
|
|
481
|
+
assertion = await passkey.get(fromB64url(d.challenge), d.rpId, d.allowCredentials, {
|
|
482
|
+
userVerification: userVerificationOf(d.userVerification, options.userVerification ?? 'required'),
|
|
483
|
+
...(signal ? { signal } : {}),
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
catch (declined) {
|
|
487
|
+
throw promptFailure(declined);
|
|
488
|
+
}
|
|
489
|
+
const body = { ...options.body, confirm: { challengeId: d.challengeId, assertion } };
|
|
490
|
+
return this.attempt(method, path, { ...options, body });
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
request(method, path, options = {}) {
|
|
494
|
+
return this.send(method, relative(path), options);
|
|
495
|
+
}
|
|
496
|
+
json(method, path, options = {}) {
|
|
497
|
+
return this.confirmed(method, relative(path), options);
|
|
498
|
+
}
|
|
499
|
+
async *events(path, options = {}) {
|
|
500
|
+
const response = await this.send('GET', relative(path), options);
|
|
501
|
+
if (!response.body)
|
|
502
|
+
throw new CalimeroError(response.status, 'EVENTS', 'the event stream has no body');
|
|
503
|
+
yield* sseFrames(response.body);
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* `GET path` in the clear, from the node in use: only for the route a node
|
|
507
|
+
* serves unsealed (`attestation`), and never with the credential.
|
|
508
|
+
*/
|
|
509
|
+
async unsealed(path, options = {}) {
|
|
510
|
+
const response = await this.raw(`${this.nodeUrl}/${relative(path)}${queryString(options.query)}`);
|
|
511
|
+
if (!response.ok)
|
|
512
|
+
throw await refusal(response);
|
|
513
|
+
return response;
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Swap the refresh token for a new session, on whichever node the transport
|
|
517
|
+
* uses: any node of the cluster takes it. A refresh token is used once, so
|
|
518
|
+
* requests made while one refresh is under way share it rather than sending
|
|
519
|
+
* the same token again (which would revoke the session).
|
|
520
|
+
*/
|
|
521
|
+
refresh() {
|
|
522
|
+
if (!this.refreshing) {
|
|
523
|
+
const pending = this.exchangeRefresh();
|
|
524
|
+
this.refreshing = pending;
|
|
525
|
+
const clear = () => {
|
|
526
|
+
if (this.refreshing === pending)
|
|
527
|
+
this.refreshing = undefined;
|
|
528
|
+
};
|
|
529
|
+
pending.then(clear, clear);
|
|
530
|
+
}
|
|
531
|
+
return this.refreshing;
|
|
532
|
+
}
|
|
533
|
+
async exchangeRefresh() {
|
|
534
|
+
const current = this.current;
|
|
535
|
+
if (!current)
|
|
536
|
+
throw new Error('no session to refresh');
|
|
537
|
+
let session;
|
|
538
|
+
// A node that may not serve the account answers `421 WRONG_NODE` before it
|
|
539
|
+
// spends the refresh token (core #4774), so the same token goes to the next
|
|
540
|
+
// node (`send` moved there already): once per node, at most.
|
|
541
|
+
for (let tried = 1; session === undefined; tried++) {
|
|
542
|
+
try {
|
|
543
|
+
session = await this.json('POST', 'auth/refresh', { body: { refreshToken: current.refreshToken } });
|
|
544
|
+
}
|
|
545
|
+
catch (error) {
|
|
546
|
+
if (!(error instanceof WrongNode) || tried >= this.nodeUrls.length)
|
|
547
|
+
throw error;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
// A sign-in or another session taken while this one was refreshing wins.
|
|
551
|
+
if (this.current === current)
|
|
552
|
+
this.useSession(session);
|
|
553
|
+
return session;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* A {@link NodeTransport} to the cluster named `cluster`, its node list read
|
|
558
|
+
* from the node directory (`directory`), those in `zone` first, each group in
|
|
559
|
+
* weighted-random order, and read again in the background after a failover
|
|
560
|
+
* and once the listing's `refreshAfterSeconds` passed.
|
|
561
|
+
*/
|
|
562
|
+
export async function directoryTransport(options) {
|
|
563
|
+
const random = options.random ?? Math.random;
|
|
564
|
+
const fetcher = options.fetch ?? ((input, init) => fetch(input, init));
|
|
565
|
+
const listing = await readDirectory(options.directory, options.cluster, fetcher);
|
|
566
|
+
const [baseUrl, ...nodes] = orderNodes(listing.nodes, options.zone, random);
|
|
567
|
+
const transport = new NodeTransport({ baseUrl, nodes, policy: options.policy, fetch: options.fetch, retry: options.retry });
|
|
568
|
+
transport.useDirectory({
|
|
569
|
+
directory: options.directory,
|
|
570
|
+
cluster: options.cluster,
|
|
571
|
+
zone: options.zone,
|
|
572
|
+
random,
|
|
573
|
+
due: Date.now() + listing.refreshAfterSeconds * 1000,
|
|
574
|
+
});
|
|
575
|
+
return transport;
|
|
576
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Generated by scripts/runtime-entries.mjs: the runtime, from the package bundle.
|
|
2
|
+
export {
|
|
3
|
+
AppError,
|
|
4
|
+
ArgsTooLarge,
|
|
5
|
+
BadOrigin,
|
|
6
|
+
CalimeroError,
|
|
7
|
+
ClusterNotUpgraded,
|
|
8
|
+
ConfirmCancelled,
|
|
9
|
+
ConfirmPromptBlocked,
|
|
10
|
+
ConfirmRequired,
|
|
11
|
+
ConsentStale,
|
|
12
|
+
DEFAULT_COLLATERAL_MIRROR,
|
|
13
|
+
DEFAULT_DIRECTORY,
|
|
14
|
+
DEFAULT_RELEASE_MIRROR,
|
|
15
|
+
DEFAULT_RETRY,
|
|
16
|
+
DirectoryError,
|
|
17
|
+
GITHUB_RELEASES,
|
|
18
|
+
HANDSHAKE_PATH,
|
|
19
|
+
IdempotencyMismatch,
|
|
20
|
+
LoginRequired,
|
|
21
|
+
MOCK_QUOTE_HEADER,
|
|
22
|
+
NODE_RELEASE_SIGNER,
|
|
23
|
+
NodeTransport,
|
|
24
|
+
NotFound,
|
|
25
|
+
NotServing,
|
|
26
|
+
OriginNotRegistered,
|
|
27
|
+
OriginTaken,
|
|
28
|
+
PRODUCTION_PROFILE,
|
|
29
|
+
PasskeyUnknownHere,
|
|
30
|
+
QuotaExceeded,
|
|
31
|
+
RecoveryNotReady,
|
|
32
|
+
ReleaseDeadlineError,
|
|
33
|
+
RuntimeTooOld,
|
|
34
|
+
SEALED_CONTENT_TYPE,
|
|
35
|
+
SEALED_PATH,
|
|
36
|
+
SealedTransportError,
|
|
37
|
+
SignUpRefused,
|
|
38
|
+
StaleTransportKeyError,
|
|
39
|
+
TrapError,
|
|
40
|
+
UnavailableError,
|
|
41
|
+
WrongNode,
|
|
42
|
+
WrongRecoveryCode,
|
|
43
|
+
b64url,
|
|
44
|
+
browserPasskey,
|
|
45
|
+
claimedRelease,
|
|
46
|
+
cloudNodeReleaseUrl,
|
|
47
|
+
createQuoteVerifier,
|
|
48
|
+
createSealedFetch,
|
|
49
|
+
createSignedReleaseVerifier,
|
|
50
|
+
directoryTransport,
|
|
51
|
+
errorFrom,
|
|
52
|
+
fetchAttestedKey,
|
|
53
|
+
fetchGithubNodeRelease,
|
|
54
|
+
fetchNodeRelease,
|
|
55
|
+
freshCollateral,
|
|
56
|
+
fromB64url,
|
|
57
|
+
newIdempotencyKey,
|
|
58
|
+
queryString,
|
|
59
|
+
refusal,
|
|
60
|
+
retryDelayMs,
|
|
61
|
+
routePath,
|
|
62
|
+
sseFrames,
|
|
63
|
+
transportKeyBinding,
|
|
64
|
+
trustSignedRelease,
|
|
65
|
+
trustedMeasurementsFromReleases,
|
|
66
|
+
verifySignedNodeRelease,
|
|
67
|
+
} from './index.browser.mjs';
|