@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.
@@ -9,10 +9,9 @@
9
9
  * const { result } = await cloud.call(appId, 'set', { key: 'a', value: '1' });
10
10
  * ```
11
11
  */
12
- import { SealedTransportError, createSealedFetch } from '../sealed/sealed.js';
13
- import { fetchAttestedKey } from './attestation.js';
14
- import { DEFAULT_DIRECTORY, orderNodes, readDirectory } from './directory.js';
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 directory = options.directory ?? DEFAULT_DIRECTORY;
200
- const random = options.random ?? Math.random;
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
- const trim = (url) => url.replace(/\/+$/, '');
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.nodeUrls[this.at];
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 [...this.nodeUrls];
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.auth = key;
341
- this.current = undefined;
116
+ this.transport.useApiKey(key);
342
117
  }
343
118
  useSession(session) {
344
- this.auth = session.sessionToken;
345
- this.current = session;
119
+ this.transport.useSession(session);
346
120
  }
347
121
  get session() {
348
- return this.current;
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
- * {@link request}, answering what a lost issuing node or a node on the
379
- * wrong release does to a session: a successful response, or the refusal as a
380
- * typed error ({@link errorFrom}).
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, undefined, 'preferred', signal);
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.nodeUrls.map((url) => {
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
- if (!this.refreshing) {
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.auth = undefined;
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.raw(`${this.nodeUrl}/attestation?nonce=${nonce}`);
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 query = options.grant === undefined ? '' : `?grant=${encodeURIComponent(options.grant)}`;
848
- const response = await this.send('GET', `apps/${seg(app)}/blobs/${seg(id)}${query}`);
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
- async confirmed(method, path, body, passkey, send = (b) => this.json(method, path, b), userVerification = 'required', signal) {
996
- try {
997
- return await send(body);
998
- }
999
- catch (error) {
1000
- if (!(error instanceof ConfirmRequired) || !passkey)
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.retried('POST', `apps/${seg(app)}/intents`, { method, args, idempotencyKey: key });
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.confirmed('POST', path, { method, args, idempotencyKey: key }, passkey, (body) => this.retried('POST', path, body, body.confirm !== undefined));
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.retried('POST', `apps/${seg(app)}/views`, {
1059
- method,
1060
- args,
1061
- consistency: options.stale ? 'stale' : 'strong',
1062
- minIndex: options.minIndex ?? 0,
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.stream(`apps/${seg(app)}/events?from=${from}`, signal);
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.stream(`apps/${seg(app)}/presence/stream`, signal);
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. */