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