@sanity-labs/client-fake-for-test 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -3,6 +3,449 @@ 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, 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: {
189
+ type: "project",
190
+ id: (id) => datasetParts(id).projectId,
191
+ roles: "served",
192
+ users: "served",
193
+ // Every registered identity is a member of every project, with its project roles.
194
+ memberRoles: ({ identity, id }) => (identity.usersByProject?.[id] ?? identity.user).roles ?? []
195
+ }
196
+ },
197
+ "media-library": {
198
+ label: (id) => `media library "${id}"`,
199
+ dataBase: (id) => `/media-libraries/${id}`,
200
+ matchBase: segmentBase("media-libraries"),
201
+ lookupAtBase: !0,
202
+ identity: "global",
203
+ acl: "served",
204
+ history: "served",
205
+ actions: "served",
206
+ refusedActions: { "sanity.action.release.create": releaseLimitExceeded },
207
+ access: {
208
+ type: "media-library",
209
+ id: (id) => id,
210
+ // Unproven on staging (no token held roles read): refused like a missing permission.
211
+ roles: noPermission,
212
+ users: "served",
213
+ unknownId: notFound("Media library"),
214
+ memberRoles: accessEntryRoles("media-library")
215
+ }
216
+ },
217
+ canvas: {
218
+ label: (id) => `canvas "${id}"`,
219
+ dataBase: (id) => `/canvases/${id}`,
220
+ matchBase: segmentBase("canvases"),
221
+ lookupAtBase: !0,
222
+ identity: "global",
223
+ acl: "served",
224
+ history: "served",
225
+ actions: notMounted,
226
+ refusedActions: {},
227
+ access: {
228
+ type: "canvas",
229
+ id: (id) => id,
230
+ // Unproven on staging (no canvas roles/members permission exists to grant).
231
+ roles: noPermission,
232
+ users: noPermission,
233
+ unknownId: notFound("Canvas"),
234
+ memberRoles: accessEntryRoles("canvas")
235
+ }
236
+ },
237
+ dashboard: {
238
+ label: (id) => `dashboard "${id}"`,
239
+ dataBase: (id) => `/dashboards/${id}`,
240
+ matchBase: segmentBase("dashboards"),
241
+ lookupAtBase: !0,
242
+ identity: "global",
243
+ acl: notMounted,
244
+ history: notMounted,
245
+ actions: notMounted,
246
+ refusedActions: {},
247
+ access: {
248
+ type: "dashboard",
249
+ id: (id) => id,
250
+ // Unproven on staging, as for canvas.
251
+ roles: noPermission,
252
+ users: noPermission,
253
+ unknownId: notFound("Dashboard"),
254
+ memberRoles: accessEntryRoles("dashboard")
255
+ }
256
+ }
257
+ };
258
+ function capabilitiesOf(type) {
259
+ const row = RESOURCE_CAPABILITIES[type];
260
+ if (row === void 0)
261
+ throw fakeError(
262
+ `Unsupported resource type ${JSON.stringify(type)}. The fake serves ${Object.keys(RESOURCE_CAPABILITIES).map((t) => `"${t}"`).join(", ")}.`
263
+ );
264
+ return row;
265
+ }
266
+ function validateResource(resource) {
267
+ const row = capabilitiesOf(resource.type);
268
+ if (resource.type === "dataset" && resource.id.split(".").length !== 2)
269
+ throw fakeError(
270
+ `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 }\`.`
271
+ );
272
+ return row;
273
+ }
274
+ function matchResourcePath(path) {
275
+ for (const [type, row] of Object.entries(RESOURCE_CAPABILITIES)) {
276
+ const match = row.matchBase(path);
277
+ if (match !== void 0)
278
+ return { resource: { type, id: match.id }, rest: match.rest };
279
+ }
280
+ }
281
+ function capabilitiesForAccessType(type) {
282
+ for (const [resourceType, row] of Object.entries(RESOURCE_CAPABILITIES))
283
+ if (row.access.type === type) return { resourceType, row };
284
+ }
285
+ function dataUrl(args) {
286
+ const { resource, dataset, operation, path } = args;
287
+ if (resource !== void 0) {
288
+ const uri2 = path !== void 0 ? `${operation}/${path}` : operation;
289
+ return `${capabilitiesOf(resource.type).dataBase(resource.id)}/${uri2}`.replace(
290
+ /\/($|\?)/,
291
+ "$1"
292
+ );
293
+ }
294
+ const base = `/${operation}/${dataset}`;
295
+ return `/data${path !== void 0 ? `${base}/${path}` : base}`.replace(/\/($|\?)/, "$1");
296
+ }
297
+ const ACCESS_PATH = /^\/access\/([^/]+)\/([^/]+)(\/.*)?$/, DATA_HISTORY_PATH = /^\/data\/history\/([^/]+)(\/.*)$/;
298
+ function page(data) {
299
+ return { data, nextCursor: null };
300
+ }
301
+ async function servePlatformRequest(ctx, url) {
302
+ 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);
303
+ if (access !== null) {
304
+ const [, type, id, rest2] = access;
305
+ return serveAccess({ ctx, request, type, id: decodeURIComponent(id), rest: rest2 ?? "", query });
306
+ }
307
+ const dataHistory = path.match(DATA_HISTORY_PATH);
308
+ if (dataHistory !== null) {
309
+ const [, dataset, rest2] = dataHistory, resource2 = { type: "dataset", id: `${ctx.projectId}.${dataset}` };
310
+ return serveHistory({ ctx, request, resource: resource2, rest: rest2, query });
311
+ }
312
+ const matched = matchResourcePath(path);
313
+ if (matched === void 0) return;
314
+ const { resource, rest } = matched, row = capabilitiesOf(resource.type);
315
+ if (rest === "") return row.lookupAtBase ? serveLookup(ctx, resource) : void 0;
316
+ if (rest === "/acl")
317
+ return serveCapability(row.acl, request, () => {
318
+ const own = ctx.own(resource);
319
+ return own === void 0 ? void 0 : { value: own.grants };
320
+ });
321
+ if (rest.startsWith("/history/"))
322
+ return serveHistory({ ctx, request, resource, rest: rest.slice(8), query });
323
+ }
324
+ function serveLookup(ctx, resource) {
325
+ if (ctx.organizationId === void 0)
326
+ throw fakeError(
327
+ `request(): GET ${capabilitiesOf(resource.type).dataBase(resource.id)} answers the resource's organization, but no organization is configured. Pass createTestClient({ organizationId }).`
328
+ );
329
+ return { value: { organizationId: ctx.organizationId, id: resource.id, status: "active" } };
330
+ }
331
+ function serveHistory(args) {
332
+ const { ctx, request, resource, rest, query } = args;
333
+ return serveCapability(capabilitiesOf(resource.type).history, request, () => {
334
+ const own = ctx.own(resource);
335
+ return own === void 0 ? void 0 : readHistory({ own, request, rest, query });
336
+ });
337
+ }
338
+ async function readHistory(args) {
339
+ const { own, request, rest, query } = args, current = new Map(own.documents().map((doc) => [doc._id, doc])), hidden = /* @__PURE__ */ new Set();
340
+ for (const id of /* @__PURE__ */ new Set([...current.keys(), ...own.history.documentIds()])) {
341
+ const image = current.get(id) ?? own.history.lastImage(id);
342
+ image !== void 0 && !await own.canRead(image) && hidden.add(id);
343
+ }
344
+ const visible = (id) => !hidden.has(id), readable = (list) => historyIds(list).filter(visible), documents = rest.match(/^\/documents\/([^/]+)$/);
345
+ if (documents !== null) {
346
+ const revision = query.get("revision");
347
+ if (revision === null)
348
+ throw fakeError(
349
+ `request(): ${request.path} is served for \`?revision=<rev>\` reads only (time-based history reads are not simulated).`
350
+ );
351
+ const ids = readable(documents[1]);
352
+ return {
353
+ value: { documents: own.history.documentsAt({ ids, revision, current: own.documents() }) }
354
+ };
355
+ }
356
+ const transactions = rest.match(/^\/transactions\/([^/]+)$/);
357
+ if (transactions !== null)
358
+ return { value: own.history.transactions(readable(transactions[1]), visible).map((record) => JSON.stringify(record)).join(`
359
+ `) };
360
+ const events = rest.match(/^\/events\/documents\/([^/]+)$/);
361
+ if (events !== null)
362
+ return {
363
+ value: {
364
+ events: own.history.events(readable(events[1])),
365
+ nextCursor: ""
366
+ }
367
+ };
368
+ }
369
+ function serveAccess(args) {
370
+ const { ctx, request, type, id, rest, query } = args, found = capabilitiesForAccessType(type);
371
+ if (found === void 0) throw invalidAccessType(request, type);
372
+ const accessType = found.row.access.type, { unknownId } = found.row.access;
373
+ if (unknownId !== void 0 && rest !== "/user-permissions/me/check" && !ctx.exists({ type: found.resourceType, id }))
374
+ throw unknownId(request);
375
+ const permissions = ctx.actorAccess()?.[accessKey(accessType, id)]?.permissions ?? [];
376
+ switch (rest) {
377
+ case "/roles":
378
+ return serveCapability(found.row.access.roles, request, () => ({
379
+ value: page(rolesOn({ row: found.row, id, identities: ctx.identities() }))
380
+ }));
381
+ case "/users":
382
+ return serveCapability(found.row.access.users, request, () => ({
383
+ value: page(usersOn({ row: found.row, id, identities: ctx.identities() }))
384
+ }));
385
+ case "/user-permissions/me":
386
+ return {
387
+ value: page(
388
+ permissions.map((permission) => {
389
+ const dot = permission.lastIndexOf(".");
390
+ return {
391
+ type: permission.slice(0, dot),
392
+ resourceType: accessType,
393
+ resourceId: id,
394
+ action: permission.slice(dot + 1)
395
+ };
396
+ })
397
+ )
398
+ };
399
+ case "/user-permissions/me/check": {
400
+ const requested = query.getAll("permissions").flatMap((value) => value.split(","));
401
+ return {
402
+ value: {
403
+ data: Object.fromEntries(requested.map((p) => [p, permissions.includes(p)]))
404
+ }
405
+ };
406
+ }
407
+ default:
408
+ return;
409
+ }
410
+ }
411
+ function rolesOn(args) {
412
+ const byName = /* @__PURE__ */ new Map();
413
+ for (const identity of args.identities)
414
+ for (const role of args.row.access.memberRoles({ identity, id: args.id }) ?? [])
415
+ byName.set(role.name, {
416
+ name: role.name,
417
+ ...role.title !== void 0 ? { title: role.title } : {},
418
+ ...role.description !== void 0 ? { description: role.description } : {}
419
+ });
420
+ return [...byName.values()].toSorted((a, b) => a.name.localeCompare(b.name));
421
+ }
422
+ function usersOn(args) {
423
+ const { row, id, identities } = args, bySanityUserId = /* @__PURE__ */ new Map();
424
+ for (const identity of identities) {
425
+ const roles = row.access.memberRoles({ identity, id });
426
+ if (roles === void 0) continue;
427
+ const user = identity.globalUser ?? identity.user, record = {
428
+ sanityUserId: user.id,
429
+ profile: {
430
+ displayName: user.name ?? user.id,
431
+ ...user.email !== void 0 ? { email: user.email } : {}
432
+ },
433
+ memberships: [
434
+ {
435
+ resourceType: row.access.type,
436
+ resourceId: id,
437
+ roleNames: roles.map((role) => role.name).toSorted()
438
+ }
439
+ ]
440
+ }, existing = bySanityUserId.get(record.sanityUserId);
441
+ if (existing !== void 0 && JSON.stringify(existing) !== JSON.stringify(record))
442
+ throw fakeError(
443
+ `request(): two registered identities resolve to user "${record.sanityUserId}" on ${row.access.type} "${id}" with different records. Fix the identity registrations so duplicate ids carry identical records.`
444
+ );
445
+ bySanityUserId.set(record.sanityUserId, record);
446
+ }
447
+ return [...bySanityUserId.values()];
448
+ }
6
449
  class WriteAccessDeniedError extends Error {
7
450
  documentId;
8
451
  permission;
@@ -59,6 +502,12 @@ async function enforceWriteAccess(args) {
59
502
  function defaultAclPath(projectId, dataset) {
60
503
  return `/projects/${projectId}/datasets/${dataset}/acl`;
61
504
  }
505
+ function refusedAcl(path) {
506
+ const matched = matchResourcePath(path);
507
+ if (matched?.rest !== "/acl") return;
508
+ const { acl } = capabilitiesOf(matched.resource.type);
509
+ return acl === "served" ? void 0 : acl({ method: "GET", path });
510
+ }
62
511
  function projectUserOf(identity, projectId) {
63
512
  return identity.usersByProject?.[projectId] ?? identity.user;
64
513
  }
@@ -122,16 +571,19 @@ function serveProject(args) {
122
571
  ...projectUser.id.startsWith("p-") ? { isRobot: !0 } : {},
123
572
  ...projectUser.roles !== void 0 ? { roles: canonicalProjectRoles(projectUser.roles) } : {}
124
573
  };
125
- });
126
- return { members: [...consistentProjectRecords({ projectId: args.pathProjectId, records }).values()] };
574
+ }), members = consistentProjectRecords({ projectId: args.pathProjectId, records });
575
+ return {
576
+ ...args.organizationId !== void 0 ? { organizationId: args.organizationId } : {},
577
+ members: [...members.values()]
578
+ };
127
579
  }
