@calimero-network/mero-js 29.15.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 +35 -0
- package/dist/cloud-v2/client.d.ts +20 -129
- package/dist/cloud-v2/client.js +43 -452
- package/dist/index.browser.mjs +5 -5
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +503 -383
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/index.mjs +503 -383
- 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
package/dist/cloud-v2/client.js
CHANGED
|
@@ -9,10 +9,9 @@
|
|
|
9
9
|
* const { result } = await cloud.call(appId, 'set', { key: 'a', value: '1' });
|
|
10
10
|
* ```
|
|
11
11
|
*/
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import { CalimeroError, ConfirmCancelled, ConfirmPromptBlocked, ConfirmRequired, LoginRequired, OriginNotRegistered, UnavailableError, WrongNode, WrongRecoveryCode, errorFrom, } from './errors.js';
|
|
12
|
+
import { DEFAULT_RETRY, NodeTransport, directoryTransport, newIdempotencyKey, refusal, retryDelayMs, } from '../runtime/transport.js';
|
|
13
|
+
import { DEFAULT_DIRECTORY } from './directory.js';
|
|
14
|
+
import { CalimeroError, LoginRequired, OriginNotRegistered, WrongRecoveryCode } from './errors.js';
|
|
16
15
|
import { b64url, fromB64url } from './passkey.js';
|
|
17
16
|
import { signRecovery } from './recovery.js';
|
|
18
17
|
import { confirmationCode, pageQuery, pendingApprovalsFrom, } from './approvals.js';
|
|
@@ -55,135 +54,15 @@ export function pageDeployment(href) {
|
|
|
55
54
|
return null;
|
|
56
55
|
}
|
|
57
56
|
}
|
|
58
|
-
/**
|
|
59
|
-
* The user verification a confirmation asks the passkey for: what the node's
|
|
60
|
-
* `412` details name, if it names one WebAuthn knows, else `fallback` (the
|
|
61
|
-
* flow's own requirement: `required` for a step-up, which the node takes only
|
|
62
|
-
* with the user verified; `preferred` for the sign-up confirmation, which it
|
|
63
|
-
* takes with the user present).
|
|
64
|
-
*/
|
|
65
|
-
function userVerificationOf(named, fallback) {
|
|
66
|
-
return named === 'required' || named === 'preferred' || named === 'discouraged' ? named : fallback;
|
|
67
|
-
}
|
|
68
|
-
/**
|
|
69
|
-
* What a failed confirmation prompt means to the caller. The browser or a
|
|
70
|
-
* password manager refusing to show the prompt (the page not focused because
|
|
71
|
-
* an extension's popup still holds focus, or another prompt still open) is
|
|
72
|
-
* {@link ConfirmPromptBlocked}; the user dismissing it (`NotAllowedError`, or
|
|
73
|
-
* `AbortError` when it was aborted) is {@link ConfirmCancelled}; anything
|
|
74
|
-
* else is a real failure, passed through.
|
|
75
|
-
*/
|
|
76
|
-
function promptFailure(error) {
|
|
77
|
-
const e = error;
|
|
78
|
-
const name = e?.name;
|
|
79
|
-
const message = typeof e?.message === 'string' ? e.message : '';
|
|
80
|
-
if (name === 'InvalidStateError' || /not focused|already pending/i.test(message)) {
|
|
81
|
-
return new ConfirmPromptBlocked(error);
|
|
82
|
-
}
|
|
83
|
-
if (name === 'NotAllowedError' || name === 'AbortError')
|
|
84
|
-
return new ConfirmCancelled(error);
|
|
85
|
-
return error;
|
|
86
|
-
}
|
|
87
57
|
/** One URL path segment, so an id cannot reach another route. */
|
|
88
58
|
function seg(value) {
|
|
89
59
|
return encodeURIComponent(value);
|
|
90
60
|
}
|
|
91
|
-
/**
|
|
92
|
-
* The node refuses an envelope past its 64 MiB sealed cap with `413` in the
|
|
93
|
-
* clear, before opening it, so the refusal arrives as the transport's. To the
|
|
94
|
-
* caller it is the API's `TOO_LARGE`, as the Rust client reports it.
|
|
95
|
-
*/
|
|
96
|
-
function sealedError(error) {
|
|
97
|
-
if (error instanceof SealedTransportError && error.status === 413) {
|
|
98
|
-
return errorFrom(413, {
|
|
99
|
-
error: 'TOO_LARGE',
|
|
100
|
-
message: error.message.replace(/^Sealed request refused: /, ''),
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
return error;
|
|
104
|
-
}
|
|
105
|
-
/** Read a refused response's body and map it ({@link errorFrom}). */
|
|
106
|
-
async function refusal(response) {
|
|
107
|
-
const text = await response.text().catch(() => '');
|
|
108
|
-
let body = {};
|
|
109
|
-
try {
|
|
110
|
-
body = text ? JSON.parse(text) : {};
|
|
111
|
-
}
|
|
112
|
-
catch {
|
|
113
|
-
body = { message: text };
|
|
114
|
-
}
|
|
115
|
-
return errorFrom(response.status, body, response.headers.get('retry-after'));
|
|
116
|
-
}
|
|
117
|
-
/** Server-sent event frames from a response body: `{ id, kind, data }` each, `data` parsed as JSON when it is. */
|
|
118
|
-
async function* sseFrames(body) {
|
|
119
|
-
const reader = body.getReader();
|
|
120
|
-
const decoder = new TextDecoder();
|
|
121
|
-
let buffer = '';
|
|
122
|
-
for (;;) {
|
|
123
|
-
const { value, done } = await reader.read();
|
|
124
|
-
if (done)
|
|
125
|
-
return;
|
|
126
|
-
buffer += decoder.decode(value, { stream: true });
|
|
127
|
-
let cut;
|
|
128
|
-
while ((cut = buffer.indexOf('\n\n')) >= 0) {
|
|
129
|
-
const frame = buffer.slice(0, cut);
|
|
130
|
-
buffer = buffer.slice(cut + 2);
|
|
131
|
-
let id = '';
|
|
132
|
-
let kind = '';
|
|
133
|
-
let data = '';
|
|
134
|
-
for (const line of frame.split('\n')) {
|
|
135
|
-
if (line.startsWith('id:'))
|
|
136
|
-
id = line.slice(3).trim();
|
|
137
|
-
else if (line.startsWith('event:'))
|
|
138
|
-
kind = line.slice(6).trim();
|
|
139
|
-
else if (line.startsWith('data:'))
|
|
140
|
-
data += line.slice(5).trim();
|
|
141
|
-
}
|
|
142
|
-
if (!kind && !data)
|
|
143
|
-
continue;
|
|
144
|
-
let parsed = data;
|
|
145
|
-
try {
|
|
146
|
-
parsed = JSON.parse(data);
|
|
147
|
-
}
|
|
148
|
-
catch {
|
|
149
|
-
/* plain text */
|
|
150
|
-
}
|
|
151
|
-
yield { id, kind, data: parsed };
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
61
|
/** Bytes as lower-case hex. */
|
|
156
62
|
function toHex(bytes) {
|
|
157
63
|
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
158
64
|
}
|
|
159
|
-
|
|
160
|
-
* A key for a call the caller gave none, made once so every retry shares it:
|
|
161
|
-
* `node:c-` and 16 random bytes in base64url without padding, as the Rust
|
|
162
|
-
* client mints it. It only has to outlive this client's own retries, so it
|
|
163
|
-
* takes the node's short-lived class: a key starting `node:` is kept 10
|
|
164
|
-
* minutes, a caller's own key 24 hours.
|
|
165
|
-
* `getRandomValues`, not `randomUUID`, which a page outside a secure context lacks.
|
|
166
|
-
*/
|
|
167
|
-
function newIdempotencyKey() {
|
|
168
|
-
return `node:c-${b64url(crypto.getRandomValues(new Uint8Array(16)))}`;
|
|
169
|
-
}
|
|
170
|
-
export const DEFAULT_RETRY = { retries: 6, baseDelayMs: 200, maxDelayMs: 2000 };
|
|
171
|
-
/** The backoff before retry `retry` (0 for the first): `baseDelayMs` doubling, at most `maxDelayMs`. */
|
|
172
|
-
export function retryDelayMs(retry, options) {
|
|
173
|
-
return Math.min(options.baseDelayMs * 2 ** retry, options.maxDelayMs);
|
|
174
|
-
}
|
|
175
|
-
/**
|
|
176
|
-
* The ceremonies that mint a session, which {@link CalimeroClient.send} never
|
|
177
|
-
* repeats by itself: a sign-in or sign-up is started again by its caller (with
|
|
178
|
-
* the passkey), and a refresh moves through the nodes in
|
|
179
|
-
* {@link CalimeroClient.exchangeRefresh}. A node answers them `421 WRONG_NODE`
|
|
180
|
-
* before it spends the challenge or the refresh token (core #4774).
|
|
181
|
-
*/
|
|
182
|
-
function ceremony(path) {
|
|
183
|
-
return path === 'auth/refresh' || path.startsWith('auth/passkey/') || RECOVERY_CEREMONIES.has(path);
|
|
184
|
-
}
|
|
185
|
-
/** The signed-out halves of an account recovery: a `401` there is the code's, not a session's. */
|
|
186
|
-
const RECOVERY_CEREMONIES = new Set(['auth/recovery/begin', 'auth/recovery/start', 'auth/recovery/finish']);
|
|
65
|
+
export { DEFAULT_RETRY, retryDelayMs };
|
|
187
66
|
export class CalimeroClient {
|
|
188
67
|
/**
|
|
189
68
|
* A client for the Calimero Cloud cluster named `cluster`: its node list read
|
|
@@ -196,28 +75,11 @@ export class CalimeroClient {
|
|
|
196
75
|
* first request once the directory's `refreshAfterSeconds` has passed.
|
|
197
76
|
*/
|
|
198
77
|
static async connect(options) {
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
const fetcher = options.fetch ?? ((input, init) => fetch(input, init));
|
|
202
|
-
const listing = await readDirectory(directory, options.cluster, fetcher);
|
|
203
|
-
const [baseUrl, ...nodes] = orderNodes(listing.nodes, options.zone, random);
|
|
204
|
-
const client = new CalimeroClient({ baseUrl, nodes, policy: options.policy, fetch: options.fetch, retry: options.retry });
|
|
205
|
-
client.source = {
|
|
206
|
-
directory,
|
|
207
|
-
cluster: options.cluster,
|
|
208
|
-
zone: options.zone,
|
|
209
|
-
random,
|
|
210
|
-
due: Date.now() + listing.refreshAfterSeconds * 1000,
|
|
211
|
-
};
|
|
212
|
-
return client;
|
|
78
|
+
const transport = await directoryTransport({ ...options, directory: options.directory ?? DEFAULT_DIRECTORY });
|
|
79
|
+
return new CalimeroClient(transport);
|
|
213
80
|
}
|
|
81
|
+
/** A client of the nodes `options` names, or one sending through an existing `transport`. */
|
|
214
82
|
constructor(options) {
|
|
215
|
-
/** The one in use: an index into {@link nodeUrls}. */
|
|
216
|
-
this.at = 0;
|
|
217
|
-
/** A sealed fetch per node, each attesting its own node on first use. */
|
|
218
|
-
this.channels = new Map();
|
|
219
|
-
/** Rejections of the underlying `fetch` itself: the node could not be reached or dropped the connection. */
|
|
220
|
-
this.networkFailures = new WeakSet();
|
|
221
83
|
// ---------------------------------------------------------------- package origins
|
|
222
84
|
/**
|
|
223
85
|
* The web origins `{package}`'s managers registered so a frontend they host
|
|
@@ -239,246 +101,30 @@ export class CalimeroClient {
|
|
|
239
101
|
}, passkey),
|
|
240
102
|
remove: (pkg, origin, passkey) => this.confirmed('DELETE', `package-origins/${seg(pkg)}`, { origin }, passkey),
|
|
241
103
|
};
|
|
242
|
-
|
|
243
|
-
this.nodeUrls = [...new Set([options.baseUrl, ...(options.nodes ?? [])].map(trim))];
|
|
244
|
-
this.policy = options.policy;
|
|
245
|
-
this.retry = { ...DEFAULT_RETRY, ...options.retry };
|
|
246
|
-
const given = options.fetch ?? ((input, init) => fetch(input, init));
|
|
247
|
-
// `fetch` rejects with a `TypeError` when it gets no answer: the connection
|
|
248
|
-
// was refused, reset or dropped before the response. The sealed fetch passes
|
|
249
|
-
// that rejection on unchanged, so it is recognised here, at its source, and
|
|
250
|
-
// not by its type alone (a bad URL is a `TypeError` too, and is not retried).
|
|
251
|
-
const raw = async (input, init) => {
|
|
252
|
-
try {
|
|
253
|
-
return await given(input, init);
|
|
254
|
-
}
|
|
255
|
-
catch (error) {
|
|
256
|
-
if (error instanceof TypeError)
|
|
257
|
-
this.networkFailures.add(error);
|
|
258
|
-
throw error;
|
|
259
|
-
}
|
|
260
|
-
};
|
|
261
|
-
this.raw = raw;
|
|
104
|
+
this.transport = options instanceof NodeTransport ? options : new NodeTransport(options);
|
|
262
105
|
}
|
|
263
106
|
/** The node requests go to now: `baseUrl`, until a `421 WRONG_NODE` or a failover moved the client on. */
|
|
264
107
|
get nodeUrl() {
|
|
265
|
-
return this.
|
|
108
|
+
return this.transport.nodeUrl;
|
|
266
109
|
}
|
|
267
110
|
/** Every node this client may use, in the order it moves through them. */
|
|
268
111
|
get nodes() {
|
|
269
|
-
return
|
|
270
|
-
}
|
|
271
|
-
/** The sealed channel to `url`, attested under the client's policy before its first request. */
|
|
272
|
-
channel(url) {
|
|
273
|
-
let sealed = this.channels.get(url);
|
|
274
|
-
if (!sealed) {
|
|
275
|
-
sealed = createSealedFetch({
|
|
276
|
-
baseUrl: url,
|
|
277
|
-
transportPublicKey: () => fetchAttestedKey(url, this.policy, this.raw),
|
|
278
|
-
fetch: this.raw,
|
|
279
|
-
});
|
|
280
|
-
this.channels.set(url, sealed);
|
|
281
|
-
}
|
|
282
|
-
return sealed;
|
|
283
|
-
}
|
|
284
|
-
/**
|
|
285
|
-
* Move on from node `from` (the one that answered `421 WRONG_NODE`) to the
|
|
286
|
-
* next one. False when there is no other. A request that saw the same node
|
|
287
|
-
* refuse after another request moved on already does not move again.
|
|
288
|
-
*/
|
|
289
|
-
moveOn(from) {
|
|
290
|
-
if (this.nodeUrls.length < 2)
|
|
291
|
-
return false;
|
|
292
|
-
if (this.nodeUrl === from)
|
|
293
|
-
this.at = (this.at + 1) % this.nodeUrls.length;
|
|
294
|
-
return true;
|
|
295
|
-
}
|
|
296
|
-
/**
|
|
297
|
-
* Fail over from node `from`, where a request's retries ran out, to the next
|
|
298
|
-
* node it has not `tried`. False when it has tried them all. A request whose
|
|
299
|
-
* node another request moved away from already goes to that node, if untried.
|
|
300
|
-
*/
|
|
301
|
-
failOver(from, tried) {
|
|
302
|
-
if (this.nodeUrl !== from && !tried.has(this.nodeUrl))
|
|
303
|
-
return true;
|
|
304
|
-
const count = this.nodeUrls.length;
|
|
305
|
-
for (let step = 1; step < count; step++) {
|
|
306
|
-
const next = (this.at + step) % count;
|
|
307
|
-
if (!tried.has(this.nodeUrls[next])) {
|
|
308
|
-
this.at = next;
|
|
309
|
-
this.reread();
|
|
310
|
-
return true;
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
return false;
|
|
314
|
-
}
|
|
315
|
-
/**
|
|
316
|
-
* Read the node list again from the directory, in the background, for a
|
|
317
|
-
* client made by {@link connect}: the node in use stays first, then the
|
|
318
|
-
* directory's order; a node no longer listed is dropped. A failed read is
|
|
319
|
-
* ignored. One read at a time.
|
|
320
|
-
*/
|
|
321
|
-
reread() {
|
|
322
|
-
const source = this.source;
|
|
323
|
-
if (!source || source.reading)
|
|
324
|
-
return;
|
|
325
|
-
source.reading = readDirectory(source.directory, source.cluster, this.raw)
|
|
326
|
-
.then((listing) => {
|
|
327
|
-
const current = this.nodeUrl;
|
|
328
|
-
const listed = orderNodes(listing.nodes, source.zone, source.random);
|
|
329
|
-
this.nodeUrls = [current, ...listed.filter((url) => url !== current)];
|
|
330
|
-
this.at = 0;
|
|
331
|
-
source.due = Date.now() + listing.refreshAfterSeconds * 1000;
|
|
332
|
-
})
|
|
333
|
-
.catch(() => undefined)
|
|
334
|
-
.finally(() => {
|
|
335
|
-
source.reading = undefined;
|
|
336
|
-
});
|
|
112
|
+
return this.transport.nodes;
|
|
337
113
|
}
|
|
338
114
|
/** Authenticate every request with an API key. */
|
|
339
115
|
useApiKey(key) {
|
|
340
|
-
this.
|
|
341
|
-
this.current = undefined;
|
|
116
|
+
this.transport.useApiKey(key);
|
|
342
117
|
}
|
|
343
118
|
useSession(session) {
|
|
344
|
-
this.
|
|
345
|
-
this.current = session;
|
|
119
|
+
this.transport.useSession(session);
|
|
346
120
|
}
|
|
347
121
|
get session() {
|
|
348
|
-
return this.
|
|
349
|
-
}
|
|
350
|
-
/**
|
|
351
|
-
* Send one request to the node in use, with the credential held now, and
|
|
352
|
-
* answer the response, whatever its status. {@link send} judges it.
|
|
353
|
-
*/
|
|
354
|
-
async request(method, path, body, raw, contentType = 'application/wasm', signal) {
|
|
355
|
-
const headers = {};
|
|
356
|
-
if (this.auth)
|
|
357
|
-
headers.authorization = `Bearer ${this.auth}`;
|
|
358
|
-
let payload;
|
|
359
|
-
if (raw) {
|
|
360
|
-
headers['content-type'] = contentType;
|
|
361
|
-
payload = raw;
|
|
362
|
-
}
|
|
363
|
-
else if (body !== undefined) {
|
|
364
|
-
headers['content-type'] = 'application/json';
|
|
365
|
-
payload = JSON.stringify(body);
|
|
366
|
-
}
|
|
367
|
-
if (this.source && Date.now() >= this.source.due)
|
|
368
|
-
this.reread();
|
|
369
|
-
const url = this.nodeUrl;
|
|
370
|
-
try {
|
|
371
|
-
return await this.channel(url)(`${url}/${path}`, { method, headers, body: payload, signal });
|
|
372
|
-
}
|
|
373
|
-
catch (error) {
|
|
374
|
-
throw sealedError(error);
|
|
375
|
-
}
|
|
122
|
+
return this.transport.session;
|
|
376
123
|
}
|
|
377
|
-
/**
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
*
|
|
382
|
-
* - `401 UNAUTHENTICATED` to a request that carried a session: the node that
|
|
383
|
-
* signed the session may be gone, replaced, or out of reach of the node
|
|
384
|
-
* asked, which any node of the cluster answers with a refresh. The client
|
|
385
|
-
* refreshes once, shared by every request that needs it at the same time
|
|
386
|
-
* ({@link refresh}), and repeats the request once. A refresh that is
|
|
387
|
-
* refused surfaces as {@link LoginRequired}.
|
|
388
|
-
* - `421 WRONG_NODE`: this node's release may not serve the account. The
|
|
389
|
-
* client moves to the next node it knows and repeats the request once; with
|
|
390
|
-
* none, the {@link WrongNode} reaches the caller.
|
|
391
|
-
*
|
|
392
|
-
* Repeating is safe even for a request that is not idempotent: the node
|
|
393
|
-
* answers `401` and `421` when it judges the credential, before it reads what
|
|
394
|
-
* the request asks, so the first attempt ran nothing. The exceptions are the
|
|
395
|
-
* ceremonies that mint a session ({@link ceremony}), never repeated here.
|
|
396
|
-
*/
|
|
397
|
-
async send(method, path, body, raw, contentType, signal) {
|
|
398
|
-
let refreshed = false;
|
|
399
|
-
let moved = false;
|
|
400
|
-
for (;;) {
|
|
401
|
-
const [token, at] = [this.auth, this.nodeUrl];
|
|
402
|
-
const response = await this.request(method, path, body, raw, contentType, signal);
|
|
403
|
-
if (response.ok)
|
|
404
|
-
return response;
|
|
405
|
-
const error = await refusal(response);
|
|
406
|
-
if (error instanceof WrongNode && !moved) {
|
|
407
|
-
moved = true;
|
|
408
|
-
if (this.moveOn(at) && !ceremony(path))
|
|
409
|
-
continue;
|
|
410
|
-
}
|
|
411
|
-
else if (error instanceof LoginRequired && !refreshed && this.mayRefresh(token, path)) {
|
|
412
|
-
refreshed = true;
|
|
413
|
-
await this.renew(token);
|
|
414
|
-
continue;
|
|
415
|
-
}
|
|
416
|
-
throw error;
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
/** Whether a `401` to a request sent with `token` is one a refresh may answer. */
|
|
420
|
-
mayRefresh(token, path) {
|
|
421
|
-
return token !== undefined && this.current !== undefined && !ceremony(path);
|
|
422
|
-
}
|
|
423
|
-
/**
|
|
424
|
-
* A fresh session in place of the one `token` belonged to: by the refresh
|
|
425
|
-
* another request already made, or by one this request starts.
|
|
426
|
-
*/
|
|
427
|
-
async renew(token) {
|
|
428
|
-
if (this.auth !== token)
|
|
429
|
-
return;
|
|
430
|
-
await this.refresh();
|
|
431
|
-
}
|
|
432
|
-
async json(method, path, body, raw, contentType) {
|
|
433
|
-
const response = await this.send(method, path, body, raw, contentType);
|
|
434
|
-
const text = await response.text();
|
|
435
|
-
return (text ? JSON.parse(text) : {});
|
|
436
|
-
}
|
|
437
|
-
/**
|
|
438
|
-
* {@link json} for a call or a view (D70): while the node answers 503 or
|
|
439
|
-
* cannot be reached, the same request, with the same idempotency key, is sent
|
|
440
|
-
* again after {@link retryDelayMs} (or the pause the node asked for, if
|
|
441
|
-
* longer), at most `retries` times. Every other answer is final.
|
|
442
|
-
*
|
|
443
|
-
* When a node's retries run out, the request fails over to the next node the
|
|
444
|
-
* client knows and its retries start again there, with the same key: the
|
|
445
|
-
* key is kept in the app's replicated log, so the cluster answers it once
|
|
446
|
-
* whichever node it reaches. Each node is tried once per request. The last
|
|
447
|
-
* failure surfaces as {@link UnavailableError}: the node's 503, or reason
|
|
448
|
-
* `network` for a connection that failed, whose `committed` is `'unknown'`.
|
|
449
|
-
*
|
|
450
|
-
* `once` sends the request exactly once, to the node in use (the confirmed
|
|
451
|
-
* attempt of {@link callConfirmed}, whose challenge is single-use).
|
|
452
|
-
*/
|
|
453
|
-
async retried(method, path, body, once = false) {
|
|
454
|
-
const retries = once ? 0 : this.retry.retries;
|
|
455
|
-
const tried = new Set();
|
|
456
|
-
for (;;) {
|
|
457
|
-
let unavailable;
|
|
458
|
-
let node = this.nodeUrl;
|
|
459
|
-
for (let attempt = 0;; attempt++) {
|
|
460
|
-
node = this.nodeUrl;
|
|
461
|
-
tried.add(node);
|
|
462
|
-
try {
|
|
463
|
-
return await this.json(method, path, body);
|
|
464
|
-
}
|
|
465
|
-
catch (error) {
|
|
466
|
-
if (error instanceof UnavailableError)
|
|
467
|
-
unavailable = error;
|
|
468
|
-
else if (error instanceof TypeError && this.networkFailures.has(error)) {
|
|
469
|
-
unavailable = new UnavailableError('NETWORK', `the node could not be reached: ${error.message}`, {}, undefined, error);
|
|
470
|
-
}
|
|
471
|
-
else
|
|
472
|
-
throw error;
|
|
473
|
-
}
|
|
474
|
-
if (attempt >= retries)
|
|
475
|
-
break;
|
|
476
|
-
const backoff = retryDelayMs(attempt, this.retry);
|
|
477
|
-
await new Promise((resolve) => setTimeout(resolve, Math.max(backoff, (unavailable.retryAfter ?? 0) * 1000)));
|
|
478
|
-
}
|
|
479
|
-
if (once || !this.failOver(node, tried))
|
|
480
|
-
throw unavailable;
|
|
481
|
-
}
|
|
124
|
+
/** A JSON request, or a raw one when `raw` is given (as `contentType`, the transport's default when absent). */
|
|
125
|
+
json(method, path, body, raw, contentType) {
|
|
126
|
+
const options = raw ? { bytes: raw, ...(contentType === undefined ? {} : { contentType }) } : { body };
|
|
127
|
+
return this.transport.json(method, path, options);
|
|
482
128
|
}
|
|
483
129
|
post(path, body = {}) {
|
|
484
130
|
return this.json('POST', path, body);
|
|
@@ -518,7 +164,7 @@ export class CalimeroClient {
|
|
|
518
164
|
// the passkey with `preferred`: asking `required` here would refuse a
|
|
519
165
|
// security key without a PIN after it has made the passkey, leaving a
|
|
520
166
|
// passkey with no account.
|
|
521
|
-
const session = await this.confirmed('POST', 'auth/passkey/register/finish', { challengeId: begin.challengeId, registration, ...invite }, passkey,
|
|
167
|
+
const session = await this.confirmed('POST', 'auth/passkey/register/finish', { challengeId: begin.challengeId, registration, ...invite }, passkey, 'preferred', signal);
|
|
522
168
|
this.useSession(session);
|
|
523
169
|
return session;
|
|
524
170
|
}
|
|
@@ -559,7 +205,7 @@ export class CalimeroClient {
|
|
|
559
205
|
const page = globalThis.location?.origin;
|
|
560
206
|
if (!page || page === 'null')
|
|
561
207
|
return undefined;
|
|
562
|
-
const nodes = new Set(this.
|
|
208
|
+
const nodes = new Set(this.transport.nodes.map((url) => {
|
|
563
209
|
try {
|
|
564
210
|
return new URL(url).origin;
|
|
565
211
|
}
|
|
@@ -588,38 +234,7 @@ export class CalimeroClient {
|
|
|
588
234
|
* same token again (which would revoke the session).
|
|
589
235
|
*/
|
|
590
236
|
refresh() {
|
|
591
|
-
|
|
592
|
-
const pending = this.exchangeRefresh();
|
|
593
|
-
this.refreshing = pending;
|
|
594
|
-
const clear = () => {
|
|
595
|
-
if (this.refreshing === pending)
|
|
596
|
-
this.refreshing = undefined;
|
|
597
|
-
};
|
|
598
|
-
pending.then(clear, clear);
|
|
599
|
-
}
|
|
600
|
-
return this.refreshing;
|
|
601
|
-
}
|
|
602
|
-
async exchangeRefresh() {
|
|
603
|
-
const current = this.current;
|
|
604
|
-
if (!current)
|
|
605
|
-
throw new Error('no session to refresh');
|
|
606
|
-
let session;
|
|
607
|
-
// A node that may not serve the account answers `421 WRONG_NODE` before it
|
|
608
|
-
// spends the refresh token (core #4774), so the same token goes to the next
|
|
609
|
-
// node (`send` moved there already): once per node, at most.
|
|
610
|
-
for (let tried = 1; session === undefined; tried++) {
|
|
611
|
-
try {
|
|
612
|
-
session = await this.post('auth/refresh', { refreshToken: current.refreshToken });
|
|
613
|
-
}
|
|
614
|
-
catch (error) {
|
|
615
|
-
if (!(error instanceof WrongNode) || tried >= this.nodeUrls.length)
|
|
616
|
-
throw error;
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
// A sign-in or another session taken while this one was refreshing wins.
|
|
620
|
-
if (this.current === current)
|
|
621
|
-
this.useSession(session);
|
|
622
|
-
return session;
|
|
237
|
+
return this.transport.refresh();
|
|
623
238
|
}
|
|
624
239
|
/**
|
|
625
240
|
* End this session. The client is signed out whether or not the node
|
|
@@ -680,8 +295,7 @@ export class CalimeroClient {
|
|
|
680
295
|
await this.post(path);
|
|
681
296
|
}
|
|
682
297
|
finally {
|
|
683
|
-
this.
|
|
684
|
-
this.current = undefined;
|
|
298
|
+
this.transport.signOut();
|
|
685
299
|
}
|
|
686
300
|
}
|
|
687
301
|
/** Who the credential is: the account, how it signed in, and the package (`app`) and `origin` a session is bound to. */
|
|
@@ -781,9 +395,7 @@ export class CalimeroClient {
|
|
|
781
395
|
*/
|
|
782
396
|
async attestation() {
|
|
783
397
|
const nonce = toHex(crypto.getRandomValues(new Uint8Array(32)));
|
|
784
|
-
const response = await this.
|
|
785
|
-
if (!response.ok)
|
|
786
|
-
throw await refusal(response);
|
|
398
|
+
const response = await this.transport.unsealed('attestation', { query: { nonce } });
|
|
787
399
|
return (await response.json());
|
|
788
400
|
}
|
|
789
401
|
/** The account's API keys (never the keys themselves). */
|
|
@@ -844,8 +456,9 @@ export class CalimeroClient {
|
|
|
844
456
|
* app; `application/octet-stream` otherwise.
|
|
845
457
|
*/
|
|
846
458
|
async blobContent(app, id, options = {}) {
|
|
847
|
-
const
|
|
848
|
-
|
|
459
|
+
const response = await this.transport.request('GET', `apps/${seg(app)}/blobs/${seg(id)}`, {
|
|
460
|
+
query: { grant: options.grant },
|
|
461
|
+
});
|
|
849
462
|
return {
|
|
850
463
|
data: new Uint8Array(await response.arrayBuffer()),
|
|
851
464
|
contentType: response.headers.get('content-type') ?? 'application/octet-stream',
|
|
@@ -992,30 +605,12 @@ export class CalimeroClient {
|
|
|
992
605
|
deleteConfirmed(path, passkey, body = {}) {
|
|
993
606
|
return this.confirmed('DELETE', path, body, passkey);
|
|
994
607
|
}
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
throw error;
|
|
1002
|
-
const d = error.details;
|
|
1003
|
-
let assertion;
|
|
1004
|
-
try {
|
|
1005
|
-
// A step-up is taken only with the user verified (PIN or biometric);
|
|
1006
|
-
// `preferred` lets a password manager skip it, and the node then
|
|
1007
|
-
// refuses. Only the sign-up confirmation passes `preferred`. A
|
|
1008
|
-
// requirement the node names itself wins.
|
|
1009
|
-
assertion = await passkey.get(fromB64url(d.challenge), d.rpId, d.allowCredentials, {
|
|
1010
|
-
userVerification: userVerificationOf(d.userVerification, userVerification),
|
|
1011
|
-
...(signal ? { signal } : {}),
|
|
1012
|
-
});
|
|
1013
|
-
}
|
|
1014
|
-
catch (declined) {
|
|
1015
|
-
throw promptFailure(declined);
|
|
1016
|
-
}
|
|
1017
|
-
return send({ ...body, confirm: { challengeId: d.challengeId, assertion } });
|
|
1018
|
-
}
|
|
608
|
+
/**
|
|
609
|
+
* `body` to `path`, answering a `412 CONFIRM_REQUIRED` with `passkey` when one
|
|
610
|
+
* is given; `signal` aborts only the passkey prompt.
|
|
611
|
+
*/
|
|
612
|
+
confirmed(method, path, body, passkey, userVerification, signal) {
|
|
613
|
+
return this.transport.json(method, path, { body, passkey, userVerification, promptSignal: signal });
|
|
1019
614
|
}
|
|
1020
615
|
/**
|
|
1021
616
|
* Create an app instance from a bundle hash, or from a package release
|
|
@@ -1039,7 +634,7 @@ export class CalimeroClient {
|
|
|
1039
634
|
*/
|
|
1040
635
|
call(app, method, args = {}, idempotencyKey) {
|
|
1041
636
|
const key = idempotencyKey ?? newIdempotencyKey();
|
|
1042
|
-
return this.
|
|
637
|
+
return this.transport.json('POST', `apps/${seg(app)}/intents`, { body: { method, args, idempotencyKey: key }, retry: true });
|
|
1043
638
|
}
|
|
1044
639
|
/**
|
|
1045
640
|
* Call a method that needs the account holder's approval: the node returns a
|
|
@@ -1051,27 +646,23 @@ export class CalimeroClient {
|
|
|
1051
646
|
const key = idempotencyKey ?? newIdempotencyKey();
|
|
1052
647
|
// The confirmed attempt goes out exactly once: its challenge is single-use,
|
|
1053
648
|
// so a resend would be refused on the challenge, not answered from the key.
|
|
1054
|
-
return this.
|
|
649
|
+
return this.transport.json('POST', path, { body: { method, args, idempotencyKey: key }, passkey, retry: true });
|
|
1055
650
|
}
|
|
1056
651
|
/** Run a read-only method: strong by default, or stale from any replica. */
|
|
1057
652
|
view(app, method, args = {}, options = {}) {
|
|
1058
|
-
return this.
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
653
|
+
return this.transport.json('POST', `apps/${seg(app)}/views`, {
|
|
654
|
+
body: {
|
|
655
|
+
method,
|
|
656
|
+
args,
|
|
657
|
+
consistency: options.stale ? 'stale' : 'strong',
|
|
658
|
+
minIndex: options.minIndex ?? 0,
|
|
659
|
+
},
|
|
660
|
+
retry: true,
|
|
1063
661
|
});
|
|
1064
662
|
}
|
|
1065
663
|
/** Committed events from index `from`, then live, until `signal` aborts. */
|
|
1066
664
|
events(app, from = 0, signal) {
|
|
1067
|
-
return this.
|
|
1068
|
-
}
|
|
1069
|
-
/** A server-sent event stream on `path`, sealed like every other answer. */
|
|
1070
|
-
async *stream(path, signal) {
|
|
1071
|
-
const response = await this.send('GET', path, undefined, undefined, undefined, signal);
|
|
1072
|
-
if (!response.body)
|
|
1073
|
-
throw new CalimeroError(response.status, 'EVENTS', 'the event stream has no body');
|
|
1074
|
-
yield* sseFrames(response.body);
|
|
665
|
+
return this.transport.events(`apps/${seg(app)}/events`, { query: { from }, signal });
|
|
1075
666
|
}
|
|
1076
667
|
// ---------------------------------------------------------------- presence
|
|
1077
668
|
/**
|
|
@@ -1092,7 +683,7 @@ export class CalimeroClient {
|
|
|
1092
683
|
* entries present), then a `presence` event (`data` one entry) per change.
|
|
1093
684
|
*/
|
|
1094
685
|
presenceStream(app, signal) {
|
|
1095
|
-
return this.
|
|
686
|
+
return this.transport.events(`apps/${seg(app)}/presence/stream`, { signal });
|
|
1096
687
|
}
|
|
1097
688
|
// ---------------------------------------------------------------- aliases
|
|
1098
689
|
/** Name an app (its id or context id) for this account. */
|