@calimero-network/mero-js 27.1.0 → 27.3.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 CHANGED
@@ -75,14 +75,17 @@ Pinned measurements go through `policy.verifyQuote` (`createQuoteVerifier`).
75
75
  - **Blobs and presence**: `uploadBlob`, `downloadBlob`, `listBlobs`, `blobInfo`, `deleteBlob`; `publishPresence`, `presence`, `presenceStream`.
76
76
  - **Organizations**: `createOrg`, `getOrg`, `deleteOrg`, `setOrgMetadata`, `setMember`, `removeMember`, `setMemberMetadata`, `setCapabilities` (`ORG_CAPABILITIES`), teams, `invite`, `redeemInvite`.
77
77
  - **Owner decisions** (passkey approval, never from an app's frontend): `upgradeOrg`, `upgradeRun`, `retryUpgradeRun`, `approveRelease`, `approveOutbound`, `bindPublisher`, `upgradePublished`.
78
- - **Approvals from an app**: `appsReport`, `releaseRules`, `pendingApprovals`, then `requestConsent` / `openConsentPage` to send the user to the node's consent page, and `consentResult`. See [Ask your users to approve node images and app upgrades](https://calimero-network.github.io/mero-js/guides/user-approvals/).
78
+ - **Approvals from an app**: `appsReport`, `releaseRules`, `pendingApprovals`, then `requestConsent` / `openConsentPage` to send the user to the node's consent page, and `consentResult`. Show the answer's `code` to the user before sending them to `url` (`openConsentPage`'s `confirm` hook); they should see the same code on the page (`confirmationCode(requestId)` checks it). See [Ask your users to approve node images and app upgrades](https://calimero-network.github.io/mero-js/guides/user-approvals/).
79
79
  - **Operator** (ops token): `opsApps`, `opsOrgs`, `setReleaseDeadline`, `liftReleaseDeadline`.
80
80
  - **Any route**: `get`, `post`, `put`, `delete`; `postConfirmed` and `deleteConfirmed` answer a `412 CONFIRM_REQUIRED` with a passkey.
81
81
 
82
- Refusals are typed (`LoginRequired`, `NotFound`, `ConfirmRequired`, `ConfirmCancelled`,
82
+ Refusals are typed (`LoginRequired`, `WrongNode`, `NotFound`, `ConfirmRequired`, `ConfirmCancelled`,
83
83
  `ArgsTooLarge`, `IdempotencyMismatch`, `ReleaseDeadlineError`, `ConsentStale`, `AppError`, `TrapError`,
84
84
  `UnavailableError`),
85
- all extending `CalimeroError` with the node's `status` and `code`. See the
85
+ all extending `CalimeroError` with the node's `status` and `code`. A `401` once
86
+ the node that issued the session is replaced or out of reach is answered by one
87
+ refresh and a repeat; a `421 WRONG_NODE` by moving to the next of the `nodes` the client was
88
+ given. See the
86
89
  [error model](https://calimero-network.github.io/mero-js/reference/error-model/).
87
90
 
88
91
  The pieces it is built from are exported too: the sealed transport
@@ -234,7 +234,34 @@ export interface ConsentStarted {
234
234
  url: string;
235
235
  /** Unix seconds; a request lives 10 minutes. */
236
236
  expiresAt: number;
237
+ /**
238
+ * The confirmation code the consent page shows, `XXX-XXX`
239
+ * ({@link confirmationCode} of `requestId`). Show it to the person before
240
+ * sending them to `url`: the page they land on should show the same code,
241
+ * which tells them it is the page their request opened. Not a secret.
242
+ * Nodes older than the code leave it out.
243
+ */
244
+ code?: string;
237
245
  }
246
+ /** How {@link CalimeroClient.openConsentPage} sends the person to the page. */
247
+ export interface OpenConsentOptions {
248
+ /** Sends the browser to the page's `url`; by default `window.location.assign`. */
249
+ navigate?: (url: string) => void;
250
+ /**
251
+ * Runs after the node answers and before navigating, with the answer: show
252
+ * the person `started.code` here so they can check the page shows the same.
253
+ * Answer `false` and the page is not opened (the request stays on the node
254
+ * until it expires).
255
+ */
256
+ confirm?: (started: ConsentStarted) => boolean | Promise<boolean>;
257
+ }
258
+ /**
259
+ * The confirmation code the node's consent page shows for `requestId`: the
260
+ * first 30 bits of SHA-256 of its UTF-8 bytes, big-endian, as six Crockford
261
+ * base32 characters written `XXX-XXX` (core's consent page). Compare it with
262
+ * {@link ConsentStarted.code} to check the node's answer.
263
+ */
264
+ export declare function confirmationCode(requestId: string): Promise<string>;
238
265
  export type ConsentStatus = 'pending' | 'approved' | 'declined';
239
266
  /** `GET /consent/{id}/result`. */
240
267
  export interface ConsentResult {
@@ -74,6 +74,24 @@ export function publisherBindingMessage(cluster, org, signerId, generation) {
74
74
  export function publisherBindingBytes(cluster, org, signerId, generation) {
75
75
  return new TextEncoder().encode(publisherBindingMessage(cluster, org, signerId, generation));
76
76
  }
77
+ const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
78
+ /**
79
+ * The confirmation code the node's consent page shows for `requestId`: the
80
+ * first 30 bits of SHA-256 of its UTF-8 bytes, big-endian, as six Crockford
81
+ * base32 characters written `XXX-XXX` (core's consent page). Compare it with
82
+ * {@link ConsentStarted.code} to check the node's answer.
83
+ */
84
+ export async function confirmationCode(requestId) {
85
+ const h = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(requestId)));
86
+ const word = ((h[0] << 24) | (h[1] << 16) | (h[2] << 8) | h[3]) >>> 0;
87
+ let code = '';
88
+ for (let i = 0; i < 6; i++) {
89
+ if (i === 3)
90
+ code += '-';
91
+ code += CROCKFORD[(word >>> (27 - 5 * i)) & 31];
92
+ }
93
+ return code;
94
+ }
77
95
  /**
78
96
  * What the consent page put on `returnTo` when it sent the person back, or
79
97
  * `null` when `href` carries none. A hint only (anyone can type a URL): confirm
@@ -11,8 +11,8 @@
11
11
  */
12
12
  import { type AttestationDoc, type CloudPolicy } from './attestation.js';
13
13
  import { type PasskeyProvider } from './passkey.js';
14
- import { type AppsReport, type ConsentRequest, type ConsentResult, type ConsentStarted, type OpsAppsReport, type OpsOrgsReport, type PageOptions, type PendingApprovals, type PublisherBindingInfo, type PublisherProof, type ReleaseRules } from './approvals.js';
15
- export { AppError, ArgsTooLarge, CalimeroError, ConfirmCancelled, ConfirmRequired, IdempotencyMismatch, LoginRequired, ConsentStale, NotFound, ReleaseDeadlineError, TrapError, UnavailableError, errorFrom, } from './errors.js';
14
+ import { type AppsReport, type ConsentRequest, type ConsentResult, type ConsentStarted, type OpenConsentOptions, type OpsAppsReport, type OpsOrgsReport, type PageOptions, type PendingApprovals, type PublisherBindingInfo, type PublisherProof, type ReleaseRules } from './approvals.js';
15
+ export { AppError, ArgsTooLarge, CalimeroError, ConfirmCancelled, ConfirmRequired, IdempotencyMismatch, LoginRequired, ConsentStale, NotFound, ReleaseDeadlineError, TrapError, UnavailableError, WrongNode, errorFrom, } from './errors.js';
16
16
  export type { Committed } from './errors.js';
17
17
  export interface CalimeroSession {
18
18
  accountId: string;
@@ -156,8 +156,15 @@ export declare function retryDelayMs(retry: number, options: {
156
156
  maxDelayMs: number;
157
157
  }): number;
158
158
  export interface CalimeroClientOptions {
159
- /** One node of the cluster. */
159
+ /** One node of the cluster: the one the client talks to first. */
160
160
  baseUrl: string;
161
+ /**
162
+ * Other nodes of the same cluster, in the order to move to them when a node
163
+ * answers `421 WRONG_NODE` (its release may not serve this account). Each is
164
+ * attested under the same `policy` before anything is sent to it. Without
165
+ * them a `421` reaches the caller as {@link WrongNode}.
166
+ */
167
+ nodes?: string[];
161
168
  policy: CloudPolicy;
162
169
  /** Fetch that carries attestation and envelopes. Defaults to global `fetch`. */
163
170
  fetch?: typeof fetch;
@@ -165,21 +172,70 @@ export interface CalimeroClientOptions {
165
172
  retry?: CloudRetryOptions;
166
173
  }
167
174
  export declare class CalimeroClient {
168
- private readonly base;
169
- private readonly sealed;
175
+ /** Every node this client may use, `baseUrl` first. */
176
+ private readonly nodeUrls;
177
+ /** The one in use: an index into {@link nodeUrls}. */
178
+ private at;
179
+ /** A sealed fetch per node, each attesting its own node on first use. */
180
+ private readonly channels;
181
+ private readonly policy;
170
182
  /** The unsealed fetch, for the one route served in the clear (`/attestation`). */
171
183
  private readonly raw;
172
184
  private auth;
173
185
  private current;
186
+ /** The refresh under way, which every request that needs one shares. */
187
+ private refreshing;
174
188
  private readonly retry;
175
189
  /** Rejections of the underlying `fetch` itself: the node could not be reached or dropped the connection. */
176
190
  private readonly networkFailures;
177
191
  constructor(options: CalimeroClientOptions);
192
+ /** The node requests go to now: `baseUrl`, until a `421 WRONG_NODE` moved the client on. */
193
+ get nodeUrl(): string;
194
+ /** The sealed channel to `url`, attested under the client's policy before its first request. */
195
+ private channel;
196
+ /**
197
+ * Move on from node `from` (the one that answered `421 WRONG_NODE`) to the
198
+ * next one. False when there is no other. A request that saw the same node
199
+ * refuse after another request moved on already does not move again.
200
+ */
201
+ private moveOn;
178
202
  /** Authenticate every request with an API key. */
179
203
  useApiKey(key: string): void;
180
204
  useSession(session: CalimeroSession): void;
181
205
  get session(): CalimeroSession | undefined;
206
+ /**
207
+ * Send one request to the node in use, with the credential held now, and
208
+ * answer the response, whatever its status. {@link send} judges it.
209
+ */
182
210
  private request;
211
+ /**
212
+ * {@link request}, answering what a lost issuing node or a node on the
213
+ * wrong release does to a session: a successful response, or the refusal as a
214
+ * typed error ({@link errorFrom}).
215
+ *
216
+ * - `401 UNAUTHENTICATED` to a request that carried a session: the node that
217
+ * signed the session may be gone, replaced, or out of reach of the node
218
+ * asked, which any node of the cluster answers with a refresh. The client
219
+ * refreshes once, shared by every request that needs it at the same time
220
+ * ({@link refresh}), and repeats the request once. A refresh that is
221
+ * refused surfaces as {@link LoginRequired}.
222
+ * - `421 WRONG_NODE`: this node's release may not serve the account. The
223
+ * client moves to the next node it knows and repeats the request once; with
224
+ * none, the {@link WrongNode} reaches the caller.
225
+ *
226
+ * Repeating is safe even for a request that is not idempotent: the node
227
+ * answers `401` and `421` when it judges the credential, before it reads what
228
+ * the request asks, so the first attempt ran nothing. The exceptions are the
229
+ * ceremonies that mint a session ({@link ceremony}), never repeated here.
230
+ */
231
+ private send;
232
+ /** Whether a `401` to a request sent with `token` is one a refresh may answer. */
233
+ private mayRefresh;
234
+ /**
235
+ * A fresh session in place of the one `token` belonged to: by the refresh
236
+ * another request already made, or by one this request starts.
237
+ */
238
+ private renew;
183
239
  private json;
184
240
  /**
185
241
  * {@link json} for a call or a view (D70): while the node answers 503 or
@@ -199,8 +255,14 @@ export declare class CalimeroClient {
199
255
  signUp(passkey: PasskeyProvider, name: string): Promise<CalimeroSession>;
200
256
  /** Sign in with an existing passkey. */
201
257
  signIn(passkey: PasskeyProvider): Promise<CalimeroSession>;
202
- /** Swap the refresh token for a new session. */
258
+ /**
259
+ * Swap the refresh token for a new session, on whichever node the client
260
+ * uses: any node of the cluster takes it. A refresh token is used once, so
261
+ * calls made while one refresh is under way share it rather than sending the
262
+ * same token again (which would revoke the session).
263
+ */
203
264
  refresh(): Promise<CalimeroSession>;
265
+ private exchangeRefresh;
204
266
  signOut(): Promise<void>;
205
267
  /** End every session of the signed-in account, on every device, this one too. */
206
268
  signOutEverywhere(): Promise<void>;
@@ -443,11 +505,16 @@ export declare class CalimeroClient {
443
505
  requestConsent(request: ConsentRequest): Promise<ConsentStarted>;
444
506
  /**
445
507
  * {@link requestConsent}, then send the browser to the consent page
446
- * (`navigate`, by default `window.location.assign`). Answers the request
447
- * once `navigate` returns: with a popup (`url => window.open(url)`), poll it
448
- * with {@link waitForConsent}.
508
+ * (`navigate`, by default `window.location.assign`). Pass `navigate` itself
509
+ * or {@link OpenConsentOptions}, whose `confirm` runs first with the node's
510
+ * answer so the app can show the person its `code`; when it answers `false`
511
+ * the page is not opened. When the node sends a `code` that is not
512
+ * {@link confirmationCode} of `requestId`, it throws `CalimeroError`
513
+ * (`CONSENT_CODE_MISMATCH`) and does not navigate. Answers the request
514
+ * once `navigate` returns (or `confirm` declines): with a popup
515
+ * (`url => window.open(url)`), poll it with {@link waitForConsent}.
449
516
  */
450
- openConsentPage(request: ConsentRequest, navigate?: (url: string) => void): Promise<ConsentStarted>;
517
+ openConsentPage(request: ConsentRequest, navigateOrOptions?: ((url: string) => void) | OpenConsentOptions): Promise<ConsentStarted>;
451
518
  /** The decision on a consent request (any node answers, for the account that asked). */
452
519
  consentResult(requestId: string): Promise<ConsentResult>;
453
520
  /**
@@ -11,10 +11,10 @@
11
11
  */
12
12
  import { SealedTransportError, createSealedFetch } from '../sealed/sealed.js';
13
13
  import { fetchAttestedKey } from './attestation.js';
14
- import { CalimeroError, ConfirmCancelled, ConfirmRequired, UnavailableError, errorFrom } from './errors.js';
14
+ import { CalimeroError, ConfirmCancelled, ConfirmRequired, LoginRequired, UnavailableError, WrongNode, errorFrom, } from './errors.js';
15
15
  import { b64url, fromB64url } from './passkey.js';
16
- import { pageQuery, pendingApprovalsFrom, } from './approvals.js';
17
- export { AppError, ArgsTooLarge, CalimeroError, ConfirmCancelled, ConfirmRequired, IdempotencyMismatch, LoginRequired, ConsentStale, NotFound, ReleaseDeadlineError, TrapError, UnavailableError, errorFrom, } from './errors.js';
16
+ import { confirmationCode, pageQuery, pendingApprovalsFrom, } from './approvals.js';
17
+ export { AppError, ArgsTooLarge, CalimeroError, ConfirmCancelled, ConfirmRequired, IdempotencyMismatch, LoginRequired, ConsentStale, NotFound, ReleaseDeadlineError, TrapError, UnavailableError, WrongNode, errorFrom, } from './errors.js';
18
18
  /** One URL path segment, so an id cannot reach another route. */
19
19
  function seg(value) {
20
20
  return encodeURIComponent(value);
@@ -103,11 +103,27 @@ export const DEFAULT_RETRY = { retries: 6, baseDelayMs: 200, maxDelayMs: 2000 };
103
103
  export function retryDelayMs(retry, options) {
104
104
  return Math.min(options.baseDelayMs * 2 ** retry, options.maxDelayMs);
105
105
  }
106
+ /**
107
+ * The ceremonies that mint a session, which {@link CalimeroClient.send} never
108
+ * repeats by itself: a sign-in or sign-up is started again by its caller (with
109
+ * the passkey), and a refresh moves through the nodes in
110
+ * {@link CalimeroClient.exchangeRefresh}. A node answers them `421 WRONG_NODE`
111
+ * before it spends the challenge or the refresh token (core #4774).
112
+ */
113
+ function ceremony(path) {
114
+ return path === 'auth/refresh' || path.startsWith('auth/passkey/');
115
+ }
106
116
  export class CalimeroClient {
107
117
  constructor(options) {
118
+ /** The one in use: an index into {@link nodeUrls}. */
119
+ this.at = 0;
120
+ /** A sealed fetch per node, each attesting its own node on first use. */
121
+ this.channels = new Map();
108
122
  /** Rejections of the underlying `fetch` itself: the node could not be reached or dropped the connection. */
109
123
  this.networkFailures = new WeakSet();
110
- this.base = options.baseUrl.replace(/\/+$/, '');
124
+ const trim = (url) => url.replace(/\/+$/, '');
125
+ this.nodeUrls = [...new Set([options.baseUrl, ...(options.nodes ?? [])].map(trim))];
126
+ this.policy = options.policy;
111
127
  this.retry = { ...DEFAULT_RETRY, ...options.retry };
112
128
  const given = options.fetch ?? ((input, init) => fetch(input, init));
113
129
  // `fetch` rejects with a `TypeError` when it gets no answer: the connection
@@ -125,11 +141,35 @@ export class CalimeroClient {
125
141
  }
126
142
  };
127
143
  this.raw = raw;
128
- this.sealed = createSealedFetch({
129
- baseUrl: this.base,
130
- transportPublicKey: () => fetchAttestedKey(this.base, options.policy, raw),
131
- fetch: raw,
132
- });
144
+ }
145
+ /** The node requests go to now: `baseUrl`, until a `421 WRONG_NODE` moved the client on. */
146
+ get nodeUrl() {
147
+ return this.nodeUrls[this.at];
148
+ }
149
+ /** The sealed channel to `url`, attested under the client's policy before its first request. */
150
+ channel(url) {
151
+ let sealed = this.channels.get(url);
152
+ if (!sealed) {
153
+ sealed = createSealedFetch({
154
+ baseUrl: url,
155
+ transportPublicKey: () => fetchAttestedKey(url, this.policy, this.raw),
156
+ fetch: this.raw,
157
+ });
158
+ this.channels.set(url, sealed);
159
+ }
160
+ return sealed;
161
+ }
162
+ /**
163
+ * Move on from node `from` (the one that answered `421 WRONG_NODE`) to the
164
+ * next one. False when there is no other. A request that saw the same node
165
+ * refuse after another request moved on already does not move again.
166
+ */
167
+ moveOn(from) {
168
+ if (this.nodeUrls.length < 2)
169
+ return false;
170
+ if (this.at === from)
171
+ this.at = (from + 1) % this.nodeUrls.length;
172
+ return true;
133
173
  }
134
174
  /** Authenticate every request with an API key. */
135
175
  useApiKey(key) {
@@ -143,7 +183,11 @@ export class CalimeroClient {
143
183
  get session() {
144
184
  return this.current;
145
185
  }
146
- async request(method, path, body, raw, contentType = 'application/wasm') {
186
+ /**
187
+ * Send one request to the node in use, with the credential held now, and
188
+ * answer the response, whatever its status. {@link send} judges it.
189
+ */
190
+ async request(method, path, body, raw, contentType = 'application/wasm', signal) {
147
191
  const headers = {};
148
192
  if (this.auth)
149
193
  headers.authorization = `Bearer ${this.auth}`;
@@ -156,17 +200,71 @@ export class CalimeroClient {
156
200
  headers['content-type'] = 'application/json';
157
201
  payload = JSON.stringify(body);
158
202
  }
203
+ const url = this.nodeUrl;
159
204
  try {
160
- return await this.sealed(`${this.base}/${path}`, { method, headers, body: payload });
205
+ return await this.channel(url)(`${url}/${path}`, { method, headers, body: payload, signal });
161
206
  }
162
207
  catch (error) {
163
208
  throw sealedError(error);
164
209
  }
165
210
  }
211
+ /**
212
+ * {@link request}, answering what a lost issuing node or a node on the
213
+ * wrong release does to a session: a successful response, or the refusal as a
214
+ * typed error ({@link errorFrom}).
215
+ *
216
+ * - `401 UNAUTHENTICATED` to a request that carried a session: the node that
217
+ * signed the session may be gone, replaced, or out of reach of the node
218
+ * asked, which any node of the cluster answers with a refresh. The client
219
+ * refreshes once, shared by every request that needs it at the same time
220
+ * ({@link refresh}), and repeats the request once. A refresh that is
221
+ * refused surfaces as {@link LoginRequired}.
222
+ * - `421 WRONG_NODE`: this node's release may not serve the account. The
223
+ * client moves to the next node it knows and repeats the request once; with
224
+ * none, the {@link WrongNode} reaches the caller.
225
+ *
226
+ * Repeating is safe even for a request that is not idempotent: the node
227
+ * answers `401` and `421` when it judges the credential, before it reads what
228
+ * the request asks, so the first attempt ran nothing. The exceptions are the
229
+ * ceremonies that mint a session ({@link ceremony}), never repeated here.
230
+ */
231
+ async send(method, path, body, raw, contentType, signal) {
232
+ let refreshed = false;
233
+ let moved = false;
234
+ for (;;) {
235
+ const [token, at] = [this.auth, this.at];
236
+ const response = await this.request(method, path, body, raw, contentType, signal);
237
+ if (response.ok)
238
+ return response;
239
+ const error = await refusal(response);
240
+ if (error instanceof WrongNode && !moved) {
241
+ moved = true;
242
+ if (this.moveOn(at) && !ceremony(path))
243
+ continue;
244
+ }
245
+ else if (error instanceof LoginRequired && !refreshed && this.mayRefresh(token, path)) {
246
+ refreshed = true;
247
+ await this.renew(token);
248
+ continue;
249
+ }
250
+ throw error;
251
+ }
252
+ }
253
+ /** Whether a `401` to a request sent with `token` is one a refresh may answer. */
254
+ mayRefresh(token, path) {
255
+ return token !== undefined && this.current !== undefined && !ceremony(path);
256
+ }
257
+ /**
258
+ * A fresh session in place of the one `token` belonged to: by the refresh
259
+ * another request already made, or by one this request starts.
260
+ */
261
+ async renew(token) {
262
+ if (this.auth !== token)
263
+ return;
264
+ await this.refresh();
265
+ }
166
266
  async json(method, path, body, raw, contentType) {
167
- const response = await this.request(method, path, body, raw, contentType);
168
- if (!response.ok)
169
- throw await refusal(response);
267
+ const response = await this.send(method, path, body, raw, contentType);
170
268
  const text = await response.text();
171
269
  return (text ? JSON.parse(text) : {});
172
270
  }
@@ -235,12 +333,44 @@ export class CalimeroClient {
235
333
  this.useSession(session);
236
334
  return session;
237
335
  }
238
- /** Swap the refresh token for a new session. */
239
- async refresh() {
240
- if (!this.current)
336
+ /**
337
+ * Swap the refresh token for a new session, on whichever node the client
338
+ * uses: any node of the cluster takes it. A refresh token is used once, so
339
+ * calls made while one refresh is under way share it rather than sending the
340
+ * same token again (which would revoke the session).
341
+ */
342
+ refresh() {
343
+ if (!this.refreshing) {
344
+ const pending = this.exchangeRefresh();
345
+ this.refreshing = pending;
346
+ const clear = () => {
347
+ if (this.refreshing === pending)
348
+ this.refreshing = undefined;
349
+ };
350
+ pending.then(clear, clear);
351
+ }
352
+ return this.refreshing;
353
+ }
354
+ async exchangeRefresh() {
355
+ const current = this.current;
356
+ if (!current)
241
357
  throw new Error('no session to refresh');
242
- const session = await this.post('auth/refresh', { refreshToken: this.current.refreshToken });
243
- this.useSession(session);
358
+ let session;
359
+ // A node that may not serve the account answers `421 WRONG_NODE` before it
360
+ // spends the refresh token (core #4774), so the same token goes to the next
361
+ // node (`send` moved there already): once per node, at most.
362
+ for (let tried = 1; session === undefined; tried++) {
363
+ try {
364
+ session = await this.post('auth/refresh', { refreshToken: current.refreshToken });
365
+ }
366
+ catch (error) {
367
+ if (!(error instanceof WrongNode) || tried >= this.nodeUrls.length)
368
+ throw error;
369
+ }
370
+ }
371
+ // A sign-in or another session taken while this one was refreshing wins.
372
+ if (this.current === current)
373
+ this.useSession(session);
244
374
  return session;
245
375
  }
246
376
  async signOut() {
@@ -285,7 +415,7 @@ export class CalimeroClient {
285
415
  */
286
416
  async attestation() {
287
417
  const nonce = toHex(crypto.getRandomValues(new Uint8Array(32)));
288
- const response = await this.raw(`${this.base}/attestation?nonce=${nonce}`);
418
+ const response = await this.raw(`${this.nodeUrl}/attestation?nonce=${nonce}`);
289
419
  if (!response.ok)
290
420
  throw await refusal(response);
291
421
  return (await response.json());
@@ -314,9 +444,7 @@ export class CalimeroClient {
314
444
  }
315
445
  /** One of `app`'s blobs, by id (base58 or hex). */
316
446
  async downloadBlob(app, id) {
317
- const response = await this.request('GET', `apps/${seg(app)}/blobs/${seg(id)}`);
318
- if (!response.ok)
319
- throw await refusal(response);
447
+ const response = await this.send('GET', `apps/${seg(app)}/blobs/${seg(id)}`);
320
448
  return new Uint8Array(await response.arrayBuffer());
321
449
  }
322
450
  /** `app`'s blobs. */
@@ -489,12 +617,7 @@ export class CalimeroClient {
489
617
  }
490
618
  /** A server-sent event stream on `path`, sealed like every other answer. */
491
619
  async *stream(path, signal) {
492
- const headers = {};
493
- if (this.auth)
494
- headers.authorization = `Bearer ${this.auth}`;
495
- const response = await this.sealed(`${this.base}/${path}`, { headers, signal });
496
- if (!response.ok)
497
- throw await refusal(response);
620
+ const response = await this.send('GET', path, undefined, undefined, undefined, signal);
498
621
  if (!response.body)
499
622
  throw new CalimeroError(response.status, 'EVENTS', 'the event stream has no body');
500
623
  yield* sseFrames(response.body);
@@ -658,17 +781,37 @@ export class CalimeroClient {
658
781
  }
659
782
  /**
660
783
  * {@link requestConsent}, then send the browser to the consent page
661
- * (`navigate`, by default `window.location.assign`). Answers the request
662
- * once `navigate` returns: with a popup (`url => window.open(url)`), poll it
663
- * with {@link waitForConsent}.
784
+ * (`navigate`, by default `window.location.assign`). Pass `navigate` itself
785
+ * or {@link OpenConsentOptions}, whose `confirm` runs first with the node's
786
+ * answer so the app can show the person its `code`; when it answers `false`
787
+ * the page is not opened. When the node sends a `code` that is not
788
+ * {@link confirmationCode} of `requestId`, it throws `CalimeroError`
789
+ * (`CONSENT_CODE_MISMATCH`) and does not navigate. Answers the request
790
+ * once `navigate` returns (or `confirm` declines): with a popup
791
+ * (`url => window.open(url)`), poll it with {@link waitForConsent}.
664
792
  */
665
- async openConsentPage(request, navigate = (url) => {
666
- const w = globalThis.window;
667
- if (!w)
668
- throw new Error('no window to open the consent page in; pass `navigate`');
669
- w.location.assign(url);
670
- }) {
793
+ async openConsentPage(request, navigateOrOptions = {}) {
794
+ const options = typeof navigateOrOptions === 'function' ? { navigate: navigateOrOptions } : navigateOrOptions;
795
+ const navigate = options.navigate ??
796
+ ((url) => {
797
+ const w = globalThis.window;
798
+ if (!w)
799
+ throw new Error('no window to open the consent page in; pass `navigate`');
800
+ w.location.assign(url);
801
+ });
671
802
  const started = await this.requestConsent(request);
803
+ if (started.code !== undefined) {
804
+ const expected = await confirmationCode(started.requestId);
805
+ if (started.code !== expected) {
806
+ throw new CalimeroError(200, 'CONSENT_CODE_MISMATCH', 'the consent page code does not match the request', {
807
+ requestId: started.requestId,
808
+ code: started.code,
809
+ expected,
810
+ });
811
+ }
812
+ }
813
+ if (options.confirm && !(await options.confirm(started)))
814
+ return started;
672
815
  navigate(started.url);
673
816
  return started;
674
817
  }
@@ -19,10 +19,35 @@ export declare class CalimeroError extends Error {
19
19
  readonly details: Record<string, unknown>;
20
20
  constructor(status: number, code: string, message: string, details?: Record<string, unknown>);
21
21
  }
22
- /** `401 UNAUTHENTICATED`: sign in (again) and repeat the request. */
22
+ /**
23
+ * `401 UNAUTHENTICATED` that refreshing could not answer: sign in (again) and
24
+ * repeat the request.
25
+ *
26
+ * A session token is signed by the node that issued it, and other nodes take
27
+ * it only while they can vouch for that node's signing key: a `401` often
28
+ * means only that the issuer was replaced or cannot be reached, not that the
29
+ * user signed out. The client answers that itself when it holds a refresh
30
+ * token: it refreshes once (any node takes the refresh) and repeats the
31
+ * request. This error reaches a caller when there is no refresh token (an API
32
+ * key, say), or the refresh was refused.
33
+ */
23
34
  export declare class LoginRequired extends CalimeroError {
24
35
  constructor(message: string, details?: Record<string, unknown>);
25
36
  }
37
+ /**
38
+ * `421 WRONG_NODE`: this node runs a mero-tee release the account's owners have
39
+ * not approved, so it neither issues nor takes the account's sessions. Use
40
+ * another node of the cluster; refreshing here would not help.
41
+ *
42
+ * The client moves to the next node it was given (`nodes`) and repeats the
43
+ * request once by itself (a refresh: once per node). This error reaches a
44
+ * caller when it knows no other node, when the next one refused too (for a
45
+ * refresh, every node), or after a sign-in (which needs the passkey again:
46
+ * repeat it, and it goes to the next node).
47
+ */
48
+ export declare class WrongNode extends CalimeroError {
49
+ constructor(message: string, details?: Record<string, unknown>);
50
+ }
26
51
  /**
27
52
  * `404 NOT_FOUND`: what the request names does not exist for this caller. For
28
53
  * an app this does not say whether it exists: an account the app does not
@@ -22,13 +22,41 @@ export class CalimeroError extends Error {
22
22
  this.name = 'CalimeroError';
23
23
  }
24
24
  }
25
- /** `401 UNAUTHENTICATED`: sign in (again) and repeat the request. */
25
+ /**
26
+ * `401 UNAUTHENTICATED` that refreshing could not answer: sign in (again) and
27
+ * repeat the request.
28
+ *
29
+ * A session token is signed by the node that issued it, and other nodes take
30
+ * it only while they can vouch for that node's signing key: a `401` often
31
+ * means only that the issuer was replaced or cannot be reached, not that the
32
+ * user signed out. The client answers that itself when it holds a refresh
33
+ * token: it refreshes once (any node takes the refresh) and repeats the
34
+ * request. This error reaches a caller when there is no refresh token (an API
35
+ * key, say), or the refresh was refused.
36
+ */
26
37
  export class LoginRequired extends CalimeroError {
27
38
  constructor(message, details = {}) {
28
39
  super(401, 'UNAUTHENTICATED', message, details);
29
40
  this.name = 'LoginRequired';
30
41
  }
31
42
  }
43
+ /**
44
+ * `421 WRONG_NODE`: this node runs a mero-tee release the account's owners have
45
+ * not approved, so it neither issues nor takes the account's sessions. Use
46
+ * another node of the cluster; refreshing here would not help.
47
+ *
48
+ * The client moves to the next node it was given (`nodes`) and repeats the
49
+ * request once by itself (a refresh: once per node). This error reaches a
50
+ * caller when it knows no other node, when the next one refused too (for a
51
+ * refresh, every node), or after a sign-in (which needs the passkey again:
52
+ * repeat it, and it goes to the next node).
53
+ */
54
+ export class WrongNode extends CalimeroError {
55
+ constructor(message, details = {}) {
56
+ super(421, 'WRONG_NODE', message, details);
57
+ this.name = 'WrongNode';
58
+ }
59
+ }
32
60
  /**
33
61
  * `404 NOT_FOUND`: what the request names does not exist for this caller. For
34
62
  * an app this does not say whether it exists: an account the app does not
@@ -174,6 +202,8 @@ export function errorFrom(status, body, retryAfterHeader) {
174
202
  switch (`${status} ${code}`) {
175
203
  case '401 UNAUTHENTICATED':
176
204
  return new LoginRequired(message, details);
205
+ case '421 WRONG_NODE':
206
+ return new WrongNode(message, details);
177
207
  case '404 NOT_FOUND':
178
208
  return new NotFound(message, details);
179
209
  case '409 IDEMPOTENCY_MISMATCH':
@@ -1,12 +1,12 @@
1
1
  export { CalimeroClient } from './client.js';
2
- export { AppError, ArgsTooLarge, CalimeroError, ConfirmCancelled, ConfirmRequired, IdempotencyMismatch, LoginRequired, ConsentStale, NotFound, ReleaseDeadlineError, TrapError, UnavailableError, errorFrom, } from './errors.js';
2
+ export { AppError, ArgsTooLarge, CalimeroError, ConfirmCancelled, ConfirmRequired, IdempotencyMismatch, LoginRequired, ConsentStale, NotFound, ReleaseDeadlineError, TrapError, UnavailableError, WrongNode, errorFrom, } from './errors.js';
3
3
  export type { Committed } from './errors.js';
4
4
  export type { AppInfo, BlobInfo, CallResult, CalimeroClientOptions, CloudEvent, CalimeroSession, PackageInfo, PackageInstalled, CloudRetryOptions, OrgInfo, OrgUpgradeTarget, PresenceEntry, UpgradeRun, ViewResult, } from './client.js';
5
5
  export { claimedRelease, fetchAttestedKey, MOCK_QUOTE_HEADER } from './attestation.js';
6
6
  export type { AttestationDoc, CloudPolicy, CloudReleasePolicy } from './attestation.js';
7
7
  export { browserPasskey, b64url, fromB64url } from './passkey.js';
8
8
  export type { AssertionJSON, PasskeyProvider, RegistrationJSON } from './passkey.js';
9
- export { MAX_REPORT_PAGE, RELEASE_VERIFIER, compareVersions, consentReturn, pageQuery, pendingApprovalsFrom, publisherBindingBytes, publisherBindingMessage, releaseConsent, releaseNotesUrl, stalledBy, upgradeConsent, verifyReleaseUrl, withdrawConsent, } from './approvals.js';
10
- export type { ActorKind, AppsReport, Authority, ClusterRelease, ConsentReleaseModel, ConsentRequest, ConsentResult, ConsentScope, ConsentStarted, ConsentStatus, Governance, LastUpgrade, OpsAppsReport, OpsOrgsReport, PageOptions, PendingApprovals, PendingRelease, PendingUpgrade, PublisherBindingInfo, PublisherProof, ReleaseDeadline, ReleaseRules, ReportScope, ReportedApp, ReportedBundle, ReportedRelease, ReportedReplica, UpgradeAuthority, } from './approvals.js';
9
+ export { MAX_REPORT_PAGE, RELEASE_VERIFIER, compareVersions, confirmationCode, consentReturn, pageQuery, pendingApprovalsFrom, publisherBindingBytes, publisherBindingMessage, releaseConsent, releaseNotesUrl, stalledBy, upgradeConsent, verifyReleaseUrl, withdrawConsent, } from './approvals.js';
10
+ export type { ActorKind, AppsReport, Authority, ClusterRelease, ConsentReleaseModel, ConsentRequest, ConsentResult, ConsentScope, ConsentStarted, ConsentStatus, OpenConsentOptions, Governance, LastUpgrade, OpsAppsReport, OpsOrgsReport, PageOptions, PendingApprovals, PendingRelease, PendingUpgrade, PublisherBindingInfo, PublisherProof, ReleaseDeadline, ReleaseRules, ReportScope, ReportedApp, ReportedBundle, ReportedRelease, ReportedReplica, UpgradeAuthority, } from './approvals.js';
11
11
  export { DEFAULT_ORG_CAPABILITIES, ORG_CAPABILITIES, orgCapabilities } from './capabilities.js';
12
12
  export type { OrgCapability } from './capabilities.js';
@@ -1,6 +1,6 @@
1
1
  export { CalimeroClient } from './client.js';
2
- export { AppError, ArgsTooLarge, CalimeroError, ConfirmCancelled, ConfirmRequired, IdempotencyMismatch, LoginRequired, ConsentStale, NotFound, ReleaseDeadlineError, TrapError, UnavailableError, errorFrom, } from './errors.js';
2
+ export { AppError, ArgsTooLarge, CalimeroError, ConfirmCancelled, ConfirmRequired, IdempotencyMismatch, LoginRequired, ConsentStale, NotFound, ReleaseDeadlineError, TrapError, UnavailableError, WrongNode, errorFrom, } from './errors.js';
3
3
  export { claimedRelease, fetchAttestedKey, MOCK_QUOTE_HEADER } from './attestation.js';
4
4
  export { browserPasskey, b64url, fromB64url } from './passkey.js';
5
- export { MAX_REPORT_PAGE, RELEASE_VERIFIER, compareVersions, consentReturn, pageQuery, pendingApprovalsFrom, publisherBindingBytes, publisherBindingMessage, releaseConsent, releaseNotesUrl, stalledBy, upgradeConsent, verifyReleaseUrl, withdrawConsent, } from './approvals.js';
5
+ export { MAX_REPORT_PAGE, RELEASE_VERIFIER, compareVersions, confirmationCode, consentReturn, pageQuery, pendingApprovalsFrom, publisherBindingBytes, publisherBindingMessage, releaseConsent, releaseNotesUrl, stalledBy, upgradeConsent, verifyReleaseUrl, withdrawConsent, } from './approvals.js';
6
6
  export { DEFAULT_ORG_CAPABILITIES, ORG_CAPABILITIES, orgCapabilities } from './capabilities.js';