@calimero-network/mero-js 7.0.0 → 7.0.1

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
@@ -21,6 +21,7 @@ var index_exports = {};
21
21
  __export(index_exports, {
22
22
  AdminApiClient: () => AdminApiClient,
23
23
  AuthApiClient: () => AuthApiClient,
24
+ AuthRevokedError: () => AuthRevokedError,
24
25
  CAPABILITIES: () => CAPABILITIES,
25
26
  CloudClient: () => CloudClient,
26
27
  HTTPError: () => HTTPError,
@@ -92,6 +93,7 @@ function createTimeoutSignal(timeoutMs) {
92
93
  }
93
94
 
94
95
  // src/http-client/web-client.ts
96
+ var TERMINAL_AUTH_ERRORS = /* @__PURE__ */ new Set(["token_reuse", "token_revoked"]);
95
97
  var HTTPError = class extends Error {
96
98
  constructor(status, statusText, url, headers, bodyText) {
97
99
  super(`HTTP ${status} ${statusText}`);
@@ -112,6 +114,14 @@ var HTTPError = class extends Error {
112
114
  };
113
115
  }
114
116
  };
117
+ var AuthRevokedError = class extends HTTPError {
118
+ constructor(reason, status, statusText, url, headers, bodyText) {
119
+ super(status, statusText, url, headers, bodyText);
120
+ this.reason = reason;
121
+ this.name = "AuthRevokedError";
122
+ this.message = `Authentication revoked (${reason}): HTTP ${status} ${statusText}`;
123
+ }
124
+ };
115
125
  function headersToRecord(headers) {
116
126
  const record = {};
117
127
  headers.forEach((value, key) => {
@@ -256,8 +266,27 @@ var WebHttpClient = class {
256
266
  response.headers,
257
267
  bodyText
258
268
  );
269
+ const authError = response.headers.get("x-auth-error");
270
+ if ((response.status === 401 || response.status === 403) && authError && TERMINAL_AUTH_ERRORS.has(authError)) {
271
+ this.refreshTokenPromise = null;
272
+ this.onTokenRefreshPromise = null;
273
+ if (this.transport.onAuthRevoked) {
274
+ try {
275
+ await this.transport.onAuthRevoked();
276
+ } catch {
277
+ }
278
+ }
279
+ throw new AuthRevokedError(
280
+ authError,
281
+ response.status,
282
+ response.statusText,
283
+ url,
284
+ response.headers,
285
+ bodyText
286
+ );
287
+ }
259
288
  const userAborted = init?.signal?.aborted === true;
260
- if (response.status === 401 && this.transport.refreshToken && response.headers.get("x-auth-error") === "token_expired" && retryCount < MAX_RETRY_ATTEMPTS && !isStreamBody && !userAborted) {
289
+ if (response.status === 401 && this.transport.refreshToken && authError === "token_expired" && retryCount < MAX_RETRY_ATTEMPTS && !isStreamBody && !userAborted) {
261
290
  try {
262
291
  let refreshPromise = this.refreshTokenPromise;
263
292
  if (!refreshPromise) {
@@ -424,6 +453,7 @@ function createBrowserHttpClient(options) {
424
453
  getAuthToken: options.getAuthToken,
425
454
  onTokenRefresh: options.onTokenRefresh,
426
455
  refreshToken: options.refreshToken,
456
+ onAuthRevoked: options.onAuthRevoked,
427
457
  defaultHeaders: options.defaultHeaders,
428
458
  timeoutMs: options.timeoutMs,
429
459
  credentials: options.credentials,
@@ -449,6 +479,7 @@ function createNodeHttpClient(options) {
449
479
  getAuthToken: options.getAuthToken,
450
480
  onTokenRefresh: options.onTokenRefresh,
451
481
  refreshToken: options.refreshToken,
482
+ onAuthRevoked: options.onAuthRevoked,
452
483
  defaultHeaders: options.defaultHeaders,
453
484
  timeoutMs: options.timeoutMs,
454
485
  credentials: options.credentials,
@@ -1912,6 +1943,12 @@ _WsClient.MAX_BACKOFF_MS = 3e4;
1912
1943
  var WsClient = _WsClient;
1913
1944
 
1914
1945
  // src/mero-js.ts
1946
+ var REFRESH_LOCK_NAME = "mero-js:token-refresh";
1947
+ function getLockManager() {
1948
+ const nav = globalThis.navigator;
1949
+ const locks = nav?.locks;
1950
+ return locks && typeof locks.request === "function" ? locks : null;
1951
+ }
1915
1952
  function expiresAtFromJwt(token, fallbackMs) {
1916
1953
  try {
1917
1954
  const parts = token.split(".");
@@ -1960,6 +1997,9 @@ var MeroJs = class {
1960
1997
  this.tokenStore?.setTokens(this.tokenData);
1961
1998
  }
1962
1999
  },
2000
+ onAuthRevoked: () => {
2001
+ this.clearToken();
2002
+ },
1963
2003
  timeoutMs: this.config.timeoutMs,
1964
2004
  credentials: this.config.requestCredentials ?? (isTauri ? "omit" : void 0)
1965
2005
  });
@@ -2082,27 +2122,52 @@ var MeroJs = class {
2082
2122
  return this.tokenData;
2083
2123
  }
2084
2124
  /**
2085
- * Refresh the access token using the refresh token
2125
+ * Refresh the access token. Single-flight: every caller (including the HTTP
2126
+ * transport's 401 hook) goes through here.
2127
+ *
2128
+ * Refresh tokens are single-use (calimero-network/core#3083): each refresh
2129
+ * consumes the presented token and returns a new one, and replaying a consumed
2130
+ * token makes the server revoke the whole family. A refresh must therefore
2131
+ * happen exactly once per rotation, which needs three layers:
2132
+ * - an in-process promise, so concurrent 401s in this instance share one call;
2133
+ * - a Web Lock (when available), so other tabs/webviews don't refresh at the
2134
+ * same time on the same stored token;
2135
+ * - a re-read of the token store inside the lock, so a bundle that another
2136
+ * instance already rotated is adopted instead of replaying a stale token.
2086
2137
  */
2087
- async refreshToken() {
2088
- if (!this.tokenData?.refresh_token) {
2089
- throw new Error("No refresh token available");
2090
- }
2138
+ async performTokenRefresh() {
2091
2139
  if (this.refreshPromise) {
2092
2140
  return this.refreshPromise;
2093
2141
  }
2094
- this.refreshPromise = this.performTokenRefresh();
2142
+ const triggeringAccessToken = this.tokenData?.access_token;
2143
+ const promise = this.withRefreshLock(
2144
+ () => this.performTokenRefreshLocked(triggeringAccessToken)
2145
+ );
2146
+ this.refreshPromise = promise;
2095
2147
  try {
2096
- const newToken = await this.refreshPromise;
2097
- return newToken;
2148
+ return await promise;
2098
2149
  } finally {
2099
- this.refreshPromise = null;
2150
+ if (this.refreshPromise === promise) {
2151
+ this.refreshPromise = null;
2152
+ }
2100
2153
  }
2101
2154
  }
2155
+ /** Run `fn` under the cross-tab refresh lock, or directly if Web Locks are unavailable. */
2156
+ withRefreshLock(fn) {
2157
+ const locks = getLockManager();
2158
+ return locks ? locks.request(REFRESH_LOCK_NAME, fn) : fn();
2159
+ }
2102
2160
  /**
2103
- * Perform the actual token refresh
2161
+ * Perform the actual token refresh. Runs with the refresh lock held.
2104
2162
  */
2105
- async performTokenRefresh() {
2163
+ async performTokenRefreshLocked(triggeringAccessToken) {
2164
+ const stored = this.tokenStore?.getTokens() ?? null;
2165
+ if (stored?.refresh_token) {
2166
+ this.tokenData = stored;
2167
+ if (triggeringAccessToken && stored.access_token !== triggeringAccessToken) {
2168
+ return stored;
2169
+ }
2170
+ }
2106
2171
  if (!this.tokenData?.refresh_token) {
2107
2172
  throw new Error("No refresh token available");
2108
2173
  }
@@ -2114,6 +2179,8 @@ var MeroJs = class {
2114
2179
  const accessToken = response.data.access_token;
2115
2180
  this.tokenData = {
2116
2181
  access_token: accessToken,
2182
+ // The refresh token is rotated on every refresh — persist the new one or
2183
+ // the next refresh replays a consumed token.
2117
2184
  refresh_token: response.data.refresh_token,
2118
2185
  expires_at: expiresAtFromJwt(accessToken, Date.now() + 36e5)
2119
2186
  };
@@ -2129,6 +2196,7 @@ var MeroJs = class {
2129
2196
  * Clear the current token
2130
2197
  */
2131
2198
  clearToken() {
2199
+ this.refreshPromise = null;
2132
2200
  this.tokenData = null;
2133
2201
  this.tokenStore?.clear();
2134
2202
  }