@calimero-network/mero-js 27.3.0 → 27.5.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
@@ -39,8 +39,11 @@ __export(index_exports, {
39
39
  ConfirmCancelled: () => ConfirmCancelled,
40
40
  ConfirmRequired: () => ConfirmRequired,
41
41
  ConsentStale: () => ConsentStale,
42
+ DEFAULT_COLLATERAL_MIRROR: () => DEFAULT_COLLATERAL_MIRROR,
43
+ DEFAULT_DIRECTORY: () => DEFAULT_DIRECTORY,
42
44
  DEFAULT_ORG_CAPABILITIES: () => DEFAULT_ORG_CAPABILITIES,
43
45
  DEFAULT_RELEASE_MIRROR: () => DEFAULT_RELEASE_MIRROR,
46
+ DirectoryError: () => DirectoryError,
44
47
  GITHUB_RELEASES: () => GITHUB_RELEASES,
45
48
  HANDSHAKE_PATH: () => HANDSHAKE_PATH,
46
49
  IdempotencyMismatch: () => IdempotencyMismatch,
@@ -79,6 +82,7 @@ __export(index_exports, {
79
82
  fetchAttestedKey: () => fetchAttestedKey,
80
83
  fetchGithubNodeRelease: () => fetchGithubNodeRelease,
81
84
  fetchNodeRelease: () => fetchNodeRelease,
85
+ freshCollateral: () => freshCollateral,
82
86
  fromB64url: () => fromB64url,
83
87
  generateOwnerKey: () => generateOwnerKey,
84
88
  orgCapabilities: () => orgCapabilities,
@@ -1107,6 +1111,7 @@ var NODE_RELEASE_SIGNER = Object.freeze({
1107
1111
  });
1108
1112
  var MEASUREMENTS = ["mrtd", "rtmr0", "rtmr1", "rtmr2", "rtmr3"];
1109
1113
  var DEFAULT_RELEASE_MIRROR = "https://cloud.calimero.network";
1114
+ var DEFAULT_COLLATERAL_MIRROR = `${DEFAULT_RELEASE_MIRROR}/api/tee/pcs`;
1110
1115
  async function verifySignedNodeRelease(publishedMrtds, bundle, options = {}) {
1111
1116
  const body = typeof publishedMrtds === "string" ? new TextEncoder().encode(publishedMrtds) : publishedMrtds;
1112
1117
  await verifyCosignBundle(
@@ -1271,6 +1276,34 @@ async function releaseFor(version, policy, fetchImpl) {
1271
1276
  if (source === "github") return fetchGithubNodeRelease(version, { fetch: fetchImpl });
1272
1277
  return fetchNodeRelease(cloudNodeReleaseUrl(source ?? DEFAULT_RELEASE_MIRROR, version), { fetch: fetchImpl });
1273
1278
  }
1279
+ function defaultCollateral(policy) {
1280
+ const source = policy.collateralSource ?? DEFAULT_COLLATERAL_MIRROR;
1281
+ if (source === "intel") {
1282
+ return async (quote) => (await dcap("getCollateralFromPcs"))(quote);
1283
+ }
1284
+ return async (quote) => (await dcap("getCollateral"))(source, quote);
1285
+ }
1286
+ function freshCollateral(fetch2, policy) {
1287
+ const maxDays = policy.maxCollateralAgeDays;
1288
+ if (maxDays === void 0) return fetch2;
1289
+ return async (quote) => {
1290
+ const collateral = await fetch2(quote);
1291
+ let issued;
1292
+ try {
1293
+ issued = Date.parse(JSON.parse(collateral.tcb_info).issueDate ?? "");
1294
+ } catch {
1295
+ issued = Number.NaN;
1296
+ }
1297
+ if (!Number.isFinite(issued)) throw new Error("The quote's collateral names no TCB info issue date");
1298
+ const ageDays = ((policy.now ?? Date.now)() - issued) / 864e5;
1299
+ if (ageDays > maxDays) {
1300
+ throw new Error(
1301
+ `The quote's collateral was issued ${Math.floor(ageDays)} days ago, more than the ${maxDays} this client accepts`
1302
+ );
1303
+ }
1304
+ return collateral;
1305
+ };
1306
+ }
1274
1307
  async function verifyRelease(doc, args, policy, fetchImpl) {
1275
1308
  const version = claimedRelease(doc, policy.minReleaseVersion);
1276
1309
  const trusted = await trustSignedRelease({
@@ -1281,7 +1314,7 @@ async function verifyRelease(doc, args, policy, fetchImpl) {
1281
1314
  identity: policy.identity,
1282
1315
  issuer: policy.issuer
1283
1316
  });
1284
- const fetchCollateral = policy.fetchCollateral ?? (async (quote) => (await dcap("getCollateralFromPcs"))(quote));
1317
+ const fetchCollateral = freshCollateral(policy.fetchCollateral ?? defaultCollateral(policy), policy);
1285
1318
  const verifier = createQuoteVerifier({
1286
1319
  dcapVerify: policy.dcapVerify ?? await dcap("verify"),
1287
1320
  fetchCollateral,
@@ -1326,6 +1359,85 @@ async function fetchAttestedKey(baseUrl, policy, fetchImpl = (input, init) => fe
1326
1359
  return transportKey;
1327
1360
  }
1328
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
+
1329
1441
  // src/cloud-v2/errors.ts
1330
1442
  var CalimeroError = class extends Error {
1331
1443
  constructor(status, code, message, details = {}) {
@@ -1712,7 +1824,7 @@ function retryDelayMs(retry, options) {
1712
1824
  function ceremony(path) {
1713
1825
  return path === "auth/refresh" || path.startsWith("auth/passkey/");
1714
1826
  }
1715
- var CalimeroClient = class {
1827
+ var CalimeroClient = class _CalimeroClient {
1716
1828
  constructor(options) {
1717
1829
  /** The one in use: an index into {@link nodeUrls}. */
1718
1830
  this.at = 0;
@@ -1735,10 +1847,40 @@ var CalimeroClient = class {
1735
1847
  };
1736
1848
  this.raw = raw;
1737
1849
  }
1738
- /** 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. */
1739
1877
  get nodeUrl() {
1740
1878
  return this.nodeUrls[this.at];
1741
1879
  }
1880
+ /** Every node this client may use, in the order it moves through them. */
1881
+ get nodes() {
1882
+ return [...this.nodeUrls];
1883
+ }
1742
1884
  /** The sealed channel to `url`, attested under the client's policy before its first request. */
1743
1885
  channel(url2) {
1744
1886
  let sealed = this.channels.get(url2);
@@ -1759,9 +1901,46 @@ var CalimeroClient = class {
1759
1901
  */
1760
1902
  moveOn(from) {
1761
1903
  if (this.nodeUrls.length < 2) return false;
1762
- if (this.at === from) this.at = (from + 1) % this.nodeUrls.length;
1904
+ if (this.nodeUrl === from) this.at = (this.at + 1) % this.nodeUrls.length;
1763
1905
  return true;
1764
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
+ }
1765
1944
  /** Authenticate every request with an API key. */
1766
1945
  useApiKey(key) {
1767
1946
  this.auth = key;
@@ -1789,6 +1968,7 @@ var CalimeroClient = class {
1789
1968
  headers["content-type"] = "application/json";
1790
1969
  payload = JSON.stringify(body);
1791
1970
  }
1971
+ if (this.source && Date.now() >= this.source.due) this.reread();
1792
1972
  const url2 = this.nodeUrl;
1793
1973
  try {
1794
1974
  return await this.channel(url2)(`${url2}/${path}`, { method, headers, body: payload, signal });
@@ -1820,7 +2000,7 @@ var CalimeroClient = class {
1820
2000
  let refreshed = false;
1821
2001
  let moved = false;
1822
2002
  for (; ; ) {
1823
- const [token, at] = [this.auth, this.at];
2003
+ const [token, at] = [this.auth, this.nodeUrl];
1824
2004
  const response = await this.request(method, path, body, raw, contentType, signal);
1825
2005
  if (response.ok) return response;
1826
2006
  const error = await refusal(response);
@@ -1856,25 +2036,40 @@ var CalimeroClient = class {
1856
2036
  * {@link json} for a call or a view (D70): while the node answers 503 or
1857
2037
  * cannot be reached, the same request, with the same idempotency key, is sent
1858
2038
  * again after {@link retryDelayMs} (or the pause the node asked for, if
1859
- * 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
1860
2045
  * failure surfaces as {@link UnavailableError}: the node's 503, or reason
1861
2046
  * `network` for a connection that failed, whose `committed` is `'unknown'`.
1862
- * `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).
1863
2050
  */
1864
- async retried(method, path, body, retries = this.retry.retries) {
1865
- 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 (; ; ) {
1866
2055
  let unavailable;
1867
- try {
1868
- return await this.json(method, path, body);
1869
- } catch (error) {
1870
- if (error instanceof UnavailableError) unavailable = error;
1871
- else if (error instanceof TypeError && this.networkFailures.has(error)) {
1872
- unavailable = new UnavailableError("NETWORK", `the node could not be reached: ${error.message}`, {}, void 0, error);
1873
- } 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)));
1874
2071
  }
1875
- if (attempt >= retries) throw unavailable;
1876
- const backoff = retryDelayMs(attempt, this.retry);
1877
- await new Promise((resolve) => setTimeout(resolve, Math.max(backoff, (unavailable.retryAfter ?? 0) * 1e3)));
2072
+ if (once || !this.failOver(node, tried)) throw unavailable;
1878
2073
  }
1879
2074
  }
1880
2075
  post(path, body = {}) {
@@ -2169,7 +2364,7 @@ var CalimeroClient = class {
2169
2364
  path,
2170
2365
  { method, args, idempotencyKey: key },
2171
2366
  passkey,
2172
- (body) => this.retried("POST", path, body, body.confirm === void 0 ? this.retry.retries : 0)
2367
+ (body) => this.retried("POST", path, body, body.confirm !== void 0)
2173
2368
  );
2174
2369
  }
2175
2370
  /** Run a read-only method: strong by default, or stale from any replica. */