@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/README.md +7 -2
- package/dist/index.cjs +208 -83
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +35 -6
- package/dist/index.d.ts +35 -6
- package/dist/index.js +208 -83
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -198,10 +198,15 @@ await canvas.releases.create({ releaseId: "r1" }); // 404 Cannot POST /canvases/
|
|
|
198
198
|
| ACL `<data base>/acl` | ✅ | ✅ | ✅ | 404 `Cannot GET …` |
|
|
199
199
|
| Access API `…/user-permissions/me`, `…/me/check` | ✅ | ✅ | ✅ | ✅ |
|
|
200
200
|
| Access API `…/users` | ✅ | ✅ | 403 | 403 |
|
|
201
|
-
| Access API `…/
|
|
201
|
+
| Access API `…/users/<sanityUserId>` (served on `organization`, below) | 403 | 403 | 403 | 403 |
|
|
202
|
+
| Access API `…/roles`, `…/roles/<roleName>` | ✅ | ✅ with `sanity.media-library.roles.read`, else 403 | 403 | 403 |
|
|
202
203
|
| Org lookup | `/projects/<projectId>` | `GET /media-libraries/<id>` | `GET /canvases/<id>` | `GET /dashboards/<id>` |
|
|
203
204
|
|
|
204
|
-
- **Access API.** Paths are `/access/<type>/<id>/…` with type `project`, `media-library`, `canvas` or `dashboard`. An identity's `access` entries, keyed `"<type>:<id>"`, hold its `roles` there (
|
|
205
|
+
- **Access API.** Paths are `/access/<type>/<id>/…` with type `organization`, `project`, `media-library`, `canvas` or `dashboard`. An identity's `access` entries, keyed `"<type>:<id>"`, hold its `roles` there (membership) and the `permissions` the `user-permissions` endpoints report. Every identity is a project member with its project roles. A user row is `{ sanityUserId, profile: { id, displayName, email, provider, createdAt }, memberships: [{ resourceType, resourceId, roleNames }] }`; the fake does not model `provider` or `createdAt`, so they are always `"sanity"` and the epoch, and `email` is `""` where the identity seeds none. Listings come back on one page (`nextCursor: null`); users listings add `totalCount`. `includeImpliedRoles` is accepted and ignored: rows carry the seeded roles only, so seed an implied role explicitly when a test needs it. An unknown type answers 400 `Invalid resource type <type>`. An id the registry does not hold (never seeded, written or addressed) answers 404 `Media library not found` / `Canvas not found` / `Dashboard not found`, except `…/me/check`, which answers `false` as staging does. `…/users/<sanityUserId>` is served only on the organization, where the fake checks members read; elsewhere it answers 403 (the reference requires `sanity.<type>.members.read` for it; no type's single-user read was probed on staging, only the media-library listing). The other 403 cells were not proven on staging (no token could list them), so they answer the way the platform refuses a missing permission: `{ statusCode: 403, error: "Forbidden", message: "You do not have permission to access this resource" }`.
|
|
206
|
+
- **Members.** Every member listing (`/projects/<projectId>`, `…/users`, `…/roles`) sees the registered identities plus, on a handle created with inline `accessControl`, that handle's own actor. Inline config belongs to its handle: a token sibling does not see it.
|
|
207
|
+
- **Roles.** `…/roles` answers `{ data: [role], nextCursor: null }` and `…/roles/<roleName>` one role (404 `Role not found` for a name no member holds). A role is `{ name, title, description?, resourceType, resourceId, appliesToUsers, appliesToRobots, permissions }`, collected from the roles the members hold on the resource. The fake does not model role definitions, so `title` is the name where the seed gives none, both `appliesTo…` flags are `true` and `permissions` is `[]`. A media library serves its roles only to an actor whose `"media-library:<id>"` entry holds `sanity.media-library.roles.read` in `permissions`, else 403, as the reference requires.
|
|
208
|
+
- **Organization.** `/access/organization/<organizationId>/users` and `…/users/<sanityUserId>` (one row, or 404 `User not found` for a non-member) serve the members of the client's `organizationId`: identities with roles in their `"organization:<id>"` entry. Each row lists the organization membership, then the member's roles on every resource the client holds (its libraries, canvases, dashboards and projects), as the platform's organization row does. Like the platform, it needs `sanity.organization.members.read` in the actor's `"organization:<id>"` `permissions` (the built-in `member` role grants it), else 403. Another organization id answers 404 `Organization not found`; `…/roles` answers 403.
|
|
209
|
+
- **User attributes.** `GET /organizations/<organizationId>/users/me/attributes` answers the caller's own attributes there: `{ sanityUserId, organizationId, attributes: [{ type, key, values: { saml?, sanity? }, activeSource, activeValue }] }`, ordered by key. Seed them as `attributes: [{ key, type, values }]` on the identity's `"organization:<id>"` entry; the Sanity value takes precedence over the SAML one for `activeSource` and `activeValue`, as on the platform. No permission is needed. `limit` (default 50) and `cursor` (the last key of the previous page) page the list. A member with no seeded attributes gets `attributes: []`; a caller without roles in the organization gets 404 `User not found`, and another organization id 404 `Organization not found`.
|
|
205
210
|
- **History.** Every committed write since the client was created is recorded per resource, authored by the acting identity; seeded documents can be read at their seeded `_rev`. A handle answers for its own resource, and hides documents its grants cannot read, as reads do (a transaction that also touched such a document is served without it). Events model `createLiveDocument` / `updateLiveDocument` only.
|
|
206
211
|
- **Org lookup.** `createTestClient({ organizationId })` names the organization every resource of the client belongs to.
|
|
207
212
|
- **Identity.** `@sanity/client` sends a `resource` client's requests to the global host, so `/users/me` answers `globalUser`.
|
package/dist/index.cjs
CHANGED
|
@@ -104,7 +104,7 @@ function accessApiError(args) {
|
|
|
104
104
|
...request,
|
|
105
105
|
statusCode,
|
|
106
106
|
statusMessage,
|
|
107
|
-
body: { statusCode, message }
|
|
107
|
+
body: { statusCode, error: statusMessage, message }
|
|
108
108
|
});
|
|
109
109
|
}
|
|
110
110
|
const noPermission = (request) => accessApiError({
|
|
@@ -187,14 +187,7 @@ const RESOURCE_CAPABILITIES = {
|
|
|
187
187
|
history: "served",
|
|
188
188
|
actions: "served",
|
|
189
189
|
refusedActions: {},
|
|
190
|
-
access: {
|
|
191
|
-
type: "project",
|
|
192
|
-
id: (id) => datasetParts(id).projectId,
|
|
193
|
-
roles: "served",
|
|
194
|
-
users: "served",
|
|
195
|
-
// Every registered identity is a member of every project, with its project roles.
|
|
196
|
-
memberRoles: ({ identity, id }) => (identity.usersByProject?.[id] ?? identity.user).roles ?? []
|
|
197
|
-
}
|
|
190
|
+
access: { type: "project", id: (id) => datasetParts(id).projectId }
|
|
198
191
|
},
|
|
199
192
|
"media-library": {
|
|
200
193
|
label: (id) => `media library "${id}"`,
|
|
@@ -206,15 +199,7 @@ const RESOURCE_CAPABILITIES = {
|
|
|
206
199
|
history: "served",
|
|
207
200
|
actions: "served",
|
|
208
201
|
refusedActions: { "sanity.action.release.create": releaseLimitExceeded },
|
|
209
|
-
access: {
|
|
210
|
-
type: "media-library",
|
|
211
|
-
id: (id) => id,
|
|
212
|
-
// Unproven on staging (no token held roles read): refused like a missing permission.
|
|
213
|
-
roles: noPermission,
|
|
214
|
-
users: "served",
|
|
215
|
-
unknownId: notFound("Media library"),
|
|
216
|
-
memberRoles: accessEntryRoles("media-library")
|
|
217
|
-
}
|
|
202
|
+
access: { type: "media-library", id: (id) => id }
|
|
218
203
|
},
|
|
219
204
|
canvas: {
|
|
220
205
|
label: (id) => `canvas "${id}"`,
|
|
@@ -226,15 +211,7 @@ const RESOURCE_CAPABILITIES = {
|
|
|
226
211
|
history: "served",
|
|
227
212
|
actions: notMounted,
|
|
228
213
|
refusedActions: {},
|
|
229
|
-
access: {
|
|
230
|
-
type: "canvas",
|
|
231
|
-
id: (id) => id,
|
|
232
|
-
// Unproven on staging (no canvas roles/members permission exists to grant).
|
|
233
|
-
roles: noPermission,
|
|
234
|
-
users: noPermission,
|
|
235
|
-
unknownId: notFound("Canvas"),
|
|
236
|
-
memberRoles: accessEntryRoles("canvas")
|
|
237
|
-
}
|
|
214
|
+
access: { type: "canvas", id: (id) => id }
|
|
238
215
|
},
|
|
239
216
|
dashboard: {
|
|
240
217
|
label: (id) => `dashboard "${id}"`,
|
|
@@ -246,19 +223,11 @@ const RESOURCE_CAPABILITIES = {
|
|
|
246
223
|
history: notMounted,
|
|
247
224
|
actions: notMounted,
|
|
248
225
|
refusedActions: {},
|
|
249
|
-
access: {
|
|
250
|
-
type: "dashboard",
|
|
251
|
-
id: (id) => id,
|
|
252
|
-
// Unproven on staging, as for canvas.
|
|
253
|
-
roles: noPermission,
|
|
254
|
-
users: noPermission,
|
|
255
|
-
unknownId: notFound("Dashboard"),
|
|
256
|
-
memberRoles: accessEntryRoles("dashboard")
|
|
257
|
-
}
|
|
226
|
+
access: { type: "dashboard", id: (id) => id }
|
|
258
227
|
}
|
|
259
228
|
};
|
|
260
229
|
function capabilitiesOf(type) {
|
|
261
|
-
const row = RESOURCE_CAPABILITIES[type];
|
|
230
|
+
const row = Object.hasOwn(RESOURCE_CAPABILITIES, type) ? RESOURCE_CAPABILITIES[type] : void 0;
|
|
262
231
|
if (row === void 0)
|
|
263
232
|
throw fakeError(
|
|
264
233
|
`Unsupported resource type ${JSON.stringify(type)}. The fake serves ${Object.keys(RESOURCE_CAPABILITIES).map((t) => `"${t}"`).join(", ")}.`
|
|
@@ -280,9 +249,77 @@ function matchResourcePath(path) {
|
|
|
280
249
|
return { resource: { type, id: match.id }, rest: match.rest };
|
|
281
250
|
}
|
|
282
251
|
}
|
|
283
|
-
function
|
|
284
|
-
|
|
285
|
-
|
|
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
|
+
// Staging refuses it only because no probe token held roles read.
|
|
296
|
+
roles: "served",
|
|
297
|
+
rolesPermission: "sanity.media-library.roles.read",
|
|
298
|
+
users: "served",
|
|
299
|
+
// Unproven on staging (only the listing was probed); refused as on `project`.
|
|
300
|
+
user: noPermission,
|
|
301
|
+
unknown: { holds: holdsResource("media-library"), refusal: notFound("Media library") },
|
|
302
|
+
memberRoles: accessEntryRoles("media-library")
|
|
303
|
+
},
|
|
304
|
+
canvas: {
|
|
305
|
+
// Unproven on staging (no canvas roles/members permission exists to grant).
|
|
306
|
+
roles: noPermission,
|
|
307
|
+
users: noPermission,
|
|
308
|
+
user: noPermission,
|
|
309
|
+
unknown: { holds: holdsResource("canvas"), refusal: notFound("Canvas") },
|
|
310
|
+
memberRoles: accessEntryRoles("canvas")
|
|
311
|
+
},
|
|
312
|
+
dashboard: {
|
|
313
|
+
// Unproven on staging, as for canvas.
|
|
314
|
+
roles: noPermission,
|
|
315
|
+
users: noPermission,
|
|
316
|
+
user: noPermission,
|
|
317
|
+
unknown: { holds: holdsResource("dashboard"), refusal: notFound("Dashboard") },
|
|
318
|
+
memberRoles: accessEntryRoles("dashboard")
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
function accessCapabilitiesOf(type) {
|
|
322
|
+
return Object.hasOwn(ACCESS_CAPABILITIES, type) ? ACCESS_CAPABILITIES[type] : void 0;
|
|
286
323
|
}
|
|
287
324
|
function dataUrl(args) {
|
|
288
325
|
const { resource, dataset, operation, path } = args;
|
|
@@ -296,12 +333,21 @@ function dataUrl(args) {
|
|
|
296
333
|
const base = `/${operation}/${dataset}`;
|
|
297
334
|
return `/data${path !== void 0 ? `${base}/${path}` : base}`.replace(/\/($|\?)/, "$1");
|
|
298
335
|
}
|
|
299
|
-
const ACCESS_PATH = /^\/access\/([^/]+)\/([^/]+)(\/.*)?$/, DATA_HISTORY_PATH = /^\/data\/history\/([^/]+)(\/.*)$/;
|
|
336
|
+
const ACCESS_PATH = /^\/access\/([^/]+)\/([^/]+)(\/.*)?$/, ACCESS_USER_PATH = /^\/users\/([^/]+)$/, ACCESS_ROLE_PATH = /^\/roles\/([^/]+)$/, MY_ATTRIBUTES_PATH = /^\/organizations\/([^/]+)\/users\/me\/attributes$/, DATA_HISTORY_PATH = /^\/data\/history\/([^/]+)(\/.*)$/;
|
|
300
337
|
function page(data) {
|
|
301
338
|
return { data, nextCursor: null };
|
|
302
339
|
}
|
|
340
|
+
function usersPage(data) {
|
|
341
|
+
return { ...page(data), totalCount: data.length };
|
|
342
|
+
}
|
|
343
|
+
const UNMODELLED_PROFILE = { provider: "sanity", createdAt: (/* @__PURE__ */ new Date(0)).toISOString() };
|
|
303
344
|
async function servePlatformRequest(ctx, url) {
|
|
304
|
-
const queryStart = url.indexOf("?"), path = queryStart === -1 ? url : url.slice(0, queryStart), query = new URLSearchParams(queryStart === -1 ? "" : url.slice(queryStart + 1)), request = { method: "GET", path },
|
|
345
|
+
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);
|
|
346
|
+
if (myAttributes !== null) {
|
|
347
|
+
const organizationId = decodeURIComponent(myAttributes[1]);
|
|
348
|
+
return serveMyAttributes({ ctx, request, organizationId, query });
|
|
349
|
+
}
|
|
350
|
+
const access = path.match(ACCESS_PATH);
|
|
305
351
|
if (access !== null) {
|
|
306
352
|
const [, type, id, rest2] = access;
|
|
307
353
|
return serveAccess({ ctx, request, type, id: decodeURIComponent(id), rest: rest2 ?? "", query });
|
|
@@ -369,20 +415,46 @@ async function readHistory(args) {
|
|
|
369
415
|
};
|
|
370
416
|
}
|
|
371
417
|
function serveAccess(args) {
|
|
372
|
-
const { ctx, request, type, id, rest, query } = args,
|
|
373
|
-
if (
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
418
|
+
const { ctx, request, type, id, rest, query } = args, row = accessCapabilitiesOf(type);
|
|
419
|
+
if (row === void 0) throw invalidAccessType(request, type);
|
|
420
|
+
const resource = { type, id }, registry = {
|
|
421
|
+
organizationId: ctx.organizationId,
|
|
422
|
+
resources: ctx.resources
|
|
423
|
+
}, permissions = ctx.actorAccess()?.[accessKey(resource.type, id)]?.permissions ?? [];
|
|
424
|
+
if (rest === "/user-permissions/me/check") {
|
|
425
|
+
const requested = query.getAll("permissions").flatMap((value) => value.split(","));
|
|
426
|
+
return {
|
|
427
|
+
value: { data: Object.fromEntries(requested.map((p) => [p, permissions.includes(p)])) }
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
if (row.unknown !== void 0 && !row.unknown.holds(registry, id))
|
|
431
|
+
throw row.unknown.refusal(request);
|
|
432
|
+
const gated = (capability, permission, serve) => serveCapability(capability, request, () => {
|
|
433
|
+
if (permission !== void 0 && !permissions.includes(permission))
|
|
434
|
+
throw noPermission(request);
|
|
435
|
+
return serve();
|
|
436
|
+
}), users = (capability, serve) => gated(capability, row.usersPermission, serve), roles = () => rolesOn({ resource, identities: ctx.identities() }), user = rest.match(ACCESS_USER_PATH);
|
|
437
|
+
if (user !== null)
|
|
438
|
+
return users(row.user, () => {
|
|
439
|
+
const sanityUserId = decodeURIComponent(user[1]), record = usersOn({ resource, registry, identities: ctx.identities() }).find(
|
|
440
|
+
(candidate) => candidate.sanityUserId === sanityUserId
|
|
441
|
+
);
|
|
442
|
+
if (record === void 0) throw notFound("User")(request);
|
|
443
|
+
return { value: record };
|
|
444
|
+
});
|
|
445
|
+
const role = rest.match(ACCESS_ROLE_PATH);
|
|
446
|
+
if (role !== null)
|
|
447
|
+
return gated(row.roles, row.rolesPermission, () => {
|
|
448
|
+
const name = decodeURIComponent(role[1]), record = roles().find((candidate) => candidate.name === name);
|
|
449
|
+
if (record === void 0) throw notFound("Role")(request);
|
|
450
|
+
return { value: record };
|
|
451
|
+
});
|
|
378
452
|
switch (rest) {
|
|
379
453
|
case "/roles":
|
|
380
|
-
return
|
|
381
|
-
value: page(rolesOn({ row: found.row, id, identities: ctx.identities() }))
|
|
382
|
-
}));
|
|
454
|
+
return gated(row.roles, row.rolesPermission, () => ({ value: page(roles()) }));
|
|
383
455
|
case "/users":
|
|
384
|
-
return
|
|
385
|
-
value:
|
|
456
|
+
return users(row.users, () => ({
|
|
457
|
+
value: usersPage(usersOn({ resource, registry, identities: ctx.identities() }))
|
|
386
458
|
}));
|
|
387
459
|
case "/user-permissions/me":
|
|
388
460
|
return {
|
|
@@ -391,63 +463,102 @@ function serveAccess(args) {
|
|
|
391
463
|
const dot = permission.lastIndexOf(".");
|
|
392
464
|
return {
|
|
393
465
|
type: permission.slice(0, dot),
|
|
394
|
-
resourceType:
|
|
466
|
+
resourceType: resource.type,
|
|
395
467
|
resourceId: id,
|
|
396
468
|
action: permission.slice(dot + 1)
|
|
397
469
|
};
|
|
398
470
|
})
|
|
399
471
|
)
|
|
400
472
|
};
|
|
401
|
-
case "/user-permissions/me/check": {
|
|
402
|
-
const requested = query.getAll("permissions").flatMap((value) => value.split(","));
|
|
403
|
-
return {
|
|
404
|
-
value: {
|
|
405
|
-
data: Object.fromEntries(requested.map((p) => [p, permissions.includes(p)]))
|
|
406
|
-
}
|
|
407
|
-
};
|
|
408
|
-
}
|
|
409
473
|
default:
|
|
410
474
|
return;
|
|
411
475
|
}
|
|
412
476
|
}
|
|
477
|
+
const UNMODELLED_ROLE = { appliesToUsers: !0, appliesToRobots: !0 };
|
|
413
478
|
function rolesOn(args) {
|
|
414
|
-
const byName = /* @__PURE__ */ new Map();
|
|
415
|
-
for (const identity of args.identities)
|
|
416
|
-
|
|
479
|
+
const { resource } = args, byName = /* @__PURE__ */ new Map();
|
|
480
|
+
for (const identity of args.identities) {
|
|
481
|
+
const roles = ACCESS_CAPABILITIES[resource.type].memberRoles({ identity, id: resource.id });
|
|
482
|
+
for (const role of roles ?? [])
|
|
417
483
|
byName.set(role.name, {
|
|
418
484
|
name: role.name,
|
|
419
|
-
|
|
420
|
-
...role.description !== void 0 ? { description: role.description } : {}
|
|
485
|
+
title: role.title ?? role.name,
|
|
486
|
+
...role.description !== void 0 ? { description: role.description } : {},
|
|
487
|
+
resourceType: resource.type,
|
|
488
|
+
resourceId: resource.id,
|
|
489
|
+
...UNMODELLED_ROLE,
|
|
490
|
+
permissions: []
|
|
421
491
|
});
|
|
492
|
+
}
|
|
422
493
|
return [...byName.values()].toSorted((a, b) => a.name.localeCompare(b.name));
|
|
423
494
|
}
|
|
495
|
+
function membershipOn(identity, resource) {
|
|
496
|
+
const roles = ACCESS_CAPABILITIES[resource.type].memberRoles({ identity, id: resource.id });
|
|
497
|
+
if (roles !== void 0)
|
|
498
|
+
return {
|
|
499
|
+
resourceType: resource.type,
|
|
500
|
+
resourceId: resource.id,
|
|
501
|
+
roleNames: roles.map((role) => role.name).toSorted()
|
|
502
|
+
};
|
|
503
|
+
}
|
|
424
504
|
function usersOn(args) {
|
|
425
|
-
const {
|
|
505
|
+
const { resource, registry, identities } = args, owned = ACCESS_CAPABILITIES[resource.type].owns?.(registry) ?? [], bySanityUserId = /* @__PURE__ */ new Map();
|
|
426
506
|
for (const identity of identities) {
|
|
427
|
-
const
|
|
428
|
-
if (
|
|
507
|
+
const own = membershipOn(identity, resource);
|
|
508
|
+
if (own === void 0) continue;
|
|
429
509
|
const user = identity.globalUser ?? identity.user, record = {
|
|
430
510
|
sanityUserId: user.id,
|
|
431
511
|
profile: {
|
|
512
|
+
id: user.id,
|
|
432
513
|
displayName: user.name ?? user.id,
|
|
433
|
-
|
|
514
|
+
email: user.email ?? "",
|
|
515
|
+
...UNMODELLED_PROFILE
|
|
434
516
|
},
|
|
435
517
|
memberships: [
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
518
|
+
own,
|
|
519
|
+
// A user is part of a resource only through a role on it.
|
|
520
|
+
...owned.flatMap((child) => {
|
|
521
|
+
const membership = membershipOn(identity, child);
|
|
522
|
+
return membership === void 0 || membership.roleNames.length === 0 ? [] : [membership];
|
|
523
|
+
})
|
|
441
524
|
]
|
|
442
525
|
}, existing = bySanityUserId.get(record.sanityUserId);
|
|
443
526
|
if (existing !== void 0 && JSON.stringify(existing) !== JSON.stringify(record))
|
|
444
527
|
throw fakeError(
|
|
445
|
-
`request(): two registered
|
|
528
|
+
`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.`
|
|
446
529
|
);
|
|
447
530
|
bySanityUserId.set(record.sanityUserId, record);
|
|
448
531
|
}
|
|
449
532
|
return [...bySanityUserId.values()];
|
|
450
533
|
}
|
|
534
|
+
const DEFAULT_ATTRIBUTES_LIMIT = 50;
|
|
535
|
+
function serveMyAttributes(args) {
|
|
536
|
+
const { ctx, request, organizationId, query } = args, organization = ACCESS_CAPABILITIES.organization, registry = { organizationId: ctx.organizationId, resources: ctx.resources };
|
|
537
|
+
if (organization.unknown !== void 0 && !organization.unknown.holds(registry, organizationId))
|
|
538
|
+
throw organization.unknown.refusal(request);
|
|
539
|
+
const entry = ctx.actorAccess()?.[accessKey("organization", organizationId)];
|
|
540
|
+
if (entry?.roles === void 0) throw notFound("User")(request);
|
|
541
|
+
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);
|
|
542
|
+
return { value: { sanityUserId: ctx.actorUser().id, organizationId, attributes } };
|
|
543
|
+
}
|
|
544
|
+
function attributesLimit(query) {
|
|
545
|
+
const raw = query.get("limit");
|
|
546
|
+
if (raw === null) return DEFAULT_ATTRIBUTES_LIMIT;
|
|
547
|
+
const limit = Number(raw);
|
|
548
|
+
if (!Number.isInteger(limit) || limit < 1)
|
|
549
|
+
throw fakeError(
|
|
550
|
+
`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).`
|
|
551
|
+
);
|
|
552
|
+
return limit;
|
|
553
|
+
}
|
|
554
|
+
function attributeRecord(attribute) {
|
|
555
|
+
const { key, type, values } = attribute, activeSource = values.sanity !== void 0 ? "sanity" : "saml", activeValue = values[activeSource];
|
|
556
|
+
if (activeValue === void 0)
|
|
557
|
+
throw fakeError(
|
|
558
|
+
`request(): user attribute "${key}" is seeded with no value; give it a \`saml\` or \`sanity\` value.`
|
|
559
|
+
);
|
|
560
|
+
return { type, key, values: { ...values }, activeSource, activeValue };
|
|
561
|
+
}
|
|
451
562
|
class WriteAccessDeniedError extends Error {
|
|
452
563
|
documentId;
|
|
453
564
|
permission;
|
|
@@ -519,7 +630,7 @@ function consistentProjectRecords(args) {
|
|
|
519
630
|
const existing = byId.get(record.id);
|
|
520
631
|
if (existing !== void 0 && JSON.stringify(existing) !== JSON.stringify(record))
|
|
521
632
|
throw fakeError(
|
|
522
|
-
`request(): two registered
|
|
633
|
+
`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.`
|
|
523
634
|
);
|
|
524
635
|
byId.set(record.id, record);
|
|
525
636
|
}
|
|
@@ -604,7 +715,7 @@ function makeRequestImpl(ctx) {
|
|
|
604
715
|
const served = path === void 0 ? void 0 : await servePlatformRequest(platform, path);
|
|
605
716
|
if (served !== void 0) return served.value;
|
|
606
717
|
throw fakeError(
|
|
607
|
-
`request(): no handler for path "${path}". With access control configured, the fake serves "/users/me", the handle's ACL ("<data base>/acl"), "/projects/<projectId>" (members), "/projects/<projectId>/users/<ids>", the History API (client.getDataUrl("history", \u2026)), resource lookup ("/media-libraries/<id>", "/canvases/<id>", "/dashboards/<id>") and the Access API ("/access/<type>/<id>/roles | users | user-permissions/me[/check]"). For any other endpoint, register a stub (client.stub("request", fn)) or intercept the HTTP call with a tool like MSW.`
|
|
718
|
+
`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.`
|
|
608
719
|
);
|
|
609
720
|
};
|
|
610
721
|
}
|
|
@@ -1667,7 +1778,17 @@ function makeHandle(handleArgs) {
|
|
|
1667
1778
|
if (access === void 0)
|
|
1668
1779
|
throw fakeError("this handle has no acting identity (no token or accessControl bound).");
|
|
1669
1780
|
return access;
|
|
1670
|
-
},
|
|
1781
|
+
}, members = () => [
|
|
1782
|
+
...backend.identities.values(),
|
|
1783
|
+
...token === void 0 && accessControl !== void 0 ? [
|
|
1784
|
+
{
|
|
1785
|
+
user: accessControl.currentUser,
|
|
1786
|
+
...accessControl.globalUser !== void 0 ? { globalUser: accessControl.globalUser } : {},
|
|
1787
|
+
grants: accessControl.grants,
|
|
1788
|
+
...accessControl.access !== void 0 ? { access: accessControl.access } : {}
|
|
1789
|
+
}
|
|
1790
|
+
] : []
|
|
1791
|
+
], capabilities = capabilitiesOf(target.type), targetLabel = capabilities.label(target.id), { store, history } = ensureEntry(registry, target), concurrentEditKey = (id) => registryKey(target) + " " + id, actorId = () => {
|
|
1671
1792
|
const access = activeAccess();
|
|
1672
1793
|
if (access !== void 0)
|
|
1673
1794
|
return capabilities.identity === "global" ? (access.globalUser ?? access.currentUser).id : access.currentUser.id;
|
|
@@ -2023,7 +2144,7 @@ function makeHandle(handleArgs) {
|
|
|
2023
2144
|
// client to the global API host.
|
|
2024
2145
|
useProjectHostname: useProjectHostname && resource === void 0,
|
|
2025
2146
|
organizationId: backend.organizationId,
|
|
2026
|
-
listIdentities:
|
|
2147
|
+
listIdentities: members,
|
|
2027
2148
|
platform: {
|
|
2028
2149
|
projectId,
|
|
2029
2150
|
organizationId: backend.organizationId,
|
|
@@ -2033,9 +2154,13 @@ function makeHandle(handleArgs) {
|
|
|
2033
2154
|
history,
|
|
2034
2155
|
canRead
|
|
2035
2156
|
} : void 0,
|
|
2157
|
+
actorUser: () => {
|
|
2158
|
+
const access = requireActiveAccess();
|
|
2159
|
+
return access.globalUser ?? access.currentUser;
|
|
2160
|
+
},
|
|
2036
2161
|
actorAccess: () => requireActiveAccess().access,
|
|
2037
|
-
identities:
|
|
2038
|
-
|
|
2162
|
+
identities: members,
|
|
2163
|
+
resources: () => [...registry.values()].map((entry) => entry.resource)
|
|
2039
2164
|
}
|
|
2040
2165
|
})
|
|
2041
2166
|
} : {},
|