128
580
  function makeRequestImpl(ctx) {
129
- const { getAccess, projectId, dataset, useProjectHostname, listIdentities } = ctx;
581
+ const { getAccess, useProjectHostname, listIdentities, organizationId, platform } = ctx;
130
582
  return async (opts) => {
131
- const accessControl = getAccess(), aclPath = accessControl.aclPath ?? defaultAclPath(projectId, dataset), path = opts.uri ?? opts.url;
583
+ const accessControl = getAccess(), path = opts.uri ?? opts.url;
132
584
  if (path === "/users/me")
133
585
  return useProjectHostname ? accessControl.currentUser : accessControl.globalUser ?? accessControl.currentUser;
134
- if (path === aclPath)
586
+ if (path !== void 0 && path === accessControl.aclPath && refusedAcl(path) === void 0)
135
587
  return accessControl.grants;
136
588
  const projectUsers = path?.match(PROJECT_USERS_PATH), [, pathProjectId, requestedIds] = projectUsers ?? [];
137
589
  if (pathProjectId !== void 0 && requestedIds !== void 0)
@@ -144,10 +596,13 @@ function makeRequestImpl(ctx) {
144
596
  if (membersProjectId !== void 0)
145
597
  return serveProject({
146
598
  pathProjectId: membersProjectId,
147
- identities: listIdentities()
599
+ identities: listIdentities(),
600
+ organizationId
148
601
  });
602
+ const served = path === void 0 ? void 0 : await servePlatformRequest(platform, path);
603
+ if (served !== void 0) return served.value;
149
604
  throw fakeError(
150
- `request(): no handler for path "${path}". With access control configured, the fake serves "/users/me", "${aclPath}", "/projects/<projectId>" (members), and "/projects/<projectId>/users/<ids>". For any other endpoint, register a stub (client.stub("request", fn)) or intercept the HTTP call with a tool like MSW.`
605
+ `request(): no handler for path "${path}". With access control configured, the fake serves "/users/me", the handle's ACL ("<data base>/acl"), "/projects/<projectId>" (members), "/projects/<projectId>/users/<ids>", the History API (client.getDataUrl("history", \u2026)), resource lookup ("/media-libraries/<id>", "/canvases/<id>", "/dashboards/<id>") and the Access API ("/access/<type>/<id>/roles | users | user-permissions/me[/check]"). For any other endpoint, register a stub (client.stub("request", fn)) or intercept the HTTP call with a tool like MSW.`
151
606
  );
152
607
  };
153
608
  }
@@ -411,7 +866,8 @@ function releaseNameOf(id) {
411
866
  return rest.slice(0, dot);
412
867
  }
413
868
  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, perspective) {
869
+ function applyPerspective(docs, requested) {
870
+ const perspective = normalizePerspective(requested);
415
871
  if (perspective === "raw") return [...docs];
416
872
  if (perspective === "published")
417
873
  return docs.filter((d) => !isDraftId(d._id) && !isVersionId(d._id) && !isSystemId(d._id));
@@ -423,6 +879,20 @@ function applyPerspective(docs, perspective) {
423
879
  entry === "drafts" ? includeDrafts = !0 : stack.push(entry);
424
880
  return mergeStack(docs, stack, { includeDrafts, includeReleaseDocs: !0 });
425
881
  }
882
+ function normalizePerspective(perspective) {
883
+ if (Array.isArray(perspective)) return perspective;
884
+ switch (perspective) {
885
+ case "raw":
886
+ return "raw";
887
+ case "published":
888
+ return "published";
889
+ case "drafts":
890
+ case "previewDrafts":
891
+ return "drafts";
892
+ default:
893
+ return perspective.split(",");
894
+ }
895
+ }
426
896
  function mergeStack(docs, releaseStack, options) {
427
897
  const groups = /* @__PURE__ */ new Map(), systemReleases = [];
428
898
  for (const doc of docs) {
@@ -856,6 +1326,7 @@ const ISSUES_URL = "https://github.com/sanity-labs/client-fake-for-test/issues",
856
1326
  "createVersion",
857
1327
  "unpublishVersion",
858
1328
  "request (only with accessControl or a bound token)",
1329
+ "getDataUrl",
859
1330
  "config",
860
1331
  "withConfig"
861
1332
  ], UNSUPPORTED_MEMBERS = {
@@ -875,10 +1346,6 @@ const ISSUES_URL = "https://github.com/sanity-labs/client-fake-for-test/issues",
875
1346
  what: "build an absolute API URL for a path",
876
1347
  workaround: "Hard-code the expected URL in your test, or stub it."
877
1348
  },
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
1349
  assets: {
883
1350
  what: "asset upload / metadata namespace",
884
1351
  workaround: "Seed asset documents into the store directly with `seed()` / `add()`."
@@ -905,7 +1372,7 @@ const ISSUES_URL = "https://github.com/sanity-labs/client-fake-for-test/issues",
905
1372
  },
906
1373
  request: {
907
1374
  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>, and /projects/<projectId>/users/<ids>). Or register a stub."
1375
+ 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
1376
  },
910
1377
  dryRun: {
911
1378
  what: "not a method \u2014 did you mean the `{ dryRun }` mutation option?",
@@ -1063,13 +1530,32 @@ function rejectAmbiguousIdentity(site, config) {
1063
1530
  `${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
1531
  );
1065
1532
  }
1066
- const registryKey = (projectId, dataset) => `${projectId}:${dataset}`, parseRegistryKey = (key) => {
1533
+ const registryKey = (resource) => `${resource.type}:${resource.id}`, datasetResource = (projectId, dataset) => ({
1534
+ type: "dataset",
1535
+ id: `${projectId}.${dataset}`
1536
+ }), parseDatasetKey = (key) => {
1067
1537
  const [projectId, dataset] = key.split(":");
1068
1538
  return { projectId, dataset };
1539
+ }, parseResourceKey = (key) => {
1540
+ const separator = key.indexOf(":"), resource = {
1541
+ type: key.slice(0, separator),
1542
+ id: key.slice(separator + 1)
1543
+ };
1544
+ if (separator <= 0 || resource.id.length === 0)
1545
+ throw fakeError(
1546
+ `createTestClient(): \`resources\` keys are "<type>:<id>" (e.g. "media-library:ml1"); got "${key}".`
1547
+ );
1548
+ return validateResource(resource), resource;
1069
1549
  };
1550
+ function ensureEntry(registry, resource) {
1551
+ const key = registryKey(resource);
1552
+ let entry = registry.get(key);
1553
+ return entry === void 0 && (entry = { resource, store: createTestStore(), history: createDocumentHistory() }, registry.set(key, entry)), entry;
1554
+ }
1070
1555
  function createTestClient(options = {}) {
1071
1556
  const defaultProjectId = options.projectId ?? DEFAULT_PROJECT_ID, defaultDataset = options.dataset ?? DEFAULT_DATASET, backend = {
1072
1557
  registry: /* @__PURE__ */ new Map(),
1558
+ organizationId: options.organizationId,
1073
1559
  log: [],
1074
1560
  stubs: /* @__PURE__ */ new Map(),
1075
1561
  calls: [],
@@ -1077,21 +1563,23 @@ function createTestClient(options = {}) {
1077
1563
  identities: new Map(Object.entries(options.identities ?? {})),
1078
1564
  writeChain: Promise.resolve()
1079
1565
  }, registry = backend.registry;
1080
- rejectAmbiguousIdentity("createTestClient()", options), options.token !== void 0 && requireIdentity(backend, options.token, "createTestClient()");
1081
- const ensureStore = (projectId, dataset) => {
1082
- const key = registryKey(projectId, dataset);
1083
- let store = registry.get(key);
1084
- return store === void 0 && (store = createTestStore(), registry.set(key, store)), store;
1085
- };
1086
- if (options.documents && ensureStore(defaultProjectId, defaultDataset).seed(options.documents), options.datasets)
1566
+ if (rejectAmbiguousIdentity("createTestClient()", options), options.token !== void 0 && requireIdentity(backend, options.token, "createTestClient()"), options.resource !== void 0 && validateResource(options.resource), options.documents) {
1567
+ const root = options.resource ?? datasetResource(defaultProjectId, defaultDataset);
1568
+ ensureEntry(registry, root).store.seed(options.documents);
1569
+ }
1570
+ if (options.datasets)
1087
1571
  for (const [key, docs] of Object.entries(options.datasets)) {
1088
- const { projectId, dataset } = parseRegistryKey(key);
1089
- ensureStore(projectId, dataset).seed(docs);
1572
+ const { projectId, dataset } = parseDatasetKey(key);
1573
+ ensureEntry(registry, datasetResource(projectId, dataset)).store.seed(docs);
1090
1574
  }
1575
+ if (options.resources)
1576
+ for (const [key, docs] of Object.entries(options.resources))
1577
+ ensureEntry(registry, parseResourceKey(key)).store.seed(docs);
1091
1578
  return makeHandle({
1092
1579
  backend,
1093
1580
  projectId: defaultProjectId,
1094
1581
  dataset: defaultDataset,
1582
+ resource: options.resource,
1095
1583
  accessControl: options.accessControl,
1096
1584
  perspective: options.perspective ?? "raw",
1097
1585
  requestTagPrefix: options.requestTagPrefix,
@@ -1153,39 +1641,52 @@ function toTxnMutations(operations) {
1153
1641
  function makeHandle(handleArgs) {
1154
1642
  const {
1155
1643
  backend,
1156
- projectId,
1157
- dataset,
1644
+ resource,
1158
1645
  accessControl,
1159
1646
  perspective,
1160
1647
  requestTagPrefix,
1161
1648
  mutationGuard,
1162
1649
  token,
1163
1650
  useProjectHostname
1164
- } = handleArgs, { registry } = backend, activeAccess = () => {
1651
+ } = handleArgs, { registry } = backend, { projectId, dataset } = resource?.type === "dataset" ? datasetParts(resource.id) : handleArgs, target = resource ?? datasetResource(projectId, dataset), activeAccess = () => {
1165
1652
  if (token === void 0) return accessControl;
1166
1653
  const identity = backend.identities.get(token);
1167
1654
  if (identity === void 0)
1168
1655
  throw fakeError(`no identity is registered for token "${token}" (was it removed?).`);
1169
1656
  return {
1170
- currentUser: projectUserOf(identity, projectId),
1657
+ // Per-project records apply only where the resource lives in a project.
1658
+ currentUser: capabilitiesOf(target.type).identity === "project" ? projectUserOf(identity, projectId) : identity.user,
1171
1659
  ...identity.globalUser !== void 0 ? { globalUser: identity.globalUser } : {},
1172
- grants: identity.grants
1660
+ grants: identity.grants,
1661
+ ...identity.access !== void 0 ? { access: identity.access } : {}
1173
1662
  };
1174
1663
  }, requireActiveAccess = () => {
1175
1664
  const access = activeAccess();
1176
1665
  if (access === void 0)
1177
1666
  throw fakeError("this handle has no acting identity (no token or accessControl bound).");
1178
1667
  return access;
1179
- }, store = (() => {
1180
- const key = registryKey(projectId, dataset);
1181
- let s = registry.get(key);
1182
- return s === void 0 && (s = createTestStore(), registry.set(key, s)), s;
1183
- })(), concurrentEditKey = (id) => registryKey(projectId, dataset) + " " + id, record = (transactionId, mutations, tag) => {
1668
+ }, capabilities = capabilitiesOf(target.type), targetLabel = capabilities.label(target.id), { store, history } = ensureEntry(registry, target), concurrentEditKey = (id) => registryKey(target) + " " + id, actorId = () => {
1669
+ const access = activeAccess();
1670
+ if (access !== void 0)
1671
+ return capabilities.identity === "global" ? (access.globalUser ?? access.currentUser).id : access.currentUser.id;
1672
+ };
1673
+ let beforeWrite;
1674
+ const write = (run) => serializeWrite(backend, async () => {
1675
+ beforeWrite = new Map(store.all().map((doc) => [doc._id, doc]));
1676
+ try {
1677
+ return await run();
1678
+ } finally {
1679
+ beforeWrite = void 0;
1680
+ }
1681
+ }), commitHistory = (transactionId) => {
1682
+ beforeWrite !== void 0 && history.commit({ transactionId, author: actorId(), before: beforeWrite, after: store.all() });
1683
+ }, record = (transactionId, mutations, tag) => {
1184
1684
  const effectiveTag = effectiveRequestTag(tag);
1185
- backend.log.push({
1685
+ commitHistory(transactionId), backend.log.push({
1186
1686
  transactionId,
1187
1687
  projectId,
1188
1688
  dataset,
1689
+ ...resource !== void 0 ? { resource } : {},
1189
1690
  mutations,
1190
1691
  ...effectiveTag !== void 0 ? { tag: effectiveTag } : {}
1191
1692
  });
@@ -1235,10 +1736,10 @@ function makeHandle(handleArgs) {
1235
1736
  document,
1236
1737
  grants: access.grants,
1237
1738
  permission: document._id !== void 0 && isSystemId(document._id) ? "manage" : permission,
1238
- userId: access.currentUser.id
1739
+ userId: actorId()
1239
1740
  });
1240
1741
  }, guardGate = async (contexts) => {
1241
- const identity = activeAccess()?.currentUser.id;
1742
+ const identity = actorId();
1242
1743
  await enforceMutationGuards(
1243
1744
  mutationGuard,
1244
1745
  contexts.map((context) => ({ ...context, identity }))
@@ -1251,13 +1752,13 @@ function makeHandle(handleArgs) {
1251
1752
  document,
1252
1753
  grants: access.grants,
1253
1754
  permission: "read",
1254
- userId: access.currentUser.id
1755
+ userId: actorId()
1255
1756
  });
1256
1757
  }, applyPatch = async (documentId, ops, ifRev, options = {}) => {
1257
1758
  const existing = store.get(documentId);
1258
1759
  if (existing === void 0)
1259
1760
  throw fakeError(
1260
- `Cannot patch document "${documentId}": not found \u2014 no document with that _id exists in dataset "${projectId}:${dataset}". patch() requires the target to already exist; use createOrReplace() / createIfNotExists() to upsert, or seed the document first.`
1761
+ `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
1762
  );
1262
1763
  checkRevision(documentId, ifRev), await gate(existing, "update");
1263
1764
  const patched = applyPatchOps(existing, ops), now = (/* @__PURE__ */ new Date()).toISOString(), next = {
@@ -1278,7 +1779,7 @@ function makeHandle(handleArgs) {
1278
1779
  const promoted = promote2(mut.doc);
1279
1780
  if (store.get(promoted._id) !== void 0)
1280
1781
  throw fakeError(
1281
- `create() failed: a document with _id "${promoted._id}" already exists in dataset "${projectId}:${dataset}". Use createOrReplace() to overwrite it, or createIfNotExists() to leave the existing one untouched.`
1782
+ `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
1783
  );
1283
1784
  await gate(promoted, "create"), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
1284
1785
  break;
@@ -1327,7 +1828,21 @@ function makeHandle(handleArgs) {
1327
1828
  txnOptions?.returnDocuments ?? !1,
1328
1829
  txnOptions?.returnFirst ?? !1
1329
1830
  );
1330
- }, runAction = (actions) => serializeWrite(backend, async () => applyActions(store, actions, { gate, guard: guardGate })), accessVisibleDocs = async () => {
1831
+ }, runAction = async (actions) => {
1832
+ const request = {
1833
+ method: "POST",
1834
+ path: `${capabilities.dataBase(target.id)}/actions`
1835
+ };
1836
+ if (capabilities.actions !== "served") throw capabilities.actions(request);
1837
+ for (const action of Array.isArray(actions) ? actions : [actions]) {
1838
+ const refused = capabilities.refusedActions[action.actionType];
1839
+ if (refused !== void 0) throw refused(request);
1840
+ }
1841
+ return write(async () => {
1842
+ const result = await applyActions(store, actions, { gate, guard: guardGate });
1843
+ return commitHistory(result.transactionId), result;
1844
+ });
1845
+ }, accessVisibleDocs = async () => {
1331
1846
  const all = store.all();
1332
1847
  if (activeAccess() === void 0) return all;
1333
1848
  const out = [];
@@ -1371,10 +1886,11 @@ function makeHandle(handleArgs) {
1371
1886
  const view = await visibleDocs(effective);
1372
1887
  return ids.map((id) => resolveDocument(view, id, effective) ?? null);
1373
1888
  },
1374
- create: (doc, opts) => serializeWrite(backend, async () => {
1889
+ getDataUrl: (operation, path) => dataUrl({ resource, dataset, operation, path }),
1890
+ create: (doc, opts) => write(async () => {
1375
1891
  if (checkRevision(doc._id, opts?.ifRevisionId), store.get(doc._id) !== void 0)
1376
1892
  throw fakeError(
1377
- `create() failed: a document with _id "${doc._id}" already exists in dataset "${projectId}:${dataset}". Use createOrReplace() to overwrite it, or createIfNotExists() to leave the existing one untouched.`
1893
+ `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
1894
  );
1379
1895
  const promoted = promote2(doc);
1380
1896
  await gate(promoted, "create"), await guardOne("create", null, promoted);
@@ -1386,7 +1902,7 @@ function makeHandle(handleArgs) {
1386
1902
  opts
1387
1903
  );
1388
1904
  }),
1389
- createOrReplace: (doc, opts) => serializeWrite(backend, async () => {
1905
+ createOrReplace: (doc, opts) => write(async () => {
1390
1906
  checkRevision(doc._id, opts?.ifRevisionId);
1391
1907
  const promoted = promote2(doc), existing = store.get(doc._id);
1392
1908
  await gate(existing ?? promoted, existing !== void 0 ? "update" : "create"), await guardOne(existing !== void 0 ? "update" : "create", existing ?? null, promoted);
@@ -1400,7 +1916,7 @@ function makeHandle(handleArgs) {
1400
1916
  opts
1401
1917
  );
1402
1918
  }),
1403
- createIfNotExists: (doc, opts) => serializeWrite(backend, async () => {
1919
+ createIfNotExists: (doc, opts) => write(async () => {
1404
1920
  checkRevision(doc._id, opts?.ifRevisionId);
1405
1921
  const existing = store.get(doc._id), transactionId = opts?.transactionId ?? cryptoRandomKey();
1406
1922
  if (existing !== void 0)
@@ -1418,7 +1934,7 @@ function makeHandle(handleArgs) {
1418
1934
  opts
1419
1935
  );
1420
1936
  }),
1421
- delete: (id, opts) => serializeWrite(backend, async () => {
1937
+ delete: (id, opts) => write(async () => {
1422
1938
  checkRevision(id, opts?.ifRevisionId);
1423
1939
  const existing = store.get(id);
1424
1940
  existing !== void 0 && await gate(existing, "update"), existing !== void 0 && await guardOne("delete", existing, null);
@@ -1433,7 +1949,7 @@ function makeHandle(handleArgs) {
1433
1949
  );
1434
1950
  }),
1435
1951
  patch: (documentId) => createPatch(documentId, {
1436
- apply: (docId, ops, ifRev, options) => serializeWrite(backend, async () => {
1952
+ apply: (docId, ops, ifRev, options) => write(async () => {
1437
1953
  const doc = await applyPatch(docId, ops, ifRev);
1438
1954
  return record(
1439
1955
  cryptoRandomKey(),
@@ -1450,9 +1966,9 @@ function makeHandle(handleArgs) {
1450
1966
  })
1451
1967
  }),
1452
1968
  transaction: () => createTransaction({
1453
- apply: (mutations, txnOptions) => serializeWrite(backend, () => applyTransaction(mutations, txnOptions))
1969
+ apply: (mutations, txnOptions) => write(() => applyTransaction(mutations, txnOptions))
1454
1970
  }),
1455
- mutate: (operations, options) => serializeWrite(backend, () => applyTransaction(toTxnMutations(operations), options)),
1971
+ mutate: (operations, options) => write(() => applyTransaction(toTxnMutations(operations), options)),
1456
1972
  // Synchronous writes still take the lock: an action landing while a
1457
1973
  // guarded patch is parked in its gate would otherwise be clobbered
1458
1974
  // when that patch resumes over its pre-action snapshot.
@@ -1501,15 +2017,30 @@ function makeHandle(handleArgs) {
1501
2017
  ...token !== void 0 || accessControl !== void 0 ? {
1502
2018
  request: makeRequestImpl({
1503
2019
  getAccess: requireActiveAccess,
1504
- projectId,
1505
- dataset,
1506
- useProjectHostname,
1507
- listIdentities: () => [...backend.identities.values()]
2020
+ // `@sanity/client` sends every request of a `resource`-configured
2021
+ // client to the global API host.
2022
+ useProjectHostname: useProjectHostname && resource === void 0,
2023
+ organizationId: backend.organizationId,
2024
+ listIdentities: () => [...backend.identities.values()],
2025
+ platform: {
2026
+ projectId,
2027
+ organizationId: backend.organizationId,
2028
+ own: (asked) => registryKey(asked) === registryKey(target) ? {
2029
+ grants: requireActiveAccess().grants,
2030
+ documents: () => store.all(),
2031
+ history,
2032
+ canRead
2033
+ } : void 0,
2034
+ actorAccess: () => requireActiveAccess().access,
2035
+ identities: () => [...backend.identities.values()],
2036
+ exists: (asked) => registry.has(registryKey(asked))
2037
+ }
1508
2038
  })
1509
2039
  } : {},
1510
2040
  config: () => ({
1511
2041
  projectId,
1512
2042
  dataset,
2043
+ ...resource !== void 0 ? { resource } : {},
1513
2044
  perspective,
1514
2045
  ...requestTagPrefix !== void 0 ? { requestTagPrefix } : {},
1515
2046
  ...token !== void 0 ? { token } : {},
@@ -1517,11 +2048,12 @@ function makeHandle(handleArgs) {
1517
2048
  }),
1518
2049
  withConfig: (config) => {
1519
2050
  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({
2051
+ const rebinds = config.token !== void 0 || config.accessControl !== void 0, nextResource = "resource" in config ? config.resource : resource;
2052
+ return nextResource !== void 0 && validateResource(nextResource), makeHandle({
1522
2053
  backend,
1523
2054
  projectId: config.projectId ?? projectId,
1524
2055
  dataset: config.dataset ?? dataset,
2056
+ resource: nextResource,
1525
2057
  accessControl: rebinds ? config.accessControl : accessControl,
1526
2058
  perspective: config.perspective ?? perspective,
1527
2059
  requestTagPrefix: config.requestTagPrefix ?? requestTagPrefix,
@@ -1539,7 +2071,8 @@ function makeHandle(handleArgs) {
1539
2071
  const editKey = concurrentEditKey(id);
1540
2072
  backend.concurrentEdits.set(editKey, (backend.concurrentEdits.get(editKey) ?? 0) + count);
1541
2073
  },
1542
- datasets: () => [...registry.keys()].map(parseRegistryKey),
2074
+ datasets: () => [...registry.values()].filter((entry) => entry.resource.type === "dataset").map((entry) => datasetParts(entry.resource.id)),
2075
+ resources: () => [...registry.values()].map((entry) => ({ ...entry.resource })),
1543
2076
  stub: (name, handler) => {
1544
2077
  backend.stubs.set(name, handler);
1545
2078
  },
@@ -1562,7 +2095,13 @@ function makeHandle(handleArgs) {
1562
2095
  return wrapWithGuards(client, {
1563
2096
  getStub: (name) => backend.stubs.get(name),
1564
2097
  onCall: (method, args) => {
1565
- const rec = { method, args, projectId, dataset }, tag = OPTIONS_ARG_POSITION[method] === void 0 ? void 0 : effectiveRequestTag(tagFromArgs(method, args));
2098
+ const rec = {
2099
+ method,
2100
+ args,
2101
+ projectId,
2102
+ dataset,
2103
+ ...resource !== void 0 ? { resource } : {}
2104
+ }, tag = OPTIONS_ARG_POSITION[method] === void 0 ? void 0 : effectiveRequestTag(tagFromArgs(method, args));
1566
2105
  return tag !== void 0 && (rec.tag = tag), backend.calls.push(rec), (outcome) => {
1567
2106
  "error" in outcome ? rec.error = outcome.error : rec.result = outcome.result;
1568
2107
  };
@@ -1573,6 +2112,7 @@ function makeHandle(handleArgs) {
1573
2112
  export {
1574
2113
  ERR_PREFIX,
1575
2114
  GuardDeniedError,
2115
+ PlatformHttpError,
1576
2116
  SUPPORTED_SURFACE,
1577
2117
  UNSUPPORTED_MEMBERS,
1578
2118
  WriteAccessDeniedError,