@sanity-labs/client-fake-for-test 0.13.0 → 0.15.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.d.cts CHANGED
@@ -841,6 +841,12 @@ export declare type ReleaseType = "asap" | "scheduled" | "undecided";
841
841
  export declare interface ResourceAccess {
842
842
  roles?: NonNullable<CurrentUser["roles"]>;
843
843
  permissions?: string[];
844
+ /**
845
+ * The user's attributes in the organization, as an administrator or SAML
846
+ * sync set them; read on `"organization:<id>"` entries only. Served by
847
+ * `GET /organizations/<id>/users/me/attributes`.
848
+ */
849
+ attributes?: UserAttribute[];
844
850
  }
845
851
 
846
852
  /** Resource types `@sanity/client`'s `resource` config can name (the ones staging probed). */
@@ -1183,8 +1189,8 @@ export declare interface TestClientAccessControl {
1183
1189
  */
1184
1190
  aclPath?: string;
1185
1191
  /**
1186
- * Access API entries, keyed `"<access type>:<id>"` — `"project:<projectId>"`,
1187
- * `"media-library:<id>"`, `"canvas:<id>"`, `"dashboard:<id>"`. See {@link ResourceAccess}.
1192
+ * Access API entries, keyed `"<access type>:<id>"` — `"organization:<id>"`,
1193
+ * `"project:<projectId>"`, `"media-library:<id>"`, `"canvas:<id>"`, `"dashboard:<id>"`. See {@link ResourceAccess}.
1188
1194
  */
1189
1195
  access?: Record<string, ResourceAccess>;
1190
1196
  }
