@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.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":
@@ -1455,6 +1463,17 @@ ${generation}`;
1455
1463
  function publisherBindingBytes(cluster, org, signerId, generation) {
1456
1464
  return new TextEncoder().encode(publisherBindingMessage(cluster, org, signerId, generation));
1457
1465
  }
1466
+ var CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
1467
+ async function confirmationCode(requestId) {
1468
+ const h = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(requestId)));
1469
+ const word = (h[0] << 24 | h[1] << 16 | h[2] << 8 | h[3]) >>> 0;
1470
+ let code = "";
1471
+ for (let i = 0; i < 6; i++) {
1472
+ if (i === 3) code += "-";
1473
+ code += CROCKFORD[word >>> 27 - 5 * i & 31];
1474
+ }
1475
+ return code;
1476
+ }
1458
1477
  function consentReturn(href) {
1459
1478
  const url2 = typeof href === "string" ? new URL(href) : href;
1460
1479
  const requestId = url2.searchParams.get("request_id");
@@ -1583,11 +1602,20 @@ var DEFAULT_RETRY = { retries: 6, baseDelayMs: 200, maxDelayMs: 2e3 };
1583
1602
  function retryDelayMs(retry, options) {
1584
1603
  return Math.min(options.baseDelayMs * 2 ** retry, options.maxDelayMs);
1585
1604
  }
1605
+ function ceremony(path) {
1606
+ return path === "auth/refresh" || path.startsWith("auth/passkey/");
1607
+ }
1586
1608
  var CalimeroClient = class {
1587
1609
  constructor(options) {
1610
+ /** The one in use: an index into {@link nodeUrls}. */
1611
+ this.at = 0;
1612
+ /** A sealed fetch per node, each attesting its own node on first use. */
1613
+ this.channels = /* @__PURE__ */ new Map();
1588
1614
  /** Rejections of the underlying `fetch` itself: the node could not be reached or dropped the connection. */
1589
1615
  this.networkFailures = /* @__PURE__ */ new WeakSet();
1590
- this.base = options.baseUrl.replace(/\/+$/, "");
1616
+ const trim = (url2) => url2.replace(/\/+$/, "");
1617
+ this.nodeUrls = [...new Set([options.baseUrl, ...options.nodes ?? []].map(trim))];
1618
+ this.policy = options.policy;
1591
1619
  this.retry = { ...DEFAULT_RETRY, ...options.retry };
1592
1620
  const given = options.fetch ?? ((input, init) => fetch(input, init));
1593
1621
  const raw = async (input, init) => {
@@ -1599,11 +1627,33 @@ var CalimeroClient = class {
1599
1627
  }
1600
1628
  };
1601
1629
  this.raw = raw;
1602
- this.sealed = createSealedFetch({
1603
- baseUrl: this.base,
1604
- transportPublicKey: () => fetchAttestedKey(this.base, options.policy, raw),
1605
- fetch: raw
1606
- });
1630
+ }
1631
+ /** The node requests go to now: `baseUrl`, until a `421 WRONG_NODE` moved the client on. */
1632
+ get nodeUrl() {
1633
+ return this.nodeUrls[this.at];
1634
+ }
1635
+ /** The sealed channel to `url`, attested under the client's policy before its first request. */
1636
+ channel(url2) {
1637
+ let sealed = this.channels.get(url2);
1638
+ if (!sealed) {
1639
+ sealed = createSealedFetch({
1640
+ baseUrl: url2,
1641
+ transportPublicKey: () => fetchAttestedKey(url2, this.policy, this.raw),
1642
+ fetch: this.raw
1643
+ });
1644
+ this.channels.set(url2, sealed);
1645
+ }
1646
+ return sealed;
1647
+ }
1648
+ /**
1649
+ * Move on from node `from` (the one that answered `421 WRONG_NODE`) to the
1650
+ * next one. False when there is no other. A request that saw the same node
1651
+ * refuse after another request moved on already does not move again.
1652
+ */
1653
+ moveOn(from) {
1654
+ if (this.nodeUrls.length < 2) return false;
1655
+ if (this.at === from) this.at = (from + 1) % this.nodeUrls.length;
1656
+ return true;
1607
1657
  }
1608
1658
  /** Authenticate every request with an API key. */
1609
1659
  useApiKey(key) {
@@ -1617,7 +1667,11 @@ var CalimeroClient = class {
1617
1667
  get session() {
1618
1668
  return this.current;
1619
1669
  }
1620
- async request(method, path, body, raw, contentType = "application/wasm") {
1670
+ /**
1671
+ * Send one request to the node in use, with the credential held now, and
1672
+ * answer the response, whatever its status. {@link send} judges it.
1673
+ */
1674
+ async request(method, path, body, raw, contentType = "application/wasm", signal) {
1621
1675
  const headers = {};
1622
1676
  if (this.auth) headers.authorization = `Bearer ${this.auth}`;
1623
1677
  let payload;
@@ -1628,15 +1682,66 @@ var CalimeroClient = class {
1628
1682
  headers["content-type"] = "application/json";
1629
1683
  payload = JSON.stringify(body);
1630
1684
  }
1685
+ const url2 = this.nodeUrl;
1631
1686
  try {
1632
- return await this.sealed(`${this.base}/${path}`, { method, headers, body: payload });
1687
+ return await this.channel(url2)(`${url2}/${path}`, { method, headers, body: payload, signal });
1633
1688
  } catch (error) {
1634
1689
  throw sealedError(error);
1635
1690
  }
1636
1691
  }
1692
+ /**
1693
+ * {@link request}, answering what a lost issuing node or a node on the
1694
+ * wrong release does to a session: a successful response, or the refusal as a
1695
+ * typed error ({@link errorFrom}).
1696
+ *
1697
+ * - `401 UNAUTHENTICATED` to a request that carried a session: the node that
1698
+ * signed the session may be gone, replaced, or out of reach of the node
1699
+ * asked, which any node of the cluster answers with a refresh. The client
1700
+ * refreshes once, shared by every request that needs it at the same time
1701
+ * ({@link refresh}), and repeats the request once. A refresh that is
1702
+ * refused surfaces as {@link LoginRequired}.
1703
+ * - `421 WRONG_NODE`: this node's release may not serve the account. The
1704
+ * client moves to the next node it knows and repeats the request once; with
1705
+ * none, the {@link WrongNode} reaches the caller.
1706
+ *
1707
+ * Repeating is safe even for a request that is not idempotent: the node
1708
+ * answers `401` and `421` when it judges the credential, before it reads what
1709
+ * the request asks, so the first attempt ran nothing. The exceptions are the
1710
+ * ceremonies that mint a session ({@link ceremony}), never repeated here.
1711
+ */
1712
+ async send(method, path, body, raw, contentType, signal) {
1713
+ let refreshed = false;
1714
+ let moved = false;
1715
+ for (; ; ) {
1716
+ const [token, at] = [this.auth, this.at];
1717
+ const response = await this.request(method, path, body, raw, contentType, signal);
1718
+ if (response.ok) return response;
1719
+ const error = await refusal(response);
1720
+ if (error instanceof WrongNode && !moved) {
1721
+ moved = true;
1722
+ if (this.moveOn(at) && !ceremony(path)) continue;
1723
+ } else if (error instanceof LoginRequired && !refreshed && this.mayRefresh(token, path)) {
1724
+ refreshed = true;
1725
+ await this.renew(token);
1726
+ continue;
1727
+ }
1728
+ throw error;
1729
+ }
1730
+ }
1731
+ /** Whether a `401` to a request sent with `token` is one a refresh may answer. */
1732
+ mayRefresh(token, path) {
1733
+ return token !== void 0 && this.current !== void 0 && !ceremony(path);
1734
+ }
1735
+ /**
1736
+ * A fresh session in place of the one `token` belonged to: by the refresh
1737
+ * another request already made, or by one this request starts.
1738
+ */
1739
+ async renew(token) {
1740
+ if (this.auth !== token) return;
1741
+ await this.refresh();
1742
+ }
1637
1743
  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);
1744
+ const response = await this.send(method, path, body, raw, contentType);
1640
1745
  const text = await response.text();
1641
1746
  return text ? JSON.parse(text) : {};
1642
1747
  }
@@ -1705,11 +1810,35 @@ var CalimeroClient = class {
1705
1810
  this.useSession(session);
1706
1811
  return session;
1707
1812
  }
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);
1813
+ /**
1814
+ * Swap the refresh token for a new session, on whichever node the client
1815
+ * uses: any node of the cluster takes it. A refresh token is used once, so
1816
+ * calls made while one refresh is under way share it rather than sending the
1817
+ * same token again (which would revoke the session).
1818
+ */
1819
+ refresh() {
1820
+ if (!this.refreshing) {
1821
+ const pending = this.exchangeRefresh();
1822
+ this.refreshing = pending;
1823
+ const clear = () => {
1824
+ if (this.refreshing === pending) this.refreshing = void 0;
1825
+ };
1826
+ pending.then(clear, clear);
1827
+ }
1828
+ return this.refreshing;
1829
+ }
1830
+ async exchangeRefresh() {
1831
+ const current = this.current;
1832
+ if (!current) throw new Error("no session to refresh");
1833
+ let session;
1834
+ for (let tried = 1; session === void 0; tried++) {
1835
+ try {
1836
+ session = await this.post("auth/refresh", { refreshToken: current.refreshToken });
1837
+ } catch (error) {
1838
+ if (!(error instanceof WrongNode) || tried >= this.nodeUrls.length) throw error;
1839
+ }
1840
+ }
1841
+ if (this.current === current) this.useSession(session);
1713
1842
  return session;
1714
1843
  }
1715
1844
  async signOut() {
@@ -1754,7 +1883,7 @@ var CalimeroClient = class {
1754
1883
  */
1755
1884
  async attestation() {
1756
1885
  const nonce2 = toHex(crypto.getRandomValues(new Uint8Array(32)));
1757
- const response = await this.raw(`${this.base}/attestation?nonce=${nonce2}`);
1886
+ const response = await this.raw(`${this.nodeUrl}/attestation?nonce=${nonce2}`);
1758
1887
  if (!response.ok) throw await refusal(response);
1759
1888
  return await response.json();
1760
1889
  }
@@ -1782,8 +1911,7 @@ var CalimeroClient = class {
1782
1911
  }
1783
1912
  /** One of `app`'s blobs, by id (base58 or hex). */
1784
1913
  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);
1914
+ const response = await this.send("GET", `apps/${seg(app)}/blobs/${seg(id)}`);
1787
1915
  return new Uint8Array(await response.arrayBuffer());
1788
1916
  }
1789
1917
  /** `app`'s blobs. */
@@ -1952,10 +2080,7 @@ var CalimeroClient = class {
1952
2080
  }
1953
2081
  /** A server-sent event stream on `path`, sealed like every other answer. */
1954
2082
  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);
2083
+ const response = await this.send("GET", path, void 0, void 0, void 0, signal);
1959
2084
  if (!response.body) throw new CalimeroError(response.status, "EVENTS", "the event stream has no body");
1960
2085
  yield* sseFrames(response.body);
1961
2086
  }
@@ -2122,16 +2247,34 @@ var CalimeroClient = class {
2122
2247
  }
2123
2248
  /**
2124
2249
  * {@link requestConsent}, then send the browser to the consent page
2125
- * (`navigate`, by default `window.location.assign`). Answers the request
2126
- * once `navigate` returns: with a popup (`url => window.open(url)`), poll it
2127
- * with {@link waitForConsent}.
2250
+ * (`navigate`, by default `window.location.assign`). Pass `navigate` itself
2251
+ * or {@link OpenConsentOptions}, whose `confirm` runs first with the node's
2252
+ * answer so the app can show the person its `code`; when it answers `false`
2253
+ * the page is not opened. When the node sends a `code` that is not
2254
+ * {@link confirmationCode} of `requestId`, it throws `CalimeroError`
2255
+ * (`CONSENT_CODE_MISMATCH`) and does not navigate. Answers the request
2256
+ * once `navigate` returns (or `confirm` declines): with a popup
2257
+ * (`url => window.open(url)`), poll it with {@link waitForConsent}.
2128
2258
  */
2129
- async openConsentPage(request, navigate = (url2) => {
2130
- const w = globalThis.window;
2131
- if (!w) throw new Error("no window to open the consent page in; pass `navigate`");
2132
- w.location.assign(url2);
2133
- }) {
2259
+ async openConsentPage(request, navigateOrOptions = {}) {
2260
+ const options = typeof navigateOrOptions === "function" ? { navigate: navigateOrOptions } : navigateOrOptions;
2261
+ const navigate = options.navigate ?? ((url2) => {
2262
+ const w = globalThis.window;
2263
+ if (!w) throw new Error("no window to open the consent page in; pass `navigate`");
2264
+ w.location.assign(url2);
2265
+ });
2134
2266
  const started = await this.requestConsent(request);
2267
+ if (started.code !== void 0) {
2268
+ const expected = await confirmationCode(started.requestId);
2269
+ if (started.code !== expected) {
2270
+ throw new CalimeroError(200, "CONSENT_CODE_MISMATCH", "the consent page code does not match the request", {
2271
+ requestId: started.requestId,
2272
+ code: started.code,
2273
+ expected
2274
+ });
2275
+ }
2276
+ }
2277
+ if (options.confirm && !await options.confirm(started)) return started;
2135
2278
  navigate(started.url);
2136
2279
  return started;
2137
2280
  }
@@ -2747,6 +2890,7 @@ export {
2747
2890
  StaleTransportKeyError,
2748
2891
  TrapError,
2749
2892
  UnavailableError,
2893
+ WrongNode,
2750
2894
  agentKeyBinding,
2751
2895
  agentQuoteVerifier,
2752
2896
  attestAgent,
@@ -2756,6 +2900,7 @@ export {
2756
2900
  claimedRelease,
2757
2901
  cloudNodeReleaseUrl,
2758
2902
  compareVersions,
2903
+ confirmationCode,
2759
2904
  consentReturn,
2760
2905
  createQuoteVerifier,
2761
2906
  createSealedFetch,