@calimero-network/mero-js 27.4.0 → 28.0.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
@@ -31,8 +31,9 @@ quotes and is loaded on first use.
31
31
  ```ts
32
32
  import { CalimeroClient, browserPasskey } from '@calimero-network/mero-js';
33
33
 
34
- const client = new CalimeroClient({
35
- baseUrl: 'https://eu1.cloud.calimero.network',
34
+ // Reach a Calimero Cloud cluster by name.
35
+ const client = await CalimeroClient.connect({
36
+ cluster: 'eu-shared-1',
36
37
  // Trust the signed mero-tee release the node names, from this one up.
37
38
  policy: { release: { minReleaseVersion: '2.3.109' } },
38
39
  });
@@ -52,6 +53,34 @@ A development cluster (`merod local`, `merod run --dev`) sends mock quotes,
52
53
  which pass only with `policy: { allowMock: true }`. Nothing is read from the
53
54
  environment.
54
55
 
56
+ ## Connecting to a cluster
57
+
58
+ `CalimeroClient.connect({ cluster, policy })` is the recommended way to reach a
59
+ Calimero Cloud cluster. It reads the cluster's nodes from the public node
60
+ directory (`DEFAULT_DIRECTORY`, `https://manager.cloud.calimero.network/api/v2/directory`;
61
+ `directory` names another), puts those in `zone` first when you give one, and
62
+ orders each group at random, weighted by the directory's weight. A cluster the
63
+ directory does not know, or one with no usable node, throws `DirectoryError`.
64
+
65
+ The directory only picks which nodes to try, and in what order. It is not
66
+ trusted: every node is attested under **your** `policy` before anything is
67
+ sent to it, so a wrong or tampered list can only make connecting fail.
68
+
69
+ Any node of a cluster serves any request. While the node in use answers `503`
70
+ or cannot be reached, a call or a view is retried there (`retry`); when those
71
+ retries run out it fails over to the next node and starts them again there,
72
+ with the **same** idempotency key, which the app's replicated log answers once
73
+ cluster-wide. Each node is tried once per request; when all have failed, the
74
+ last `UnavailableError` reaches you. After a failover, and once the directory's
75
+ `refreshAfterSeconds` has passed (checked at the next request), the client
76
+ reads the directory again in the background: the node in use stays first,
77
+ nodes no longer listed are dropped. `client.nodeUrl` is the node in use,
78
+ `client.nodes` the list.
79
+
80
+ For a fixed list of nodes or a cluster you run yourself, construct the client
81
+ directly: `new CalimeroClient({ baseUrl, nodes, policy })`. It fails over
82
+ between `baseUrl` and `nodes` the same way, and never reads a directory.
83
+
55
84
  ## What the client checks
56
85
 
57
86
  Before it sends anything to a node, as core's Rust client does (`Measurements::Release`):
@@ -68,12 +97,12 @@ Pinned measurements go through `policy.verifyQuote` (`createQuoteVerifier`).
68
97
 
69
98
  ## The API
70
99
 
71
- - **Credentials**: `signUp`, `signIn`, `refresh`, `signOut`, `signOutEverywhere`, `useSession`, `useApiKey`, `me`, `usage`, `addPasskey`, `labelPasskey`, `removePasskey`, `createApiKey`, `listApiKeys`, `revokeApiKey`.
100
+ - **Credentials**: `signUp`, `signIn`, `refresh`, `signOut`, `signOutEverywhere`, `useSession`, `useApiKey`, `me`, `usage`, `addPasskey`, `labelPasskey`, `removePasskey`, `createApiKey`, `listApiKeys`, `revokeApiKey`. Adding or removing a passkey and creating an API key are approved with a passkey the account already has.
72
101
  - **Packages**: `installPackage`, `uploadPackage`, `packages`, `package`, `uninstallPackage`; `uploadBundle` (raw WASM, development clusters only).
73
102
  - **Apps**: `createApp`, `listApps`, `getApp`, `deleteApp`, `setAccess`, `setAppMetadata`, `appStorage`, `appAbi`, `upgradeApp`, `transferApp`.
74
- - **Calls**: `call`, `callConfirmed`, `view`, `events`. A call always carries an idempotency key, and is sent again with it while the node answers `503`.
103
+ - **Calls**: `call`, `callConfirmed`, `view`, `events`. A call always carries an idempotency key, and is sent again with it while the node answers `503`, then to the next node.
75
104
  - **Blobs and presence**: `uploadBlob`, `downloadBlob`, `listBlobs`, `blobInfo`, `deleteBlob`; `publishPresence`, `presence`, `presenceStream`.