@@ -1225,10 +1231,10 @@ export declare interface TestClientIdentity {
1225
1231
  /** Grants served at the ACL path and enforced on reads/writes. */
1226
1232
  grants: Grant[];
1227
1233
  /**
1228
- * Access API entries, keyed `"<access type>:<id>"` (`"media-library:<id>"`,
1229
- * `"canvas:<id>"`, `"dashboard:<id>"`, `"project:<projectId>"`). On a
1230
- * non-dataset resource, an entry with `roles` makes the identity a member
1231
- * there. Project membership needs no entry: every identity is a member of
1234
+ * Access API entries, keyed `"<access type>:<id>"` (`"organization:<id>"`,
1235
+ * `"media-library:<id>"`, `"canvas:<id>"`, `"dashboard:<id>"`,
1236
+ * `"project:<projectId>"`). On a non-project resource, an entry with
1237
+ * `roles` makes the identity a member there. Project membership needs no entry: every identity is a member of
1232
1238
  * every project with its project-scoped roles.
1233
1239
  */
1234
1240
  access?: Record<string, ResourceAccess>;
@@ -1365,6 +1371,29 @@ declare interface UnsupportedEntry {
1365
1371
  workaround: string;
1366
1372
  }
1367
1373
 
1374
+ /**
1375
+ * One user attribute: its key, its type, and its value from each source.
1376
+ * The served row adds `activeSource` and `activeValue`: the Sanity value
1377
+ * when there is one, else the SAML value.
1378
+ */
1379
+ export declare interface UserAttribute {
1380
+ key: string;
1381
+ type: string;
1382
+ values: {
1383
+ saml?: UserAttributeValue;
1384
+ sanity?: UserAttributeValue;
1385
+ };
1386
+ }
1387
+
1388
+ /** A value a user attribute can hold. */
1389
+ export declare type UserAttributeValue =
1390
+ | string
1391
+ | number
1392
+ | boolean
1393
+ | string[]
1394
+ | number[]
1395
+ | boolean[];
1396
+
1368
1397
  export declare type VersionAction =
1369
1398
  | {
1370
1399
  actionType: "sanity.action.document.version.create";
package/dist/index.d.ts CHANGED
@@ -841,6 +841,12 @@ export declare type ReleaseType = "asap" | "scheduled" | "undecided";
841
841
  export declare interface ResourceAccess {
842
842
  roles?: NonNullable<CurrentUser["roles"]>;
843
843
  permissions?: string[];
844
+ /**
845
+ * The user's attributes in the organization, as an administrator or SAML
846
+ * sync set them; read on `"organization:<id>"` entries only. Served by
847
+ * `GET /organizations/<id>/users/me/attributes`.
848
+ */
849
+ attributes?: UserAttribute[];
844
850
  }
845
851
 
846
852
  /** Resource types `@sanity/client`'s `resource` config can name (the ones staging probed). */
@@ -1183,8 +1189,8 @@ export declare interface TestClientAccessControl {
1183
1189
  */
1184
1190
  aclPath?: string;
1185
1191
  /**
1186
- * Access API entries, keyed `"<access type>:<id>"` — `"project:<projectId>"`,
1187
- * `"media-library:<id>"`, `"canvas:<id>"`, `"dashboard:<id>"`. See {@link ResourceAccess}.
1192
+ * Access API entries, keyed `"<access type>:<id>"` — `"organization:<id>"`,
1193
+ * `"project:<projectId>"`, `"media-library:<id>"`, `"canvas:<id>"`, `"dashboard:<id>"`. See {@link ResourceAccess}.
1188
1194
  */
1189
1195
  access?: Record<string, ResourceAccess>;
1190
1196
  }
@@ -1225,10 +1231,10 @@ export declare interface TestClientIdentity {
1225
1231
  /** Grants served at the ACL path and enforced on reads/writes. */
1226
1232
  grants: Grant[];
1227
1233
  /**
1228
- * Access API entries, keyed `"<access type>:<id>"` (`"media-library:<id>"`,
1229
- * `"canvas:<id>"`, `"dashboard:<id>"`, `"project:<projectId>"`). On a
1230
- * non-dataset resource, an entry with `roles` makes the identity a member
1231
- * there. Project membership needs no entry: every identity is a member of
1234
+ * Access API entries, keyed `"<access type>:<id>"` (`"organization:<id>"`,
1235
+ * `"media-library:<id>"`, `"canvas:<id>"`, `"dashboard:<id>"`,
1236
+ * `"project:<projectId>"`). On a non-project resource, an entry with
1237
+ * `roles` makes the identity a member there. Project membership needs no entry: every identity is a member of
1232
1238
  * every project with its project-scoped roles.
1233
1239
  */
1234
1240
  access?: Record<string, ResourceAccess>;
@@ -1365,6 +1371,29 @@ declare interface UnsupportedEntry {
1365
1371
  workaround: string;
1366
1372
  }
1367
1373
 
1374
+ /**
1375
+ * One user attribute: its key, its type, and its value from each source.
1376
+ * The served row adds `activeSource` and `activeValue`: the Sanity value
1377
+ * when there is one, else the SAML value.
1378
+ */
1379
+ export declare interface UserAttribute {
1380
+ key: string;
1381
+ type: string;
1382
+ values: {
1383
+ saml?: UserAttributeValue;
1384
+ sanity?: UserAttributeValue;
1385
+ };
1386
+ }
1387
+
1388
+ /** A value a user attribute can hold. */
1389
+ export declare type UserAttributeValue =
1390
+ | string
1391
+ | number
1392
+ | boolean
1393
+ | string[]
1394
+ | number[]
1395
+ | boolean[];
1396
+
1368
1397
  export declare type VersionAction =
1369
1398
  | {
1370
1399
  actionType: "sanity.action.document.version.create";
package/dist/index.js CHANGED
@@ -102,7 +102,7 @@ function accessApiError(args) {
102
102
  ...request,
103
103
  statusCode,
104
104
  statusMessage,
105
- body: { statusCode, message }
105
+ body: { statusCode, error: statusMessage, message }
106
106
  });
107
107
  }
108
108
  const noPermission = (request) => accessApiError({
@@ -185,14 +185,7 @@ const RESOURCE_CAPABILITIES = {
185
185
  history: "served",
186
186
  actions: "served",
187
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
- }
188
+ access: { type: "project", id: (id) => datasetParts(id).projectId }
196
189
  },
197
190
  "media-library": {
198
191
  label: (id) => `media library "${id}"`,
@@ -204,15 +197,7 @@ const RESOURCE_CAPABILITIES = {
204
197
  history: "served",
205
198
  actions: "served",
206
199
  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
- }
200
+ access: { type: "media-library", id: (id) => id }
216
201
  },
217
202
  canvas: {
218
203
  label: (id) => `canvas "${id}"`,
@@ -224,15 +209,7 @@ const RESOURCE_CAPABILITIES = {
224
209
  history: "served",
225
210
  actions: notMounted,
226
211
  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
- }
212
+ access: { type: "canvas", id: (id) => id }
236
213
  },
237
214
  dashboard: {
238
215
  label: (id) => `dashboard "${id}"`,
@@ -244,19 +221,11 @@ const RESOURCE_CAPABILITIES = {
244
221
  history: notMounted,
245
222
  actions: notMounted,
246
223
  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
- }
224
+ access: { type: "dashboard", id: (id) => id }
256
225
  }
257
226
  };
258
227
  function capabilitiesOf(type) {
259
- const row = RESOURCE_CAPABILITIES[type];
228
+ const row = Object.hasOwn(RESOURCE_CAPABILITIES, type) ? RESOURCE_CAPABILITIES[type] : void 0;
260
229
  if (row === void 0)
261
230
  throw fakeError(
262
231
  `Unsupported resource type ${JSON.stringify(type)}. The fake serves ${Object.keys(RESOURCE_CAPABILITIES).map((t) => `"${t}"`).join(", ")}.`
@@ -278,9 +247,77 @@ function matchResourcePath(path) {
278
247
  return { resource: { type, id: match.id }, rest: match.rest };
279
248
  }
280
249
  }
281
- function capabilitiesForAccessType(type) {
282
- for (const [resourceType, row] of Object.entries(RESOURCE_CAPABILITIES))
283
- if (row.access.type === type) return { resourceType, row };
250
+ function accessResourceOf(resource) {
251
+ const { access } = capabilitiesOf(resource.type);
252
+ return { type: access.type, id: access.id(resource.id) };
253
+ }
254
+ function holdsResource(type) {
255
+ return (registry, id) => registry.resources().some((resource) => {
256
+ const access = accessResourceOf(resource);
257
+ return access.type === type && access.id === id;
258
+ });
259
+ }
260
+ const ACCESS_CAPABILITIES = {
261
+ organization: {
262
+ // Proven on staging, but outside what the fake serves: refused like a missing permission.
263
+ roles: noPermission,
264
+ users: "served",
265
+ user: "served",
266
+ // Granted by the built-in organization `member` role.
267
+ usersPermission: "sanity.organization.members.read",
268
+ unknown: {
269
+ holds: (registry, id) => id === registry.organizationId,
270
+ refusal: notFound("Organization")
271
+ },
272
+ memberRoles: accessEntryRoles("organization"),
273
+ // Every resource of the family belongs to its organization (see `organizationId`).
274
+ owns: (registry) => {
275
+ const byKey = /* @__PURE__ */ new Map();
276
+ for (const resource of registry.resources()) {
277
+ const access = accessResourceOf(resource);
278
+ byKey.set(accessKey(access.type, access.id), access);
279
+ }
280
+ return [...byKey.values()];
281
+ }
282
+ },
283
+ project: {
284
+ roles: "served",
285
+ users: "served",
286
+ // Single-user reads are served only where the fake checks members read (the
287
+ // organization); elsewhere they refuse rather than answer unchecked.
288
+ user: noPermission,
289
+ // Every registered identity is a member of every project, with its project roles.
290
+ memberRoles: ({ identity, id }) => (identity.usersByProject?.[id] ?? identity.user).roles ?? []
291
+ },
292
+ "media-library": {
293
+ // Staging refuses it only because no probe token held roles read.
294
+ roles: "served",
295
+ rolesPermission: "sanity.media-library.roles.read",
296
+ users: "served",
297
+ // Unproven on staging (only the listing was probed); refused as on `project`.
298
+ user: noPermission,
299
+ unknown: { holds: holdsResource("media-library"), refusal: notFound("Media library") },
300
+ memberRoles: accessEntryRoles("media-library")
301
+ },
302
+ canvas: {
303
+ // Unproven on staging (no canvas roles/members permission exists to grant).
304
+ roles: noPermission,
305
+ users: noPermission,
306
+ user: noPermission,
307
+ unknown: { holds: holdsResource("canvas"), refusal: notFound("Canvas") },
308
+ memberRoles: accessEntryRoles("canvas")
309
+ },
310
+ dashboard: {
311
+ // Unproven on staging, as for canvas.
312
+ roles: noPermission,
313
+ users: noPermission,
314
+ user: noPermission,
315
+ unknown: { holds: holdsResource("dashboard"), refusal: notFound("Dashboard") },
316
+ memberRoles: accessEntryRoles("dashboard")
317
+ }
318
+ };
319
+ function accessCapabilitiesOf(type) {
320
+ return Object.hasOwn(ACCESS_CAPABILITIES, type) ? ACCESS_CAPABILITIES[type] : void 0;
284
321
  }
285
322
  function dataUrl(args) {
286
323
  const { resource, dataset, operation, path } = args;
@@ -294,12 +331,21 @@ function dataUrl(args) {
294
331
  const base = `/${operation}/${dataset}`;
295
332
  return `/data${path !== void 0 ? `${base}/${path}` : base}`.replace(/\/($|\?)/, "$1");
296
333
  }
297
- const ACCESS_PATH = /^\/access\/([^/]+)\/([^/]+)(\/.*)?$/, DATA_HISTORY_PATH = /^\/data\/history\/([^/]+)(\/.*)$/;
334
+ const ACCESS_PATH = /^\/access\/([^/]+)\/([^/]+)(\/.*)?$/, ACCESS_USER_PATH = /^\/users\/([^/]+)$/, ACCESS_ROLE_PATH = /^\/roles\/([^/]+)$/, MY_ATTRIBUTES_PATH = /^\/organizations\/([^/]+)\/users\/me\/attributes$/, DATA_HISTORY_PATH = /^\/data\/history\/([^/]+)(\/.*)$/;
298
335
  function page(data) {
299
336
  return { data, nextCursor: null };
300
337
  }
338
+ function usersPage(data) {
339
+ return { ...page(data), totalCount: data.length };
340
+ }
341
+ const UNMODELLED_PROFILE = { provider: "sanity", createdAt: (/* @__PURE__ */ new Date(0)).toISOString() };
301
342
  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);
343
+ 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 }, myAttributes = path.match(MY_ATTRIBUTES_PATH);
344
+ if (myAttributes !== null) {
345
+ const organizationId = decodeURIComponent(myAttributes[1]);
346
+ return serveMyAttributes({ ctx, request, organizationId, query });
347
+ }
348
+ const access = path.match(ACCESS_PATH);
303
349
  if (access !== null) {
304
350
  const [, type, id, rest2] = access;
305
351
  return serveAccess({ ctx, request, type, id: decodeURIComponent(id), rest: rest2 ?? "", query });
@@ -367,20 +413,46 @@ async function readHistory(args) {
367
413
  };
368
414
  }
369
415
  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 ?? [];
416
+ const { ctx, request, type, id, rest, query } = args, row = accessCapabilitiesOf(type);
417
+ if (row === void 0) throw invalidAccessType(request, type);
418
+ const resource = { type, id }, registry = {
419
+ organizationId: ctx.organizationId,
420
+ resources: ctx.resources
421
+ }, permissions = ctx.actorAccess()?.[accessKey(resource.type, id)]?.permissions ?? [];
422
+ if (rest === "/user-permissions/me/check") {
423
+ const requested = query.getAll("permissions").flatMap((value) => value.split(","));
424
+ return {
425
+ value: { data: Object.fromEntries(requested.map((p) => [p, permissions.includes(p)])) }
426
+ };
427
+ }
428
+ if (row.unknown !== void 0 && !row.unknown.holds(registry, id))
429
+ throw row.unknown.refusal(request);
430
+ const gated = (capability, permission, serve) => serveCapability(capability, request, () => {
431
+ if (permission !== void 0 && !permissions.includes(permission))
432
+ throw noPermission(request);
433
+ return serve();
434
+ }), users = (capability, serve) => gated(capability, row.usersPermission, serve), roles = () => rolesOn({ resource, identities: ctx.identities() }), user = rest.match(ACCESS_USER_PATH);
435
+ if (user !== null)
436
+ return users(row.user, () => {
437
+ const sanityUserId = decodeURIComponent(user[1]), record = usersOn({ resource, registry, identities: ctx.identities() }).find(
438
+ (candidate) => candidate.sanityUserId === sanityUserId
439
+ );
440
+ if (record === void 0) throw notFound("User")(request);
441
+ return { value: record };
442
+ });
443
+ const role = rest.match(ACCESS_ROLE_PATH);
444
+ if (role !== null)
445
+ return gated(row.roles, row.rolesPermission, () => {
446
+ const name = decodeURIComponent(role[1]), record = roles().find((candidate) => candidate.name === name);
447
+ if (record === void 0) throw notFound("Role")(request);
448
+ return { value: record };
449
+ });
376
450
  switch (rest) {
377
451
  case "/roles":
378
- return serveCapability(found.row.access.roles, request, () => ({
379
- value: page(rolesOn({ row: found.row, id, identities: ctx.identities() }))
380
- }));
452
+ return gated(row.roles, row.rolesPermission, () => ({ value: page(roles()) }));
381
453
  case "/users":
382
- return serveCapability(found.row.access.users, request, () => ({
383
- value: page(usersOn({ row: found.row, id, identities: ctx.identities() }))
454
+ return users(row.users, () => ({
455
+ value: usersPage(usersOn({ resource, registry, identities: ctx.identities() }))
384
456
  }));
385
457
  case "/user-permissions/me":
386
458
  return {
@@ -389,63 +461,102 @@ function serveAccess(args) {
389
461
  const dot = permission.lastIndexOf(".");
390
462
  return {
391
463
  type: permission.slice(0, dot),
392
- resourceType: accessType,
464
+ resourceType: resource.type,
393
465
  resourceId: id,
394
466
  action: permission.slice(dot + 1)
395
467
  };
396
468
  })
397
469
  )
398
470
  };
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
471
  default:
408
472
  return;
409
473
  }
410
474
  }
475
+ const UNMODELLED_ROLE = { appliesToUsers: !0, appliesToRobots: !0 };
411
476
  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 }) ?? [])
