@sanity/sdk 2.12.0 → 2.14.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/_chunks-dts/createGroqSearchFilter.d.ts +925 -0
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +1 -0
- package/dist/_chunks-es/createGroqSearchFilter.js +261 -225
- package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -1
- package/dist/_chunks-es/version.js +1 -1
- package/dist/_exports/_internal.d.ts +3 -2
- package/dist/_exports/_internal.d.ts.map +1 -0
- package/dist/index.d.ts +1856 -2
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +207 -133
- package/dist/index.js.map +1 -1
- package/package.json +11 -11
- package/src/auth/authLogger.ts +30 -0
- package/src/auth/authStore.test.ts +96 -1
- package/src/auth/authStore.ts +55 -24
- package/src/auth/handleAuthCallback.test.ts +23 -1
- package/src/auth/handleAuthCallback.ts +25 -6
- package/src/auth/logout.test.ts +68 -1
- package/src/auth/logout.ts +22 -3
- package/src/auth/refreshStampedToken.test.ts +15 -0
- package/src/auth/refreshStampedToken.ts +12 -1
- package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +17 -2
- package/src/auth/subscribeToStateAndFetchCurrentUser.ts +9 -0
- package/src/document/applyDocumentActions.test.ts +24 -0
- package/src/document/applyDocumentActions.ts +13 -2
- package/src/document/documentConstants.ts +7 -0
- package/src/document/documentStore.test.ts +69 -0
- package/src/document/documentStore.ts +36 -5
- package/src/document/listen.ts +1 -1
- package/src/document/permissions.test.ts +79 -0
- package/src/document/permissions.ts +8 -7
- package/src/document/processActions/create.ts +7 -4
- package/src/document/processActions/delete.ts +4 -4
- package/src/document/processActions/discard.ts +2 -2
- package/src/document/processActions/edit.ts +4 -3
- package/src/document/processActions/processActions.ts +9 -0
- package/src/document/processActions/publish.ts +4 -4
- package/src/document/processActions/releaseArchive.ts +4 -4
- package/src/document/processActions/releaseCreate.ts +2 -2
- package/src/document/processActions/releaseDelete.ts +2 -2
- package/src/document/processActions/releaseEdit.ts +2 -1
- package/src/document/processActions/releasePublish.ts +2 -2
- package/src/document/processActions/releaseSchedule.ts +4 -4
- package/src/document/processActions/shared.ts +15 -3
- package/src/document/processActions/unpublish.ts +3 -3
- package/src/document/reducers.ts +4 -3
- package/src/document/resourceRules.test.ts +178 -0
- package/src/document/resourceRules.ts +117 -0
- package/dist/_chunks-dts/utils.d.ts +0 -2774
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
|
|
15
15
|
import {type StoreContext} from '../store/defineStore'
|
|
16
16
|
import {DEFAULT_API_VERSION, REQUEST_TAG_PREFIX} from './authConstants'
|
|
17
|
+
import {getAuthLogger} from './authLogger'
|
|
17
18
|
import {AuthStateType} from './authStateType'
|
|
18
19
|
import {type AuthState, type AuthStoreState} from './authStore'
|
|
19
20
|
|
|
@@ -141,7 +142,12 @@ function shouldRefreshToken(lastRefresh: number | undefined): boolean {
|
|
|
141
142
|
/**
|
|
142
143
|
* @internal
|
|
143
144
|
*/
|
|
144
|
-
export const refreshStampedToken = ({
|
|
145
|
+
export const refreshStampedToken = ({
|
|
146
|
+
state,
|
|
147
|
+
instance,
|
|
148
|
+
}: StoreContext<AuthStoreState>): Subscription => {
|
|
149
|
+
const logger = getAuthLogger(instance)
|
|
150
|
+
|
|
145
151
|
const {clientFactory, apiHost, storageArea, storageKey} = state.get().options
|
|
146
152
|
|
|
147
153
|
const refreshToken$ = state.observable.pipe(
|
|
@@ -171,14 +177,17 @@ export const refreshStampedToken = ({state}: StoreContext<AuthStoreState>): Subs
|
|
|
171
177
|
// Read the latest token directly from state inside refresh
|
|
172
178
|
const currentState = state.get()
|
|
173
179
|
if (currentState.authState.type !== AuthStateType.LOGGED_IN) {
|
|
180
|
+
logger.debug('Token refresh aborted - user logged out')
|
|
174
181
|
throw new Error('User logged out before refresh could complete') // Abort refresh
|
|
175
182
|
}
|
|
176
183
|
const currentToken = currentState.authState.token
|
|
177
184
|
|
|
185
|
+
logger.debug('Refreshing stamped token')
|
|
178
186
|
const response = await firstValueFrom(
|
|
179
187
|
createTokenRefreshStream(currentToken, clientFactory, apiHost),
|
|
180
188
|
)
|
|
181
189
|
|
|
190
|
+
logger.info('Token refreshed successfully')
|
|
182
191
|
state.set('setRefreshStampedToken', (prev) => ({
|
|
183
192
|
authState:
|
|
184
193
|
prev.authState.type === AuthStateType.LOGGED_IN
|
|
@@ -276,6 +285,7 @@ export const refreshStampedToken = ({state}: StoreContext<AuthStoreState>): Subs
|
|
|
276
285
|
|
|
277
286
|
return refreshToken$.subscribe({
|
|
278
287
|
next: (response: {token: string}) => {
|
|
288
|
+
logger.debug('Token refresh completed, updating state')
|
|
279
289
|
state.set('setRefreshStampedToken', (prev) => ({
|
|
280
290
|
authState:
|
|
281
291
|
prev.authState.type === AuthStateType.LOGGED_IN
|
|
@@ -289,6 +299,7 @@ export const refreshStampedToken = ({state}: StoreContext<AuthStoreState>): Subs
|
|
|
289
299
|
storageArea?.setItem(storageKey, JSON.stringify({token: response.token}))
|
|
290
300
|
},
|
|
291
301
|
error: (error) => {
|
|
302
|
+
logger.error('Token refresh failed', {error})
|
|
292
303
|
state.set('setRefreshStampedTokenError', {authState: {type: AuthStateType.ERROR, error}})
|
|
293
304
|
},
|
|
294
305
|
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {type CurrentUser} from '@sanity/types'
|
|
2
2
|
import {delay, of, throwError} from 'rxjs'
|
|
3
|
-
import {beforeEach, describe, it} from 'vitest'
|
|
3
|
+
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
4
4
|
|
|
5
5
|
import {createSanityInstance} from '../store/createSanityInstance'
|
|
6
6
|
import {createStoreState} from '../store/createStoreState'
|
|
@@ -8,13 +8,28 @@ import {AuthStateType} from './authStateType'
|
|
|
8
8
|
import {authStore} from './authStore'
|
|
9
9
|
import {subscribeToStateAndFetchCurrentUser} from './subscribeToStateAndFetchCurrentUser'
|
|
10
10
|
|
|
11
|
+
// Mock logger to prevent actual logging during tests
|
|
12
|
+
vi.mock('../utils/logger', async (importOriginal) => {
|
|
13
|
+
const original = await importOriginal<typeof import('../utils/logger')>()
|
|
14
|
+
return {
|
|
15
|
+
...original,
|
|
16
|
+
createLogger: vi.fn(() => ({
|
|
17
|
+
info: vi.fn(),
|
|
18
|
+
debug: vi.fn(),
|
|
19
|
+
warn: vi.fn(),
|
|
20
|
+
error: vi.fn(),
|
|
21
|
+
trace: vi.fn(),
|
|
22
|
+
})),
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
11
26
|
describe('subscribeToStateAndFetchCurrentUser', () => {
|
|
12
27
|
beforeEach(() => {
|
|
13
28
|
vi.clearAllMocks()
|
|
14
29
|
})
|
|
15
30
|
|
|
16
31
|
it('fetches the current user if the is logged in without a current user present', () => {
|
|
17
|
-
const mockUser = {id: 'example-user'} as CurrentUser
|
|
32
|
+
const mockUser = {id: 'example-user', email: 'user@example.com'} as CurrentUser
|
|
18
33
|
const mockRequest = vi.fn().mockReturnValue(of(mockUser))
|
|
19
34
|
const mockClient = {observable: {request: mockRequest}}
|
|
20
35
|
const clientFactory = vi.fn().mockReturnValue(mockClient)
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
|
|
12
12
|
import {type StoreContext} from '../store/defineStore'
|
|
13
13
|
import {DEFAULT_API_VERSION, REQUEST_TAG_PREFIX} from './authConstants'
|
|
14
|
+
import {getAuthLogger} from './authLogger'
|
|
14
15
|
import {isStudioConfig} from './authMode'
|
|
15
16
|
import {AuthStateType} from './authStateType'
|
|
16
17
|
import {type AuthMethodOptions, type AuthState, type AuthStoreState} from './authStore'
|
|
@@ -30,6 +31,8 @@ export const subscribeToStateAndFetchCurrentUser = (
|
|
|
30
31
|
{state, instance}: StoreContext<AuthStoreState>,
|
|
31
32
|
fetchOptions?: {useProjectHostname?: boolean},
|
|
32
33
|
): Subscription => {
|
|
34
|
+
const logger = getAuthLogger(instance)
|
|
35
|
+
|
|
33
36
|
const {clientFactory, apiHost} = state.get().options
|
|
34
37
|
const useProjectHostname = fetchOptions?.useProjectHostname ?? isStudioConfig(instance.config)
|
|
35
38
|
const projectId = instance.config.projectId
|
|
@@ -82,6 +85,7 @@ export const subscribeToStateAndFetchCurrentUser = (
|
|
|
82
85
|
* @see SDK-1409
|
|
83
86
|
*/
|
|
84
87
|
catchError((error) => {
|
|
88
|
+
logger.error('Failed to fetch current user', {error})
|
|
85
89
|
state.set('setError', {authState: {type: AuthStateType.ERROR, error}})
|
|
86
90
|
return EMPTY
|
|
87
91
|
}),
|
|
@@ -91,6 +95,11 @@ export const subscribeToStateAndFetchCurrentUser = (
|
|
|
91
95
|
|
|
92
96
|
return currentUser$.subscribe({
|
|
93
97
|
next: (currentUser) => {
|
|
98
|
+
logger.info('Current user fetched successfully', {
|
|
99
|
+
hasEmail: !!currentUser.email,
|
|
100
|
+
})
|
|
101
|
+
// userId is PII, so only surface it at debug level
|
|
102
|
+
logger.debug('Current user details', {userId: currentUser.id})
|
|
94
103
|
state.set('setCurrentUser', (prev) => ({
|
|
95
104
|
authState:
|
|
96
105
|
prev.authState.type === AuthStateType.LOGGED_IN
|
|
@@ -202,4 +202,28 @@ describe('applyDocumentActions', () => {
|
|
|
202
202
|
childInstance.dispose()
|
|
203
203
|
parentInstance.dispose()
|
|
204
204
|
})
|
|
205
|
+
|
|
206
|
+
it('normalizes actions for the bound resource before queueing', async () => {
|
|
207
|
+
// A plain edit action with no `liveEdit` flag. Canvas forces liveEdit, so
|
|
208
|
+
// the queued action should come back with `liveEdit: true` — proving that
|
|
209
|
+
// `applyDocumentActions` runs `normalizeActionsForResource`.
|
|
210
|
+
const action: DocumentAction = {
|
|
211
|
+
type: 'document.edit',
|
|
212
|
+
documentId: 'doc1',
|
|
213
|
+
documentType: 'sanity.canvas.document',
|
|
214
|
+
patches: [{set: {foo: 'bar'}}],
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Don't await — we only need to inspect the synchronously-queued transaction.
|
|
218
|
+
applyDocumentActions(instance, {
|
|
219
|
+
actions: [action],
|
|
220
|
+
transactionId: 'txn-normalize',
|
|
221
|
+
resource: {canvasId: 'c'},
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
const queued = state.get().queued.find((t) => t.transactionId === 'txn-normalize')
|
|
225
|
+
expect(queued).toBeDefined()
|
|
226
|
+
const [queuedAction] = queued!.actions
|
|
227
|
+
expect(queuedAction).toMatchObject({type: 'document.edit', liveEdit: true})
|
|
228
|
+
})
|
|
205
229
|
})
|
|
@@ -10,6 +10,7 @@ import {documentStore, type DocumentStoreState} from './documentStore'
|
|
|
10
10
|
import {type DocumentTransactionSubmissionResult} from './events'
|
|
11
11
|
import {type DocumentSet} from './processMutations'
|
|
12
12
|
import {type AppliedTransaction, type QueuedTransaction, queueTransaction} from './reducers'
|
|
13
|
+
import {normalizeActionsForResource} from './resourceRules'
|
|
13
14
|
|
|
14
15
|
/** @beta */
|
|
15
16
|
export interface ActionsResult<TDocument extends SanityDocument = SanityDocument> {
|
|
@@ -73,13 +74,23 @@ const boundApplyDocumentActions = bindActionByResource(documentStore, _applyDocu
|
|
|
73
74
|
/** @internal */
|
|
74
75
|
async function _applyDocumentActions(
|
|
75
76
|
{state}: StoreContext<DocumentStoreState>,
|
|
76
|
-
{
|
|
77
|
+
{
|
|
78
|
+
actions,
|
|
79
|
+
resource,
|
|
80
|
+
transactionId = crypto.randomUUID(),
|
|
81
|
+
disableBatching,
|
|
82
|
+
}: ApplyDocumentActionsOptions,
|
|
77
83
|
): Promise<ActionsResult> {
|
|
78
84
|
const {events} = state.get()
|
|
79
85
|
|
|
86
|
+
// Rewrite edit actions to match the bound resource's editing model (e.g.
|
|
87
|
+
// forcing liveEdit for Canvas, stripping unsupported release perspectives).
|
|
88
|
+
// Non-edit and release actions pass through unchanged.
|
|
89
|
+
const normalizedActions = normalizeActionsForResource(actions, resource)
|
|
90
|
+
|
|
80
91
|
const transaction: QueuedTransaction = {
|
|
81
92
|
transactionId,
|
|
82
|
-
actions,
|
|
93
|
+
actions: normalizedActions,
|
|
83
94
|
...(disableBatching && {disableBatching}),
|
|
84
95
|
}
|
|
85
96
|
|
|
@@ -8,3 +8,10 @@
|
|
|
8
8
|
export const DOCUMENT_STATE_CLEAR_DELAY = 1000
|
|
9
9
|
export const INITIAL_OUTGOING_THROTTLE_TIME = 1000
|
|
10
10
|
export const API_VERSION = 'v2025-05-06'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Base delay (ms) before retrying a document listener after an `OutOfSyncError`.
|
|
14
|
+
* Backoff doubles on each successive retry, capped at {@link OUT_OF_SYNC_RETRY_MAX_DELAY}.
|
|
15
|
+
*/
|
|
16
|
+
export const OUT_OF_SYNC_RETRY_BASE_DELAY = 500
|
|
17
|
+
export const OUT_OF_SYNC_RETRY_MAX_DELAY = 10_000
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
subscribeDocumentEvents,
|
|
43
43
|
} from './documentStore'
|
|
44
44
|
import {type ActionErrorEvent, type TransactionRevertedEvent} from './events'
|
|
45
|
+
import {DEFAULT_MAX_BUFFER_SIZE} from './listen'
|
|
45
46
|
import {type DatasetAcl} from './permissions'
|
|
46
47
|
import {type DocumentSet, processMutations} from './processMutations'
|
|
47
48
|
import {type HttpAction} from './reducers'
|
|
@@ -1006,6 +1007,72 @@ it('version edits are isolated from draft state', async () => {
|
|
|
1006
1007
|
unsubscribeDraft()
|
|
1007
1008
|
})
|
|
1008
1009
|
|
|
1010
|
+
it('subscribeToSubscriptionsAndListenToDocuments recovers from an OutOfSyncError instead of failing the document store', async () => {
|
|
1011
|
+
const documentId = DocumentId('doc-out-of-sync-recovery')
|
|
1012
|
+
const doc = createDocumentHandle({documentId, documentType: 'article'})
|
|
1013
|
+
const documentState = getDocumentState<TestDocument>(instance, doc)
|
|
1014
|
+
const unsubscribe = documentState.subscribe()
|
|
1015
|
+
const draftId = getDraftId(documentId)
|
|
1016
|
+
|
|
1017
|
+
// Establish a synced document so the listener has a base revision.
|
|
1018
|
+
const created = await applyDocumentActions(instance, {
|
|
1019
|
+
actions: [createDocument(doc), editDocument(doc, {set: {title: 'initial'}})],
|
|
1020
|
+
})
|
|
1021
|
+
await created.submitted()
|
|
1022
|
+
expect(documentState.getCurrent()?.title).toBe('initial')
|
|
1023
|
+
|
|
1024
|
+
// Capture the fetchDocument spy so we can detect when the listener
|
|
1025
|
+
// re-subscribes. (every frest subscription re-runs fetchDocument)
|
|
1026
|
+
const fetchDocumentSpy = vi.mocked(createFetchDocument).mock.results.at(-1)?.value as ReturnType<
|
|
1027
|
+
typeof vi.fn
|
|
1028
|
+
>
|
|
1029
|
+
const fetchCallsBefore = fetchDocumentSpy.mock.calls.filter(([id]) => id === draftId).length
|
|
1030
|
+
|
|
1031
|
+
// Inject DEFAULT_MAX_BUFFER_SIZE mutations whose previousRev doesn't chain
|
|
1032
|
+
// to the base revision (basically forcing the error)
|
|
1033
|
+
const sharedListener = (
|
|
1034
|
+
createSharedListener as unknown as () => {events: Subject<ListenEvent<SanityDocument>>}
|
|
1035
|
+
)()
|
|
1036
|
+
for (let i = 0; i < DEFAULT_MAX_BUFFER_SIZE; i++) {
|
|
1037
|
+
sharedListener.events.next({
|
|
1038
|
+
type: 'mutation',
|
|
1039
|
+
documentId: draftId,
|
|
1040
|
+
eventId: `bad-${i}`,
|
|
1041
|
+
identity: 'user',
|
|
1042
|
+
mutations: [],
|
|
1043
|
+
timestamp: new Date().toISOString(),
|
|
1044
|
+
transactionId: `bad-tx-${i}`,
|
|
1045
|
+
transactionCurrentEvent: 0,
|
|
1046
|
+
transactionTotalEvents: 1,
|
|
1047
|
+
previousRev: `dangling-rev-${i}`,
|
|
1048
|
+
resultRev: `bad-rev-${i}`,
|
|
1049
|
+
transition: 'update',
|
|
1050
|
+
visibility: 'query',
|
|
1051
|
+
})
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// The above should have triggered a retry and re-subscribed to the listener
|
|
1055
|
+
await vi.waitFor(() => {
|
|
1056
|
+
expect(fetchDocumentSpy.mock.calls.filter(([id]) => id === draftId).length).toBeGreaterThan(
|
|
1057
|
+
fetchCallsBefore,
|
|
1058
|
+
)
|
|
1059
|
+
})
|
|
1060
|
+
|
|
1061
|
+
// The store stayed healthy through the OutOfSyncError.
|
|
1062
|
+
expect(() => documentState.getCurrent()).not.toThrow()
|
|
1063
|
+
expect(() => getDocumentSyncStatus(instance, doc).getCurrent()).not.toThrow()
|
|
1064
|
+
|
|
1065
|
+
// A follow-up edit still propagates through the recovered listener.
|
|
1066
|
+
const edited = await applyDocumentActions(instance, {
|
|
1067
|
+
actions: [editDocument(doc, {set: {title: 'after-recovery'}})],
|
|
1068
|
+
resource,
|
|
1069
|
+
})
|
|
1070
|
+
await edited.submitted()
|
|
1071
|
+
expect(documentState.getCurrent()?.title).toBe('after-recovery')
|
|
1072
|
+
|
|
1073
|
+
unsubscribe()
|
|
1074
|
+
})
|
|
1075
|
+
|
|
1009
1076
|
vi.mock('../client/clientStore.ts', () => ({
|
|
1010
1077
|
getClientState: vi.fn().mockReturnValue({observable: new ReplaySubject(1)}),
|
|
1011
1078
|
}))
|
|
@@ -1039,6 +1106,8 @@ vi.mock('./documentConstants.ts', async (importOriginal) => {
|
|
|
1039
1106
|
...original,
|
|
1040
1107
|
INITIAL_OUTGOING_THROTTLE_TIME: 0,
|
|
1041
1108
|
DOCUMENT_STATE_CLEAR_DELAY: 25,
|
|
1109
|
+
OUT_OF_SYNC_RETRY_BASE_DELAY: 0,
|
|
1110
|
+
OUT_OF_SYNC_RETRY_MAX_DELAY: 0,
|
|
1042
1111
|
}
|
|
1043
1112
|
})
|
|
1044
1113
|
|
|
@@ -17,15 +17,18 @@ import {
|
|
|
17
17
|
Observable,
|
|
18
18
|
of,
|
|
19
19
|
pairwise,
|
|
20
|
+
retry,
|
|
20
21
|
startWith,
|
|
21
22
|
Subject,
|
|
22
23
|
switchMap,
|
|
23
24
|
tap,
|
|
24
25
|
throttle,
|
|
26
|
+
throwError,
|
|
25
27
|
timer,
|
|
26
28
|
withLatestFrom,
|
|
27
29
|
} from 'rxjs'
|
|
28
30
|
|
|
31
|
+
import {getCurrentUserState} from '../auth/authStore'
|
|
29
32
|
import {type ClientOptions, getClientState} from '../client/clientStore'
|
|
30
33
|
import {
|
|
31
34
|
type DocumentHandle,
|
|
@@ -44,7 +47,12 @@ import {type SanityInstance} from '../store/createSanityInstance'
|
|
|
44
47
|
import {createStateSourceAction, type StateSource} from '../store/createStateSourceAction'
|
|
45
48
|
import {defineStore, type StoreContext} from '../store/defineStore'
|
|
46
49
|
import {type DocumentAction} from './actions'
|
|
47
|
-
import {
|
|
50
|
+
import {
|
|
51
|
+
API_VERSION,
|
|
52
|
+
INITIAL_OUTGOING_THROTTLE_TIME,
|
|
53
|
+
OUT_OF_SYNC_RETRY_BASE_DELAY,
|
|
54
|
+
OUT_OF_SYNC_RETRY_MAX_DELAY,
|
|
55
|
+
} from './documentConstants'
|
|
48
56
|
import {
|
|
49
57
|
type DocumentEvent,
|
|
50
58
|
type DocumentTransactionSubmissionResult,
|
|
@@ -82,6 +90,10 @@ export interface DocumentStoreState {
|
|
|
82
90
|
applied: AppliedTransaction[]
|
|
83
91
|
outgoing?: OutgoingTransaction
|
|
84
92
|
grants?: Record<Grant, ExprNode>
|
|
93
|
+
/**
|
|
94
|
+
* The current user's identity (their user ID).
|
|
95
|
+
*/
|
|
96
|
+
identity?: string
|
|
85
97
|
error?: unknown
|
|
86
98
|
sharedListener: SharedListener
|
|
87
99
|
fetchDocument: (documentId: string) => Observable<SanityDocument | null>
|
|
@@ -141,6 +153,7 @@ export const documentStore = defineStore<DocumentStoreState, BoundResourceKey>({
|
|
|
141
153
|
subscribeToSubscriptionsAndListenToDocuments(context),
|
|
142
154
|
subscribeToAppliedAndSubmitNextTransaction(context),
|
|
143
155
|
subscribeToClientAndFetchDatasetAcl(context),
|
|
156
|
+
subscribeToCurrentUserAndSetIdentity(context),
|
|
144
157
|
]
|
|
145
158
|
|
|
146
159
|
return () => {
|
|
@@ -497,10 +510,15 @@ const subscribeToSubscriptionsAndListenToDocuments = (
|
|
|
497
510
|
switchMap((e) => {
|
|
498
511
|
if (!e.add) return EMPTY
|
|
499
512
|
return listen(context, e.id).pipe(
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
513
|
+
retry({
|
|
514
|
+
delay: (error, retryCount) => {
|
|
515
|
+
if (!(error instanceof OutOfSyncError)) return throwError(() => error)
|
|
516
|
+
const backoff = Math.min(
|
|
517
|
+
OUT_OF_SYNC_RETRY_BASE_DELAY * 2 ** (retryCount - 1),
|
|
518
|
+
OUT_OF_SYNC_RETRY_MAX_DELAY,
|
|
519
|
+
)
|
|
520
|
+
return timer(backoff)
|
|
521
|
+
},
|
|
504
522
|
}),
|
|
505
523
|
tap((remote) =>
|
|
506
524
|
state.set('applyRemoteDocument', (prev) =>
|
|
@@ -548,3 +566,16 @@ const subscribeToClientAndFetchDatasetAcl = ({
|
|
|
548
566
|
error: (error) => state.set('setError', {error}),
|
|
549
567
|
})
|
|
550
568
|
}
|
|
569
|
+
|
|
570
|
+
const subscribeToCurrentUserAndSetIdentity = ({
|
|
571
|
+
instance,
|
|
572
|
+
state,
|
|
573
|
+
}: StoreContext<DocumentStoreState, BoundResourceKey>) =>
|
|
574
|
+
getCurrentUserState(instance).observable.subscribe({
|
|
575
|
+
next: (currentUser) => state.set('setIdentity', {identity: currentUser?.id}),
|
|
576
|
+
// A transient identity-fetch failure (network blip, expired token, or a
|
|
577
|
+
// normal logout/re-login transition) should not brick all document
|
|
578
|
+
// operations. Reset the identity to `undefined` and keep going — GROQ's
|
|
579
|
+
// `identity()` then evaluates to null, matching the unauthenticated state.
|
|
580
|
+
error: () => state.set('setIdentity', {identity: undefined}),
|
|
581
|
+
})
|
package/src/document/listen.ts
CHANGED
|
@@ -20,7 +20,7 @@ import {type StoreContext} from '../store/defineStore'
|
|
|
20
20
|
import {type DocumentStoreState} from './documentStore'
|
|
21
21
|
import {processMutations} from './processMutations'
|
|
22
22
|
|
|
23
|
-
const DEFAULT_MAX_BUFFER_SIZE = 20
|
|
23
|
+
export const DEFAULT_MAX_BUFFER_SIZE = 20
|
|
24
24
|
const DEFAULT_DEADLINE_MS = 30000
|
|
25
25
|
|
|
26
26
|
export interface RemoteDocument {
|
|
@@ -32,9 +32,11 @@ const alwaysDeny = parse('false')
|
|
|
32
32
|
const createState = (
|
|
33
33
|
docStates: Record<string, {local: unknown}>,
|
|
34
34
|
grants?: Record<Grant, ExprNode>,
|
|
35
|
+
identity?: string,
|
|
35
36
|
): SyncTransactionState => ({
|
|
36
37
|
documentStates: docStates as SyncTransactionState['documentStates'],
|
|
37
38
|
grants,
|
|
39
|
+
identity,
|
|
38
40
|
queued: [],
|
|
39
41
|
applied: [],
|
|
40
42
|
})
|
|
@@ -52,6 +54,18 @@ describe('createGrantsLookup', () => {
|
|
|
52
54
|
expect(evaluateSync(grants[key], {params: {document: dummyDoc}}).data).toBe(true)
|
|
53
55
|
})
|
|
54
56
|
})
|
|
57
|
+
|
|
58
|
+
it('should ignore unknown permissions like "manage"', () => {
|
|
59
|
+
const datasetAcl = [
|
|
60
|
+
{filter: '_id != null', permissions: ['history', 'read', 'update', 'create', 'manage']},
|
|
61
|
+
] as DatasetAcl
|
|
62
|
+
const grants = createGrantsLookup(datasetAcl)
|
|
63
|
+
const dummyDoc = {_id: 'doc1'}
|
|
64
|
+
;(['read', 'update', 'create', 'history'] as Grant[]).forEach((key) => {
|
|
65
|
+
expect(grants[key]).toBeDefined()
|
|
66
|
+
expect(evaluateSync(grants[key], {params: {document: dummyDoc}}).data).toBe(true)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
55
69
|
})
|
|
56
70
|
|
|
57
71
|
describe('calculatePermissions', () => {
|
|
@@ -234,4 +248,69 @@ describe('calculatePermissions', () => {
|
|
|
234
248
|
const result2 = calculatePermissions({instance, state}, {actions: [{...action}]})
|
|
235
249
|
expect(result1).toBe(result2)
|
|
236
250
|
})
|
|
251
|
+
|
|
252
|
+
describe('identity-aware grants', () => {
|
|
253
|
+
// Mirrors the canvas ACL filter shape: only the document's creator may
|
|
254
|
+
// update it. Without an identity passed to groq, `identity()` is null and
|
|
255
|
+
// the expression collapses to null — which used to read as "denied".
|
|
256
|
+
const canvasUpdateAcl: DatasetAcl = [
|
|
257
|
+
{
|
|
258
|
+
filter: '_type == "sanity.canvas.document" && _system.createdBy == identity()',
|
|
259
|
+
permissions: ['read', 'update'],
|
|
260
|
+
},
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
const canvasDoc = (createdBy: string): SanityDocument => ({
|
|
264
|
+
_id: 'canvas-1',
|
|
265
|
+
_type: 'sanity.canvas.document',
|
|
266
|
+
_createdAt: '2025-01-01T00:00:00.000Z',
|
|
267
|
+
_updatedAt: '2025-01-01T00:00:00.000Z',
|
|
268
|
+
_rev: 'rev-1',
|
|
269
|
+
_system: {createdBy},
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
const editAction: DocumentAction = {
|
|
273
|
+
documentId: 'canvas-1',
|
|
274
|
+
documentType: 'sanity.canvas.document',
|
|
275
|
+
type: 'document.edit',
|
|
276
|
+
liveEdit: true,
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
it('allows edits when identity matches the document creator', () => {
|
|
280
|
+
const state = createState(
|
|
281
|
+
{'canvas-1': {local: canvasDoc('user-A')}},
|
|
282
|
+
createGrantsLookup(canvasUpdateAcl),
|
|
283
|
+
'user-A',
|
|
284
|
+
)
|
|
285
|
+
expect(calculatePermissions({instance, state}, {actions: [editAction]})).toEqual({
|
|
286
|
+
allowed: true,
|
|
287
|
+
})
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('denies edits when identity does not match the document creator', () => {
|
|
291
|
+
const state = createState(
|
|
292
|
+
{'canvas-1': {local: canvasDoc('user-A')}},
|
|
293
|
+
createGrantsLookup(canvasUpdateAcl),
|
|
294
|
+
'user-B',
|
|
295
|
+
)
|
|
296
|
+
const result = calculatePermissions({instance, state}, {actions: [editAction]})
|
|
297
|
+
expect(result?.allowed).toBe(false)
|
|
298
|
+
expect(result?.reasons).toEqual(
|
|
299
|
+
expect.arrayContaining([expect.objectContaining({type: 'access', documentId: 'canvas-1'})]),
|
|
300
|
+
)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('denies edits when identity is not yet loaded', () => {
|
|
304
|
+
const state = createState(
|
|
305
|
+
{'canvas-1': {local: canvasDoc('user-A')}},
|
|
306
|
+
createGrantsLookup(canvasUpdateAcl),
|
|
307
|
+
undefined,
|
|
308
|
+
)
|
|
309
|
+
const result = calculatePermissions({instance, state}, {actions: [editAction]})
|
|
310
|
+
expect(result?.allowed).toBe(false)
|
|
311
|
+
expect(result?.reasons).toEqual(
|
|
312
|
+
expect.arrayContaining([expect.objectContaining({type: 'access', documentId: 'canvas-1'})]),
|
|
313
|
+
)
|
|
314
|
+
})
|
|
315
|
+
})
|
|
237
316
|
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {DocumentId, getDraftId, getPublishedId, getVersionId} from '@sanity/id-utils'
|
|
2
2
|
import {type SanityDocument} from '@sanity/types'
|
|
3
|
-
import {
|
|
3
|
+
import {type ExprNode, parse} from 'groq-js'
|
|
4
4
|
import {createSelector} from 'reselect'
|
|
5
5
|
|
|
6
6
|
import {isReleasePerspective} from '../releases/utils/isReleasePerspective'
|
|
@@ -8,6 +8,7 @@ import {type SelectorContext} from '../store/createStateSourceAction'
|
|
|
8
8
|
import {MultiKeyWeakMap} from '../utils/MultiKeyWeakMap'
|
|
9
9
|
import {type DocumentAction} from './actions'
|
|
10
10
|
import {ActionError, PermissionActionError, processActions} from './processActions/processActions'
|
|
11
|
+
import {checkGrant} from './processActions/shared'
|
|
11
12
|
import {type DocumentSet} from './processMutations'
|
|
12
13
|
import {type SyncTransactionState} from './reducers'
|
|
13
14
|
|
|
@@ -29,6 +30,8 @@ export function createGrantsLookup(datasetAcl: DatasetAcl): Record<Grant, ExprNo
|
|
|
29
30
|
for (const entry of datasetAcl) {
|
|
30
31
|
for (const grant of entry.permissions) {
|
|
31
32
|
const set = filtersByGrant[grant]
|
|
33
|
+
// Ignore permissions we don't model (e.g. "manage")
|
|
34
|
+
if (!set) continue
|
|
32
35
|
set.add(entry.filter)
|
|
33
36
|
filtersByGrant[grant] = set
|
|
34
37
|
}
|
|
@@ -140,11 +143,6 @@ const memoizedActionsSelector = createSelector(
|
|
|
140
143
|
},
|
|
141
144
|
)
|
|
142
145
|
|
|
143
|
-
function checkGrant(grantExpr: ExprNode, document: SanityDocument): boolean {
|
|
144
|
-
const value = evaluateSync(grantExpr, {params: {document}})
|
|
145
|
-
return value.type === 'boolean' && value.data
|
|
146
|
-
}
|
|
147
|
-
|
|
148
146
|
/** @beta */
|
|
149
147
|
export interface PermissionDeniedReason {
|
|
150
148
|
type: 'precondition' | 'access'
|
|
@@ -172,11 +170,13 @@ export function calculatePermissions(
|
|
|
172
170
|
const _calculatePermissions = createSelector(
|
|
173
171
|
[
|
|
174
172
|
({state: {grants}}: SelectorContext<SyncTransactionState>) => grants,
|
|
173
|
+
({state: {identity}}: SelectorContext<SyncTransactionState>) => identity,
|
|
175
174
|
documentsSelector,
|
|
176
175
|
memoizedActionsSelector,
|
|
177
176
|
],
|
|
178
177
|
(
|
|
179
178
|
grants: Record<Grant, ExprNode> | undefined,
|
|
179
|
+
identity: string | undefined,
|
|
180
180
|
documents: DocumentSet | undefined,
|
|
181
181
|
actions: DocumentAction[] | undefined,
|
|
182
182
|
): DocumentPermissionsResult | undefined => {
|
|
@@ -195,6 +195,7 @@ const _calculatePermissions = createSelector(
|
|
|
195
195
|
base: documents,
|
|
196
196
|
timestamp,
|
|
197
197
|
grants,
|
|
198
|
+
identity,
|
|
198
199
|
})
|
|
199
200
|
} catch (error) {
|
|
200
201
|
if (error instanceof PermissionActionError) {
|
|
@@ -244,7 +245,7 @@ const _calculatePermissions = createSelector(
|
|
|
244
245
|
documentId: docId,
|
|
245
246
|
})
|
|
246
247
|
}
|
|
247
|
-
} else if (!checkGrant(grants.update, doc)) {
|
|
248
|
+
} else if (!checkGrant(grants.update, doc, identity)) {
|
|
248
249
|
reasons.push({
|
|
249
250
|
type: 'access',
|
|
250
251
|
message: `You are not allowed to edit the document with ID "${docId}".`,
|
|
@@ -16,7 +16,7 @@ export function handleCreate(
|
|
|
16
16
|
action: CreateDocumentAction,
|
|
17
17
|
ctx: ActionHandlerContext,
|
|
18
18
|
): ActionHandlerResult {
|
|
19
|
-
const {transactionId, timestamp, grants, outgoingActions, outgoingMutations} = ctx
|
|
19
|
+
const {transactionId, timestamp, grants, identity, outgoingActions, outgoingMutations} = ctx
|
|
20
20
|
let {base, working} = ctx
|
|
21
21
|
|
|
22
22
|
const documentId = getId(action.documentId)
|
|
@@ -51,7 +51,7 @@ export function handleCreate(
|
|
|
51
51
|
timestamp,
|
|
52
52
|
})
|
|
53
53
|
|
|
54
|
-
if (!checkGrant(grants.create, working[documentId] as SanityDocument)) {
|
|
54
|
+
if (!checkGrant(grants.create, working[documentId] as SanityDocument, identity)) {
|
|
55
55
|
throw new PermissionActionError({
|
|
56
56
|
documentId,
|
|
57
57
|
transactionId,
|
|
@@ -111,13 +111,16 @@ export function handleCreate(
|
|
|
111
111
|
timestamp,
|
|
112
112
|
})
|
|
113
113
|
|
|
114
|
-
if (versionId && !checkGrant(grants.create, working[versionId] as SanityDocument)) {
|
|
114
|
+
if (versionId && !checkGrant(grants.create, working[versionId] as SanityDocument, identity)) {
|
|
115
115
|
throw new PermissionActionError({
|
|
116
116
|
documentId,
|
|
117
117
|
transactionId,
|
|
118
118
|
message: `You do not have permission to create a release version for document "${documentId}".`,
|
|
119
119
|
})
|
|
120
|
-
} else if (
|
|
120
|
+
} else if (
|
|
121
|
+
!versionId &&
|
|
122
|
+
!checkGrant(grants.create, working[draftId] as SanityDocument, identity)
|
|
123
|
+
) {
|
|
121
124
|
throw new PermissionActionError({
|
|
122
125
|
documentId,
|
|
123
126
|
transactionId,
|
|
@@ -16,7 +16,7 @@ export function handleDelete(
|
|
|
16
16
|
action: DeleteDocumentAction,
|
|
17
17
|
ctx: ActionHandlerContext,
|
|
18
18
|
): ActionHandlerResult {
|
|
19
|
-
const {transactionId, timestamp, grants, outgoingActions, outgoingMutations} = ctx
|
|
19
|
+
const {transactionId, timestamp, grants, identity, outgoingActions, outgoingMutations} = ctx
|
|
20
20
|
let {base, working} = ctx
|
|
21
21
|
|
|
22
22
|
const documentId = action.documentId
|
|
@@ -38,7 +38,7 @@ export function handleDelete(
|
|
|
38
38
|
})
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
if (!checkGrant(grants.update, working[documentId])) {
|
|
41
|
+
if (!checkGrant(grants.update, working[documentId], identity)) {
|
|
42
42
|
throw new PermissionActionError({
|
|
43
43
|
documentId,
|
|
44
44
|
transactionId,
|
|
@@ -72,9 +72,9 @@ export function handleDelete(
|
|
|
72
72
|
})
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
const cantDeleteDraft = working[draftId] && !checkGrant(grants.update, working[draftId])
|
|
75
|
+
const cantDeleteDraft = working[draftId] && !checkGrant(grants.update, working[draftId], identity)
|
|
76
76
|
const cantDeletePublished =
|
|
77
|
-
working[publishedId] && !checkGrant(grants.update, working[publishedId])
|
|
77
|
+
working[publishedId] && !checkGrant(grants.update, working[publishedId], identity)
|
|
78
78
|
|
|
79
79
|
if (cantDeleteDraft || cantDeletePublished) {
|
|
80
80
|
throw new PermissionActionError({
|
|
@@ -16,7 +16,7 @@ export function handleDiscard(
|
|
|
16
16
|
action: DiscardDocumentAction,
|
|
17
17
|
ctx: ActionHandlerContext,
|
|
18
18
|
): ActionHandlerResult {
|
|
19
|
-
const {transactionId, timestamp, grants, outgoingActions, outgoingMutations} = ctx
|
|
19
|
+
const {transactionId, timestamp, grants, identity, outgoingActions, outgoingMutations} = ctx
|
|
20
20
|
let {base, working} = ctx
|
|
21
21
|
|
|
22
22
|
const documentId = getId(action.documentId)
|
|
@@ -43,7 +43,7 @@ export function handleDiscard(
|
|
|
43
43
|
})
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
if (!checkGrant(grants.update, working[versionId])) {
|
|
46
|
+
if (!checkGrant(grants.update, working[versionId], identity)) {
|
|
47
47
|
throw new PermissionActionError({
|
|
48
48
|
documentId,
|
|
49
49
|
transactionId,
|