@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/dist/index.cjs CHANGED
@@ -40,8 +40,10 @@ __export(index_exports, {
40
40
  ConfirmRequired: () => ConfirmRequired,
41
41
  ConsentStale: () => ConsentStale,
42
42
  DEFAULT_COLLATERAL_MIRROR: () => DEFAULT_COLLATERAL_MIRROR,
43
+ DEFAULT_DIRECTORY: () => DEFAULT_DIRECTORY,
43
44
  DEFAULT_ORG_CAPABILITIES: () => DEFAULT_ORG_CAPABILITIES,
44
45
  DEFAULT_RELEASE_MIRROR: () => DEFAULT_RELEASE_MIRROR,
46
+ DirectoryError: () => DirectoryError,
45
47
  GITHUB_RELEASES: () => GITHUB_RELEASES,
46
48
  HANDSHAKE_PATH: () => HANDSHAKE_PATH,
47
49
  IdempotencyMismatch: () => IdempotencyMismatch,
@@ -1357,6 +1359,85 @@ async function fetchAttestedKey(baseUrl, policy, fetchImpl = (input, init) => fe
1357
1359
  return transportKey;
1358
1360
  }
1359
1361
 
1362
+ // src/cloud-v2/directory.ts
1363
+ var DEFAULT_DIRECTORY = "https://manager.cloud.calimero.network/api/v2/directory";
1364
+ var DEFAULT_REFRESH_SECONDS = 60;
1365
+ var DirectoryError = class extends Error {
1366
+ constructor(cluster, reason, message, status, cause) {
1367
+ super(`directory for cluster ${cluster}: ${message}`);
1368
+ this.cluster = cluster;
1369
+ this.reason = reason;
1370
+ this.status = status;
1371
+ this.cause = cause;
1372
+ this.name = "DirectoryError";
1373
+ }
1374
+ };
1375
+ function directoryUrl(directory, cluster) {
1376
+ return `${directory.replace(/\/+$/, "")}/${encodeURIComponent(cluster)}`;
1377
+ }
1378
+ async function readDirectory(directory, cluster, fetcher) {
1379
+ let response;
1380
+ try {
1381
+ response = await fetcher(directoryUrl(directory, cluster), { method: "GET", headers: { accept: "application/json" } });
1382
+ } catch (error) {
1383
+ throw new DirectoryError(cluster, "unreachable", `could not be reached: ${String(error)}`, void 0, error);
1384
+ }
1385
+ if (response.status === 404) throw new DirectoryError(cluster, "not-found", "unknown cluster", 404);
1386
+ if (!response.ok) throw new DirectoryError(cluster, "unreachable", `answered ${response.status}`, response.status);
1387
+ let body;
1388
+ try {
1389
+ body = await response.json();
1390
+ } catch (error) {
1391
+ throw new DirectoryError(cluster, "bad-response", "answered something other than JSON", response.status, error);
1392
+ }
1393
+ const listing = body;
1394
+ if (!listing || typeof listing !== "object" || !Array.isArray(listing.nodes)) {
1395
+ throw new DirectoryError(cluster, "bad-response", "answered no node list", response.status);
1396
+ }
1397
+ const nodes = [];
1398
+ const seen = /* @__PURE__ */ new Set();
1399
+ for (const entry of listing.nodes) {
1400
+ const node = usable(entry);
1401
+ if (node && !seen.has(node.url)) {
1402
+ seen.add(node.url);
1403
+ nodes.push(node);
1404
+ }
1405
+ }
1406
+ if (nodes.length === 0) throw new DirectoryError(cluster, "empty", "lists no usable node", response.status);
1407
+ const after = listing.refreshAfterSeconds;
1408
+ const refreshAfterSeconds = typeof after === "number" && Number.isFinite(after) && after >= 1 ? after : DEFAULT_REFRESH_SECONDS;
1409
+ return { nodes, refreshAfterSeconds };
1410
+ }
1411
+ function usable(entry) {
1412
+ if (!entry || typeof entry !== "object") return void 0;
1413
+ const { url: url2, zone, weight } = entry;
1414
+ if (typeof url2 !== "string" || typeof weight !== "number" || !Number.isInteger(weight) || weight < 1) return void 0;
1415
+ let parsed;
1416
+ try {
1417
+ parsed = new URL(url2);
1418
+ } catch {
1419
+ return void 0;
1420
+ }
1421
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return void 0;
1422
+ return { url: url2.replace(/\/+$/, ""), zone: typeof zone === "string" ? zone : void 0, weight };
1423
+ }
1424
+ function orderNodes(nodes, zone, random) {
1425
+ const near = zone === void 0 ? [] : nodes.filter((node) => node.zone === zone);
1426
+ const far = zone === void 0 ? nodes : nodes.filter((node) => node.zone !== zone);
1427
+ return [...weightedOrder(near, random), ...weightedOrder(far, random)];
1428
+ }
1429
+ function weightedOrder(nodes, random) {
1430
+ const left = [...nodes];
1431
+ const order = [];
1432
+ while (left.length > 0) {
1433
+ let roll = random() * left.reduce((sum, node) => sum + node.weight, 0);
1434
+ let pick = 0;
1435
+ while (pick < left.length - 1 && roll >= left[pick].weight) roll -= left[pick++].weight;
1436
+ order.push(left.splice(pick, 1)[0].url);
1437
+ }
1438
+ return order;
1439
+ }
1440
+
1360
1441
  // src/cloud-v2/errors.ts
1361
1442
  var CalimeroError = class extends Error {
1362
1443
  constructor(status, code, message, details = {}) {
@@ -1743,7 +1824,7 @@ function retryDelayMs(retry, options) {
1743
1824
  function ceremony(path) {
1744
1825
  return path === "auth/refresh" || path.startsWith("auth/passkey/");
1745
1826
  }
1746
- var CalimeroClient = class {
1827
+ var CalimeroClient = class _CalimeroClient {
1747
1828
  constructor(options) {
1748
1829
  /** The one in use: an index into {@link nodeUrls}. */
1749
1830
  this.at = 0;
@@ -1766,10 +1847,40 @@ var CalimeroClient = class {
1766
1847
  };
1767
1848
  this.raw = raw;
1768
1849
  }
1769
- /** The node requests go to now: `baseUrl`, until a `421 WRONG_NODE` moved the client on. */
1850
+ /**
1851
+ * A client for the Calimero Cloud cluster named `cluster`: its node list read
1852
+ * from the public node directory, those in `zone` first, each group in
1853
+ * weighted-random order. The directory only picks which nodes to try: each is
1854
+ * attested under `policy` before anything is sent to it. Throws
1855
+ * {@link DirectoryError} when the directory names no usable node.
1856
+ *
1857
+ * The list is read again in the background after a failover, and at the
1858
+ * first request once the directory's `refreshAfterSeconds` has passed.
1859
+ */
1860
+ static async connect(options) {
1861
+ const directory = options.directory ?? DEFAULT_DIRECTORY;
1862
+ const random = options.random ?? Math.random;
1863
+ const fetcher = options.fetch ?? ((input, init) => fetch(input, init));
1864
+ const listing = await readDirectory(directory, options.cluster, fetcher);
1865
+ const [baseUrl, ...nodes] = orderNodes(listing.nodes, options.zone, random);
1866
+ const client = new _CalimeroClient({ baseUrl, nodes, policy: options.policy, fetch: options.fetch, retry: options.retry });
1867
+ client.source = {
1868
+ directory,
1869
+ cluster: options.cluster,
1870
+ zone: options.zone,
1871
+ random,
1872
+ due: Date.now() + listing.refreshAfterSeconds * 1e3
1873
+ };
1874
+ return client;
1875
+ }
1876
+ /** The node requests go to now: `baseUrl`, until a `421 WRONG_NODE` or a failover moved the client on. */
1770
1877
  get nodeUrl() {
1771
1878
  return this.nodeUrls[this.at];
1772
1879
  }
1880
+ /** Every node this client may use, in the order it moves through them. */
1881
+ get nodes() {
1882
+ return [...this.nodeUrls];
1883
+ }
1773
1884
  /** The sealed channel to `url`, attested under the client's policy before its first request. */
1774
1885
  channel(url2) {
1775
1886
  let sealed = this.channels.get(url2);
@@ -1790,9 +1901,46 @@ var CalimeroClient = class {
1790
1901
  */
1791
1902
  moveOn(from) {
1792
1903
  if (this.nodeUrls.length < 2) return false;
1793
- if (this.at === from) this.at = (from + 1) % this.nodeUrls.length;
1904
+ if (this.nodeUrl === from) this.at = (this.at + 1) % this.nodeUrls.length;
1794
1905
  return true;
1795
1906
  }
1907
+ /**
1908
+ * Fail over from node `from`, where a request's retries ran out, to the next
1909
+ * node it has not `tried`. False when it has tried them all. A request whose
1910
+ * node another request moved away from already goes to that node, if untried.
1911
+ */
1912
+ failOver(from, tried) {
1913
+ if (this.nodeUrl !== from && !tried.has(this.nodeUrl)) return true;
1914
+ const count = this.nodeUrls.length;
1915
+ for (let step = 1; step < count; step++) {
1916
+ const next = (this.at + step) % count;
1917
+ if (!tried.has(this.nodeUrls[next])) {
1918
+ this.at = next;
1919
+ this.reread();
1920
+ return true;
1921
+ }
1922
+ }
1923
+ return false;
1924
+ }
1925
+ /**
1926
+ * Read the node list again from the directory, in the background, for a
1927
+ * client made by {@link connect}: the node in use stays first, then the
1928
+ * directory's order; a node no longer listed is dropped. A failed read is
1929
+ * ignored. One read at a time.
1930
+ */
1931
+ reread() {
1932
+ const source = this.source;
1933
+ if (!source || source.reading) return;
1934
+ source.reading = readDirectory(source.directory, source.cluster, this.raw).then((listing) => {
1935
+ const current = this.nodeUrl;
1936
+ const listed = orderNodes(listing.nodes, source.zone, source.random);
1937
+ this.nodeUrls = [current, ...listed.filter((url2) => url2 !== current)];
1938
+ this.at = 0;
1939
+ source.due = Date.now() + listing.refreshAfterSeconds * 1e3;
1940
+ }).catch(() => void 0).finally(() => {
1941
+ source.reading = void 0;
1942
+ });
1943
+ }
1796
1944
  /** Authenticate every request with an API key. */
1797
1945
  useApiKey(key) {
1798
1946
  this.auth = key;
@@ -1820,6 +1968,7 @@ var CalimeroClient = class {
1820
1968
  headers["content-type"] = "application/json";
1821
1969
  payload = JSON.stringify(body);
1822
1970
  }
1971
+ if (this.source && Date.now() >= this.source.due) this.reread();
1823
1972
  const url2 = this.nodeUrl;
1824
1973
  try {
1825
1974
  return await this.channel(url2)(`${url2}/${path}`, { method, headers, body: payload, signal });
@@ -1851,7 +2000,7 @@ var CalimeroClient = class {
1851
2000
  let refreshed = false;
1852
2001
  let moved = false;
1853
2002
  for (; ; ) {
1854
- const [token, at] = [this.auth, this.at];
2003
+ const [token, at] = [this.auth, this.nodeUrl];
1855
2004
  const response = await this.request(method, path, body, raw, contentType, signal);
1856
2005
  if (response.ok) return response;
1857
2006
  const error = await refusal(response);
@@ -1887,25 +2036,40 @@ var CalimeroClient = class {
1887
2036
  * {@link json} for a call or a view (D70): while the node answers 503 or
1888
2037
  * cannot be reached, the same request, with the same idempotency key, is sent
1889
2038
  * again after {@link retryDelayMs} (or the pause the node asked for, if
1890
- * longer), at most `retries` times. Every other answer is final. The last
2039
+ * longer), at most `retries` times. Every other answer is final.
2040
+ *
2041
+ * When a node's retries run out, the request fails over to the next node the
2042
+ * client knows and its retries start again there, with the same key: the
2043
+ * key is kept in the app's replicated log, so the cluster answers it once
2044
+ * whichever node it reaches. Each node is tried once per request. The last
1891
2045
  * failure surfaces as {@link UnavailableError}: the node's 503, or reason
1892
2046
  * `network` for a connection that failed, whose `committed` is `'unknown'`.
1893
- * `retries` 0 sends the request exactly once.
2047
+ *
2048
+ * `once` sends the request exactly once, to the node in use (the confirmed
2049
+ * attempt of {@link callConfirmed}, whose challenge is single-use).
1894
2050
  */
1895
- async retried(method, path, body, retries = this.retry.retries) {
1896
- for (let attempt = 0; ; attempt++) {
2051
+ async retried(method, path, body, once = false) {
2052
+ const retries = once ? 0 : this.retry.retries;
2053
+ const tried = /* @__PURE__ */ new Set();
2054
+ for (; ; ) {
1897
2055
  let unavailable;
1898
- try {
1899
- return await this.json(method, path, body);
1900
- } catch (error) {
1901
- if (error instanceof UnavailableError) unavailable = error;
1902
- else if (error instanceof TypeError && this.networkFailures.has(error)) {
1903
- unavailable = new UnavailableError("NETWORK", `the node could not be reached: ${error.message}`, {}, void 0, error);
1904
- } else throw error;
2056
+ let node = this.nodeUrl;
2057
+ for (let attempt = 0; ; attempt++) {
2058
+ node = this.nodeUrl;
2059
+ tried.add(node);
2060
+ try {
2061
+ return await this.json(method, path, body);
2062
+ } catch (error) {
2063
+ if (error instanceof UnavailableError) unavailable = error;
2064
+ else if (error instanceof TypeError && this.networkFailures.has(error)) {
2065
+ unavailable = new UnavailableError("NETWORK", `the node could not be reached: ${error.message}`, {}, void 0, error);
2066
+ } else throw error;
2067
+ }
2068
+ if (attempt >= retries) break;
2069
+ const backoff = retryDelayMs(attempt, this.retry);
2070
+ await new Promise((resolve) => setTimeout(resolve, Math.max(backoff, (unavailable.retryAfter ?? 0) * 1e3)));
1905
2071
  }
1906
- if (attempt >= retries) throw unavailable;
1907
- const backoff = retryDelayMs(attempt, this.retry);
1908
- await new Promise((resolve) => setTimeout(resolve, Math.max(backoff, (unavailable.retryAfter ?? 0) * 1e3)));
2072
+ if (once || !this.failOver(node, tried)) throw unavailable;
1909
2073
  }
1910
2074
  }
1911
2075
  post(path, body = {}) {
@@ -1993,19 +2157,25 @@ var CalimeroClient = class {
1993
2157
  me() {
1994
2158
  return this.get("auth/me");
1995
2159
  }
1996
- /** Add another passkey (another device) to the signed-in account; answers its credential id. */
1997
- async addPasskey(passkey) {
2160
+ /**
2161
+ * Add another passkey (another device, `passkey`) to the signed-in account;
2162
+ * answers its credential id. The node asks for the approval of a passkey
2163
+ * the account already has (`412 CONFIRM_REQUIRED`, never the new one),
2164
+ * which `approver` gives.
2165
+ */
2166
+ async addPasskey(passkey, approver) {
1998
2167
  const begin = await this.post("auth/passkeys/add/begin");
1999
2168
  const registration = await passkey.create(begin.options);
2000
- const { credentialId } = await this.post("auth/passkeys/add/finish", {
2001
- challengeId: begin.challengeId,
2002
- registration
2003
- });
2169
+ const { credentialId } = await this.postConfirmed(
2170
+ "auth/passkeys/add/finish",
2171
+ { challengeId: begin.challengeId, registration },
2172
+ approver
2173
+ );
2004
2174
  return credentialId;
2005
2175
  }
2006
- /** Remove one of the account's passkeys (not the last). */
2007
- async removePasskey(credentialId) {
2008
- await this.delete(`auth/passkeys/${seg(credentialId)}`);
2176
+ /** Remove one of the account's passkeys (not the last), approved with `passkey` (one it has). */
2177
+ async removePasskey(credentialId, passkey) {
2178
+ await this.deleteConfirmed(`auth/passkeys/${seg(credentialId)}`, passkey);
2009
2179
  }
2010
2180
  /** Name one of the account's passkeys (a device label); `''` clears the name. */
2011
2181
  async labelPasskey(credentialId, label) {
@@ -2033,9 +2203,13 @@ var CalimeroClient = class {
2033
2203
  async revokeApiKey(keyId) {
2034
2204
  await this.delete(`auth/apikeys/${seg(keyId)}`);
2035
2205
  }
2036
- /** Create an API key for an agent or server. The key is returned once. */
2037
- createApiKey(options) {
2038
- return this.post("auth/apikeys", options);
2206
+ /**
2207
+ * Create an API key for an agent or server, approved with the account's
2208
+ * `passkey` (`412 CONFIRM_REQUIRED`); needs a passkey session (an API key
2209
+ * is refused `403`). The key is returned once.
2210
+ */
2211
+ createApiKey(options, passkey) {
2212
+ return this.postConfirmed("auth/apikeys", { ...options }, passkey);
2039
2213
  }
2040
2214
  // ---------------------------------------------------------------- apps
2041
2215
  /** Upload a raw WASM bundle (development clusters only); answers its hash. */
@@ -2143,12 +2317,19 @@ var CalimeroClient = class {
2143
2317
  }
2144
2318
  /**
2145
2319
  * POST `body` to `path`, answering a `412 CONFIRM_REQUIRED` with `passkey`'s
2146
- * assertion over the node's challenge (deleting an org, approving a release, ...).
2320
+ * assertion over the node's challenge (deleting an org, approving a release,
2321
+ * adding a member, ...): the same body again, with `confirm`. Without a
2322
+ * passkey (a caller in plain JavaScript passing none) the `412` reaches the
2323
+ * caller as {@link ConfirmRequired}, carrying the challenge.
2147
2324
  */
2148
2325
  postConfirmed(path, body, passkey) {
2149
2326
  return this.confirmed("POST", path, body, passkey);
2150
2327
  }
2151
- /** {@link postConfirmed} for a DELETE (deleting an organization). */
2328
+ /**
2329
+ * {@link postConfirmed} for a DELETE (deleting an organization, removing a
2330
+ * member or a passkey): `body` (`{}` by default) goes as JSON, and the
2331
+ * confirmed retry carries `confirm` beside it.
2332
+ */
2152
2333
  deleteConfirmed(path, passkey, body = {}) {
2153
2334
  return this.confirmed("DELETE", path, body, passkey);
2154
2335
  }
@@ -2156,7 +2337,7 @@ var CalimeroClient = class {
2156
2337
  try {
2157
2338
  return await send(body);
2158
2339
  } catch (error) {
2159
- if (!(error instanceof ConfirmRequired)) throw error;
2340
+ if (!(error instanceof ConfirmRequired) || !passkey) throw error;
2160
2341
  const d = error.details;
2161
2342
  let assertion;
2162
2343
  try {
@@ -2200,7 +2381,7 @@ var CalimeroClient = class {
2200
2381
  path,
2201
2382
  { method, args, idempotencyKey: key },
2202
2383
  passkey,
2203
- (body) => this.retried("POST", path, body, body.confirm === void 0 ? this.retry.retries : 0)
2384
+ (body) => this.retried("POST", path, body, body.confirm !== void 0)
2204
2385
  );
2205
2386
  }
2206
2387
  /** Run a read-only method: strong by default, or stale from any replica. */
@@ -2480,20 +2661,24 @@ var CalimeroClient = class {
2480
2661
  async liftReleaseDeadline(version) {
2481
2662
  await this.delete(`ops/releases/${seg(version)}/deadline`);
2482
2663
  }
2483
- /** Add a member, or change their role. */
2484
- async setMember(org, account, role) {
2485
- await this.post(`orgs/${seg(org)}/members`, { account, role });
2664
+ /** Add a member, or change their role, approved with the caller's `passkey`. */
2665
+ async setMember(org, account, role, passkey) {
2666
+ await this.postConfirmed(`orgs/${seg(org)}/members`, { account, role }, passkey);
2486
2667
  }
2487
- removeMember(org, account) {
2488
- return this.delete(`orgs/${seg(org)}/members/${seg(account)}`);
2668
+ /**
2669
+ * Remove a member, or leave (`account` the caller's own), approved with the
2670
+ * caller's `passkey`.
2671
+ */
2672
+ removeMember(org, account, passkey) {
2673
+ return this.deleteConfirmed(`orgs/${seg(org)}/members/${seg(account)}`, passkey);
2489
2674
  }
2490
2675
  /**
2491
2676
  * Set capability bits ({@link ORG_CAPABILITIES}): for a member (`account`),
2492
2677
  * for a member of one team (`account` and `team`), or the organization's
2493
- * default (neither).
2678
+ * default (neither). Approved with the caller's `passkey`.
2494
2679
  */
2495
- async setCapabilities(org, capabilities, at = {}) {
2496
- await this.post(`orgs/${seg(org)}/capabilities`, { capabilities, ...at });
2680
+ async setCapabilities(org, capabilities, passkey, at = {}) {
2681
+ await this.postConfirmed(`orgs/${seg(org)}/capabilities`, { capabilities, ...at }, passkey);
2497
2682
  }
2498
2683
  async createTeam(org, name, join, parent) {
2499
2684
  const { teamId } = await this.post(`orgs/${seg(org)}/teams`, { name, join, parent });
@@ -2513,15 +2698,28 @@ var CalimeroClient = class {
2513
2698
  async deleteTeam(org, team) {
2514
2699
  await this.delete(`orgs/${seg(org)}/teams/${seg(team)}`);
2515
2700
  }
2516
- /** Add a member to a team, or change their row: role, lead, capability bits. */
2517
- async setTeamMember(org, team, account, row = {}) {
2518
- await this.post(`orgs/${seg(org)}/teams/${seg(team)}/members`, { account, ...row });
2701
+ /**
2702
+ * Add a member to a team, or change their row: role, lead, capability bits.
2703
+ * Approved with the caller's `passkey`.
2704
+ */
2705
+ async setTeamMember(org, team, account, passkey, row = {}) {
2706
+ await this.postConfirmed(`orgs/${seg(org)}/teams/${seg(team)}/members`, { account, ...row }, passkey);
2519
2707
  }
2520
- async removeTeamMember(org, team, account) {
2521
- await this.delete(`orgs/${seg(org)}/teams/${seg(team)}/members/${seg(account)}`);
2708
+ /** Remove a member from a team, approved with the caller's `passkey`. */
2709
+ async removeTeamMember(org, team, account, passkey) {
2710
+ await this.deleteConfirmed(`orgs/${seg(org)}/teams/${seg(team)}/members/${seg(account)}`, passkey);
2522
2711
  }
2523
- async invite(org, role, teams = [], options = {}) {
2524
- const { token } = await this.post(`orgs/${seg(org)}/invites`, { role, teams, ...options });
2712
+ /**
2713
+ * Make an invite into `org` (and `teams`), approved with the caller's
2714
+ * `passkey`; answers its token, which {@link redeemInvite} takes (no
2715
+ * approval there).
2716
+ */
2717
+ async invite(org, role, passkey, teams = [], options = {}) {
2718
+ const { token } = await this.postConfirmed(
2719
+ `orgs/${seg(org)}/invites`,
2720
+ { role, teams, ...options },
2721
+ passkey
2722
+ );
2525
2723
  return token;
2526
2724
  }
2527
2725
  redeemInvite(token) {