@sanity/sdk 3.3.0 → 3.4.0-rc.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.
Files changed (34) hide show
  1. package/dist/_exports/_internal.d.ts +3 -3
  2. package/dist/_exports/_internal.js +4 -4
  3. package/dist/_exports/agent.d.ts +1 -1
  4. package/dist/_exports/agent.js +1 -1
  5. package/dist/_exports/comlink.d.ts +1 -1
  6. package/dist/_exports/dashboard.d.ts +1 -1
  7. package/dist/{applications-pRS8tuC2.d.ts → applications-903KQTrD.d.ts} +20 -5
  8. package/dist/applications-903KQTrD.d.ts.map +1 -0
  9. package/dist/{clientStore-BI4D8HhJ.js → clientStore-QDrJNTM4.js} +14 -10
  10. package/dist/clientStore-QDrJNTM4.js.map +1 -0
  11. package/dist/{createSanityInstance-BQIqyGLX.d.ts → createSanityInstance--Lrldto8.d.ts} +6 -3
  12. package/dist/createSanityInstance--Lrldto8.d.ts.map +1 -0
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.js +182 -142
  15. package/dist/index.js.map +1 -1
  16. package/dist/{topicStore-D-jEwzlX.d.ts → topicStore-VQILEI1R.d.ts} +2 -2
  17. package/dist/{topicStore-D-jEwzlX.d.ts.map → topicStore-VQILEI1R.d.ts.map} +1 -1
  18. package/dist/{version-CD4aBigm.js → version-CYqZgJX1.js} +3 -3
  19. package/dist/{version-CD4aBigm.js.map → version-CYqZgJX1.js.map} +1 -1
  20. package/package.json +1 -1
  21. package/src/auth/authStore.test.ts +18 -0
  22. package/src/auth/authStore.ts +4 -1
  23. package/src/auth/handleAuthCallback.test.ts +33 -0
  24. package/src/auth/handleAuthCallback.ts +11 -1
  25. package/src/auth/logout.test.ts +9 -1
  26. package/src/auth/logout.ts +18 -1
  27. package/src/auth/oauth/oauthActions.test.ts +178 -1
  28. package/src/auth/oauth/oauthActions.ts +111 -46
  29. package/src/auth/oauth/oauthAuth.test.ts +23 -0
  30. package/src/auth/oauth/oauthAuth.ts +18 -8
  31. package/src/config/authConfig.ts +5 -2
  32. package/dist/applications-pRS8tuC2.d.ts.map +0 -1
  33. package/dist/clientStore-BI4D8HhJ.js.map +0 -1
  34. package/dist/createSanityInstance-BQIqyGLX.d.ts.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/sdk",
3
- "version": "3.3.0",
3
+ "version": "3.4.0-rc.0",
4
4
  "private": false,
5
5
  "description": "Sanity SDK",
