@remit/web-client 0.0.36 → 0.0.38

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.36",
3
+ "version": "0.0.38",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -29,7 +29,7 @@
29
29
  "build:dist": "npm run generate:routes && node --import tsx harness/build.mjs",
30
30
  "preview": "vite preview",
31
31
  "test:typecheck": "npm run generate:routes && tsgo --noEmit",
32
- "test:run": "node --import tsx --import ./test-support/register.mjs --test 'src/**/*.test.ts'",
32
+ "test:run": "node --import tsx --import ./test-support/register.mjs --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=50 --test 'src/**/*.test.ts'",
33
33
  "test": "npm run test:typecheck && npm run test:run"
34
34
  },
35
35
  "peerDependencies": {
@@ -1,5 +1,5 @@
1
1
  import { createAuthClient } from "better-auth/react";
2
- import { taggedFetch } from "../lib/network-error";
2
+ import { NetworkError, taggedFetch } from "../lib/network-error";
3
3
  import { getRuntimeConfig } from "../runtime-config";
4
4
 
5
5
  /**
@@ -185,18 +185,47 @@ const isFresh = (token: CachedToken): boolean =>
185
185
  const isUsable = (token: CachedToken): boolean =>
186
186
  token.expiresAt > nowSeconds();
187
187
 
188
+ /**
189
+ * A mint failure the held token is allowed to ride out: a request timeout (408),
190
+ * throttling (429), a server-side fault (5xx), or a transport failure that never
191
+ * reached the server. These say nothing about the session's validity, so keeping
192
+ * the token the tab already holds is correct. A 4xx that is not 408 or 429 does
193
+ * not qualify — least of all a 401/403, which is the server revoking the session.
194
+ */
195
+ const isTransientMintFailure = (error: unknown): boolean => {
196
+ if (error instanceof NetworkError) return true;
197
+ if (error instanceof AuthTokenError) {
198
+ return (
199
+ error.status === 408 || error.status === 429 || (error.status ?? 0) >= 500
200
+ );
201
+ }
202
+ return false;
203
+ };
204
+
205
+ /**
206
+ * The mint was refused because the session is no longer valid. The held token is
207
+ * signed for that same revoked session, so it must be discarded rather than
208
+ * served until it expires.
209
+ */
210
+ const isRevocation = (error: unknown): boolean =>
211
+ error instanceof AuthTokenError &&
212
+ (error.status === 401 || error.status === 403);
213
+
188
214
  /**
189
215
  * Return a valid RS256 JWT for the current session, minting a fresh one only
190
216
  * when the cached token is missing or within the skew window of expiry. The API
191
217
  * interceptor attaches it as a Bearer token; the backend verifies it against the
192
218
  * JWKS.
193
219
  *
194
- * A throttled or failed refresh never discards a token that is still usable: the
195
- * request keeps the session it already holds and the endpoint is left alone
196
- * until the backoff clears. Only a mint with nothing usable to fall back to
197
- * throws returning null would put the request on the wire without an
198
- * Authorization header, and the backend answers that with a 401 that names a
199
- * missing token rather than the mint that failed.
220
+ * A throttled, faulted, or unreachable refresh never discards a token that is
221
+ * still usable: the request keeps the session it already holds and the endpoint
222
+ * is left alone until the backoff clears. A revocation (401/403) is the opposite
223
+ * — the session behind the held token is gone, so it is cleared and the failure
224
+ * propagates to re-authentication rather than serving a dead token until expiry.
225
+ * Only a mint with nothing usable to fall back to throws otherwise — returning
226
+ * null would put the request on the wire without an Authorization header, and the
227
+ * backend answers that with a 401 that names a missing token rather than the mint
228
+ * that failed.
200
229
  */
201
230
  export const fetchBetterAuthToken = async (): Promise<string> => {
202
231
  const current = currentCache();
@@ -212,7 +241,11 @@ export const fetchBetterAuthToken = async (): Promise<string> => {
212
241
  try {
213
242
  return await mint();
214
243
  } catch (error) {
215
- if (current && isUsable(current)) {
244
+ if (isRevocation(error)) {
245
+ resetBetterAuthTokenCache();
246
+ throw error;
247
+ }
248
+ if (current && isUsable(current) && isTransientMintFailure(error)) {
216
249
  backoffUntil = nowSeconds() + REFRESH_BACKOFF_SECONDS;
217
250
  return current.value;
218
251
  }
@@ -197,6 +197,134 @@ describe("fetchBetterAuthToken", () => {
197
197
  assert.equal(second, held);
198
198
  });
199
199
 
200
+ test("a network failure keeps the still-valid token instead of discarding it", async () => {
201
+ const seed = stubTokenEndpoint(() =>
202
+ tokenResponse(nearExpiryJwt("still-valid")),
203
+ );
204
+ seed.release();
205
+ const held = await fetchBetterAuthToken();
206
+
207
+ globalThis.fetch = (async () => {
208
+ throw new TypeError("Failed to fetch");
209
+ }) as typeof fetch;
210
+ const afterNetworkFailure = await fetchBetterAuthToken();
211
+
212
+ assert.equal(afterNetworkFailure, held);
213
+ });
214
+
215
+ test("a 5xx refresh keeps the still-valid token instead of discarding it", async () => {
216
+ const seed = stubTokenEndpoint(() =>
217
+ tokenResponse(nearExpiryJwt("still-valid")),
218
+ );
219
+ seed.release();
220
+ const held = await fetchBetterAuthToken();
221
+
222
+ const faulted = stubTokenEndpoint(
223
+ () =>
224
+ new Response("", { status: 503, statusText: "Service Unavailable" }),
225
+ );
226
+ faulted.release();
227
+ const afterFault = await fetchBetterAuthToken();
228
+
229
+ assert.equal(faulted.calls, 1);
230
+ assert.equal(afterFault, held);
231
+ });
232
+
233
+ test("a 408 refresh keeps the still-valid token instead of discarding it", async () => {
234
+ const seed = stubTokenEndpoint(() =>
235
+ tokenResponse(nearExpiryJwt("still-valid")),
236
+ );
237
+ seed.release();
238
+ const held = await fetchBetterAuthToken();
239
+
240
+ const timedOut = stubTokenEndpoint(
241
+ () => new Response("", { status: 408, statusText: "Request Timeout" }),
242
+ );
243
+ timedOut.release();
244
+ const afterTimeout = await fetchBetterAuthToken();
245
+
246
+ assert.equal(timedOut.calls, 1);
247
+ assert.equal(afterTimeout, held);
248
+ });
249
+
250
+ test("an unclassified 4xx refresh throws without serving the held token", async () => {
251
+ const seed = stubTokenEndpoint(() =>
252
+ tokenResponse(nearExpiryJwt("still-valid")),
253
+ );
254
+ seed.release();
255
+ await fetchBetterAuthToken();
256
+
257
+ const rejected = stubTokenEndpoint(
258
+ () => new Response("", { status: 400, statusText: "Bad Request" }),
259
+ );
260
+ rejected.release();
261
+
262
+ await assert.rejects(
263
+ () => fetchBetterAuthToken(),
264
+ (error: unknown) => {
265
+ assert.ok(error instanceof AuthTokenError);
266
+ assert.equal(error.status, 400);
267
+ return true;
268
+ },
269
+ );
270
+ assert.equal(rejected.calls, 1);
271
+ });
272
+
273
+ test("a revoked mint (401) clears the held token and re-authenticates", async () => {
274
+ const seed = stubTokenEndpoint(() =>
275
+ tokenResponse(nearExpiryJwt("revoked-soon")),
276
+ );
277
+ seed.release();
278
+ const held = await fetchBetterAuthToken();
279
+
280
+ const revoked = stubTokenEndpoint(
281
+ () => new Response("", { status: 401, statusText: "Unauthorized" }),
282
+ );
283
+ revoked.release();
284
+ await assert.rejects(
285
+ () => fetchBetterAuthToken(),
286
+ (error: unknown) => {
287
+ assert.ok(error instanceof AuthTokenError);
288
+ assert.equal(error.status, 401);
289
+ return true;
290
+ },
291
+ );
292
+ assert.equal(revoked.calls, 1);
293
+
294
+ const reissued = stubTokenEndpoint(() => tokenResponse(jwt("reissued")));
295
+ reissued.release();
296
+ const afterReauth = await fetchBetterAuthToken();
297
+
298
+ assert.equal(reissued.calls, 1);
299
+ assert.notEqual(afterReauth, held);
300
+ });
301
+
302
+ test("a revoked mint (403) clears the held token and re-authenticates", async () => {
303
+ const seed = stubTokenEndpoint(() =>
304
+ tokenResponse(nearExpiryJwt("forbidden-soon")),
305
+ );
306
+ seed.release();
307
+ await fetchBetterAuthToken();
308
+
309
+ const forbidden = stubTokenEndpoint(
310
+ () => new Response("", { status: 403, statusText: "Forbidden" }),
311
+ );
312
+ forbidden.release();
313
+ await assert.rejects(
314
+ () => fetchBetterAuthToken(),
315
+ (error: unknown) => {
316
+ assert.ok(error instanceof AuthTokenError);
317
+ assert.equal(error.status, 403);
318
+ return true;
319
+ },
320
+ );
321
+
322
+ const reissued = stubTokenEndpoint(() => tokenResponse(jwt("reissued")));
323
+ reissued.release();
324
+ assert.ok(await fetchBetterAuthToken());
325
+ assert.equal(reissued.calls, 1);
326
+ });
327
+
200
328
  test("a throttled refresh with no usable token to fall back on still throws", async () => {
201
329
  const seed = stubTokenEndpoint(() =>
202
330
  tokenResponse(jwtExpiringIn("already-expired", -10)),