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