@calimero-network/mero-js 7.0.0 → 7.0.2

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
@@ -32,6 +32,7 @@ function createTimeoutSignal(timeoutMs) {
32
32
  }
33
33
 
34
34
  // src/http-client/web-client.ts
35
+ var TERMINAL_AUTH_ERRORS = /* @__PURE__ */ new Set(["token_reuse", "token_revoked"]);
35
36
  var HTTPError = class extends Error {
36
37
  constructor(status, statusText, url, headers, bodyText) {
37
38
  super(`HTTP ${status} ${statusText}`);
@@ -52,6 +53,14 @@ var HTTPError = class extends Error {
52
53
  };
53
54
  }
54
55
  };
56
+ var AuthRevokedError = class extends HTTPError {
57
+ constructor(reason, status, statusText, url, headers, bodyText) {
58
+ super(status, statusText, url, headers, bodyText);
59
+ this.reason = reason;
60
+ this.name = "AuthRevokedError";
61
+ this.message = `Authentication revoked (${reason}): HTTP ${status} ${statusText}`;
62
+ }
63
+ };
55
64
  function headersToRecord(headers) {
56
65
  const record = {};
57
66
  headers.forEach((value, key) => {
@@ -196,8 +205,27 @@ var WebHttpClient = class {
196
205
  response.headers,
197
206
  bodyText
198
207
  );
208
+ const authError = response.headers.get("x-auth-error");
209
+ if ((response.status === 401 || response.status === 403) && authError && TERMINAL_AUTH_ERRORS.has(authError)) {
210
+ this.refreshTokenPromise = null;
211
+ this.onTokenRefreshPromise = null;
212
+ if (this.transport.onAuthRevoked) {
213
+ try {
214
+ await this.transport.onAuthRevoked();
215
+ } catch {
216
+ }
217
+ }
218
+ throw new AuthRevokedError(
219
+ authError,
220
+ response.status,
221
+ response.statusText,
222
+ url,
223
+ response.headers,
224
+ bodyText
225
+ );
226
+ }
199
227
  const userAborted = init?.signal?.aborted === true;
200
- if (response.status === 401 && this.transport.refreshToken && response.headers.get("x-auth-error") === "token_expired" && retryCount < MAX_RETRY_ATTEMPTS && !isStreamBody && !userAborted) {
228
+ if (response.status === 401 && this.transport.refreshToken && authError === "token_expired" && retryCount < MAX_RETRY_ATTEMPTS && !isStreamBody && !userAborted) {
201
229
  try {
202
230
  let refreshPromise = this.refreshTokenPromise;
203
231
  if (!refreshPromise) {
@@ -364,6 +392,7 @@ function createBrowserHttpClient(options) {
364
392
  getAuthToken: options.getAuthToken,
365
393
  onTokenRefresh: options.onTokenRefresh,
366
394
  refreshToken: options.refreshToken,
395
+ onAuthRevoked: options.onAuthRevoked,
367
396
  defaultHeaders: options.defaultHeaders,
368
397
  timeoutMs: options.timeoutMs,
369
398
  credentials: options.credentials,
@@ -389,6 +418,7 @@ function createNodeHttpClient(options) {
389
418
  getAuthToken: options.getAuthToken,
390
419
  onTokenRefresh: options.onTokenRefresh,
391
420
  refreshToken: options.refreshToken,
421
+ onAuthRevoked: options.onAuthRevoked,
392
422
  defaultHeaders: options.defaultHeaders,
393
423
  timeoutMs: options.timeoutMs,
394
424
  credentials: options.credentials,
@@ -1852,6 +1882,12 @@ _WsClient.MAX_BACKOFF_MS = 3e4;
1852
1882
  var WsClient = _WsClient;
1853
1883
 
1854
1884
  // src/mero-js.ts
1885
+ var REFRESH_LOCK_NAME = "mero-js:token-refresh";
1886
+ function getLockManager() {
1887
+ const nav = globalThis.navigator;
1888
+ const locks = nav?.locks;
1889
+ return locks && typeof locks.request === "function" ? locks : null;
1890
+ }
1855
1891
  function expiresAtFromJwt(token, fallbackMs) {
1856
1892
  try {
1857
1893
  const parts = token.split(".");
@@ -1900,6 +1936,9 @@ var MeroJs = class {
1900
1936
  this.tokenStore?.setTokens(this.tokenData);
1901
1937
  }
1902
1938
  },
1939
+ onAuthRevoked: () => {
1940
+ this.clearToken();
1941
+ },
1903
1942
  timeoutMs: this.config.timeoutMs,
1904
1943
  credentials: this.config.requestCredentials ?? (isTauri ? "omit" : void 0)
1905
1944
  });
@@ -2022,27 +2061,52 @@ var MeroJs = class {
2022
2061
  return this.tokenData;
2023
2062
  }
2024
2063
  /**
2025
- * Refresh the access token using the refresh token
2064
+ * Refresh the access token. Single-flight: every caller (including the HTTP
2065
+ * transport's 401 hook) goes through here.
2066
+ *
2067
+ * Refresh tokens are single-use (calimero-network/core#3083): each refresh
2068
+ * consumes the presented token and returns a new one, and replaying a consumed
2069
+ * token makes the server revoke the whole family. A refresh must therefore
2070
+ * happen exactly once per rotation, which needs three layers:
2071
+ * - an in-process promise, so concurrent 401s in this instance share one call;
2072
+ * - a Web Lock (when available), so other tabs/webviews don't refresh at the
2073
+ * same time on the same stored token;
2074
+ * - a re-read of the token store inside the lock, so a bundle that another
2075
+ * instance already rotated is adopted instead of replaying a stale token.
2026
2076
  */
2027
- async refreshToken() {
2028
- if (!this.tokenData?.refresh_token) {
2029
- throw new Error("No refresh token available");
2030
- }
2077
+ async performTokenRefresh() {
2031
2078
  if (this.refreshPromise) {
2032
2079
  return this.refreshPromise;
2033
2080
  }
2034
- this.refreshPromise = this.performTokenRefresh();
2081
+ const triggeringAccessToken = this.tokenData?.access_token;
2082
+ const promise = this.withRefreshLock(
2083
+ () => this.performTokenRefreshLocked(triggeringAccessToken)
2084
+ );
2085
+ this.refreshPromise = promise;
2035
2086
  try {
2036
- const newToken = await this.refreshPromise;
2037
- return newToken;
2087
+ return await promise;
2038
2088
  } finally {
2039
- this.refreshPromise = null;
2089
+ if (this.refreshPromise === promise) {
2090
+ this.refreshPromise = null;
2091
+ }
2040
2092
  }
2041
2093
  }
2094
+ /** Run `fn` under the cross-tab refresh lock, or directly if Web Locks are unavailable. */
2095
+ withRefreshLock(fn) {
2096
+ const locks = getLockManager();
2097
+ return locks ? locks.request(REFRESH_LOCK_NAME, fn) : fn();
2098
+ }
2042
2099
  /**
2043
- * Perform the actual token refresh
2100
+ * Perform the actual token refresh. Runs with the refresh lock held.
2044
2101
  */
2045
- async performTokenRefresh() {
2102
+ async performTokenRefreshLocked(triggeringAccessToken) {
2103
+ const stored = this.tokenStore?.getTokens() ?? null;
2104
+ if (stored?.refresh_token) {
2105
+ this.tokenData = stored;
2106
+ if (triggeringAccessToken && stored.access_token !== triggeringAccessToken) {
2107
+ return stored;
2108
+ }
2109
+ }
2046
2110
  if (!this.tokenData?.refresh_token) {
2047
2111
  throw new Error("No refresh token available");
2048
2112
  }
@@ -2054,6 +2118,8 @@ var MeroJs = class {
2054
2118
  const accessToken = response.data.access_token;
2055
2119
  this.tokenData = {
2056
2120
  access_token: accessToken,
2121
+ // The refresh token is rotated on every refresh — persist the new one or
2122
+ // the next refresh replays a consumed token.
2057
2123
  refresh_token: response.data.refresh_token,
2058
2124
  expires_at: expiresAtFromJwt(accessToken, Date.now() + 36e5)
2059
2125
  };
@@ -2069,6 +2135,7 @@ var MeroJs = class {
2069
2135
  * Clear the current token
2070
2136
  */
2071
2137
  clearToken() {
2138
+ this.refreshPromise = null;
2072
2139
  this.tokenData = null;
2073
2140
  this.tokenStore?.clear();
2074
2141
  }
@@ -2224,6 +2291,7 @@ function withoutCap(mask, cap) {
2224
2291
  export {
2225
2292
  AdminApiClient,
2226
2293
  AuthApiClient,
2294
+ AuthRevokedError,
2227
2295
  CAPABILITIES,
2228
2296
  CloudClient,
2229
2297
  HTTPError,