@sanity/sdk 3.0.0-rc.0 → 3.0.0-rc.1

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 (197) hide show
  1. package/dist/_chunks-dts/createGroqSearchFilter.d.ts +1147 -0
  2. package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +1 -0
  3. package/dist/_chunks-es/_internal.js +134 -0
  4. package/dist/_chunks-es/_internal.js.map +1 -0
  5. package/dist/_chunks-es/createGroqSearchFilter.js +1864 -0
  6. package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -0
  7. package/dist/_chunks-es/telemetryManager.js +93 -0
  8. package/dist/_chunks-es/telemetryManager.js.map +1 -0
  9. package/dist/_exports/_internal.d.ts +95 -0
  10. package/dist/_exports/_internal.d.ts.map +1 -0
  11. package/dist/_exports/_internal.js +24 -0
  12. package/dist/_exports/_internal.js.map +1 -0
  13. package/dist/index.d.ts +1079 -1076
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +1967 -2302
  16. package/dist/index.js.map +1 -1
  17. package/package.json +48 -47
  18. package/src/_exports/_internal.ts +26 -0
  19. package/src/_exports/index.ts +120 -11
  20. package/src/access/checkPermissions.test.ts +55 -0
  21. package/src/access/checkPermissions.ts +54 -0
  22. package/src/agent/agentActions.test.ts +16 -60
  23. package/src/agent/agentActions.ts +25 -38
  24. package/src/applications/applications.test-d.ts +83 -0
  25. package/src/applications/applications.test.ts +130 -0
  26. package/src/applications/applications.ts +364 -0
  27. package/src/applications/userApplications.test.ts +126 -0
  28. package/src/applications/userApplications.ts +261 -0
  29. package/src/auth/authLogger.ts +30 -0
  30. package/src/auth/authMode.test.ts +25 -0
  31. package/src/auth/authMode.ts +6 -3
  32. package/src/auth/authStore.test.ts +194 -13
  33. package/src/auth/authStore.ts +59 -26
  34. package/src/auth/getOrganizationVerificationState.test.ts +18 -17
  35. package/src/auth/getOrganizationVerificationState.ts +4 -4
  36. package/src/auth/handleAuthCallback.test.ts +35 -1
  37. package/src/auth/handleAuthCallback.ts +25 -6
  38. package/src/auth/logout.test.ts +75 -2
  39. package/src/auth/logout.ts +23 -4
  40. package/src/auth/refreshStampedToken.test.ts +219 -469
  41. package/src/auth/refreshStampedToken.ts +79 -195
  42. package/src/auth/studioAuth.ts +1 -1
  43. package/src/auth/studioModeAuth.ts +2 -1
  44. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +146 -13
  45. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +37 -6
  46. package/src/auth/subscribeToStorageEventsAndSetToken.test.ts +2 -0
  47. package/src/client/clientStore.test.ts +62 -37
  48. package/src/client/clientStore.ts +45 -75
  49. package/src/client/liveEvents.test.ts +151 -0
  50. package/src/client/liveEvents.ts +107 -0
  51. package/src/comlink/controller/actions/destroyController.test.ts +4 -1
  52. package/src/comlink/controller/actions/getOrCreateChannel.test.ts +4 -1
  53. package/src/comlink/controller/actions/getOrCreateChannel.ts +2 -2
  54. package/src/comlink/controller/actions/getOrCreateController.test.ts +4 -1
  55. package/src/comlink/controller/actions/releaseChannel.test.ts +1 -1
  56. package/src/comlink/controller/comlinkControllerStore.test.ts +4 -1
  57. package/src/comlink/node/actions/getOrCreateNode.test.ts +9 -3
  58. package/src/comlink/node/actions/getOrCreateNode.ts +2 -2
  59. package/src/comlink/node/actions/releaseNode.test.ts +7 -4
  60. package/src/comlink/node/comlinkNodeStore.test.ts +2 -2
  61. package/src/comlink/node/getNodeState.test.ts +1 -1
  62. package/src/comlink/node/getNodeState.ts +2 -1
  63. package/src/config/__tests__/handles.test.ts +18 -12
  64. package/src/config/handles.ts +25 -7
  65. package/src/config/sanityConfig.ts +109 -79
  66. package/src/datasets/datasets.test.ts +2 -2
  67. package/src/datasets/datasets.ts +10 -4
  68. package/src/document/actions.test.ts +115 -33
  69. package/src/document/actions.ts +212 -15
  70. package/src/document/applyDocumentActions.test.ts +38 -12
  71. package/src/document/applyDocumentActions.ts +30 -9
  72. package/src/document/documentConstants.ts +7 -0
  73. package/src/document/documentStore.concurrency.test.ts +1306 -0
  74. package/src/document/documentStore.test.ts +673 -278
  75. package/src/document/documentStore.ts +162 -71
  76. package/src/document/events.test.ts +57 -2
  77. package/src/document/events.ts +82 -26
  78. package/src/document/listen.test.ts +56 -0
  79. package/src/document/listen.ts +74 -21
  80. package/src/document/listenerEventOperators.test.ts +311 -0
  81. package/src/document/listenerEventOperators.ts +217 -0
  82. package/src/document/patchOperations.test.ts +49 -2
  83. package/src/document/patchOperations.ts +50 -0
  84. package/src/document/permissions.test.ts +105 -60
  85. package/src/document/permissions.ts +44 -19
  86. package/src/document/processActions/create.ts +138 -0
  87. package/src/document/processActions/delete.ts +100 -0
  88. package/src/document/processActions/discard.ts +63 -0
  89. package/src/document/processActions/edit.ts +154 -0
  90. package/src/document/processActions/processActions.ts +218 -0
  91. package/src/document/processActions/publish.ts +120 -0
  92. package/src/document/processActions/releaseArchive.ts +77 -0
  93. package/src/document/processActions/releaseCreate.ts +59 -0
  94. package/src/document/processActions/releaseDelete.ts +65 -0
  95. package/src/document/processActions/releaseEdit.ts +37 -0
  96. package/src/document/processActions/releasePublish.ts +45 -0
  97. package/src/document/processActions/releaseSchedule.ts +87 -0
  98. package/src/document/processActions/releaseUtil.ts +31 -0
  99. package/src/document/processActions/shared.ts +308 -0
  100. package/src/document/processActions/unpublish.ts +85 -0
  101. package/src/document/processActions.test.ts +1129 -121
  102. package/src/document/reducers.test.ts +639 -64
  103. package/src/document/reducers.ts +187 -48
  104. package/src/document/resourceRules.test.ts +178 -0
  105. package/src/document/resourceRules.ts +117 -0
  106. package/src/document/sharedListener.test.ts +16 -14
  107. package/src/document/sharedListener.ts +24 -8
  108. package/src/document/util.ts +14 -0
  109. package/src/favorites/favorites.test.ts +5 -6
  110. package/src/installations/installations.test-d.ts +68 -0
  111. package/src/installations/installations.test.ts +62 -0
  112. package/src/installations/installations.ts +202 -0
  113. package/src/organization/organization.test-d.ts +36 -0
  114. package/src/organization/organization.test.ts +138 -0
  115. package/src/organization/organization.ts +138 -0
  116. package/src/organizations/organizations.test-d.ts +56 -0
  117. package/src/organizations/organizations.test.ts +150 -0
  118. package/src/organizations/organizations.ts +107 -0
  119. package/src/presence/bifurTransport.test.ts +46 -6
  120. package/src/presence/bifurTransport.ts +19 -2
  121. package/src/presence/presenceStore.test.ts +94 -23
  122. package/src/presence/presenceStore.ts +85 -29
  123. package/src/preview/getPreviewState.test.ts +137 -0
  124. package/src/preview/getPreviewState.ts +69 -0
  125. package/src/preview/previewProjectionUtils.test.ts +9 -22
  126. package/src/preview/previewProjectionUtils.ts +7 -3
  127. package/src/preview/resolvePreview.test.ts +64 -0
  128. package/src/preview/resolvePreview.ts +43 -0
  129. package/src/preview/types.ts +10 -0
  130. package/src/project/project.test-d.ts +37 -0
  131. package/src/project/project.test.ts +157 -12
  132. package/src/project/project.ts +138 -25
  133. package/src/projection/getProjectionState.test.ts +18 -20
  134. package/src/projection/getProjectionState.ts +23 -3
  135. package/src/projection/projectionQuery.ts +5 -5
  136. package/src/projection/projectionStore.test.ts +1 -1
  137. package/src/projection/resolveProjection.test.ts +1 -2
  138. package/src/projection/resolveProjection.ts +16 -0
  139. package/src/projection/subscribeToStateAndFetchBatches.test.ts +1 -1
  140. package/src/projection/subscribeToStateAndFetchBatches.ts +32 -23
  141. package/src/projection/types.ts +8 -0
  142. package/src/projects/projects.test-d.ts +32 -0
  143. package/src/projects/projects.test.ts +108 -42
  144. package/src/projects/projects.ts +63 -27
  145. package/src/query/queryStore.test.ts +259 -123
  146. package/src/query/queryStore.ts +78 -63
  147. package/src/query/reducers.test.ts +0 -42
  148. package/src/query/reducers.ts +2 -18
  149. package/src/releases/getPerspectiveState.test.ts +17 -23
  150. package/src/releases/getPerspectiveState.ts +8 -20
  151. package/src/releases/observeReleases.test.ts +127 -0
  152. package/src/releases/observeReleases.ts +102 -0
  153. package/src/releases/releasesStore.test.ts +100 -53
  154. package/src/releases/releasesStore.ts +69 -44
  155. package/src/releases/utils/sortReleases.test.ts +38 -2
  156. package/src/releases/utils/sortReleases.ts +15 -10
  157. package/src/store/createActionBinder.test.ts +87 -90
  158. package/src/store/createActionBinder.ts +79 -25
  159. package/src/store/createSanityInstance.test.ts +83 -13
  160. package/src/store/createSanityInstance.ts +77 -2
  161. package/src/store/createStateSourceAction.test.ts +64 -2
  162. package/src/store/createStateSourceAction.ts +39 -44
  163. package/src/store/createStoreInstance.test.ts +4 -2
  164. package/src/store/fetcherStore.test-d.ts +121 -0
  165. package/src/store/fetcherStore.test.ts +299 -0
  166. package/src/store/fetcherStore.ts +801 -0
  167. package/src/telemetry/environment.test.ts +119 -0
  168. package/src/telemetry/environment.ts +92 -0
  169. package/src/telemetry/events.ts +42 -0
  170. package/src/telemetry/initTelemetry.test.ts +432 -0
  171. package/src/telemetry/initTelemetry.ts +218 -0
  172. package/src/telemetry/telemetryManager.test.ts +327 -0
  173. package/src/telemetry/telemetryManager.ts +199 -0
  174. package/src/users/reducers.test.ts +6 -1
  175. package/src/users/reducers.ts +5 -6
  176. package/src/users/types.ts +4 -4
  177. package/src/users/usersStore.test.ts +16 -12
  178. package/src/users/usersStore.ts +5 -1
  179. package/src/utils/buildQuery.test.ts +14 -0
  180. package/src/utils/buildQuery.ts +17 -0
  181. package/src/utils/createFetcherStore.test.ts +7 -5
  182. package/src/utils/createFetcherStore.ts +8 -5
  183. package/src/utils/getStagingApiHost.test.ts +21 -0
  184. package/src/utils/getStagingApiHost.ts +14 -0
  185. package/src/utils/ids.test.ts +1 -29
  186. package/src/utils/ids.ts +0 -10
  187. package/src/utils/includeShape.ts +36 -0
  188. package/src/utils/isImportError.test.ts +72 -0
  189. package/src/utils/isImportError.ts +34 -0
  190. package/src/utils/logger.test.ts +12 -0
  191. package/src/utils/logger.ts +8 -3
  192. package/src/utils/object.test.ts +95 -0
  193. package/src/utils/object.ts +142 -0
  194. package/src/utils/setCleanupTimeout.ts +37 -0
  195. package/src/document/processActions.ts +0 -707
  196. package/src/utils/defineIntent.test.ts +0 -477
  197. package/src/utils/defineIntent.ts +0 -244
