@calimero-network/mero-js 27.1.0 → 27.2.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,
@@ -1340,6 +1341,12 @@ var LoginRequired = class extends CalimeroError {
1340
1341
  this.name = "LoginRequired";
1341
1342
  }
1342
1343
  };
1344
+ var WrongNode = class extends CalimeroError {
1345
+ constructor(message, details = {}) {
1346
+ super(421, "WRONG_NODE", message, details);
1347
+ this.name = "WrongNode";
1348
+ }
1349
+ };
1343
1350
  var NotFound = class extends CalimeroError {
1344
1351
  constructor(message, details = {}) {
1345
1352
  super(404, "NOT_FOUND", message, details);
@@ -1434,6 +1441,8 @@ function errorFrom(status, body, retryAfterHeader) {
1434
1441
  switch (`${status} ${code}`) {
1435
1442
  case "401 UNAUTHENTICATED":
1436
1443
  return new LoginRequired(message, details);
1444
+ case "421 WRONG_NODE":
1445
+ return new WrongNode(message, details);
1437
1446
  case "404 NOT_FOUND":
1438
1447
  return new NotFound(message, details);
1439
1448
  case "409 IDEMPOTENCY_MISMATCH":
@@ -1688,11 +1697,20 @@ var DEFAULT_RETRY = { retries: 6, baseDelayMs: 200, maxDelayMs: 2e3 };
1688
1697
  function retryDelayMs(retry, options) {
1689
1698
  return Math.min(options.baseDelayMs * 2 ** retry, options.maxDelayMs);
1690
1699
  }
1700
+ function ceremony(path) {
1701
+ return path === "auth/refresh" || path.startsWith("auth/passkey/");
1702
+ }
1691
1703
  var CalimeroClient = class {
1692
1704
  constructor(options) {
1705
+ /** The one in use: an index into {@link nodeUrls}. */
1706
+ this.at = 0;
1707
+ /** A sealed fetch per node, each attesting its own node on first use. */
1708
+ this.channels = /* @__PURE__ */ new Map();
1693
1709
  /** Rejections of the underlying `fetch` itself: the node could not be reached or dropped the connection. */
1694
1710
  this.networkFailures = /* @__PURE__ */ new WeakSet();
1695
- this.base = options.baseUrl.replace(/\/+$/, "");
1711
+ const trim = (url2) => url2.replace(/\/+$/, "");
1712
+ this.nodeUrls = [...new Set([options.baseUrl, ...options.nodes ?? []].map(trim))];
1713
+ this.policy = options.policy;
1696
1714
  this.retry = { ...DEFAULT_RETRY, ...options.retry };
1697
1715
  const given = options.fetch ?? ((input, init) => fetch(input, init));
1698
1716
  const raw = async (input, init) => {
@@ -1704,11 +1722,33 @@ var CalimeroClient = class {
1704
1722
  }
1705
1723
  };
1706
1724
  this.raw = raw;
1707
- this.sealed = createSealedFetch({
1708
- baseUrl: this.base,
1709
- transportPublicKey: () => fetchAttestedKey(this.base, options.policy, raw),
1710
- fetch: raw
1711
- });
1725
+ }
1726
+ /** The node requests go to now: `baseUrl`, until a `421 WRONG_NODE` moved the client on. */
1727
+ get nodeUrl() {
1728
+ return this.nodeUrls[this.at];
1729
+ }
1730
+ /** The sealed channel to `url`, attested under the client's policy before its first request. */
1731
+ channel(url2) {
1732
+ let sealed = this.channels.get(url2);
1733
+ if (!sealed) {
1734
+ sealed = createSealedFetch({
1735
+ baseUrl: url2,
1736
+ transportPublicKey: () => fetchAttestedKey(url2, this.policy, this.raw),
1737
+ fetch: this.raw
1738
+ });
1739
+ this.channels.set(url2, sealed);
1740
+ }
1741
+ return sealed;
1742
+ }
1743
+ /**
1744
+ * Move on from node `from` (the one that answered `421 WRONG_NODE`) to the
1745
+ * next one. False when there is no other. A request that saw the same node
1746
+ * refuse after another request moved on already does not move again.
1747
+ */
1748
+ moveOn(from) {
1749
+ if (this.nodeUrls.length < 2) return false;
1750
+ if (this.at === from) this.at = (from + 1) % this.nodeUrls.length;
1751
+ return true;
1712
1752
  }
1713
1753
  /** Authenticate every request with an API key. */
1714
1754
  useApiKey(key) {
@@ -1722,7 +1762,11 @@ var CalimeroClient = class {
1722
1762
  get session() {
1723
1763
  return this.current;
1724
1764
  }
1725
- async request(method, path, body, raw, contentType = "application/wasm") {
1765
+ /**
1766
+ * Send one request to the node in use, with the credential held now, and
1767
+ * answer the response, whatever its status. {@link send} judges it.
1768
+ */
1769
+ async request(method, path, body, raw, contentType = "application/wasm", signal) {
1726
1770
  const headers = {};
1727
1771
  if (this.auth) headers.authorization = `Bearer ${this.auth}`;
1728
1772
  let payload;
@@ -1733,15 +1777,66 @@ var CalimeroClient = class {
1733
1777
  headers["content-type"] = "application/json";
1734
1778
  payload = JSON.stringify(body);
1735
1779
  }
1780
+ const url2 = this.nodeUrl;
1736
1781
  try {
1737
- return await this.sealed(`${this.base}/${path}`, { method, headers, body: payload });
1782
+ return await this.channel(url2)(`${url2}/${path}`, { method, headers, body: payload, signal });
1738
1783
  } catch (error) {
1739
1784
  throw sealedError(error);
1740
1785
  }
1741
1786
  }
1787
+ /**
1788
+ * {@link request}, answering what a lost issuing node or a node on the
1789
+ * wrong release does to a session: a successful response, or the refusal as a
1790
+ * typed error ({@link errorFrom}).
1791
+ *
1792
+ * - `401 UNAUTHENTICATED` to a request that carried a session: the node that
1793
+ * signed the session may be gone, replaced, or out of reach of the node
1794
+ * asked, which any node of the cluster answers with a refresh. The client
1795
+ * refreshes once, shared by every request that needs it at the same time
1796
+ * ({@link refresh}), and repeats the request once. A refresh that is
1797
+ * refused surfaces as {@link LoginRequired}.
1798
+ * - `421 WRONG_NODE`: this node's release may not serve the account. The
1799
+ * client moves to the next node it knows and repeats the request once; with
1800
+ * none, the {@link WrongNode} reaches the caller.
1801
+ *
1802
+ * Repeating is safe even for a request that is not idempotent: the node
1803
+ * answers `401` and `421` when it judges the credential, before it reads what
1804
+ * the request asks, so the first attempt ran nothing. The exceptions are the
1805
+ * ceremonies that mint a session ({@link ceremony}), never repeated here.
1806
+ */
1807
+ async send(method, path, body, raw, contentType, signal) {
1808
+ let refreshed = false;
1809
+ let moved = false;
1810
+ for (; ; ) {
1811
+ const [token, at] = [this.auth, this.at];
1812
+ const response = await this.request(method, path, body, raw, contentType, signal);
1813
+ if (response.ok) return response;
1814
+ const error = await refusal(response);
1815
+ if (error instanceof WrongNode && !moved) {
1816
+ moved = true;
1817
+ if (this.moveOn(at) && !ceremony(path)) continue;
1818
+ } else if (error instanceof LoginRequired && !refreshed && this.mayRefresh(token, path)) {
1819
+ refreshed = true;
1820
+ await this.renew(token);
1821
+ continue;
1822
+ }
1823
+ throw error;
1824
+ }
1825
+ }
1826
+ /** Whether a `401` to a request sent with `token` is one a refresh may answer. */
1827
+ mayRefresh(token, path) {
1828
+ return token !== void 0 && this.current !== void 0 && !ceremony(path);
1829
+ }
1830
+ /**
1831
+ * A fresh session in place of the one `token` belonged to: by the refresh
1832
+ * another request already made, or by one this request starts.
1833
+ */
1834
+ async renew(token) {
1835
+ if (this.auth !== token) return;
1836
+ await this.refresh();
1837
+ }
1742
1838
  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);
1839
+ const response = await this.send(method, path, body, raw, contentType);
1745
1840
  const text = await response.text();
1746
1841
  return text ? JSON.parse(text) : {};
1747
1842
  }
@@ -1810,11 +1905,35 @@ var CalimeroClient = class {
1810
1905
  this.useSession(session);
1811
1906
  return session;
1812
1907
  }
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);
1908
+ /**
1909
+ * Swap the refresh token for a new session, on whichever node the client
1910
+ * uses: any node of the cluster takes it. A refresh token is used once, so
1911
+ * calls made while one refresh is under way share it rather than sending the
1912
+ * same token again (which would revoke the session).
1913
+ */
1914
+ refresh() {
1915
+ if (!this.refreshing) {
1916
+ const pending = this.exchangeRefresh();
1917
+ this.refreshing = pending;
1918
+ const clear = () => {
1919
+ if (this.refreshing === pending) this.refreshing = void 0;
1920
+ };
1921
+ pending.then(clear, clear);
1922
+ }
1923
+ return this.refreshing;
1924
+ }
1925
+ async exchangeRefresh() {
1926
+ const current = this.current;
1927
+ if (!current) throw new Error("no session to refresh");
1928
+ let session;
1929
+ for (let tried = 1; session === void 0; tried++) {
1930
+ try {
1931
+ session = await this.post("auth/refresh", { refreshToken: current.refreshToken });
1932
+ } catch (error) {
1933
+ if (!(error instanceof WrongNode) || tried >= this.nodeUrls.length) throw error;
1934
+ }
1935
+ }
1936
+ if (this.current === current) this.useSession(session);
1818
1937
  return session;
1819
1938
  }
1820
1939
  async signOut() {
@@ -1859,7 +1978,7 @@ var CalimeroClient = class {
1859
1978
  */
1860
1979
  async attestation() {
1861
1980
  const nonce2 = toHex(crypto.getRandomValues(new Uint8Array(32)));
1862
- const response = await this.raw(`${this.base}/attestation?nonce=${nonce2}`);
1981
+ const response = await this.raw(`${this.nodeUrl}/attestation?nonce=${nonce2}`);
1863
1982
  if (!response.ok) throw await refusal(response);
1864
1983
  return await response.json();
1865
1984
  }
@@ -1887,8 +2006,7 @@ var CalimeroClient = class {
1887
2006
  }
1888
2007
  /** One of `app`'s blobs, by id (base58 or hex). */
1889
2008
  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);
2009
+ const response = await this.send("GET", `apps/${seg(app)}/blobs/${seg(id)}`);
1892
2010
  return new Uint8Array(await response.arrayBuffer());
1893
2011
  }
1894
2012
  /** `app`'s blobs. */
@@ -2057,10 +2175,7 @@ var CalimeroClient = class {
2057
2175
  }
2058
2176
  /** A server-sent event stream on `path`, sealed like every other answer. */
2059
2177
  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);
2178
+ const response = await this.send("GET", path, void 0, void 0, void 0, signal);
2064
2179
  if (!response.body) throw new CalimeroError(response.status, "EVENTS", "the event stream has no body");
2065
2180
  yield* sseFrames(response.body);
2066
2181
  }