@sanity/sdk 2.11.1 → 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.
- package/dist/_chunks-dts/utils.d.ts +175 -19
- package/dist/_chunks-es/_internal.js +41 -26
- package/dist/_chunks-es/_internal.js.map +1 -1
- package/dist/_chunks-es/createGroqSearchFilter.js +15 -4
- package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -1
- package/dist/_chunks-es/telemetryManager.js +25 -19
- package/dist/_chunks-es/telemetryManager.js.map +1 -1
- package/dist/_chunks-es/version.js +1 -1
- package/dist/_exports/_internal.d.ts +27 -11
- package/dist/index.d.ts +2 -2
- package/dist/index.js +465 -131
- package/dist/index.js.map +1 -1
- package/package.json +11 -11
- package/src/_exports/index.ts +23 -2
- package/src/config/sanityConfig.ts +12 -0
- package/src/document/actions.test.ts +112 -1
- package/src/document/actions.ts +148 -1
- package/src/document/applyDocumentActions.test.ts +24 -0
- package/src/document/applyDocumentActions.ts +17 -5
- package/src/document/documentConstants.ts +7 -0
- package/src/document/documentStore.test.ts +69 -0
- package/src/document/documentStore.ts +42 -10
- package/src/document/events.test.ts +57 -2
- package/src/document/events.ts +43 -24
- package/src/document/listen.ts +1 -1
- package/src/document/permissions.test.ts +79 -0
- package/src/document/permissions.ts +8 -7
- package/src/document/processActions/create.ts +7 -4
- package/src/document/processActions/delete.ts +4 -4
- package/src/document/processActions/discard.ts +2 -2
- package/src/document/processActions/edit.ts +13 -47
- package/src/document/processActions/processActions.ts +53 -3
- package/src/document/processActions/publish.ts +4 -4
- package/src/document/processActions/releaseArchive.ts +77 -0
- package/src/document/processActions/releaseCreate.ts +59 -0
- package/src/document/processActions/releaseDelete.ts +65 -0
- package/src/document/processActions/releaseEdit.ts +37 -0
- package/src/document/processActions/releasePublish.ts +45 -0
- package/src/document/processActions/releaseSchedule.ts +87 -0
- package/src/document/processActions/releaseUtil.ts +31 -0
- package/src/document/processActions/shared.ts +108 -4
- package/src/document/processActions/unpublish.ts +3 -3
- package/src/document/processActions.test.ts +423 -1
- package/src/document/reducers.ts +44 -8
- package/src/document/resourceRules.test.ts +178 -0
- package/src/document/resourceRules.ts +117 -0
- package/src/releases/getPerspectiveState.test.ts +1 -1
- package/src/releases/releasesStore.test.ts +50 -1
- package/src/releases/releasesStore.ts +41 -18
- package/src/releases/utils/sortReleases.test.ts +2 -2
- package/src/releases/utils/sortReleases.ts +1 -1
- package/src/telemetry/environment.test.ts +119 -0
- package/src/telemetry/environment.ts +92 -0
- package/src/telemetry/{__telemetry__/sdk.telemetry.ts → events.ts} +9 -9
- package/src/telemetry/initTelemetry.test.ts +240 -16
- package/src/telemetry/initTelemetry.ts +39 -16
- package/src/telemetry/telemetryManager.test.ts +129 -65
- package/src/telemetry/telemetryManager.ts +41 -29
- package/src/telemetry/devMode.test.ts +0 -60
- package/src/telemetry/devMode.ts +0 -41
|
@@ -17,15 +17,18 @@ import {
|
|
|
17
17
|
Observable,
|
|
18
18
|
of,
|
|
19
19
|
pairwise,
|
|
20
|
+
retry,
|
|
20
21
|
startWith,
|
|
21
22
|
Subject,
|
|
22
23
|
switchMap,
|
|
23
24
|
tap,
|
|
24
25
|
throttle,
|
|
26
|
+
throwError,
|
|
25
27
|
timer,
|
|
26
28
|
withLatestFrom,
|
|
27
29
|
} from 'rxjs'
|
|
28
30
|
|
|
31
|
+
import {getCurrentUserState} from '../auth/authStore'
|
|
29
32
|
import {type ClientOptions, getClientState} from '../client/clientStore'
|
|
30
33
|
import {
|
|
31
34
|
type DocumentHandle,
|
|
@@ -44,7 +47,12 @@ import {type SanityInstance} from '../store/createSanityInstance'
|
|
|
44
47
|
import {createStateSourceAction, type StateSource} from '../store/createStateSourceAction'
|
|
45
48
|
import {defineStore, type StoreContext} from '../store/defineStore'
|
|
46
49
|
import {type DocumentAction} from './actions'
|
|
47
|
-
import {
|
|
50
|
+
import {
|
|
51
|
+
API_VERSION,
|
|
52
|
+
INITIAL_OUTGOING_THROTTLE_TIME,
|
|
53
|
+
OUT_OF_SYNC_RETRY_BASE_DELAY,
|
|
54
|
+
OUT_OF_SYNC_RETRY_MAX_DELAY,
|
|
55
|
+
} from './documentConstants'
|
|
48
56
|
import {
|
|
49
57
|
type DocumentEvent,
|
|
50
58
|
type DocumentTransactionSubmissionResult,
|
|
@@ -60,6 +68,7 @@ import {
|
|
|
60
68
|
type Grant,
|
|
61
69
|
} from './permissions'
|
|
62
70
|
import {ActionError} from './processActions/processActions'
|
|
71
|
+
import {isReleaseAction} from './processActions/releaseUtil'
|
|
63
72
|
import {
|
|
64
73
|
type AppliedTransaction,
|
|
65
74
|
applyFirstQueuedTransaction,
|
|
@@ -81,6 +90,10 @@ export interface DocumentStoreState {
|
|
|
81
90
|
applied: AppliedTransaction[]
|
|
82
91
|
outgoing?: OutgoingTransaction
|
|
83
92
|
grants?: Record<Grant, ExprNode>
|
|
93
|
+
/**
|
|
94
|
+
* The current user's identity (their user ID).
|
|
95
|
+
*/
|
|
96
|
+
identity?: string
|
|
84
97
|
error?: unknown
|
|
85
98
|
sharedListener: SharedListener
|
|
86
99
|
fetchDocument: (documentId: string) => Observable<SanityDocument | null>
|
|
@@ -140,6 +153,7 @@ export const documentStore = defineStore<DocumentStoreState, BoundResourceKey>({
|
|
|
140
153
|
subscribeToSubscriptionsAndListenToDocuments(context),
|
|
141
154
|
subscribeToAppliedAndSubmitNextTransaction(context),
|
|
142
155
|
subscribeToClientAndFetchDatasetAcl(context),
|
|
156
|
+
subscribeToCurrentUserAndSetIdentity(context),
|
|
143
157
|
]
|
|
144
158
|
|
|
145
159
|
return () => {
|
|
@@ -414,10 +428,11 @@ const subscribeToAppliedAndSubmitNextTransaction = ({
|
|
|
414
428
|
outgoing,
|
|
415
429
|
}))
|
|
416
430
|
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
|
|
431
|
+
// liveEdit transactions route to the mutations API; everything else routes
|
|
432
|
+
// to the actions API. processActions rejects transactions that mix the two,
|
|
433
|
+
// and reducers won't batch across that boundary, so a batch is always
|
|
434
|
+
// entirely liveEdit or entirely not.
|
|
435
|
+
if (outgoing.actions.some((action) => !isReleaseAction(action) && action.liveEdit)) {
|
|
421
436
|
return client.observable
|
|
422
437
|
.mutate(outgoing.outgoingMutations as Mutation[], {
|
|
423
438
|
transactionId: outgoing.transactionId,
|
|
@@ -430,7 +445,6 @@ const subscribeToAppliedAndSubmitNextTransaction = ({
|
|
|
430
445
|
.pipe(revertOnError, toResult)
|
|
431
446
|
}
|
|
432
447
|
|
|
433
|
-
// Pure non-liveEdit transactions use the actions API.
|
|
434
448
|
return client.observable
|
|
435
449
|
.action(outgoing.outgoingActions as Action[], {
|
|
436
450
|
transactionId: outgoing.transactionId,
|
|
@@ -496,10 +510,15 @@ const subscribeToSubscriptionsAndListenToDocuments = (
|
|
|
496
510
|
switchMap((e) => {
|
|
497
511
|
if (!e.add) return EMPTY
|
|
498
512
|
return listen(context, e.id).pipe(
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
+
},
|
|
503
522
|
}),
|
|
504
523
|
tap((remote) =>
|
|
505
524
|
state.set('applyRemoteDocument', (prev) =>
|
|
@@ -547,3 +566,16 @@ const subscribeToClientAndFetchDatasetAcl = ({
|
|
|
547
566
|
error: (error) => state.set('setError', {error}),
|
|
548
567
|
})
|
|
549
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
|
+
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {describe, expect, it} from 'vitest'
|
|
2
2
|
|
|
3
|
-
import {type DocumentAction} from '../document/actions'
|
|
3
|
+
import {type Action, type DocumentAction} from '../document/actions'
|
|
4
4
|
import {type DocumentEvent, getDocumentEvents} from '../document/events'
|
|
5
5
|
import {type OutgoingTransaction} from '../document/reducers'
|
|
6
6
|
|
|
@@ -41,11 +41,66 @@ describe('getDocumentEvents', () => {
|
|
|
41
41
|
expect(events).toHaveLength(outgoing.actions.length)
|
|
42
42
|
events.forEach((event) => {
|
|
43
43
|
const action = outgoing.actions.find(
|
|
44
|
-
(a) => 'documentId' in event && event.documentId === a.documentId,
|
|
44
|
+
(a) => 'documentId' in a && 'documentId' in event && event.documentId === a.documentId,
|
|
45
45
|
)
|
|
46
46
|
expect(action).toBeDefined()
|
|
47
47
|
expect(event.type).toEqual(expectedMap[action!.type])
|
|
48
48
|
expect((event as Extract<DocumentEvent, {outgoing: unknown}>).outgoing).toBe(outgoing)
|
|
49
49
|
})
|
|
50
50
|
})
|
|
51
|
+
|
|
52
|
+
it('emits created/edited/deleted events for release.create/edit/delete with release doc IDs', () => {
|
|
53
|
+
const outgoing: OutgoingTransaction = {
|
|
54
|
+
transactionId: 'txn-release',
|
|
55
|
+
actions: [
|
|
56
|
+
{type: 'release.create', releaseId: 'r1'} as Action,
|
|
57
|
+
{type: 'release.edit', releaseId: 'r2', patch: {set: {}}} as Action,
|
|
58
|
+
{type: 'release.delete', releaseId: 'r3'} as Action,
|
|
59
|
+
],
|
|
60
|
+
disableBatching: false,
|
|
61
|
+
batchedTransactionIds: [],
|
|
62
|
+
outgoingActions: [],
|
|
63
|
+
outgoingMutations: [],
|
|
64
|
+
base: {},
|
|
65
|
+
working: {},
|
|
66
|
+
previous: {},
|
|
67
|
+
previousRevs: {},
|
|
68
|
+
timestamp: '2025-02-06T00:00:00.000Z',
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const events = getDocumentEvents(outgoing)
|
|
72
|
+
expect(events).toEqual([
|
|
73
|
+
{type: 'created', documentId: '_.releases.r1', outgoing},
|
|
74
|
+
{type: 'edited', documentId: '_.releases.r2', outgoing},
|
|
75
|
+
{type: 'deleted', documentId: '_.releases.r3', outgoing},
|
|
76
|
+
])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('skips release actions that have no local mutation (publish/schedule/archive/etc.)', () => {
|
|
80
|
+
const outgoing: OutgoingTransaction = {
|
|
81
|
+
transactionId: 'txn-release-noop',
|
|
82
|
+
actions: [
|
|
83
|
+
{type: 'release.publish', releaseId: 'r1'} as Action,
|
|
84
|
+
{
|
|
85
|
+
type: 'release.schedule',
|
|
86
|
+
releaseId: 'r2',
|
|
87
|
+
publishAt: '2026-01-01T00:00:00.000Z',
|
|
88
|
+
} as Action,
|
|
89
|
+
{type: 'release.unschedule', releaseId: 'r3'} as Action,
|
|
90
|
+
{type: 'release.archive', releaseId: 'r4'} as Action,
|
|
91
|
+
{type: 'release.unarchive', releaseId: 'r5'} as Action,
|
|
92
|
+
],
|
|
93
|
+
disableBatching: false,
|
|
94
|
+
batchedTransactionIds: [],
|
|
95
|
+
outgoingActions: [],
|
|
96
|
+
outgoingMutations: [],
|
|
97
|
+
base: {},
|
|
98
|
+
working: {},
|
|
99
|
+
previous: {},
|
|
100
|
+
previousRevs: {},
|
|
101
|
+
timestamp: '2025-02-06T00:00:00.000Z',
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
expect(getDocumentEvents(outgoing)).toEqual([])
|
|
105
|
+
})
|
|
51
106
|
})
|
package/src/document/events.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {type MultipleMutationResult, type SanityClient} from '@sanity/client'
|
|
2
2
|
|
|
3
|
-
import {type DocumentAction} from './actions'
|
|
3
|
+
import {type DocumentAction, type ReleaseAction} from './actions'
|
|
4
|
+
import {getReleaseDocumentId, isReleaseAction} from './processActions/releaseUtil'
|
|
4
5
|
import {type OutgoingTransaction} from './reducers'
|
|
5
6
|
|
|
6
7
|
/** @beta Response body from submitting an outgoing transaction (actions or mutations API). */
|
|
@@ -118,31 +119,49 @@ export interface DocumentDiscardedEvent {
|
|
|
118
119
|
outgoing: OutgoingTransaction
|
|
119
120
|
}
|
|
120
121
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
122
|
+
// Release actions that write a mutation to the local release doc map onto
|
|
123
|
+
// the regular per-document events with `documentId = '_.releases.<releaseId>'`.
|
|
124
|
+
// The other release actions (publish/schedule/unschedule/archive/unarchive)
|
|
125
|
+
// don't mutate local state, so they aren't in the map and get skipped — they
|
|
126
|
+
// surface through the transaction-level `accepted`/`reverted` events instead.
|
|
127
|
+
const actionMap = {
|
|
128
|
+
'document.create': 'created',
|
|
129
|
+
'document.delete': 'deleted',
|
|
130
|
+
'document.discard': 'discarded',
|
|
131
|
+
'document.edit': 'edited',
|
|
132
|
+
'document.publish': 'published',
|
|
133
|
+
'document.unpublish': 'unpublished',
|
|
134
|
+
'release.create': 'created',
|
|
135
|
+
'release.edit': 'edited',
|
|
136
|
+
'release.delete': 'deleted',
|
|
137
|
+
} satisfies Partial<Record<DocumentAction['type'] | ReleaseAction['type'], DocumentEvent['type']>>
|
|
138
|
+
|
|
139
|
+
type MappedActionType = keyof typeof actionMap
|
|
133
140
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
141
|
+
export function getDocumentEvents(outgoing: OutgoingTransaction): DocumentEvent[] {
|
|
142
|
+
const documentIdsByAction = outgoing.actions.reduce(
|
|
143
|
+
(acc, action) => {
|
|
144
|
+
if (!(action.type in actionMap)) return acc
|
|
145
|
+
const documentId = isReleaseAction(action)
|
|
146
|
+
? getReleaseDocumentId(action.releaseId)
|
|
147
|
+
: action.documentId
|
|
148
|
+
if (!documentId) return acc
|
|
149
|
+
const type = action.type as MappedActionType
|
|
150
|
+
const ids = acc[type] ?? new Set<string>()
|
|
151
|
+
ids.add(documentId)
|
|
152
|
+
acc[type] = ids
|
|
153
|
+
return acc
|
|
154
|
+
},
|
|
155
|
+
{} as Partial<Record<MappedActionType, Set<string>>>,
|
|
156
|
+
)
|
|
142
157
|
|
|
143
|
-
return documentIdsByAction.flatMap(([actionType, documentIds]) =>
|
|
144
|
-
Array.from(documentIds).map(
|
|
145
|
-
(documentId): DocumentEvent => ({
|
|
158
|
+
return Object.entries(documentIdsByAction).flatMap(([actionType, documentIds]) =>
|
|
159
|
+
Array.from(documentIds ?? []).map(
|
|
160
|
+
(documentId): DocumentEvent => ({
|
|
161
|
+
type: actionMap[actionType as MappedActionType],
|
|
162
|
+
documentId,
|
|
163
|
+
outgoing,
|
|
164
|
+
}),
|
|
146
165
|
),
|
|
147
166
|
)
|
|
148
167
|
}
|
package/src/document/listen.ts
CHANGED
|
@@ -20,7 +20,7 @@ import {type StoreContext} from '../store/defineStore'
|
|
|
20
20
|
import {type DocumentStoreState} from './documentStore'
|
|
21
21
|
import {processMutations} from './processMutations'
|
|
22
22
|
|
|
23
|
-
const DEFAULT_MAX_BUFFER_SIZE = 20
|
|
23
|
+
export const DEFAULT_MAX_BUFFER_SIZE = 20
|
|
24
24
|
const DEFAULT_DEADLINE_MS = 30000
|
|
25
25
|
|
|
26
26
|
export interface RemoteDocument {
|
|
@@ -32,9 +32,11 @@ const alwaysDeny = parse('false')
|
|
|
32
32
|
const createState = (
|
|
33
33
|
docStates: Record<string, {local: unknown}>,
|
|
34
34
|
grants?: Record<Grant, ExprNode>,
|
|
35
|
+
identity?: string,
|
|
35
36
|
): SyncTransactionState => ({
|
|
36
37
|
documentStates: docStates as SyncTransactionState['documentStates'],
|
|
37
38
|
grants,
|
|
39
|
+
identity,
|
|
38
40
|
queued: [],
|
|
39
41
|
applied: [],
|
|
40
42
|
})
|
|
@@ -52,6 +54,18 @@ describe('createGrantsLookup', () => {
|
|
|
52
54
|
expect(evaluateSync(grants[key], {params: {document: dummyDoc}}).data).toBe(true)
|
|
53
55
|
})
|
|
54
56
|
})
|
|
57
|
+
|
|
58
|
+
it('should ignore unknown permissions like "manage"', () => {
|
|
59
|
+
const datasetAcl = [
|
|
60
|
+
{filter: '_id != null', permissions: ['history', 'read', 'update', 'create', 'manage']},
|
|
61
|
+
] as DatasetAcl
|
|
62
|
+
const grants = createGrantsLookup(datasetAcl)
|
|
63
|
+
const dummyDoc = {_id: 'doc1'}
|
|
64
|
+
;(['read', 'update', 'create', 'history'] as Grant[]).forEach((key) => {
|
|
65
|
+
expect(grants[key]).toBeDefined()
|
|
66
|
+
expect(evaluateSync(grants[key], {params: {document: dummyDoc}}).data).toBe(true)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
55
69
|
})
|
|
56
70
|
|
|
57
71
|
describe('calculatePermissions', () => {
|
|
@@ -234,4 +248,69 @@ describe('calculatePermissions', () => {
|
|
|
234
248
|
const result2 = calculatePermissions({instance, state}, {actions: [{...action}]})
|
|
235
249
|
expect(result1).toBe(result2)
|
|
236
250
|
})
|
|
251
|
+
|
|
252
|
+
describe('identity-aware grants', () => {
|
|
253
|
+
// Mirrors the canvas ACL filter shape: only the document's creator may
|
|
254
|
+
// update it. Without an identity passed to groq, `identity()` is null and
|
|
255
|
+
// the expression collapses to null — which used to read as "denied".
|
|
256
|
+
const canvasUpdateAcl: DatasetAcl = [
|
|
257
|
+
{
|
|
258
|
+
filter: '_type == "sanity.canvas.document" && _system.createdBy == identity()',
|
|
259
|
+
permissions: ['read', 'update'],
|
|
260
|
+
},
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
const canvasDoc = (createdBy: string): SanityDocument => ({
|
|
264
|
+
_id: 'canvas-1',
|
|
265
|
+
_type: 'sanity.canvas.document',
|
|
266
|
+
_createdAt: '2025-01-01T00:00:00.000Z',
|
|
267
|
+
_updatedAt: '2025-01-01T00:00:00.000Z',
|
|
268
|
+
_rev: 'rev-1',
|
|
269
|
+
_system: {createdBy},
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
const editAction: DocumentAction = {
|
|
273
|
+
documentId: 'canvas-1',
|
|
274
|
+
documentType: 'sanity.canvas.document',
|
|
275
|
+
type: 'document.edit',
|
|
276
|
+
liveEdit: true,
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
it('allows edits when identity matches the document creator', () => {
|
|
280
|
+
const state = createState(
|
|
281
|
+
{'canvas-1': {local: canvasDoc('user-A')}},
|
|
282
|
+
createGrantsLookup(canvasUpdateAcl),
|
|
283
|
+
'user-A',
|
|
284
|
+
)
|
|
285
|
+
expect(calculatePermissions({instance, state}, {actions: [editAction]})).toEqual({
|
|
286
|
+
allowed: true,
|
|
287
|
+
})
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('denies edits when identity does not match the document creator', () => {
|
|
291
|
+
const state = createState(
|
|
292
|
+
{'canvas-1': {local: canvasDoc('user-A')}},
|
|
293
|
+
createGrantsLookup(canvasUpdateAcl),
|
|
294
|
+
'user-B',
|
|
295
|
+
)
|
|
296
|
+
const result = calculatePermissions({instance, state}, {actions: [editAction]})
|
|
297
|
+
expect(result?.allowed).toBe(false)
|
|
298
|
+
expect(result?.reasons).toEqual(
|
|
299
|
+
expect.arrayContaining([expect.objectContaining({type: 'access', documentId: 'canvas-1'})]),
|
|
300
|
+
)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('denies edits when identity is not yet loaded', () => {
|
|
304
|
+
const state = createState(
|
|
305
|
+
{'canvas-1': {local: canvasDoc('user-A')}},
|
|
306
|
+
createGrantsLookup(canvasUpdateAcl),
|
|
307
|
+
undefined,
|
|
308
|
+
)
|
|
309
|
+
const result = calculatePermissions({instance, state}, {actions: [editAction]})
|
|
310
|
+
expect(result?.allowed).toBe(false)
|
|
311
|
+
expect(result?.reasons).toEqual(
|
|
312
|
+
expect.arrayContaining([expect.objectContaining({type: 'access', documentId: 'canvas-1'})]),
|
|
313
|
+
)
|
|
314
|
+
})
|
|
315
|
+
})
|
|
237
316
|
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {DocumentId, getDraftId, getPublishedId, getVersionId} from '@sanity/id-utils'
|
|
2
2
|
import {type SanityDocument} from '@sanity/types'
|
|
3
|
-
import {
|
|
3
|
+
import {type ExprNode, parse} from 'groq-js'
|
|
4
4
|
import {createSelector} from 'reselect'
|
|
5
5
|
|
|
6
6
|
import {isReleasePerspective} from '../releases/utils/isReleasePerspective'
|
|
@@ -8,6 +8,7 @@ import {type SelectorContext} from '../store/createStateSourceAction'
|
|
|
8
8
|
import {MultiKeyWeakMap} from '../utils/MultiKeyWeakMap'
|
|
9
9
|
import {type DocumentAction} from './actions'
|
|
10
10
|
import {ActionError, PermissionActionError, processActions} from './processActions/processActions'
|
|
11
|
+
import {checkGrant} from './processActions/shared'
|
|
11
12
|
import {type DocumentSet} from './processMutations'
|
|
12
13
|
import {type SyncTransactionState} from './reducers'
|
|
13
14
|
|
|
@@ -29,6 +30,8 @@ export function createGrantsLookup(datasetAcl: DatasetAcl): Record<Grant, ExprNo
|
|
|
29
30
|
for (const entry of datasetAcl) {
|
|
30
31
|
for (const grant of entry.permissions) {
|
|
31
32
|
const set = filtersByGrant[grant]
|
|
33
|
+
// Ignore permissions we don't model (e.g. "manage")
|
|
34
|
+
if (!set) continue
|
|
32
35
|
set.add(entry.filter)
|
|
33
36
|
filtersByGrant[grant] = set
|
|
34
37
|
}
|
|
@@ -140,11 +143,6 @@ const memoizedActionsSelector = createSelector(
|
|
|
140
143
|
},
|
|
141
144
|
)
|
|
142
145
|
|
|
143
|
-
function checkGrant(grantExpr: ExprNode, document: SanityDocument): boolean {
|
|
144
|
-
const value = evaluateSync(grantExpr, {params: {document}})
|
|
145
|
-
return value.type === 'boolean' && value.data
|
|
146
|
-
}
|
|
147
|
-
|
|
148
146
|
/** @beta */
|
|
149
147
|
export interface PermissionDeniedReason {
|
|
150
148
|
type: 'precondition' | 'access'
|
|
@@ -172,11 +170,13 @@ export function calculatePermissions(
|
|
|
172
170
|
const _calculatePermissions = createSelector(
|
|
173
171
|
[
|
|
174
172
|
({state: {grants}}: SelectorContext<SyncTransactionState>) => grants,
|
|
173
|
+
({state: {identity}}: SelectorContext<SyncTransactionState>) => identity,
|
|
175
174
|
documentsSelector,
|
|
176
175
|
memoizedActionsSelector,
|
|
177
176
|
],
|
|
178
177
|
(
|
|
179
178
|
grants: Record<Grant, ExprNode> | undefined,
|
|
179
|
+
identity: string | undefined,
|
|
180
180
|
documents: DocumentSet | undefined,
|
|
181
181
|
actions: DocumentAction[] | undefined,
|
|
182
182
|
): DocumentPermissionsResult | undefined => {
|
|
@@ -195,6 +195,7 @@ const _calculatePermissions = createSelector(
|
|
|
195
195
|
base: documents,
|
|
196
196
|
timestamp,
|
|
197
197
|
grants,
|
|
198
|
+
identity,
|
|
198
199
|
})
|
|
199
200
|
} catch (error) {
|
|
200
201
|
if (error instanceof PermissionActionError) {
|
|
@@ -244,7 +245,7 @@ const _calculatePermissions = createSelector(
|
|
|
244
245
|
documentId: docId,
|
|
245
246
|
})
|
|
246
247
|
}
|
|
247
|
-
} else if (!checkGrant(grants.update, doc)) {
|
|
248
|
+
} else if (!checkGrant(grants.update, doc, identity)) {
|
|
248
249
|
reasons.push({
|
|
249
250
|
type: 'access',
|
|
250
251
|
message: `You are not allowed to edit the document with ID "${docId}".`,
|
|
@@ -16,7 +16,7 @@ export function handleCreate(
|
|
|
16
16
|
action: CreateDocumentAction,
|
|
17
17
|
ctx: ActionHandlerContext,
|
|
18
18
|
): ActionHandlerResult {
|
|
19
|
-
const {transactionId, timestamp, grants, outgoingActions, outgoingMutations} = ctx
|
|
19
|
+
const {transactionId, timestamp, grants, identity, outgoingActions, outgoingMutations} = ctx
|
|
20
20
|
let {base, working} = ctx
|
|
21
21
|
|
|
22
22
|
const documentId = getId(action.documentId)
|
|
@@ -51,7 +51,7 @@ export function handleCreate(
|
|
|
51
51
|
timestamp,
|
|
52
52
|
})
|
|
53
53
|
|
|
54
|
-
if (!checkGrant(grants.create, working[documentId] as SanityDocument)) {
|
|
54
|
+
if (!checkGrant(grants.create, working[documentId] as SanityDocument, identity)) {
|
|
55
55
|
throw new PermissionActionError({
|
|
56
56
|
documentId,
|
|
57
57
|
transactionId,
|
|
@@ -111,13 +111,16 @@ export function handleCreate(
|
|
|
111
111
|
timestamp,
|
|
112
112
|
})
|
|
113
113
|
|
|
114
|
-
if (versionId && !checkGrant(grants.create, working[versionId] as SanityDocument)) {
|
|
114
|
+
if (versionId && !checkGrant(grants.create, working[versionId] as SanityDocument, identity)) {
|
|
115
115
|
throw new PermissionActionError({
|
|
116
116
|
documentId,
|
|
117
117
|
transactionId,
|
|
118
118
|
message: `You do not have permission to create a release version for document "${documentId}".`,
|
|
119
119
|
})
|
|
120
|
-
} else if (
|
|
120
|
+
} else if (
|
|
121
|
+
!versionId &&
|
|
122
|
+
!checkGrant(grants.create, working[draftId] as SanityDocument, identity)
|
|
123
|
+
) {
|
|
121
124
|
throw new PermissionActionError({
|
|
122
125
|
documentId,
|
|
123
126
|
transactionId,
|
|
@@ -16,7 +16,7 @@ export function handleDelete(
|
|
|
16
16
|
action: DeleteDocumentAction,
|
|
17
17
|
ctx: ActionHandlerContext,
|
|
18
18
|
): ActionHandlerResult {
|
|
19
|
-
const {transactionId, timestamp, grants, outgoingActions, outgoingMutations} = ctx
|
|
19
|
+
const {transactionId, timestamp, grants, identity, outgoingActions, outgoingMutations} = ctx
|
|
20
20
|
let {base, working} = ctx
|
|
21
21
|
|
|
22
22
|
const documentId = action.documentId
|
|
@@ -38,7 +38,7 @@ export function handleDelete(
|
|
|
38
38
|
})
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
if (!checkGrant(grants.update, working[documentId])) {
|
|
41
|
+
if (!checkGrant(grants.update, working[documentId], identity)) {
|
|
42
42
|
throw new PermissionActionError({
|
|
43
43
|
documentId,
|
|
44
44
|
transactionId,
|
|
@@ -72,9 +72,9 @@ export function handleDelete(
|
|
|
72
72
|
})
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
const cantDeleteDraft = working[draftId] && !checkGrant(grants.update, working[draftId])
|
|
75
|
+
const cantDeleteDraft = working[draftId] && !checkGrant(grants.update, working[draftId], identity)
|
|
76
76
|
const cantDeletePublished =
|
|
77
|
-
working[publishedId] && !checkGrant(grants.update, working[publishedId])
|
|
77
|
+
working[publishedId] && !checkGrant(grants.update, working[publishedId], identity)
|
|
78
78
|
|
|
79
79
|
if (cantDeleteDraft || cantDeletePublished) {
|
|
80
80
|
throw new PermissionActionError({
|
|
@@ -16,7 +16,7 @@ export function handleDiscard(
|
|
|
16
16
|
action: DiscardDocumentAction,
|
|
17
17
|
ctx: ActionHandlerContext,
|
|
18
18
|
): ActionHandlerResult {
|
|
19
|
-
const {transactionId, timestamp, grants, outgoingActions, outgoingMutations} = ctx
|
|
19
|
+
const {transactionId, timestamp, grants, identity, outgoingActions, outgoingMutations} = ctx
|
|
20
20
|
let {base, working} = ctx
|
|
21
21
|
|
|
22
22
|
const documentId = getId(action.documentId)
|
|
@@ -43,7 +43,7 @@ export function handleDiscard(
|
|
|
43
43
|
})
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
if (!checkGrant(grants.update, working[versionId])) {
|
|
46
|
+
if (!checkGrant(grants.update, working[versionId], identity)) {
|
|
47
47
|
throw new PermissionActionError({
|
|
48
48
|
documentId,
|
|
49
49
|
transactionId,
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
ActionError,
|
|
10
10
|
type ActionHandlerContext,
|
|
11
11
|
type ActionHandlerResult,
|
|
12
|
+
applySingleDocPatch,
|
|
12
13
|
checkGrant,
|
|
13
14
|
PermissionActionError,
|
|
14
15
|
} from './shared'
|
|
@@ -17,60 +18,25 @@ export function handleEdit(
|
|
|
17
18
|
action: EditDocumentAction,
|
|
18
19
|
ctx: ActionHandlerContext,
|
|
19
20
|
): ActionHandlerResult {
|
|
20
|
-
const {transactionId, timestamp, grants, outgoingActions, outgoingMutations} = ctx
|
|
21
|
+
const {transactionId, timestamp, grants, identity, outgoingActions, outgoingMutations} = ctx
|
|
21
22
|
let {base, working} = ctx
|
|
22
23
|
|
|
23
24
|
const documentId = getId(action.documentId)
|
|
24
25
|
|
|
25
26
|
if (action.liveEdit) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (!working[documentId] || !base[documentId]) {
|
|
33
|
-
throw new ActionError({
|
|
34
|
-
documentId,
|
|
35
|
-
transactionId,
|
|
36
|
-
message: `Cannot edit document because it does not exist.`,
|
|
37
|
-
})
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const baseBefore = base[documentId] as SanityDocument
|
|
41
|
-
if (userPatches) {
|
|
42
|
-
base = processMutations({
|
|
43
|
-
documents: base,
|
|
44
|
-
transactionId,
|
|
45
|
-
mutations: userPatches,
|
|
46
|
-
timestamp,
|
|
47
|
-
})
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const baseAfter = base[documentId] as SanityDocument
|
|
51
|
-
const patches = diffValue(baseBefore, baseAfter)
|
|
52
|
-
|
|
53
|
-
const workingBefore = working[documentId] as SanityDocument
|
|
54
|
-
if (!checkGrant(grants.update, workingBefore)) {
|
|
55
|
-
throw new PermissionActionError({
|
|
56
|
-
documentId,
|
|
57
|
-
transactionId,
|
|
58
|
-
message: `You do not have permission to edit document "${documentId}".`,
|
|
59
|
-
})
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const workingMutations = patches.map((patch) => ({patch: {id: documentId, ...patch}}))
|
|
63
|
-
|
|
64
|
-
working = processMutations({
|
|
65
|
-
documents: working,
|
|
27
|
+
const result = applySingleDocPatch({
|
|
28
|
+
base,
|
|
29
|
+
working,
|
|
30
|
+
documentId,
|
|
31
|
+
patches: action.patches,
|
|
66
32
|
transactionId,
|
|
67
|
-
mutations: workingMutations,
|
|
68
33
|
timestamp,
|
|
34
|
+
grants,
|
|
35
|
+
identity,
|
|
69
36
|
})
|
|
70
|
-
|
|
71
37
|
// liveEdit documents use the mutation endpoint directly -- we don't send actions
|
|
72
|
-
outgoingMutations.push(...workingMutations)
|
|
73
|
-
return {base, working}
|
|
38
|
+
outgoingMutations.push(...result.workingMutations)
|
|
39
|
+
return {base: result.base, working: result.working}
|
|
74
40
|
}
|
|
75
41
|
|
|
76
42
|
const versionId = isReleasePerspective(action.perspective)
|
|
@@ -133,7 +99,7 @@ export function handleEdit(
|
|
|
133
99
|
if (!isReleasePerspective(action.perspective) && !working[draftId] && working[publishedId]) {
|
|
134
100
|
const newDraftFromPublished = {...working[publishedId], _id: draftId}
|
|
135
101
|
|
|
136
|
-
if (!checkGrant(grants.create, newDraftFromPublished)) {
|
|
102
|
+
if (!checkGrant(grants.create, newDraftFromPublished, identity)) {
|
|
137
103
|
throw new PermissionActionError({
|
|
138
104
|
documentId,
|
|
139
105
|
transactionId,
|
|
@@ -146,7 +112,7 @@ export function handleEdit(
|
|
|
146
112
|
|
|
147
113
|
// the first if statement should make this never be null or undefined
|
|
148
114
|
const workingBefore = working[patchDocumentId] ?? working[publishedId]
|
|
149
|
-
if (!checkGrant(grants.update, workingBefore
|
|
115
|
+
if (!checkGrant(grants.update, workingBefore!, identity)) {
|
|
150
116
|
throw new PermissionActionError({
|
|
151
117
|
documentId,
|
|
152
118
|
transactionId,
|