@@ -274,10 +274,51 @@ describe('set', () => {
274
274
  expect(output).toEqual({a: 1, nonexistent: {path: 999}})
275
275
  })
276
276
 
277
- it('creates an item from a key constraint if the key is not present', () => {
277
+ // Content Lake never creates array items when setting: a keyed or indexed
278
+ // segment that does not resolve is a silent no-op server-side. The applier
279
+ // used to append a degenerate item (without the key, even), which
280
+ // fabricated array items locally that would never exist on the server.
281
+ it('does not create an item from a key constraint if the key is not present', () => {
278
282
  const input = {items: [{_key: 'item1'}]}
279
283
  const output = set(input, {'items[_key=="item2"]': {_key: 'item2'}})
280
- expect(output).toEqual({items: [{_key: 'item1'}, {_key: 'item2'}]})
284
+ expect(output).toEqual({items: [{_key: 'item1'}]})
285
+ })
286
+
287
+ it('does not create an item when setting a property under an unresolvable key constraint', () => {
288
+ const input = {
289
+ blocks: [
290
+ {
291
+ _key: 'b1',
292
+ children: [{_key: 's1', _type: 'span', text: 'hello', marks: []}],
293
+ },
294
+ ],
295
+ }
296
+ const output = set(input, {'blocks[_key=="b1"].children[_key=="missing"].marks': ['mark1']})
297
+ expect(output).toEqual(input)
298
+ })
299
+
300
+ it('does not create an item when setting through an out-of-bounds index', () => {
301
+ const input = {items: [{_key: 'item1', value: 1}]}
302
+ const output = set(input, {'items[3].value': 99})
303
+ expect(output).toEqual(input)
304
+ })
305
+
306
+ it('still sets a missing property on an existing keyed item', () => {
307
+ const input = {items: [{_key: 'item1'}]}
308
+ const output = set(input, {'items[_key=="item1"].value': 42})
309
+ expect(output).toEqual({items: [{_key: 'item1', value: 42}]})
310
+ })
311
+
312
+ it('does not create anything when an array-addressing segment targets a non-array', () => {
313
+ const input = {items: {nested: true}}
314
+ const output = set(input, {'items[_key=="item1"].value': 42})
315
+ expect(output).toEqual(input)
316
+ })
317
+
318
+ it('does not create an item when setting through a negative out-of-bounds index', () => {
319
+ const input = {items: [{_key: 'item1', value: 1}]}
320
+ const output = set(input, {'items[-5].value': 99})
321
+ expect(output).toEqual(input)
281
322
  })
282
323
  })
