@sanity/sdk 3.3.0-rc.1 → 3.3.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.
- package/dist/_exports/_internal.d.ts +13 -5
- package/dist/_exports/_internal.d.ts.map +1 -1
- package/dist/_exports/_internal.js +24 -6
- package/dist/_exports/_internal.js.map +1 -1
- package/dist/_exports/agent.d.ts +1 -1
- package/dist/_exports/agent.js +1 -1
- package/dist/_exports/comlink.d.ts +1 -1
- package/dist/_exports/dashboard.d.ts +1 -1
- package/dist/_exports/dashboard.js +1 -1
- package/dist/{authStore-7dZCfKUq.d.ts → applications-pRS8tuC2.d.ts} +350 -363
- package/dist/applications-pRS8tuC2.d.ts.map +1 -0
- package/dist/{clientStore-QDrJNTM4.js → clientStore-BI4D8HhJ.js} +10 -14
- package/dist/clientStore-BI4D8HhJ.js.map +1 -0
- package/dist/{createSanityInstance--Lrldto8.d.ts → createSanityInstance-BQIqyGLX.d.ts} +3 -6
- package/dist/createSanityInstance-BQIqyGLX.d.ts.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +142 -182
- package/dist/index.js.map +1 -1
- package/dist/{topicStore-Ct7IFNtY.js → topicStore-BJ54_X6H.js} +3 -1
- package/dist/topicStore-BJ54_X6H.js.map +1 -0
- package/dist/{topicStore-CAnJsiqI.d.ts → topicStore-D-jEwzlX.d.ts} +54 -25
- package/dist/topicStore-D-jEwzlX.d.ts.map +1 -0
- package/dist/{version-CYs30C7v.js → version-CD4aBigm.js} +3 -3
- package/dist/{version-CYs30C7v.js.map → version-CD4aBigm.js.map} +1 -1
- package/package.json +7 -7
- package/src/_exports/_internal.ts +2 -0
- package/src/applications/applicationOrigin.test.ts +51 -0
- package/src/applications/applicationOrigin.ts +26 -0
- package/src/auth/authStore.test.ts +0 -18
- package/src/auth/authStore.ts +1 -4
- package/src/auth/handleAuthCallback.test.ts +0 -33
- package/src/auth/handleAuthCallback.ts +1 -11
- package/src/auth/logout.test.ts +1 -9
- package/src/auth/logout.ts +1 -18
- package/src/auth/oauth/oauthActions.test.ts +1 -178
- package/src/auth/oauth/oauthActions.ts +46 -111
- package/src/auth/oauth/oauthAuth.test.ts +0 -23
- package/src/auth/oauth/oauthAuth.ts +8 -18
- package/src/config/authConfig.ts +2 -5
- package/src/dashboard/messageBus/bus.test.ts +39 -0
- package/src/dashboard/messageBus/bus.ts +32 -22
- package/src/dashboard/messageBus/topics.test.ts +36 -2
- package/src/dashboard/messageBus/topics.ts +51 -16
- package/src/installations/installations.test-d.ts +11 -0
- package/src/installations/installations.test.ts +6 -4
- package/src/installations/installations.ts +13 -2
- package/dist/authStore-7dZCfKUq.d.ts.map +0 -1
- package/dist/clientStore-QDrJNTM4.js.map +0 -1
- package/dist/createSanityInstance--Lrldto8.d.ts.map +0 -1
- package/dist/topicStore-CAnJsiqI.d.ts.map +0 -1
- package/dist/topicStore-Ct7IFNtY.js.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.3.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Sanity SDK",
|
|
6
6
|
"keywords": [
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@sanity/bifur-client": "^1.0.0",
|
|
48
|
-
"@sanity/client": "^8.6.
|
|
48
|
+
"@sanity/client": "^8.6.2",
|
|
49
49
|
"@sanity/comlink": "^4.0.3",
|
|
50
50
|
"@sanity/diff-match-patch": "^3.2.0",
|
|
51
51
|
"@sanity/diff-patch": "^6.0.0",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"@sanity/message-protocol": "^0.24.0",
|
|
56
56
|
"@sanity/mutate": "^0.18.2",
|
|
57
57
|
"@sanity/telemetry": "^1.1.0",
|
|
58
|
-
"@sanity/types": "^6.
|
|
58
|
+
"@sanity/types": "^6.14.1",
|
|
59
59
|
"groq": "^6.13.2",
|
|
60
60
|
"groq-js": "^2.0.0",
|
|
61
61
|
"reselect": "^5.1.1",
|
|
@@ -70,14 +70,14 @@
|
|
|
70
70
|
"@repo/tsconfig": "0.0.1",
|
|
71
71
|
"@sanity/browserslist-config": "^1.0.5",
|
|
72
72
|
"@sanity/pkg-utils": "^13.0.0",
|
|
73
|
-
"@types/node": "^24.13.
|
|
74
|
-
"@vitest/coverage-v8": "^5.0.
|
|
73
|
+
"@types/node": "^24.13.5",
|
|
74
|
+
"@vitest/coverage-v8": "^5.0.1",
|
|
75
75
|
"eslint": "^10.10.0",
|
|
76
|
-
"oxfmt": "^0.
|
|
76
|
+
"oxfmt": "^0.68.0",
|
|
77
77
|
"rolldown": "^1.2.8",
|
|
78
78
|
"typescript": "^6.0.3",
|
|
79
79
|
"vite": "^8.3.0",
|
|
80
|
-
"vitest": "^5.0.
|
|
80
|
+
"vitest": "^5.0.1"
|
|
81
81
|
},
|
|
82
82
|
"browserslist": "extends @sanity/browserslist-config",
|
|
83
83
|
"scripts": {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export {getApplicationOrigin} from '../applications/applicationOrigin'
|
|
1
2
|
export {isStudioConfig} from '../auth/authMode'
|
|
2
3
|
export {
|
|
3
4
|
type ApiErrorBody,
|
|
@@ -17,6 +18,7 @@ export {
|
|
|
17
18
|
isDashboardEnvironment,
|
|
18
19
|
requireDashboardMessageBus,
|
|
19
20
|
} from '../dashboard/messageBus/store'
|
|
21
|
+
export type {ApplicationStatus, ApplicationStatusUpdate} from '../dashboard/messageBus/topics'
|
|
20
22
|
export {getTopicState, resolveTopic} from '../dashboard/messageBus/topicStore'
|
|
21
23
|
export {PREVIEW_PROJECTION} from '../preview/previewConstants'
|
|
22
24
|
export {transformProjectionToPreview} from '../preview/previewProjectionUtils'
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import {afterEach, describe, expect, it, vi} from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {getApplicationOrigin} from './applicationOrigin'
|
|
4
|
+
import {type ApplicationBase} from './applications'
|
|
5
|
+
|
|
6
|
+
const application: ApplicationBase = {
|
|
7
|
+
id: 'application-1',
|
|
8
|
+
type: 'coreApp',
|
|
9
|
+
title: 'Inbox',
|
|
10
|
+
name: 'inbox',
|
|
11
|
+
reference: 'sanity/inbox',
|
|
12
|
+
icon: null,
|
|
13
|
+
isSingleton: true,
|
|
14
|
+
visibility: 'default',
|
|
15
|
+
slug: 'inbox',
|
|
16
|
+
externalUrl: null,
|
|
17
|
+
organizationId: 'organization-1',
|
|
18
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
19
|
+
updatedAt: '2026-01-02T00:00:00.000Z',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('getApplicationOrigin', () => {
|
|
23
|
+
afterEach(() => vi.unstubAllGlobals())
|
|
24
|
+
|
|
25
|
+
it('derives singleton and organization origins from the slug', () => {
|
|
26
|
+
expect(getApplicationOrigin(application)).toBe('https://inbox-apps-organization-1.sanity.run')
|
|
27
|
+
expect(getApplicationOrigin({...application, isSingleton: false, slug: 'canvas'})).toBe(
|
|
28
|
+
'https://canvas.sanity.studio',
|
|
29
|
+
)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('uses the staging domains when the host sets the runtime flag', () => {
|
|
33
|
+
vi.stubGlobal('__SANITY_STAGING__', true)
|
|
34
|
+
expect(getApplicationOrigin(application)).toBe(
|
|
35
|
+
'https://inbox-apps-organization-1.run.sanity.work',
|
|
36
|
+
)
|
|
37
|
+
expect(getApplicationOrigin({...application, isSingleton: false})).toBe(
|
|
38
|
+
'https://inbox.studio.sanity.work',
|
|
39
|
+
)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('prefers the external URL origin and returns null without slug or URL', () => {
|
|
43
|
+
expect(
|
|
44
|
+
getApplicationOrigin({
|
|
45
|
+
...application,
|
|
46
|
+
externalUrl: 'https://apps.example.com/external/index.html',
|
|
47
|
+
}),
|
|
48
|
+
).toBe('https://apps.example.com')
|
|
49
|
+
expect(getApplicationOrigin({...application, slug: null})).toBeNull()
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {type ApplicationBase} from './applications'
|
|
2
|
+
|
|
3
|
+
type SanityGlobal = typeof globalThis & {__SANITY_STAGING__?: boolean}
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The origin an application is served from, or `null` when it has neither an external URL nor
|
|
7
|
+
* a slug to derive one from. Callers treat `null` as "nothing to load" rather than failing the
|
|
8
|
+
* whole list on one bad record.
|
|
9
|
+
*
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
export function getApplicationOrigin(application: ApplicationBase): string | null {
|
|
13
|
+
if (application.externalUrl !== null) return new URL(application.externalUrl).origin
|
|
14
|
+
if (application.slug === null) return null
|
|
15
|
+
|
|
16
|
+
// Read at runtime, not via a bundler define: a remote is built once and runs in whichever
|
|
17
|
+
// host page loaded it, and the host sets this flag. Mirrors workbench's `getSanityEnv`.
|
|
18
|
+
const staging = (globalThis as SanityGlobal).__SANITY_STAGING__ === true
|
|
19
|
+
if (application.isSingleton) {
|
|
20
|
+
const domain = staging ? 'run.sanity.work' : 'sanity.run'
|
|
21
|
+
return `https://${application.slug}-apps-${application.organizationId}.${domain}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const domain = staging ? 'studio.sanity.work' : 'sanity.studio'
|
|
25
|
+
return `https://${application.slug}.${domain}`
|
|
26
|
+
}
|
|
@@ -339,24 +339,6 @@ 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
|
-
|
|
360
342
|
it('logs auth initialized in logged out state when no token available', () => {
|
|
361
343
|
instance = createSanityInstance({
|
|
362
344
|
projectId: 'p',
|
package/src/auth/authStore.ts
CHANGED
|
@@ -191,10 +191,7 @@ export const authStore = defineStore<AuthStoreState>({
|
|
|
191
191
|
storageKey: result.storageKey,
|
|
192
192
|
storageArea: result.storageArea,
|
|
193
193
|
authMethod: result.authMethod,
|
|
194
|
-
|
|
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,
|
|
194
|
+
oauth: authConfig.oauth,
|
|
198
195
|
},
|
|
199
196
|
}
|
|
200
197
|
},
|
|
@@ -5,7 +5,6 @@ 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'
|
|
9
8
|
import {subscribeToStateAndFetchCurrentUser} from './subscribeToStateAndFetchCurrentUser'
|
|
10
9
|
import {subscribeToStorageEventsAndSetToken} from './subscribeToStorageEventsAndSetToken'
|
|
11
10
|
import {getAuthCode, getTokenFromLocation, getTokenFromStorage} from './utils'
|
|
@@ -23,11 +22,6 @@ vi.mock('./utils', async (importOriginal) => {
|
|
|
23
22
|
vi.mock('./subscribeToStateAndFetchCurrentUser')
|
|
24
23
|
vi.mock('./subscribeToStorageEventsAndSetToken')
|
|
25
24
|
|
|
26
|
-
vi.mock('./oauth/oauthActions', async (importOriginal) => {
|
|
27
|
-
const original = await importOriginal<typeof import('./oauth/oauthActions')>()
|
|
28
|
-
return {...original, handleOAuthCallback: vi.fn()}
|
|
29
|
-
})
|
|
30
|
-
|
|
31
25
|
// Mock logger to prevent actual logging during tests
|
|
32
26
|
vi.mock('../utils/logger', async (importOriginal) => {
|
|
33
27
|
const original = await importOriginal<typeof import('../utils/logger')>()
|
|
@@ -283,31 +277,4 @@ describe('handleCallback', () => {
|
|
|
283
277
|
// Verify logger was called
|
|
284
278
|
expect(createLogger).toHaveBeenCalled()
|
|
285
279
|
})
|
|
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
|
-
})
|
|
313
280
|
})
|
|
@@ -3,7 +3,6 @@ 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'
|
|
7
6
|
import {
|
|
8
7
|
createLoggedInAuthState,
|
|
9
8
|
getAuthCode,
|
|
@@ -13,10 +12,6 @@ import {
|
|
|
13
12
|
} from './utils'
|
|
14
13
|
|
|
15
14
|
/**
|
|
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
|
-
*
|
|
20
15
|
* @public
|
|
21
16
|
*/
|
|
22
17
|
export const handleAuthCallback = bindActionGlobally(
|
|
@@ -24,7 +19,7 @@ export const handleAuthCallback = bindActionGlobally(
|
|
|
24
19
|
async ({state, instance}, locationHref: string = getDefaultLocation()) => {
|
|
25
20
|
const logger = getAuthLogger(instance)
|
|
26
21
|
|
|
27
|
-
const {providedToken, callbackUrl, clientFactory, apiHost, storageArea, storageKey
|
|
22
|
+
const {providedToken, callbackUrl, clientFactory, apiHost, storageArea, storageKey} =
|
|
28
23
|
state.get().options
|
|
29
24
|
|
|
30
25
|
// If a token is provided, no need to handle callback
|
|
@@ -33,11 +28,6 @@ export const handleAuthCallback = bindActionGlobally(
|
|
|
33
28
|
return false
|
|
34
29
|
}
|
|
35
30
|
|
|
36
|
-
if (oauth) {
|
|
37
|
-
logger.debug('OAuth mode - delegating to handleOAuthCallback')
|
|
38
|
-
return handleOAuthCallback(instance, locationHref)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
31
|
// Don't handle the callback if already in flight.
|
|
42
32
|
const {authState} = state.get()
|
|
43
33
|
if (authState.type === AuthStateType.LOGGING_IN && authState.isExchangingToken) {
|
package/src/auth/logout.test.ts
CHANGED
|
@@ -169,7 +169,7 @@ describe('logout', () => {
|
|
|
169
169
|
expect(removeItem).toHaveBeenCalledWith('__sanity_auth_token')
|
|
170
170
|
})
|
|
171
171
|
|
|
172
|
-
it('
|
|
172
|
+
it('clears the OAuth tokens from the store on logout', async () => {
|
|
173
173
|
const oauthTokens: OAuthTokens = {
|
|
174
174
|
accessToken: 'oauth-access',
|
|
175
175
|
tokenType: 'bearer',
|
|
@@ -204,16 +204,8 @@ 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})
|
|
210
207
|
await logout(instance)
|
|
211
|
-
await first
|
|
212
208
|
|
|
213
|
-
expect(mockRequest).toHaveBeenCalledTimes(1)
|
|
214
|
-
expect(mockRequest).toHaveBeenCalledWith(
|
|
215
|
-
expect.objectContaining({url: '/auth/oauth/revoke', tag: 'oauth.revoke'}),
|
|
216
|
-
)
|
|
217
209
|
expect(getOAuthTokensState(instance).getCurrent()).toBeNull()
|
|
218
210
|
expect(getAuthState(instance).getCurrent()).toMatchObject({type: AuthStateType.LOGGED_OUT})
|
|
219
211
|
expect(map.get(OAUTH_TOKENS_KEY)).toBeUndefined()
|
package/src/auth/logout.ts
CHANGED
|
@@ -3,22 +3,14 @@ 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'
|
|
7
6
|
|
|
8
7
|
/**
|
|
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
|
-
*
|
|
15
8
|
* @public
|
|
16
9
|
*/
|
|
17
10
|
export const logout = bindActionGlobally(authStore, async ({state, instance}) => {
|
|
18
11
|
const logger = getAuthLogger(instance)
|
|
19
12
|
|
|
20
|
-
const {clientFactory, apiHost, providedToken, storageArea, storageKey
|
|
21
|
-
state.get().options
|
|
13
|
+
const {clientFactory, apiHost, providedToken, storageArea, storageKey} = state.get().options
|
|
22
14
|
|
|
23
15
|
// If a token is statically provided, logout does nothing
|
|
24
16
|
if (providedToken) {
|
|
@@ -33,15 +25,6 @@ export const logout = bindActionGlobally(authStore, async ({state, instance}) =>
|
|
|
33
25
|
logger.debug('Skipping logout - already in progress')
|
|
34
26
|
return
|
|
35
27
|
}
|
|
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
|
-
}
|
|
45
28
|
const token = authState.type === AuthStateType.LOGGED_IN && authState.token
|
|
46
29
|
|
|
47
30
|
try {
|
|
@@ -9,7 +9,6 @@ import {subscribeToStateAndFetchCurrentUser} from '../subscribeToStateAndFetchCu
|
|
|
9
9
|
import {
|
|
10
10
|
getOAuthTokensState,
|
|
11
11
|
handleOAuthCallback,
|
|
12
|
-
OAUTH_RETURN_TO_KEY,
|
|
13
12
|
OAUTH_STATE_KEY,
|
|
14
13
|
OAUTH_VERIFIER_KEY,
|
|
15
14
|
refreshOAuthTokens,
|
|
@@ -43,11 +42,6 @@ const oauthConfig = {
|
|
|
43
42
|
organizationId: 'org123',
|
|
44
43
|
}
|
|
45
44
|
|
|
46
|
-
const oauthConfigNoOrg = {
|
|
47
|
-
clientId: 'client-abc',
|
|
48
|
-
redirectUri: 'https://app.example.com/callback',
|
|
49
|
-
}
|
|
50
|
-
|
|
51
45
|
const seededTokens: OAuthTokens = {
|
|
52
46
|
accessToken: 'stored-access',
|
|
53
47
|
tokenType: 'bearer',
|
|
@@ -99,7 +93,6 @@ interface SetupOptions {
|
|
|
99
93
|
sessionSeed?: Record<string, string>
|
|
100
94
|
initialLocationHref?: string
|
|
101
95
|
withOAuthConfig?: boolean
|
|
102
|
-
oauthConfig?: {clientId: string; redirectUri: string; organizationId?: string}
|
|
103
96
|
apiHost?: string
|
|
104
97
|
}
|
|
105
98
|
|
|
@@ -118,7 +111,7 @@ function setup(options: SetupOptions = {}) {
|
|
|
118
111
|
storageArea,
|
|
119
112
|
initialLocationHref: options.initialLocationHref ?? 'https://app.example.com/',
|
|
120
113
|
...(options.apiHost && {apiHost: options.apiHost}),
|
|
121
|
-
...(options.withOAuthConfig === false ? {} : {oauth:
|
|
114
|
+
...(options.withOAuthConfig === false ? {} : {oauth: oauthConfig}),
|
|
122
115
|
},
|
|
123
116
|
})
|
|
124
117
|
|
|
@@ -163,39 +156,6 @@ describe('startOAuthAuthorization', () => {
|
|
|
163
156
|
expect(url.searchParams.getAll('resource')).toEqual(['urn:io.sanity:organization:org123'])
|
|
164
157
|
})
|
|
165
158
|
|
|
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
|
-
|
|
199
159
|
it('throws when OAuth is not configured', async () => {
|
|
200
160
|
setup({withOAuthConfig: false})
|
|
201
161
|
await expect(startOAuthAuthorization(instance!)).rejects.toThrow(/OAuth is not configured/)
|
|
@@ -243,75 +203,6 @@ describe('handleOAuthCallback', () => {
|
|
|
243
203
|
})
|
|
244
204
|
})
|
|
245
205
|
|
|
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
|
-
|
|
315
206
|
it('does not perform a second exchange on a duplicate concurrent invocation', async () => {
|
|
316
207
|
const pending = deferred<typeof tokenResponse>()
|
|
317
208
|
const request = vi.fn().mockReturnValue(pending.promise)
|
|
@@ -330,31 +221,6 @@ describe('handleOAuthCallback', () => {
|
|
|
330
221
|
expect(await first).toBe('https://app.example.com/callback')
|
|
331
222
|
})
|
|
332
223
|
|
|
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
|
-
|
|
358
224
|
it('surfaces an ?error= callback as ERROR without exchanging', async () => {
|
|
359
225
|
const {request} = setup()
|
|
360
226
|
|
|
@@ -368,25 +234,6 @@ describe('handleOAuthCallback', () => {
|
|
|
368
234
|
expect(getAuthState(instance!).getCurrent()).toMatchObject({type: AuthStateType.ERROR})
|
|
369
235
|
})
|
|
370
236
|
|
|
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
|
-
|
|
390
237
|
it('surfaces an ?error= callback without a description', async () => {
|
|
391
238
|
setup()
|
|
392
239
|
await handleOAuthCallback(instance!, 'https://app.example.com/callback?error=access_denied')
|
|
@@ -422,18 +269,6 @@ describe('handleOAuthCallback', () => {
|
|
|
422
269
|
expect(request).not.toHaveBeenCalled()
|
|
423
270
|
})
|
|
424
271
|
|
|
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
|
-
|
|
437
272
|
it('sets ERROR when the token exchange fails', async () => {
|
|
438
273
|
const request = vi.fn().mockRejectedValue(new Error('boom'))
|
|
439
274
|
const {session} = setup({
|
|
@@ -468,18 +303,6 @@ describe('refreshOAuthTokens', () => {
|
|
|
468
303
|
expect(readStored(storageArea)).toMatchObject({accessToken: 'new-access'})
|
|
469
304
|
})
|
|
470
305
|
|
|
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
|
-
|
|
483
306
|
it('shares a single in-flight request across concurrent callers', async () => {
|
|
484
307
|
const pending = deferred<typeof tokenResponse>()
|
|
485
308
|
const request = vi.fn().mockReturnValue(pending.promise)
|