@sanity/sdk 2.12.0 → 2.13.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 (30) hide show
  1. package/dist/_chunks-dts/utils.d.ts +4 -0
  2. package/dist/_chunks-es/version.js +1 -1
  3. package/dist/index.js +160 -106
  4. package/dist/index.js.map +1 -1
  5. package/package.json +11 -11
  6. package/src/document/applyDocumentActions.test.ts +24 -0
  7. package/src/document/applyDocumentActions.ts +13 -2
  8. package/src/document/documentConstants.ts +7 -0
  9. package/src/document/documentStore.test.ts +69 -0
  10. package/src/document/documentStore.ts +36 -5
  11. package/src/document/listen.ts +1 -1
  12. package/src/document/permissions.test.ts +79 -0
  13. package/src/document/permissions.ts +8 -7
  14. package/src/document/processActions/create.ts +7 -4
  15. package/src/document/processActions/delete.ts +4 -4
  16. package/src/document/processActions/discard.ts +2 -2
  17. package/src/document/processActions/edit.ts +4 -3
  18. package/src/document/processActions/processActions.ts +9 -0
  19. package/src/document/processActions/publish.ts +4 -4
  20. package/src/document/processActions/releaseArchive.ts +4 -4
  21. package/src/document/processActions/releaseCreate.ts +2 -2
  22. package/src/document/processActions/releaseDelete.ts +2 -2
  23. package/src/document/processActions/releaseEdit.ts +2 -1
  24. package/src/document/processActions/releasePublish.ts +2 -2
  25. package/src/document/processActions/releaseSchedule.ts +4 -4
  26. package/src/document/processActions/shared.ts +15 -3
  27. package/src/document/processActions/unpublish.ts +3 -3
  28. package/src/document/reducers.ts +4 -3
  29. package/src/document/resourceRules.test.ts +178 -0
  30. package/src/document/resourceRules.ts +117 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/sdk",
3
- "version": "2.12.0",
3
+ "version": "2.13.0",
4
4
  "private": false,
5
5
  "description": "Sanity SDK",
6
6
  "keywords": [
@@ -57,32 +57,32 @@
57
57
  "@sanity/image-url": "^2.1.1",
58
58
  "@sanity/json-match": "^1.0.5",
59
59
  "@sanity/message-protocol": "^0.23.0",
60
- "@sanity/mutate": "^0.16.1",
60
+ "@sanity/mutate": "^0.18.0",
61
61
  "@sanity/telemetry": "^1.1.0",
62
62
  "@sanity/types": "^5.26.0",
63
63
  "groq": "3.88.1-typegen-experimental.0",
64
- "groq-js": "^1.30.1",
64
+ "groq-js": "^1.30.2",
65
65
  "reselect": "^5.1.1",
66
66
  "rxjs": "^7.8.2",
67
67
  "zustand": "^5.0.13"
68
68
  },
69
69
  "devDependencies": {
70
70
  "@sanity/browserslist-config": "^1.0.5",
71
- "@sanity/pkg-utils": "^8.1.29",
71
+ "@sanity/pkg-utils": "^9.2.3",
72
72
  "@sanity/prettier-config": "^1.0.6",
73
73
  "@types/node": "^24.12.4",
74
- "@vitest/coverage-v8": "4.1.6",
74
+ "@vitest/coverage-v8": "^4.1.8",
75
75
  "eslint": "^9.39.4",
76
76
  "prettier": "^3.8.3",
77
77
  "rollup-plugin-visualizer": "^5.14.0",
78
78
  "typescript": "^5.9.3",
79
- "vite": "^7.3.3",
80
- "vitest": "^4.1.6",
81
- "@repo/package.bundle": "3.82.0",
82
- "@repo/package.config": "0.0.1",
83
- "@repo/tsconfig": "0.0.1",
79
+ "vite": "^7.3.5",
80
+ "vitest": "^4.1.8",
84
81
  "@repo/config-test": "0.0.1",
85
- "@repo/config-eslint": "0.0.0"
82
+ "@repo/package.config": "0.0.1",
83
+ "@repo/config-eslint": "0.0.0",
84
+ "@repo/package.bundle": "3.82.0",
85
+ "@repo/tsconfig": "0.0.1"
86
86
  },
