@calimero-network/mero-js 27.1.0 → 27.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -60,6 +60,7 @@ __export(index_exports, {
60
60
  StaleTransportKeyError: () => StaleTransportKeyError,
61
61
  TrapError: () => TrapError,
62
62
  UnavailableError: () => UnavailableError,
63
+ WrongNode: () => WrongNode,
63
64
  agentKeyBinding: () => agentKeyBinding,
64
65
  agentQuoteVerifier: () => agentQuoteVerifier,
65
66
  attestAgent: () => attestAgent,
@@ -69,6 +70,7 @@ __export(index_exports, {
69
70
  claimedRelease: () => claimedRelease,
70
71
  cloudNodeReleaseUrl: () => cloudNodeReleaseUrl,
71
72
  compareVersions: () => compareVersions,
73
+ confirmationCode: () => confirmationCode,
72
74
  consentReturn: () => consentReturn,
73
75
  createQuoteVerifier: () => createQuoteVerifier,
74
76
  createSealedFetch: () => createSealedFetch,
@@ -1340,6 +1342,12 @@ var LoginRequired = class extends CalimeroError {
1340
1342
  this.name = "LoginRequired";
1341
1343
  }
1342
1344
  };
1345
+ var WrongNode = class extends CalimeroError {
1346
+ constructor(message, details = {}) {
1347
+ super(421, "WRONG_NODE", message, details);
1348
+ this.name = "WrongNode";
1349
+ }
1350
+ };
1343
1351
  var NotFound = class extends CalimeroError {
1344
1352
  constructor(message, details = {}) {
1345
1353
  super(404, "NOT_FOUND", message, details);
@@ -1434,6 +1442,8 @@ function errorFrom(status, body, retryAfterHeader) {
1434
1442
  switch (`${status} ${code}`) {
1435
1443
  case "401 UNAUTHENTICATED":
1436
1444
  return new LoginRequired(message, details);
1445
+ case "421 WRONG_NODE":
1446
+ return new WrongNode(message, details);
1437
1447
  case "404 NOT_FOUND":
1438
1448
  return new NotFound(message, details);
1439
1449
  case "409 IDEMPOTENCY_MISMATCH":
@@ -1560,6 +1570,17 @@ ${generation}`;
1560
1570
  function publisherBindingBytes(cluster, org, signerId, generation) {
1561
1571
  return new TextEncoder().encode(publisherBindingMessage(cluster, org, signerId, generation));
1562
1572
  }
1573
+ var CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
1574
+ async function confirmationCode(requestId) {
1575
+ const h = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(requestId)));
1576
+ const word = (h[0] << 24 | h[1] << 16 | h[2] << 8 | h[3]) >>> 0;
1577
+ let code = "";
1578
+ for (let i = 0; i < 6; i++) {
1579
+ if (i === 3) code += "-";
1580
+ code += CROCKFORD[word >>> 27 - 5 * i & 31];
1581
+ }
1582
+ return code;
1583
+ }
1563
1584
  function consentReturn(href) {
1564
1585
  const url2 = typeof href === "string" ? new URL(href) : href;
1565
1586
  const requestId = url2.searchParams.get("request_id");
@@ -1688,11 +1709,20 @@ var DEFAULT_RETRY = { retries: 6, baseDelayMs: 200, maxDelayMs: 2e3 };
1688
1709
  function retryDelayMs(retry, options) {
1689
1710
  return Math.min(options.baseDelayMs * 2 ** retry, options.maxDelayMs);
1690
1711
  }
1712
+ function ceremony(path) {
1713
+ return path === "auth/refresh" || path.startsWith("auth/passkey/");
1714
+ }
1691
1715
  var CalimeroClient = class {
1692
1716
  constructor(options) {
1717
+ /** The one in use: an index into {@link nodeUrls}. */
1718
+ this.at = 0;
1719
+ /** A sealed fetch per node, each attesting its own node on first use. */
1720
+ this.channels = /* @__PURE__ */ new Map();
1693
1721
  /** Rejections of the underlying `fetch` itself: the node could not be reached or dropped the connection. */
1694
1722
  this.networkFailures = /* @__PURE__ */ new WeakSet();
1695
- this.base = options.baseUrl.replace(/\/+$/, "");
1723
+ const trim = (url2) => url2.replace(/\/+$/, "");
1724
+ this.nodeUrls = [...new Set([options.baseUrl, ...options.nodes ?? []].map(trim))];
1725
+ this.policy = options.policy;
1696
1726
  this.retry = { ...DEFAULT_RETRY, ...options.retry };
1697
1727
  const given = options.fetch ?? ((input, init) => fetch(input, init));
1698
1728
  const raw = async (input, init) => {
@@ -1704,11 +1734,33 @@ var CalimeroClient = class {
1704
1734
  }
1705
1735
  };
1706
1736
  this.raw = raw;
1707
- this.sealed = createSealedFetch({
1708
- baseUrl: this.base,
1709
- transportPublicKey: () => fetchAttestedKey(this.base, options.policy, raw),
1710
- fetch: raw
1711
- });
1737
+ }
1738
+ /** The node requests go to now: `baseUrl`, until a `421 WRONG_NODE` moved the client on. */
1739
+ get nodeUrl() {
1740
+ return this.nodeUrls[this.at];
1741
+ }
1742
+ /** The sealed channel to `url`, attested under the client's policy before its first request. */
1743
+ channel(url2) {
1744
+ let sealed = this.channels.get(url2);
1745
+ if (!sealed) {
1746
+ sealed = createSealedFetch({
1747
+ baseUrl: url2,
1748
+ transportPublicKey: () => fetchAttestedKey(url2, this.policy, this.raw),
1749
+ fetch: this.raw
1750
+ });
1751
+ this.channels.set(url2, sealed);
1752
+ }
1753
+ return sealed;
1754
+ }
1755
+ /**
1756
+ * Move on from node `from` (the one that answered `421 WRONG_NODE`) to the
1757
+ * next one. False when there is no other. A request that saw the same node
1758
+ * refuse after another request moved on already does not move again.
1759
+ */
1760
+ moveOn(from) {
1761
+ if (this.nodeUrls.length < 2) return false;
1762
+ if (this.at === from) this.at = (from + 1) % this.nodeUrls.length;
1763
+ return true;
1712
1764
  }
1713
1765
  /** Authenticate every request with an API key. */
1714
1766
  useApiKey(key) {
@@ -1722,7 +1774,11 @@ var CalimeroClient = class {
1722
1774
  get session() {
1723
1775
  return this.current;
1724
1776
  }
1725
- async request(method, path, body, raw, contentType = "application/wasm") {
1777
+ /**
1778
+ * Send one request to the node in use, with the credential held now, and
1779
+ * answer the response, whatever its status. {@link send} judges it.
1780
+ */
1781
+ async request(method, path, body, raw, contentType = "application/wasm", signal) {
1726
1782
  const headers = {};
1727
1783
  if (this.auth) headers.authorization = `Bearer ${this.auth}`;
1728
1784
  let payload;
@@ -1733,15 +1789,66 @@ var CalimeroClient = class {
1733
1789
  headers["content-type"] = "application/json";
1734
1790
  payload = JSON.stringify(body);
1735
1791
  }
1792
+ const url2 = this.nodeUrl;
1736
1793
  try {
1737
- return await this.sealed(`${this.base}/${path}`, { method, headers, body: payload });
1794
+ return await this.channel(url2)(`${url2}/${path}`, { method, headers, body: payload, signal });
1738
1795
  } catch (error) {
1739
1796
  throw sealedError(error);
1740
1797
  }
1741
1798
  }
1799
+ /**
1800
+ * {@link request}, answering what a lost issuing node or a node on the
1801
+ * wrong release does to a session: a successful response, or the refusal as a
1802
+ * typed error ({@link errorFrom}).
1803
+ *
1804
+ * - `401 UNAUTHENTICATED` to a request that carried a session: the node that
1805
+ * signed the session may be gone, replaced, or out of reach of the node
1806
+ * asked, which any node of the cluster answers with a refresh. The client
1807
+ * refreshes once, shared by every request that needs it at the same time
1808
+ * ({@link refresh}), and repeats the request once. A refresh that is
1809
+ * refused surfaces as {@link LoginRequired}.
1810
+ * - `421 WRONG_NODE`: this node's release may not serve the account. The
1811
+ * client moves to the next node it knows and repeats the request once; with
1812
+ * none, the {@link WrongNode} reaches the caller.
1813
+ *
1814
+ * Repeating is safe even for a request that is not idempotent: the node
1815
+ * answers `401` and `421` when it judges the credential, before it reads what
1816
+ * the request asks, so the first attempt ran nothing. The exceptions are the
1817
+ * ceremonies that mint a session ({@link ceremony}), never repeated here.
1818
+ */
1819
+ async send(method, path, body, raw, contentType, signal) {
1820
+ let refreshed = false;
1821
+ let moved = false;
1822
+ for (; ; ) {
1823
+ const [token, at] = [this.auth, this.at];
1824
+ const response = await this.request(method, path, body, raw, contentType, signal);
1825
+ if (response.ok) return response;
1826
+ const error = await refusal(response);
1827
+ if (error instanceof WrongNode && !moved) {
1828
+ moved = true;
1829
+ if (this.moveOn(at) && !ceremony(path)) continue;
1830
+ } else if (error instanceof LoginRequired && !refreshed && this.mayRefresh(token, path)) {
1831
+ refreshed = true;
1832
+ await this.renew(token);
1833
+ continue;
1834
+ }
1835
+ throw error;
1836
+ }
1837
+ }
1838
+ /** Whether a `401` to a request sent with `token` is one a refresh may answer. */
1839
+ mayRefresh(token, path) {
1840
+ return token !== void 0 && this.current !== void 0 && !ceremony(path);
1841
+ }
1842
+ /**
1843
+ * A fresh session in place of the one `token` belonged to: by the refresh
1844
+ * another request already made, or by one this request starts.
1845
+ */
1846
+ async renew(token) {
1847
+ if (this.auth !== token) return;
1848
+ await this.refresh();
1849
+ }
1742
1850
  async json(method, path, body, raw, contentType) {
1743
- const response = await this.request(method, path, body, raw, contentType);
1744
- if (!response.ok) throw await refusal(response);
1851
+ const response = await this.send(method, path, body, raw, contentType);
1745
1852
  const text = await response.text();
1746
1853
  return text ? JSON.parse(text) : {};
1747
1854
  }
@@ -1810,11 +1917,35 @@ var CalimeroClient = class {
1810
1917
  this.useSession(session);
1811
1918
  return session;
1812
1919
  }
1813
- /** Swap the refresh token for a new session. */
1814
- async refresh() {
1815
- if (!this.current) throw new Error("no session to refresh");
1816
- const session = await this.post("auth/refresh", { refreshToken: this.current.refreshToken });
1817
- this.useSession(session);
1920
+ /**
1921
+ * Swap the refresh token for a new session, on whichever node the client
1922
+ * uses: any node of the cluster takes it. A refresh token is used once, so
1923
+ * calls made while one refresh is under way share it rather than sending the
1924
+ * same token again (which would revoke the session).
1925
+ */
1926
+ refresh() {
1927
+ if (!this.refreshing) {
1928
+ const pending = this.exchangeRefresh();
1929
+ this.refreshing = pending;
1930
+ const clear = () => {
1931
+ if (this.refreshing === pending) this.refreshing = void 0;
1932
+ };
1933
+ pending.then(clear, clear);
1934
+ }
1935
+ return this.refreshing;
1936
+ }
1937
+ async exchangeRefresh() {
1938
+ const current = this.current;
1939
+ if (!current) throw new Error("no session to refresh");
1940
+ let session;
1941
+ for (let tried = 1; session === void 0; tried++) {
1942
+ try {
1943
+ session = await this.post("auth/refresh", { refreshToken: current.refreshToken });
1944
+ } catch (error) {
1945
+ if (!(error instanceof WrongNode) || tried >= this.nodeUrls.length) throw error;
1946
+ }
1947
+ }
1948
+ if (this.current === current) this.useSession(session);
1818
1949
  return session;
1819
1950
  }
1820
1951
  async signOut() {
@@ -1859,7 +1990,7 @@ var CalimeroClient = class {
1859
1990
  */
1860
1991
  async attestation() {
1861
1992
  const nonce2 = toHex(crypto.getRandomValues(new Uint8Array(32)));
1862
- const response = await this.raw(`${this.base}/attestation?nonce=${nonce2}`);
1993
+ const response = await this.raw(`${this.nodeUrl}/attestation?nonce=${nonce2}`);
1863
1994
  if (!response.ok) throw await refusal(response);
1864
1995
  return await response.json();
1865
1996
  }
@@ -1887,8 +2018,7 @@ var CalimeroClient = class {
1887
2018
  }
1888
2019
  /** One of `app`'s blobs, by id (base58 or hex). */
1889
2020
  async downloadBlob(app, id) {
1890
- const response = await this.request("GET", `apps/${seg(app)}/blobs/${seg(id)}`);
1891
- if (!response.ok) throw await refusal(response);
2021
+ const response = await this.send("GET", `apps/${seg(app)}/blobs/${seg(id)}`);
1892
2022
  return new Uint8Array(await response.arrayBuffer());
1893
2023
  }
1894
2024
  /** `app`'s blobs. */
@@ -2057,10 +2187,7 @@ var CalimeroClient = class {
2057
2187
  }
2058
2188
  /** A server-sent event stream on `path`, sealed like every other answer. */
2059
2189
  async *stream(path, signal) {
2060
- const headers = {};
2061
- if (this.auth) headers.authorization = `Bearer ${this.auth}`;
2062
- const response = await this.sealed(`${this.base}/${path}`, { headers, signal });
2063
- if (!response.ok) throw await refusal(response);
2190
+ const response = await this.send("GET", path, void 0, void 0, void 0, signal);
2064
2191
  if (!response.body) throw new CalimeroError(response.status, "EVENTS", "the event stream has no body");
2065
2192
  yield* sseFrames(response.body);
2066
2193
  }
@@ -2227,16 +2354,34 @@ var CalimeroClient = class {
2227
2354
  }
2228
2355
  /**
2229
2356
  * {@link requestConsent}, then send the browser to the consent page
2230
- * (`navigate`, by default `window.location.assign`). Answers the request
2231
- * once `navigate` returns: with a popup (`url => window.open(url)`), poll it
2232
- * with {@link waitForConsent}.
2357
+ * (`navigate`, by default `window.location.assign`). Pass `navigate` itself
2358
+ * or {@link OpenConsentOptions}, whose `confirm` runs first with the node's
2359
+ * answer so the app can show the person its `code`; when it answers `false`
2360
+ * the page is not opened. When the node sends a `code` that is not
2361
+ * {@link confirmationCode} of `requestId`, it throws `CalimeroError`
2362
+ * (`CONSENT_CODE_MISMATCH`) and does not navigate. Answers the request
2363
+ * once `navigate` returns (or `confirm` declines): with a popup
2364
+ * (`url => window.open(url)`), poll it with {@link waitForConsent}.
2233
2365
  */
2234
- async openConsentPage(request, navigate = (url2) => {
2235
- const w = globalThis.window;
2236
- if (!w) throw new Error("no window to open the consent page in; pass `navigate`");
2237
- w.location.assign(url2);
2238
- }) {
2366
+ async openConsentPage(request, navigateOrOptions = {}) {
2367
+ const options = typeof navigateOrOptions === "function" ? { navigate: navigateOrOptions } : navigateOrOptions;
2368
+ const navigate = options.navigate ?? ((url2) => {
2369
+ const w = globalThis.window;
2370
+ if (!w) throw new Error("no window to open the consent page in; pass `navigate`");
2371
+ w.location.assign(url2);
2372
+ });
2239
2373
  const started = await this.requestConsent(request);
2374
+ if (started.code !== void 0) {
2375
+ const expected = await confirmationCode(started.requestId);
2376
+ if (started.code !== expected) {
2377
+ throw new CalimeroError(200, "CONSENT_CODE_MISMATCH", "the consent page code does not match the request", {
2378
+ requestId: started.requestId,
2379
+ code: started.code,
2380
+ expected
2381
+ });
2382
+ }
2383
+ }
2384
+ if (options.confirm && !await options.confirm(started)) return started;
2240
2385
  navigate(started.url);
2241
2386
  return started;
2242
2387
  }