@biffo/cli 0.247.1 → 0.247.3

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.
@@ -1,5 +1,10 @@
1
1
  import { describe, expect, it, vi, afterEach } from 'vitest'
2
- import { ApiError, createApiClient, extractErrorMessage } from './api-client'
2
+ import {
3
+ ApiError,
4
+ createApiClient,
5
+ extractErrorMessage,
6
+ __resetRenewedTokenCacheForTests,
7
+ } from './api-client'
3
8
 
4
9
  /**
5
10
  * A backend's error body is JSON like `{"detail": "..."}`. Throwing the whole
@@ -42,6 +47,7 @@ describe('extractErrorMessage', () => {
42
47
  describe('createApiClient error handling', () => {
43
48
  afterEach(() => {
44
49
  vi.unstubAllGlobals()
50
+ __resetRenewedTokenCacheForTests()
45
51
  })
46
52
 
47
53
  it('throws an ApiError carrying the message, not the wire format', async () => {
@@ -83,6 +89,7 @@ describe('createApiClient session renewal', () => {
83
89
 
84
90
  afterEach(() => {
85
91
  vi.unstubAllGlobals()
92
+ __resetRenewedTokenCacheForTests()
86
93
  })
87
94
 
88
95
  function ok(body: unknown) {
@@ -117,6 +124,20 @@ describe('createApiClient session renewal', () => {
117
124
  })
118
125
  }
119
126
 
127
+ /**
128
+ * Same shape as `fetchAcceptingOnly`, but for a scenario spanning more than
129
+ * one session's fresh token — proving a later session is never let in on an
130
+ * earlier session's renewed credential.
131
+ */
132
+ function fetchAcceptingAnyOf(freshTokens: string[], body: unknown = { items: [] }) {
133
+ return vi.fn((_url: string, init: RequestInit) => {
134
+ const headers = (init.headers ?? {}) as Record<string, string>
135
+ const sent = headers['Authorization']
136
+ const accepted = freshTokens.some((token) => sent === `Bearer ${token}`)
137
+ return Promise.resolve(accepted ? ok(body) : unauthorized())
138
+ })
139
+ }
140
+
120
141
  it('renews the session and retries once, so the call succeeds', async () => {
121
142
  const fetchMock = fetchAcceptingOnly('fresh', { items: ['course'] })
122
143
  vi.stubGlobal('fetch', fetchMock)
@@ -267,6 +288,89 @@ describe('createApiClient session renewal', () => {
267
288
  expect(retry[1].body).toBe(JSON.stringify({ title: 'New' }))
268
289
  })
269
290
 
291
+ /**
292
+ * The claim this PR (biffo-template#1283) exists to prove, not the retry
293
+ * behaviour #1277 already covers above. #1277 fixed the user-visible half —
294
+ * the FIRST 401 after expiry renews and retries so the call still succeeds.
295
+ * What it left is every LATER request on the same page: nothing wrote the
296
+ * renewed token back to the state `getIdToken()` reads, so a second request
297
+ * with the same stale closure paid the identical 401 + renew + retry cycle
298
+ * all over again, for the rest of the page's life. Without the fix in this
299
+ * PR, the second `api.get` below would fetch twice and renew a second time
300
+ * — exactly like the first request did — rather than sending the renewed
301
+ * token straight away.
302
+ */
303
+ it('sends the renewed token directly on a later request, and does not 401 again', async () => {
304
+ const fetchMock = fetchAcceptingOnly('fresh')
305
+ vi.stubGlobal('fetch', fetchMock)
306
+ const refresh = vi.fn(() => Promise.resolve<string | null>('fresh'))
307
+
308
+ // Every real caller passes `() => token` closing over React state that a
309
+ // renewal outside React never updates — so this closure keeps returning
310
+ // 'expired' for the rest of the page's life, exactly like the reporter's
311
+ // hours-long authoring session.
312
+ const api = createApiClient(() => 'expired', refresh)
313
+
314
+ // First request: pays the full cycle, exactly as #1277 fixed it.
315
+ await expect(api.get('/api/v1/courses')).resolves.toEqual({ items: [] })
316
+ expect(fetchMock).toHaveBeenCalledTimes(2)
317
+ expect(refresh).toHaveBeenCalledTimes(1)
318
+
319
+ // Second request: same client, same stale `getIdToken()` value. Assert
320
+ // the actual claim — exactly ONE fetch, carrying the renewed token
321
+ // directly, with no second 401 and no second renewal.
322
+ fetchMock.mockClear()
323
+ await expect(api.get('/api/v1/modules')).resolves.toEqual({ items: [] })
324
+
325
+ expect(fetchMock).toHaveBeenCalledTimes(1)
326
+ const [, init] = fetchMock.mock.calls[0]
327
+ expect((init.headers as Record<string, string>)['Authorization']).toBe('Bearer fresh')
328
+ expect(refresh).toHaveBeenCalledTimes(1)
329
+ })
330
+
331
+ /**
332
+ * The hazard called out when this fix was scoped: a module-level cache is
333
+ * shared mutable state, so sign-out — or a different user signing in, in
334
+ * the same tab, which `tabsii-marketplace`'s public self-service flow makes
335
+ * real — must not let the previous session's renewed token leak into the
336
+ * next one. That would be a security defect, not a latency one.
337
+ */
338
+ it('does not leak a renewed token across sign-out to a different session', async () => {
339
+ const fetchMock = fetchAcceptingAnyOf(['fresh-a', 'fresh-b'])
340
+ vi.stubGlobal('fetch', fetchMock)
341
+
342
+ let currentToken = 'expired-a'
343
+ const refresh = vi.fn(() =>
344
+ Promise.resolve<string | null>(currentToken === 'expired-a' ? 'fresh-a' : 'fresh-b'),
345
+ )
346
+ const api = createApiClient(() => currentToken, refresh)
347
+
348
+ // User A's session: first call renews and retries; second call is served
349
+ // straight from the cache (this is the behaviour under test above).
350
+ await expect(api.get('/api/v1/courses')).resolves.toEqual({ items: [] })
351
+ await expect(api.get('/api/v1/courses')).resolves.toEqual({ items: [] })
352
+ expect(refresh).toHaveBeenCalledTimes(1)
353
+
354
+ // Sign-out, then a DIFFERENT user signs in — a brand new stale token from
355
+ // `getIdToken()`, simulating React state now belonging to a new session.
356
+ currentToken = 'expired-b'
357
+ const callsBeforeSwitch = fetchMock.mock.calls.length
358
+
359
+ await expect(api.get('/api/v1/courses')).resolves.toEqual({ items: [] })
360
+
361
+ const callsSinceSwitch = fetchMock.mock.calls.slice(callsBeforeSwitch)
362
+ // If user A's cached token had leaked, this request would succeed on its
363
+ // FIRST fetch by reusing 'fresh-a'. Instead it must pay its own renewal —
364
+ // proof the new session's first request sent its OWN stale token, not the
365
+ // previous session's cached fresh one.
366
+ expect(callsSinceSwitch).toHaveLength(2)
367
+ const firstAuthHeader = (callsSinceSwitch[0][1].headers as Record<string, string>)[
368
+ 'Authorization'
369
+ ]
370
+ expect(firstAuthHeader).toBe('Bearer expired-b')
371
+ expect(refresh).toHaveBeenCalledTimes(2)
372
+ })
373
+
270
374
  it('leaves a non-401 failure entirely alone', async () => {
271
375
  const fetchMock = vi.fn().mockResolvedValue({
272
376
  ok: false,
@@ -179,6 +179,51 @@ async function refreshViaPortalSession(): Promise<string | null> {
179
179
  return session?.getIdToken().getJwtToken() ?? null
180
180
  }
181
181
 
182
+ /**
183
+ * The freshest token this module has renewed a specific stale one into.
184
+ *
185
+ * Every caller passes `getIdToken` as `() => token` closing over React state
186
+ * that a renewal happening outside React never updates (see `send` below).
187
+ * Without this, the SAME stale value keeps coming back from `getIdToken()` for
188
+ * the rest of the page's life, so every request after the first expiry pays a
189
+ * full 401 + renew + retry cycle even though the previous renewal already
190
+ * produced a token that would have worked.
191
+ *
192
+ * Keyed by the stale token it replaced — not just "the freshest token" — and
193
+ * consulted in `send` only when the caller's own `getIdToken()` result still
194
+ * equals that key. That is what makes it safe to share at module scope:
195
+ *
196
+ * - **Safe across sign-out, or a different user signing in.** Either changes
197
+ * what `getIdToken()` returns, and a Cognito id token is unique per session,
198
+ * so the new value cannot equal a previous session's `staleToken`. The
199
+ * cache simply stops matching — nothing has to remember to clear it.
200
+ * Rejected: a bare "freshest known token" cache with no key would keep
201
+ * answering with the previous session's token until something explicitly
202
+ * cleared it on sign-out, which is a security defect (a stale credential
203
+ * handed to the wrong session) far worse than the latency this fixes.
204
+ * - **Safe for concurrent requests and multiple `createApiClient` instances.**
205
+ * They all read the same React state through their own `getIdToken`
206
+ * closures, so they all see the same stale value and all benefit from the
207
+ * one cache entry — there is nothing per-instance to keep in step, the same
208
+ * reasoning as `inFlightRenewal` above.
209
+ *
210
+ * Rejected alternative: an optional `onTokenRenewed` callback for callers to
211
+ * update their own state themselves. Cleaner ownership in principle, but it
212
+ * only helps callers that wire it up — every sibling's call sites today do
213
+ * not — so the default path would stay slow. This fixes the default path
214
+ * with no caller changes required.
215
+ */
216
+ let renewedTokenCache: { staleToken: string; freshToken: string } | null = null
217
+
218
+ /**
219
+ * Test-only: clear the module-scope renewal cache so one test's renewal
220
+ * cannot leak into the next. Production code never calls this — the cache is
221
+ * meant to live for the whole page.
222
+ */
223
+ export function __resetRenewedTokenCacheForTests(): void {
224
+ renewedTokenCache = null
225
+ }
226
+
182
227
  export function createApiClient(
183
228
  getIdToken: () => string | null,
184
229
  refreshIdToken: () => Promise<string | null> = refreshViaPortalSession,
@@ -197,13 +242,24 @@ export function createApiClient(
197
242
  * makes "once" structural: there is no path back to the top, so a genuinely
198
243
  * revoked session cannot spin however many 401s it produces.
199
244
  *
200
- * It also carries the token the renewal returned rather than re-reading
201
- * `getIdToken()`. Every caller passes `() => token` closing over React state,
202
- * which a renewal outside React has not updated so re-reading it would
203
- * re-send the credential the gateway just rejected and burn the one retry.
245
+ * The first attempt prefers `renewedTokenCache` over `getIdToken()` when the
246
+ * caller's own state is still the exact value a previous renewal in this
247
+ * module already replaced see the doc on `renewedTokenCache` above for why
248
+ * that is safe. That is what stops a page from paying the full cycle on
249
+ * every request for the rest of its life once React state has gone stale.
250
+ *
251
+ * The retry itself carries the token the renewal returned rather than
252
+ * re-reading `getIdToken()`. Every caller passes `() => token` closing over
253
+ * React state, which a renewal outside React has not updated — so re-reading
254
+ * it would re-send the credential the gateway just rejected and burn the one
255
+ * retry.
204
256
  */
205
257
  async function send<T>(path: string, init: RequestInit): Promise<T> {
206
- const sentToken = getIdToken()
258
+ const callerToken = getIdToken()
259
+ const sentToken =
260
+ callerToken !== null && renewedTokenCache?.staleToken === callerToken
261
+ ? renewedTokenCache.freshToken
262
+ : callerToken
207
263
  const res = await fetch(`${API_URL}${path}`, { ...init, headers: authHeaders(sentToken) })
208
264
  if (res.status !== 401) return handleResponse<T>(res)
209
265
 
@@ -214,6 +270,14 @@ export function createApiClient(
214
270
  throw new ApiError(401, unauthorizedMessage(await bodyOf(res)))
215
271
  }
216
272
 
273
+ // Cache against the caller's OWN stale value, not `sentToken` (which may
274
+ // already have been substituted from a previous cache hit) — that is the
275
+ // value `getIdToken()` keeps returning for the rest of the page's life, so
276
+ // it is the correct key for the next call to match against.
277
+ if (callerToken !== null) {
278
+ renewedTokenCache = { staleToken: callerToken, freshToken: renewed }
279
+ }
280
+
217
281
  const retried = await fetch(`${API_URL}${path}`, { ...init, headers: authHeaders(renewed) })
218
282
  if (retried.status === 401) {
219
283
  throw new ApiError(401, unauthorizedMessage(await bodyOf(retried)))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.247.1",
3
+ "version": "0.247.3",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",