87
87
  "publishConfig": {
88
88
  "access": "public"
@@ -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,
@@ -18,7 +18,7 @@ export function handleEdit(
18
18
  action: EditDocumentAction,
19
19
  ctx: ActionHandlerContext,
20
20
  ): ActionHandlerResult {
21
- const {transactionId, timestamp, grants, outgoingActions, outgoingMutations} = ctx
21
+ const {transactionId, timestamp, grants, identity, outgoingActions, outgoingMutations} = ctx
22
22
  let {base, working} = ctx
23
23
 
24
24
  const documentId = getId(action.documentId)
@@ -32,6 +32,7 @@ export function handleEdit(
32
32
  transactionId,
33
33
  timestamp,
34
34
  grants,
35
+ identity,
35
36
  })
36
37
  // liveEdit documents use the mutation endpoint directly -- we don't send actions
37
38
  outgoingMutations.push(...result.workingMutations)
@@ -98,7 +99,7 @@ export function handleEdit(
98
99
  if (!isReleasePerspective(action.perspective) && !working[draftId] && working[publishedId]) {
99
100
  const newDraftFromPublished = {...working[publishedId], _id: draftId}
100
101
 
101
- if (!checkGrant(grants.create, newDraftFromPublished)) {
102
+ if (!checkGrant(grants.create, newDraftFromPublished, identity)) {
102
103
  throw new PermissionActionError({
103
104
  documentId,
104
105
  transactionId,
@@ -111,7 +112,7 @@ export function handleEdit(
111
112
 
112
113
  // the first if statement should make this never be null or undefined
113
114
  const workingBefore = working[patchDocumentId] ?? working[publishedId]
114
- if (!checkGrant(grants.update, workingBefore!)) {
115
+ if (!checkGrant(grants.update, workingBefore!, identity)) {
115
116
  throw new PermissionActionError({
116
117
  documentId,
117
118
  transactionId,
@@ -65,6 +65,13 @@ interface ProcessActionsOptions {
65
65
  */
66
66
  grants: Record<Grant, ExprNode>
67
67
 
68
+ /**
69
+ * The current user's ID, passed to GROQ as the value of `identity()` when
70
+ * evaluating ACL filters. Optional because the user may not have loaded yet;
71
+ * filters that reference `identity()` will evaluate to null in that case.
72
+ */
73
+ identity?: string
74
+
68
75
  // // TODO: implement initial values from the schema?
69
76
  // initialValues?: {[TDocumentType in string]?: {_type: string}}
70
77
  }
@@ -116,6 +123,7 @@ export function processActions({
116
123
  base: initialBase,
117
124
  timestamp,
118
125
  grants,
126
+ identity,
119
127
  }: ProcessActionsOptions): ProcessActionsResult {
120
128
  let base: DocumentSet = {...initialBase}
121
129
  let working: DocumentSet = {...initialWorking}
@@ -148,6 +156,7 @@ export function processActions({
148
156
  transactionId,
149
157
  timestamp,
150
158
  grants,
159
+ identity,
151
160
  outgoingActions,
152
161
  outgoingMutations,
153
162
  })
@@ -17,7 +17,7 @@ export function handlePublish(
17
17
  action: PublishDocumentAction,
18
18
  ctx: ActionHandlerContext,
19
19
  ): ActionHandlerResult {
20
- const {transactionId, timestamp, grants, outgoingActions, outgoingMutations} = ctx
20
+ const {transactionId, timestamp, grants, identity, outgoingActions, outgoingMutations} = ctx
21
21
  let {base, working} = ctx
22
22
 
23
23
  const documentId = getId(action.documentId)
@@ -58,7 +58,7 @@ export function handlePublish(
58
58
 
59
59
  const mutations: Mutation[] = [{delete: {id: draftId}}, {createOrReplace: newPublishedFromDraft}]
60
60
 
61
- if (working[draftId] && !checkGrant(grants.update, working[draftId])) {
61
+ if (working[draftId] && !checkGrant(grants.update, working[draftId], identity)) {
62
62
  throw new PermissionActionError({
63
63
  documentId,
64
64
  transactionId,
@@ -66,13 +66,13 @@ export function handlePublish(
66
66
  })
67
67
  }
68
68
 
69
- if (working[publishedId] && !checkGrant(grants.update, newPublishedFromDraft)) {
69
+ if (working[publishedId] && !checkGrant(grants.update, newPublishedFromDraft, identity)) {
70
70
  throw new PermissionActionError({
71
71
  documentId,
72
72
  transactionId,
73
73
  message: `Publish failed: You do not have permission to update the published version of "${documentId}".`,
74
74
  })
75
- } else if (!working[publishedId] && !checkGrant(grants.create, newPublishedFromDraft)) {
75
+ } else if (!working[publishedId] && !checkGrant(grants.create, newPublishedFromDraft, identity)) {
76
76
  throw new PermissionActionError({
77
77
  documentId,
78
78
  transactionId,