@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.mjs CHANGED
@@ -1235,6 +1235,12 @@ var LoginRequired = class extends CalimeroError {
1235
1235
  this.name = "LoginRequired";
1236
1236
  }
1237
1237
  };
1238
+ var WrongNode = class extends CalimeroError {
1239
+ constructor(message, details = {}) {
1240
+ super(421, "WRONG_NODE", message, details);
1241
+ this.name = "WrongNode";
1242
+ }
1243
+ };
1238
1244
  var NotFound = class extends CalimeroError {
1239
1245
  constructor(message, details = {}) {
1240
1246
  super(404, "NOT_FOUND", message, details);
@@ -1329,6 +1335,8 @@ function errorFrom(status, body, retryAfterHeader) {
1329
1335
  switch (`${status} ${code}`) {
1330
1336
  case "401 UNAUTHENTICATED":
1331
1337
  return new LoginRequired(message, details);
1338
+ case "421 WRONG_NODE":
1339
+ return new WrongNode(message, details);
1332
1340
  case "404 NOT_FOUND":
1333
1341
  return new NotFound(message, details);
1334
1342
  case "409 IDEMPOTENCY_MISMATCH":
@@ -1583,11 +1591,20 @@ var DEFAULT_RETRY = { retries: 6, baseDelayMs: 200, maxDelayMs: 2e3 };
1583
1591
  function retryDelayMs(retry, options) {
1584
1592
  return Math.min(options.baseDelayMs * 2 ** retry, options.maxDelayMs);
1585
1593
  }
1594
+ function ceremony(path) {
1595
+ return path === "auth/refresh" || path.startsWith("auth/passkey/");
1596
+ }
1586
1597
  var CalimeroClient = class {
1587
1598
  constructor(options) {
1599
+ /** The one in use: an index into {@link nodeUrls}. */
1600
+ this.at = 0;
1601
+ /** A sealed fetch per node, each attesting its own node on first use. */
1602
+ this.channels = /* @__PURE__ */ new Map();
1588
1603
  /** Rejections of the underlying `fetch` itself: the node could not be reached or dropped the connection. */
1589
1604
  this.networkFailures = /* @__PURE__ */ new WeakSet();
1590
- this.base = options.baseUrl.replace(/\/+$/, "");
1605
+ const trim = (url2) => url2.replace(/\/+$/, "");
1606
+ this.nodeUrls = [...new Set([options.baseUrl, ...options.nodes ?? []].map(trim))];
1607
+ this.policy = options.policy;
1591
1608
  this.retry = { ...DEFAULT_RETRY, ...options.retry };
1592
1609
  const given = options.fetch ?? ((input, init) => fetch(input, init));
1593
1610
  const raw = async (input, init) => {
@@ -1599,11 +1616,33 @@ var CalimeroClient = class {
1599
1616
  }
1600
1617
  };
1601
1618
  this.raw = raw;
1602
- this.sealed = createSealedFetch({
1603
- baseUrl: this.base,
1604
- transportPublicKey: () => fetchAttestedKey(this.base, options.policy, raw),
1605
- fetch: raw
1606
- });
1619
+ }
1620
+ /** The node requests go to now: `baseUrl`, until a `421 WRONG_NODE` moved the client on. */
1621
+ get nodeUrl() {
1622
+ return this.nodeUrls[this.at];
1623
+ }
1624
+ /** The sealed channel to `url`, attested under the client's policy before its first request. */
1625
+ channel(url2) {
1626
+ let sealed = this.channels.get(url2);
1627
+ if (!sealed) {
1628
+ sealed = createSealedFetch({
1629
+ baseUrl: url2,
1630
+ transportPublicKey: () => fetchAttestedKey(url2, this.policy, this.raw),
1631
+ fetch: this.raw
1632
+ });
1633
+ this.channels.set(url2, sealed);
1634
+ }
1635
+ return sealed;
1636
+ }
1637
+ /**
1638
+ * Move on from node `from` (the one that answered `421 WRONG_NODE`) to the
1639
+ * next one. False when there is no other. A request that saw the same node
1640
+ * refuse after another request moved on already does not move again.
1641
+ */
1642
+ moveOn(from) {
1643
+ if (this.nodeUrls.length < 2) return false;
1644
+ if (this.at === from) this.at = (from + 1) % this.nodeUrls.length;
1645
+ return true;
1607
1646
  }
1608
1647
  /** Authenticate every request with an API key. */
1609
1648
  useApiKey(key) {
@@ -1617,7 +1656,11 @@ var CalimeroClient = class {
1617
1656
  get session() {
1618
1657
  return this.current;
1619
1658
  }
1620
- async request(method, path, body, raw, contentType = "application/wasm") {
1659
+ /**
1660
+ * Send one request to the node in use, with the credential held now, and
1661
+ * answer the response, whatever its status. {@link send} judges it.
1662
+ */
1663
+ async request(method, path, body, raw, contentType = "application/wasm", signal) {
1621
1664
  const headers = {};
1622
1665
  if (this.auth) headers.authorization = `Bearer ${this.auth}`;
1623
1666
  let payload;
@@ -1628,15 +1671,66 @@ var CalimeroClient = class {
1628
1671
  headers["content-type"] = "application/json";
1629
1672
  payload = JSON.stringify(body);
1630
1673
  }
1674
+ const url2 = this.nodeUrl;
1631
1675
  try {
1632
- return await this.sealed(`${this.base}/${path}`, { method, headers, body: payload });
1676
+ return await this.channel(url2)(`${url2}/${path}`, { method, headers, body: payload, signal });
1633
1677
  } catch (error) {
1634
1678
  throw sealedError(error);
1635
1679
  }
1636
1680
  }
1681
+ /**
1682
+ * {@link request}, answering what a lost issuing node or a node on the
1683
+ * wrong release does to a session: a successful response, or the refusal as a
1684
+ * typed error ({@link errorFrom}).
1685
+ *
1686
+ * - `401 UNAUTHENTICATED` to a request that carried a session: the node that
1687
+ * signed the session may be gone, replaced, or out of reach of the node
1688
+ * asked, which any node of the cluster answers with a refresh. The client
1689
+ * refreshes once, shared by every request that needs it at the same time
1690
+ * ({@link refresh}), and repeats the request once. A refresh that is
1691
+ * refused surfaces as {@link LoginRequired}.
1692
+ * - `421 WRONG_NODE`: this node's release may not serve the account. The
1693
+ * client moves to the next node it knows and repeats the request once; with
1694
+ * none, the {@link WrongNode} reaches the caller.
1695
+ *
1696
+ * Repeating is safe even for a request that is not idempotent: the node
1697
+ * answers `401` and `421` when it judges the credential, before it reads what
1698
+ * the request asks, so the first attempt ran nothing. The exceptions are the
1699
+ * ceremonies that mint a session ({@link ceremony}), never repeated here.
1700
+ */
1701
+ async send(method, path, body, raw, contentType, signal) {
1702
+ let refreshed = false;
1703
+ let moved = false;
1704
+ for (; ; ) {
1705
+ const [token, at] = [this.auth, this.at];
1706
+ const response = await this.request(method, path, body, raw, contentType, signal);
1707
+ if (response.ok) return response;
1708
+ const error = await refusal(response);
1709
+ if (error instanceof WrongNode && !moved) {
1710
+ moved = true;
1711
+ if (this.moveOn(at) && !ceremony(path)) continue;
1712
+ } else if (error instanceof LoginRequired && !refreshed && this.mayRefresh(token, path)) {
1713
+ refreshed = true;
1714
+ await this.renew(token);
1715
+ continue;
1716
+ }
1717
+ throw error;
1718
+ }
1719
+ }
1720
+ /** Whether a `401` to a request sent with `token` is one a refresh may answer. */
1721
+ mayRefresh(token, path) {
1722
+ return token !== void 0 && this.current !== void 0 && !ceremony(path);
1723
+ }
1724
+ /**
1725
+ * A fresh session in place of the one `token` belonged to: by the refresh
1726
+ * another request already made, or by one this request starts.
1727
+ */
1728
+ async renew(token) {
1729
+ if (this.auth !== token) return;
1730
+ await this.refresh();
1731
+ }
1637
1732
  async json(method, path, body, raw, contentType) {
1638
- const response = await this.request(method, path, body, raw, contentType);
1639
- if (!response.ok) throw await refusal(response);
1733
+ const response = await this.send(method, path, body, raw, contentType);
1640
1734
  const text = await response.text();
1641
1735
  return text ? JSON.parse(text) : {};
1642
1736
  }
@@ -1705,11 +1799,35 @@ var CalimeroClient = class {
1705
1799
  this.useSession(session);
1706
1800
  return session;
1707
1801
  }
1708
- /** Swap the refresh token for a new session. */
1709
- async refresh() {
1710
- if (!this.current) throw new Error("no session to refresh");
1711
- const session = await this.post("auth/refresh", { refreshToken: this.current.refreshToken });
1712
- this.useSession(session);
1802
+ /**
1803
+ * Swap the refresh token for a new session, on whichever node the client
1804
+ * uses: any node of the cluster takes it. A refresh token is used once, so
1805
+ * calls made while one refresh is under way share it rather than sending the
1806
+ * same token again (which would revoke the session).
1807
+ */
1808
+ refresh() {
1809
+ if (!this.refreshing) {
1810
+ const pending = this.exchangeRefresh();
1811
+ this.refreshing = pending;
1812
+ const clear = () => {
1813
+ if (this.refreshing === pending) this.refreshing = void 0;
1814
+ };
1815
+ pending.then(clear, clear);
1816
+ }
1817
+ return this.refreshing;
1818
+ }
1819
+ async exchangeRefresh() {
1820
+ const current = this.current;
1821
+ if (!current) throw new Error("no session to refresh");
1822
+ let session;
1823
+ for (let tried = 1; session === void 0; tried++) {
1824
+ try {
1825
+ session = await this.post("auth/refresh", { refreshToken: current.refreshToken });
1826
+ } catch (error) {
1827
+ if (!(error instanceof WrongNode) || tried >= this.nodeUrls.length) throw error;
1828
+ }
1829
+ }
1830
+ if (this.current === current) this.useSession(session);
1713
1831
  return session;
1714
1832
  }
1715
1833
  async signOut() {
@@ -1754,7 +1872,7 @@ var CalimeroClient = class {
1754
1872
  */
1755
1873
  async attestation() {
1756
1874
  const nonce2 = toHex(crypto.getRandomValues(new Uint8Array(32)));
1757
- const response = await this.raw(`${this.base}/attestation?nonce=${nonce2}`);
1875
+ const response = await this.raw(`${this.nodeUrl}/attestation?nonce=${nonce2}`);
1758
1876
  if (!response.ok) throw await refusal(response);
1759
1877
  return await response.json();
1760
1878
  }
@@ -1782,8 +1900,7 @@ var CalimeroClient = class {
1782
1900
  }
1783
1901
  /** One of `app`'s blobs, by id (base58 or hex). */
1784
1902
  async downloadBlob(app, id) {
1785
- const response = await this.request("GET", `apps/${seg(app)}/blobs/${seg(id)}`);
1786
- if (!response.ok) throw await refusal(response);
1903
+ const response = await this.send("GET", `apps/${seg(app)}/blobs/${seg(id)}`);
1787
1904
  return new Uint8Array(await response.arrayBuffer());
1788
1905
  }
1789
1906
  /** `app`'s blobs. */
@@ -1952,10 +2069,7 @@ var CalimeroClient = class {
1952
2069
  }
1953
2070
  /** A server-sent event stream on `path`, sealed like every other answer. */
1954
2071
  async *stream(path, signal) {
1955
- const headers = {};
1956
- if (this.auth) headers.authorization = `Bearer ${this.auth}`;
1957
- const response = await this.sealed(`${this.base}/${path}`, { headers, signal });
1958
- if (!response.ok) throw await refusal(response);
2072
+ const response = await this.send("GET", path, void 0, void 0, void 0, signal);
1959
2073
  if (!response.body) throw new CalimeroError(response.status, "EVENTS", "the event stream has no body");
1960
2074
  yield* sseFrames(response.body);
1961
2075
  }
@@ -2747,6 +2861,7 @@ export {
2747
2861
  StaleTransportKeyError,
2748
2862
  TrapError,
2749
2863
  UnavailableError,
2864
+ WrongNode,
2750
2865
  agentKeyBinding,
2751
2866
  agentQuoteVerifier,
2752
2867
  attestAgent,