6
6
  "keywords": [
@@ -339,6 +339,24 @@ describe('authStore', () => {
339
339
  expect(options.authMethod).toBe('localstorage')
340
340
  })
341
341
 
342
+ it('only exposes oauth options when the resolved mode is oauth', () => {
343
+ const oauth = {
344
+ clientId: 'client-abc',
345
+ redirectUri: 'https://app.example.com/callback',
346
+ organizationId: 'org123',
347
+ }
348
+ vi.mocked(getStudioTokenFromLocalStorage).mockReturnValue(null)
349
+ vi.mocked(getTokenFromStorage).mockReturnValue(null)
350
+
351
+ // studio takes precedence over oauth, so oauth options must be dropped
352
+ instance = createSanityInstance({projectId: 'p', dataset: 'd', studio: {}, auth: {oauth}})
353
+ expect(authStore.getInitialState(instance, null).options.oauth).toBeUndefined()
354
+ instance.dispose()
355
+
356
+ instance = createSanityInstance({projectId: 'p', dataset: 'd', auth: {oauth}})
357
+ expect(authStore.getInitialState(instance, null).options.oauth).toEqual(oauth)
358
+ })
359
+
342
360
  it('logs auth initialized in logged out state when no token available', () => {
343
361
  instance = createSanityInstance({
344
362
  projectId: 'p',
@@ -191,7 +191,10 @@ export const authStore = defineStore<AuthStoreState>({
191
191
  storageKey: result.storageKey,
192
192
  storageArea: result.storageArea,
193
193
  authMethod: result.authMethod,
194
- oauth: authConfig.oauth,
194
+ // Only expose oauth options when the oauth strategy is actually in use,
195
+ // so mode-gated actions (logout, handleAuthCallback) stay consistent
196
+ // with the resolved mode (e.g. `studio` takes precedence over `oauth`).
197
+ oauth: mode === 'oauth' ? authConfig.oauth : undefined,
195
198
  },
196
199
  }
197
200
  },
@@ -5,6 +5,7 @@ import {createSanityInstance, type SanityInstance} from '../store/createSanityIn
5
5
  import {AuthStateType} from './authStateType'
6
6
  import {getAuthState} from './authStore'
7
7
  import {handleAuthCallback} from './handleAuthCallback'
8
+ import {handleOAuthCallback} from './oauth/oauthActions'
8
9
  import {subscribeToStateAndFetchCurrentUser} from './subscribeToStateAndFetchCurrentUser'
9
10
  import {subscribeToStorageEventsAndSetToken} from './subscribeToStorageEventsAndSetToken'
10
11
  import {getAuthCode, getTokenFromLocation, getTokenFromStorage} from './utils'
@@ -22,6 +23,11 @@ vi.mock('./utils', async (importOriginal) => {
22
23
  vi.mock('./subscribeToStateAndFetchCurrentUser')
23
24
  vi.mock('./subscribeToStorageEventsAndSetToken')
24
25
 
26
+ vi.mock('./oauth/oauthActions', async (importOriginal) => {
27
+ const original = await importOriginal<typeof import('./oauth/oauthActions')>()
28
+ return {...original, handleOAuthCallback: vi.fn()}
29
+ })
30
+
25
31
  // Mock logger to prevent actual logging during tests
26
32
  vi.mock('../utils/logger', async (importOriginal) => {
27
33
  const original = await importOriginal<typeof import('../utils/logger')>()
@@ -277,4 +283,31 @@ describe('handleCallback', () => {
277
283
  // Verify logger was called
278
284
  expect(createLogger).toHaveBeenCalled()
279
285
  })
286
+
287
+ it('delegates to handleOAuthCallback in OAuth mode', async () => {
288
+ const clientFactory = vi.fn()
289
+ vi.mocked(getTokenFromStorage).mockReturnValue(null)
290
+ vi.mocked(handleOAuthCallback).mockResolvedValue('https://app.example.com/callback')
291
+
292
+ instance = createSanityInstance({
293
+ projectId: 'p',
294
+ dataset: 'd',
295
+ auth: {
296
+ clientFactory,
297
+ oauth: {
298
+ clientId: 'client-abc',
299
+ redirectUri: 'https://app.example.com/callback',
300
+ organizationId: 'org123',
301
+ },
302
+ },
303
+ })
304
+
305
+ const href = 'https://app.example.com/callback?code=abc&state=xyz'
306
+ const result = await handleAuthCallback(instance, href)
307
+
308
+ expect(result).toBe('https://app.example.com/callback')
309
+ expect(handleOAuthCallback).toHaveBeenCalledWith(instance, href)
310
+ expect(getAuthCode).not.toHaveBeenCalled()
311
+ expect(clientFactory).not.toHaveBeenCalled()
312
+ })
280
313
  })
@@ -3,6 +3,7 @@ import {DEFAULT_API_VERSION, REQUEST_TAG_PREFIX} from './authConstants'
3
3
  import {getAuthLogger} from './authLogger'
4
4
  import {AuthStateType} from './authStateType'
5
5
  import {authStore, type AuthStoreState, type DashboardContext} from './authStore'
6
+ import {handleOAuthCallback} from './oauth/oauthActions'
6
7
  import {
7
8
  createLoggedInAuthState,
8
9
  getAuthCode,
@@ -12,6 +13,10 @@ import {
12
13
  } from './utils'
13
14
 
14
15
  /**
16
+ * Handles the auth redirect callback. In OAuth mode this delegates to
17
+ * {@link handleOAuthCallback}; otherwise it exchanges the legacy `sid` for a
18
+ * long-lived token.
19
+ *
15
20
  * @public
16
21
  */
17
22
  export const handleAuthCallback = bindActionGlobally(
@@ -19,7 +24,7 @@ export const handleAuthCallback = bindActionGlobally(
19
24
  async ({state, instance}, locationHref: string = getDefaultLocation()) => {
20
25
  const logger = getAuthLogger(instance)
21
26
 
22
- const {providedToken, callbackUrl, clientFactory, apiHost, storageArea, storageKey} =
27
+ const {providedToken, callbackUrl, clientFactory, apiHost, storageArea, storageKey, oauth} =
23
28
  state.get().options
24
29
 
25
30
  // If a token is provided, no need to handle callback
@@ -28,6 +33,11 @@ export const handleAuthCallback = bindActionGlobally(
28
33
  return false
29
34
  }
30
35
 
36
+ if (oauth) {
37
+ logger.debug('OAuth mode - delegating to handleOAuthCallback')
38
+ return handleOAuthCallback(instance, locationHref)
39
+ }
40
+
31
41
  // Don't handle the callback if already in flight.
32
42
  const {authState} = state.get()
33
43
  if (authState.type === AuthStateType.LOGGING_IN && authState.isExchangingToken) {
@@ -169,7 +169,7 @@ describe('logout', () => {
169
169
  expect(removeItem).toHaveBeenCalledWith('__sanity_auth_token')
170
170
  })
171
171
 
172
- it('clears the OAuth tokens from the store on logout', async () => {
172
+ it('revokes OAuth tokens instead of calling /auth/logout in OAuth mode', async () => {
173
173
  const oauthTokens: OAuthTokens = {
174
174
  accessToken: 'oauth-access',
175
175
  tokenType: 'bearer',
@@ -204,8 +204,16 @@ describe('logout', () => {
204
204
  // Boots logged in with the persisted OAuth tokens exposed on the store.
205
205
  expect(getOAuthTokensState(instance).getCurrent()).toMatchObject({accessToken: 'oauth-access'})
206
206
 
207
+ const first = logout(instance)
208
+ // Session is marked as destroying so a concurrent call is a no-op
209
+ expect(getAuthState(instance).getCurrent()).toMatchObject({isDestroyingSession: true})
207
210
  await logout(instance)
211
+ await first
208
212
 
213
+ expect(mockRequest).toHaveBeenCalledTimes(1)
214
+ expect(mockRequest).toHaveBeenCalledWith(
215
+ expect.objectContaining({url: '/auth/oauth/revoke', tag: 'oauth.revoke'}),
216
+ )
209
217
  expect(getOAuthTokensState(instance).getCurrent()).toBeNull()
210
218
  expect(getAuthState(instance).getCurrent()).toMatchObject({type: AuthStateType.LOGGED_OUT})
211
219
  expect(map.get(OAUTH_TOKENS_KEY)).toBeUndefined()
@@ -3,14 +3,22 @@ import {DEFAULT_API_VERSION, REQUEST_TAG_PREFIX} from './authConstants'
3
3
  import {getAuthLogger} from './authLogger'
4
4
  import {AuthStateType} from './authStateType'
5
5
  import {authStore} from './authStore'
6
+ import {revokeOAuthTokens} from './oauth/oauthActions'
6
7
 
7
8
  /**
9
+ * Logs the current user out. In OAuth mode this revokes the OAuth tokens
10
+ * instead of calling the legacy `/auth/logout` endpoint; revocation is
11
+ * best-effort, so the OAuth path resolves even if the revoke request fails,
12
+ * whereas the legacy path rejects when `/auth/logout` fails. Local state and
13
+ * storage are cleared in both cases.
14
+ *
8
15
  * @public
9
16
  */
10
17
  export const logout = bindActionGlobally(authStore, async ({state, instance}) => {
11
18
  const logger = getAuthLogger(instance)
12
19
 
13
- const {clientFactory, apiHost, providedToken, storageArea, storageKey} = state.get().options
20
+ const {clientFactory, apiHost, providedToken, storageArea, storageKey, oauth} =
21
+ state.get().options
14
22
 
15
23
  // If a token is statically provided, logout does nothing
16
24
  if (providedToken) {
@@ -25,6 +33,15 @@ export const logout = bindActionGlobally(authStore, async ({state, instance}) =>
25
33
  logger.debug('Skipping logout - already in progress')
26
34
  return
27
35
  }
36
+
37
+ if (oauth) {
38
+ logger.debug('OAuth mode - revoking tokens instead of calling /auth/logout')
39
+ state.set('loggingOut', {
40
+ authState: {type: AuthStateType.LOGGED_OUT, isDestroyingSession: true},
41
+ })
42
+ await revokeOAuthTokens(instance)
43
+ return
44
+ }
28
45
  const token = authState.type === AuthStateType.LOGGED_IN && authState.token
29
46
 
30
47
  try {
@@ -9,6 +9,7 @@ import {subscribeToStateAndFetchCurrentUser} from '../subscribeToStateAndFetchCu
9
9
  import {
10
10
  getOAuthTokensState,
11
11
  handleOAuthCallback,
12
+ OAUTH_RETURN_TO_KEY,
12
13
  OAUTH_STATE_KEY,
13
14
  OAUTH_VERIFIER_KEY,
14
15
  refreshOAuthTokens,
@@ -42,6 +43,11 @@ const oauthConfig = {
42
43
  organizationId: 'org123',
43
44
  }
44
45
 
46
+ const oauthConfigNoOrg = {
47
+ clientId: 'client-abc',
48
+ redirectUri: 'https://app.example.com/callback',
49
+ }
50
+
45
51
  const seededTokens: OAuthTokens = {
46
52
  accessToken: 'stored-access',
47
53
  tokenType: 'bearer',
@@ -93,6 +99,7 @@ interface SetupOptions {
93
99
  sessionSeed?: Record<string, string>
94
100
  initialLocationHref?: string
95
101
  withOAuthConfig?: boolean
102
+ oauthConfig?: {clientId: string; redirectUri: string; organizationId?: string}
96
103
  apiHost?: string
97
104
  }
98
105
 
@@ -111,7 +118,7 @@ function setup(options: SetupOptions = {}) {
111
118
  storageArea,
112
119
  initialLocationHref: options.initialLocationHref ?? 'https://app.example.com/',
113
120
  ...(options.apiHost && {apiHost: options.apiHost}),
114
- ...(options.withOAuthConfig === false ? {} : {oauth: oauthConfig}),
121
+ ...(options.withOAuthConfig === false ? {} : {oauth: options.oauthConfig ?? oauthConfig}),
115
122
  },
116
123
  })
117
124
 
@@ -156,6 +163,39 @@ describe('startOAuthAuthorization', () => {
156
163
  expect(url.searchParams.getAll('resource')).toEqual(['urn:io.sanity:organization:org123'])
157
164
  })
158
165
 
166
+ it('stashes the current location to return to, keeping app params intact', async () => {
167
+ const href = 'https://app.example.com/documents/abc?state=draft&code=x#h'
168
+ vi.stubGlobal('window', {location: {assign: vi.fn(), href}})
169
+ vi.stubGlobal('location', {href})
170
+ const {session} = setup()
171
+
172
+ await startOAuthAuthorization(instance!)
173
+
174
+ expect(session.getItem(OAUTH_RETURN_TO_KEY)).toBe(href)
175
+ })
176
+
177
+ it('strips stale OAuth params from the stashed location when already on the callback route', async () => {
178
+ const href = 'https://app.example.com/callback?x=1&code=old&state=old'
179
+ vi.stubGlobal('window', {location: {assign: vi.fn(), href}})
180
+ vi.stubGlobal('location', {href})
181
+ const {session} = setup()
182
+
183
+ await startOAuthAuthorization(instance!)
184
+
185
+ expect(session.getItem(OAUTH_RETURN_TO_KEY)).toBe('https://app.example.com/callback?x=1')
186
+ })
187
+
188
+ it('omits the resource param when no organizationId is configured', async () => {
189
+ const assign = vi.fn()
190
+ vi.stubGlobal('window', {location: {assign}})
191
+ setup({initialLocationHref: 'https://app.example.com/', oauthConfig: oauthConfigNoOrg})
192
+
193
+ await startOAuthAuthorization(instance!)
194
+
195
+ const url = new URL(assign.mock.calls[0][0])
196
+ expect(url.searchParams.has('resource')).toBe(false)
197
+ })
198
+
159
199
  it('throws when OAuth is not configured', async () => {
160
200
  setup({withOAuthConfig: false})
161
201
  await expect(startOAuthAuthorization(instance!)).rejects.toThrow(/OAuth is not configured/)
@@ -203,6 +243,75 @@ describe('handleOAuthCallback', () => {
203
243
  })
204
244
  })
205
245
 
246
+ it('returns the stashed same-origin location after a successful exchange', async () => {
247
+ const returnTo = 'https://app.example.com/documents/abc?x=1'
248
+ const {session} = setup({
249
+ sessionSeed: {
250
+ [OAUTH_STATE_KEY]: 'state-xyz',
251
+ [OAUTH_VERIFIER_KEY]: 'verifier-1',
252
+ [OAUTH_RETURN_TO_KEY]: returnTo,
253
+ },
254
+ })
255
+
256
+ expect(await handleOAuthCallback(instance!, callbackHref)).toBe(returnTo)
257
+ expect(session.getItem(OAUTH_RETURN_TO_KEY)).toBeNull()
258
+ })
259
+
260
+ it('ignores a stashed location on a different origin', async () => {
261
+ setup({
262
+ sessionSeed: {
263
+ [OAUTH_STATE_KEY]: 'state-xyz',
264
+ [OAUTH_VERIFIER_KEY]: 'verifier-1',
265
+ [OAUTH_RETURN_TO_KEY]: 'https://evil.example.com/',
266
+ },
267
+ })
268
+
269
+ expect(await handleOAuthCallback(instance!, callbackHref)).toBe(
270
+ 'https://app.example.com/callback',
271
+ )
272
+ })
273
+
274
+ it('ignores a stashed location that is not a URL', async () => {
275
+ setup({
276
+ sessionSeed: {
277
+ [OAUTH_STATE_KEY]: 'state-xyz',
278
+ [OAUTH_VERIFIER_KEY]: 'verifier-1',
279
+ [OAUTH_RETURN_TO_KEY]: 'not a url',
280
+ },
281
+ })
282
+
283
+ expect(await handleOAuthCallback(instance!, callbackHref)).toBe(
284
+ 'https://app.example.com/callback',
285
+ )
286
+ })
287
+
288
+ it('does not restore the stashed location on a state mismatch', async () => {
289
+ const {session} = setup({
290
+ sessionSeed: {
291
+ [OAUTH_STATE_KEY]: 'different',
292
+ [OAUTH_VERIFIER_KEY]: 'verifier-1',
293
+ [OAUTH_RETURN_TO_KEY]: 'https://app.example.com/deep',
294
+ },
295
+ })
296
+
297
+ expect(await handleOAuthCallback(instance!, callbackHref)).toBe(
298
+ 'https://app.example.com/callback',
299
+ )
300
+ expect(session.getItem(OAUTH_RETURN_TO_KEY)).toBeNull()
301
+ })
302
+
303
+ it('does not restore the stashed location on an ?error= callback', async () => {
304
+ const {session} = setup({sessionSeed: {[OAUTH_RETURN_TO_KEY]: 'https://app.example.com/deep'}})
305
+
306
+ const result = await handleOAuthCallback(
307
+ instance!,
308
+ 'https://app.example.com/callback?error=access_denied',
309
+ )
310
+
311
+ expect(result).toBe('https://app.example.com/callback')
312
+ expect(session.getItem(OAUTH_RETURN_TO_KEY)).toBeNull()
313
+ })
314
+
206
315
  it('does not perform a second exchange on a duplicate concurrent invocation', async () => {
207
316
  const pending = deferred<typeof tokenResponse>()
208
317
  const request = vi.fn().mockReturnValue(pending.promise)
@@ -221,6 +330,31 @@ describe('handleOAuthCallback', () => {
221
330
  expect(await first).toBe('https://app.example.com/callback')
222
331
  })
223
332
 
333
+ it('does not clobber LOGGED_IN when called again with a stale callback URL', async () => {
334
+ const {request} = setup({
335
+ sessionSeed: {[OAUTH_STATE_KEY]: 'state-xyz', [OAUTH_VERIFIER_KEY]: 'verifier-1'},
336
+ })
337
+
338
+ expect(await handleOAuthCallback(instance!, callbackHref)).toBe(
339
+ 'https://app.example.com/callback',
340
+ )
341
+ // Artifacts are now cleared; a repeat with the same URL fails state validation.
342
+ // Seed fresh artifacts as if a re-authorization started in this tab: the
343
+ // ignored stale callback must not wipe them.
344
+ sessionStorage.setItem(OAUTH_STATE_KEY, 'state-next')
345
+ sessionStorage.setItem(OAUTH_VERIFIER_KEY, 'verifier-next')
346
+ const result = await handleOAuthCallback(instance!, callbackHref)
347
+
348
+ expect(result).toBe('https://app.example.com/callback')
349
+ expect(request).toHaveBeenCalledTimes(1)
350
+ expect(getAuthState(instance!).getCurrent()).toMatchObject({
351
+ type: AuthStateType.LOGGED_IN,
352
+ token: 'new-access',
353
+ })
354
+ expect(sessionStorage.getItem(OAUTH_STATE_KEY)).toBe('state-next')
355
+ expect(sessionStorage.getItem(OAUTH_VERIFIER_KEY)).toBe('verifier-next')
356
+ })
357
+
224
358
  it('surfaces an ?error= callback as ERROR without exchanging', async () => {
225
359
  const {request} = setup()
226
360
 
@@ -234,6 +368,25 @@ describe('handleOAuthCallback', () => {
234
368
  expect(getAuthState(instance!).getCurrent()).toMatchObject({type: AuthStateType.ERROR})
235
369
  })
236
370
 
371
+ it('ignores an ?error= callback when a session is already established', async () => {
372
+ setup({
373
+ storageSeed: {[OAUTH_TOKENS_KEY]: serializeTokens(seededTokens)},
374
+ sessionSeed: {[OAUTH_STATE_KEY]: 'state-next', [OAUTH_VERIFIER_KEY]: 'verifier-next'},
375
+ })
376
+
377
+ const result = await handleOAuthCallback(
378
+ instance!,
379
+ 'https://app.example.com/callback?error=access_denied',
380
+ )
381
+
382
+ expect(result).toBe('https://app.example.com/callback')
383
+ expect(getAuthState(instance!).getCurrent()).toMatchObject({
384
+ type: AuthStateType.LOGGED_IN,
385
+ token: seededTokens.accessToken,
386
+ })
387
+ expect(sessionStorage.getItem(OAUTH_STATE_KEY)).toBe('state-next')
388
+ })
389
+
237
390
  it('surfaces an ?error= callback without a description', async () => {
238
391
  setup()
239
392
  await handleOAuthCallback(instance!, 'https://app.example.com/callback?error=access_denied')
@@ -269,6 +422,18 @@ describe('handleOAuthCallback', () => {
269
422
  expect(request).not.toHaveBeenCalled()
270
423
  })
271
424
 
425
+ it('omits the resource param when no organizationId is configured', async () => {
426
+ const {request} = setup({
427
+ oauthConfig: oauthConfigNoOrg,
428
+ sessionSeed: {[OAUTH_STATE_KEY]: 'state-xyz', [OAUTH_VERIFIER_KEY]: 'verifier-1'},
429
+ })
430
+
431
+ await handleOAuthCallback(instance!, callbackHref)
432
+
433
+ const body = parseBody(request.mock.calls[0][0].body)
434
+ expect(body.has('resource')).toBe(false)
435
+ })
436
+
272
437
  it('sets ERROR when the token exchange fails', async () => {
273
438
  const request = vi.fn().mockRejectedValue(new Error('boom'))
274
439
  const {session} = setup({
@@ -303,6 +468,18 @@ describe('refreshOAuthTokens', () => {
303
468
  expect(readStored(storageArea)).toMatchObject({accessToken: 'new-access'})
304
469
  })
305
470
 
471
+ it('omits the resource param when no organizationId is configured', async () => {
472
+ const {request} = setup({
473
+ oauthConfig: oauthConfigNoOrg,
474
+ storageSeed: {[OAUTH_TOKENS_KEY]: serializeTokens(seededTokens)},
475
+ })
476
+
477
+ await refreshOAuthTokens(instance!)
478
+
479
+ const body = parseBody(request.mock.calls[0][0].body)
480
+ expect(body.has('resource')).toBe(false)
481
+ })
482
+
306
483
  it('shares a single in-flight request across concurrent callers', async () => {
307
484
  const pending = deferred<typeof tokenResponse>()
308
485
  const request = vi.fn().mockReturnValue(pending.promise)