@biffo/cli 0.244.10 → 0.245.0

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.
@@ -61,3 +61,226 @@ describe('createApiClient error handling', () => {
61
61
  )
62
62
  })
63
63
  })
64
+
65
+ /**
66
+ * An expired id token is not an error the user can act on — it is a renewal
67
+ * this app already knows how to perform, and did not (tabsii-lms#3).
68
+ *
69
+ * The observed failure: after ~an hour an authoring session's next action
70
+ * rendered `{"message":"Unauthorized"}` in red in the page. That body is API
71
+ * Gateway's, produced by the JWT authorizer before the request ever reached the
72
+ * app, so `extractErrorMessage` has no `detail` to unwrap and correctly falls
73
+ * back to the raw text — the message layer was working exactly as designed on an
74
+ * input it was never given a chance to improve. Meanwhile a usable refresh
75
+ * token sat in localStorage, and reloading the page renewed silently.
76
+ *
77
+ * Every assertion below is on observable behaviour rather than on an exported
78
+ * constant, so each one fails against the unfixed client for the reason the
79
+ * reporter saw rather than on a missing import.
80
+ */
81
+ describe('createApiClient session renewal', () => {
82
+ const GATEWAY_401 = '{"message":"Unauthorized"}'
83
+
84
+ afterEach(() => {
85
+ vi.unstubAllGlobals()
86
+ })
87
+
88
+ function ok(body: unknown) {
89
+ return {
90
+ ok: true,
91
+ status: 200,
92
+ statusText: 'OK',
93
+ json: () => Promise.resolve(body),
94
+ text: () => Promise.resolve(JSON.stringify(body)),
95
+ }
96
+ }
97
+
98
+ function unauthorized(body = GATEWAY_401) {
99
+ return {
100
+ ok: false,
101
+ status: 401,
102
+ statusText: 'Unauthorized',
103
+ text: () => Promise.resolve(body),
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Answers on the token it is given rather than on call count, so a retry that
109
+ * re-sends the stale token is a failed assertion rather than a passing one.
110
+ */
111
+ function fetchAcceptingOnly(freshToken: string, body: unknown = { items: [] }) {
112
+ return vi.fn((_url: string, init: RequestInit) => {
113
+ const headers = (init.headers ?? {}) as Record<string, string>
114
+ return Promise.resolve(
115
+ headers['Authorization'] === `Bearer ${freshToken}` ? ok(body) : unauthorized(),
116
+ )
117
+ })
118
+ }
119
+
120
+ it('renews the session and retries once, so the call succeeds', async () => {
121
+ const fetchMock = fetchAcceptingOnly('fresh', { items: ['course'] })
122
+ vi.stubGlobal('fetch', fetchMock)
123
+ const refresh = vi.fn(() => Promise.resolve<string | null>('fresh'))
124
+
125
+ const api = createApiClient(() => 'expired', refresh)
126
+
127
+ await expect(api.get('/api/v1/courses')).resolves.toEqual({ items: ['course'] })
128
+ expect(refresh).toHaveBeenCalledTimes(1)
129
+ expect(fetchMock).toHaveBeenCalledTimes(2)
130
+ })
131
+
132
+ it('retries with the refreshed token, never the one that was rejected', async () => {
133
+ const fetchMock = fetchAcceptingOnly('fresh')
134
+ vi.stubGlobal('fetch', fetchMock)
135
+
136
+ // The shape every caller actually uses: `() => token` closes over React
137
+ // state, so it still returns the expired token after a renewal. A retry
138
+ // that re-reads it would send the same rejected credential.
139
+ const api = createApiClient(
140
+ () => 'expired',
141
+ () => Promise.resolve('fresh'),
142
+ )
143
+ await api.get('/api/v1/courses')
144
+
145
+ const sent = fetchMock.mock.calls.map(
146
+ ([, init]) => ((init.headers ?? {}) as Record<string, string>)['Authorization'],
147
+ )
148
+ expect(sent).toEqual(['Bearer expired', 'Bearer fresh'])
149
+ })
150
+
151
+ it('renews once for concurrent 401s rather than once per caller', async () => {
152
+ vi.stubGlobal('fetch', fetchAcceptingOnly('fresh'))
153
+ let resolveRefresh: (token: string) => void = () => {}
154
+ const refresh = vi.fn(
155
+ () =>
156
+ new Promise<string | null>((resolve) => {
157
+ resolveRefresh = resolve
158
+ }),
159
+ )
160
+
161
+ const api = createApiClient(() => 'expired', refresh)
162
+ const inFlight = Promise.all([
163
+ api.get('/api/v1/courses'),
164
+ api.get('/api/v1/modules'),
165
+ api.get('/api/v1/learners'),
166
+ ])
167
+ // Let all three reach their 401 before the single refresh settles.
168
+ await Promise.resolve()
169
+ resolveRefresh('fresh')
170
+ await inFlight
171
+
172
+ expect(refresh).toHaveBeenCalledTimes(1)
173
+ })
174
+
175
+ it('surfaces an actionable sentence, not API Gateway body, when renewal fails', async () => {
176
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(unauthorized()))
177
+
178
+ const api = createApiClient(
179
+ () => 'expired',
180
+ () => Promise.resolve(null),
181
+ )
182
+ const error = await api.get('/api/v1/courses').then(
183
+ () => {
184
+ throw new Error('expected the request to reject')
185
+ },
186
+ (e: unknown) => e as ApiError,
187
+ )
188
+
189
+ expect(error).toBeInstanceOf(ApiError)
190
+ expect(error.status).toBe(401)
191
+ expect(error.message).not.toContain('Unauthorized"')
192
+ expect(error.message).toBe(
193
+ 'Your session has expired. Reload the page to sign in again, then retry that action.',
194
+ )
195
+ })
196
+
197
+ it('does not retry in a loop when the renewed token is also rejected', async () => {
198
+ const fetchMock = vi.fn().mockResolvedValue(unauthorized())
199
+ vi.stubGlobal('fetch', fetchMock)
200
+
201
+ const api = createApiClient(
202
+ () => 'expired',
203
+ () => Promise.resolve('also-stale'),
204
+ )
205
+
206
+ await expect(api.get('/api/v1/courses')).rejects.toThrow(/session has expired/i)
207
+ // The first attempt and exactly one retry. A revoked session must not spin.
208
+ expect(fetchMock).toHaveBeenCalledTimes(2)
209
+ })
210
+
211
+ it('treats a renewal that hands back the same token as no renewal at all', async () => {
212
+ const fetchMock = vi.fn().mockResolvedValue(unauthorized())
213
+ vi.stubGlobal('fetch', fetchMock)
214
+
215
+ const api = createApiClient(
216
+ () => 'expired',
217
+ () => Promise.resolve('expired'),
218
+ )
219
+
220
+ await expect(api.get('/api/v1/courses')).rejects.toThrow(/session has expired/i)
221
+ // Re-sending a credential the gateway just rejected cannot succeed, so the
222
+ // retry is skipped rather than spent.
223
+ expect(fetchMock).toHaveBeenCalledTimes(1)
224
+ })
225
+
226
+ it('reports the expiry sentence when the renewal itself throws', async () => {
227
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(unauthorized()))
228
+
229
+ const api = createApiClient(
230
+ () => 'expired',
231
+ () => Promise.reject(new Error('network down')),
232
+ )
233
+
234
+ // The Cognito failure is plumbing the user cannot act on; what they can act
235
+ // on is signing in again.
236
+ await expect(api.get('/api/v1/courses')).rejects.toThrow(/session has expired/i)
237
+ })
238
+
239
+ it('keeps a backend 401 that explains itself, rather than overwriting it', async () => {
240
+ vi.stubGlobal(
241
+ 'fetch',
242
+ vi.fn().mockResolvedValue(unauthorized('{"detail":"Token issued for another tenant"}')),
243
+ )
244
+
245
+ const api = createApiClient(
246
+ () => 'expired',
247
+ () => Promise.resolve(null),
248
+ )
249
+
250
+ // Same reasoning as extractErrorMessage's: never hide information the
251
+ // caller already had. A `detail` is the product's own voice already.
252
+ await expect(api.get('/api/v1/courses')).rejects.toThrow('Token issued for another tenant')
253
+ })
254
+
255
+ it('renews for a mutating verb too, and retries the body', async () => {
256
+ const fetchMock = fetchAcceptingOnly('fresh', { id: 'c1' })
257
+ vi.stubGlobal('fetch', fetchMock)
258
+
259
+ const api = createApiClient(
260
+ () => 'expired',
261
+ () => Promise.resolve('fresh'),
262
+ )
263
+ await expect(api.post('/api/v1/courses', { title: 'New' })).resolves.toEqual({ id: 'c1' })
264
+
265
+ const [, retry] = fetchMock.mock.calls
266
+ expect(retry[1].method).toBe('POST')
267
+ expect(retry[1].body).toBe(JSON.stringify({ title: 'New' }))
268
+ })
269
+
270
+ it('leaves a non-401 failure entirely alone', async () => {
271
+ const fetchMock = vi.fn().mockResolvedValue({
272
+ ok: false,
273
+ status: 403,
274
+ statusText: 'Forbidden',
275
+ text: () => Promise.resolve('{"detail":"Administrator access required"}'),
276
+ })
277
+ vi.stubGlobal('fetch', fetchMock)
278
+ const refresh = vi.fn(() => Promise.resolve<string | null>('fresh'))
279
+
280
+ const api = createApiClient(() => 'token', refresh)
281
+
282
+ await expect(api.get('/api/v1/users')).rejects.toThrow('Administrator access required')
283
+ expect(refresh).not.toHaveBeenCalled()
284
+ expect(fetchMock).toHaveBeenCalledTimes(1)
285
+ })
286
+ })
@@ -38,67 +38,204 @@ export class ApiError extends Error {
38
38
  * status text, because a browser rendering `''` shows the user nothing at all.
39
39
  */
40
40
  export function extractErrorMessage(body: string, statusText: string): string {
41
+ const detail = parsedDetail(body)
42
+ if (detail !== null) return detail || statusText
43
+ return body || statusText
44
+ }
45
+
46
+ /**
47
+ * The `detail` a Biffo backend put in its error body, or null if there isn't
48
+ * one to read.
49
+ *
50
+ * Split out of `extractErrorMessage` so the 401 path can ask the same question
51
+ * without re-implementing the answer — writing this parse a second time in the
52
+ * same file is precisely the mistake `_extract_detail` records (#1107/#1108),
53
+ * and a second copy would drift on the next FastAPI change.
54
+ *
55
+ * Returns null for "the backend did not speak", and a string — possibly `''` —
56
+ * for "it did". Callers need that distinction: an empty `detail` still means the
57
+ * body was the product's own shape, and collapsing the two would make an empty
58
+ * string indistinguishable from a CloudFront HTML page.
59
+ */
60
+ function parsedDetail(body: string): string | null {
41
61
  let parsed: unknown
42
62
  try {
43
63
  parsed = JSON.parse(body)
44
64
  } catch {
45
- return body || statusText
65
+ return null
46
66
  }
47
67
  if (typeof parsed === 'object' && parsed !== null && 'detail' in parsed) {
48
68
  const { detail } = parsed
49
- if (typeof detail === 'string') return detail || statusText
69
+ // A non-string `detail` also falls back. FastAPI's own 422 makes it a list
70
+ // of field errors; picking something out of it would move the problem
71
+ // rather than fix it.
72
+ if (typeof detail === 'string') return detail
50
73
  }
51
- return body || statusText
74
+ return null
75
+ }
76
+
77
+ /** A response body as text, or `''` if it could not be read. */
78
+ async function bodyOf(res: Response): Promise<string> {
79
+ return res.text().catch(() => '')
52
80
  }
53
81
 
54
82
  async function handleResponse<T>(res: Response): Promise<T> {
55
83
  if (!res.ok) {
56
- const body = await res.text().catch(() => '')
57
- throw new ApiError(res.status, extractErrorMessage(body, res.statusText))
84
+ throw new ApiError(res.status, extractErrorMessage(await bodyOf(res), res.statusText))
58
85
  }
59
86
  return res.json() as Promise<T>
60
87
  }
61
88
 
62
- export function createApiClient(getIdToken: () => string | null) {
63
- function authHeaders(): HeadersInit {
64
- const token = getIdToken()
89
+ /**
90
+ * What a person is told when their session could not be renewed.
91
+ *
92
+ * "Unauthorized" is not an instruction, and it was not even the app's word: a
93
+ * 401 from the API Gateway JWT authorizer never reaches this app, so its body is
94
+ * `{"message":"Unauthorized"}` — a wire format with no `detail` for
95
+ * `extractErrorMessage` to unwrap (tabsii-lms#3). Reloading is named explicitly
96
+ * because it is what actually works: the mount path renews from the refresh
97
+ * token in localStorage, and if that token is dead too the reload lands on the
98
+ * portal's login, which is where the user needs to be either way.
99
+ */
100
+ const SESSION_EXPIRED_MESSAGE =
101
+ 'Your session has expired. Reload the page to sign in again, then retry that action.'
102
+
103
+ /**
104
+ * The message for a 401 that survived a renewal attempt.
105
+ *
106
+ * This deliberately keeps `extractErrorMessage`'s first principle — never hide
107
+ * information the caller already had — rather than stamping the expiry sentence
108
+ * over every 401. A body carrying a `detail` is the product's own voice and may
109
+ * say something the expiry sentence would erase ("Token issued for another
110
+ * tenant"); a body without one is upstream plumbing the user cannot act on, and
111
+ * that is the only case worth replacing.
112
+ *
113
+ * It takes no `statusText`, unlike `extractErrorMessage`: the fallback here is a
114
+ * full sentence rather than a bare status, so there is nothing left for "401" or
115
+ * "Unauthorized" to add.
116
+ */
117
+ function unauthorizedMessage(body: string): string {
118
+ const detail = parsedDetail(body)
119
+ return detail !== null && detail !== '' ? detail : SESSION_EXPIRED_MESSAGE
120
+ }
121
+
122
+ /**
123
+ * One renewal in flight at a time, across every client this module hands out.
124
+ *
125
+ * The Cognito session lives in localStorage, shared by the whole page, so a
126
+ * refresh is a property of the app rather than of one client instance — and a
127
+ * page load routinely fans out several calls at once, each of which would
128
+ * otherwise expire together and fire its own refresh. Cognito would then serve
129
+ * concurrent renewals for one session, and the losers could persist a token
130
+ * older than the winner's.
131
+ *
132
+ * Module scope rather than per-client is what makes that true even though
133
+ * `createApiClient` is called in several components independently.
134
+ */
135
+ let inFlightRenewal: Promise<string | null> | null = null
136
+ let renewalCount = 0
137
+
138
+ function renewSessionOnce(refreshIdToken: () => Promise<string | null>): Promise<string | null> {
139
+ if (inFlightRenewal) return inFlightRenewal
140
+
141
+ // Compared in the `finally` so a settling renewal can only clear itself,
142
+ // never a newer one. (Identity on the promise would say the same thing, but
143
+ // it cannot reference itself before it is assigned.)
144
+ const id = ++renewalCount
145
+ inFlightRenewal = (async () => {
146
+ try {
147
+ return await refreshIdToken()
148
+ } catch {
149
+ // A refresh that throws is indistinguishable, to the user, from one that
150
+ // returns no session: either way they need to sign in again. Rejecting
151
+ // here instead would surface a Cognito internal at them.
152
+ return null
153
+ } finally {
154
+ if (renewalCount === id) inFlightRenewal = null
155
+ }
156
+ })()
157
+
158
+ return inFlightRenewal
159
+ }
160
+
161
+ /**
162
+ * Renew through the portal session this sibling already reads (`./auth`).
163
+ *
164
+ * Imported dynamically, for two reasons. It keeps `api-client.ts` free of a
165
+ * load-time dependency on `auth.ts` — which is NOT distributed with this file
166
+ * (it carries a declared per-repo divergence, biffo-template#1117), so a static
167
+ * import would couple a synced file's module graph to one that may legitimately
168
+ * differ. And it keeps the Cognito SDK out of the bundle of any module that only
169
+ * ever calls the API and never hits a 401.
170
+ *
171
+ * `getCurrentSession()` is the renewal: `CognitoUser.getSession()` exchanges the
172
+ * stored refresh token for a fresh id token when the current one has expired.
173
+ * That is exactly what a page reload was already doing by accident, and nothing
174
+ * did on a live call.
175
+ */
176
+ async function refreshViaPortalSession(): Promise<string | null> {
177
+ const { getCurrentSession } = await import('./auth')
178
+ const session = await getCurrentSession()
179
+ return session?.getIdToken().getJwtToken() ?? null
180
+ }
181
+
182
+ export function createApiClient(
183
+ getIdToken: () => string | null,
184
+ refreshIdToken: () => Promise<string | null> = refreshViaPortalSession,
185
+ ) {
186
+ function authHeaders(token: string | null): HeadersInit {
65
187
  return {
66
188
  'Content-Type': 'application/json',
67
189
  ...(token != null ? { Authorization: `Bearer ${token}` } : {}),
68
190
  }
69
191
  }
70
192
 
193
+ /**
194
+ * Send the request; on a 401, renew the session once and send it again.
195
+ *
196
+ * The retry is a second statement rather than a recursive call, which is what
197
+ * makes "once" structural: there is no path back to the top, so a genuinely
198
+ * revoked session cannot spin however many 401s it produces.
199
+ *
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.
204
+ */
205
+ async function send<T>(path: string, init: RequestInit): Promise<T> {
206
+ const sentToken = getIdToken()
207
+ const res = await fetch(`${API_URL}${path}`, { ...init, headers: authHeaders(sentToken) })
208
+ if (res.status !== 401) return handleResponse<T>(res)
209
+
210
+ const renewed = await renewSessionOnce(refreshIdToken)
211
+ // An unchanged token means the renewal had nothing newer to give, so the
212
+ // retry would re-send what was just rejected. Skip it rather than spend it.
213
+ if (renewed == null || renewed === sentToken) {
214
+ throw new ApiError(401, unauthorizedMessage(await bodyOf(res)))
215
+ }
216
+
217
+ const retried = await fetch(`${API_URL}${path}`, { ...init, headers: authHeaders(renewed) })
218
+ if (retried.status === 401) {
219
+ throw new ApiError(401, unauthorizedMessage(await bodyOf(retried)))
220
+ }
221
+ return handleResponse<T>(retried)
222
+ }
223
+
71
224
  return {
72
- get: <T>(path: string): Promise<T> =>
73
- fetch(`${API_URL}${path}`, { headers: authHeaders() }).then((r) => handleResponse<T>(r)),
225
+ get: <T>(path: string): Promise<T> => send<T>(path, {}),
74
226
 
75
227
  post: <T>(path: string, body: unknown): Promise<T> =>
76
- fetch(`${API_URL}${path}`, {
77
- method: 'POST',
78
- headers: authHeaders(),
79
- body: JSON.stringify(body),
80
- }).then((r) => handleResponse<T>(r)),
228
+ send<T>(path, { method: 'POST', body: JSON.stringify(body) }),
81
229
 
82
230
  put: <T>(path: string, body: unknown): Promise<T> =>
83
- fetch(`${API_URL}${path}`, {
84
- method: 'PUT',
85
- headers: authHeaders(),
86
- body: JSON.stringify(body),
87
- }).then((r) => handleResponse<T>(r)),
231
+ send<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
88
232
 
89
233
  // Folded in from tabsii-crm, which had added it locally. It costs a sibling
90
234
  // that never calls it nothing, and leaving it out would mean this file
91
235
  // could never be distributed without destroying crm's copy.
92
236
  patch: <T>(path: string, body: unknown): Promise<T> =>
93
- fetch(`${API_URL}${path}`, {
94
- method: 'PATCH',
95
- headers: authHeaders(),
96
- body: JSON.stringify(body),
97
- }).then((r) => handleResponse<T>(r)),
98
-
99
- delete: <T>(path: string): Promise<T> =>
100
- fetch(`${API_URL}${path}`, { method: 'DELETE', headers: authHeaders() }).then((r) =>
101
- handleResponse<T>(r),
102
- ),
237
+ send<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
238
+
239
+ delete: <T>(path: string): Promise<T> => send<T>(path, { method: 'DELETE' }),
103
240
  }
104
241
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.244.10",
3
+ "version": "0.245.0",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",