@remit/web-client 0.0.32 → 0.0.33

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.32",
3
+ "version": "0.0.33",
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,12 +29,78 @@ export const authClient = createAuthClient();
29
29
 
30
30
  const EXPIRY_SKEW_SECONDS = 60;
31
31
 
32
+ // How long to stop reaching for the token endpoint after a mint is throttled.
33
+ // A near-expiry token is still usable for `EXPIRY_SKEW_SECONDS`, so backing off
34
+ // keeps a throttled window from turning every request into another mint attempt
35
+ // against an endpoint that already said no.
36
+ const REFRESH_BACKOFF_SECONDS = 30;
37
+
38
+ // Survives page reloads and in-tab navigations so a fresh page load reuses the
39
+ // session's token instead of minting a new one. Cleared on sign-out. Scoped to
40
+ // the tab, not the origin: the token is short-lived and the httpOnly session
41
+ // cookie remains the real credential.
42
+ const STORAGE_KEY = "remit.better-auth.token";
43
+
32
44
  interface CachedToken {
33
45
  value: string;
34
46
  expiresAt: number;
35
47
  }
36
48
 
49
+ const tokenStore = (): Storage | null => {
50
+ try {
51
+ return globalThis.sessionStorage ?? null;
52
+ } catch {
53
+ return null;
54
+ }
55
+ };
56
+
57
+ const readStore = (): CachedToken | null => {
58
+ const raw = tokenStore()?.getItem(STORAGE_KEY);
59
+ if (!raw) return null;
60
+ try {
61
+ const parsed: unknown = JSON.parse(raw);
62
+ if (
63
+ parsed &&
64
+ typeof parsed === "object" &&
65
+ typeof (parsed as CachedToken).value === "string" &&
66
+ typeof (parsed as CachedToken).expiresAt === "number"
67
+ ) {
68
+ return {
69
+ value: (parsed as CachedToken).value,
70
+ expiresAt: (parsed as CachedToken).expiresAt,
71
+ };
72
+ }
73
+ } catch {
74
+ // A corrupt entry is treated as no token.
75
+ }
76
+ return null;
77
+ };
78
+
79
+ const writeStore = (token: CachedToken | null): void => {
80
+ const store = tokenStore();
81
+ if (!store) return;
82
+ try {
83
+ if (!token) {
84
+ store.removeItem(STORAGE_KEY);
85
+ return;
86
+ }
87
+ store.setItem(STORAGE_KEY, JSON.stringify(token));
88
+ } catch {
89
+ // Storage being unavailable (private mode, quota) is not fatal: the
90
+ // in-memory cache still serves the current page.
91
+ }
92
+ };
93
+
37
94
  let cached: CachedToken | null = null;
95
+ let backoffUntil = 0;
96
+
97
+ // Hydrate the in-memory cache from storage on first read of a page load, so a
98
+ // token minted before a reload is reused rather than re-minted.
99
+ const currentCache = (): CachedToken | null => {
100
+ if (cached) return cached;
101
+ cached = readStore();
102
+ return cached;
103
+ };
38
104
 
39
105
  const nowSeconds = (): number => Math.floor(Date.now() / 1000);
40
106
 