283
324
 
@@ -299,6 +340,12 @@ describe('setIfMissing', () => {
299
340
  const output = setIfMissing(input, {'a.b': 42})
300
341
  expect(output).toEqual({a: {b: 1}})
301
342
  })
343
+
344
+ it('does not create an item under an unresolvable key constraint', () => {
345
+ const input = {items: [{_key: 'item1'}]}
346
+ const output = setIfMissing(input, {'items[_key=="missing"].value': 1})
347
+ expect(output).toEqual(input)
348
+ })
302
349
  })
303
350
 
304
351
  describe('unset', () => {
@@ -175,6 +175,54 @@ export const ensureArrayKeysDeep = memoize(<R>(input: R): R => {
175
175
  return Object.fromEntries(entries) as R
176
176
  })
177
177
 
178
+ /**
179
+ * Whether every array-addressing segment (keyed `[_key=="…"]` or numeric
180
+ * index) in the given match path resolves to an existing array item.
181
+ *
182
+ * This mirrors Content Lake, which auto-creates missing *object* properties
183
+ * when setting but never creates *array items*:
184
+ *
185
+ * ```js
186
+ * // object properties are created, even nested ones:
187
+ * set({}, {'meta.author.name': 'Ada'})
188
+ * // -> {meta: {author: {name: 'Ada'}}} (applies)
189
+ *
190
+ * // array items are not:
191
+ * set({items: [{_key: 'a'}]}, {'items[_key=="b"].title': 'x'})
192
+ * // -> {items: [{_key: 'a'}]} (no-op)
193
+ * ```
194
+ *
195
+ * Without this filter, the second example fabricated a degenerate item
196
+ * (`{title: 'x'}`, no `_key`) that would never exist on the server, which
197
+ * is how concurrent Portable Text edits grew phantom span-less children.
198
+ */
199
+ function resolvesArraySegments(input: unknown, path: SingleValuePath): boolean {
200
+ let current: unknown = input
201
+ for (const segment of path) {
202
+ if (typeof segment === 'string') {
203
+ // missing object properties (nested ones included) are created
204
+ // server-side, so a string segment may descend into undefined
205
+ current =
206
+ typeof current === 'object' && current !== null && !Array.isArray(current)
207
+ ? (current as Record<string, unknown>)[segment]
208
+ : undefined
209
+ continue
210
+ }
211
+ // keyed and numeric segments address array items, which are never created
212
+ if (!Array.isArray(current)) return false
213
+ if (isKeySegment(segment)) {
214
+ const index = getIndexForKey(current, segment._key)
215
+ if (index === undefined) return false
216
+ current = current[index]
217
+ } else {
218
+ const index = segment < 0 ? current.length + segment : segment
219
+ if (!(index in current)) return false
220
+ current = current[index]
221
+ }
222
+ }
223
+ return true
224
+ }
225
+
178
226
  /**
179
227
  * Given an input object and a record of path expressions to values, this
180
228
  * function will set each match with the given value.
@@ -198,6 +246,7 @@ export function set(input: unknown, pathExpressionValues: Record<string, unknown
198
246
  replacementValue,
199
247
  })),
200
248
  )
249
+ .filter(({path}) => resolvesArraySegments(input, path))
201
250
  .reduce((acc, {path, replacementValue}) => setDeep(acc, path, replacementValue), input)
202
251
 
203
252
  return ensureArrayKeysDeep(result)
@@ -231,6 +280,7 @@ export function setIfMissing(
231
280
  }))
232
281
  })
233
282
  .filter((matchEntry) => matchEntry.value === null || matchEntry.value === undefined)
283
+ .filter(({path}) => resolvesArraySegments(input, path))
234
284
  .reduce((acc, {path, replacementValue}) => setDeep(acc, path, replacementValue), input)
235
285
 
236
286
  return ensureArrayKeysDeep(result)
@@ -1,14 +1,14 @@
1
+ import {DocumentId, getDraftId, getPublishedId} from '@sanity/id-utils'
1
2
  import {type SanityDocument} from '@sanity/types'
2
3
  import {evaluateSync, type ExprNode, parse} from 'groq-js'
3
4
  import {describe, expect, it} from 'vitest'
4
5
 
5
6
  import {createSanityInstance} from '../store/createSanityInstance'
6
- import {getDraftId, getPublishedId} from '../utils/ids'
7
7
  import {type DocumentAction} from './actions'
8
8
  import {calculatePermissions, createGrantsLookup, type DatasetAcl, type Grant} from './permissions'
9
9
  import {type SyncTransactionState} from './reducers'
10
10
 
11
- const instance = createSanityInstance()
11
+ const instance = createSanityInstance({projectId: 'p', dataset: 'd'})
12
12
 
13
13
  afterAll(() => {
14
14
  instance.dispose()
@@ -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', () => {
@@ -66,19 +80,16 @@ describe('calculatePermissions', () => {
66
80
  // For a document.create action, the selector expects both published and draft keys.
67
81
  const state = createState(
68
82
  {
69
- [getPublishedId('doc1')]: {local: createDoc('doc1', 'Original Title')},
70
- [getDraftId('doc1')]: {local: null},
83
+ [getPublishedId(DocumentId('doc1'))]: {
84
+ local: createDoc(DocumentId('doc1'), 'Original Title'),
85
+ },
86
+ [getDraftId(DocumentId('doc1'))]: {local: null},
71
87
  },
72
88
  defaultGrants,
73
89
  )
74
90
 
75
91
  const actions: DocumentAction[] = [
76
- {
77
- documentId: 'doc1',
78
- type: 'document.create',
79
- documentType: 'article',
80
- resource: {projectId: 'p', dataset: 'd'},
81
- },
92
+ {documentId: 'doc1', type: 'document.create', documentType: 'article'},
82
93
  ]
83
94
  const result = calculatePermissions({instance, state}, {actions})
84
95
  expect(result).toEqual({allowed: true})
@@ -88,18 +99,13 @@ describe('calculatePermissions', () => {
88
99
  // Missing the draft key will cause documentsSelector to return undefined.
89
100
  const state = createState(
90
101
  {
91
- [getPublishedId('doc1')]: {local: createDoc('doc1', 'Title')},
102
+ [getPublishedId(DocumentId('doc1'))]: {local: createDoc(DocumentId('doc1'), 'Title')},
92
103
  // Missing getDraftId('doc1')
93
104
  },
94
105
  defaultGrants,
95
106
  )
96
107
  const actions: DocumentAction[] = [
97
- {
98
- documentId: 'doc1',
99
- type: 'document.create',
100
- documentType: 'article',
101
- resource: {projectId: 'p', dataset: 'd'},
102
- },
108
+ {documentId: 'doc1', type: 'document.create', documentType: 'article'},
103
109
  ]
104
110
  expect(calculatePermissions({instance, state}, {actions})).toBeUndefined()
105
111
  })
@@ -109,18 +115,13 @@ describe('calculatePermissions', () => {
109
115
  const deniedGrants = {...defaultGrants, create: alwaysDeny}
110
116
  const state = createState(
111
117
  {
112
- [getPublishedId('doc1')]: {local: createDoc('doc1', 'Title')},
113
- [getDraftId('doc1')]: {local: null},
118
+ [getPublishedId(DocumentId('doc1'))]: {local: createDoc(DocumentId('doc1'), 'Title')},
119
+ [getDraftId(DocumentId('doc1'))]: {local: null},
114
120
  },
115
121
  deniedGrants,
116
122
  )
117
123
  const actions: DocumentAction[] = [
118
- {
119
- documentId: 'doc1',
120
- type: 'document.create',
121
- documentType: 'article',
122
- resource: {projectId: 'p', dataset: 'd'},
123
- },
124
+ {documentId: 'doc1', type: 'document.create', documentType: 'article'},
124
125
  ]
125
126
  const result = calculatePermissions({instance, state}, {actions})
126
127
  expect(result).toBeDefined()
@@ -142,18 +143,13 @@ describe('calculatePermissions', () => {
142
143
  // Both published and draft documents are present as null.
143
144
  const state = createState(
144
145
  {
145
- [getPublishedId('doc1')]: {local: null},
146
- [getDraftId('doc1')]: {local: null},
146
+ [getPublishedId(DocumentId('doc1'))]: {local: null},
147
+ [getDraftId(DocumentId('doc1'))]: {local: null},
147
148
  },
148
149
  defaultGrants,
149
150
  )
150
151
  const actions: DocumentAction[] = [
151
- {
152
- documentId: 'doc1',
153
- documentType: 'book',
154
- type: 'document.edit',
155
- resource: {projectId: 'p', dataset: 'd'},
156
- },
152
+ {documentId: 'doc1', documentType: 'book', type: 'document.edit'},
157
153
  ]
158
154
  const result = calculatePermissions({instance, state}, {actions})
159
155
  expect(result).toBeDefined()
@@ -173,18 +169,13 @@ describe('calculatePermissions', () => {
173
169
  const deniedGrants = {...defaultGrants, update: alwaysDeny}
174
170
  const state = createState(
175
171
  {
176
- [getPublishedId('doc1')]: {local: createDoc('doc1', 'Title')},
177
- [getDraftId('doc1')]: {local: createDoc(getDraftId('doc1'), 'Draft Title')},
172
+ [getPublishedId(DocumentId('doc1'))]: {local: createDoc(DocumentId('doc1'), 'Title')},
173
+ [getDraftId(DocumentId('doc1'))]: {local: createDoc(DocumentId('doc1'), 'Draft Title')},
178
174
  },
179
175
  deniedGrants,
180
176
  )
181
177
  const actions: DocumentAction[] = [
182
- {
183
- documentId: 'doc1',
184
- documentType: 'book',
185
- type: 'document.edit',
186
- resource: {projectId: 'p', dataset: 'd'},
187
- },
178
+ {documentId: 'doc1', documentType: 'book', type: 'document.edit'},
188
179
  ]
189
180
  const result = calculatePermissions({instance, state}, {actions})
190
181
  expect(result).toBeDefined()
@@ -204,16 +195,11 @@ describe('calculatePermissions', () => {
204
195
 
205
196
  it('should return undefined if grants are not provided', () => {
206
197
  const state = createState({
207
- [getPublishedId('doc1')]: {local: createDoc('doc1', 'Title')},
208
- [getDraftId('doc1')]: {local: null},
198
+ [getPublishedId(DocumentId('doc1'))]: {local: createDoc(DocumentId('doc1'), 'Title')},
199
+ [getDraftId(DocumentId('doc1'))]: {local: null},
209
200
  })
210
201
  const actions: DocumentAction[] = [
211
- {
212
- documentId: 'doc1',
213
- type: 'document.create',
214
- documentType: 'article',
215
- resource: {projectId: 'p', dataset: 'd'},
216
- },
202
+ {documentId: 'doc1', type: 'document.create', documentType: 'article'},
217
203
  ]
218
204
  expect(calculatePermissions({instance, state}, {actions})).toBeUndefined()
219
205
  })
@@ -222,18 +208,13 @@ describe('calculatePermissions', () => {
222
208
  // For document.delete, if the published document is missing, processActions throws an ActionError.
223
209
  const state = createState(
224
210
  {
225
- [getPublishedId('doc1')]: {local: null},
226
- [getDraftId('doc1')]: {local: null},
211
+ [getPublishedId(DocumentId('doc1'))]: {local: null},
212
+ [getDraftId(DocumentId('doc1'))]: {local: null},
227
213
  },
228
214
  defaultGrants,
229
215
  )
230
216
  const actions: DocumentAction[] = [
231
- {
232
- documentId: 'doc1',
233
- documentType: 'book',
234
- type: 'document.delete',
235
- resource: {projectId: 'p', dataset: 'd'},
236
- },
217
+ {documentId: 'doc1', documentType: 'book', type: 'document.delete'},
237
218
  ]
238
219
  const result = calculatePermissions({instance, state}, {actions})
239
220
  expect(result).toBeDefined()
@@ -252,8 +233,8 @@ describe('calculatePermissions', () => {
252
233
  it('should memoize the result for identical state and actions inputs', () => {
253
234
  const state = createState(
254
235
  {
255
- [getPublishedId('doc1')]: {local: createDoc('doc1', 'Title')},
256
- [getDraftId('doc1')]: {local: null},
236
+ [getPublishedId(DocumentId('doc1'))]: {local: createDoc(DocumentId('doc1'), 'Title')},
237
+ [getDraftId(DocumentId('doc1'))]: {local: null},
257
238
  },
258
239
  defaultGrants,
259
240
  )
@@ -261,11 +242,75 @@ describe('calculatePermissions', () => {
261
242
  documentId: 'doc1',
262
243
  type: 'document.create',
263
244
  documentType: 'article',
264
- resource: {projectId: 'p', dataset: 'd'},
265
245
  }
266
246
  // notice how the action is a copy
267
247
  const result1 = calculatePermissions({instance, state}, {actions: [{...action}]})
268
248
  const result2 = calculatePermissions({instance, state}, {actions: [{...action}]})
269
249
  expect(result1).toBe(result2)
270
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
+ })
271
316
  })
@@ -1,12 +1,14 @@
1
+ import {DocumentId, getDraftId, getPublishedId, getVersionId} from '@sanity/id-utils'
1
2
  import {type SanityDocument} from '@sanity/types'
2
- import {evaluateSync, type ExprNode, parse} from 'groq-js'
3
+ import {type ExprNode, parse} from 'groq-js'
3
4
  import {createSelector} from 'reselect'
4
5
 
6
+ import {isReleasePerspective} from '../releases/utils/isReleasePerspective'
5
7
  import {type SelectorContext} from '../store/createStateSourceAction'
6
- import {getDraftId, getPublishedId} from '../utils/ids'
7
8
  import {MultiKeyWeakMap} from '../utils/MultiKeyWeakMap'
8
9
  import {type DocumentAction} from './actions'
9
- import {ActionError, PermissionActionError, processActions} from './processActions'
10
+ import {ActionError, PermissionActionError, processActions} from './processActions/processActions'
11
+ import {checkGrant} from './processActions/shared'
10
12
  import {type DocumentSet} from './processMutations'
11
13
  import {type SyncTransactionState} from './reducers'
12
14
 
@@ -28,6 +30,8 @@ export function createGrantsLookup(datasetAcl: DatasetAcl): Record<Grant, ExprNo
28
30
  for (const entry of datasetAcl) {
29
31
  for (const grant of entry.permissions) {
30
32
  const set = filtersByGrant[grant]
33
+ // Ignore permissions we don't model (e.g. "manage")
34
+ if (!set) continue
31
35
  set.add(entry.filter)
32
36
  filtersByGrant[grant] = set
33
37
  }
@@ -71,7 +75,14 @@ const documentsSelector = createSelector(
71
75
  // For liveEdit documents, only fetch the single document
72
76
  if (action.liveEdit) return [action.documentId]
73
77
  // For standard documents, fetch both draft and published
74
- return [getPublishedId(action.documentId), getDraftId(action.documentId)]
78
+ const ids: string[] = [
79
+ getPublishedId(DocumentId(action.documentId)),
80
+ getDraftId(DocumentId(action.documentId)),
81
+ ]
82
+ if (isReleasePerspective(action.perspective)) {
83
+ ids.push(getVersionId(DocumentId(action.documentId), action.perspective.releaseName))
84
+ }
85
+ return ids
75
86
  })
76
87
  .flat(),
77
88
  )
@@ -132,11 +143,6 @@ const memoizedActionsSelector = createSelector(
132
143
  },
133
144
  )
134
145
 
135
- function checkGrant(grantExpr: ExprNode, document: SanityDocument): boolean {
136
- const value = evaluateSync(grantExpr, {params: {document}})
137
- return value.type === 'boolean' && value.data
138
- }
139
-
140
146
  /** @beta */
141
147
  export interface PermissionDeniedReason {
142
148
  type: 'precondition' | 'access'
@@ -164,11 +170,13 @@ export function calculatePermissions(
164
170
  const _calculatePermissions = createSelector(
165
171
  [
166
172
  ({state: {grants}}: SelectorContext<SyncTransactionState>) => grants,
173
+ ({state: {identity}}: SelectorContext<SyncTransactionState>) => identity,
167
174
  documentsSelector,
168
175
  memoizedActionsSelector,
169
176
  ],
170
177
  (
171
178
  grants: Record<Grant, ExprNode> | undefined,
179
+ identity: string | undefined,
172
180
  documents: DocumentSet | undefined,
173
181
  actions: DocumentAction[] | undefined,
174
182
  ): DocumentPermissionsResult | undefined => {
@@ -187,6 +195,7 @@ const _calculatePermissions = createSelector(
187
195
  base: documents,
188
196
  timestamp,
189
197
  grants,
198
+ identity,
190
199
  })
191
200
  } catch (error) {
192
201
  if (error instanceof PermissionActionError) {
@@ -210,17 +219,33 @@ const _calculatePermissions = createSelector(
210
219
  // Check edit actions with no patches
211
220
  if (action.type === 'document.edit' && !action.patches?.length) {
212
221
  const docId = action.documentId
213
- // For liveEdit documents, only check the single document
214
- const doc = action.liveEdit
215
- ? documents[docId]
216
- : (documents[getDraftId(docId)] ?? documents[getPublishedId(docId)])
222
+ let doc: SanityDocument | null | undefined
223
+ if (action.liveEdit) {
224
+ doc = documents[docId]
225
+ }
226
+ // don't allow users to edit version documents that don't exist
227
+ // they should be explicitly created first, as in studio
228
+ else if (isReleasePerspective(action.perspective)) {
229
+ doc = documents[getVersionId(DocumentId(docId), action.perspective.releaseName)]
230
+ } else {
231
+ doc =
232
+ documents[getDraftId(DocumentId(docId))] ?? documents[getPublishedId(DocumentId(docId))]
233
+ }
217
234
  if (!doc) {
218
- reasons.push({
219
- type: 'precondition',
220
- message: `The document with ID "${docId}" could not be found. Please check that it exists before editing.`,
221
- documentId: docId,
222
- })
223
- } else if (!checkGrant(grants.update, doc)) {
235
+ if (isReleasePerspective(action.perspective)) {
236
+ reasons.push({
237
+ type: 'precondition',
238
+ message: `The version document with ID "${docId}" could not be found. Please create it or add it to the release first.`,
239
+ documentId: docId,
240
+ })
241
+ } else {
242
+ reasons.push({
243
+ type: 'precondition',
244
+ message: `The document with ID "${docId}" could not be found. Please check that it exists before editing.`,
245
+ documentId: docId,
246
+ })
247
+ }
248
+ } else if (!checkGrant(grants.update, doc, identity)) {
224
249
  reasons.push({
225
250
  type: 'access',
226
251
  message: `You are not allowed to edit the document with ID "${docId}".`,
@@ -0,0 +1,138 @@
1
+ import {DocumentId, getDraftId, getPublishedId, getVersionId} from '@sanity/id-utils'
2
+ import {type Mutation, type SanityDocument} from '@sanity/types'
3
+
4
+ import {isReleasePerspective} from '../../releases/utils/isReleasePerspective'
5
+ import {type CreateDocumentAction} from '../actions'
6
+ import {getId, processMutations} from '../processMutations'
7
+ import {
8
+ ActionError,
9
+ type ActionHandlerContext,
10
+ type ActionHandlerResult,
11
+ checkGrant,
12
+ PermissionActionError,
13
+ } from './shared'
14
+
15
+ export function handleCreate(
16
+ action: CreateDocumentAction,
17
+ ctx: ActionHandlerContext,
18
+ ): ActionHandlerResult {
19
+ const {transactionId, timestamp, grants, identity, outgoingActions, outgoingMutations} = ctx
20
+ let {base, working} = ctx
21
+
22
+ const documentId = getId(action.documentId)
23
+
24
+ if (action.liveEdit) {
25
+ if (working[documentId]) {
26
+ throw new ActionError({
27
+ documentId,
28
+ transactionId,
29
+ message: `This document already exists.`,
30
+ })
31
+ }
32
+
33
+ const newDocBase = {_type: action.documentType, _id: documentId, ...action.initialValue}
34
+ const newDocWorking = {
35
+ _type: action.documentType,
36
+ _id: documentId,
37
+ ...action.initialValue,
38
+ }
39
+ const mutations: Mutation[] = [{create: newDocWorking}]
40
+
41
+ base = processMutations({
42
+ documents: base,
43
+ transactionId,
44
+ mutations: [{create: newDocBase}],
45
+ timestamp,
46
+ })
47
+ working = processMutations({
48
+ documents: working,
49
+ transactionId,
50
+ mutations,
51
+ timestamp,
52
+ })
53
+
54
+ if (!checkGrant(grants.create, working[documentId] as SanityDocument, identity)) {
55
+ throw new PermissionActionError({
56
+ documentId,
57
+ transactionId,
58
+ message: `You do not have permission to create document "${documentId}".`,
59
+ })
60
+ }
61
+
62
+ // liveEdit documents use the mutation endpoint directly -- we don't send actions
63
+ outgoingMutations.push(...mutations)
64
+ return {base, working}
65
+ }
66
+
67
+ // Standard draft/published/version logic
68
+ const versionId = isReleasePerspective(action.perspective)
69
+ ? getVersionId(DocumentId(documentId), action.perspective.releaseName)
70
+ : undefined
71
+ const draftId = getDraftId(DocumentId(documentId))
72
+ const publishedId = getPublishedId(DocumentId(documentId))
73
+
74
+ const alreadyHasVersion = versionId ? working[versionId] : working[draftId]
75
+
76
+ if (alreadyHasVersion) {
77
+ const errorDocType = versionId ? 'release version' : 'draft'
78
+ throw new ActionError({
79
+ documentId,
80
+ transactionId,
81
+ message: `A ${errorDocType} of this document already exists. Please use or discard the existing ${errorDocType} before creating a new one.`,
82
+ })
83
+ }
84
+
85
+ // Spread the (possibly undefined) draft or published version directly.
86
+ // (studio uses the draft version as a base if you are in a release perspective)
87
+ const newDocBase = {
88
+ ...(base[draftId] ?? base[publishedId]),
89
+ _type: action.documentType,
90
+ _id: versionId ?? draftId,
91
+ ...action.initialValue,
92
+ }
93
+ const newDocWorking = {
94
+ ...(working[draftId] ?? working[publishedId]),
95
+ _type: action.documentType,
96
+ _id: versionId ?? draftId,
97
+ ...action.initialValue,
98
+ }
99
+ const mutations: Mutation[] = [{create: newDocWorking}]
100
+
101
+ base = processMutations({
102
+ documents: base,
103
+ transactionId,
104
+ mutations: [{create: newDocBase}],
105
+ timestamp,
106
+ })
107
+ working = processMutations({
108
+ documents: working,
109
+ transactionId,
110
+ mutations,
111
+ timestamp,
112
+ })
113
+
114
+ if (versionId && !checkGrant(grants.create, working[versionId] as SanityDocument, identity)) {
115
+ throw new PermissionActionError({
116
+ documentId,
117
+ transactionId,
118
+ message: `You do not have permission to create a release version for document "${documentId}".`,
119
+ })
120
+ } else if (
121
+ !versionId &&
122
+ !checkGrant(grants.create, working[draftId] as SanityDocument, identity)
123
+ ) {
124
+ throw new PermissionActionError({
125
+ documentId,
126
+ transactionId,
127
+ message: `You do not have permission to create a draft for document "${documentId}".`,
128
+ })
129
+ }
130
+
131
+ outgoingMutations.push(...mutations)
132
+ outgoingActions.push({
133
+ actionType: 'sanity.action.document.version.create',
134
+ publishedId,
135
+ attributes: newDocWorking,
136
+ })
137
+ return {base, working}
138
+ }