76
- - **Organizations**: `createOrg`, `getOrg`, `deleteOrg`, `setOrgMetadata`, `setMember`, `removeMember`, `setMemberMetadata`, `setCapabilities` (`ORG_CAPABILITIES`), teams, `invite`, `redeemInvite`.
105
+ - **Organizations**: `createOrg`, `getOrg`, `deleteOrg`, `setOrgMetadata`, `setMember`, `removeMember`, `setMemberMetadata`, `setCapabilities` (`ORG_CAPABILITIES`), teams, `invite`, `redeemInvite`. Setting or removing a member or team member, capabilities and invites are approved with the caller's passkey; redeeming an invite is not.
77
106
  - **Owner decisions** (passkey approval, never from an app's frontend): `upgradeOrg`, `upgradeRun`, `retryUpgradeRun`, `approveRelease`, `approveOutbound`, `bindPublisher`, `upgradePublished`.
78
107
  - **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
108
  - **Operator** (ops token): `opsApps`, `opsOrgs`, `setReleaseDeadline`, `liftReleaseDeadline`.
@@ -160,9 +160,10 @@ export interface CalimeroClientOptions {
160
160
  baseUrl: string;
161
161
  /**
162
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}.
163
+ * answers `421 WRONG_NODE` (its release may not serve this account), or stays
164
+ * unavailable to a call or a view through all its retries. Each is attested
165
+ * under the same `policy` before anything is sent to it. Without them a `421`
166
+ * reaches the caller as {@link WrongNode}.
166
167
  */
167
168
  nodes?: string[];
168
169
  policy: CloudPolicy;
@@ -171,9 +172,26 @@ export interface CalimeroClientOptions {
171
172
  /** How calls and views retry a 503 or a failed connection. */
172
173
  retry?: CloudRetryOptions;
173
174
  }
175
+ /** How {@link CalimeroClient.connect} reaches a Calimero Cloud cluster by name. */
176
+ export interface CalimeroConnectOptions {
177
+ /** The cluster's name, e.g. `eu-shared-1`. */
178
+ cluster: string;
179
+ /** What every node must attest to before anything is sent to it. */
180
+ policy: CloudPolicy;
181
+ /** The node directory to ask. Default {@link DEFAULT_DIRECTORY}. */
182
+ directory?: string;
183
+ /** Try the nodes in this zone (e.g. `europe-west4-a`) first. */
184
+ zone?: string;
185
+ /** Fetch for the directory, attestation and envelopes. Defaults to global `fetch`. */
186
+ fetch?: typeof fetch;
187
+ /** How calls and views retry a 503 or a failed connection. */
188
+ retry?: CloudRetryOptions;
189
+ /** Randomness for the weighted node order, in [0, 1). Defaults to `Math.random`. */
190
+ random?: () => number;
191
+ }
174
192
  export declare class CalimeroClient {
175
- /** Every node this client may use, `baseUrl` first. */
176
- private readonly nodeUrls;
193
+ /** Every node this client may use, `baseUrl` first (or as the directory last ordered them). */
194
+ private nodeUrls;
177
195
  /** The one in use: an index into {@link nodeUrls}. */
178
196
  private at;
179
197
  /** A sealed fetch per node, each attesting its own node on first use. */
@@ -188,9 +206,24 @@ export declare class CalimeroClient {
188
206
  private readonly retry;
189
207
  /** Rejections of the underlying `fetch` itself: the node could not be reached or dropped the connection. */
190
208
  private readonly networkFailures;
209
+ /** The directory the node list came from, for a client made by {@link connect}. */
210
+ private source;
211
+ /**
212
+ * A client for the Calimero Cloud cluster named `cluster`: its node list read
213
+ * from the public node directory, those in `zone` first, each group in
214
+ * weighted-random order. The directory only picks which nodes to try: each is
215
+ * attested under `policy` before anything is sent to it. Throws
216
+ * {@link DirectoryError} when the directory names no usable node.
217
+ *
218
+ * The list is read again in the background after a failover, and at the
219
+ * first request once the directory's `refreshAfterSeconds` has passed.
220
+ */
221
+ static connect(options: CalimeroConnectOptions): Promise<CalimeroClient>;
191
222
  constructor(options: CalimeroClientOptions);
192
- /** The node requests go to now: `baseUrl`, until a `421 WRONG_NODE` moved the client on. */
223
+ /** The node requests go to now: `baseUrl`, until a `421 WRONG_NODE` or a failover moved the client on. */
193
224
  get nodeUrl(): string;
225
+ /** Every node this client may use, in the order it moves through them. */
226
+ get nodes(): string[];
194
227
  /** The sealed channel to `url`, attested under the client's policy before its first request. */
195
228
  private channel;
196
229
  /**
@@ -199,6 +232,19 @@ export declare class CalimeroClient {
199
232
  * refuse after another request moved on already does not move again.
200
233
  */
201
234
  private moveOn;
235
+ /**
236
+ * Fail over from node `from`, where a request's retries ran out, to the next
237
+ * node it has not `tried`. False when it has tried them all. A request whose
238
+ * node another request moved away from already goes to that node, if untried.
239
+ */
240
+ private failOver;
241
+ /**
242
+ * Read the node list again from the directory, in the background, for a
243
+ * client made by {@link connect}: the node in use stays first, then the
244
+ * directory's order; a node no longer listed is dropped. A failed read is
245
+ * ignored. One read at a time.
246
+ */
247
+ private reread;
202
248
  /** Authenticate every request with an API key. */
203
249
  useApiKey(key: string): void;
204
250
  useSession(session: CalimeroSession): void;
@@ -241,10 +287,17 @@ export declare class CalimeroClient {
241
287
  * {@link json} for a call or a view (D70): while the node answers 503 or
242
288
  * cannot be reached, the same request, with the same idempotency key, is sent
243
289
  * again after {@link retryDelayMs} (or the pause the node asked for, if
244
- * longer), at most `retries` times. Every other answer is final. The last
290
+ * longer), at most `retries` times. Every other answer is final.
291
+ *
292
+ * When a node's retries run out, the request fails over to the next node the
293
+ * client knows and its retries start again there, with the same key: the
294
+ * key is kept in the app's replicated log, so the cluster answers it once
295
+ * whichever node it reaches. Each node is tried once per request. The last
245
296
  * failure surfaces as {@link UnavailableError}: the node's 503, or reason
246
297
  * `network` for a connection that failed, whose `committed` is `'unknown'`.
247
- * `retries` 0 sends the request exactly once.
298
+ *
299
+ * `once` sends the request exactly once, to the node in use (the confirmed
300
+ * attempt of {@link callConfirmed}, whose challenge is single-use).
248
301
  */
249
302
  private retried;
250
303
  post<T = Record<string, unknown>>(path: string, body?: unknown): Promise<T>;
@@ -267,10 +320,15 @@ export declare class CalimeroClient {
267
320
  /** End every session of the signed-in account, on every device, this one too. */
268
321
  signOutEverywhere(): Promise<void>;
269
322
  me(): Promise<Record<string, unknown>>;
270
- /** Add another passkey (another device) to the signed-in account; answers its credential id. */
271
- addPasskey(passkey: PasskeyProvider): Promise<string>;
272
- /** Remove one of the account's passkeys (not the last). */
273
- removePasskey(credentialId: string): Promise<void>;
323
+ /**
324
+ * Add another passkey (another device, `passkey`) to the signed-in account;
325
+ * answers its credential id. The node asks for the approval of a passkey
326
+ * the account already has (`412 CONFIRM_REQUIRED`, never the new one),
327
+ * which `approver` gives.
328
+ */
329
+ addPasskey(passkey: PasskeyProvider, approver: PasskeyProvider): Promise<string>;
330
+ /** Remove one of the account's passkeys (not the last), approved with `passkey` (one it has). */
331
+ removePasskey(credentialId: string, passkey: PasskeyProvider): Promise<void>;
274
332
  /** Name one of the account's passkeys (a device label); `''` clears the name. */
275
333
  labelPasskey(credentialId: string, label: string): Promise<void>;
276
334
  /** What this account uses (apps, bundle bytes, API keys, owned orgs), with the cluster's quotas. */
@@ -283,7 +341,11 @@ export declare class CalimeroClient {
283
341
  /** The account's API keys (never the keys themselves). */
284
342
  listApiKeys(): Promise<Array<Record<string, unknown>>>;
285
343
  revokeApiKey(keyId: string): Promise<void>;
286
- /** Create an API key for an agent or server. The key is returned once. */
344
+ /**
345
+ * Create an API key for an agent or server, approved with the account's
346
+ * `passkey` (`412 CONFIRM_REQUIRED`); needs a passkey session (an API key
347
+ * is refused `403`). The key is returned once.
348
+ */
287
349
  createApiKey(options: {
288
350
  label?: string;
289
351
  apps?: string[];
@@ -291,7 +353,7 @@ export declare class CalimeroClient {
291
353
  readOnly?: boolean;
292
354
  allowConfirm?: boolean;
293
355
  ttlSecs?: number;
294
- }): Promise<{
356
+ }, passkey: PasskeyProvider): Promise<{
295
357
  keyId: string;
296
358
  key: string;
297
359
  }>;
@@ -365,10 +427,17 @@ export declare class CalimeroClient {
365
427
  }>;
366
428
  /**
367
429
  * POST `body` to `path`, answering a `412 CONFIRM_REQUIRED` with `passkey`'s
368
- * assertion over the node's challenge (deleting an org, approving a release, ...).
430
+ * assertion over the node's challenge (deleting an org, approving a release,
431
+ * adding a member, ...): the same body again, with `confirm`. Without a
432
+ * passkey (a caller in plain JavaScript passing none) the `412` reaches the
433
+ * caller as {@link ConfirmRequired}, carrying the challenge.
369
434
  */
370
435
  postConfirmed<T>(path: string, body: Record<string, unknown>, passkey: PasskeyProvider): Promise<T>;
371
- /** {@link postConfirmed} for a DELETE (deleting an organization). */
436
+ /**
437
+ * {@link postConfirmed} for a DELETE (deleting an organization, removing a
438
+ * member or a passkey): `body` (`{}` by default) goes as JSON, and the
439
+ * confirmed retry carries `confirm` beside it.
440
+ */
372
441
  deleteConfirmed<T>(path: string, passkey: PasskeyProvider, body?: Record<string, unknown>): Promise<T>;
373
442
  private confirmed;
374
443
  /**
@@ -559,15 +628,19 @@ export declare class CalimeroClient {
559
628
  setReleaseDeadline(version: string, after: Date | string): Promise<void>;
560
629
  /** Lift a release's deadline. Operator token. */
561
630
  liftReleaseDeadline(version: string): Promise<void>;
562
- /** Add a member, or change their role. */
563
- setMember(org: string, account: string, role: 'guest' | 'member' | 'admin' | 'owner'): Promise<void>;
564
- removeMember(org: string, account: string): Promise<Record<string, unknown>>;
631
+ /** Add a member, or change their role, approved with the caller's `passkey`. */
632
+ setMember(org: string, account: string, role: 'guest' | 'member' | 'admin' | 'owner', passkey: PasskeyProvider): Promise<void>;
633
+ /**
634
+ * Remove a member, or leave (`account` the caller's own), approved with the
635
+ * caller's `passkey`.
636
+ */
637
+ removeMember(org: string, account: string, passkey: PasskeyProvider): Promise<Record<string, unknown>>;
565
638
  /**
566
639
  * Set capability bits ({@link ORG_CAPABILITIES}): for a member (`account`),
567
640
  * for a member of one team (`account` and `team`), or the organization's
568
- * default (neither).
641
+ * default (neither). Approved with the caller's `passkey`.
569
642
  */
570
- setCapabilities(org: string, capabilities: number, at?: {
643
+ setCapabilities(org: string, capabilities: number, passkey: PasskeyProvider, at?: {
571
644
  account?: string;
572
645
  team?: string;
573
646
  }): Promise<void>;
@@ -585,14 +658,23 @@ export declare class CalimeroClient {
585
658
  }): Promise<void>;
586
659
  /** Delete a team, the teams below it and their apps. */
587
660
  deleteTeam(org: string, team: string): Promise<void>;
588
- /** Add a member to a team, or change their row: role, lead, capability bits. */
589
- setTeamMember(org: string, team: string, account: string, row?: {
661
+ /**
662
+ * Add a member to a team, or change their row: role, lead, capability bits.
663
+ * Approved with the caller's `passkey`.
664
+ */
665
+ setTeamMember(org: string, team: string, account: string, passkey: PasskeyProvider, row?: {
590
666
  role?: 'lead' | 'member' | 'readOnly';
591
667
  lead?: boolean;
592
668
  capabilities?: number;
593
669
  }): Promise<void>;
594
- removeTeamMember(org: string, team: string, account: string): Promise<void>;
595
- invite(org: string, role: 'guest' | 'member' | 'admin', teams?: string[], options?: {
670
+ /** Remove a member from a team, approved with the caller's `passkey`. */
671
+ removeTeamMember(org: string, team: string, account: string, passkey: PasskeyProvider): Promise<void>;
672
+ /**
673
+ * Make an invite into `org` (and `teams`), approved with the caller's
674
+ * `passkey`; answers its token, which {@link redeemInvite} takes (no
675
+ * approval there).
676
+ */
677
+ invite(org: string, role: 'guest' | 'member' | 'admin', passkey: PasskeyProvider, teams?: string[], options?: {
596
678
  ttlSecs?: number;
597
679
  uses?: number;
598
680
  }): Promise<string>;
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { SealedTransportError, createSealedFetch } from '../sealed/sealed.js';
13
13
  import { fetchAttestedKey } from './attestation.js';
14
+ import { DEFAULT_DIRECTORY, orderNodes, readDirectory } from './directory.js';
14
15
  import { CalimeroError, ConfirmCancelled, ConfirmRequired, LoginRequired, UnavailableError, WrongNode, errorFrom, } from './errors.js';
15
16
  import { b64url, fromB64url } from './passkey.js';
16
17
  import { confirmationCode, pageQuery, pendingApprovalsFrom, } from './approvals.js';
@@ -114,6 +115,32 @@ function ceremony(path) {
114
115
  return path === 'auth/refresh' || path.startsWith('auth/passkey/');
115
116
  }
116
117
  export class CalimeroClient {
118
+ /**
119
+ * A client for the Calimero Cloud cluster named `cluster`: its node list read
120
+ * from the public node directory, those in `zone` first, each group in
121
+ * weighted-random order. The directory only picks which nodes to try: each is
122
+ * attested under `policy` before anything is sent to it. Throws
123
+ * {@link DirectoryError} when the directory names no usable node.
124
+ *
125
+ * The list is read again in the background after a failover, and at the
126
+ * first request once the directory's `refreshAfterSeconds` has passed.
127
+ */
128
+ static async connect(options) {
129
+ const directory = options.directory ?? DEFAULT_DIRECTORY;
130
+ const random = options.random ?? Math.random;
131
+ const fetcher = options.fetch ?? ((input, init) => fetch(input, init));
132
+ const listing = await readDirectory(directory, options.cluster, fetcher);
133
+ const [baseUrl, ...nodes] = orderNodes(listing.nodes, options.zone, random);
134
+ const client = new CalimeroClient({ baseUrl, nodes, policy: options.policy, fetch: options.fetch, retry: options.retry });
135
+ client.source = {
136
+ directory,
137
+ cluster: options.cluster,
138
+ zone: options.zone,
139
+ random,
140
+ due: Date.now() + listing.refreshAfterSeconds * 1000,
141
+ };
142
+ return client;
143
+ }
117
144
  constructor(options) {
118
145
  /** The one in use: an index into {@link nodeUrls}. */
119
146
  this.at = 0;
@@ -142,10 +169,14 @@ export class CalimeroClient {
142
169
  };
143
170
  this.raw = raw;
144
171
  }
145
- /** The node requests go to now: `baseUrl`, until a `421 WRONG_NODE` moved the client on. */
172
+ /** The node requests go to now: `baseUrl`, until a `421 WRONG_NODE` or a failover moved the client on. */
146
173
  get nodeUrl() {
147
174
  return this.nodeUrls[this.at];
148
175
  }
176
+ /** Every node this client may use, in the order it moves through them. */
177
+ get nodes() {
178
+ return [...this.nodeUrls];
179
+ }
149
180
  /** The sealed channel to `url`, attested under the client's policy before its first request. */
150
181
  channel(url) {
151
182
  let sealed = this.channels.get(url);
@@ -167,10 +198,52 @@ export class CalimeroClient {
167
198
  moveOn(from) {
168
199
  if (this.nodeUrls.length < 2)
169
200
  return false;
170
- if (this.at === from)
171
- this.at = (from + 1) % this.nodeUrls.length;
201
+ if (this.nodeUrl === from)
202
+ this.at = (this.at + 1) % this.nodeUrls.length;
172
203
  return true;
173
204
  }
205
+ /**
206
+ * Fail over from node `from`, where a request's retries ran out, to the next
207
+ * node it has not `tried`. False when it has tried them all. A request whose
208
+ * node another request moved away from already goes to that node, if untried.
209
+ */
210
+ failOver(from, tried) {
211
+ if (this.nodeUrl !== from && !tried.has(this.nodeUrl))
212
+ return true;
213
+ const count = this.nodeUrls.length;
214
+ for (let step = 1; step < count; step++) {
215
+ const next = (this.at + step) % count;
216
+ if (!tried.has(this.nodeUrls[next])) {
217
+ this.at = next;
218
+ this.reread();
219
+ return true;
220
+ }
221
+ }
222
+ return false;
223
+ }
224
+ /**
225
+ * Read the node list again from the directory, in the background, for a
226
+ * client made by {@link connect}: the node in use stays first, then the
227
+ * directory's order; a node no longer listed is dropped. A failed read is
228
+ * ignored. One read at a time.
229
+ */
230
+ reread() {
231
+ const source = this.source;
232
+ if (!source || source.reading)
233
+ return;
234
+ source.reading = readDirectory(source.directory, source.cluster, this.raw)
235
+ .then((listing) => {
236
+ const current = this.nodeUrl;
237
+ const listed = orderNodes(listing.nodes, source.zone, source.random);
238
+ this.nodeUrls = [current, ...listed.filter((url) => url !== current)];
239
+ this.at = 0;
240
+ source.due = Date.now() + listing.refreshAfterSeconds * 1000;
241
+ })
242
+ .catch(() => undefined)
243
+ .finally(() => {
244
+ source.reading = undefined;
245
+ });
246
+ }
174
247
  /** Authenticate every request with an API key. */
175
248
  useApiKey(key) {
176
249
  this.auth = key;
@@ -200,6 +273,8 @@ export class CalimeroClient {
200
273
  headers['content-type'] = 'application/json';
201
274
  payload = JSON.stringify(body);
202
275
  }
276
+ if (this.source && Date.now() >= this.source.due)
277
+ this.reread();
203
278
  const url = this.nodeUrl;
204
279
  try {
205
280
  return await this.channel(url)(`${url}/${path}`, { method, headers, body: payload, signal });
@@ -232,7 +307,7 @@ export class CalimeroClient {
232
307
  let refreshed = false;
233
308
  let moved = false;
234
309
  for (;;) {
235
- const [token, at] = [this.auth, this.at];
310
+ const [token, at] = [this.auth, this.nodeUrl];
236
311
  const response = await this.request(method, path, body, raw, contentType, signal);
237
312
  if (response.ok)
238
313
  return response;
@@ -272,30 +347,46 @@ export class CalimeroClient {
272
347
  * {@link json} for a call or a view (D70): while the node answers 503 or
273
348
  * cannot be reached, the same request, with the same idempotency key, is sent
274
349
  * again after {@link retryDelayMs} (or the pause the node asked for, if
275
- * longer), at most `retries` times. Every other answer is final. The last
350
+ * longer), at most `retries` times. Every other answer is final.
351
+ *
352
+ * When a node's retries run out, the request fails over to the next node the
353
+ * client knows and its retries start again there, with the same key: the
354
+ * key is kept in the app's replicated log, so the cluster answers it once
355
+ * whichever node it reaches. Each node is tried once per request. The last
276
356
  * failure surfaces as {@link UnavailableError}: the node's 503, or reason
277
357
  * `network` for a connection that failed, whose `committed` is `'unknown'`.
278
- * `retries` 0 sends the request exactly once.
358
+ *
359
+ * `once` sends the request exactly once, to the node in use (the confirmed
360
+ * attempt of {@link callConfirmed}, whose challenge is single-use).
279
361
  */
280
- async retried(method, path, body, retries = this.retry.retries) {
281
- for (let attempt = 0;; attempt++) {
362
+ async retried(method, path, body, once = false) {
363
+ const retries = once ? 0 : this.retry.retries;
364
+ const tried = new Set();
365
+ for (;;) {
282
366
  let unavailable;
283
- try {
284
- return await this.json(method, path, body);
285
- }
286
- catch (error) {
287
- if (error instanceof UnavailableError)
288
- unavailable = error;
289
- else if (error instanceof TypeError && this.networkFailures.has(error)) {
290
- unavailable = new UnavailableError('NETWORK', `the node could not be reached: ${error.message}`, {}, undefined, error);
367
+ let node = this.nodeUrl;
368
+ for (let attempt = 0;; attempt++) {
369
+ node = this.nodeUrl;
370
+ tried.add(node);
371
+ try {
372
+ return await this.json(method, path, body);
291
373
  }
292
- else
293
- throw error;
374
+ catch (error) {
375
+ if (error instanceof UnavailableError)
376
+ unavailable = error;
377
+ else if (error instanceof TypeError && this.networkFailures.has(error)) {
378
+ unavailable = new UnavailableError('NETWORK', `the node could not be reached: ${error.message}`, {}, undefined, error);
379
+ }
380
+ else
381
+ throw error;
382
+ }
383
+ if (attempt >= retries)
384
+ break;
385
+ const backoff = retryDelayMs(attempt, this.retry);
386
+ await new Promise((resolve) => setTimeout(resolve, Math.max(backoff, (unavailable.retryAfter ?? 0) * 1000)));
294
387
  }
295
- if (attempt >= retries)
388
+ if (once || !this.failOver(node, tried))
296
389
  throw unavailable;
297
- const backoff = retryDelayMs(attempt, this.retry);
298
- await new Promise((resolve) => setTimeout(resolve, Math.max(backoff, (unavailable.retryAfter ?? 0) * 1000)));
299
390
  }
300
391
  }
301
392
  post(path, body = {}) {
@@ -387,19 +478,21 @@ export class CalimeroClient {
387
478
  me() {
388
479
  return this.get('auth/me');
389
480
  }
390
- /** Add another passkey (another device) to the signed-in account; answers its credential id. */
391
- async addPasskey(passkey) {
481
+ /**
482
+ * Add another passkey (another device, `passkey`) to the signed-in account;
483
+ * answers its credential id. The node asks for the approval of a passkey
484
+ * the account already has (`412 CONFIRM_REQUIRED`, never the new one),
485
+ * which `approver` gives.
486
+ */
487
+ async addPasskey(passkey, approver) {
392
488
  const begin = await this.post('auth/passkeys/add/begin');
393
489
  const registration = await passkey.create(begin.options);
394
- const { credentialId } = await this.post('auth/passkeys/add/finish', {
395
- challengeId: begin.challengeId,
396
- registration,
397
- });
490
+ const { credentialId } = await this.postConfirmed('auth/passkeys/add/finish', { challengeId: begin.challengeId, registration }, approver);
398
491
  return credentialId;
399
492
  }
400
- /** Remove one of the account's passkeys (not the last). */
401
- async removePasskey(credentialId) {
402
- await this.delete(`auth/passkeys/${seg(credentialId)}`);
493
+ /** Remove one of the account's passkeys (not the last), approved with `passkey` (one it has). */
494
+ async removePasskey(credentialId, passkey) {
495
+ await this.deleteConfirmed(`auth/passkeys/${seg(credentialId)}`, passkey);
403
496
  }
404
497
  /** Name one of the account's passkeys (a device label); `''` clears the name. */
405
498
  async labelPasskey(credentialId, label) {
@@ -428,9 +521,13 @@ export class CalimeroClient {
428
521
  async revokeApiKey(keyId) {
429
522
  await this.delete(`auth/apikeys/${seg(keyId)}`);
430
523
  }
431
- /** Create an API key for an agent or server. The key is returned once. */
432
- createApiKey(options) {
433
- return this.post('auth/apikeys', options);
524
+ /**
525
+ * Create an API key for an agent or server, approved with the account's
526
+ * `passkey` (`412 CONFIRM_REQUIRED`); needs a passkey session (an API key
527
+ * is refused `403`). The key is returned once.
528
+ */
529
+ createApiKey(options, passkey) {
530
+ return this.postConfirmed('auth/apikeys', { ...options }, passkey);
434
531
  }
435
532
  // ---------------------------------------------------------------- apps
436
533
  /** Upload a raw WASM bundle (development clusters only); answers its hash. */
@@ -540,12 +637,19 @@ export class CalimeroClient {
540
637
  }
541
638
  /**
542
639
  * POST `body` to `path`, answering a `412 CONFIRM_REQUIRED` with `passkey`'s
543
- * assertion over the node's challenge (deleting an org, approving a release, ...).
640
+ * assertion over the node's challenge (deleting an org, approving a release,
641
+ * adding a member, ...): the same body again, with `confirm`. Without a
642
+ * passkey (a caller in plain JavaScript passing none) the `412` reaches the
643
+ * caller as {@link ConfirmRequired}, carrying the challenge.
544
644
  */
545
645
  postConfirmed(path, body, passkey) {
546
646
  return this.confirmed('POST', path, body, passkey);
547
647
  }
548
- /** {@link postConfirmed} for a DELETE (deleting an organization). */
648
+ /**
649
+ * {@link postConfirmed} for a DELETE (deleting an organization, removing a
650
+ * member or a passkey): `body` (`{}` by default) goes as JSON, and the
651
+ * confirmed retry carries `confirm` beside it.
652
+ */
549
653
  deleteConfirmed(path, passkey, body = {}) {
550
654
  return this.confirmed('DELETE', path, body, passkey);
551
655
  }
@@ -554,7 +658,7 @@ export class CalimeroClient {
554
658
  return await send(body);
555
659
  }
556
660
  catch (error) {
557
- if (!(error instanceof ConfirmRequired))
661
+ if (!(error instanceof ConfirmRequired) || !passkey)
558
662
  throw error;
559
663
  const d = error.details;
560
664
  let assertion;
@@ -600,7 +704,7 @@ export class CalimeroClient {
600
704
  const key = idempotencyKey ?? newIdempotencyKey();
601
705
  // The confirmed attempt goes out exactly once: its challenge is single-use,
602
706
  // so a resend would be refused on the challenge, not answered from the key.
603
- return this.confirmed('POST', path, { method, args, idempotencyKey: key }, passkey, (body) => this.retried('POST', path, body, body.confirm === undefined ? this.retry.retries : 0));
707
+ return this.confirmed('POST', path, { method, args, idempotencyKey: key }, passkey, (body) => this.retried('POST', path, body, body.confirm !== undefined));
604
708
  }
605
709
  /** Run a read-only method: strong by default, or stale from any replica. */
606
710
  view(app, method, args = {}, options = {}) {
@@ -882,20 +986,24 @@ export class CalimeroClient {
882
986
  async liftReleaseDeadline(version) {
883
987
  await this.delete(`ops/releases/${seg(version)}/deadline`);
884
988
  }
885
- /** Add a member, or change their role. */
886
- async setMember(org, account, role) {
887
- await this.post(`orgs/${seg(org)}/members`, { account, role });
989
+ /** Add a member, or change their role, approved with the caller's `passkey`. */
990
+ async setMember(org, account, role, passkey) {
991
+ await this.postConfirmed(`orgs/${seg(org)}/members`, { account, role }, passkey);
888
992
  }
889
- removeMember(org, account) {
890
- return this.delete(`orgs/${seg(org)}/members/${seg(account)}`);
993
+ /**
994
+ * Remove a member, or leave (`account` the caller's own), approved with the
995
+ * caller's `passkey`.
996
+ */
997
+ removeMember(org, account, passkey) {
998
+ return this.deleteConfirmed(`orgs/${seg(org)}/members/${seg(account)}`, passkey);
891
999
  }
892
1000
  /**
893
1001
  * Set capability bits ({@link ORG_CAPABILITIES}): for a member (`account`),
894
1002
  * for a member of one team (`account` and `team`), or the organization's
895
- * default (neither).
1003
+ * default (neither). Approved with the caller's `passkey`.
896
1004
  */
897
- async setCapabilities(org, capabilities, at = {}) {
898
- await this.post(`orgs/${seg(org)}/capabilities`, { capabilities, ...at });
1005
+ async setCapabilities(org, capabilities, passkey, at = {}) {
1006
+ await this.postConfirmed(`orgs/${seg(org)}/capabilities`, { capabilities, ...at }, passkey);
899
1007
  }
900
1008
  async createTeam(org, name, join, parent) {
901
1009
  const { teamId } = await this.post(`orgs/${seg(org)}/teams`, { name, join, parent });
@@ -915,15 +1023,24 @@ export class CalimeroClient {
915
1023
  async deleteTeam(org, team) {
916
1024
  await this.delete(`orgs/${seg(org)}/teams/${seg(team)}`);
917
1025
  }
918
- /** Add a member to a team, or change their row: role, lead, capability bits. */
919
- async setTeamMember(org, team, account, row = {}) {
920
- await this.post(`orgs/${seg(org)}/teams/${seg(team)}/members`, { account, ...row });
1026
+ /**
1027
+ * Add a member to a team, or change their row: role, lead, capability bits.
1028
+ * Approved with the caller's `passkey`.
1029
+ */
1030
+ async setTeamMember(org, team, account, passkey, row = {}) {
1031
+ await this.postConfirmed(`orgs/${seg(org)}/teams/${seg(team)}/members`, { account, ...row }, passkey);
921
1032
  }
922
- async removeTeamMember(org, team, account) {
923
- await this.delete(`orgs/${seg(org)}/teams/${seg(team)}/members/${seg(account)}`);
1033
+ /** Remove a member from a team, approved with the caller's `passkey`. */
1034
+ async removeTeamMember(org, team, account, passkey) {
1035
+ await this.deleteConfirmed(`orgs/${seg(org)}/teams/${seg(team)}/members/${seg(account)}`, passkey);
924
1036
  }
925
- async invite(org, role, teams = [], options = {}) {
926
- const { token } = await this.post(`orgs/${seg(org)}/invites`, { role, teams, ...options });
1037
+ /**
1038
+ * Make an invite into `org` (and `teams`), approved with the caller's
1039
+ * `passkey`; answers its token, which {@link redeemInvite} takes (no
1040
+ * approval there).
1041
+ */
1042
+ async invite(org, role, passkey, teams = [], options = {}) {
1043
+ const { token } = await this.postConfirmed(`orgs/${seg(org)}/invites`, { role, teams, ...options }, passkey);
927
1044
  return token;
928
1045
  }
929
1046
  redeemInvite(token) {