477
+ const { resource } = args, byName = /* @__PURE__ */ new Map();
478
+ for (const identity of args.identities) {
479
+ const roles = ACCESS_CAPABILITIES[resource.type].memberRoles({ identity, id: resource.id });
480
+ for (const role of roles ?? [])
415
481
  byName.set(role.name, {
416
482
  name: role.name,
417
- ...role.title !== void 0 ? { title: role.title } : {},
418
- ...role.description !== void 0 ? { description: role.description } : {}
483
+ title: role.title ?? role.name,
484
+ ...role.description !== void 0 ? { description: role.description } : {},
485
+ resourceType: resource.type,
486
+ resourceId: resource.id,
487
+ ...UNMODELLED_ROLE,
488
+ permissions: []
419
489
  });
490
+ }
420
491
  return [...byName.values()].toSorted((a, b) => a.name.localeCompare(b.name));
421
492
  }
493
+ function membershipOn(identity, resource) {
494
+ const roles = ACCESS_CAPABILITIES[resource.type].memberRoles({ identity, id: resource.id });
495
+ if (roles !== void 0)
496
+ return {
497
+ resourceType: resource.type,
498
+ resourceId: resource.id,
499
+ roleNames: roles.map((role) => role.name).toSorted()
500
+ };
501
+ }
422
502
  function usersOn(args) {
423
- const { row, id, identities } = args, bySanityUserId = /* @__PURE__ */ new Map();
503
+ const { resource, registry, identities } = args, owned = ACCESS_CAPABILITIES[resource.type].owns?.(registry) ?? [], bySanityUserId = /* @__PURE__ */ new Map();
424
504
  for (const identity of identities) {
425
- const roles = row.access.memberRoles({ identity, id });
426
- if (roles === void 0) continue;
505
+ const own = membershipOn(identity, resource);
506
+ if (own === void 0) continue;
427
507
  const user = identity.globalUser ?? identity.user, record = {
428
508
  sanityUserId: user.id,
429
509
  profile: {
510
+ id: user.id,
430
511
  displayName: user.name ?? user.id,
431
- ...user.email !== void 0 ? { email: user.email } : {}
512
+ email: user.email ?? "",
513
+ ...UNMODELLED_PROFILE
432
514
  },
433
515
  memberships: [
434
- {
435
- resourceType: row.access.type,
436
- resourceId: id,
437
- roleNames: roles.map((role) => role.name).toSorted()
438
- }
516
+ own,
517
+ // A user is part of a resource only through a role on it.
518
+ ...owned.flatMap((child) => {
519
+ const membership = membershipOn(identity, child);
520
+ return membership === void 0 || membership.roleNames.length === 0 ? [] : [membership];
521
+ })
439
522
  ]
440
523
  }, existing = bySanityUserId.get(record.sanityUserId);
441
524
  if (existing !== void 0 && JSON.stringify(existing) !== JSON.stringify(record))
442
525
  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.`
526
+ `request(): two identities (registered, or the handle's inline accessControl) resolve to user "${record.sanityUserId}" on ${resource.type} "${resource.id}" with different records. Fix the identities so duplicate ids carry identical records.`
444
527
  );
445
528
  bySanityUserId.set(record.sanityUserId, record);
446
529
  }
447
530
  return [...bySanityUserId.values()];
448
531
  }
