@sanity-labs/client-fake-for-test 0.11.0 → 0.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/README.md +52 -10
- package/dist/index.cjs +672 -78
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +240 -71
- package/dist/index.d.ts +240 -71
- package/dist/index.js +672 -78
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -5,6 +5,449 @@ const ERR_PREFIX = "[client-fake-for-test]";
|
|
|
5
5
|
function fakeError(message) {
|
|
6
6
|
return new Error(`${ERR_PREFIX} ${message}`);
|
|
7
7
|
}
|
|
8
|
+
function createDocumentHistory() {
|
|
9
|
+
const transactions = [], revisions = /* @__PURE__ */ new Map(), keep = (doc) => revisions.set(`${doc._id} ${doc._rev}`, structuredClone(doc)), deleted = /* @__PURE__ */ new Map();
|
|
10
|
+
return {
|
|
11
|
+
commit: ({ transactionId, author, before, after }) => {
|
|
12
|
+
const afterById = new Map(after.map((doc) => [doc._id, doc])), ids = /* @__PURE__ */ new Set([...before.keys(), ...afterById.keys()]), effects = [];
|
|
13
|
+
for (const id of ids) {
|
|
14
|
+
const previous = before.get(id), next = afterById.get(id);
|
|
15
|
+
previous !== next && (previous !== void 0 && keep(previous), next !== void 0 && keep(next), next === void 0 && previous !== void 0 ? deleted.set(id, structuredClone(previous)) : deleted.delete(id), effects.push({
|
|
16
|
+
documentId: id,
|
|
17
|
+
after: next === void 0 ? void 0 : structuredClone(next),
|
|
18
|
+
existed: previous !== void 0
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
effects.length !== 0 && transactions.push({
|
|
22
|
+
id: transactionId,
|
|
23
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
24
|
+
author,
|
|
25
|
+
effects
|
|
26
|
+
});
|
|
27
|
+
},
|
|
28
|
+
documentsAt: ({ ids, revision, current }) => ids.flatMap((id) => {
|
|
29
|
+
const image = revisions.get(`${id} ${revision}`) ?? current.find((doc) => doc._id === id && doc._rev === revision);
|
|
30
|
+
return image === void 0 ? [] : [structuredClone(image)];
|
|
31
|
+
}),
|
|
32
|
+
lastImage: (id) => deleted.get(id),
|
|
33
|
+
documentIds: () => [
|
|
34
|
+
...new Set(transactions.flatMap((txn) => txn.effects.map((effect) => effect.documentId)))
|
|
35
|
+
],
|
|
36
|
+
transactions: (ids, visible) => transactions.filter((txn) => txn.effects.some((effect) => ids.includes(effect.documentId))).map((txn) => ({ ...txn, effects: txn.effects.filter((e) => visible(e.documentId)) })).map((txn) => ({
|
|
37
|
+
id: txn.id,
|
|
38
|
+
timestamp: txn.timestamp,
|
|
39
|
+
...txn.author !== void 0 ? { author: txn.author } : {},
|
|
40
|
+
mutations: txn.effects.map((effect) => {
|
|
41
|
+
if (effect.after === void 0) return { delete: { id: effect.documentId } };
|
|
42
|
+
const after = structuredClone(effect.after);
|
|
43
|
+
return effect.existed ? { createOrReplace: after } : { create: after };
|
|
44
|
+
}),
|
|
45
|
+
documentIDs: txn.effects.map((effect) => effect.documentId)
|
|
46
|
+
})),
|
|
47
|
+
events: (ids) => Object.fromEntries(
|
|
48
|
+
ids.map((id) => [
|
|
49
|
+
id,
|
|
50
|
+
transactions.flatMap(
|
|
51
|
+
(txn) => txn.effects.filter((effect) => effect.documentId === id && effect.after !== void 0).map(
|
|
52
|
+
(effect) => ({
|
|
53
|
+
type: effect.existed ? "updateLiveDocument" : "createLiveDocument",
|
|
54
|
+
id: txn.id,
|
|
55
|
+
documentId: id,
|
|
56
|
+
revisionId: effect.after._rev,
|
|
57
|
+
timestamp: txn.timestamp,
|
|
58
|
+
...txn.author !== void 0 ? { author: txn.author } : {}
|
|
59
|
+
})
|
|
60
|
+
)
|
|
61
|
+
).toReversed()
|
|
62
|
+
])
|
|
63
|
+
)
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function historyIds(list) {
|
|
67
|
+
return list.split(",").map((id) => decodeURIComponent(id));
|
|
68
|
+
}
|
|
69
|
+
class PlatformHttpError extends Error {
|
|
70
|
+
statusCode;
|
|
71
|
+
response;
|
|
72
|
+
responseBody;
|
|
73
|
+
details;
|
|
74
|
+
constructor(args) {
|
|
75
|
+
const { method, path, statusCode, statusMessage, body } = args;
|
|
76
|
+
super(clientErrorMessage(args)), this.name = "PlatformHttpError", this.statusCode = statusCode, this.response = { body, url: path, method, headers: {}, statusCode, statusMessage }, this.responseBody = typeof body == "string" ? body : JSON.stringify(body, null, 2);
|
|
77
|
+
const error = isRecord(body) ? body.error : void 0;
|
|
78
|
+
this.details = isRecord(error) ? error : void 0;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function isRecord(value) {
|
|
82
|
+
return typeof value == "object" && value !== null;
|
|
83
|
+
}
|
|
84
|
+
function clientErrorMessage(args) {
|
|
85
|
+
const { body } = args;
|
|
86
|
+
if (isRecord(body)) {
|
|
87
|
+
const { error, message } = body;
|
|
88
|
+
if (isRecord(error) && typeof error.description == "string") return error.description;
|
|
89
|
+
if (typeof message == "string") return message;
|
|
90
|
+
}
|
|
91
|
+
const details = typeof body == "string" ? ` (${body.length > 100 ? `${body.slice(0, 100)}\u2026` : body})` : "";
|
|
92
|
+
return `${args.method}-request to ${args.path} resulted in HTTP ${args.statusCode} ${args.statusMessage}${details}`;
|
|
93
|
+
}
|
|
94
|
+
const notMounted = ({ method, path }) => new PlatformHttpError({
|
|
95
|
+
method,
|
|
96
|
+
path,
|
|
97
|
+
statusCode: 404,
|
|
98
|
+
statusMessage: "Not Found",
|
|
99
|
+
body: `Cannot ${method} ${path}`
|
|
100
|
+
});
|
|
101
|
+
function accessApiError(args) {
|
|
102
|
+
const { request, statusCode, statusMessage, message } = args;
|
|
103
|
+
return new PlatformHttpError({
|
|
104
|
+
...request,
|
|
105
|
+
statusCode,
|
|
106
|
+
statusMessage,
|
|
107
|
+
body: { statusCode, message }
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
const noPermission = (request) => accessApiError({
|
|
111
|
+
request,
|
|
112
|
+
statusCode: 403,
|
|
113
|
+
statusMessage: "Forbidden",
|
|
114
|
+
message: "You do not have permission to access this resource"
|
|
115
|
+
});
|
|
116
|
+
function invalidAccessType(request, type) {
|
|
117
|
+
return accessApiError({
|
|
118
|
+
request,
|
|
119
|
+
statusCode: 400,
|
|
120
|
+
statusMessage: "Bad Request",
|
|
121
|
+
message: `Invalid resource type ${type}`
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
function notFound(name) {
|
|
125
|
+
return (request) => accessApiError({
|
|
126
|
+
request,
|
|
127
|
+
statusCode: 404,
|
|
128
|
+
statusMessage: "Not Found",
|
|
129
|
+
message: `${name} not found`
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
const releaseLimitExceeded = ({ method, path }) => new PlatformHttpError({
|
|
133
|
+
method,
|
|
134
|
+
path,
|
|
135
|
+
statusCode: 403,
|
|
136
|
+
statusMessage: "Forbidden",
|
|
137
|
+
body: {
|
|
138
|
+
error: {
|
|
139
|
+
description: "active release count exceeds the limit of 0",
|
|
140
|
+
type: "releaseLimitExceededError"
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
function serveCapability(capability, request, serve) {
|
|
145
|
+
if (capability !== "served") throw capability(request);
|
|
146
|
+
return serve();
|
|
147
|
+
}
|
|
148
|
+
const ACCESS_KEY_SEPARATOR = ":";
|
|
149
|
+
function accessKey(type, id) {
|
|
150
|
+
return `${type}${ACCESS_KEY_SEPARATOR}${id}`;
|
|
151
|
+
}
|
|
152
|
+
function segmentBase(segment) {
|
|
153
|
+
const pattern = new RegExp(`^/${segment}/([^/]+)(/.*)?$`);
|
|
154
|
+
return (path) => {
|
|
155
|
+
const match = path.match(pattern);
|
|
156
|
+
if (match !== null)
|
|
157
|
+
return { id: decodeURIComponent(match[1]), rest: match[2] ?? "" };
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
const DATASET_BASE = /^\/projects\/([^/]+)\/datasets\/([^/]+)(\/.*)?$/;
|
|
161
|
+
function datasetParts(id) {
|
|
162
|
+
const dot = id.indexOf(".");
|
|
163
|
+
return { projectId: id.slice(0, dot), dataset: id.slice(dot + 1) };
|
|
164
|
+
}
|
|
165
|
+
function accessEntryRoles(type) {
|
|
166
|
+
return ({ identity, id }) => identity.access?.[accessKey(type, id)]?.roles;
|
|
167
|
+
}
|
|
168
|
+
const RESOURCE_CAPABILITIES = {
|
|
169
|
+
dataset: {
|
|
170
|
+
label: (id) => {
|
|
171
|
+
const { projectId, dataset } = datasetParts(id);
|
|
172
|
+
return `dataset "${projectId}:${dataset}"`;
|
|
173
|
+
},
|
|
174
|
+
dataBase: (id) => {
|
|
175
|
+
const { projectId, dataset } = datasetParts(id);
|
|
176
|
+
return `/projects/${projectId}/datasets/${dataset}`;
|
|
177
|
+
},
|
|
178
|
+
matchBase: (path) => {
|
|
179
|
+
const match = path.match(DATASET_BASE);
|
|
180
|
+
if (match !== null)
|
|
181
|
+
return { id: `${match[1]}.${match[2]}`, rest: match[3] ?? "" };
|
|
182
|
+
},
|
|
183
|
+
// A dataset's organization comes from its project: `GET /projects/<projectId>`.
|
|
184
|
+
lookupAtBase: !1,
|
|
185
|
+
identity: "project",
|
|
186
|
+
acl: "served",
|
|
187
|
+
history: "served",
|
|
188
|
+
actions: "served",
|
|
189
|
+
refusedActions: {},
|
|
190
|
+
access: {
|
|
191
|
+
type: "project",
|
|
192
|
+
id: (id) => datasetParts(id).projectId,
|
|
193
|
+
roles: "served",
|
|
194
|
+
users: "served",
|
|
195
|
+
// Every registered identity is a member of every project, with its project roles.
|
|
196
|
+
memberRoles: ({ identity, id }) => (identity.usersByProject?.[id] ?? identity.user).roles ?? []
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
"media-library": {
|
|
200
|
+
label: (id) => `media library "${id}"`,
|
|
201
|
+
dataBase: (id) => `/media-libraries/${id}`,
|
|
202
|
+
matchBase: segmentBase("media-libraries"),
|
|
203
|
+
lookupAtBase: !0,
|
|
204
|
+
identity: "global",
|
|
205
|
+
acl: "served",
|
|
206
|
+
history: "served",
|
|
207
|
+
actions: "served",
|
|
208
|
+
refusedActions: { "sanity.action.release.create": releaseLimitExceeded },
|
|
209
|
+
access: {
|
|
210
|
+
type: "media-library",
|
|
211
|
+
id: (id) => id,
|
|
212
|
+
// Unproven on staging (no token held roles read): refused like a missing permission.
|
|
213
|
+
roles: noPermission,
|
|
214
|
+
users: "served",
|
|
215
|
+
unknownId: notFound("Media library"),
|
|
216
|
+
memberRoles: accessEntryRoles("media-library")
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
canvas: {
|
|
220
|
+
label: (id) => `canvas "${id}"`,
|
|
221
|
+
dataBase: (id) => `/canvases/${id}`,
|
|
222
|
+
matchBase: segmentBase("canvases"),
|
|
223
|
+
lookupAtBase: !0,
|
|
224
|
+
identity: "global",
|
|
225
|
+
acl: "served",
|
|
226
|
+
history: "served",
|
|
227
|
+
actions: notMounted,
|
|
228
|
+
refusedActions: {},
|
|
229
|
+
access: {
|
|
230
|
+
type: "canvas",
|
|
231
|
+
id: (id) => id,
|
|
232
|
+
// Unproven on staging (no canvas roles/members permission exists to grant).
|
|
233
|
+
roles: noPermission,
|
|
234
|
+
users: noPermission,
|
|
235
|
+
unknownId: notFound("Canvas"),
|
|
236
|
+
memberRoles: accessEntryRoles("canvas")
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
dashboard: {
|
|
240
|
+
label: (id) => `dashboard "${id}"`,
|
|
241
|
+
dataBase: (id) => `/dashboards/${id}`,
|
|
242
|
+
matchBase: segmentBase("dashboards"),
|
|
243
|
+
lookupAtBase: !0,
|
|
244
|
+
identity: "global",
|
|
245
|
+
acl: notMounted,
|
|
246
|
+
history: notMounted,
|
|
247
|
+
actions: notMounted,
|
|
248
|
+
refusedActions: {},
|
|
249
|
+
access: {
|
|
250
|
+
type: "dashboard",
|
|
251
|
+
id: (id) => id,
|
|
252
|
+
// Unproven on staging, as for canvas.
|
|
253
|
+
roles: noPermission,
|
|
254
|
+
users: noPermission,
|
|
255
|
+
unknownId: notFound("Dashboard"),
|
|
256
|
+
memberRoles: accessEntryRoles("dashboard")
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
function capabilitiesOf(type) {
|
|
261
|
+
const row = RESOURCE_CAPABILITIES[type];
|
|
262
|
+
if (row === void 0)
|
|
263
|
+
throw fakeError(
|
|
264
|
+
`Unsupported resource type ${JSON.stringify(type)}. The fake serves ${Object.keys(RESOURCE_CAPABILITIES).map((t) => `"${t}"`).join(", ")}.`
|
|
265
|
+
);
|
|
266
|
+
return row;
|
|
267
|
+
}
|
|
268
|
+
function validateResource(resource) {
|
|
269
|
+
const row = capabilitiesOf(resource.type);
|
|
270
|
+
if (resource.type === "dataset" && resource.id.split(".").length !== 2)
|
|
271
|
+
throw fakeError(
|
|
272
|
+
`Dataset resource ID must be in the format "project.dataset" (got ${JSON.stringify(resource.id)}), the same rule @sanity/client applies to \`resource: { type: 'dataset', id }\`.`
|
|
273
|
+
);
|
|
274
|
+
return row;
|
|
275
|
+
}
|
|
276
|
+
function matchResourcePath(path) {
|
|
277
|
+
for (const [type, row] of Object.entries(RESOURCE_CAPABILITIES)) {
|
|
278
|
+
const match = row.matchBase(path);
|
|
279
|
+
if (match !== void 0)
|
|
280
|
+
return { resource: { type, id: match.id }, rest: match.rest };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
function capabilitiesForAccessType(type) {
|
|
284
|
+
for (const [resourceType, row] of Object.entries(RESOURCE_CAPABILITIES))
|
|
285
|
+
if (row.access.type === type) return { resourceType, row };
|
|
286
|
+
}
|
|
287
|
+
function dataUrl(args) {
|
|
288
|
+
const { resource, dataset, operation, path } = args;
|
|
289
|
+
if (resource !== void 0) {
|
|
290
|
+
const uri2 = path !== void 0 ? `${operation}/${path}` : operation;
|
|
291
|
+
return `${capabilitiesOf(resource.type).dataBase(resource.id)}/${uri2}`.replace(
|
|
292
|
+
/\/($|\?)/,
|
|
293
|
+
"$1"
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
const base = `/${operation}/${dataset}`;
|
|
297
|
+
return `/data${path !== void 0 ? `${base}/${path}` : base}`.replace(/\/($|\?)/, "$1");
|
|
298
|
+
}
|
|
299
|
+
const ACCESS_PATH = /^\/access\/([^/]+)\/([^/]+)(\/.*)?$/, DATA_HISTORY_PATH = /^\/data\/history\/([^/]+)(\/.*)$/;
|
|
300
|
+
function page(data) {
|
|
301
|
+
return { data, nextCursor: null };
|
|
302
|
+
}
|
|
303
|
+
async function servePlatformRequest(ctx, url) {
|
|
304
|
+
const queryStart = url.indexOf("?"), path = queryStart === -1 ? url : url.slice(0, queryStart), query = new URLSearchParams(queryStart === -1 ? "" : url.slice(queryStart + 1)), request = { method: "GET", path }, access = path.match(ACCESS_PATH);
|
|
305
|
+
if (access !== null) {
|
|
306
|
+
const [, type, id, rest2] = access;
|
|
307
|
+
return serveAccess({ ctx, request, type, id: decodeURIComponent(id), rest: rest2 ?? "", query });
|
|
308
|
+
}
|
|
309
|
+
const dataHistory = path.match(DATA_HISTORY_PATH);
|
|
310
|
+
if (dataHistory !== null) {
|
|
311
|
+
const [, dataset, rest2] = dataHistory, resource2 = { type: "dataset", id: `${ctx.projectId}.${dataset}` };
|
|
312
|
+
return serveHistory({ ctx, request, resource: resource2, rest: rest2, query });
|
|
313
|
+
}
|
|
314
|
+
const matched = matchResourcePath(path);
|
|
315
|
+
if (matched === void 0) return;
|
|
316
|
+
const { resource, rest } = matched, row = capabilitiesOf(resource.type);
|
|
317
|
+
if (rest === "") return row.lookupAtBase ? serveLookup(ctx, resource) : void 0;
|
|
318
|
+
if (rest === "/acl")
|
|
319
|
+
return serveCapability(row.acl, request, () => {
|
|
320
|
+
const own = ctx.own(resource);
|
|
321
|
+
return own === void 0 ? void 0 : { value: own.grants };
|
|
322
|
+
});
|
|
323
|
+
if (rest.startsWith("/history/"))
|
|
324
|
+
return serveHistory({ ctx, request, resource, rest: rest.slice(8), query });
|
|
325
|
+
}
|
|
326
|
+
function serveLookup(ctx, resource) {
|
|
327
|
+
if (ctx.organizationId === void 0)
|
|
328
|
+
throw fakeError(
|
|
329
|
+
`request(): GET ${capabilitiesOf(resource.type).dataBase(resource.id)} answers the resource's organization, but no organization is configured. Pass createTestClient({ organizationId }).`
|
|
330
|
+
);
|
|
331
|
+
return { value: { organizationId: ctx.organizationId, id: resource.id, status: "active" } };
|
|
332
|
+
}
|
|
333
|
+
function serveHistory(args) {
|
|
334
|
+
const { ctx, request, resource, rest, query } = args;
|
|
335
|
+
return serveCapability(capabilitiesOf(resource.type).history, request, () => {
|
|
336
|
+
const own = ctx.own(resource);
|
|
337
|
+
return own === void 0 ? void 0 : readHistory({ own, request, rest, query });
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
async function readHistory(args) {
|
|
341
|
+
const { own, request, rest, query } = args, current = new Map(own.documents().map((doc) => [doc._id, doc])), hidden = /* @__PURE__ */ new Set();
|
|
342
|
+
for (const id of /* @__PURE__ */ new Set([...current.keys(), ...own.history.documentIds()])) {
|
|
343
|
+
const image = current.get(id) ?? own.history.lastImage(id);
|
|
344
|
+
image !== void 0 && !await own.canRead(image) && hidden.add(id);
|
|
345
|
+
}
|
|
346
|
+
const visible = (id) => !hidden.has(id), readable = (list) => historyIds(list).filter(visible), documents = rest.match(/^\/documents\/([^/]+)$/);
|
|
347
|
+
if (documents !== null) {
|
|
348
|
+
const revision = query.get("revision");
|
|
349
|
+
if (revision === null)
|
|
350
|
+
throw fakeError(
|
|
351
|
+
`request(): ${request.path} is served for \`?revision=<rev>\` reads only (time-based history reads are not simulated).`
|
|
352
|
+
);
|
|
353
|
+
const ids = readable(documents[1]);
|
|
354
|
+
return {
|
|
355
|
+
value: { documents: own.history.documentsAt({ ids, revision, current: own.documents() }) }
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
const transactions = rest.match(/^\/transactions\/([^/]+)$/);
|
|
359
|
+
if (transactions !== null)
|
|
360
|
+
return { value: own.history.transactions(readable(transactions[1]), visible).map((record) => JSON.stringify(record)).join(`
|
|
361
|
+
`) };
|
|
362
|
+
const events = rest.match(/^\/events\/documents\/([^/]+)$/);
|
|
363
|
+
if (events !== null)
|
|
364
|
+
return {
|
|
365
|
+
value: {
|
|
366
|
+
events: own.history.events(readable(events[1])),
|
|
367
|
+
nextCursor: ""
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
function serveAccess(args) {
|
|
372
|
+
const { ctx, request, type, id, rest, query } = args, found = capabilitiesForAccessType(type);
|
|
373
|
+
if (found === void 0) throw invalidAccessType(request, type);
|
|
374
|
+
const accessType = found.row.access.type, { unknownId } = found.row.access;
|
|
375
|
+
if (unknownId !== void 0 && rest !== "/user-permissions/me/check" && !ctx.exists({ type: found.resourceType, id }))
|
|
376
|
+
throw unknownId(request);
|
|
377
|
+
const permissions = ctx.actorAccess()?.[accessKey(accessType, id)]?.permissions ?? [];
|
|
378
|
+
switch (rest) {
|
|
379
|
+
case "/roles":
|
|
380
|
+
return serveCapability(found.row.access.roles, request, () => ({
|
|
381
|
+
value: page(rolesOn({ row: found.row, id, identities: ctx.identities() }))
|
|
382
|
+
}));
|
|
383
|
+
case "/users":
|
|
384
|
+
return serveCapability(found.row.access.users, request, () => ({
|
|
385
|
+
value: page(usersOn({ row: found.row, id, identities: ctx.identities() }))
|
|
386
|
+
}));
|
|
387
|
+
case "/user-permissions/me":
|
|
388
|
+
return {
|
|
389
|
+
value: page(
|
|
390
|
+
permissions.map((permission) => {
|
|
391
|
+
const dot = permission.lastIndexOf(".");
|
|
392
|
+
return {
|
|
393
|
+
type: permission.slice(0, dot),
|
|
394
|
+
resourceType: accessType,
|
|
395
|
+
resourceId: id,
|
|
396
|
+
action: permission.slice(dot + 1)
|
|
397
|
+
};
|
|
398
|
+
})
|
|
399
|
+
)
|
|
400
|
+
};
|
|
401
|
+
case "/user-permissions/me/check": {
|
|
402
|
+
const requested = query.getAll("permissions").flatMap((value) => value.split(","));
|
|
403
|
+
return {
|
|
404
|
+
value: {
|
|
405
|
+
data: Object.fromEntries(requested.map((p) => [p, permissions.includes(p)]))
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
default:
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
function rolesOn(args) {
|
|
414
|
+
const byName = /* @__PURE__ */ new Map();
|
|
415
|
+
for (const identity of args.identities)
|
|
416
|
+
for (const role of args.row.access.memberRoles({ identity, id: args.id }) ?? [])
|
|
417
|
+
byName.set(role.name, {
|
|
418
|
+
name: role.name,
|
|
419
|
+
...role.title !== void 0 ? { title: role.title } : {},
|
|
420
|
+
...role.description !== void 0 ? { description: role.description } : {}
|
|
421
|
+
});
|
|
422
|
+
return [...byName.values()].toSorted((a, b) => a.name.localeCompare(b.name));
|
|
423
|
+
}
|
|
424
|
+
function usersOn(args) {
|
|
425
|
+
const { row, id, identities } = args, bySanityUserId = /* @__PURE__ */ new Map();
|
|
426
|
+
for (const identity of identities) {
|
|
427
|
+
const roles = row.access.memberRoles({ identity, id });
|
|
428
|
+
if (roles === void 0) continue;
|
|
429
|
+
const user = identity.globalUser ?? identity.user, record = {
|
|
430
|
+
sanityUserId: user.id,
|
|
431
|
+
profile: {
|
|
432
|
+
displayName: user.name ?? user.id,
|
|
433
|
+
...user.email !== void 0 ? { email: user.email } : {}
|
|
434
|
+
},
|
|
435
|
+
memberships: [
|
|
436
|
+
{
|
|
437
|
+
resourceType: row.access.type,
|
|
438
|
+
resourceId: id,
|
|
439
|
+
roleNames: roles.map((role) => role.name).toSorted()
|
|
440
|
+
}
|
|
441
|
+
]
|
|
442
|
+
}, existing = bySanityUserId.get(record.sanityUserId);
|
|
443
|
+
if (existing !== void 0 && JSON.stringify(existing) !== JSON.stringify(record))
|
|
444
|
+
throw fakeError(
|
|
445
|
+
`request(): two registered identities resolve to user "${record.sanityUserId}" on ${row.access.type} "${id}" with different records. Fix the identity registrations so duplicate ids carry identical records.`
|
|
446
|
+
);
|
|
447
|
+
bySanityUserId.set(record.sanityUserId, record);
|
|
448
|
+
}
|
|
449
|
+
return [...bySanityUserId.values()];
|
|
450
|
+
}
|
|
8
451
|
class WriteAccessDeniedError extends Error {
|
|
9
452
|
documentId;
|
|
10
453
|
permission;
|
|
@@ -61,6 +504,12 @@ async function enforceWriteAccess(args) {
|
|
|
61
504
|
function defaultAclPath(projectId, dataset) {
|
|
62
505
|
return `/projects/${projectId}/datasets/${dataset}/acl`;
|
|
63
506
|
}
|
|
507
|
+
function refusedAcl(path) {
|
|
508
|
+
const matched = matchResourcePath(path);
|
|
509
|
+
if (matched?.rest !== "/acl") return;
|
|
510
|
+
const { acl } = capabilitiesOf(matched.resource.type);
|
|
511
|
+
return acl === "served" ? void 0 : acl({ method: "GET", path });
|
|
512
|
+
}
|
|
64
513
|
function projectUserOf(identity, projectId) {
|
|
65
514
|
return identity.usersByProject?.[projectId] ?? identity.user;
|
|
66
515
|
}
|
|
@@ -124,16 +573,19 @@ function serveProject(args) {
|
|
|
124
573
|
...projectUser.id.startsWith("p-") ? { isRobot: !0 } : {},
|
|
125
574
|
...projectUser.roles !== void 0 ? { roles: canonicalProjectRoles(projectUser.roles) } : {}
|
|
126
575
|
};
|
|
127
|
-
});
|
|
128
|
-
return {
|
|
576
|
+
}), members = consistentProjectRecords({ projectId: args.pathProjectId, records });
|
|
577
|
+
return {
|
|
578
|
+
...args.organizationId !== void 0 ? { organizationId: args.organizationId } : {},
|
|
579
|
+
members: [...members.values()]
|
|
580
|
+
};
|
|
129
581
|
}
|
|
130
582
|
function makeRequestImpl(ctx) {
|
|
131
|
-
const { getAccess,
|
|
583
|
+
const { getAccess, useProjectHostname, listIdentities, organizationId, platform } = ctx;
|
|
132
584
|
return async (opts) => {
|
|
133
|
-
const accessControl = getAccess(),
|
|
585
|
+
const accessControl = getAccess(), path = opts.uri ?? opts.url;
|
|
134
586
|
if (path === "/users/me")
|
|
135
587
|
return useProjectHostname ? accessControl.currentUser : accessControl.globalUser ?? accessControl.currentUser;
|
|
136
|
-
if (path === aclPath)
|
|
588
|
+
if (path !== void 0 && path === accessControl.aclPath && refusedAcl(path) === void 0)
|
|
137
589
|
return accessControl.grants;
|
|
138
590
|
const projectUsers = path?.match(PROJECT_USERS_PATH), [, pathProjectId, requestedIds] = projectUsers ?? [];
|
|
139
591
|
if (pathProjectId !== void 0 && requestedIds !== void 0)
|
|
@@ -146,13 +598,58 @@ function makeRequestImpl(ctx) {
|
|
|
146
598
|
if (membersProjectId !== void 0)
|
|
147
599
|
return serveProject({
|
|
148
600
|
pathProjectId: membersProjectId,
|
|
149
|
-
identities: listIdentities()
|
|
601
|
+
identities: listIdentities(),
|
|
602
|
+
organizationId
|
|
150
603
|
});
|
|
604
|
+
const served = path === void 0 ? void 0 : await servePlatformRequest(platform, path);
|
|
605
|
+
if (served !== void 0) return served.value;
|
|
151
606
|
throw fakeError(
|
|
152
|
-
`request(): no handler for path "${path}". With access control configured, the fake serves "/users/me", "
|
|
607
|
+
`request(): no handler for path "${path}". With access control configured, the fake serves "/users/me", the handle's ACL ("<data base>/acl"), "/projects/<projectId>" (members), "/projects/<projectId>/users/<ids>", the History API (client.getDataUrl("history", \u2026)), resource lookup ("/media-libraries/<id>", "/canvases/<id>", "/dashboards/<id>") and the Access API ("/access/<type>/<id>/roles | users | user-permissions/me[/check]"). For any other endpoint, register a stub (client.stub("request", fn)) or intercept the HTTP call with a tool like MSW.`
|
|
153
608
|
);
|
|
154
609
|
};
|
|
155
610
|
}
|
|
611
|
+
class GuardDeniedError extends Error {
|
|
612
|
+
statusCode = 403;
|
|
613
|
+
type = "guardDeniedError";
|
|
614
|
+
denials;
|
|
615
|
+
details;
|
|
616
|
+
response;
|
|
617
|
+
constructor(denials) {
|
|
618
|
+
const unique = uniqueDenials(denials);
|
|
619
|
+
super(`Mutation denied by guard(s) [${unique.map((denial) => denial.guardID).join(", ")}]`), this.name = "GuardDeniedError", this.denials = unique, this.details = { type: this.type, denials: unique }, this.response = {
|
|
620
|
+
body: { error: this.details },
|
|
621
|
+
url: "memory://client-fake-for-test/data/mutate",
|
|
622
|
+
method: "POST",
|
|
623
|
+
headers: {},
|
|
624
|
+
statusCode: this.statusCode,
|
|
625
|
+
statusMessage: "Forbidden"
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
function uniqueDenials(denials) {
|
|
630
|
+
const seen = /* @__PURE__ */ new Set();
|
|
631
|
+
return denials.filter((denial) => seen.has(denial.guardID) ? !1 : (seen.add(denial.guardID), !0));
|
|
632
|
+
}
|
|
633
|
+
function mutationContext(images) {
|
|
634
|
+
const { before, after } = images;
|
|
635
|
+
if (before !== after)
|
|
636
|
+
return before === void 0 ? { action: "create", before: null, after: after ?? null } : after === void 0 ? { action: "delete", before, after: null } : { action: "update", before, after };
|
|
637
|
+
}
|
|
638
|
+
function mutationContexts(images) {
|
|
639
|
+
return [...images].flatMap((image) => {
|
|
640
|
+
const context = mutationContext(image);
|
|
641
|
+
return context === void 0 ? [] : [context];
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
async function enforceMutationGuards(guard, contexts) {
|
|
645
|
+
if (guard === void 0) return;
|
|
646
|
+
const denials = [];
|
|
647
|
+
for (const context of contexts) {
|
|
648
|
+
const denied = await guard(context);
|
|
649
|
+
denied !== void 0 && denials.push(...denied);
|
|
650
|
+
}
|
|
651
|
+
if (denials.length > 0) throw new GuardDeniedError(denials);
|
|
652
|
+
}
|
|
156
653
|
function describeType(value) {
|
|
157
654
|
return value === null ? "null" : Array.isArray(value) ? "an array" : `a ${typeof value}`;
|
|
158
655
|
}
|
|
@@ -371,7 +868,8 @@ function releaseNameOf(id) {
|
|
|
371
868
|
return rest.slice(0, dot);
|
|
372
869
|
}
|
|
373
870
|
const isDraftId = (id) => id.startsWith(DRAFTS_PREFIX), isVersionId = (id) => id.startsWith(VERSIONS_PREFIX$1), isSystemId = (id) => id.startsWith("_."), isReleaseDocId = (id) => id.startsWith(RELEASE_DOC_PREFIX$1);
|
|
374
|
-
function applyPerspective(docs,
|
|
871
|
+
function applyPerspective(docs, requested) {
|
|
872
|
+
const perspective = normalizePerspective(requested);
|
|
375
873
|
if (perspective === "raw") return [...docs];
|
|
376
874
|
if (perspective === "published")
|
|
377
875
|
return docs.filter((d) => !isDraftId(d._id) && !isVersionId(d._id) && !isSystemId(d._id));
|
|
@@ -383,6 +881,20 @@ function applyPerspective(docs, perspective) {
|
|
|
383
881
|
entry === "drafts" ? includeDrafts = !0 : stack.push(entry);
|
|
384
882
|
return mergeStack(docs, stack, { includeDrafts, includeReleaseDocs: !0 });
|
|
385
883
|
}
|
|
884
|
+
function normalizePerspective(perspective) {
|
|
885
|
+
if (Array.isArray(perspective)) return perspective;
|
|
886
|
+
switch (perspective) {
|
|
887
|
+
case "raw":
|
|
888
|
+
return "raw";
|
|
889
|
+
case "published":
|
|
890
|
+
return "published";
|
|
891
|
+
case "drafts":
|
|
892
|
+
case "previewDrafts":
|
|
893
|
+
return "drafts";
|
|
894
|
+
default:
|
|
895
|
+
return perspective.split(",");
|
|
896
|
+
}
|
|
897
|
+
}
|
|
386
898
|
function mergeStack(docs, releaseStack, options) {
|
|
387
899
|
const groups = /* @__PURE__ */ new Map(), systemReleases = [];
|
|
388
900
|
for (const doc of docs) {
|
|
@@ -686,7 +1198,7 @@ async function applyAction(store, action, hooks) {
|
|
|
686
1198
|
const published = store.get(action.publishedId);
|
|
687
1199
|
requireRevisionMatch(action.draftId, draft._rev, action.ifDraftRevisionId), requireRevisionMatch(action.publishedId, published?._rev, action.ifPublishedRevisionId);
|
|
688
1200
|
const promoted = promote({ ...draft, _id: action.publishedId });
|
|
689
|
-
return published !== void 0 && (promoted._createdAt = published._createdAt), await hooks?.gate?.(promoted, published === void 0 ? "create" : "update"),
|
|
1201
|
+
return published !== void 0 && (promoted._createdAt = published._createdAt), await hooks?.gate?.(promoted, published === void 0 ? "create" : "update"), store.put(promoted), store.remove(action.draftId), { transactionId: cryptoRandomKey() };
|
|
690
1202
|
}
|
|
691
1203
|
case "sanity.action.document.unpublish": {
|
|
692
1204
|
requireDraftOf(action);
|
|
@@ -695,7 +1207,7 @@ async function applyAction(store, action, hooks) {
|
|
|
695
1207
|
throw fakeError(
|
|
696
1208
|
`Cannot unpublish "${action.publishedId}": no published document with that _id exists. document.unpublish retracts a live published document into "${action.draftId}".`
|
|
697
1209
|
);
|
|
698
|
-
if (await hooks?.gate?.(published, "update"),
|
|
1210
|
+
if (await hooks?.gate?.(published, "update"), store.get(action.draftId) === void 0) {
|
|
699
1211
|
const draft = promote({ ...published, _id: action.draftId });
|
|
700
1212
|
draft._createdAt = published._createdAt, store.put(draft);
|
|
701
1213
|
}
|
|
@@ -716,7 +1228,7 @@ function requireRevisionMatch(documentId, actualRev, expectedRev) {
|
|
|
716
1228
|
);
|
|
717
1229
|
}
|
|
718
1230
|
async function applyActions(store, actions, hooks) {
|
|
719
|
-
const list = Array.isArray(actions) ? actions : [actions], snapshot = /* @__PURE__ */ new Map(), remember = (id) => {
|
|
1231
|
+
const list = Array.isArray(actions) ? actions : [actions], snapshot = /* @__PURE__ */ new Map(), guardedIds = /* @__PURE__ */ new Set(), remember = (id) => {
|
|
720
1232
|
snapshot.has(id) || snapshot.set(id, store.get(id));
|
|
721
1233
|
};
|
|
722
1234
|
try {
|
|
@@ -752,13 +1264,18 @@ async function applyActions(store, actions, hooks) {
|
|
|
752
1264
|
remember(a.versionId);
|
|
753
1265
|
break;
|
|
754
1266
|
case "sanity.action.document.publish":
|
|
1267
|
+
remember(a.draftId), remember(a.publishedId), guardedIds.add(a.publishedId), guardedIds.add(a.draftId);
|
|
1268
|
+
break;
|
|
755
1269
|
case "sanity.action.document.unpublish":
|
|
756
|
-
remember(a.draftId), remember(a.publishedId);
|
|
1270
|
+
remember(a.draftId), remember(a.publishedId), guardedIds.add(a.draftId), guardedIds.add(a.publishedId);
|
|
757
1271
|
break;
|
|
758
1272
|
}
|
|
759
1273
|
last = await applyAction(store, a, hooks);
|
|
760
1274
|
}
|
|
761
|
-
|
|
1275
|
+
const contexts = mutationContexts(
|
|
1276
|
+
[...guardedIds].map((id) => ({ before: snapshot.get(id), after: store.get(id) }))
|
|
1277
|
+
);
|
|
1278
|
+
return await hooks?.guard?.(contexts), last;
|
|
762
1279
|
} catch (err) {
|
|
763
1280
|
for (const [id, doc] of snapshot)
|
|
764
1281
|
doc === void 0 ? store.remove(id) : store.put(doc);
|
|
@@ -811,6 +1328,7 @@ const ISSUES_URL = "https://github.com/sanity-labs/client-fake-for-test/issues",
|
|
|
811
1328
|
"createVersion",
|
|
812
1329
|
"unpublishVersion",
|
|
813
1330
|
"request (only with accessControl or a bound token)",
|
|
1331
|
+
"getDataUrl",
|
|
814
1332
|
"config",
|
|
815
1333
|
"withConfig"
|
|
816
1334
|
], UNSUPPORTED_MEMBERS = {
|
|
@@ -830,10 +1348,6 @@ const ISSUES_URL = "https://github.com/sanity-labs/client-fake-for-test/issues",
|
|
|
830
1348
|
what: "build an absolute API URL for a path",
|
|
831
1349
|
workaround: "Hard-code the expected URL in your test, or stub it."
|
|
832
1350
|
},
|
|
833
|
-
getDataUrl: {
|
|
834
|
-
what: "build an absolute data-API URL",
|
|
835
|
-
workaround: "Hard-code the expected URL in your test, or stub it."
|
|
836
|
-
},
|
|
837
1351
|
assets: {
|
|
838
1352
|
what: "asset upload / metadata namespace",
|
|
839
1353
|
workaround: "Seed asset documents into the store directly with `seed()` / `add()`."
|
|
@@ -860,7 +1374,7 @@ const ISSUES_URL = "https://github.com/sanity-labs/client-fake-for-test/issues",
|
|
|
860
1374
|
},
|
|
861
1375
|
request: {
|
|
862
1376
|
what: "generic HTTP request hook \u2014 only wired up when the handle has an acting identity",
|
|
863
|
-
workaround: "Create the client with `accessControl`, or bind a registered token (`identities` + `token` / `withConfig({ token })`), to enable request() (it serves /users/me, the ACL path, /projects/<projectId>,
|
|
1377
|
+
workaround: "Create the client with `accessControl`, or bind a registered token (`identities` + `token` / `withConfig({ token })`), to enable request() (it serves /users/me, the ACL path, /projects/<projectId>, /projects/<projectId>/users/<ids>, the History API, resource lookup and the Access API). Or register a stub."
|
|
864
1378
|
},
|
|
865
1379
|
dryRun: {
|
|
866
1380
|
what: "not a method \u2014 did you mean the `{ dryRun }` mutation option?",
|
|
@@ -1018,13 +1532,32 @@ function rejectAmbiguousIdentity(site, config) {
|
|
|
1018
1532
|
`${site}: \`token\` and \`accessControl\` are mutually exclusive \u2014 a handle acts either as a registered identity (token) or as an inline accessControl config, not both.`
|
|
1019
1533
|
);
|
|
1020
1534
|
}
|
|
1021
|
-
const registryKey = (
|
|
1535
|
+
const registryKey = (resource) => `${resource.type}:${resource.id}`, datasetResource = (projectId, dataset) => ({
|
|
1536
|
+
type: "dataset",
|
|
1537
|
+
id: `${projectId}.${dataset}`
|
|
1538
|
+
}), parseDatasetKey = (key) => {
|
|
1022
1539
|
const [projectId, dataset] = key.split(":");
|
|
1023
1540
|
return { projectId, dataset };
|
|
1541
|
+
}, parseResourceKey = (key) => {
|
|
1542
|
+
const separator = key.indexOf(":"), resource = {
|
|
1543
|
+
type: key.slice(0, separator),
|
|
1544
|
+
id: key.slice(separator + 1)
|
|
1545
|
+
};
|
|
1546
|
+
if (separator <= 0 || resource.id.length === 0)
|
|
1547
|
+
throw fakeError(
|
|
1548
|
+
`createTestClient(): \`resources\` keys are "<type>:<id>" (e.g. "media-library:ml1"); got "${key}".`
|
|
1549
|
+
);
|
|
1550
|
+
return validateResource(resource), resource;
|
|
1024
1551
|
};
|
|
1552
|
+
function ensureEntry(registry, resource) {
|
|
1553
|
+
const key = registryKey(resource);
|
|
1554
|
+
let entry = registry.get(key);
|
|
1555
|
+
return entry === void 0 && (entry = { resource, store: createTestStore(), history: createDocumentHistory() }, registry.set(key, entry)), entry;
|
|
1556
|
+
}
|
|
1025
1557
|
function createTestClient(options = {}) {
|
|
1026
1558
|
const defaultProjectId = options.projectId ?? DEFAULT_PROJECT_ID, defaultDataset = options.dataset ?? DEFAULT_DATASET, backend = {
|
|
1027
1559
|
registry: /* @__PURE__ */ new Map(),
|
|
1560
|
+
organizationId: options.organizationId,
|
|
1028
1561
|
log: [],
|
|
1029
1562
|
stubs: /* @__PURE__ */ new Map(),
|
|
1030
1563
|
calls: [],
|
|
@@ -1032,21 +1565,23 @@ function createTestClient(options = {}) {
|
|
|
1032
1565
|
identities: new Map(Object.entries(options.identities ?? {})),
|
|
1033
1566
|
writeChain: Promise.resolve()
|
|
1034
1567
|
}, registry = backend.registry;
|
|
1035
|
-
rejectAmbiguousIdentity("createTestClient()", options), options.token !== void 0 && requireIdentity(backend, options.token, "createTestClient()")
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
};
|
|
1041
|
-
if (options.documents && ensureStore(defaultProjectId, defaultDataset).seed(options.documents), options.datasets)
|
|
1568
|
+
if (rejectAmbiguousIdentity("createTestClient()", options), options.token !== void 0 && requireIdentity(backend, options.token, "createTestClient()"), options.resource !== void 0 && validateResource(options.resource), options.documents) {
|
|
1569
|
+
const root = options.resource ?? datasetResource(defaultProjectId, defaultDataset);
|
|
1570
|
+
ensureEntry(registry, root).store.seed(options.documents);
|
|
1571
|
+
}
|
|
1572
|
+
if (options.datasets)
|
|
1042
1573
|
for (const [key, docs] of Object.entries(options.datasets)) {
|
|
1043
|
-
const { projectId, dataset } =
|
|
1044
|
-
|
|
1574
|
+
const { projectId, dataset } = parseDatasetKey(key);
|
|
1575
|
+
ensureEntry(registry, datasetResource(projectId, dataset)).store.seed(docs);
|
|
1045
1576
|
}
|
|
1577
|
+
if (options.resources)
|
|
1578
|
+
for (const [key, docs] of Object.entries(options.resources))
|
|
1579
|
+
ensureEntry(registry, parseResourceKey(key)).store.seed(docs);
|
|
1046
1580
|
return makeHandle({
|
|
1047
1581
|
backend,
|
|
1048
1582
|
projectId: defaultProjectId,
|
|
1049
1583
|
dataset: defaultDataset,
|
|
1584
|
+
resource: options.resource,
|
|
1050
1585
|
accessControl: options.accessControl,
|
|
1051
1586
|
perspective: options.perspective ?? "raw",
|
|
1052
1587
|
requestTagPrefix: options.requestTagPrefix,
|
|
@@ -1108,39 +1643,52 @@ function toTxnMutations(operations) {
|
|
|
1108
1643
|
function makeHandle(handleArgs) {
|
|
1109
1644
|
const {
|
|
1110
1645
|
backend,
|
|
1111
|
-
|
|
1112
|
-
dataset,
|
|
1646
|
+
resource,
|
|
1113
1647
|
accessControl,
|
|
1114
1648
|
perspective,
|
|
1115
1649
|
requestTagPrefix,
|
|
1116
1650
|
mutationGuard,
|
|
1117
1651
|
token,
|
|
1118
1652
|
useProjectHostname
|
|
1119
|
-
} = handleArgs, { registry } = backend, activeAccess = () => {
|
|
1653
|
+
} = handleArgs, { registry } = backend, { projectId, dataset } = resource?.type === "dataset" ? datasetParts(resource.id) : handleArgs, target = resource ?? datasetResource(projectId, dataset), activeAccess = () => {
|
|
1120
1654
|
if (token === void 0) return accessControl;
|
|
1121
1655
|
const identity = backend.identities.get(token);
|
|
1122
1656
|
if (identity === void 0)
|
|
1123
1657
|
throw fakeError(`no identity is registered for token "${token}" (was it removed?).`);
|
|
1124
1658
|
return {
|
|
1125
|
-
|
|
1659
|
+
// Per-project records apply only where the resource lives in a project.
|
|
1660
|
+
currentUser: capabilitiesOf(target.type).identity === "project" ? projectUserOf(identity, projectId) : identity.user,
|
|
1126
1661
|
...identity.globalUser !== void 0 ? { globalUser: identity.globalUser } : {},
|
|
1127
|
-
grants: identity.grants
|
|
1662
|
+
grants: identity.grants,
|
|
1663
|
+
...identity.access !== void 0 ? { access: identity.access } : {}
|
|
1128
1664
|
};
|
|
1129
1665
|
}, requireActiveAccess = () => {
|
|
1130
1666
|
const access = activeAccess();
|
|
1131
1667
|
if (access === void 0)
|
|
1132
1668
|
throw fakeError("this handle has no acting identity (no token or accessControl bound).");
|
|
1133
1669
|
return access;
|
|
1134
|
-
}, store = (() => {
|
|
1135
|
-
const
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
}
|
|
1670
|
+
}, capabilities = capabilitiesOf(target.type), targetLabel = capabilities.label(target.id), { store, history } = ensureEntry(registry, target), concurrentEditKey = (id) => registryKey(target) + " " + id, actorId = () => {
|
|
1671
|
+
const access = activeAccess();
|
|
1672
|
+
if (access !== void 0)
|
|
1673
|
+
return capabilities.identity === "global" ? (access.globalUser ?? access.currentUser).id : access.currentUser.id;
|
|
1674
|
+
};
|
|
1675
|
+
let beforeWrite;
|
|
1676
|
+
const write = (run) => serializeWrite(backend, async () => {
|
|
1677
|
+
beforeWrite = new Map(store.all().map((doc) => [doc._id, doc]));
|
|
1678
|
+
try {
|
|
1679
|
+
return await run();
|
|
1680
|
+
} finally {
|
|
1681
|
+
beforeWrite = void 0;
|
|
1682
|
+
}
|
|
1683
|
+
}), commitHistory = (transactionId) => {
|
|
1684
|
+
beforeWrite !== void 0 && history.commit({ transactionId, author: actorId(), before: beforeWrite, after: store.all() });
|
|
1685
|
+
}, record = (transactionId, mutations, tag) => {
|
|
1139
1686
|
const effectiveTag = effectiveRequestTag(tag);
|
|
1140
|
-
backend.log.push({
|
|
1687
|
+
commitHistory(transactionId), backend.log.push({
|
|
1141
1688
|
transactionId,
|
|
1142
1689
|
projectId,
|
|
1143
1690
|
dataset,
|
|
1691
|
+
...resource !== void 0 ? { resource } : {},
|
|
1144
1692
|
mutations,
|
|
1145
1693
|
...effectiveTag !== void 0 ? { tag: effectiveTag } : {}
|
|
1146
1694
|
});
|
|
@@ -1189,24 +1737,30 @@ function makeHandle(handleArgs) {
|
|
|
1189
1737
|
access !== void 0 && await enforceWriteAccess({
|
|
1190
1738
|
document,
|
|
1191
1739
|
grants: access.grants,
|
|
1192
|
-
permission,
|
|
1193
|
-
userId:
|
|
1740
|
+
permission: document._id !== void 0 && isSystemId(document._id) ? "manage" : permission,
|
|
1741
|
+
userId: actorId()
|
|
1194
1742
|
});
|
|
1195
|
-
}, guardGate = async (
|
|
1196
|
-
|
|
1743
|
+
}, guardGate = async (contexts) => {
|
|
1744
|
+
const identity = actorId();
|
|
1745
|
+
await enforceMutationGuards(
|
|
1746
|
+
mutationGuard,
|
|
1747
|
+
contexts.map((context) => ({ ...context, identity }))
|
|
1748
|
+
);
|
|
1749
|
+
}, guardOne = async (action, before, after) => {
|
|
1750
|
+
await guardGate([{ action, before, after }]);
|
|
1197
1751
|
}, canRead = async (document) => {
|
|
1198
1752
|
const access = activeAccess();
|
|
1199
1753
|
return access === void 0 ? !0 : canAccess({
|
|
1200
1754
|
document,
|
|
1201
1755
|
grants: access.grants,
|
|
1202
1756
|
permission: "read",
|
|
1203
|
-
userId:
|
|
1757
|
+
userId: actorId()
|
|
1204
1758
|
});
|
|
1205
|
-
}, applyPatch = async (documentId, ops, ifRev) => {
|
|
1759
|
+
}, applyPatch = async (documentId, ops, ifRev, options = {}) => {
|
|
1206
1760
|
const existing = store.get(documentId);
|
|
1207
1761
|
if (existing === void 0)
|
|
1208
1762
|
throw fakeError(
|
|
1209
|
-
`Cannot patch document "${documentId}": not found \u2014 no document with that _id exists in
|
|
1763
|
+
`Cannot patch document "${documentId}": not found \u2014 no document with that _id exists in ${targetLabel}. patch() requires the target to already exist; use createOrReplace() / createIfNotExists() to upsert, or seed the document first.`
|
|
1210
1764
|
);
|
|
1211
1765
|
checkRevision(documentId, ifRev), await gate(existing, "update");
|
|
1212
1766
|
const patched = applyPatchOps(existing, ops), now = (/* @__PURE__ */ new Date()).toISOString(), next = {
|
|
@@ -1214,7 +1768,7 @@ function makeHandle(handleArgs) {
|
|
|
1214
1768
|
_rev: cryptoRandomKey(),
|
|
1215
1769
|
_updatedAt: now
|
|
1216
1770
|
};
|
|
1217
|
-
return await
|
|
1771
|
+
return options.evaluateGuard !== !1 && await guardOne("update", existing, next), store.put(next), next;
|
|
1218
1772
|
}, applyTransaction = async (mutations, txnOptions) => {
|
|
1219
1773
|
const transactionId = txnOptions?.transactionId ?? cryptoRandomKey(), snapshot = /* @__PURE__ */ new Map(), touchedIds = [], operations = [], remember = (id) => {
|
|
1220
1774
|
snapshot.has(id) || snapshot.set(id, store.get(id));
|
|
@@ -1227,19 +1781,15 @@ function makeHandle(handleArgs) {
|
|
|
1227
1781
|
const promoted = promote2(mut.doc);
|
|
1228
1782
|
if (store.get(promoted._id) !== void 0)
|
|
1229
1783
|
throw fakeError(
|
|
1230
|
-
`create() failed: a document with _id "${promoted._id}" already exists in
|
|
1784
|
+
`create() failed: a document with _id "${promoted._id}" already exists in ${targetLabel}. Use createOrReplace() to overwrite it, or createIfNotExists() to leave the existing one untouched.`
|
|
1231
1785
|
);
|
|
1232
|
-
await gate(promoted, "create"),
|
|
1786
|
+
await gate(promoted, "create"), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
|
|
1233
1787
|
break;
|
|
1234
1788
|
}
|
|
1235
1789
|
case "createOrReplace": {
|
|
1236
1790
|
remember(mut.doc._id);
|
|
1237
1791
|
const promoted = promote2(mut.doc), existing = store.get(promoted._id);
|
|
1238
|
-
await gate(existing ?? promoted, existing !== void 0 ? "update" : "create")
|
|
1239
|
-
existing !== void 0 ? "update" : "create",
|
|
1240
|
-
existing ?? null,
|
|
1241
|
-
promoted
|
|
1242
|
-
);
|
|
1792
|
+
await gate(existing ?? promoted, existing !== void 0 ? "update" : "create");
|
|
1243
1793
|
const wasPresent = existing !== void 0;
|
|
1244
1794
|
store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: wasPresent ? "update" : "create" });
|
|
1245
1795
|
break;
|
|
@@ -1247,21 +1797,25 @@ function makeHandle(handleArgs) {
|
|
|
1247
1797
|
case "createIfNotExists": {
|
|
1248
1798
|
if (remember(mut.doc._id), store.get(mut.doc._id) === void 0) {
|
|
1249
1799
|
const promoted = promote2(mut.doc);
|
|
1250
|
-
await gate(promoted, "create"),
|
|
1800
|
+
await gate(promoted, "create"), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
|
|
1251
1801
|
}
|
|
1252
1802
|
break;
|
|
1253
1803
|
}
|
|
1254
1804
|
case "delete": {
|
|
1255
1805
|
remember(mut.id);
|
|
1256
1806
|
const existing = store.get(mut.id);
|
|
1257
|
-
existing !== void 0 && await gate(existing, "update"),
|
|
1807
|
+
existing !== void 0 && await gate(existing, "update"), store.remove(mut.id) && (touchedIds.push(mut.id), operations.push({ id: mut.id, operation: "delete" }));
|
|
1258
1808
|
break;
|
|
1259
1809
|
}
|
|
1260
1810
|
case "patch": {
|
|
1261
|
-
remember(mut.documentId), await applyPatch(mut.documentId, mut.ops, mut.ifRev), touchedIds.push(mut.documentId), operations.push({ id: mut.documentId, operation: "update" });
|
|
1811
|
+
remember(mut.documentId), await applyPatch(mut.documentId, mut.ops, mut.ifRev, { evaluateGuard: !1 }), touchedIds.push(mut.documentId), operations.push({ id: mut.documentId, operation: "update" });
|
|
1262
1812
|
break;
|
|
1263
1813
|
}
|
|
1264
1814
|
}
|
|
1815
|
+
const contexts = mutationContexts(
|
|
1816
|
+
[...snapshot].map(([id, before]) => ({ before, after: store.get(id) }))
|
|
1817
|
+
);
|
|
1818
|
+
await guardGate(contexts);
|
|
1265
1819
|
} catch (error) {
|
|
1266
1820
|
for (const [id, doc] of snapshot)
|
|
1267
1821
|
doc === void 0 ? store.remove(id) : store.put(doc);
|
|
@@ -1276,7 +1830,21 @@ function makeHandle(handleArgs) {
|
|
|
1276
1830
|
txnOptions?.returnDocuments ?? !1,
|
|
1277
1831
|
txnOptions?.returnFirst ?? !1
|
|
1278
1832
|
);
|
|
1279
|
-
}, runAction =
|
|
1833
|
+
}, runAction = async (actions) => {
|
|
1834
|
+
const request = {
|
|
1835
|
+
method: "POST",
|
|
1836
|
+
path: `${capabilities.dataBase(target.id)}/actions`
|
|
1837
|
+
};
|
|
1838
|
+
if (capabilities.actions !== "served") throw capabilities.actions(request);
|
|
1839
|
+
for (const action of Array.isArray(actions) ? actions : [actions]) {
|
|
1840
|
+
const refused = capabilities.refusedActions[action.actionType];
|
|
1841
|
+
if (refused !== void 0) throw refused(request);
|
|
1842
|
+
}
|
|
1843
|
+
return write(async () => {
|
|
1844
|
+
const result = await applyActions(store, actions, { gate, guard: guardGate });
|
|
1845
|
+
return commitHistory(result.transactionId), result;
|
|
1846
|
+
});
|
|
1847
|
+
}, accessVisibleDocs = async () => {
|
|
1280
1848
|
const all = store.all();
|
|
1281
1849
|
if (activeAccess() === void 0) return all;
|
|
1282
1850
|
const out = [];
|
|
@@ -1320,13 +1888,14 @@ function makeHandle(handleArgs) {
|
|
|
1320
1888
|
const view = await visibleDocs(effective);
|
|
1321
1889
|
return ids.map((id) => resolveDocument(view, id, effective) ?? null);
|
|
1322
1890
|
},
|
|
1323
|
-
|
|
1891
|
+
getDataUrl: (operation, path) => dataUrl({ resource, dataset, operation, path }),
|
|
1892
|
+
create: (doc, opts) => write(async () => {
|
|
1324
1893
|
if (checkRevision(doc._id, opts?.ifRevisionId), store.get(doc._id) !== void 0)
|
|
1325
1894
|
throw fakeError(
|
|
1326
|
-
`create() failed: a document with _id "${doc._id}" already exists in
|
|
1895
|
+
`create() failed: a document with _id "${doc._id}" already exists in ${targetLabel}. Use createOrReplace() to overwrite it, or createIfNotExists() to leave the existing one untouched.`
|
|
1327
1896
|
);
|
|
1328
1897
|
const promoted = promote2(doc);
|
|
1329
|
-
await gate(promoted, "create"), await
|
|
1898
|
+
await gate(promoted, "create"), await guardOne("create", null, promoted);
|
|
1330
1899
|
const transactionId = opts?.transactionId ?? cryptoRandomKey();
|
|
1331
1900
|
return opts?.dryRun !== !0 && (store.put(promoted), record(transactionId, [{ kind: "create", doc }], opts?.tag)), writeResult(
|
|
1332
1901
|
[promoted],
|
|
@@ -1335,10 +1904,10 @@ function makeHandle(handleArgs) {
|
|
|
1335
1904
|
opts
|
|
1336
1905
|
);
|
|
1337
1906
|
}),
|
|
1338
|
-
createOrReplace: (doc, opts) =>
|
|
1907
|
+
createOrReplace: (doc, opts) => write(async () => {
|
|
1339
1908
|
checkRevision(doc._id, opts?.ifRevisionId);
|
|
1340
1909
|
const promoted = promote2(doc), existing = store.get(doc._id);
|
|
1341
|
-
await gate(existing ?? promoted, existing !== void 0 ? "update" : "create"), await
|
|
1910
|
+
await gate(existing ?? promoted, existing !== void 0 ? "update" : "create"), await guardOne(existing !== void 0 ? "update" : "create", existing ?? null, promoted);
|
|
1342
1911
|
const transactionId = opts?.transactionId ?? cryptoRandomKey();
|
|
1343
1912
|
opts?.dryRun !== !0 && (store.put(promoted), record(transactionId, [{ kind: "createOrReplace", doc }], opts?.tag));
|
|
1344
1913
|
const operation = existing !== void 0 ? "update" : "create";
|
|
@@ -1349,7 +1918,7 @@ function makeHandle(handleArgs) {
|
|
|
1349
1918
|
opts
|
|
1350
1919
|
);
|
|
1351
1920
|
}),
|
|
1352
|
-
createIfNotExists: (doc, opts) =>
|
|
1921
|
+
createIfNotExists: (doc, opts) => write(async () => {
|
|
1353
1922
|
checkRevision(doc._id, opts?.ifRevisionId);
|
|
1354
1923
|
const existing = store.get(doc._id), transactionId = opts?.transactionId ?? cryptoRandomKey();
|
|
1355
1924
|
if (existing !== void 0)
|
|
@@ -1360,17 +1929,17 @@ function makeHandle(handleArgs) {
|
|
|
1360
1929
|
opts
|
|
1361
1930
|
);
|
|
1362
1931
|
const promoted = promote2(doc);
|
|
1363
|
-
return await gate(promoted, "create"), await
|
|
1932
|
+
return await gate(promoted, "create"), await guardOne("create", null, promoted), opts?.dryRun !== !0 && (store.put(promoted), record(transactionId, [{ kind: "createIfNotExists", doc }], opts?.tag)), writeResult(
|
|
1364
1933
|
[promoted],
|
|
1365
1934
|
[{ id: promoted._id, operation: "create" }],
|
|
1366
1935
|
transactionId,
|
|
1367
1936
|
opts
|
|
1368
1937
|
);
|
|
1369
1938
|
}),
|
|
1370
|
-
delete: (id, opts) =>
|
|
1939
|
+
delete: (id, opts) => write(async () => {
|
|
1371
1940
|
checkRevision(id, opts?.ifRevisionId);
|
|
1372
1941
|
const existing = store.get(id);
|
|
1373
|
-
existing !== void 0 && await gate(existing, "update"), existing !== void 0 && await
|
|
1942
|
+
existing !== void 0 && await gate(existing, "update"), existing !== void 0 && await guardOne("delete", existing, null);
|
|
1374
1943
|
const transactionId = opts?.transactionId ?? cryptoRandomKey();
|
|
1375
1944
|
let removed = existing;
|
|
1376
1945
|
return opts?.dryRun !== !0 && (removed = store.remove(id), removed !== void 0 && record(transactionId, [{ kind: "delete", id }], opts?.tag)), opts?.returnDocuments === void 0 && opts?.returnFirst === void 0 ? removed !== void 0 ? { _id: id } : null : formatMutationResult(
|
|
@@ -1382,7 +1951,7 @@ function makeHandle(handleArgs) {
|
|
|
1382
1951
|
);
|
|
1383
1952
|
}),
|
|
1384
1953
|
patch: (documentId) => createPatch(documentId, {
|
|
1385
|
-
apply: (docId, ops, ifRev, options) =>
|
|
1954
|
+
apply: (docId, ops, ifRev, options) => write(async () => {
|
|
1386
1955
|
const doc = await applyPatch(docId, ops, ifRev);
|
|
1387
1956
|
return record(
|
|
1388
1957
|
cryptoRandomKey(),
|
|
@@ -1399,9 +1968,9 @@ function makeHandle(handleArgs) {
|
|
|
1399
1968
|
})
|
|
1400
1969
|
}),
|
|
1401
1970
|
transaction: () => createTransaction({
|
|
1402
|
-
apply: (mutations, txnOptions) =>
|
|
1971
|
+
apply: (mutations, txnOptions) => write(() => applyTransaction(mutations, txnOptions))
|
|
1403
1972
|
}),
|
|
1404
|
-
mutate: (operations, options) =>
|
|
1973
|
+
mutate: (operations, options) => write(() => applyTransaction(toTxnMutations(operations), options)),
|
|
1405
1974
|
// Synchronous writes still take the lock: an action landing while a
|
|
1406
1975
|
// guarded patch is parked in its gate would otherwise be clobbered
|
|
1407
1976
|
// when that patch resumes over its pre-action snapshot.
|
|
@@ -1450,15 +2019,30 @@ function makeHandle(handleArgs) {
|
|
|
1450
2019
|
...token !== void 0 || accessControl !== void 0 ? {
|
|
1451
2020
|
request: makeRequestImpl({
|
|
1452
2021
|
getAccess: requireActiveAccess,
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
useProjectHostname,
|
|
1456
|
-
|
|
2022
|
+
// `@sanity/client` sends every request of a `resource`-configured
|
|
2023
|
+
// client to the global API host.
|
|
2024
|
+
useProjectHostname: useProjectHostname && resource === void 0,
|
|
2025
|
+
organizationId: backend.organizationId,
|
|
2026
|
+
listIdentities: () => [...backend.identities.values()],
|
|
2027
|
+
platform: {
|
|
2028
|
+
projectId,
|
|
2029
|
+
organizationId: backend.organizationId,
|
|
2030
|
+
own: (asked) => registryKey(asked) === registryKey(target) ? {
|
|
2031
|
+
grants: requireActiveAccess().grants,
|
|
2032
|
+
documents: () => store.all(),
|
|
2033
|
+
history,
|
|
2034
|
+
canRead
|
|
2035
|
+
} : void 0,
|
|
2036
|
+
actorAccess: () => requireActiveAccess().access,
|
|
2037
|
+
identities: () => [...backend.identities.values()],
|
|
2038
|
+
exists: (asked) => registry.has(registryKey(asked))
|
|
2039
|
+
}
|
|
1457
2040
|
})
|
|
1458
2041
|
} : {},
|
|
1459
2042
|
config: () => ({
|
|
1460
2043
|
projectId,
|
|
1461
2044
|
dataset,
|
|
2045
|
+
...resource !== void 0 ? { resource } : {},
|
|
1462
2046
|
perspective,
|
|
1463
2047
|
...requestTagPrefix !== void 0 ? { requestTagPrefix } : {},
|
|
1464
2048
|
...token !== void 0 ? { token } : {},
|
|
@@ -1466,11 +2050,12 @@ function makeHandle(handleArgs) {
|
|
|
1466
2050
|
}),
|
|
1467
2051
|
withConfig: (config) => {
|
|
1468
2052
|
rejectAmbiguousIdentity("withConfig()", config), config.token !== void 0 && requireIdentity(backend, config.token, "withConfig()");
|
|
1469
|
-
const rebinds = config.token !== void 0 || config.accessControl !== void 0;
|
|
1470
|
-
return makeHandle({
|
|
2053
|
+
const rebinds = config.token !== void 0 || config.accessControl !== void 0, nextResource = "resource" in config ? config.resource : resource;
|
|
2054
|
+
return nextResource !== void 0 && validateResource(nextResource), makeHandle({
|
|
1471
2055
|
backend,
|
|
1472
2056
|
projectId: config.projectId ?? projectId,
|
|
1473
2057
|
dataset: config.dataset ?? dataset,
|
|
2058
|
+
resource: nextResource,
|
|
1474
2059
|
accessControl: rebinds ? config.accessControl : accessControl,
|
|
1475
2060
|
perspective: config.perspective ?? perspective,
|
|
1476
2061
|
requestTagPrefix: config.requestTagPrefix ?? requestTagPrefix,
|
|
@@ -1488,7 +2073,8 @@ function makeHandle(handleArgs) {
|
|
|
1488
2073
|
const editKey = concurrentEditKey(id);
|
|
1489
2074
|
backend.concurrentEdits.set(editKey, (backend.concurrentEdits.get(editKey) ?? 0) + count);
|
|
1490
2075
|
},
|
|
1491
|
-
datasets: () => [...registry.
|
|
2076
|
+
datasets: () => [...registry.values()].filter((entry) => entry.resource.type === "dataset").map((entry) => datasetParts(entry.resource.id)),
|
|
2077
|
+
resources: () => [...registry.values()].map((entry) => ({ ...entry.resource })),
|
|
1492
2078
|
stub: (name, handler) => {
|
|
1493
2079
|
backend.stubs.set(name, handler);
|
|
1494
2080
|
},
|
|
@@ -1511,7 +2097,13 @@ function makeHandle(handleArgs) {
|
|
|
1511
2097
|
return wrapWithGuards(client, {
|
|
1512
2098
|
getStub: (name) => backend.stubs.get(name),
|
|
1513
2099
|
onCall: (method, args) => {
|
|
1514
|
-
const rec = {
|
|
2100
|
+
const rec = {
|
|
2101
|
+
method,
|
|
2102
|
+
args,
|
|
2103
|
+
projectId,
|
|
2104
|
+
dataset,
|
|
2105
|
+
...resource !== void 0 ? { resource } : {}
|
|
2106
|
+
}, tag = OPTIONS_ARG_POSITION[method] === void 0 ? void 0 : effectiveRequestTag(tagFromArgs(method, args));
|
|
1515
2107
|
return tag !== void 0 && (rec.tag = tag), backend.calls.push(rec), (outcome) => {
|
|
1516
2108
|
"error" in outcome ? rec.error = outcome.error : rec.result = outcome.result;
|
|
1517
2109
|
};
|
|
@@ -1520,6 +2112,8 @@ function makeHandle(handleArgs) {
|
|
|
1520
2112
|
});
|
|
1521
2113
|
}
|
|
1522
2114
|
exports.ERR_PREFIX = ERR_PREFIX;
|
|
2115
|
+
exports.GuardDeniedError = GuardDeniedError;
|
|
2116
|
+
exports.PlatformHttpError = PlatformHttpError;
|
|
1523
2117
|
exports.SUPPORTED_SURFACE = SUPPORTED_SURFACE;
|
|
1524
2118
|
exports.UNSUPPORTED_MEMBERS = UNSUPPORTED_MEMBERS;
|
|
1525
2119
|
exports.WriteAccessDeniedError = WriteAccessDeniedError;
|