@sanity/sdk 2.5.0 → 2.7.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/index.d.ts +429 -27
- package/dist/index.js +657 -266
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- package/src/_exports/index.ts +18 -3
- package/src/auth/authMode.test.ts +56 -0
- package/src/auth/authMode.ts +71 -0
- package/src/auth/authStore.test.ts +85 -4
- package/src/auth/authStore.ts +63 -125
- package/src/auth/authStrategy.ts +39 -0
- package/src/auth/dashboardAuth.ts +132 -0
- package/src/auth/standaloneAuth.ts +109 -0
- package/src/auth/studioAuth.ts +217 -0
- package/src/auth/studioModeAuth.test.ts +43 -1
- package/src/auth/studioModeAuth.ts +10 -1
- package/src/auth/subscribeToStateAndFetchCurrentUser.ts +21 -6
- package/src/client/clientStore.test.ts +45 -43
- package/src/client/clientStore.ts +23 -9
- package/src/config/loggingConfig.ts +149 -0
- package/src/config/sanityConfig.ts +82 -22
- package/src/projection/getProjectionState.ts +6 -5
- package/src/projection/projectionQuery.test.ts +38 -55
- package/src/projection/projectionQuery.ts +27 -31
- package/src/projection/projectionStore.test.ts +4 -4
- package/src/projection/projectionStore.ts +3 -2
- package/src/projection/resolveProjection.ts +2 -2
- package/src/projection/statusQuery.test.ts +35 -0
- package/src/projection/statusQuery.ts +71 -0
- package/src/projection/subscribeToStateAndFetchBatches.test.ts +63 -50
- package/src/projection/subscribeToStateAndFetchBatches.ts +106 -27
- package/src/projection/types.ts +12 -0
- package/src/projection/util.ts +0 -1
- package/src/query/queryStore.test.ts +64 -0
- package/src/query/queryStore.ts +33 -11
- package/src/releases/getPerspectiveState.test.ts +17 -14
- package/src/releases/getPerspectiveState.ts +58 -38
- package/src/releases/releasesStore.test.ts +59 -61
- package/src/releases/releasesStore.ts +21 -35
- package/src/releases/utils/isReleasePerspective.ts +7 -0
- package/src/store/createActionBinder.test.ts +211 -1
- package/src/store/createActionBinder.ts +102 -13
- package/src/store/createSanityInstance.test.ts +85 -1
- package/src/store/createSanityInstance.ts +55 -4
- package/src/utils/logger-usage-example.md +141 -0
- package/src/utils/logger.test.ts +757 -0
- package/src/utils/logger.ts +537 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import {type Subscription} from 'rxjs'
|
|
2
|
+
|
|
3
|
+
import {type StoreContext} from '../store/defineStore'
|
|
4
|
+
import {DEFAULT_BASE} from './authConstants'
|
|
5
|
+
import {AuthStateType} from './authStateType'
|
|
6
|
+
import {type AuthStoreState, type DashboardContext} from './authStore'
|
|
7
|
+
import {type AuthStrategyOptions, type AuthStrategyResult} from './authStrategy'
|
|
8
|
+
import {refreshStampedToken} from './refreshStampedToken'
|
|
9
|
+
import {subscribeToStateAndFetchCurrentUser} from './subscribeToStateAndFetchCurrentUser'
|
|
10
|
+
import {getAuthCode, getTokenFromLocation} from './utils'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parses the dashboard context from a location href's `_context` URL parameter.
|
|
14
|
+
* Strips the `sid` property from the parsed context (it's handled separately).
|
|
15
|
+
*
|
|
16
|
+
* @internal
|
|
17
|
+
*/
|
|
18
|
+
function parseDashboardContext(locationHref: string): DashboardContext {
|
|
19
|
+
try {
|
|
20
|
+
const parsedUrl = new URL(locationHref, DEFAULT_BASE)
|
|
21
|
+
const contextParam = parsedUrl.searchParams.get('_context')
|
|
22
|
+
if (contextParam) {
|
|
23
|
+
const parsedContext = JSON.parse(contextParam)
|
|
24
|
+
|
|
25
|
+
if (
|
|
26
|
+
parsedContext &&
|
|
27
|
+
typeof parsedContext === 'object' &&
|
|
28
|
+
!Array.isArray(parsedContext) &&
|
|
29
|
+
Object.keys(parsedContext).length > 0
|
|
30
|
+
) {
|
|
31
|
+
// Explicitly remove the 'sid' property before assigning
|
|
32
|
+
delete parsedContext.sid
|
|
33
|
+
return parsedContext as DashboardContext
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
} catch (err) {
|
|
37
|
+
// eslint-disable-next-line no-console
|
|
38
|
+
console.error('Failed to parse dashboard context from initial location:', err)
|
|
39
|
+
}
|
|
40
|
+
return {} // Empty dashboard context if parsing fails
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolves the initial auth state for Dashboard mode.
|
|
45
|
+
*
|
|
46
|
+
* In dashboard mode the token is provided by the parent frame via Comlink,
|
|
47
|
+
* so the SDK starts in a `LOGGED_OUT` state and waits for the token to arrive.
|
|
48
|
+
* The `_context` URL parameter provides dashboard metadata (orgId, mode, env).
|
|
49
|
+
*
|
|
50
|
+
* A provided token (`auth.token`) or an auth code/callback still takes
|
|
51
|
+
* precedence, even when running inside the dashboard.
|
|
52
|
+
*
|
|
53
|
+
* @internal
|
|
54
|
+
*/
|
|
55
|
+
export function getDashboardInitialState(options: AuthStrategyOptions): AuthStrategyResult {
|
|
56
|
+
const {authConfig, initialLocationHref} = options
|
|
57
|
+
const providedToken = authConfig.token
|
|
58
|
+
const callbackUrl = authConfig.callbackUrl
|
|
59
|
+
const storageKey = '__sanity_auth_token'
|
|
60
|
+
|
|
61
|
+
const dashboardContext = parseDashboardContext(initialLocationHref)
|
|
62
|
+
|
|
63
|
+
// Dashboard does NOT use localStorage — token comes from the parent frame
|
|
64
|
+
const storageArea = undefined
|
|
65
|
+
|
|
66
|
+
// Provided token always wins, even in dashboard
|
|
67
|
+
if (providedToken) {
|
|
68
|
+
return {
|
|
69
|
+
authState: {type: AuthStateType.LOGGED_IN, token: providedToken, currentUser: null},
|
|
70
|
+
storageKey,
|
|
71
|
+
storageArea,
|
|
72
|
+
authMethod: undefined,
|
|
73
|
+
dashboardContext,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Check for auth code or token-from-location (callback handling)
|
|
78
|
+
if (getAuthCode(callbackUrl, initialLocationHref) || getTokenFromLocation(initialLocationHref)) {
|
|
79
|
+
return {
|
|
80
|
+
authState: {type: AuthStateType.LOGGING_IN, isExchangingToken: false},
|
|
81
|
+
storageKey,
|
|
82
|
+
storageArea,
|
|
83
|
+
authMethod: undefined,
|
|
84
|
+
dashboardContext,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Default: logged out, waiting for Comlink to provide token
|
|
89
|
+
return {
|
|
90
|
+
authState: {type: AuthStateType.LOGGED_OUT, isDestroyingSession: false},
|
|
91
|
+
storageKey,
|
|
92
|
+
storageArea,
|
|
93
|
+
authMethod: undefined,
|
|
94
|
+
dashboardContext,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Initialize Dashboard auth subscriptions:
|
|
100
|
+
* - Subscribe to state changes and fetch current user
|
|
101
|
+
* - Start stamped token refresh
|
|
102
|
+
*
|
|
103
|
+
* Note: storage events are NOT subscribed in dashboard mode since
|
|
104
|
+
* the dashboard does not use localStorage for token persistence.
|
|
105
|
+
*
|
|
106
|
+
* @internal
|
|
107
|
+
*/
|
|
108
|
+
export function initializeDashboardAuth(
|
|
109
|
+
context: StoreContext<AuthStoreState>,
|
|
110
|
+
tokenRefresherRunning: boolean,
|
|
111
|
+
): {dispose: () => void; tokenRefresherStarted: boolean} {
|
|
112
|
+
const subscriptions: Subscription[] = []
|
|
113
|
+
let startedRefresher = false
|
|
114
|
+
|
|
115
|
+
subscriptions.push(subscribeToStateAndFetchCurrentUser(context, {useProjectHostname: false}))
|
|
116
|
+
|
|
117
|
+
// Dashboard does not subscribe to storage events
|
|
118
|
+
|
|
119
|
+
if (!tokenRefresherRunning) {
|
|
120
|
+
startedRefresher = true
|
|
121
|
+
subscriptions.push(refreshStampedToken(context))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
dispose: () => {
|
|
126
|
+
for (const subscription of subscriptions) {
|
|
127
|
+
subscription.unsubscribe()
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
tokenRefresherStarted: startedRefresher,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import {type Subscription} from 'rxjs'
|
|
2
|
+
|
|
3
|
+
import {type StoreContext} from '../store/defineStore'
|
|
4
|
+
import {AuthStateType} from './authStateType'
|
|
5
|
+
import {type AuthStoreState} from './authStore'
|
|
6
|
+
import {type AuthStrategyOptions, type AuthStrategyResult} from './authStrategy'
|
|
7
|
+
import {refreshStampedToken} from './refreshStampedToken'
|
|
8
|
+
import {subscribeToStateAndFetchCurrentUser} from './subscribeToStateAndFetchCurrentUser'
|
|
9
|
+
import {subscribeToStorageEventsAndSetToken} from './subscribeToStorageEventsAndSetToken'
|
|
10
|
+
import {getAuthCode, getDefaultStorage, getTokenFromLocation, getTokenFromStorage} from './utils'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolves the initial auth state for Standalone mode.
|
|
14
|
+
*
|
|
15
|
+
* Token discovery order:
|
|
16
|
+
* 1. Provided token (`auth.token` in config)
|
|
17
|
+
* 2. Auth code or token from location (OAuth callback)
|
|
18
|
+
* 3. localStorage: `__sanity_auth_token`
|
|
19
|
+
* 4. Falls back to `LOGGED_OUT`
|
|
20
|
+
*
|
|
21
|
+
* @internal
|
|
22
|
+
*/
|
|
23
|
+
export function getStandaloneInitialState(options: AuthStrategyOptions): AuthStrategyResult {
|
|
24
|
+
const {authConfig, initialLocationHref} = options
|
|
25
|
+
const providedToken = authConfig.token
|
|
26
|
+
const callbackUrl = authConfig.callbackUrl
|
|
27
|
+
const storageKey = '__sanity_auth_token'
|
|
28
|
+
const storageArea = authConfig.storageArea ?? getDefaultStorage()
|
|
29
|
+
|
|
30
|
+
// Provided token always wins
|
|
31
|
+
if (providedToken) {
|
|
32
|
+
return {
|
|
33
|
+
authState: {type: AuthStateType.LOGGED_IN, token: providedToken, currentUser: null},
|
|
34
|
+
storageKey,
|
|
35
|
+
storageArea,
|
|
36
|
+
authMethod: undefined,
|
|
37
|
+
dashboardContext: {},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Check for auth code or token-from-location (OAuth callback)
|
|
42
|
+
if (getAuthCode(callbackUrl, initialLocationHref) || getTokenFromLocation(initialLocationHref)) {
|
|
43
|
+
return {
|
|
44
|
+
authState: {type: AuthStateType.LOGGING_IN, isExchangingToken: false},
|
|
45
|
+
storageKey,
|
|
46
|
+
storageArea,
|
|
47
|
+
authMethod: undefined,
|
|
48
|
+
dashboardContext: {},
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Try localStorage
|
|
53
|
+
const token = getTokenFromStorage(storageArea, storageKey)
|
|
54
|
+
if (token) {
|
|
55
|
+
return {
|
|
56
|
+
authState: {type: AuthStateType.LOGGED_IN, token, currentUser: null},
|
|
57
|
+
storageKey,
|
|
58
|
+
storageArea,
|
|
59
|
+
authMethod: 'localstorage',
|
|
60
|
+
dashboardContext: {},
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// No token found
|
|
65
|
+
return {
|
|
66
|
+
authState: {type: AuthStateType.LOGGED_OUT, isDestroyingSession: false},
|
|
67
|
+
storageKey,
|
|
68
|
+
storageArea,
|
|
69
|
+
authMethod: undefined,
|
|
70
|
+
dashboardContext: {},
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Initialize Standalone auth subscriptions:
|
|
76
|
+
* - Subscribe to state changes and fetch current user
|
|
77
|
+
* - Subscribe to storage events for token key
|
|
78
|
+
* - Start stamped token refresh
|
|
79
|
+
*
|
|
80
|
+
* @internal
|
|
81
|
+
*/
|
|
82
|
+
export function initializeStandaloneAuth(
|
|
83
|
+
context: StoreContext<AuthStoreState>,
|
|
84
|
+
tokenRefresherRunning: boolean,
|
|
85
|
+
): {dispose: () => void; tokenRefresherStarted: boolean} {
|
|
86
|
+
const subscriptions: Subscription[] = []
|
|
87
|
+
let startedRefresher = false
|
|
88
|
+
|
|
89
|
+
subscriptions.push(subscribeToStateAndFetchCurrentUser(context, {useProjectHostname: false}))
|
|
90
|
+
|
|
91
|
+
const storageArea = context.state.get().options?.storageArea
|
|
92
|
+
if (storageArea) {
|
|
93
|
+
subscriptions.push(subscribeToStorageEventsAndSetToken(context))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!tokenRefresherRunning) {
|
|
97
|
+
startedRefresher = true
|
|
98
|
+
subscriptions.push(refreshStampedToken(context))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
dispose: () => {
|
|
103
|
+
for (const subscription of subscriptions) {
|
|
104
|
+
subscription.unsubscribe()
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
tokenRefresherStarted: startedRefresher,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import {type Subscription} from 'rxjs'
|
|
2
|
+
|
|
3
|
+
import {type TokenSource} from '../config/sanityConfig'
|
|
4
|
+
import {type StoreContext} from '../store/defineStore'
|
|
5
|
+
import {AuthStateType} from './authStateType'
|
|
6
|
+
import {type AuthStoreState, type LoggedInAuthState} from './authStore'
|
|
7
|
+
import {type AuthStrategyOptions, type AuthStrategyResult} from './authStrategy'
|
|
8
|
+
import {refreshStampedToken} from './refreshStampedToken'
|
|
9
|
+
import {checkForCookieAuth, getStudioTokenFromLocalStorage} from './studioModeAuth'
|
|
10
|
+
import {subscribeToStateAndFetchCurrentUser} from './subscribeToStateAndFetchCurrentUser'
|
|
11
|
+
import {subscribeToStorageEventsAndSetToken} from './subscribeToStorageEventsAndSetToken'
|
|
12
|
+
import {getDefaultStorage} from './utils'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolves the initial auth state for Studio mode.
|
|
16
|
+
*
|
|
17
|
+
* When a `tokenSource` is provided (via `config.studio.auth.token`), the
|
|
18
|
+
* initial state is `LOGGING_IN` — the actual token arrives asynchronously
|
|
19
|
+
* via the subscription set up in `initializeStudioAuth`.
|
|
20
|
+
*
|
|
21
|
+
* Fallback token discovery order (no tokenSource):
|
|
22
|
+
* 1. Provided token (`auth.token` in config)
|
|
23
|
+
* 2. localStorage: `__studio_auth_token_${projectId}`
|
|
24
|
+
* 3. Falls back to `LOGGED_OUT` (cookie auth is checked async in `initializeStudioAuth`)
|
|
25
|
+
*
|
|
26
|
+
* @internal
|
|
27
|
+
*/
|
|
28
|
+
export function getStudioInitialState(options: AuthStrategyOptions): AuthStrategyResult {
|
|
29
|
+
const {authConfig, projectId, tokenSource} = options
|
|
30
|
+
const storageArea = authConfig.storageArea ?? getDefaultStorage()
|
|
31
|
+
const studioStorageKey = `__studio_auth_token_${projectId ?? ''}`
|
|
32
|
+
|
|
33
|
+
// When a reactive token source is available, start in LOGGING_IN state.
|
|
34
|
+
// The actual token will arrive via subscription in initializeStudioAuth.
|
|
35
|
+
if (tokenSource) {
|
|
36
|
+
return {
|
|
37
|
+
authState: {type: AuthStateType.LOGGING_IN, isExchangingToken: false},
|
|
38
|
+
storageKey: studioStorageKey,
|
|
39
|
+
storageArea,
|
|
40
|
+
authMethod: undefined,
|
|
41
|
+
dashboardContext: {},
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Fallback: discover token from config or localStorage
|
|
46
|
+
const providedToken = authConfig.token
|
|
47
|
+
|
|
48
|
+
// Check localStorage first — mirrors original authStore behavior where
|
|
49
|
+
// the localStorage read always runs before the providedToken check.
|
|
50
|
+
let authMethod: AuthStrategyResult['authMethod'] = undefined
|
|
51
|
+
const token = getStudioTokenFromLocalStorage(storageArea, studioStorageKey)
|
|
52
|
+
if (token) {
|
|
53
|
+
authMethod = 'localstorage'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (providedToken) {
|
|
57
|
+
return {
|
|
58
|
+
authState: {type: AuthStateType.LOGGED_IN, token: providedToken, currentUser: null},
|
|
59
|
+
storageKey: studioStorageKey,
|
|
60
|
+
storageArea,
|
|
61
|
+
authMethod,
|
|
62
|
+
dashboardContext: {},
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (token) {
|
|
67
|
+
return {
|
|
68
|
+
authState: {type: AuthStateType.LOGGED_IN, token, currentUser: null},
|
|
69
|
+
storageKey: studioStorageKey,
|
|
70
|
+
storageArea,
|
|
71
|
+
authMethod: 'localstorage',
|
|
72
|
+
dashboardContext: {},
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// No token found — start logged out, cookie auth will be checked asynchronously
|
|
77
|
+
return {
|
|
78
|
+
authState: {type: AuthStateType.LOGGED_OUT, isDestroyingSession: false},
|
|
79
|
+
storageKey: studioStorageKey,
|
|
80
|
+
storageArea,
|
|
81
|
+
authMethod: undefined,
|
|
82
|
+
dashboardContext: {},
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Initialize Studio auth subscriptions.
|
|
88
|
+
*
|
|
89
|
+
* When a `tokenSource` is available (reactive path), subscribes to it for
|
|
90
|
+
* ongoing token sync. The Studio is the token authority — no independent
|
|
91
|
+
* token refresh or cookie auth probing is needed.
|
|
92
|
+
*
|
|
93
|
+
* Fallback path (no tokenSource):
|
|
94
|
+
* - Subscribe to state changes and fetch current user
|
|
95
|
+
* - Subscribe to storage events for studio token key
|
|
96
|
+
* - Check for cookie auth asynchronously if no token was found
|
|
97
|
+
* - Start stamped token refresh
|
|
98
|
+
*
|
|
99
|
+
* @internal
|
|
100
|
+
*/
|
|
101
|
+
export function initializeStudioAuth(
|
|
102
|
+
context: StoreContext<AuthStoreState>,
|
|
103
|
+
tokenRefresherRunning: boolean,
|
|
104
|
+
): {dispose: () => void; tokenRefresherStarted: boolean} {
|
|
105
|
+
const tokenSource = context.instance.config.studio?.auth?.token
|
|
106
|
+
|
|
107
|
+
// Reactive token path — Studio provides the token
|
|
108
|
+
if (tokenSource) {
|
|
109
|
+
return initializeWithTokenSource(context, tokenSource)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Fallback path — discover token from localStorage/cookies
|
|
113
|
+
return initializeWithFallback(context, tokenRefresherRunning)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Subscribe to a reactive token source from the Studio workspace.
|
|
118
|
+
* The Studio is the single authority for auth — the SDK does not run
|
|
119
|
+
* its own token refresher or cookie auth checks.
|
|
120
|
+
*/
|
|
121
|
+
function initializeWithTokenSource(
|
|
122
|
+
context: StoreContext<AuthStoreState>,
|
|
123
|
+
tokenSource: TokenSource,
|
|
124
|
+
): {dispose: () => void; tokenRefresherStarted: boolean} {
|
|
125
|
+
const subscriptions: Subscription[] = []
|
|
126
|
+
|
|
127
|
+
// Subscribe to the current user fetcher — runs whenever auth state changes
|
|
128
|
+
subscriptions.push(subscribeToStateAndFetchCurrentUser(context, {useProjectHostname: true}))
|
|
129
|
+
|
|
130
|
+
// Subscribe to the Studio's token stream
|
|
131
|
+
const tokenSub = tokenSource.subscribe({
|
|
132
|
+
next: (token) => {
|
|
133
|
+
const {state} = context
|
|
134
|
+
if (token) {
|
|
135
|
+
state.set('studioTokenSource', (prev) => ({
|
|
136
|
+
options: {...prev.options, authMethod: undefined},
|
|
137
|
+
authState: {type: AuthStateType.LOGGED_IN, token, currentUser: null},
|
|
138
|
+
}))
|
|
139
|
+
} else {
|
|
140
|
+
state.set('studioTokenSourceLoggedOut', (prev) => ({
|
|
141
|
+
options: {...prev.options, authMethod: undefined},
|
|
142
|
+
authState: {type: AuthStateType.LOGGED_OUT, isDestroyingSession: false},
|
|
143
|
+
}))
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
dispose: () => {
|
|
150
|
+
tokenSub.unsubscribe()
|
|
151
|
+
for (const subscription of subscriptions) {
|
|
152
|
+
subscription.unsubscribe()
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
// Studio handles token refresh — do not start the SDK's refresher
|
|
156
|
+
tokenRefresherStarted: false,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Fallback initialization when no reactive token source is available.
|
|
162
|
+
* Uses localStorage/cookie discovery (existing behavior).
|
|
163
|
+
*/
|
|
164
|
+
function initializeWithFallback(
|
|
165
|
+
context: StoreContext<AuthStoreState>,
|
|
166
|
+
tokenRefresherRunning: boolean,
|
|
167
|
+
): {dispose: () => void; tokenRefresherStarted: boolean} {
|
|
168
|
+
const subscriptions: Subscription[] = []
|
|
169
|
+
let startedRefresher = false
|
|
170
|
+
|
|
171
|
+
subscriptions.push(subscribeToStateAndFetchCurrentUser(context, {useProjectHostname: true}))
|
|
172
|
+
|
|
173
|
+
const storageArea = context.state.get().options?.storageArea
|
|
174
|
+
if (storageArea) {
|
|
175
|
+
subscriptions.push(subscribeToStorageEventsAndSetToken(context))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// If no token found during getInitialState, try cookie auth asynchronously
|
|
179
|
+
try {
|
|
180
|
+
const {instance, state} = context
|
|
181
|
+
const token: string | null =
|
|
182
|
+
state.get().authState?.type === AuthStateType.LOGGED_IN
|
|
183
|
+
? (state.get().authState as LoggedInAuthState).token
|
|
184
|
+
: null
|
|
185
|
+
|
|
186
|
+
if (!token) {
|
|
187
|
+
const projectIdValue = instance.config.projectId
|
|
188
|
+
const clientFactory = state.get().options.clientFactory
|
|
189
|
+
checkForCookieAuth(projectIdValue, clientFactory).then((isCookieAuthEnabled) => {
|
|
190
|
+
if (!isCookieAuthEnabled) return
|
|
191
|
+
state.set('enableCookieAuth', (prev) => ({
|
|
192
|
+
options: {...prev.options, authMethod: 'cookie'},
|
|
193
|
+
authState:
|
|
194
|
+
prev.authState.type === AuthStateType.LOGGED_IN
|
|
195
|
+
? prev.authState
|
|
196
|
+
: {type: AuthStateType.LOGGED_IN, token: '', currentUser: null},
|
|
197
|
+
}))
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
} catch {
|
|
201
|
+
// best-effort cookie detection
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (!tokenRefresherRunning) {
|
|
205
|
+
startedRefresher = true
|
|
206
|
+
subscriptions.push(refreshStampedToken(context))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
dispose: () => {
|
|
211
|
+
for (const subscription of subscriptions) {
|
|
212
|
+
subscription.unsubscribe()
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
tokenRefresherStarted: startedRefresher,
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -21,7 +21,7 @@ describe('checkForCookieAuth', () => {
|
|
|
21
21
|
|
|
22
22
|
await checkForCookieAuth(projectId, clientFactory)
|
|
23
23
|
|
|
24
|
-
expect(clientFactory).toHaveBeenCalledWith({projectId, useCdn: false})
|
|
24
|
+
expect(clientFactory).toHaveBeenCalledWith(expect.objectContaining({projectId, useCdn: false}))
|
|
25
25
|
})
|
|
26
26
|
|
|
27
27
|
it('should return true if client request returns a user with id', async () => {
|
|
@@ -64,6 +64,48 @@ describe('checkForCookieAuth', () => {
|
|
|
64
64
|
|
|
65
65
|
expect(result).toBe(false)
|
|
66
66
|
})
|
|
67
|
+
|
|
68
|
+
it('should return false if client request returns null', async () => {
|
|
69
|
+
const projectId = 'test-project'
|
|
70
|
+
const mockClient = {
|
|
71
|
+
request: vi.fn().mockResolvedValue(null),
|
|
72
|
+
} as unknown as SanityClient
|
|
73
|
+
const clientFactory = vi.fn().mockReturnValue(mockClient)
|
|
74
|
+
|
|
75
|
+
const result = await checkForCookieAuth(projectId, clientFactory)
|
|
76
|
+
|
|
77
|
+
expect(result).toBe(false)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('should return false if client request returns a non-object', async () => {
|
|
81
|
+
const projectId = 'test-project'
|
|
82
|
+
const mockClient = {
|
|
83
|
+
request: vi.fn().mockResolvedValue('unexpected-string'),
|
|
84
|
+
} as unknown as SanityClient
|
|
85
|
+
const clientFactory = vi.fn().mockReturnValue(mockClient)
|
|
86
|
+
|
|
87
|
+
const result = await checkForCookieAuth(projectId, clientFactory)
|
|
88
|
+
|
|
89
|
+
expect(result).toBe(false)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('should pass timeout to client factory', async () => {
|
|
93
|
+
const projectId = 'test-project'
|
|
94
|
+
const mockClient = {
|
|
95
|
+
request: vi.fn().mockResolvedValue({id: 'user-id'}),
|
|
96
|
+
} as unknown as SanityClient
|
|
97
|
+
const clientFactory = vi.fn().mockReturnValue(mockClient)
|
|
98
|
+
|
|
99
|
+
await checkForCookieAuth(projectId, clientFactory)
|
|
100
|
+
|
|
101
|
+
expect(clientFactory).toHaveBeenCalledWith(
|
|
102
|
+
expect.objectContaining({
|
|
103
|
+
projectId,
|
|
104
|
+
useCdn: false,
|
|
105
|
+
timeout: 10_000,
|
|
106
|
+
}),
|
|
107
|
+
)
|
|
108
|
+
})
|
|
67
109
|
})
|
|
68
110
|
|
|
69
111
|
describe('getStudioTokenFromLocalStorage', () => {
|
|
@@ -2,6 +2,13 @@ import {type ClientConfig, type SanityClient} from '@sanity/client'
|
|
|
2
2
|
|
|
3
3
|
import {getTokenFromStorage} from './utils'
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Cookie auth is a best-effort probe — if the API doesn't respond quickly,
|
|
7
|
+
* the user likely isn't cookie-authenticated and we should move on rather
|
|
8
|
+
* than block the auth flow indefinitely.
|
|
9
|
+
*/
|
|
10
|
+
const COOKIE_AUTH_TIMEOUT_MS = 10_000
|
|
11
|
+
|
|
5
12
|
/**
|
|
6
13
|
* Attempts to check for cookie auth by making a withCredentials request to the users endpoint.
|
|
7
14
|
* @param projectId - The project ID to check for cookie auth.
|
|
@@ -18,13 +25,15 @@ export async function checkForCookieAuth(
|
|
|
18
25
|
const client = clientFactory({
|
|
19
26
|
projectId,
|
|
20
27
|
useCdn: false,
|
|
28
|
+
requestTagPrefix: 'sdk',
|
|
29
|
+
timeout: COOKIE_AUTH_TIMEOUT_MS,
|
|
21
30
|
})
|
|
22
31
|
const user = await client.request({
|
|
23
32
|
uri: '/users/me',
|
|
24
33
|
withCredentials: true,
|
|
25
34
|
tag: 'users.get-current',
|
|
26
35
|
})
|
|
27
|
-
return typeof user
|
|
36
|
+
return user != null && typeof user === 'object' && typeof user.id === 'string'
|
|
28
37
|
} catch {
|
|
29
38
|
return false
|
|
30
39
|
}
|
|
@@ -3,20 +3,35 @@ import {distinctUntilChanged, filter, map, type Subscription, switchMap} from 'r
|
|
|
3
3
|
|
|
4
4
|
import {type StoreContext} from '../store/defineStore'
|
|
5
5
|
import {DEFAULT_API_VERSION, REQUEST_TAG_PREFIX} from './authConstants'
|
|
6
|
+
import {isStudioConfig} from './authMode'
|
|
6
7
|
import {AuthStateType} from './authStateType'
|
|
7
8
|
import {type AuthMethodOptions, type AuthState, type AuthStoreState} from './authStore'
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Subscribes to auth state changes and fetches the current user when
|
|
12
|
+
* the state transitions to `LOGGED_IN` without a `currentUser`.
|
|
13
|
+
*
|
|
14
|
+
* @param context - The store context for the auth store.
|
|
15
|
+
* @param fetchOptions - Configuration options. When `useProjectHostname` is
|
|
16
|
+
* `true`, requests use the project-specific hostname (required for Studio
|
|
17
|
+
* cookie auth). Set to `true` for studio mode, `false` otherwise.
|
|
18
|
+
*
|
|
19
|
+
* @internal
|
|
20
|
+
*/
|
|
21
|
+
export const subscribeToStateAndFetchCurrentUser = (
|
|
22
|
+
{state, instance}: StoreContext<AuthStoreState>,
|
|
23
|
+
fetchOptions?: {useProjectHostname?: boolean},
|
|
24
|
+
): Subscription => {
|
|
13
25
|
const {clientFactory, apiHost} = state.get().options
|
|
14
|
-
const useProjectHostname =
|
|
26
|
+
const useProjectHostname = fetchOptions?.useProjectHostname ?? isStudioConfig(instance.config)
|
|
15
27
|
const projectId = instance.config.projectId
|
|
16
28
|
|
|
17
29
|
const currentUser$ = state.observable
|
|
18
30
|
.pipe(
|
|
19
|
-
map(({authState, options}) => ({
|
|
31
|
+
map(({authState, options: storeOptions}) => ({
|
|
32
|
+
authState,
|
|
33
|
+
authMethod: storeOptions.authMethod,
|
|
34
|
+
})),
|
|
20
35
|
filter(
|
|
21
36
|
(
|
|
22
37
|
value,
|