@sanity-labs/client-fake-for-test 0.12.0 → 0.14.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 +43 -6
- package/dist/index.cjs +663 -56
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +172 -11
- package/dist/index.d.ts +172 -11
- package/dist/index.js +663 -56
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -3,6 +3,516 @@ const ERR_PREFIX = "[client-fake-for-test]";
|
|
|
3
3
|
function fakeError(message) {
|
|
4
4
|
return new Error(`${ERR_PREFIX} ${message}`);
|
|
5
5
|
}
|
|
6
|
+
function createDocumentHistory() {
|
|
7
|
+
const transactions = [], revisions = /* @__PURE__ */ new Map(), keep = (doc) => revisions.set(`${doc._id} ${doc._rev}`, structuredClone(doc)), deleted = /* @__PURE__ */ new Map();
|
|
8
|
+
return {
|
|
9
|
+
commit: ({ transactionId, author, before, after }) => {
|
|
10
|
+
const afterById = new Map(after.map((doc) => [doc._id, doc])), ids = /* @__PURE__ */ new Set([...before.keys(), ...afterById.keys()]), effects = [];
|
|
11
|
+
for (const id of ids) {
|
|
12
|
+
const previous = before.get(id), next = afterById.get(id);
|
|
13
|
+
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({
|
|
14
|
+
documentId: id,
|
|
15
|
+
after: next === void 0 ? void 0 : structuredClone(next),
|
|
16
|
+
existed: previous !== void 0
|
|
17
|
+
}));
|
|
18
|
+
}
|
|
19
|
+
effects.length !== 0 && transactions.push({
|
|
20
|
+
id: transactionId,
|
|
21
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
22
|
+
author,
|
|
23
|
+
effects
|
|
24
|
+
});
|
|
25
|
+
},
|
|
26
|
+
documentsAt: ({ ids, revision, current }) => ids.flatMap((id) => {
|
|
27
|
+
const image = revisions.get(`${id} ${revision}`) ?? current.find((doc) => doc._id === id && doc._rev === revision);
|
|
28
|
+
return image === void 0 ? [] : [structuredClone(image)];
|
|
29
|
+
}),
|
|
30
|
+
lastImage: (id) => deleted.get(id),
|
|
31
|
+
documentIds: () => [
|
|
32
|
+
...new Set(transactions.flatMap((txn) => txn.effects.map((effect) => effect.documentId)))
|
|
33
|
+
],
|
|
34
|
+
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) => ({
|
|
35
|
+
id: txn.id,
|
|
36
|
+
timestamp: txn.timestamp,
|
|
37
|
+
...txn.author !== void 0 ? { author: txn.author } : {},
|
|
38
|
+
mutations: txn.effects.map((effect) => {
|
|
39
|
+
if (effect.after === void 0) return { delete: { id: effect.documentId } };
|
|
40
|
+
const after = structuredClone(effect.after);
|
|
41
|
+
return effect.existed ? { createOrReplace: after } : { create: after };
|
|
42
|
+
}),
|
|
43
|
+
documentIDs: txn.effects.map((effect) => effect.documentId)
|
|
44
|
+
})),
|
|
45
|
+
events: (ids) => Object.fromEntries(
|
|
46
|
+
ids.map((id) => [
|
|
47
|
+
id,
|
|
48
|
+
transactions.flatMap(
|
|
49
|
+
(txn) => txn.effects.filter((effect) => effect.documentId === id && effect.after !== void 0).map(
|
|
50
|
+
(effect) => ({
|
|
51
|
+
type: effect.existed ? "updateLiveDocument" : "createLiveDocument",
|
|
52
|
+
id: txn.id,
|
|
53
|
+
documentId: id,
|
|
54
|
+
revisionId: effect.after._rev,
|
|
55
|
+
timestamp: txn.timestamp,
|
|
56
|
+
...txn.author !== void 0 ? { author: txn.author } : {}
|
|
57
|
+
})
|
|
58
|
+
)
|
|
59
|
+
).toReversed()
|
|
60
|
+
])
|
|
61
|
+
)
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function historyIds(list) {
|
|
65
|
+
return list.split(",").map((id) => decodeURIComponent(id));
|
|
66
|
+
}
|
|
67
|
+
class PlatformHttpError extends Error {
|
|
68
|
+
statusCode;
|
|
69
|
+
response;
|
|
70
|
+
responseBody;
|
|
71
|
+
details;
|
|
72
|
+
constructor(args) {
|
|
73
|
+
const { method, path, statusCode, statusMessage, body } = args;
|
|
74
|
+
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);
|
|
75
|
+
const error = isRecord(body) ? body.error : void 0;
|
|
76
|
+
this.details = isRecord(error) ? error : void 0;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function isRecord(value) {
|
|
80
|
+
return typeof value == "object" && value !== null;
|
|
81
|
+
}
|
|
82
|
+
function clientErrorMessage(args) {
|
|
83
|
+
const { body } = args;
|
|
84
|
+
if (isRecord(body)) {
|
|
85
|
+
const { error, message } = body;
|
|
86
|
+
if (isRecord(error) && typeof error.description == "string") return error.description;
|
|
87
|
+
if (typeof message == "string") return message;
|
|
88
|
+
}
|
|
89
|
+
const details = typeof body == "string" ? ` (${body.length > 100 ? `${body.slice(0, 100)}\u2026` : body})` : "";
|
|
90
|
+
return `${args.method}-request to ${args.path} resulted in HTTP ${args.statusCode} ${args.statusMessage}${details}`;
|
|
91
|
+
}
|
|
92
|
+
const notMounted = ({ method, path }) => new PlatformHttpError({
|
|
93
|
+
method,
|
|
94
|
+
path,
|
|
95
|
+
statusCode: 404,
|
|
96
|
+
statusMessage: "Not Found",
|
|
97
|
+
body: `Cannot ${method} ${path}`
|
|
98
|
+
});
|
|
99
|
+
function accessApiError(args) {
|
|
100
|
+
const { request, statusCode, statusMessage, message } = args;
|
|
101
|
+
return new PlatformHttpError({
|
|
102
|
+
...request,
|
|
103
|
+
statusCode,
|
|
104
|
+
statusMessage,
|
|
105
|
+
body: { statusCode, error: statusMessage, message }
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const noPermission = (request) => accessApiError({
|
|
109
|
+
request,
|
|
110
|
+
statusCode: 403,
|
|
111
|
+
statusMessage: "Forbidden",
|
|
112
|
+
message: "You do not have permission to access this resource"
|
|
113
|
+
});
|
|
114
|
+
function invalidAccessType(request, type) {
|
|
115
|
+
return accessApiError({
|
|
116
|
+
request,
|
|
117
|
+
statusCode: 400,
|
|
118
|
+
statusMessage: "Bad Request",
|
|
119
|
+
message: `Invalid resource type ${type}`
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
function notFound(name) {
|
|
123
|
+
return (request) => accessApiError({
|
|
124
|
+
request,
|
|
125
|
+
statusCode: 404,
|
|
126
|
+
statusMessage: "Not Found",
|
|
127
|
+
message: `${name} not found`
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
const releaseLimitExceeded = ({ method, path }) => new PlatformHttpError({
|
|
131
|
+
method,
|
|
132
|
+
path,
|
|
133
|
+
statusCode: 403,
|
|
134
|
+
statusMessage: "Forbidden",
|
|
135
|
+
body: {
|
|
136
|
+
error: {
|
|
137
|
+
description: "active release count exceeds the limit of 0",
|
|
138
|
+
type: "releaseLimitExceededError"
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
function serveCapability(capability, request, serve) {
|
|
143
|
+
if (capability !== "served") throw capability(request);
|
|
144
|
+
return serve();
|
|
145
|
+
}
|
|
146
|
+
const ACCESS_KEY_SEPARATOR = ":";
|
|
147
|
+
function accessKey(type, id) {
|
|
148
|
+
return `${type}${ACCESS_KEY_SEPARATOR}${id}`;
|
|
149
|
+
}
|
|
150
|
+
function segmentBase(segment) {
|
|
151
|
+
const pattern = new RegExp(`^/${segment}/([^/]+)(/.*)?$`);
|
|
152
|
+
return (path) => {
|
|
153
|
+
const match = path.match(pattern);
|
|
154
|
+
if (match !== null)
|
|
155
|
+
return { id: decodeURIComponent(match[1]), rest: match[2] ?? "" };
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const DATASET_BASE = /^\/projects\/([^/]+)\/datasets\/([^/]+)(\/.*)?$/;
|
|
159
|
+
function datasetParts(id) {
|
|
160
|
+
const dot = id.indexOf(".");
|
|
161
|
+
return { projectId: id.slice(0, dot), dataset: id.slice(dot + 1) };
|
|
162
|
+
}
|
|
163
|
+
function accessEntryRoles(type) {
|
|
164
|
+
return ({ identity, id }) => identity.access?.[accessKey(type, id)]?.roles;
|
|
165
|
+
}
|
|
166
|
+
const RESOURCE_CAPABILITIES = {
|
|
167
|
+
dataset: {
|
|
168
|
+
label: (id) => {
|
|
169
|
+
const { projectId, dataset } = datasetParts(id);
|
|
170
|
+
return `dataset "${projectId}:${dataset}"`;
|
|
171
|
+
},
|
|
172
|
+
dataBase: (id) => {
|
|
173
|
+
const { projectId, dataset } = datasetParts(id);
|
|
174
|
+
return `/projects/${projectId}/datasets/${dataset}`;
|
|
175
|
+
},
|
|
176
|
+
matchBase: (path) => {
|
|
177
|
+
const match = path.match(DATASET_BASE);
|
|
178
|
+
if (match !== null)
|
|
179
|
+
return { id: `${match[1]}.${match[2]}`, rest: match[3] ?? "" };
|
|
180
|
+
},
|
|
181
|
+
// A dataset's organization comes from its project: `GET /projects/<projectId>`.
|
|
182
|
+
lookupAtBase: !1,
|
|
183
|
+
identity: "project",
|
|
184
|
+
acl: "served",
|
|
185
|
+
history: "served",
|
|
186
|
+
actions: "served",
|
|
187
|
+
refusedActions: {},
|
|
188
|
+
access: { type: "project", id: (id) => datasetParts(id).projectId }
|
|
189
|
+
},
|
|
190
|
+
"media-library": {
|
|
191
|
+
label: (id) => `media library "${id}"`,
|
|
192
|
+
dataBase: (id) => `/media-libraries/${id}`,
|
|
193
|
+
matchBase: segmentBase("media-libraries"),
|
|
194
|
+
lookupAtBase: !0,
|
|
195
|
+
identity: "global",
|
|
196
|
+
acl: "served",
|
|
197
|
+
history: "served",
|
|
198
|
+
actions: "served",
|
|
199
|
+
refusedActions: { "sanity.action.release.create": releaseLimitExceeded },
|
|
200
|
+
access: { type: "media-library", id: (id) => id }
|
|
201
|
+
},
|
|
202
|
+
canvas: {
|
|
203
|
+
label: (id) => `canvas "${id}"`,
|
|
204
|
+
dataBase: (id) => `/canvases/${id}`,
|
|
205
|
+
matchBase: segmentBase("canvases"),
|
|
206
|
+
lookupAtBase: !0,
|
|
207
|
+
identity: "global",
|
|
208
|
+
acl: "served",
|
|
209
|
+
history: "served",
|
|
210
|
+
actions: notMounted,
|
|
211
|
+
refusedActions: {},
|
|
212
|
+
access: { type: "canvas", id: (id) => id }
|
|
213
|
+
},
|
|
214
|
+
dashboard: {
|
|
215
|
+
label: (id) => `dashboard "${id}"`,
|
|
216
|
+
dataBase: (id) => `/dashboards/${id}`,
|
|
217
|
+
matchBase: segmentBase("dashboards"),
|
|
218
|
+
lookupAtBase: !0,
|
|
219
|
+
identity: "global",
|
|
220
|
+
acl: notMounted,
|
|
221
|
+
history: notMounted,
|
|
222
|
+
actions: notMounted,
|
|
223
|
+
refusedActions: {},
|
|
224
|
+
access: { type: "dashboard", id: (id) => id }
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
function capabilitiesOf(type) {
|
|
228
|
+
const row = Object.hasOwn(RESOURCE_CAPABILITIES, type) ? RESOURCE_CAPABILITIES[type] : void 0;
|
|
229
|
+
if (row === void 0)
|
|
230
|
+
throw fakeError(
|
|
231
|
+
`Unsupported resource type ${JSON.stringify(type)}. The fake serves ${Object.keys(RESOURCE_CAPABILITIES).map((t) => `"${t}"`).join(", ")}.`
|
|
232
|
+
);
|
|
233
|
+
return row;
|
|
234
|
+
}
|
|
235
|
+
function validateResource(resource) {
|
|
236
|
+
const row = capabilitiesOf(resource.type);
|
|
237
|
+
if (resource.type === "dataset" && resource.id.split(".").length !== 2)
|
|
238
|
+
throw fakeError(
|
|
239
|
+
`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 }\`.`
|
|
240
|
+
);
|
|
241
|
+
return row;
|
|
242
|
+
}
|
|
243
|
+
function matchResourcePath(path) {
|
|
244
|
+
for (const [type, row] of Object.entries(RESOURCE_CAPABILITIES)) {
|
|
245
|
+
const match = row.matchBase(path);
|
|
246
|
+
if (match !== void 0)
|
|
247
|
+
return { resource: { type, id: match.id }, rest: match.rest };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function accessResourceOf(resource) {
|
|
251
|
+
const { access } = capabilitiesOf(resource.type);
|
|
252
|
+
return { type: access.type, id: access.id(resource.id) };
|
|
253
|
+
}
|
|
254
|
+
function holdsResource(type) {
|
|
255
|
+
return (registry, id) => registry.resources().some((resource) => {
|
|
256
|
+
const access = accessResourceOf(resource);
|
|
257
|
+
return access.type === type && access.id === id;
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
const ACCESS_CAPABILITIES = {
|
|
261
|
+
organization: {
|
|
262
|
+
// Proven on staging, but outside what the fake serves: refused like a missing permission.
|
|
263
|
+
roles: noPermission,
|
|
264
|
+
users: "served",
|
|
265
|
+
user: "served",
|
|
266
|
+
// Granted by the built-in organization `member` role.
|
|
267
|
+
usersPermission: "sanity.organization.members.read",
|
|
268
|
+
unknown: {
|
|
269
|
+
holds: (registry, id) => id === registry.organizationId,
|
|
270
|
+
refusal: notFound("Organization")
|
|
271
|
+
},
|
|
272
|
+
memberRoles: accessEntryRoles("organization"),
|
|
273
|
+
// Every resource of the family belongs to its organization (see `organizationId`).
|
|
274
|
+
owns: (registry) => {
|
|
275
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
276
|
+
for (const resource of registry.resources()) {
|
|
277
|
+
const access = accessResourceOf(resource);
|
|
278
|
+
byKey.set(accessKey(access.type, access.id), access);
|
|
279
|
+
}
|
|
280
|
+
return [...byKey.values()];
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
project: {
|
|
284
|
+
roles: "served",
|
|
285
|
+
users: "served",
|
|
286
|
+
// Single-user reads are served only where the fake checks members read (the
|
|
287
|
+
// organization); elsewhere they refuse rather than answer unchecked.
|
|
288
|
+
user: noPermission,
|
|
289
|
+
// Every registered identity is a member of every project, with its project roles.
|
|
290
|
+
memberRoles: ({ identity, id }) => (identity.usersByProject?.[id] ?? identity.user).roles ?? []
|
|
291
|
+
},
|
|
292
|
+
"media-library": {
|
|
293
|
+
// Unproven on staging (no token held roles read): refused like a missing permission.
|
|
294
|
+
roles: noPermission,
|
|
295
|
+
users: "served",
|
|
296
|
+
// Unproven on staging (only the listing was probed); refused as on `project`.
|
|
297
|
+
user: noPermission,
|
|
298
|
+
unknown: { holds: holdsResource("media-library"), refusal: notFound("Media library") },
|
|
299
|
+
memberRoles: accessEntryRoles("media-library")
|
|
300
|
+
},
|
|
301
|
+
canvas: {
|
|
302
|
+
// Unproven on staging (no canvas roles/members permission exists to grant).
|
|
303
|
+
roles: noPermission,
|
|
304
|
+
users: noPermission,
|
|
305
|
+
user: noPermission,
|
|
306
|
+
unknown: { holds: holdsResource("canvas"), refusal: notFound("Canvas") },
|
|
307
|
+
memberRoles: accessEntryRoles("canvas")
|
|
308
|
+
},
|
|
309
|
+
dashboard: {
|
|
310
|
+
// Unproven on staging, as for canvas.
|
|
311
|
+
roles: noPermission,
|
|
312
|
+
users: noPermission,
|
|
313
|
+
user: noPermission,
|
|
314
|
+
unknown: { holds: holdsResource("dashboard"), refusal: notFound("Dashboard") },
|
|
315
|
+
memberRoles: accessEntryRoles("dashboard")
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
function accessCapabilitiesOf(type) {
|
|
319
|
+
return Object.hasOwn(ACCESS_CAPABILITIES, type) ? ACCESS_CAPABILITIES[type] : void 0;
|
|
320
|
+
}
|
|
321
|
+
function dataUrl(args) {
|
|
322
|
+
const { resource, dataset, operation, path } = args;
|
|
323
|
+
if (resource !== void 0) {
|
|
324
|
+
const uri2 = path !== void 0 ? `${operation}/${path}` : operation;
|
|
325
|
+
return `${capabilitiesOf(resource.type).dataBase(resource.id)}/${uri2}`.replace(
|
|
326
|
+
/\/($|\?)/,
|
|
327
|
+
"$1"
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
const base = `/${operation}/${dataset}`;
|
|
331
|
+
return `/data${path !== void 0 ? `${base}/${path}` : base}`.replace(/\/($|\?)/, "$1");
|
|
332
|
+
}
|
|
333
|
+
const ACCESS_PATH = /^\/access\/([^/]+)\/([^/]+)(\/.*)?$/, ACCESS_USER_PATH = /^\/users\/([^/]+)$/, DATA_HISTORY_PATH = /^\/data\/history\/([^/]+)(\/.*)$/;
|
|
334
|
+
function page(data) {
|
|
335
|
+
return { data, nextCursor: null };
|
|
336
|
+
}
|
|
337
|
+
function usersPage(data) {
|
|
338
|
+
return { ...page(data), totalCount: data.length };
|
|
339
|
+
}
|
|
340
|
+
const UNMODELLED_PROFILE = { provider: "sanity", createdAt: (/* @__PURE__ */ new Date(0)).toISOString() };
|
|
341
|
+
async function servePlatformRequest(ctx, url) {
|
|
342
|
+
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);
|
|
343
|
+
if (access !== null) {
|
|
344
|
+
const [, type, id, rest2] = access;
|
|
345
|
+
return serveAccess({ ctx, request, type, id: decodeURIComponent(id), rest: rest2 ?? "", query });
|
|
346
|
+
}
|
|
347
|
+
const dataHistory = path.match(DATA_HISTORY_PATH);
|
|
348
|
+
if (dataHistory !== null) {
|
|
349
|
+
const [, dataset, rest2] = dataHistory, resource2 = { type: "dataset", id: `${ctx.projectId}.${dataset}` };
|
|
350
|
+
return serveHistory({ ctx, request, resource: resource2, rest: rest2, query });
|
|
351
|
+
}
|
|
352
|
+
const matched = matchResourcePath(path);
|
|
353
|
+
if (matched === void 0) return;
|
|
354
|
+
const { resource, rest } = matched, row = capabilitiesOf(resource.type);
|
|
355
|
+
if (rest === "") return row.lookupAtBase ? serveLookup(ctx, resource) : void 0;
|
|
356
|
+
if (rest === "/acl")
|
|
357
|
+
return serveCapability(row.acl, request, () => {
|
|
358
|
+
const own = ctx.own(resource);
|
|
359
|
+
return own === void 0 ? void 0 : { value: own.grants };
|
|
360
|
+
});
|
|
361
|
+
if (rest.startsWith("/history/"))
|
|
362
|
+
return serveHistory({ ctx, request, resource, rest: rest.slice(8), query });
|
|
363
|
+
}
|
|
364
|
+
function serveLookup(ctx, resource) {
|
|
365
|
+
if (ctx.organizationId === void 0)
|
|
366
|
+
throw fakeError(
|
|
367
|
+
`request(): GET ${capabilitiesOf(resource.type).dataBase(resource.id)} answers the resource's organization, but no organization is configured. Pass createTestClient({ organizationId }).`
|
|
368
|
+
);
|
|
369
|
+
return { value: { organizationId: ctx.organizationId, id: resource.id, status: "active" } };
|
|
370
|
+
}
|
|
371
|
+
function serveHistory(args) {
|
|
372
|
+
const { ctx, request, resource, rest, query } = args;
|
|
373
|
+
return serveCapability(capabilitiesOf(resource.type).history, request, () => {
|
|
374
|
+
const own = ctx.own(resource);
|
|
375
|
+
return own === void 0 ? void 0 : readHistory({ own, request, rest, query });
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
async function readHistory(args) {
|
|
379
|
+
const { own, request, rest, query } = args, current = new Map(own.documents().map((doc) => [doc._id, doc])), hidden = /* @__PURE__ */ new Set();
|
|
380
|
+
for (const id of /* @__PURE__ */ new Set([...current.keys(), ...own.history.documentIds()])) {
|
|
381
|
+
const image = current.get(id) ?? own.history.lastImage(id);
|
|
382
|
+
image !== void 0 && !await own.canRead(image) && hidden.add(id);
|
|
383
|
+
}
|
|
384
|
+
const visible = (id) => !hidden.has(id), readable = (list) => historyIds(list).filter(visible), documents = rest.match(/^\/documents\/([^/]+)$/);
|
|
385
|
+
if (documents !== null) {
|
|
386
|
+
const revision = query.get("revision");
|
|
387
|
+
if (revision === null)
|
|
388
|
+
throw fakeError(
|
|
389
|
+
`request(): ${request.path} is served for \`?revision=<rev>\` reads only (time-based history reads are not simulated).`
|
|
390
|
+
);
|
|
391
|
+
const ids = readable(documents[1]);
|
|
392
|
+
return {
|
|
393
|
+
value: { documents: own.history.documentsAt({ ids, revision, current: own.documents() }) }
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
const transactions = rest.match(/^\/transactions\/([^/]+)$/);
|
|
397
|
+
if (transactions !== null)
|
|
398
|
+
return { value: own.history.transactions(readable(transactions[1]), visible).map((record) => JSON.stringify(record)).join(`
|
|
399
|
+
`) };
|
|
400
|
+
const events = rest.match(/^\/events\/documents\/([^/]+)$/);
|
|
401
|
+
if (events !== null)
|
|
402
|
+
return {
|
|
403
|
+
value: {
|
|
404
|
+
events: own.history.events(readable(events[1])),
|
|
405
|
+
nextCursor: ""
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
function serveAccess(args) {
|
|
410
|
+
const { ctx, request, type, id, rest, query } = args, row = accessCapabilitiesOf(type);
|
|
411
|
+
if (row === void 0) throw invalidAccessType(request, type);
|
|
412
|
+
const resource = { type, id }, registry = {
|
|
413
|
+
organizationId: ctx.organizationId,
|
|
414
|
+
resources: ctx.resources
|
|
415
|
+
}, permissions = ctx.actorAccess()?.[accessKey(resource.type, id)]?.permissions ?? [];
|
|
416
|
+
if (rest === "/user-permissions/me/check") {
|
|
417
|
+
const requested = query.getAll("permissions").flatMap((value) => value.split(","));
|
|
418
|
+
return {
|
|
419
|
+
value: { data: Object.fromEntries(requested.map((p) => [p, permissions.includes(p)])) }
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
if (row.unknown !== void 0 && !row.unknown.holds(registry, id))
|
|
423
|
+
throw row.unknown.refusal(request);
|
|
424
|
+
const users = (capability, serve) => serveCapability(capability, request, () => {
|
|
425
|
+
if (row.usersPermission !== void 0 && !permissions.includes(row.usersPermission))
|
|
426
|
+
throw noPermission(request);
|
|
427
|
+
return serve();
|
|
428
|
+
}), user = rest.match(ACCESS_USER_PATH);
|
|
429
|
+
if (user !== null)
|
|
430
|
+
return users(row.user, () => {
|
|
431
|
+
const sanityUserId = decodeURIComponent(user[1]), record = usersOn({ resource, registry, identities: ctx.identities() }).find(
|
|
432
|
+
(candidate) => candidate.sanityUserId === sanityUserId
|
|
433
|
+
);
|
|
434
|
+
if (record === void 0) throw notFound("User")(request);
|
|
435
|
+
return { value: record };
|
|
436
|
+
});
|
|
437
|
+
switch (rest) {
|
|
438
|
+
case "/roles":
|
|
439
|
+
return serveCapability(row.roles, request, () => ({
|
|
440
|
+
value: page(rolesOn({ resource, identities: ctx.identities() }))
|
|
441
|
+
}));
|
|
442
|
+
case "/users":
|
|
443
|
+
return users(row.users, () => ({
|
|
444
|
+
value: usersPage(usersOn({ resource, registry, identities: ctx.identities() }))
|
|
445
|
+
}));
|
|
446
|
+
case "/user-permissions/me":
|
|
447
|
+
return {
|
|
448
|
+
value: page(
|
|
449
|
+
permissions.map((permission) => {
|
|
450
|
+
const dot = permission.lastIndexOf(".");
|
|
451
|
+
return {
|
|
452
|
+
type: permission.slice(0, dot),
|
|
453
|
+
resourceType: resource.type,
|
|
454
|
+
resourceId: id,
|
|
455
|
+
action: permission.slice(dot + 1)
|
|
456
|
+
};
|
|
457
|
+
})
|
|
458
|
+
)
|
|
459
|
+
};
|
|
460
|
+
default:
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
function rolesOn(args) {
|
|
465
|
+
const { resource } = args, byName = /* @__PURE__ */ new Map();
|
|
466
|
+
for (const identity of args.identities) {
|
|
467
|
+
const roles = ACCESS_CAPABILITIES[resource.type].memberRoles({ identity, id: resource.id });
|
|
468
|
+
for (const role of roles ?? [])
|
|
469
|
+
byName.set(role.name, {
|
|
470
|
+
name: role.name,
|
|
471
|
+
...role.title !== void 0 ? { title: role.title } : {},
|
|
472
|
+
...role.description !== void 0 ? { description: role.description } : {}
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
return [...byName.values()].toSorted((a, b) => a.name.localeCompare(b.name));
|
|
476
|
+
}
|
|
477
|
+
function membershipOn(identity, resource) {
|
|
478
|
+
const roles = ACCESS_CAPABILITIES[resource.type].memberRoles({ identity, id: resource.id });
|
|
479
|
+
if (roles !== void 0)
|
|
480
|
+
return {
|
|
481
|
+
resourceType: resource.type,
|
|
482
|
+
resourceId: resource.id,
|
|
483
|
+
roleNames: roles.map((role) => role.name).toSorted()
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
function usersOn(args) {
|
|
487
|
+
const { resource, registry, identities } = args, owned = ACCESS_CAPABILITIES[resource.type].owns?.(registry) ?? [], bySanityUserId = /* @__PURE__ */ new Map();
|
|
488
|
+
for (const identity of identities) {
|
|
489
|
+
const own = membershipOn(identity, resource);
|
|
490
|
+
if (own === void 0) continue;
|
|
491
|
+
const user = identity.globalUser ?? identity.user, record = {
|
|
492
|
+
sanityUserId: user.id,
|
|
493
|
+
profile: {
|
|
494
|
+
id: user.id,
|
|
495
|
+
displayName: user.name ?? user.id,
|
|
496
|
+
email: user.email ?? "",
|
|
497
|
+
...UNMODELLED_PROFILE
|
|
498
|
+
},
|
|
499
|
+
memberships: [
|
|
500
|
+
own,
|
|
501
|
+
// A user is part of a resource only through a role on it.
|
|
502
|
+
...owned.flatMap((child) => {
|
|
503
|
+
const membership = membershipOn(identity, child);
|
|
504
|
+
return membership === void 0 || membership.roleNames.length === 0 ? [] : [membership];
|
|
505
|
+
})
|
|
506
|
+
]
|
|
507
|
+
}, existing = bySanityUserId.get(record.sanityUserId);
|
|
508
|
+
if (existing !== void 0 && JSON.stringify(existing) !== JSON.stringify(record))
|
|
509
|
+
throw fakeError(
|
|
510
|
+
`request(): two registered identities resolve to user "${record.sanityUserId}" on ${resource.type} "${resource.id}" with different records. Fix the identity registrations so duplicate ids carry identical records.`
|
|
511
|
+
);
|
|
512
|
+
bySanityUserId.set(record.sanityUserId, record);
|
|
513
|
+
}
|
|
514
|
+
return [...bySanityUserId.values()];
|
|
515
|
+
}
|
|
6
516
|
class WriteAccessDeniedError extends Error {
|
|
7
517
|
documentId;
|
|
8
518
|
permission;
|
|
@@ -59,6 +569,12 @@ async function enforceWriteAccess(args) {
|
|
|
59
569
|
function defaultAclPath(projectId, dataset) {
|
|
60
570
|
return `/projects/${projectId}/datasets/${dataset}/acl`;
|
|
61
571
|
}
|
|
572
|
+
function refusedAcl(path) {
|
|
573
|
+
const matched = matchResourcePath(path);
|
|
574
|
+
if (matched?.rest !== "/acl") return;
|
|
575
|
+
const { acl } = capabilitiesOf(matched.resource.type);
|
|
576
|
+
return acl === "served" ? void 0 : acl({ method: "GET", path });
|
|
577
|
+
}
|
|
62
578
|
function projectUserOf(identity, projectId) {
|
|
63
579
|
return identity.usersByProject?.[projectId] ?? identity.user;
|
|
64
580
|
}
|
|
@@ -122,16 +638,19 @@ function serveProject(args) {
|
|
|
122
638
|
...projectUser.id.startsWith("p-") ? { isRobot: !0 } : {},
|
|
123
639
|
...projectUser.roles !== void 0 ? { roles: canonicalProjectRoles(projectUser.roles) } : {}
|
|
124
640
|
};
|
|
125
|
-
});
|
|
126
|
-
return {
|
|
641
|
+
}), members = consistentProjectRecords({ projectId: args.pathProjectId, records });
|
|
642
|
+
return {
|
|
643
|
+
...args.organizationId !== void 0 ? { organizationId: args.organizationId } : {},
|
|
644
|
+
members: [...members.values()]
|
|
645
|
+
};
|
|
127
646
|
}
|
|
128
647
|
function makeRequestImpl(ctx) {
|
|
129
|
-
const { getAccess,
|
|
648
|
+
const { getAccess, useProjectHostname, listIdentities, organizationId, platform } = ctx;
|
|
130
649
|
return async (opts) => {
|
|
131
|
-
const accessControl = getAccess(),
|
|
650
|
+
const accessControl = getAccess(), path = opts.uri ?? opts.url;
|
|
132
651
|
if (path === "/users/me")
|
|
133
652
|
return useProjectHostname ? accessControl.currentUser : accessControl.globalUser ?? accessControl.currentUser;
|
|
134
|
-
if (path === aclPath)
|
|
653
|
+
if (path !== void 0 && path === accessControl.aclPath && refusedAcl(path) === void 0)
|
|
135
654
|
return accessControl.grants;
|
|
136
655
|
const projectUsers = path?.match(PROJECT_USERS_PATH), [, pathProjectId, requestedIds] = projectUsers ?? [];
|
|
137
656
|
if (pathProjectId !== void 0 && requestedIds !== void 0)
|
|
@@ -144,10 +663,13 @@ function makeRequestImpl(ctx) {
|
|
|
144
663
|
if (membersProjectId !== void 0)
|
|
145
664
|
return serveProject({
|
|
146
665
|
pathProjectId: membersProjectId,
|
|
147
|
-
identities: listIdentities()
|
|
666
|
+
identities: listIdentities(),
|
|
667
|
+
organizationId
|
|
148
668
|
});
|
|
669
|
+
const served = path === void 0 ? void 0 : await servePlatformRequest(platform, path);
|
|
670
|
+
if (served !== void 0) return served.value;
|
|
149
671
|
throw fakeError(
|
|
150
|
-
`request(): no handler for path "${path}". With access control configured, the fake serves "/users/me", "
|
|
672
|
+
`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[/<id>] | 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.`
|
|
151
673
|
);
|
|
152
674
|
};
|
|
153
675
|
}
|
|
@@ -411,7 +933,8 @@ function releaseNameOf(id) {
|
|
|
411
933
|
return rest.slice(0, dot);
|
|
412
934
|
}
|
|
413
935
|
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);
|
|
414
|
-
function applyPerspective(docs,
|
|
936
|
+
function applyPerspective(docs, requested) {
|
|
937
|
+
const perspective = normalizePerspective(requested);
|
|
415
938
|
if (perspective === "raw") return [...docs];
|
|
416
939
|
if (perspective === "published")
|
|
417
940
|
return docs.filter((d) => !isDraftId(d._id) && !isVersionId(d._id) && !isSystemId(d._id));
|
|
@@ -423,6 +946,20 @@ function applyPerspective(docs, perspective) {
|
|
|
423
946
|
entry === "drafts" ? includeDrafts = !0 : stack.push(entry);
|
|
424
947
|
return mergeStack(docs, stack, { includeDrafts, includeReleaseDocs: !0 });
|
|
425
948
|
}
|
|
949
|
+
function normalizePerspective(perspective) {
|
|
950
|
+
if (Array.isArray(perspective)) return perspective;
|
|
951
|
+
switch (perspective) {
|
|
952
|
+
case "raw":
|
|
953
|
+
return "raw";
|
|
954
|
+
case "published":
|
|
955
|
+
return "published";
|
|
956
|
+
case "drafts":
|
|
957
|
+
case "previewDrafts":
|
|
958
|
+
return "drafts";
|
|
959
|
+
default:
|
|
960
|
+
return perspective.split(",");
|
|
961
|
+
}
|
|
962
|
+
}
|
|
426
963
|
function mergeStack(docs, releaseStack, options) {
|
|
427
964
|
const groups = /* @__PURE__ */ new Map(), systemReleases = [];
|
|
428
965
|
for (const doc of docs) {
|
|
@@ -856,6 +1393,7 @@ const ISSUES_URL = "https://github.com/sanity-labs/client-fake-for-test/issues",
|
|
|
856
1393
|
"createVersion",
|
|
857
1394
|
"unpublishVersion",
|
|
858
1395
|
"request (only with accessControl or a bound token)",
|
|
1396
|
+
"getDataUrl",
|
|
859
1397
|
"config",
|
|
860
1398
|
"withConfig"
|
|
861
1399
|
], UNSUPPORTED_MEMBERS = {
|
|
@@ -875,10 +1413,6 @@ const ISSUES_URL = "https://github.com/sanity-labs/client-fake-for-test/issues",
|
|
|
875
1413
|
what: "build an absolute API URL for a path",
|
|
876
1414
|
workaround: "Hard-code the expected URL in your test, or stub it."
|
|
877
1415
|
},
|
|
878
|
-
getDataUrl: {
|
|
879
|
-
what: "build an absolute data-API URL",
|
|
880
|
-
workaround: "Hard-code the expected URL in your test, or stub it."
|
|
881
|
-
},
|
|
882
1416
|
assets: {
|
|
883
1417
|
what: "asset upload / metadata namespace",
|
|
884
1418
|
workaround: "Seed asset documents into the store directly with `seed()` / `add()`."
|
|
@@ -905,7 +1439,7 @@ const ISSUES_URL = "https://github.com/sanity-labs/client-fake-for-test/issues",
|
|
|
905
1439
|
},
|
|
906
1440
|
request: {
|
|
907
1441
|
what: "generic HTTP request hook \u2014 only wired up when the handle has an acting identity",
|
|
908
|
-
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>,
|
|
1442
|
+
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."
|
|
909
1443
|
},
|
|
910
1444
|
dryRun: {
|
|
911
1445
|
what: "not a method \u2014 did you mean the `{ dryRun }` mutation option?",
|
|
@@ -1063,13 +1597,32 @@ function rejectAmbiguousIdentity(site, config) {
|
|
|
1063
1597
|
`${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.`
|
|
1064
1598
|
);
|
|
1065
1599
|
}
|
|
1066
|
-
const registryKey = (
|
|
1600
|
+
const registryKey = (resource) => `${resource.type}:${resource.id}`, datasetResource = (projectId, dataset) => ({
|
|
1601
|
+
type: "dataset",
|
|
1602
|
+
id: `${projectId}.${dataset}`
|
|
1603
|
+
}), parseDatasetKey = (key) => {
|
|
1067
1604
|
const [projectId, dataset] = key.split(":");
|
|
1068
1605
|
return { projectId, dataset };
|
|
1606
|
+
}, parseResourceKey = (key) => {
|
|
1607
|
+
const separator = key.indexOf(":"), resource = {
|
|
1608
|
+
type: key.slice(0, separator),
|
|
1609
|
+
id: key.slice(separator + 1)
|
|
1610
|
+
};
|
|
1611
|
+
if (separator <= 0 || resource.id.length === 0)
|
|
1612
|
+
throw fakeError(
|
|
1613
|
+
`createTestClient(): \`resources\` keys are "<type>:<id>" (e.g. "media-library:ml1"); got "${key}".`
|
|
1614
|
+
);
|
|
1615
|
+
return validateResource(resource), resource;
|
|
1069
1616
|
};
|
|
1617
|
+
function ensureEntry(registry, resource) {
|
|
1618
|
+
const key = registryKey(resource);
|
|
1619
|
+
let entry = registry.get(key);
|
|
1620
|
+
return entry === void 0 && (entry = { resource, store: createTestStore(), history: createDocumentHistory() }, registry.set(key, entry)), entry;
|
|
1621
|
+
}
|
|
1070
1622
|
function createTestClient(options = {}) {
|
|
1071
1623
|
const defaultProjectId = options.projectId ?? DEFAULT_PROJECT_ID, defaultDataset = options.dataset ?? DEFAULT_DATASET, backend = {
|
|
1072
1624
|
registry: /* @__PURE__ */ new Map(),
|
|
1625
|
+
organizationId: options.organizationId,
|
|
1073
1626
|
log: [],
|
|
1074
1627
|
stubs: /* @__PURE__ */ new Map(),
|
|
1075
1628
|
calls: [],
|
|
@@ -1077,21 +1630,23 @@ function createTestClient(options = {}) {
|
|
|
1077
1630
|
identities: new Map(Object.entries(options.identities ?? {})),
|
|
1078
1631
|
writeChain: Promise.resolve()
|
|
1079
1632
|
}, registry = backend.registry;
|
|
1080
|
-
rejectAmbiguousIdentity("createTestClient()", options), options.token !== void 0 && requireIdentity(backend, options.token, "createTestClient()")
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
};
|
|
1086
|
-
if (options.documents && ensureStore(defaultProjectId, defaultDataset).seed(options.documents), options.datasets)
|
|
1633
|
+
if (rejectAmbiguousIdentity("createTestClient()", options), options.token !== void 0 && requireIdentity(backend, options.token, "createTestClient()"), options.resource !== void 0 && validateResource(options.resource), options.documents) {
|
|
1634
|
+
const root = options.resource ?? datasetResource(defaultProjectId, defaultDataset);
|
|
1635
|
+
ensureEntry(registry, root).store.seed(options.documents);
|
|
1636
|
+
}
|
|
1637
|
+
if (options.datasets)
|
|
1087
1638
|
for (const [key, docs] of Object.entries(options.datasets)) {
|
|
1088
|
-
const { projectId, dataset } =
|
|
1089
|
-
|
|
1639
|
+
const { projectId, dataset } = parseDatasetKey(key);
|
|
1640
|
+
ensureEntry(registry, datasetResource(projectId, dataset)).store.seed(docs);
|
|
1090
1641
|
}
|
|
1642
|
+
if (options.resources)
|
|
1643
|
+
for (const [key, docs] of Object.entries(options.resources))
|
|
1644
|
+
ensureEntry(registry, parseResourceKey(key)).store.seed(docs);
|
|
1091
1645
|
return makeHandle({
|
|
1092
1646
|
backend,
|
|
1093
1647
|
projectId: defaultProjectId,
|
|
1094
1648
|
dataset: defaultDataset,
|
|
1649
|
+
resource: options.resource,
|
|
1095
1650
|
accessControl: options.accessControl,
|
|
1096
1651
|
perspective: options.perspective ?? "raw",
|
|
1097
1652
|
requestTagPrefix: options.requestTagPrefix,
|
|
@@ -1153,39 +1708,52 @@ function toTxnMutations(operations) {
|
|
|
1153
1708
|
function makeHandle(handleArgs) {
|
|
1154
1709
|
const {
|
|
1155
1710
|
backend,
|
|
1156
|
-
|
|
1157
|
-
dataset,
|
|
1711
|
+
resource,
|
|
1158
1712
|
accessControl,
|
|
1159
1713
|
perspective,
|
|
1160
1714
|
requestTagPrefix,
|
|
1161
1715
|
mutationGuard,
|
|
1162
1716
|
token,
|
|
1163
1717
|
useProjectHostname
|
|
1164
|
-
} = handleArgs, { registry } = backend, activeAccess = () => {
|
|
1718
|
+
} = handleArgs, { registry } = backend, { projectId, dataset } = resource?.type === "dataset" ? datasetParts(resource.id) : handleArgs, target = resource ?? datasetResource(projectId, dataset), activeAccess = () => {
|
|
1165
1719
|
if (token === void 0) return accessControl;
|
|
1166
1720
|
const identity = backend.identities.get(token);
|
|
1167
1721
|
if (identity === void 0)
|
|
1168
1722
|
throw fakeError(`no identity is registered for token "${token}" (was it removed?).`);
|
|
1169
1723
|
return {
|
|
1170
|
-
|
|
1724
|
+
// Per-project records apply only where the resource lives in a project.
|
|
1725
|
+
currentUser: capabilitiesOf(target.type).identity === "project" ? projectUserOf(identity, projectId) : identity.user,
|
|
1171
1726
|
...identity.globalUser !== void 0 ? { globalUser: identity.globalUser } : {},
|
|
1172
|
-
grants: identity.grants
|
|
1727
|
+
grants: identity.grants,
|
|
1728
|
+
...identity.access !== void 0 ? { access: identity.access } : {}
|
|
1173
1729
|
};
|
|
1174
1730
|
}, requireActiveAccess = () => {
|
|
1175
1731
|
const access = activeAccess();
|
|
1176
1732
|
if (access === void 0)
|
|
1177
1733
|
throw fakeError("this handle has no acting identity (no token or accessControl bound).");
|
|
1178
1734
|
return access;
|
|
1179
|
-
}, store = (() => {
|
|
1180
|
-
const
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
}
|
|
1735
|
+
}, capabilities = capabilitiesOf(target.type), targetLabel = capabilities.label(target.id), { store, history } = ensureEntry(registry, target), concurrentEditKey = (id) => registryKey(target) + " " + id, actorId = () => {
|
|
1736
|
+
const access = activeAccess();
|
|
1737
|
+
if (access !== void 0)
|
|
1738
|
+
return capabilities.identity === "global" ? (access.globalUser ?? access.currentUser).id : access.currentUser.id;
|
|
1739
|
+
};
|
|
1740
|
+
let beforeWrite;
|
|
1741
|
+
const write = (run) => serializeWrite(backend, async () => {
|
|
1742
|
+
beforeWrite = new Map(store.all().map((doc) => [doc._id, doc]));
|
|
1743
|
+
try {
|
|
1744
|
+
return await run();
|
|
1745
|
+
} finally {
|
|
1746
|
+
beforeWrite = void 0;
|
|
1747
|
+
}
|
|
1748
|
+
}), commitHistory = (transactionId) => {
|
|
1749
|
+
beforeWrite !== void 0 && history.commit({ transactionId, author: actorId(), before: beforeWrite, after: store.all() });
|
|
1750
|
+
}, record = (transactionId, mutations, tag) => {
|
|
1184
1751
|
const effectiveTag = effectiveRequestTag(tag);
|
|
1185
|
-
backend.log.push({
|
|
1752
|
+
commitHistory(transactionId), backend.log.push({
|
|
1186
1753
|
transactionId,
|
|
1187
1754
|
projectId,
|
|
1188
1755
|
dataset,
|
|
1756
|
+
...resource !== void 0 ? { resource } : {},
|
|
1189
1757
|
mutations,
|
|
1190
1758
|
...effectiveTag !== void 0 ? { tag: effectiveTag } : {}
|
|
1191
1759
|
});
|
|
@@ -1235,10 +1803,10 @@ function makeHandle(handleArgs) {
|
|
|
1235
1803
|
document,
|
|
1236
1804
|
grants: access.grants,
|
|
1237
1805
|
permission: document._id !== void 0 && isSystemId(document._id) ? "manage" : permission,
|
|
1238
|
-
userId:
|
|
1806
|
+
userId: actorId()
|
|
1239
1807
|
});
|
|
1240
1808
|
}, guardGate = async (contexts) => {
|
|
1241
|
-
const identity =
|
|
1809
|
+
const identity = actorId();
|
|
1242
1810
|
await enforceMutationGuards(
|
|
1243
1811
|
mutationGuard,
|
|
1244
1812
|
contexts.map((context) => ({ ...context, identity }))
|
|
@@ -1251,13 +1819,13 @@ function makeHandle(handleArgs) {
|
|
|
1251
1819
|
document,
|
|
1252
1820
|
grants: access.grants,
|
|
1253
1821
|
permission: "read",
|
|
1254
|
-
userId:
|
|
1822
|
+
userId: actorId()
|
|
1255
1823
|
});
|
|
1256
1824
|
}, applyPatch = async (documentId, ops, ifRev, options = {}) => {
|
|
1257
1825
|
const existing = store.get(documentId);
|
|
1258
1826
|
if (existing === void 0)
|
|
1259
1827
|
throw fakeError(
|
|
1260
|
-
`Cannot patch document "${documentId}": not found \u2014 no document with that _id exists in
|
|
1828
|
+
`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.`
|
|
1261
1829
|
);
|
|
1262
1830
|
checkRevision(documentId, ifRev), await gate(existing, "update");
|
|
1263
1831
|
const patched = applyPatchOps(existing, ops), now = (/* @__PURE__ */ new Date()).toISOString(), next = {
|
|
@@ -1278,7 +1846,7 @@ function makeHandle(handleArgs) {
|
|
|
1278
1846
|
const promoted = promote2(mut.doc);
|
|
1279
1847
|
if (store.get(promoted._id) !== void 0)
|
|
1280
1848
|
throw fakeError(
|
|
1281
|
-
`create() failed: a document with _id "${promoted._id}" already exists in
|
|
1849
|
+
`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.`
|
|
1282
1850
|
);
|
|
1283
1851
|
await gate(promoted, "create"), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
|
|
1284
1852
|
break;
|
|
@@ -1327,7 +1895,21 @@ function makeHandle(handleArgs) {
|
|
|
1327
1895
|
txnOptions?.returnDocuments ?? !1,
|
|
1328
1896
|
txnOptions?.returnFirst ?? !1
|
|
1329
1897
|
);
|
|
1330
|
-
}, runAction =
|
|
1898
|
+
}, runAction = async (actions) => {
|
|
1899
|
+
const request = {
|
|
1900
|
+
method: "POST",
|
|
1901
|
+
path: `${capabilities.dataBase(target.id)}/actions`
|
|
1902
|
+
};
|
|
1903
|
+
if (capabilities.actions !== "served") throw capabilities.actions(request);
|
|
1904
|
+
for (const action of Array.isArray(actions) ? actions : [actions]) {
|
|
1905
|
+
const refused = capabilities.refusedActions[action.actionType];
|
|
1906
|
+
if (refused !== void 0) throw refused(request);
|
|
1907
|
+
}
|
|
1908
|
+
return write(async () => {
|
|
1909
|
+
const result = await applyActions(store, actions, { gate, guard: guardGate });
|
|
1910
|
+
return commitHistory(result.transactionId), result;
|
|
1911
|
+
});
|
|
1912
|
+
}, accessVisibleDocs = async () => {
|
|
1331
1913
|
const all = store.all();
|
|
1332
1914
|
if (activeAccess() === void 0) return all;
|
|
1333
1915
|
const out = [];
|
|
@@ -1371,10 +1953,11 @@ function makeHandle(handleArgs) {
|
|
|
1371
1953
|
const view = await visibleDocs(effective);
|
|
1372
1954
|
return ids.map((id) => resolveDocument(view, id, effective) ?? null);
|
|
1373
1955
|
},
|
|
1374
|
-
|
|
1956
|
+
getDataUrl: (operation, path) => dataUrl({ resource, dataset, operation, path }),
|
|
1957
|
+
create: (doc, opts) => write(async () => {
|
|
1375
1958
|
if (checkRevision(doc._id, opts?.ifRevisionId), store.get(doc._id) !== void 0)
|
|
1376
1959
|
throw fakeError(
|
|
1377
|
-
`create() failed: a document with _id "${doc._id}" already exists in
|
|
1960
|
+
`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.`
|
|
1378
1961
|
);
|
|
1379
1962
|
const promoted = promote2(doc);
|
|
1380
1963
|
await gate(promoted, "create"), await guardOne("create", null, promoted);
|
|
@@ -1386,7 +1969,7 @@ function makeHandle(handleArgs) {
|
|
|
1386
1969
|
opts
|
|
1387
1970
|
);
|
|
1388
1971
|
}),
|
|
1389
|
-
createOrReplace: (doc, opts) =>
|
|
1972
|
+
createOrReplace: (doc, opts) => write(async () => {
|
|
1390
1973
|
checkRevision(doc._id, opts?.ifRevisionId);
|
|
1391
1974
|
const promoted = promote2(doc), existing = store.get(doc._id);
|
|
1392
1975
|
await gate(existing ?? promoted, existing !== void 0 ? "update" : "create"), await guardOne(existing !== void 0 ? "update" : "create", existing ?? null, promoted);
|
|
@@ -1400,7 +1983,7 @@ function makeHandle(handleArgs) {
|
|
|
1400
1983
|
opts
|
|
1401
1984
|
);
|
|
1402
1985
|
}),
|
|
1403
|
-
createIfNotExists: (doc, opts) =>
|
|
1986
|
+
createIfNotExists: (doc, opts) => write(async () => {
|
|
1404
1987
|
checkRevision(doc._id, opts?.ifRevisionId);
|
|
1405
1988
|
const existing = store.get(doc._id), transactionId = opts?.transactionId ?? cryptoRandomKey();
|
|
1406
1989
|
if (existing !== void 0)
|
|
@@ -1418,7 +2001,7 @@ function makeHandle(handleArgs) {
|
|
|
1418
2001
|
opts
|
|
1419
2002
|
);
|
|
1420
2003
|
}),
|
|
1421
|
-
delete: (id, opts) =>
|
|
2004
|
+
delete: (id, opts) => write(async () => {
|
|
1422
2005
|
checkRevision(id, opts?.ifRevisionId);
|
|
1423
2006
|
const existing = store.get(id);
|
|
1424
2007
|
existing !== void 0 && await gate(existing, "update"), existing !== void 0 && await guardOne("delete", existing, null);
|
|
@@ -1433,7 +2016,7 @@ function makeHandle(handleArgs) {
|
|
|
1433
2016
|
);
|
|
1434
2017
|
}),
|
|
1435
2018
|
patch: (documentId) => createPatch(documentId, {
|
|
1436
|
-
apply: (docId, ops, ifRev, options) =>
|
|
2019
|
+
apply: (docId, ops, ifRev, options) => write(async () => {
|
|
1437
2020
|
const doc = await applyPatch(docId, ops, ifRev);
|
|
1438
2021
|
return record(
|
|
1439
2022
|
cryptoRandomKey(),
|
|
@@ -1450,9 +2033,9 @@ function makeHandle(handleArgs) {
|
|
|
1450
2033
|
})
|
|
1451
2034
|
}),
|
|
1452
2035
|
transaction: () => createTransaction({
|
|
1453
|
-
apply: (mutations, txnOptions) =>
|
|
2036
|
+
apply: (mutations, txnOptions) => write(() => applyTransaction(mutations, txnOptions))
|
|
1454
2037
|
}),
|
|
1455
|
-
mutate: (operations, options) =>
|
|
2038
|
+
mutate: (operations, options) => write(() => applyTransaction(toTxnMutations(operations), options)),
|
|
1456
2039
|
// Synchronous writes still take the lock: an action landing while a
|
|
1457
2040
|
// guarded patch is parked in its gate would otherwise be clobbered
|
|
1458
2041
|
// when that patch resumes over its pre-action snapshot.
|
|
@@ -1501,15 +2084,30 @@ function makeHandle(handleArgs) {
|
|
|
1501
2084
|
...token !== void 0 || accessControl !== void 0 ? {
|
|
1502
2085
|
request: makeRequestImpl({
|
|
1503
2086
|
getAccess: requireActiveAccess,
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
useProjectHostname,
|
|
1507
|
-
|
|
2087
|
+
// `@sanity/client` sends every request of a `resource`-configured
|
|
2088
|
+
// client to the global API host.
|
|
2089
|
+
useProjectHostname: useProjectHostname && resource === void 0,
|
|
2090
|
+
organizationId: backend.organizationId,
|
|
2091
|
+
listIdentities: () => [...backend.identities.values()],
|
|
2092
|
+
platform: {
|
|
2093
|
+
projectId,
|
|
2094
|
+
organizationId: backend.organizationId,
|
|
2095
|
+
own: (asked) => registryKey(asked) === registryKey(target) ? {
|
|
2096
|
+
grants: requireActiveAccess().grants,
|
|
2097
|
+
documents: () => store.all(),
|
|
2098
|
+
history,
|
|
2099
|
+
canRead
|
|
2100
|
+
} : void 0,
|
|
2101
|
+
actorAccess: () => requireActiveAccess().access,
|
|
2102
|
+
identities: () => [...backend.identities.values()],
|
|
2103
|
+
resources: () => [...registry.values()].map((entry) => entry.resource)
|
|
2104
|
+
}
|
|
1508
2105
|
})
|
|
1509
2106
|
} : {},
|
|
1510
2107
|
config: () => ({
|
|
1511
2108
|
projectId,
|
|
1512
2109
|
dataset,
|
|
2110
|
+
...resource !== void 0 ? { resource } : {},
|
|
1513
2111
|
perspective,
|
|
1514
2112
|
...requestTagPrefix !== void 0 ? { requestTagPrefix } : {},
|
|
1515
2113
|
...token !== void 0 ? { token } : {},
|
|
@@ -1517,11 +2115,12 @@ function makeHandle(handleArgs) {
|
|
|
1517
2115
|
}),
|
|
1518
2116
|
withConfig: (config) => {
|
|
1519
2117
|
rejectAmbiguousIdentity("withConfig()", config), config.token !== void 0 && requireIdentity(backend, config.token, "withConfig()");
|
|
1520
|
-
const rebinds = config.token !== void 0 || config.accessControl !== void 0;
|
|
1521
|
-
return makeHandle({
|
|
2118
|
+
const rebinds = config.token !== void 0 || config.accessControl !== void 0, nextResource = "resource" in config ? config.resource : resource;
|
|
2119
|
+
return nextResource !== void 0 && validateResource(nextResource), makeHandle({
|
|
1522
2120
|
backend,
|
|
1523
2121
|
projectId: config.projectId ?? projectId,
|
|
1524
2122
|
dataset: config.dataset ?? dataset,
|
|
2123
|
+
resource: nextResource,
|
|
1525
2124
|
accessControl: rebinds ? config.accessControl : accessControl,
|
|
1526
2125
|
perspective: config.perspective ?? perspective,
|
|
1527
2126
|
requestTagPrefix: config.requestTagPrefix ?? requestTagPrefix,
|
|
@@ -1539,7 +2138,8 @@ function makeHandle(handleArgs) {
|
|
|
1539
2138
|
const editKey = concurrentEditKey(id);
|
|
1540
2139
|
backend.concurrentEdits.set(editKey, (backend.concurrentEdits.get(editKey) ?? 0) + count);
|
|
1541
2140
|
},
|
|
1542
|
-
datasets: () => [...registry.
|
|
2141
|
+
datasets: () => [...registry.values()].filter((entry) => entry.resource.type === "dataset").map((entry) => datasetParts(entry.resource.id)),
|
|
2142
|
+
resources: () => [...registry.values()].map((entry) => ({ ...entry.resource })),
|
|
1543
2143
|
stub: (name, handler) => {
|
|
1544
2144
|
backend.stubs.set(name, handler);
|
|
1545
2145
|
},
|
|
@@ -1562,7 +2162,13 @@ function makeHandle(handleArgs) {
|
|
|
1562
2162
|
return wrapWithGuards(client, {
|
|
1563
2163
|
getStub: (name) => backend.stubs.get(name),
|
|
1564
2164
|
onCall: (method, args) => {
|
|
1565
|
-
const rec = {
|
|
2165
|
+
const rec = {
|
|
2166
|
+
method,
|
|
2167
|
+
args,
|
|
2168
|
+
projectId,
|
|
2169
|
+
dataset,
|
|
2170
|
+
...resource !== void 0 ? { resource } : {}
|
|
2171
|
+
}, tag = OPTIONS_ARG_POSITION[method] === void 0 ? void 0 : effectiveRequestTag(tagFromArgs(method, args));
|
|
1566
2172
|
return tag !== void 0 && (rec.tag = tag), backend.calls.push(rec), (outcome) => {
|
|
1567
2173
|
"error" in outcome ? rec.error = outcome.error : rec.result = outcome.result;
|
|
1568
2174
|
};
|
|
@@ -1573,6 +2179,7 @@ function makeHandle(handleArgs) {
|
|
|
1573
2179
|
export {
|
|
1574
2180
|
ERR_PREFIX,
|
|
1575
2181
|
GuardDeniedError,
|
|
2182
|
+
PlatformHttpError,
|
|
1576
2183
|
SUPPORTED_SURFACE,
|
|
1577
2184
|
UNSUPPORTED_MEMBERS,
|
|
1578
2185
|
WriteAccessDeniedError,
|