@@ -103,6 +169,8 @@ const mint = (): Promise<string> => {
103
169
  inFlight = requestToken()
104
170
  .then((token) => {
105
171
  cached = { value: token, expiresAt: decodeExp(token) };
172
+ writeStore(cached);
173
+ backoffUntil = 0;
106
174
  return token;
107
175
  })
108
176
  .finally(() => {
@@ -111,23 +179,49 @@ const mint = (): Promise<string> => {
111
179
  return inFlight;
112
180
  };
113
181
 
182
+ const isFresh = (token: CachedToken): boolean =>
183
+ token.expiresAt - EXPIRY_SKEW_SECONDS > nowSeconds();
184
+
185
+ const isUsable = (token: CachedToken): boolean =>
186
+ token.expiresAt > nowSeconds();
187
+
114
188
  /**
115
189
  * Return a valid RS256 JWT for the current session, minting a fresh one only
116
190
  * when the cached token is missing or within the skew window of expiry. The API
117
191
  * interceptor attaches it as a Bearer token; the backend verifies it against the
118
192
  * JWKS.
119
193
  *
120
- * Never resolves to null: a failed mint throws. Returning null would put the
121
- * request on the wire without an Authorization header, and the backend answers
122
- * that with a 401 that names a missing token rather than the mint that failed.
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.
123
200
  */
124
201
  export const fetchBetterAuthToken = async (): Promise<string> => {
125
- if (cached && cached.expiresAt - EXPIRY_SKEW_SECONDS > nowSeconds()) {
126
- return cached.value;
202
+ const current = currentCache();
203
+
204
+ if (current && isFresh(current)) {
205
+ return current.value;
206
+ }
207
+
208
+ if (current && isUsable(current) && nowSeconds() < backoffUntil) {
209
+ return current.value;
210
+ }
211
+
212
+ try {
213
+ return await mint();
214
+ } catch (error) {
215
+ if (current && isUsable(current)) {
216
+ backoffUntil = nowSeconds() + REFRESH_BACKOFF_SECONDS;
217
+ return current.value;
218
+ }
219
+ throw error;
127
220
  }
128
- return mint();
129
221
  };
130
222
 
131
223
  export const resetBetterAuthTokenCache = (): void => {
224
+ backoffUntil = 0;
225
+ writeStore(null);
132
226
  cached = null;
133
227
  };
@@ -13,12 +13,21 @@ const base64url = (value: string): string =>
13
13
  .replace(/\//g, "_")
14
14
  .replace(/=+$/, "");
15
15
 
16
- /** A token shaped like the one better-auth mints, valid for an hour. */
17
- const jwt = (label: string): string =>
16
+ /** A token shaped like the one better-auth mints, expiring after `ttl` seconds. */
17
+ const jwtExpiringIn = (label: string, ttl: number): string =>
18
18
  `header.${base64url(
19
- JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600, label }),
19
+ JSON.stringify({ exp: Math.floor(Date.now() / 1000) + ttl, label }),
20
20
  )}.signature`;
21
21
 
22
+ /** A token shaped like the one better-auth mints, valid for an hour. */
23
+ const jwt = (label: string): string => jwtExpiringIn(label, 3600);
24
+
25
+ /**
26
+ * A token past its comfortable-refresh window but not yet expired: `getToken`
27
+ * treats it as needing a refresh while still holding it as a usable fallback.
28
+ */
29
+ const nearExpiryJwt = (label: string): string => jwtExpiringIn(label, 30);
30
+
22
31
  const realFetch = globalThis.fetch;
23
32
 
24
33
  interface Stub {
@@ -149,4 +158,64 @@ describe("fetchBetterAuthToken", () => {
149
158
  /could not be completed|Failed to fetch/i,
150
159
  );
151
160
  });
161
+
162
+ test("a throttled refresh keeps the still-valid token instead of discarding it", async () => {
163
+ const seed = stubTokenEndpoint(() =>
164
+ tokenResponse(nearExpiryJwt("still-valid")),
165
+ );
166
+ seed.release();
167
+ const held = await fetchBetterAuthToken();
168
+
169
+ const throttled = stubTokenEndpoint(
170
+ () => new Response("", { status: 429, statusText: "Too Many Requests" }),
171
+ );
172
+ throttled.release();
173
+ const afterThrottle = await fetchBetterAuthToken();
174
+
175
+ assert.equal(throttled.calls, 1);
176
+ assert.equal(afterThrottle, held);
177
+ });
178
+
179
+ test("after a throttled refresh it backs off rather than hammering the endpoint", async () => {
180
+ const seed = stubTokenEndpoint(() =>
181
+ tokenResponse(nearExpiryJwt("still-valid")),
182
+ );
183
+ seed.release();
184
+ const held = await fetchBetterAuthToken();
185
+
186
+ const throttled = stubTokenEndpoint(
187
+ () => new Response("", { status: 429, statusText: "Too Many Requests" }),
188
+ );
189
+ throttled.release();
190
+ await fetchBetterAuthToken();
191
+
192
+ const first = await fetchBetterAuthToken();
193
+ const second = await fetchBetterAuthToken();
194
+
195
+ assert.equal(throttled.calls, 1);
196
+ assert.equal(first, held);
197
+ assert.equal(second, held);
198
+ });
199
+
200
+ test("a throttled refresh with no usable token to fall back on still throws", async () => {
201
+ const seed = stubTokenEndpoint(() =>
202
+ tokenResponse(jwtExpiringIn("already-expired", -10)),
203
+ );
204
+ seed.release();
205
+ await fetchBetterAuthToken();
206
+
207
+ const throttled = stubTokenEndpoint(
208
+ () => new Response("", { status: 429, statusText: "Too Many Requests" }),
209
+ );
210
+ throttled.release();
211
+
212
+ await assert.rejects(
213
+ () => fetchBetterAuthToken(),
214
+ (error: unknown) => {
215
+ assert.ok(error instanceof AuthTokenError);
216
+ assert.equal(error.status, 429);
217
+ return true;
218
+ },
219
+ );
220
+ });
152
221
  });