@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 +34 -5
- package/dist/cloud-v2/client.d.ts +107 -25
- package/dist/cloud-v2/client.js +169 -52
- package/dist/cloud-v2/directory.d.ts +50 -0
- package/dist/cloud-v2/directory.js +115 -0
- package/dist/cloud-v2/index.d.ts +3 -1
- package/dist/cloud-v2/index.js +1 -0
- package/dist/index.browser.mjs +4 -4
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +247 -49
- package/dist/index.cjs.map +4 -4
- package/dist/index.mjs +247 -49
- package/dist/index.mjs.map +4 -4
- package/package.json +1 -1
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
|
-
|
|
35
|
-
|
|
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)
|
|
164
|
-
*
|
|
165
|
-
*
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
589
|
-
|
|
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
|
-
|
|
595
|
-
|
|
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>;
|
package/dist/cloud-v2/client.js
CHANGED
|
@@ -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.
|
|
171
|
-
this.at = (
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
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,
|
|
281
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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 (
|
|
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
|
-
/**
|
|
391
|
-
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
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
|
-
|
|
890
|
-
|
|
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.
|
|
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
|
-
/**
|
|
919
|
-
|
|
920
|
-
|
|
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
|
-
|
|
923
|
-
|
|
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
|
-
|
|
926
|
-
|
|
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) {
|