@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.
Files changed (49) hide show
  1. package/dist/_chunks-dts/createGroqSearchFilter.d.ts +925 -0
  2. package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +1 -0
  3. package/dist/_chunks-es/createGroqSearchFilter.js +261 -225
  4. package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -1
  5. package/dist/_chunks-es/version.js +1 -1
  6. package/dist/_exports/_internal.d.ts +3 -2
  7. package/dist/_exports/_internal.d.ts.map +1 -0
  8. package/dist/index.d.ts +1856 -2
  9. package/dist/index.d.ts.map +1 -0
  10. package/dist/index.js +207 -133
  11. package/dist/index.js.map +1 -1
  12. package/package.json +11 -11
  13. package/src/auth/authLogger.ts +30 -0
  14. package/src/auth/authStore.test.ts +96 -1
  15. package/src/auth/authStore.ts +55 -24
  16. package/src/auth/handleAuthCallback.test.ts +23 -1
  17. package/src/auth/handleAuthCallback.ts +25 -6
  18. package/src/auth/logout.test.ts +68 -1
  19. package/src/auth/logout.ts +22 -3
  20. package/src/auth/refreshStampedToken.test.ts +15 -0
  21. package/src/auth/refreshStampedToken.ts +12 -1
  22. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +17 -2
  23. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +9 -0
  24. package/src/document/applyDocumentActions.test.ts +24 -0
  25. package/src/document/applyDocumentActions.ts +13 -2
  26. package/src/document/documentConstants.ts +7 -0
  27. package/src/document/documentStore.test.ts +69 -0
  28. package/src/document/documentStore.ts +36 -5
  29. package/src/document/listen.ts +1 -1
  30. package/src/document/permissions.test.ts +79 -0
  31. package/src/document/permissions.ts +8 -7
  32. package/src/document/processActions/create.ts +7 -4
  33. package/src/document/processActions/delete.ts +4 -4
  34. package/src/document/processActions/discard.ts +2 -2
  35. package/src/document/processActions/edit.ts +4 -3
  36. package/src/document/processActions/processActions.ts +9 -0
  37. package/src/document/processActions/publish.ts +4 -4
  38. package/src/document/processActions/releaseArchive.ts +4 -4
  39. package/src/document/processActions/releaseCreate.ts +2 -2
  40. package/src/document/processActions/releaseDelete.ts +2 -2
  41. package/src/document/processActions/releaseEdit.ts +2 -1
  42. package/src/document/processActions/releasePublish.ts +2 -2
  43. package/src/document/processActions/releaseSchedule.ts +4 -4
  44. package/src/document/processActions/shared.ts +15 -3
  45. package/src/document/processActions/unpublish.ts +3 -3
  46. package/src/document/reducers.ts +4 -3
  47. package/src/document/resourceRules.test.ts +178 -0
  48. package/src/document/resourceRules.ts +117 -0
  49. 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 = ({state}: StoreContext<AuthStoreState>): Subscription => {
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
- {actions, transactionId = crypto.randomUUID(), disableBatching}: ApplyDocumentActionsOptions,
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 {API_VERSION, INITIAL_OUTGOING_THROTTLE_TIME} from './documentConstants'
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
- catchError((error) => {
501
- // retry on `OutOfSyncError`
502
- if (error instanceof OutOfSyncError) listen(context, e.id)
503
- throw error
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
+ })
@@ -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 {evaluateSync, type ExprNode, parse} from 'groq-js'
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 (!versionId && !checkGrant(grants.create, working[draftId] as SanityDocument)) {
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,