532
+ const DEFAULT_ATTRIBUTES_LIMIT = 50;
533
+ function serveMyAttributes(args) {
534
+ const { ctx, request, organizationId, query } = args, organization = ACCESS_CAPABILITIES.organization, registry = { organizationId: ctx.organizationId, resources: ctx.resources };
535
+ if (organization.unknown !== void 0 && !organization.unknown.holds(registry, organizationId))
536
+ throw organization.unknown.refusal(request);
537
+ const entry = ctx.actorAccess()?.[accessKey("organization", organizationId)];
538
+ if (entry?.roles === void 0) throw notFound("User")(request);
539
+ const limit = attributesLimit(query), cursor = query.get("cursor"), attributes = (entry.attributes ?? []).map(attributeRecord).toSorted((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0).filter((attribute) => cursor === null || attribute.key > cursor).slice(0, limit);
540
+ return { value: { sanityUserId: ctx.actorUser().id, organizationId, attributes } };
541
+ }
542
+ function attributesLimit(query) {
543
+ const raw = query.get("limit");
544
+ if (raw === null) return DEFAULT_ATTRIBUTES_LIMIT;
545
+ const limit = Number(raw);
546
+ if (!Number.isInteger(limit) || limit < 1)
547
+ throw fakeError(
548
+ `request(): users/me/attributes got limit=${JSON.stringify(raw)}; the fake serves positive integer limits only (the platform's answer to others is not simulated).`
549
+ );
550
+ return limit;
551
+ }
552
+ function attributeRecord(attribute) {
553
+ const { key, type, values } = attribute, activeSource = values.sanity !== void 0 ? "sanity" : "saml", activeValue = values[activeSource];
554
+ if (activeValue === void 0)
555
+ throw fakeError(
556
+ `request(): user attribute "${key}" is seeded with no value; give it a \`saml\` or \`sanity\` value.`
557
+ );
558
+ return { type, key, values: { ...values }, activeSource, activeValue };
559
+ }
449
560
  class WriteAccessDeniedError extends Error {
450
561
  documentId;
451
562
  permission;
@@ -517,7 +628,7 @@ function consistentProjectRecords(args) {
517
628
  const existing = byId.get(record.id);
518
629
  if (existing !== void 0 && JSON.stringify(existing) !== JSON.stringify(record))
519
630
  throw fakeError(
520
- `request(): two registered identities resolve to project user "${record.id}" in project "${args.projectId}" with different records. The project records cannot be served unambiguously \u2014 fix the identity registrations so duplicate ids carry identical records.`
631
+ `request(): two identities (registered, or the handle's inline accessControl) resolve to project user "${record.id}" in project "${args.projectId}" with different records. The project records cannot be served unambiguously \u2014 fix the identities so duplicate ids carry identical records.`
521
632
  );
522
633
  byId.set(record.id, record);
523
634
  }
@@ -602,7 +713,7 @@ function makeRequestImpl(ctx) {
602
713
  const served = path === void 0 ? void 0 : await servePlatformRequest(platform, path);
603
714
  if (served !== void 0) return served.value;
604
715
  throw fakeError(
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.`
716
+ `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[/<name>] | users[/<id>] | user-permissions/me[/check]") and the caller's attributes ("/organizations/<id>/users/me/attributes"). For any other endpoint, register a stub (client.stub("request", fn)) or intercept the HTTP call with a tool like MSW.`
606
717
  );
607
718
  };
608
719
  }
@@ -1665,7 +1776,17 @@ function makeHandle(handleArgs) {
1665
1776
  if (access === void 0)
1666
1777
  throw fakeError("this handle has no acting identity (no token or accessControl bound).");
1667
1778
  return access;
1668
- }, capabilities = capabilitiesOf(target.type), targetLabel = capabilities.label(target.id), { store, history } = ensureEntry(registry, target), concurrentEditKey = (id) => registryKey(target) + " " + id, actorId = () => {
1779
+ }, members = () => [
1780
+ ...backend.identities.values(),
1781
+ ...token === void 0 && accessControl !== void 0 ? [
1782
+ {
1783
+ user: accessControl.currentUser,
1784
+ ...accessControl.globalUser !== void 0 ? { globalUser: accessControl.globalUser } : {},
1785
+ grants: accessControl.grants,
1786
+ ...accessControl.access !== void 0 ? { access: accessControl.access } : {}
1787
+ }
1788
+ ] : []
1789
+ ], capabilities = capabilitiesOf(target.type), targetLabel = capabilities.label(target.id), { store, history } = ensureEntry(registry, target), concurrentEditKey = (id) => registryKey(target) + " " + id, actorId = () => {
1669
1790
  const access = activeAccess();
1670
1791
  if (access !== void 0)
1671
1792
  return capabilities.identity === "global" ? (access.globalUser ?? access.currentUser).id : access.currentUser.id;
@@ -2021,7 +2142,7 @@ function makeHandle(handleArgs) {
2021
2142
  // client to the global API host.
2022
2143
  useProjectHostname: useProjectHostname && resource === void 0,
2023
2144
  organizationId: backend.organizationId,
2024
- listIdentities: () => [...backend.identities.values()],
2145
+ listIdentities: members,
2025
2146
  platform: {
2026
2147
  projectId,
2027
2148
  organizationId: backend.organizationId,
@@ -2031,9 +2152,13 @@ function makeHandle(handleArgs) {
2031
2152
  history,
2032
2153
  canRead
2033
2154
  } : void 0,
2155
+ actorUser: () => {
2156
+ const access = requireActiveAccess();
2157
+ return access.globalUser ?? access.currentUser;
2158
+ },
2034
2159
  actorAccess: () => requireActiveAccess().access,
2035
- identities: () => [...backend.identities.values()],
2036
- exists: (asked) => registry.has(registryKey(asked))
2160
+ identities: members,
2161
+ resources: () => [...registry.values()].map((entry) => entry.resource)
2037
2162
  }
2038
2163
  })
2039
2164
  } : {},