@easy-cms/core 0.43.0 → 0.45.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.
Files changed (3) hide show
  1. package/dist/index.d.ts +192 -10
  2. package/dist/index.js +446 -164
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // src/access.ts
2
+ var isSystemAdmin = (user) => user?.role === "admin" && !user.apiKey && user.scoped !== true;
2
3
  var anyone = () => true;
3
4
  var isLoggedIn = ({ user }) => user !== null;
4
5
  var isAdmin = ({ user }) => user?.role === "admin";
@@ -41,8 +42,7 @@ function keyAllows(user, target, operation) {
41
42
  const allowed2 = key2.permissions.globals?.[target.global];
42
43
  return allowed2?.includes(operation) ?? false;
43
44
  }
44
- var isAdmin2 = (user) => user?.role === "admin";
45
- var ownKeys = ({ user }) => !user || user.apiKey ? false : isAdmin2(user) ? true : { user: { equals: user.id } };
45
+ var ownKeys = ({ user }) => !user || user.apiKey ? false : isSystemAdmin(user) ? true : { user: { equals: user.id } };
46
46
  var system = { update: () => false };
47
47
  var apiKeysCollection = {
48
48
  slug: API_KEYS,
@@ -99,14 +99,17 @@ var apiKeysCollection = {
99
99
  { name: "keyHash", type: "text", hidden: true }
100
100
  ],
101
101
  hooks: {
102
- // Only real collections, globals and operations are kept.
102
+ // Only real collections, globals and operations are kept. A key keeps the context it was
103
+ // created in (`createApiKey`), whatever its permissions are changed to.
103
104
  beforeChange: [
104
- ({ data, cms }) => data.permissions === void 0 ? data : {
105
- ...data,
106
- permissions: cleanPermissions(data.permissions, {
105
+ ({ data, cms, operation, originalDoc }) => {
106
+ if (data.permissions === void 0) return data;
107
+ const context = operation === "create" ? data.permissions.context : originalDoc?.permissions?.context;
108
+ const permissions = cleanPermissions(data.permissions, {
107
109
  collections: cms.config.collections.map((c) => c.slug),
108
110
  globals: cms.config.globals.map((g) => g.slug)
109
- })
111
+ });
112
+ return { ...data, permissions: context ? { ...permissions, context } : permissions };
110
113
  }
111
114
  ]
112
115
  }
@@ -183,11 +186,11 @@ function withCreatedBy(config) {
183
186
  }
184
187
  var adminOrSelf = ({ user }) => {
185
188
  if (!user) return false;
186
- if (user.role === "admin") return true;
189
+ if (isSystemAdmin(user)) return true;
187
190
  return { id: { equals: user.id } };
188
191
  };
189
192
  var adminOnly = {
190
- update: ({ user }) => user?.role === "admin"
193
+ update: ({ user }) => isSystemAdmin(user)
191
194
  };
192
195
  function userFields(roles, rbac) {
193
196
  const defaultRole = roles.includes("editor") ? "editor" : roles[roles.length - 1];
@@ -239,9 +242,9 @@ function withUsers(config) {
239
242
  fields: [...userFields(roles, config.auth?.rbac === true), ...custom?.fields ?? []],
240
243
  access: {
241
244
  read: isLoggedIn,
242
- create: isAdmin,
245
+ create: ({ user }) => isSystemAdmin(user),
243
246
  update: adminOrSelf,
244
- delete: isAdmin,
247
+ delete: ({ user }) => isSystemAdmin(user),
245
248
  ...custom?.access
246
249
  }
247
250
  };
@@ -421,13 +424,37 @@ function withMedia(config) {
421
424
  ]
422
425
  }
423
426
  };
424
- const others = (config.collections ?? []).filter((c) => c.slug !== MEDIA);
427
+ const customFolders = config.collections?.find((c) => c.slug === MEDIA_FOLDERS);
428
+ const mediaFolders = customFolders ? {
429
+ ...mediaFoldersCollection,
430
+ // A field named like a built-in one changes it (e.g. `key` unique per tenant).
431
+ fields: [
432
+ ...mediaFoldersCollection.fields.map((field) => {
433
+ const own = customFolders.fields.find((f) => f.name === field.name);
434
+ return own ? { ...field, ...own } : field;
435
+ }),
436
+ ...customFolders.fields.filter(
437
+ (f) => !mediaFoldersCollection.fields.some((b) => b.name === f.name)
438
+ )
439
+ ],
440
+ access: { ...mediaFoldersCollection.access, ...customFolders.access },
441
+ hooks: {
442
+ ...customFolders.hooks,
443
+ afterRead: [
444
+ ...mediaFoldersCollection.hooks?.afterRead ?? [],
445
+ ...customFolders.hooks?.afterRead ?? []
446
+ ]
447
+ }
448
+ } : mediaFoldersCollection;
449
+ const others = (config.collections ?? []).filter(
450
+ (c) => c.slug !== MEDIA && c.slug !== MEDIA_FOLDERS
451
+ );
425
452
  return {
426
453
  ...config,
427
454
  collections: [
428
455
  ...others.slice(0, 1),
429
456
  media,
430
- ...folders ? [mediaFoldersCollection] : [],
457
+ ...folders ? [mediaFolders] : [],
431
458
  ...others.slice(1)
432
459
  ]
433
460
  };
@@ -544,7 +571,9 @@ var auditLogsCollection = {
544
571
  { name: "userAgent", type: "text" },
545
572
  { name: "changes", type: "json" },
546
573
  { name: "detail", type: "json" },
547
- { name: "signature", type: "text" }
574
+ { name: "signature", type: "text" },
575
+ // The part of the site, e.g. the tenant (`audit.scope`).
576
+ { name: "scope", type: "text", index: true }
548
577
  ]
549
578
  };
550
579
  var emailDeliveriesCollection = {
@@ -617,7 +646,8 @@ var AuditLog = class {
617
646
  return this.cms.config.audit || {
618
647
  keep: DEFAULT_AUDIT_KEEP,
619
648
  values: true,
620
- failedLogins: DEFAULT_FAILED_LOGINS
649
+ failedLogins: DEFAULT_FAILED_LOGINS,
650
+ scope: null
621
651
  };
622
652
  }
623
653
  /**
@@ -628,6 +658,7 @@ var AuditLog = class {
628
658
  if (!this.enabled) return;
629
659
  try {
630
660
  const context = auditContext.getStore();
661
+ const scope = this.scopeOf(entry.context ?? context?.context);
631
662
  const user = entry.user !== void 0 ? entry.user : context?.user ?? null;
632
663
  const apiKey = user?.apiKey;
633
664
  const row = {
@@ -642,7 +673,8 @@ var AuditLog = class {
642
673
  userAgent: context?.userAgent ? context.userAgent.slice(0, 200) : null,
643
674
  changes: entry.changes && entry.changes.length > 0 ? entry.changes : null,
644
675
  detail: apiKey ? { ...entry.detail, apiKey: apiKey.name ?? "" } : entry.detail ?? null,
645
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
676
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
677
+ ...scope ? { scope } : {}
646
678
  };
647
679
  row.signature = this.sign(row);
648
680
  row.updatedAt = row.createdAt;
@@ -658,7 +690,7 @@ var AuditLog = class {
658
690
  * A change to a document or global: what changed between `before` and `after`. Internal
659
691
  * collections (sessions, versions…) are not logged.
660
692
  */
661
- async content(config, global2, action, after, before, user) {
693
+ async content(config, global2, action, after, before, user, context) {
662
694
  if (!this.enabled) return;
663
695
  if (!global2 && INTERNAL_COLLECTIONS.has(config.slug)) return;
664
696
  const doc = after ?? before;
@@ -674,7 +706,8 @@ var AuditLog = class {
674
706
  title,
675
707
  // A trusted call without a user keeps the request's (or is the system).
676
708
  ...user ? { user } : {},
677
- changes: action === "delete" ? null : this.diff(config.fields, before ?? {}, after ?? {})
709
+ changes: action === "delete" ? null : this.diff(config.fields, before ?? {}, after ?? {}),
710
+ ...context && Object.keys(context).length > 0 ? { context } : {}
678
711
  });
679
712
  }
680
713
  /** Fields whose stored value changed. Hidden ones never (the password only as "changed"). */
@@ -819,8 +852,15 @@ var AuditLog = class {
819
852
  return out;
820
853
  }
821
854
  // -------------------------------------------------------------------------
855
+ /** The part of the site a context is in (`scope`), or `null`. */
856
+ scopeOf(context) {
857
+ const scope = this.settings.scope;
858
+ return (scope && context ? scope(context) : null) ?? null;
859
+ }
822
860
  sign(row) {
823
- const payload = stable(SIGNED.map((key2) => row[key2] ?? null));
861
+ const values = SIGNED.map((key2) => row[key2] ?? null);
862
+ if (typeof row.scope === "string" && row.scope) values.push(row.scope);
863
+ const payload = stable(values);
824
864
  return createHmac("sha256", this.cms.config.secret).update(`audit:${payload}`).digest("base64url");
825
865
  }
826
866
  async lastVerification() {
@@ -848,6 +888,7 @@ var AuditLog = class {
848
888
  if (filter.actor) parts.push({ actorEmail: { like: filter.actor } });
849
889
  if (filter.from) parts.push({ createdAt: { gte: filter.from } });
850
890
  if (filter.to) parts.push({ createdAt: { lte: filter.to } });
891
+ if (filter.scope) parts.push({ scope: { equals: filter.scope } });
851
892
  return parts.length === 0 ? void 0 : parts.length === 1 ? parts[0] : { and: parts };
852
893
  }
853
894
  };
@@ -1119,6 +1160,7 @@ function sizeKey(filename, size) {
1119
1160
  var PRIVATE_KEY = /^[\p{L}\p{M}\p{N}-]+\.private(-[\p{L}\p{N}_-]+)?\.[a-z0-9]+$/u;
1120
1161
 
1121
1162
  // src/fields.ts
1163
+ var uniqueWithinOf = (field) => field.uniqueWithin === void 0 ? [] : [field.uniqueWithin].flat();
1122
1164
  var isHasMany = (field) => (field.type === "select" || field.type === "relationship" || field.type === "upload") && field.hasMany === true;
1123
1165
  function mimeAllowedBy(mimeType, patterns) {
1124
1166
  const type = mimeType.toLowerCase();
@@ -2872,7 +2914,7 @@ async function normalizeValue(field, raw, path, options, fail, errors, reference
2872
2914
  field: path,
2873
2915
  collection: target,
2874
2916
  id,
2875
- ...field.type === "relationship" && field.filterOptions ? { filterOptions: field.filterOptions } : {},
2917
+ ...field.filterOptions ? { filterOptions: field.filterOptions } : {},
2876
2918
  ...field.type === "upload" && field.mimeTypes ? { mimeTypes: field.mimeTypes } : {},
2877
2919
  ...field.type === "upload" && field.folder && field.folderOnly ? { folderOnly: field.folder } : {}
2878
2920
  });
@@ -3358,7 +3400,8 @@ var MediaFolders = class {
3358
3400
  name: String(doc.name ?? ""),
3359
3401
  key: typeof doc.key === "string" && doc.key ? doc.key : null,
3360
3402
  private: doc.private === true,
3361
- permissions: permissions && typeof permissions === "object" && !Array.isArray(permissions) ? permissions : null
3403
+ permissions: permissions && typeof permissions === "object" && !Array.isArray(permissions) ? permissions : null,
3404
+ doc
3362
3405
  });
3363
3406
  }
3364
3407
  this.cache = { at: Date.now(), rows };
@@ -3428,18 +3471,15 @@ var MediaFolders = class {
3428
3471
  });
3429
3472
  return this.within(rows, [id]).map((f) => ({ id: f, private: this.privateIn(rows, f) }));
3430
3473
  }
3431
- /**
3432
- * The folder with this key (`folder: 'banners'` on upload fields), made at the top level when
3433
- * there is none yet.
3434
- */
3435
- async keyed(key2) {
3436
- const find = async () => [...(await this.rows()).values()].find((r) => r.key === key2);
3474
+ async keyed(key2, scope = {}, create = true) {
3475
+ const find = async () => [...(await this.rows()).values()].find((r) => r.key === key2 && inScope(r.doc, scope));
3437
3476
  let found = await find();
3438
3477
  if (!found) {
3439
3478
  this.invalidate();
3440
3479
  found = await find();
3441
3480
  }
3442
3481
  if (found) return found.id;
3482
+ if (!create) return null;
3443
3483
  const now = (/* @__PURE__ */ new Date()).toISOString();
3444
3484
  try {
3445
3485
  const doc = await this.db.create({
@@ -3450,6 +3490,7 @@ var MediaFolders = class {
3450
3490
  parent: null,
3451
3491
  permissions: null,
3452
3492
  private: false,
3493
+ ...scope,
3453
3494
  createdAt: now,
3454
3495
  updatedAt: now
3455
3496
  }
@@ -3464,10 +3505,10 @@ var MediaFolders = class {
3464
3505
  }
3465
3506
  }
3466
3507
  /** Whether a file in `folder` is in the folder with this key, or below it. */
3467
- async inKeyed(key2, folder) {
3508
+ async inKeyed(key2, folder, scope = {}) {
3468
3509
  if (folder === null) return false;
3469
3510
  const rows = await this.rows();
3470
- const root = [...rows.values()].find((r) => r.key === key2);
3511
+ const root = [...rows.values()].find((r) => r.key === key2 && inScope(r.doc, scope));
3471
3512
  return !!root && this.within(rows, [root.id]).some((id) => String(id) === String(folder));
3472
3513
  }
3473
3514
  /** The user's level in a folder (`null`: the top level). */
@@ -3592,6 +3633,11 @@ var MediaFolders = class {
3592
3633
  return this.rows();
3593
3634
  }
3594
3635
  };
3636
+ function inScope(row, scope) {
3637
+ return Object.entries(scope).every(
3638
+ ([name, value]) => value === null || value === void 0 ? row[name] === null || row[name] === void 0 : String(row[name]) === String(value)
3639
+ );
3640
+ }
3595
3641
 
3596
3642
  // src/populate.ts
3597
3643
  var DEFAULT_DEPTH = 1;
@@ -4079,8 +4125,10 @@ var Roles = class {
4079
4125
  * it; without roles, nobody else (`status`, `deliveries`) or everybody (plugin pages).
4080
4126
  */
4081
4127
  async canView(user, id) {
4082
- if (user.role === "admin" && !user.apiKey) return true;
4128
+ if (isSystemAdmin(user)) return true;
4083
4129
  if (user.apiKey) return false;
4130
+ if (id === "audit" && user.role === "admin" && user.scoped === true && this.config.audit !== false && typeof this.config.audit.scope === "function")
4131
+ return true;
4084
4132
  if (!this.enabled) return id.startsWith("page:") || id.startsWith("widget:");
4085
4133
  return (await this.permissionsOf(user.role)).admin?.includes(id) ?? false;
4086
4134
  }
@@ -4866,6 +4914,14 @@ function validateAdmin(config, add) {
4866
4914
  "e.g. siteUrl: 'https://example.com'"
4867
4915
  );
4868
4916
  }
4917
+ const switcher = admin.switcher;
4918
+ if (switcher !== void 0 && switcher !== null) {
4919
+ const { cookie: cookie2, options } = switcher;
4920
+ if (typeof cookie2 !== "string" || !/^[A-Za-z0-9_-]+$/.test(cookie2))
4921
+ add("admin.switcher.cookie", "must be a cookie name (letters, digits, - and _)");
4922
+ if (typeof options !== "string" || !options.startsWith("/"))
4923
+ add("admin.switcher.options", 'must be a path under the API starting with "/"');
4924
+ }
4869
4925
  const modules = admin.modules;
4870
4926
  if (modules !== void 0) {
4871
4927
  if (!Array.isArray(modules)) {
@@ -5501,6 +5557,22 @@ function validateField(field, path, siblings, collectionSlugs, add) {
5501
5557
  if (cell !== void 0) validateComponent(cell, `${path}.admin.cell`, add);
5502
5558
  }
5503
5559
  }
5560
+ if (field.uniqueWithin !== void 0) {
5561
+ if (!field.unique && field.type !== "slug")
5562
+ add(`${path}.uniqueWithin`, "needs `unique: true` (or a slug field)");
5563
+ else
5564
+ for (const name of [field.uniqueWithin].flat()) {
5565
+ const scope = siblings.get(name);
5566
+ if (!scope || scope === field) {
5567
+ add(`${path}.uniqueWithin`, `no sibling field named "${name}"`);
5568
+ } else if (!["relationship", "select", "text", "number"].includes(scope.type) || "hasMany" in scope && scope.hasMany || scope.localized) {
5569
+ add(
5570
+ `${path}.uniqueWithin`,
5571
+ `"${name}" must be a single, unlocalized relationship, select, text or number field`
5572
+ );
5573
+ }
5574
+ }
5575
+ }
5504
5576
  switch (field.type) {
5505
5577
  case "text":
5506
5578
  case "textarea":
@@ -5521,17 +5593,6 @@ function validateField(field, path, siblings, collectionSlugs, add) {
5521
5593
  add(`${path}.from`, `"${field.from}" must be a text field (got ${source.type})`);
5522
5594
  }
5523
5595
  }
5524
- if (field.uniqueWithin !== void 0) {
5525
- const scope = siblings.get(field.uniqueWithin);
5526
- if (!scope || scope === field) {
5527
- add(`${path}.uniqueWithin`, `no sibling field named "${field.uniqueWithin}"`);
5528
- } else if (!["relationship", "select", "text", "number"].includes(scope.type) || "hasMany" in scope && scope.hasMany || scope.localized) {
5529
- add(
5530
- `${path}.uniqueWithin`,
5531
- `"${field.uniqueWithin}" must be a single, unlocalized relationship, select, text or number field`
5532
- );
5533
- }
5534
- }
5535
5596
  break;
5536
5597
  case "relationship":
5537
5598
  if (typeof field.to !== "string" || !collectionSlugs.has(field.to)) {
@@ -5822,7 +5883,8 @@ async function resolveConfig(input) {
5822
5883
  menu: config.admin?.menu ?? [],
5823
5884
  modules: config.admin?.modules ?? [],
5824
5885
  pages: config.admin?.pages ?? [],
5825
- dashboard: config.admin?.dashboard ?? []
5886
+ dashboard: config.admin?.dashboard ?? [],
5887
+ switcher: config.admin?.switcher ?? null
5826
5888
  },
5827
5889
  upload: {
5828
5890
  dir: config.upload?.dir ?? "uploads",
@@ -5885,7 +5947,8 @@ function resolveAudit(audit) {
5885
5947
  return {
5886
5948
  keep: audit.keep ?? DEFAULT_AUDIT_KEEP,
5887
5949
  values: audit.values !== false,
5888
- failedLogins: audit.failedLogins ?? DEFAULT_FAILED_LOGINS
5950
+ failedLogins: audit.failedLogins ?? DEFAULT_FAILED_LOGINS,
5951
+ scope: audit.scope ?? null
5889
5952
  };
5890
5953
  }
5891
5954
 
@@ -5969,6 +6032,29 @@ var EasyCMS = class {
5969
6032
  );
5970
6033
  this.auth = new Auth(this);
5971
6034
  }
6035
+ /**
6036
+ * The user and context of a request, as the REST API works them out: its session cookie or
6037
+ * Bearer token, then `onRequest`. For framework routes and server code that act for a visitor;
6038
+ * pass both to the Local API with `overrideAccess: false`.
6039
+ */
6040
+ async forRequest(request) {
6041
+ const headers = request instanceof Headers ? request : request.headers;
6042
+ const url = request instanceof Headers ? void 0 : new URL(request.url);
6043
+ return this.applyOnRequest(headers, url, await this.auth.userFromHeaders(headers));
6044
+ }
6045
+ /** @internal `onRequest` of the config, for a request whose user is known. */
6046
+ async applyOnRequest(headers, url, user) {
6047
+ const result = await this.config.onRequest?.({
6048
+ headers,
6049
+ url,
6050
+ user,
6051
+ cms: this
6052
+ });
6053
+ return {
6054
+ user: result?.user !== void 0 ? result.user : user,
6055
+ context: result?.context ?? NO_CONTEXT
6056
+ };
6057
+ }
5972
6058
  async find(collection2, options = {}) {
5973
6059
  const config = this.collection(collection2);
5974
6060
  const guard = guardOf(options);
@@ -6222,6 +6308,7 @@ var EasyCMS = class {
6222
6308
  await this.checkGrant(guard, { collection: MEDIA }, "create");
6223
6309
  const allowed2 = await evaluateAccess(this.collection(MEDIA).access?.create, {
6224
6310
  user: guard.user,
6311
+ context: guard.context,
6225
6312
  data
6226
6313
  });
6227
6314
  if (allowed2 !== true) throw deny(guard.user);
@@ -6259,7 +6346,11 @@ var EasyCMS = class {
6259
6346
  const fromURL = this.config.upload.fromURL;
6260
6347
  if (guard.enforce) {
6261
6348
  await this.checkGrant(guard, { collection: MEDIA }, "create");
6262
- const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data });
6349
+ const allowed2 = await evaluateAccess(config.access?.create, {
6350
+ user: guard.user,
6351
+ context: guard.context,
6352
+ data
6353
+ });
6263
6354
  if (allowed2 !== true) throw deny(guard.user);
6264
6355
  if (!fromURL)
6265
6356
  throw new ValidationError(MEDIA, [
@@ -6291,7 +6382,10 @@ var EasyCMS = class {
6291
6382
  const config = this.collection(API_KEYS);
6292
6383
  const guard = guardOf(options);
6293
6384
  if (guard.enforce) {
6294
- const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user });
6385
+ const allowed2 = await evaluateAccess(config.access?.create, {
6386
+ user: guard.user,
6387
+ context: guard.context
6388
+ });
6295
6389
  if (allowed2 !== true) throw deny(guard.user);
6296
6390
  }
6297
6391
  const owner = guard.enforce ? guard.user?.id : data.user;
@@ -6303,7 +6397,12 @@ var EasyCMS = class {
6303
6397
  config,
6304
6398
  {
6305
6399
  name: data.name,
6306
- permissions: data.permissions ?? {},
6400
+ // The context it is created in (e.g. a tenant) stays with the key. Requests can't name
6401
+ // one; trusted calls may.
6402
+ permissions: {
6403
+ ...withoutContext(data.permissions),
6404
+ ...guard.enforce ? Object.keys(guard.context).length > 0 ? { context: guard.context } : {} : data.permissions?.context ? { context: data.permissions.context } : {}
6405
+ },
6307
6406
  expiresAt: expiresAt ?? null,
6308
6407
  prefix,
6309
6408
  user: owner
@@ -6564,7 +6663,11 @@ var EasyCMS = class {
6564
6663
  if (id === null) {
6565
6664
  if (guard.enforce) {
6566
6665
  await this.checkGrant(guard, { collection: collection2 }, "create");
6567
- const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data: raw });
6666
+ const allowed2 = await evaluateAccess(config.access?.create, {
6667
+ user: guard.user,
6668
+ context: guard.context,
6669
+ data: raw
6670
+ });
6568
6671
  if (allowed2 !== true) throw deny(guard.user);
6569
6672
  }
6570
6673
  } else {
@@ -6620,8 +6723,9 @@ var EasyCMS = class {
6620
6723
  raw,
6621
6724
  await this.fieldChecker("update", guard, void 0, raw)
6622
6725
  );
6623
- const saved = await this.db.findGlobal({ slug: slug2 }) ?? {};
6624
- const current = await this.globalDraft(config, saved);
6726
+ const key2 = this.globalKey(config, guard);
6727
+ const saved = (key2 ? await this.db.findGlobal({ slug: key2 }) : null) ?? {};
6728
+ const current = await this.globalDraft(config, saved, key2);
6625
6729
  const merged = generateSlugs(
6626
6730
  config.fields,
6627
6731
  applyDefaults(
@@ -6719,7 +6823,7 @@ var EasyCMS = class {
6719
6823
  "update",
6720
6824
  parsed,
6721
6825
  options,
6722
- guard.user
6826
+ guard
6723
6827
  );
6724
6828
  prepared = await this.transform(
6725
6829
  config.hooks?.beforeChange,
@@ -6853,8 +6957,12 @@ var EasyCMS = class {
6853
6957
  const config = this.global(slug2);
6854
6958
  const guard = guardOf(options);
6855
6959
  await this.checkGlobalAccess(config, "read", guard);
6856
- const saved = await this.db.findGlobal({ slug: slug2 });
6857
- const stored = saved && options.draft ? await this.globalDraft(config, saved) : saved;
6960
+ return this.readGlobal(config, this.globalKey(config, guard), guard, options);
6961
+ }
6962
+ /** A global as stored under `key` (see `globalKey`); empty when there is no key. */
6963
+ async readGlobal(config, key2, guard, options) {
6964
+ const saved = key2 ? await this.db.findGlobal({ slug: key2 }) : null;
6965
+ const stored = saved && options.draft ? await this.globalDraft(config, saved, key2) : saved;
6858
6966
  const data = fillMissing(
6859
6967
  config.fields,
6860
6968
  applyDefaults(config.fields, stored ?? {}, this.config.localization)
@@ -6867,6 +6975,27 @@ var EasyCMS = class {
6867
6975
  const { id: _id, ...doc } = out;
6868
6976
  return doc;
6869
6977
  }
6978
+ /**
6979
+ * Where a global is kept: its slug, or `<slug>@<scope>` for globals with `scope` (e.g. one per
6980
+ * tenant). `null` when the call has no scope: reads give the global empty. `scope` is given by
6981
+ * scheduled jobs, which have no request.
6982
+ */
6983
+ globalKey(config, guard, scope) {
6984
+ if (scope !== void 0) return `${config.slug}@${scope}`;
6985
+ if (!config.scope) return config.slug;
6986
+ const own = config.scope({ context: guard.context, user: guard.user });
6987
+ if (own === void 0) return config.slug;
6988
+ return own === null ? null : `${config.slug}@${own}`;
6989
+ }
6990
+ /** `globalKey` for changes, which need a scope. */
6991
+ writeKey(config, guard, scope) {
6992
+ const key2 = this.globalKey(config, guard, scope);
6993
+ if (key2 === null)
6994
+ throw new QueryError(
6995
+ `"${config.slug}" has a value per scope (e.g. per tenant): choose one to change it`
6996
+ );
6997
+ return key2;
6998
+ }
6870
6999
  async updateGlobal(slug2, data, options = {}) {
6871
7000
  return this.saveGlobal(slug2, asObject(data, slug2), options);
6872
7001
  }
@@ -6880,20 +7009,21 @@ var EasyCMS = class {
6880
7009
  const config = this.global(slug2);
6881
7010
  const guard = guardOf(options);
6882
7011
  await this.checkGlobalAccess(config, "update", guard);
6883
- const saved = await this.db.findGlobal({ slug: slug2 });
6884
- if (saved && await this.globalDraft(config, saved) !== saved) {
6885
- await this.saveVersion(config, globalParent(slug2), 0, saved, guard);
7012
+ const key2 = this.writeKey(config, guard);
7013
+ const saved = await this.db.findGlobal({ slug: key2 });
7014
+ if (saved && await this.globalDraft(config, saved, key2) !== saved) {
7015
+ await this.saveVersion(config, globalParent(key2), 0, saved, guard);
6886
7016
  }
6887
7017
  return this.findGlobal(slug2, { ...options, draft: true });
6888
7018
  }
6889
7019
  /** Saved versions of a global, newest first. Needs update access. */
6890
7020
  async findGlobalVersions(slug2, options = {}) {
6891
- await this.globalVersionTarget(slug2, options);
6892
- return this.versions.list(globalParent(slug2), 0, options.limit ?? 20, options.page ?? 1);
7021
+ const { key: key2 } = await this.globalVersionTarget(slug2, options);
7022
+ return this.versions.list(globalParent(key2), 0, options.limit ?? 20, options.page ?? 1);
6893
7023
  }
6894
7024
  async findGlobalVersion(slug2, versionId, options = {}) {
6895
- const config = await this.globalVersionTarget(slug2, options);
6896
- const version = await this.versions.get(globalParent(slug2), 0, versionId);
7025
+ const { config, key: key2 } = await this.globalVersionTarget(slug2, options);
7026
+ const version = await this.versions.get(globalParent(key2), 0, versionId);
6897
7027
  if (!version) return null;
6898
7028
  const data = fillMissing(
6899
7029
  config.fields,
@@ -6908,8 +7038,8 @@ var EasyCMS = class {
6908
7038
  }
6909
7039
  /** Makes an old version of a global current (as a draft when the global has drafts). */
6910
7040
  async restoreGlobalVersion(slug2, versionId, options = {}) {
6911
- const config = await this.globalVersionTarget(slug2, options);
6912
- const version = await this.versions.get(globalParent(slug2), 0, versionId);
7041
+ const { config, key: key2 } = await this.globalVersionTarget(slug2, options);
7042
+ const version = await this.versions.get(globalParent(key2), 0, versionId);
6913
7043
  if (!version) throw new NotFoundError(`${slug2} version`, versionId);
6914
7044
  const { status: _status, ...data } = version.data;
6915
7045
  return this.saveGlobal(
@@ -6922,20 +7052,22 @@ var EasyCMS = class {
6922
7052
  async globalVersionTarget(slug2, options) {
6923
7053
  const config = this.global(slug2);
6924
7054
  if (!versionLimit(config)) throw new QueryError(`"${slug2}" has no versions`);
6925
- await this.checkGlobalAccess(config, "update", guardOf(options));
6926
- return config;
7055
+ const guard = guardOf(options);
7056
+ await this.checkGlobalAccess(config, "update", guard);
7057
+ return { config, key: this.writeKey(config, guard) };
6927
7058
  }
6928
7059
  /** The global's pending draft when it has one (see `withDrafts`), otherwise `saved`. */
6929
- async globalDraft(config, saved) {
6930
- if (!this.separateDrafts(config) || saved.status !== "published") return saved;
6931
- const version = (await this.versions.latest(globalParent(config.slug), [0])).get("0");
7060
+ async globalDraft(config, saved, key2) {
7061
+ if (!key2 || !this.separateDrafts(config) || saved.status !== "published") return saved;
7062
+ const version = (await this.versions.latest(globalParent(key2), [0])).get("0");
6932
7063
  if (version?.status !== "draft") return saved;
6933
7064
  return { ...version.data, updatedAt: version.createdAt, status: "draft" };
6934
7065
  }
6935
- async saveGlobal(slug2, raw, options, mode = "save") {
7066
+ async saveGlobal(slug2, raw, options, mode = "save", scope) {
6936
7067
  const config = this.global(slug2);
6937
7068
  const guard = guardOf(options);
6938
7069
  await this.checkGlobalAccess(config, "update", guard);
7070
+ const key2 = this.writeKey(config, guard, scope);
6939
7071
  if (config.drafts && (mode === "unpublish" || raw.status === "published"))
6940
7072
  await this.checkGrant(guard, { global: slug2 }, "publish");
6941
7073
  const input = await filterInput(
@@ -6943,8 +7075,8 @@ var EasyCMS = class {
6943
7075
  raw,
6944
7076
  await this.fieldChecker("update", guard, void 0, raw)
6945
7077
  );
6946
- const existing = await this.db.findGlobal({ slug: slug2 }) ?? {};
6947
- const current = await this.globalDraft(config, existing);
7078
+ const existing = await this.db.findGlobal({ slug: key2 }) ?? {};
7079
+ const current = await this.globalDraft(config, existing, key2);
6948
7080
  const localized = mode === "restore" ? input : this.toMaps(config, input, current, options);
6949
7081
  const merged = generateSlugs(
6950
7082
  config.fields,
@@ -6957,7 +7089,7 @@ var EasyCMS = class {
6957
7089
  );
6958
7090
  if (config.drafts) merged.status = input.status ?? current.status ?? "draft";
6959
7091
  const base = this.hookArgs(config, guard);
6960
- let prepared = await this.prepare(config, merged, "update", void 0, options, guard.user);
7092
+ let prepared = await this.prepare(config, merged, "update", void 0, options, guard);
6961
7093
  prepared = await this.transform(
6962
7094
  config.hooks?.beforeChange,
6963
7095
  "data",
@@ -6967,7 +7099,7 @@ var EasyCMS = class {
6967
7099
  const now = (/* @__PURE__ */ new Date()).toISOString();
6968
7100
  if (mode !== "unpublish" && this.separateDrafts(config) && existing.status === "published" && prepared.status === "draft") {
6969
7101
  const draft = { ...prepared, updatedAt: now };
6970
- await this.saveVersion(config, globalParent(slug2), 0, draft, guard);
7102
+ await this.saveVersion(config, globalParent(key2), 0, draft, guard);
6971
7103
  await this.notify(config.hooks?.afterChange, "afterChange", slug2, {
6972
7104
  ...base,
6973
7105
  doc: draft,
@@ -6976,10 +7108,10 @@ var EasyCMS = class {
6976
7108
  });
6977
7109
  this.emit(config, "draft", draft);
6978
7110
  await this.audited(config, "draft", draft, current, guard, mode);
6979
- return this.findGlobal(slug2, { ...options, draft: true });
7111
+ return this.readGlobal(config, key2, guard, { ...options, draft: true });
6980
7112
  }
6981
- const doc = await this.db.updateGlobal({ slug: slug2, data: { ...prepared, updatedAt: now } });
6982
- await this.saveVersion(config, globalParent(slug2), 0, doc, guard);
7113
+ const doc = await this.db.updateGlobal({ slug: key2, data: { ...prepared, updatedAt: now } });
7114
+ await this.saveVersion(config, globalParent(key2), 0, doc, guard);
6983
7115
  await this.notify(config.hooks?.afterChange, "afterChange", slug2, {
6984
7116
  ...base,
6985
7117
  doc,
@@ -6988,17 +7120,17 @@ var EasyCMS = class {
6988
7120
  });
6989
7121
  this.emit(config, "update", doc, existing.status);
6990
7122
  await this.audited(config, "update", doc, current, guard, mode);
6991
- return this.findGlobal(slug2, { ...options, draft: true });
7123
+ return this.readGlobal(config, key2, guard, { ...options, draft: true });
6992
7124
  }
6993
7125
  /**
6994
7126
  * What `user` may do with one document. Resolves `where`-style access
6995
7127
  * against the document, so the admin UI can hide actions precisely.
6996
7128
  */
6997
- async documentPermissions(collection2, id, user) {
7129
+ async documentPermissions(collection2, id, user, context = NO_CONTEXT) {
6998
7130
  const config = this.collection(collection2);
6999
7131
  const parsed = parseId(id);
7000
7132
  if (parsed === void 0) return { update: false, delete: false };
7001
- const guard = { enforce: true, user };
7133
+ const guard = { enforce: true, user, context };
7002
7134
  const check2 = (operation) => this.checkDocumentAccess(config, operation, guard, parsed, void 0).then(
7003
7135
  () => true,
7004
7136
  (error) => {
@@ -7166,9 +7298,10 @@ var EasyCMS = class {
7166
7298
  const action = row.action;
7167
7299
  try {
7168
7300
  if (parent.startsWith("global:")) {
7169
- const slug2 = parent.slice("global:".length);
7170
- if (action === "publish") await this.saveGlobal(slug2, { status: "published" }, {});
7171
- else await this.saveGlobal(slug2, { status: "draft" }, {}, "unpublish");
7301
+ const { slug: slug2, scope } = splitGlobalKey(parent.slice("global:".length));
7302
+ if (action === "publish")
7303
+ await this.saveGlobal(slug2, { status: "published" }, {}, "save", scope);
7304
+ else await this.saveGlobal(slug2, { status: "draft" }, {}, "unpublish", scope);
7172
7305
  } else if (action === "publish") {
7173
7306
  await this.updateDocument(parent, row.doc, { status: "published" }, { depth: 0 });
7174
7307
  } else {
@@ -7213,16 +7346,16 @@ var EasyCMS = class {
7213
7346
  await this.removeJob(collection2, parsed, jobId);
7214
7347
  }
7215
7348
  async scheduleGlobal(slug2, job, options = {}) {
7216
- await this.globalScheduleTarget(slug2, options);
7217
- return this.addJob(`global:${slug2}`, 0, job, options);
7349
+ const key2 = await this.globalScheduleTarget(slug2, options);
7350
+ return this.addJob(`global:${key2}`, 0, job, options);
7218
7351
  }
7219
7352
  async scheduledGlobal(slug2, options = {}) {
7220
- await this.globalScheduleTarget(slug2, options);
7221
- return this.pendingJobs(`global:${slug2}`, 0);
7353
+ const key2 = await this.globalScheduleTarget(slug2, options);
7354
+ return this.pendingJobs(`global:${key2}`, 0);
7222
7355
  }
7223
7356
  async cancelGlobalSchedule(slug2, jobId, options = {}) {
7224
- await this.globalScheduleTarget(slug2, options);
7225
- await this.removeJob(`global:${slug2}`, 0, jobId);
7357
+ const key2 = await this.globalScheduleTarget(slug2, options);
7358
+ await this.removeJob(`global:${key2}`, 0, jobId);
7226
7359
  }
7227
7360
  async scheduleTarget(collection2, id, options) {
7228
7361
  const config = this.collection(collection2);
@@ -7237,8 +7370,10 @@ var EasyCMS = class {
7237
7370
  async globalScheduleTarget(slug2, options) {
7238
7371
  const config = this.global(slug2);
7239
7372
  if (!config.schedule) throw new QueryError(`"${slug2}" has no schedule`);
7240
- await this.checkGlobalAccess(config, "update", guardOf(options));
7241
- await this.checkGrant(guardOf(options), { global: slug2 }, "publish");
7373
+ const guard = guardOf(options);
7374
+ await this.checkGlobalAccess(config, "update", guard);
7375
+ await this.checkGrant(guard, { global: slug2 }, "publish");
7376
+ return this.writeKey(config, guard);
7242
7377
  }
7243
7378
  async addJob(parent, doc, job, options) {
7244
7379
  const runAt = new Date(job.at);
@@ -7295,9 +7430,16 @@ var EasyCMS = class {
7295
7430
  if (!guard.enforce) return true;
7296
7431
  const cached = allowed2.get(parent);
7297
7432
  if (cached !== void 0) return cached;
7298
- const config = parent.startsWith("global:") ? this.config.globals.find((g) => g.slug === parent.slice("global:".length)) : this.config.collections.find((c) => c.slug === parent);
7299
- const access = config ? await evaluateAccess(config.access?.update, { user: guard.user }) : false;
7300
- const target = parent.startsWith("global:") ? { global: parent.slice("global:".length) } : { collection: parent };
7433
+ const global2 = parent.startsWith("global:") ? this.config.globals.find(
7434
+ (g) => g.slug === splitGlobalKey(parent.slice("global:".length)).slug
7435
+ ) : void 0;
7436
+ if (global2 && this.globalKey(global2, guard) !== parent.slice("global:".length)) {
7437
+ allowed2.set(parent, false);
7438
+ return false;
7439
+ }
7440
+ const config = parent.startsWith("global:") ? global2 : this.config.collections.find((c) => c.slug === parent);
7441
+ const access = config ? await evaluateAccess(config.access?.update, { user: guard.user, context: guard.context }) : false;
7442
+ const target = parent.startsWith("global:") ? { global: splitGlobalKey(parent.slice("global:".length)).slug } : { collection: parent };
7301
7443
  const result = access !== false && await this.roles.allows(guard.user, target, "update") !== false;
7302
7444
  allowed2.set(parent, result);
7303
7445
  return result;
@@ -7306,7 +7448,7 @@ var EasyCMS = class {
7306
7448
  for (const row of rows.docs) {
7307
7449
  const parent = String(row.parent);
7308
7450
  if (!await may2(parent)) continue;
7309
- const target = parent.startsWith("global:") ? { global: parent.slice("global:".length) } : { collection: parent };
7451
+ const target = parent.startsWith("global:") ? { global: splitGlobalKey(parent.slice("global:".length)).slug } : { collection: parent };
7310
7452
  jobs.push({ ...toJob(row), ...target, doc: row.doc });
7311
7453
  if (jobs.length === limit) break;
7312
7454
  }
@@ -7418,7 +7560,10 @@ var EasyCMS = class {
7418
7560
  async readWhere(config, guard, where) {
7419
7561
  if (!guard.enforce) return where;
7420
7562
  const own = await this.checkGrant(guard, { collection: config.slug }, "read");
7421
- const access = await evaluateAccess(config.access?.read, { user: guard.user });
7563
+ const access = await evaluateAccess(config.access?.read, {
7564
+ user: guard.user,
7565
+ context: guard.context
7566
+ });
7422
7567
  if (access === false) throw deny(guard.user);
7423
7568
  await this.checkQueryFields(config, guard, where);
7424
7569
  return andWhere(andWhere(where, access), own);
@@ -7428,6 +7573,7 @@ var EasyCMS = class {
7428
7573
  const own = await this.checkGrant(guard, { collection: config.slug }, operation);
7429
7574
  const allowed2 = await evaluateAccess(config.access?.[operation], {
7430
7575
  user: guard.user,
7576
+ context: guard.context,
7431
7577
  id,
7432
7578
  ...data ? { data } : {}
7433
7579
  });
@@ -7445,7 +7591,10 @@ var EasyCMS = class {
7445
7591
  async checkGlobalAccess(config, operation, guard) {
7446
7592
  if (!guard.enforce) return;
7447
7593
  await this.checkGrant(guard, { global: config.slug }, operation);
7448
- const access = await evaluateAccess(config.access?.[operation], { user: guard.user });
7594
+ const access = await evaluateAccess(config.access?.[operation], {
7595
+ user: guard.user,
7596
+ context: guard.context
7597
+ });
7449
7598
  if (typeof access === "object")
7450
7599
  throw new QueryError(`${operation} access of global "${config.slug}" must return a boolean`);
7451
7600
  if (!access) throw deny(guard.user);
@@ -7456,6 +7605,7 @@ var EasyCMS = class {
7456
7605
  kind,
7457
7606
  {
7458
7607
  user: guard.user,
7608
+ context: guard.context,
7459
7609
  ...id !== void 0 ? { id } : {},
7460
7610
  ...data ? { data } : {}
7461
7611
  },
@@ -7536,7 +7686,11 @@ var EasyCMS = class {
7536
7686
  await this.checkGrant(guard, { collection: collection2 }, "create");
7537
7687
  if (config.drafts && raw.status === "published")
7538
7688
  await this.checkGrant(guard, { collection: collection2 }, "publish");
7539
- const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data: raw });
7689
+ const allowed2 = await evaluateAccess(config.access?.create, {
7690
+ user: guard.user,
7691
+ context: guard.context,
7692
+ data: raw
7693
+ });
7540
7694
  if (typeof allowed2 === "object")
7541
7695
  throw new QueryError(`create access of "${collection2}" must return a boolean`);
7542
7696
  if (!allowed2) throw deny(guard.user);
@@ -7568,7 +7722,7 @@ var EasyCMS = class {
7568
7722
  "create",
7569
7723
  void 0,
7570
7724
  options,
7571
- (hookGuard ?? guard).user
7725
+ hookGuard ?? guard
7572
7726
  );
7573
7727
  prepared = await this.transform(
7574
7728
  config.hooks?.beforeChange,
@@ -7689,7 +7843,8 @@ var EasyCMS = class {
7689
7843
  action,
7690
7844
  event === "delete" ? void 0 : doc,
7691
7845
  event === "create" ? void 0 : previous,
7692
- guard.user
7846
+ guard.user,
7847
+ guard.context
7693
7848
  );
7694
7849
  }
7695
7850
  /** Records a version after a save, when the collection or global keeps versions. */
@@ -7706,7 +7861,12 @@ var EasyCMS = class {
7706
7861
  });
7707
7862
  }
7708
7863
  hookArgs(config, guard) {
7709
- return { user: guard.user, cms: this, slug: config.slug };
7864
+ return {
7865
+ user: guard.user,
7866
+ cms: this,
7867
+ slug: config.slug,
7868
+ context: guard.context
7869
+ };
7710
7870
  }
7711
7871
  /**
7712
7872
  * Runs hooks that may replace a value, passed as `data` (before hooks) or `doc` (afterRead).
@@ -7768,7 +7928,7 @@ var EasyCMS = class {
7768
7928
  return result;
7769
7929
  }
7770
7930
  /** Validates, checks uniqueness and references. Returns clean data or throws `ValidationError`. */
7771
- async prepare(config, data, operation, selfId, options = {}, user = null) {
7931
+ async prepare(config, data, operation, selfId, options = {}, guard = { user: null, context: NO_CONTEXT }) {
7772
7932
  const isDraft = config.drafts === true && (data.status ?? "draft") === "draft";
7773
7933
  const locale = this.localeOf(options);
7774
7934
  const result = await validateFields(config.fields, data, {
@@ -7794,9 +7954,9 @@ var EasyCMS = class {
7794
7954
  await this.makeSlugsUnique(config, clean2, selfId);
7795
7955
  errors.push(...await this.checkUnique(config, clean2, selfId));
7796
7956
  }
7797
- errors.push(...await this.checkReferences(result.references));
7957
+ errors.push(...await this.checkReferences(result.references, guard));
7798
7958
  if (errors.length === 0)
7799
- errors.push(...await this.checkFilterOptions(result.references, selfId, user));
7959
+ errors.push(...await this.checkFilterOptions(result.references, selfId, guard));
7800
7960
  if (errors.length > 0) throw new ValidationError(config.slug, errors);
7801
7961
  return clean2;
7802
7962
  }
@@ -7824,7 +7984,11 @@ var EasyCMS = class {
7824
7984
  async makeSlugsUnique(config, data, selfId) {
7825
7985
  for (const field of config.fields) {
7826
7986
  if (field.type !== "slug") continue;
7827
- const scope = field.uniqueWithin === void 0 ? void 0 : { [field.uniqueWithin]: { equals: data[field.uniqueWithin] ?? null } };
7987
+ const scope = field.uniqueWithin === void 0 ? void 0 : {
7988
+ and: uniqueWithinOf(field).map((name) => ({
7989
+ [name]: { equals: data[name] ?? null }
7990
+ }))
7991
+ };
7828
7992
  const unique = async (path, base) => {
7829
7993
  let candidate = base;
7830
7994
  for (let n = 2; await this.isTaken(config.slug, path, candidate, selfId, scope); n++) {
@@ -7855,9 +8019,14 @@ var EasyCMS = class {
7855
8019
  locale === this.config.localization?.defaultLocale ? field.name : `${field.name}.${locale}`,
7856
8020
  v
7857
8021
  ]) : [[field.name, field.name, value]];
8022
+ const scope = field.uniqueWithin === void 0 ? void 0 : {
8023
+ and: uniqueWithinOf(field).map((name) => ({
8024
+ [name]: { equals: data[name] ?? null }
8025
+ }))
8026
+ };
7858
8027
  for (const [path, errorField, v] of checks) {
7859
8028
  if (v === null || v === void 0) continue;
7860
- if (await this.isTaken(config.slug, path, v, selfId)) {
8029
+ if (await this.isTaken(config.slug, path, v, selfId, scope)) {
7861
8030
  errors.push({ field: errorField, message: "must be unique" });
7862
8031
  }
7863
8032
  }
@@ -7874,15 +8043,57 @@ var EasyCMS = class {
7874
8043
  };
7875
8044
  return await this.db.count({ collection: collection2, where }) > 0;
7876
8045
  }
8046
+ /**
8047
+ * What folder keys are unique within for a call (`uniqueWithin` of the `key` field of media
8048
+ * folders, e.g. the tenant): the value a folder made by this call would get from its hooks.
8049
+ */
8050
+ async folderScope(guard) {
8051
+ return this.uniqueScope(MEDIA_FOLDERS, "key", guard);
8052
+ }
8053
+ /**
8054
+ * Where a unique field's values must differ for a call (`uniqueWithin`, e.g. the tenant): the
8055
+ * value a document created by this call would get there from its hooks, as `{ [field]: value }`;
8056
+ * `{}` when the field is unique everywhere. For finding documents by such a field, e.g. a page
8057
+ * by its path in the current tenant.
8058
+ */
8059
+ async uniqueScope(collection2, field, options = {}) {
8060
+ const config = this.config.collections.find((c) => c.slug === collection2);
8061
+ const target = config?.fields.find((f) => f.name === field);
8062
+ const within = target ? uniqueWithinOf(target) : [];
8063
+ if (!config || within.length === 0) return {};
8064
+ const guard = {
8065
+ enforce: false,
8066
+ user: options.user ?? null,
8067
+ context: options.context ?? NO_CONTEXT
8068
+ };
8069
+ const data = await this.transform(
8070
+ config.hooks?.beforeValidate,
8071
+ "data",
8072
+ { ...this.hookArgs(config, guard), operation: "create" },
8073
+ {}
8074
+ ).catch(() => ({}));
8075
+ return Object.fromEntries(within.map((name) => [name, data[name] ?? null]));
8076
+ }
8077
+ /**
8078
+ * @internal The id of the media folder with this key (`folder` of upload fields), made when
8079
+ * needed; `null` when the call has no scope to make it in (e.g. all tenants) and none exists.
8080
+ */
8081
+ async keyedFolder(key2, options = {}) {
8082
+ const scope = await this.folderScope(guardOf(options));
8083
+ const complete = Object.values(scope).every((v) => v !== null && v !== void 0);
8084
+ return this.folders.keyed(key2, scope, complete);
8085
+ }
7877
8086
  /** Each reference is one its relationship's `filterOptions` allow. */
7878
- async checkFilterOptions(references2, selfId, user) {
8087
+ async checkFilterOptions(references2, selfId, guard) {
7879
8088
  const errors = [];
7880
8089
  const cms = this;
8090
+ const { user, context } = guard;
7881
8091
  const allowed2 = /* @__PURE__ */ new Map();
7882
8092
  for (const ref of references2) {
7883
8093
  const filter = ref.filterOptions;
7884
8094
  if (!filter) continue;
7885
- if (!allowed2.has(filter)) allowed2.set(filter, await filter({ id: selfId, user, cms }));
8095
+ if (!allowed2.has(filter))
8096
+ allowed2.set(filter, await filter({ id: selfId, user, cms, context }));
7886
8097
  const where = allowed2.get(filter);
7887
8098
  if (where === true) continue;
7888
8099
  const target = this.config.collections.find((c) => c.slug === ref.collection);
@@ -7897,7 +8108,7 @@ var EasyCMS = class {
7897
8108
  }
7898
8109
  return errors;
7899
8110
  }
7900
- async checkReferences(references2) {
8111
+ async checkReferences(references2, guard) {
7901
8112
  const byCollection = /* @__PURE__ */ new Map();
7902
8113
  for (const ref of references2) {
7903
8114
  if (!this.config.collections.some((c) => c.slug === ref.collection)) continue;
@@ -7926,7 +8137,8 @@ var EasyCMS = class {
7926
8137
  }
7927
8138
  if (ref.folderOnly && this.folders.enabled && !await this.folders.inKeyed(
7928
8139
  ref.folderOnly,
7929
- doc.folder ?? null
8140
+ doc.folder ?? null,
8141
+ await this.folderScope(guard)
7930
8142
  ))
7931
8143
  errors.push({
7932
8144
  field: ref.field,
@@ -7968,8 +8180,21 @@ function wherePaths(where) {
7968
8180
  }
7969
8181
  return out;
7970
8182
  }
8183
+ var NO_CONTEXT = Object.freeze({});
8184
+ function withoutContext(permissions) {
8185
+ const { context: _context, ...rest } = permissions ?? {};
8186
+ return rest;
8187
+ }
8188
+ function splitGlobalKey(key2) {
8189
+ const at = key2.indexOf("@");
8190
+ return at < 0 ? { slug: key2 } : { slug: key2.slice(0, at), scope: key2.slice(at + 1) };
8191
+ }
7971
8192
  function guardOf(options) {
7972
- return { enforce: options.overrideAccess === false, user: options.user ?? null };
8193
+ return {
8194
+ enforce: options.overrideAccess === false,
8195
+ user: options.user ?? null,
8196
+ context: options.context ?? NO_CONTEXT
8197
+ };
7973
8198
  }
7974
8199
  function deny(user) {
7975
8200
  return user ? new ForbiddenError() : new UnauthorizedError();
@@ -8256,10 +8481,10 @@ function componentRef(component) {
8256
8481
  if (typeof component === "string") return { tag: component };
8257
8482
  return component.props ? { tag: component.tag, props: JSON.parse(JSON.stringify(component.props)) } : { tag: component.tag };
8258
8483
  }
8259
- async function allowed(access, user) {
8260
- return await evaluateAccess(access, { user }) !== false;
8484
+ async function allowed(access, user, context) {
8485
+ return await evaluateAccess(access, { user, context }) !== false;
8261
8486
  }
8262
- async function serializeFields(fields, checks, localized, folderId) {
8487
+ async function serializeFields(fields, checks, localized, folderId, who) {
8263
8488
  const { update: update2 } = checks;
8264
8489
  const out = [];
8265
8490
  for (const field of fields) {
@@ -8269,6 +8494,13 @@ async function serializeFields(fields, checks, localized, folderId) {
8269
8494
  if (field.required) f.required = true;
8270
8495
  if (field.unique) f.unique = true;
8271
8496
  if (field.defaultValue !== void 0) f.defaultValue = field.defaultValue;
8497
+ if (field.admin?.defaultValue) {
8498
+ const value = await field.admin.defaultValue(who);
8499
+ if (value !== void 0) f.defaultValue = value;
8500
+ }
8501
+ const column = field.admin?.column;
8502
+ if (column === true || typeof column === "function" && await column(who)) f.column = true;
8503
+ if (field.admin?.allowCreate === false) f.noCreate = true;
8272
8504
  if (!await update2.allows(field)) f.readOnly = true;
8273
8505
  if (field.localized && localized) f.localized = true;
8274
8506
  if (field.position === "sidebar") f.position = "sidebar";
@@ -8309,16 +8541,19 @@ async function serializeFields(fields, checks, localized, folderId) {
8309
8541
  case "upload":
8310
8542
  f.to = "media";
8311
8543
  if (field.hasMany) f.hasMany = true;
8544
+ if (field.filterOptions) f.filtered = true;
8312
8545
  if (field.minRows !== void 0) f.minRows = field.minRows;
8313
8546
  if (field.maxRows !== void 0) f.maxRows = field.maxRows;
8314
8547
  if (field.mimeTypes) f.mimeTypes = expandMimeTypes(field.mimeTypes);
8315
- if (field.folder && folderId)
8316
- f.folder = { id: await folderId(field.folder), only: field.folderOnly === true };
8548
+ if (field.folder && folderId) {
8549
+ const id = await folderId(field.folder);
8550
+ if (id !== null) f.folder = { id, only: field.folderOnly === true };
8551
+ }
8317
8552
  break;
8318
8553
  case "array":
8319
8554
  if (field.minRows !== void 0) f.minRows = field.minRows;
8320
8555
  if (field.maxRows !== void 0) f.maxRows = field.maxRows;
8321
- f.fields = await serializeFields(field.fields, checks, localized, folderId);
8556
+ f.fields = await serializeFields(field.fields, checks, localized, folderId, who);
8322
8557
  break;
8323
8558
  case "blocks":
8324
8559
  if (field.minRows !== void 0) f.minRows = field.minRows;
@@ -8327,35 +8562,36 @@ async function serializeFields(fields, checks, localized, folderId) {
8327
8562
  field.blocks.map(async (block) => ({
8328
8563
  slug: block.slug,
8329
8564
  ...block.labels ? { labels: block.labels } : {},
8330
- fields: await serializeFields(block.fields, checks, localized, folderId)
8565
+ fields: await serializeFields(block.fields, checks, localized, folderId, who)
8331
8566
  }))
8332
8567
  );
8333
8568
  break;
8334
8569
  case "group":
8335
- f.fields = await serializeFields(field.fields, checks, localized, folderId);
8570
+ f.fields = await serializeFields(field.fields, checks, localized, folderId, who);
8336
8571
  break;
8337
8572
  }
8338
8573
  out.push(f);
8339
8574
  }
8340
8575
  return out;
8341
8576
  }
8342
- async function checkers(cms, user) {
8577
+ async function checkers(cms, user, context) {
8343
8578
  const rules = await cms.roles.fieldRules(user);
8344
8579
  return {
8345
- update: new FieldAccessChecker("update", { user }, rules),
8346
- read: new FieldAccessChecker("read", { user }, rules)
8580
+ update: new FieldAccessChecker("update", { user, context }, rules),
8581
+ read: new FieldAccessChecker("read", { user, context }, rules)
8347
8582
  };
8348
8583
  }
8349
- async function may(cms, access, user, target, operation) {
8350
- return await allowed(access, user) && await cms.roles.allows(user, target, operation) !== false;
8584
+ async function may(cms, access, user, target, operation, context) {
8585
+ return await allowed(access, user, context) && await cms.roles.allows(user, target, operation) !== false;
8351
8586
  }
8352
- async function collection(cms, config, user, localized, roles) {
8587
+ async function collection(cms, config, user, localized, roles, context) {
8353
8588
  const target = { collection: config.slug };
8354
8589
  const fields = await serializeFields(
8355
8590
  config.fields,
8356
- await checkers(cms, user),
8591
+ await checkers(cms, user, context),
8357
8592
  localized,
8358
- folderIds(cms)
8593
+ folderIds(cms, user, context),
8594
+ { user, context }
8359
8595
  );
8360
8596
  if (config.slug === USERS && cms.roles.enabled) {
8361
8597
  const role = fields.find((f) => f.name === "role");
@@ -8372,11 +8608,11 @@ async function collection(cms, config, user, localized, roles) {
8372
8608
  schedule: config.schedule === true,
8373
8609
  fields,
8374
8610
  permissions: {
8375
- read: await may(cms, config.access?.read, user, target, "read"),
8376
- create: await may(cms, config.access?.create, user, target, "create"),
8377
- update: await may(cms, config.access?.update, user, target, "update"),
8378
- delete: await may(cms, config.access?.delete, user, target, "delete"),
8379
- publish: config.drafts === true && await may(cms, config.access?.update, user, target, "publish")
8611
+ read: await may(cms, config.access?.read, user, target, "read", context),
8612
+ create: await may(cms, config.access?.create, user, target, "create", context),
8613
+ update: await may(cms, config.access?.update, user, target, "update", context),
8614
+ delete: await may(cms, config.access?.delete, user, target, "delete", context),
8615
+ publish: config.drafts === true && await may(cms, config.access?.update, user, target, "publish", context)
8380
8616
  }
8381
8617
  };
8382
8618
  if (config.labels) result.labels = config.labels;
@@ -8386,26 +8622,30 @@ async function collection(cms, config, user, localized, roles) {
8386
8622
  if (config.admin?.group === "settings" || SETTINGS.has(config.slug)) result.group = "settings";
8387
8623
  if (config.admin?.list?.tree) result.tree = config.admin.list.tree;
8388
8624
  if (config.admin?.list?.sort) result.defaultSort = config.admin.list.sort;
8625
+ if (config.admin?.confirmDelete) result.confirmDelete = { ...config.admin.confirmDelete };
8389
8626
  const owner = cms.roles.ownerOf(config.slug);
8390
8627
  if (owner && fields.some((f) => f.name === owner)) result.owner = owner;
8391
8628
  if (config.editIn === "drawer" && !result.drafts && !result.versions && !result.preview)
8392
8629
  result.editIn = "drawer";
8393
8630
  return result;
8394
8631
  }
8395
- async function global(cms, config, user, localized) {
8632
+ async function global(cms, config, user, localized, context) {
8396
8633
  const target = { global: config.slug };
8397
- const checks = await checkers(cms, user);
8634
+ const checks = await checkers(cms, user, context);
8398
8635
  const result = {
8399
8636
  slug: config.slug,
8400
8637
  drafts: config.drafts === true,
8401
8638
  versions: Boolean(config.versions),
8402
8639
  preview: typeof config.preview === "function",
8403
8640
  schedule: config.schedule === true,
8404
- fields: await serializeFields(config.fields, checks, localized, folderIds(cms)),
8641
+ fields: await serializeFields(config.fields, checks, localized, folderIds(cms, user, context), {
8642
+ user,
8643
+ context
8644
+ }),
8405
8645
  permissions: {
8406
- read: await may(cms, config.access?.read, user, target, "read"),
8407
- update: await may(cms, config.access?.update, user, target, "update"),
8408
- publish: config.drafts === true && await may(cms, config.access?.update, user, target, "publish")
8646
+ read: await may(cms, config.access?.read, user, target, "read", context),
8647
+ update: await may(cms, config.access?.update, user, target, "update", context),
8648
+ publish: config.drafts === true && await may(cms, config.access?.update, user, target, "publish", context)
8409
8649
  }
8410
8650
  };
8411
8651
  if (config.label !== void 0) result.label = config.label;
@@ -8413,11 +8653,11 @@ async function global(cms, config, user, localized) {
8413
8653
  if (config.admin?.sidebar?.length) result.sidebar = config.admin.sidebar.map(componentRef);
8414
8654
  return result;
8415
8655
  }
8416
- async function adminSchema(cms, user, origin) {
8656
+ async function adminSchema(cms, user, origin, context = {}) {
8417
8657
  const collections = cms.config.collections.filter((c) => !INTERNAL_COLLECTIONS.has(c.slug));
8418
8658
  const localization = cms.config.localization;
8419
8659
  const localized = localization !== null;
8420
- const admin = user.role === "admin" && !user.apiKey;
8660
+ const admin = isSystemAdmin(user);
8421
8661
  const deliveries = cms.config.collections.some(
8422
8662
  (c) => c.slug === WEBHOOK_DELIVERIES || c.slug === EMAIL_DELIVERIES
8423
8663
  );
@@ -8433,13 +8673,16 @@ async function adminSchema(cms, user, origin) {
8433
8673
  const roles = await cms.roles.options();
8434
8674
  return {
8435
8675
  locale: cms.config.admin.locale,
8676
+ switcher: cms.config.admin.switcher,
8436
8677
  passwordLinks: cms.auth.canSendPasswordLinks(origin),
8437
8678
  menu: [...cms.config.admin.menu],
8438
8679
  localization: localization ? { locales: [...localization.locales], defaultLocale: localization.defaultLocale } : null,
8439
8680
  collections: await Promise.all(
8440
- collections.map((c) => collection(cms, c, user, localized, roles))
8681
+ collections.map((c) => collection(cms, c, user, localized, roles, context))
8682
+ ),
8683
+ globals: await Promise.all(
8684
+ cms.config.globals.map((g) => global(cms, g, user, localized, context))
8441
8685
  ),
8442
- globals: await Promise.all(cms.config.globals.map((g) => global(cms, g, user, localized))),
8443
8686
  modules: adminModuleUrls(cms),
8444
8687
  uploadFromURL: cms.config.upload.fromURL !== void 0,
8445
8688
  upload: {
@@ -8499,12 +8742,12 @@ async function widgets(cms, user) {
8499
8742
  }
8500
8743
  return out;
8501
8744
  }
8502
- function folderIds(cms) {
8503
- return cms.folders.enabled ? (key2) => cms.folders.keyed(key2) : void 0;
8745
+ function folderIds(cms, user, context) {
8746
+ return cms.folders.enabled ? (key2) => cms.keyedFolder(key2, { user, context }) : void 0;
8504
8747
  }
8505
8748
 
8506
8749
  // src/version.ts
8507
- var VERSION = true ? "0.43.0" : "0.0.0-dev";
8750
+ var VERSION = true ? "0.45.0" : "0.0.0-dev";
8508
8751
 
8509
8752
  // src/rest/admin-status.ts
8510
8753
  var DAY2 = 864e5;
@@ -8592,7 +8835,7 @@ function fieldAt(fields, path) {
8592
8835
  }
8593
8836
  return found;
8594
8837
  }
8595
- async function pickerFilter(cms, url, target, user) {
8838
+ async function pickerFilter(cms, url, target, user, context = {}) {
8596
8839
  const forCollection = url.searchParams.get("filterFor");
8597
8840
  const forGlobal = url.searchParams.get("filterForGlobal");
8598
8841
  const spec = forCollection ?? forGlobal;
@@ -8601,12 +8844,13 @@ async function pickerFilter(cms, url, target, user) {
8601
8844
  const slug2 = dot > 0 ? spec.slice(0, dot) : "";
8602
8845
  const owner = forCollection !== null ? cms.config.collections.find((c) => c.slug === slug2) : cms.config.globals.find((g) => g.slug === slug2);
8603
8846
  const field = owner ? fieldAt(owner.fields, spec.slice(dot + 1)) : void 0;
8604
- if (field?.type !== "relationship" || field.to !== target)
8847
+ const to = field?.type === "upload" ? "media" : field?.type === "relationship" ? field.to : null;
8848
+ if (!field || field.type !== "relationship" && field.type !== "upload" || to !== target)
8605
8849
  throw new QueryError(`filterFor: no relationship to "${target}" at "${spec}"`);
8606
8850
  if (!field.filterOptions) return void 0;
8607
8851
  const rawId = url.searchParams.get("filterId");
8608
8852
  const id = rawId === null || rawId === "" ? void 0 : /^\d+$/.test(rawId) ? Number(rawId) : rawId;
8609
- const where = await field.filterOptions({ id, user, cms });
8853
+ const where = await field.filterOptions({ id, user, cms, context });
8610
8854
  return where === true ? void 0 : where;
8611
8855
  }
8612
8856
 
@@ -8766,14 +9010,24 @@ async function respond(cms, request, url, path, root, options, production) {
8766
9010
  if (path === null) throw new HttpError("Not found", 404);
8767
9011
  const segments = pathSegments(path);
8768
9012
  const { token, via } = readToken(request);
8769
- const user = token ? await cms.auth.verify(token) : null;
8770
- if (!user && via === "bearer" && token?.startsWith("ecms_"))
9013
+ const signedIn = token ? await cms.auth.verify(token) : null;
9014
+ if (!signedIn && via === "bearer" && token?.startsWith("ecms_"))
8771
9015
  throw new UnauthorizedError("Invalid or expired API key");
8772
- const ctx = { request, url, user, via: user ? via : null, token, headers };
9016
+ const { user, context } = await cms.applyOnRequest(request.headers, url, signedIn);
9017
+ const ctx = {
9018
+ request,
9019
+ url,
9020
+ user,
9021
+ context,
9022
+ via: signedIn ? via : null,
9023
+ token,
9024
+ headers
9025
+ };
8773
9026
  const method = request.method.toUpperCase();
8774
9027
  if (method !== "GET" && method !== "HEAD") checkCsrf(cms, ctx);
8775
9028
  const audit = {
8776
9029
  user,
9030
+ context,
8777
9031
  ip: options.getClientIp?.(request),
8778
9032
  userAgent: request.headers.get("user-agent")
8779
9033
  };
@@ -8833,6 +9087,7 @@ async function customEndpoint(cms, ctx, method, segments, root, options) {
8833
9087
  url: ctx.url,
8834
9088
  params,
8835
9089
  user: ctx.user,
9090
+ context: ctx.context,
8836
9091
  ip: options.getClientIp?.(ctx.request),
8837
9092
  cms,
8838
9093
  json: () => readJson(ctx.request)
@@ -8859,7 +9114,12 @@ function matchPath(path, segments) {
8859
9114
  }
8860
9115
  async function route(cms, ctx, method, segments, options) {
8861
9116
  const [first, second, third] = segments;
8862
- const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
9117
+ const access = {
9118
+ overrideAccess: false,
9119
+ user: ctx.user,
9120
+ context: ctx.context,
9121
+ ...parseLocale(ctx.url)
9122
+ };
8863
9123
  const draft = ctx.user !== null && ctx.url.searchParams.get("draft") === "true";
8864
9124
  if (segments.length === 0) throw new HttpError("Not found", 404);
8865
9125
  if (first === "auth") return ssoRoute(cms, ctx, method, segments);
@@ -8957,14 +9217,14 @@ async function route(cms, ctx, method, segments, options) {
8957
9217
  const secret = cms.config.cronSecret ?? process.env.CRON_SECRET;
8958
9218
  const header = ctx.request.headers.get("authorization") ?? "";
8959
9219
  const byCron = !!secret && safeEqual(header, `Bearer ${secret}`);
8960
- if (!byCron && ctx.user?.role !== "admin") {
9220
+ if (!byCron && !isSystemAdmin(ctx.user)) {
8961
9221
  throw ctx.user ? new HttpError("Forbidden", 403) : new UnauthorizedError();
8962
9222
  }
8963
9223
  return { body: await cms.runJobs() };
8964
9224
  }
8965
9225
  if (first === "admin" && second === "backups") {
8966
9226
  if (!ctx.user) throw new UnauthorizedError();
8967
- if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
9227
+ if (!isSystemAdmin(ctx.user)) throw new ForbiddenError();
8968
9228
  const [, , id2, action, extra] = segments;
8969
9229
  if (extra !== void 0) throw new HttpError("Not found", 404);
8970
9230
  if (id2 === void 0) {
@@ -9006,7 +9266,7 @@ async function route(cms, ctx, method, segments, options) {
9006
9266
  }
9007
9267
  if (first === "admin" && second === "email") {
9008
9268
  if (!ctx.user) throw new UnauthorizedError();
9009
- if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
9269
+ if (!isSystemAdmin(ctx.user)) throw new ForbiddenError();
9010
9270
  const [, , action, extra] = segments;
9011
9271
  if (extra !== void 0) throw new HttpError("Not found", 404);
9012
9272
  if (action === void 0) {
@@ -9025,7 +9285,7 @@ async function route(cms, ctx, method, segments, options) {
9025
9285
  }
9026
9286
  if (first === "admin" && second === "sso" && third === void 0) {
9027
9287
  if (!ctx.user) throw new UnauthorizedError();
9028
- if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
9288
+ if (!isSystemAdmin(ctx.user)) throw new ForbiddenError();
9029
9289
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
9030
9290
  return { body: cms.auth.sso.settings(ctx.url.origin) };
9031
9291
  }
@@ -9040,8 +9300,11 @@ async function route(cms, ctx, method, segments, options) {
9040
9300
  doc: q.get("doc"),
9041
9301
  actor: q.get("actor"),
9042
9302
  from: q.get("from"),
9043
- to: q.get("to")
9303
+ to: q.get("to"),
9304
+ // Users of a part of the site (e.g. one tenant) see its entries; others the chosen part's.
9305
+ scope: cms.audit.scopeOf(ctx.context)
9044
9306
  };
9307
+ if (ctx.user.scoped && !filter.scope) throw new ForbiddenError();
9045
9308
  if (second === "audit.csv" && third === void 0) {
9046
9309
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
9047
9310
  return {
@@ -9066,7 +9329,7 @@ async function route(cms, ctx, method, segments, options) {
9066
9329
  }
9067
9330
  if (first === "admin" && second === "owned" && third !== void 0 && segments.length === 3) {
9068
9331
  if (!ctx.user) throw new UnauthorizedError();
9069
- if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
9332
+ if (!isSystemAdmin(ctx.user)) throw new ForbiddenError();
9070
9333
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
9071
9334
  const parsed = parseId(third);
9072
9335
  if (parsed === void 0) throw new HttpError("Not found", 404);
@@ -9080,7 +9343,7 @@ async function route(cms, ctx, method, segments, options) {
9080
9343
  }
9081
9344
  if (first === "admin" && second === "roles") {
9082
9345
  if (!ctx.user) throw new UnauthorizedError();
9083
- if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
9346
+ if (!isSystemAdmin(ctx.user)) throw new ForbiddenError();
9084
9347
  if (!cms.roles.enabled) throw new HttpError("Not found", 404);
9085
9348
  const [, , id2, action, extra] = segments;
9086
9349
  if (extra !== void 0) throw new HttpError("Not found", 404);
@@ -9166,7 +9429,7 @@ async function route(cms, ctx, method, segments, options) {
9166
9429
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
9167
9430
  if (!ctx.user) throw new UnauthorizedError();
9168
9431
  if (second === "schema" && third === void 0)
9169
- return { body: await adminSchema(cms, ctx.user, ctx.url.origin) };
9432
+ return { body: await adminSchema(cms, ctx.user, ctx.url.origin, ctx.context) };
9170
9433
  if (second === "modules" && third !== void 0 && segments.length === 3) {
9171
9434
  const index = /^(\d+)\.js$/.exec(third)?.[1];
9172
9435
  const file = index === void 0 ? void 0 : await readAdminModule(cms, Number(index));
@@ -9186,13 +9449,19 @@ async function route(cms, ctx, method, segments, options) {
9186
9449
  return { body: await adminStatus(cms) };
9187
9450
  }
9188
9451
  if (second === "scheduled" && third === void 0)
9189
- return { body: await cms.upcomingJobs({ user: ctx.user, overrideAccess: false }) };
9452
+ return {
9453
+ body: await cms.upcomingJobs({
9454
+ user: ctx.user,
9455
+ context: ctx.context,
9456
+ overrideAccess: false
9457
+ })
9458
+ };
9190
9459
  const [, , collection3, id2, extra] = segments;
9191
9460
  if (second === "access" && collection3 && id2 && extra === void 0) {
9192
9461
  if (INTERNAL_COLLECTIONS.has(collection3) || !cms.config.collections.some((c) => c.slug === collection3)) {
9193
9462
  throw new HttpError(`Unknown collection "${collection3}"`, 404);
9194
9463
  }
9195
- return { body: await cms.documentPermissions(collection3, id2, ctx.user) };
9464
+ return { body: await cms.documentPermissions(collection3, id2, ctx.user, ctx.context) };
9196
9465
  }
9197
9466
  throw new HttpError("Not found", 404);
9198
9467
  }
@@ -9297,7 +9566,7 @@ async function route(cms, ctx, method, segments, options) {
9297
9566
  if (second === void 0) {
9298
9567
  if (method === "GET") {
9299
9568
  const query = parseListQuery(ctx.url);
9300
- const filter = await pickerFilter(cms, ctx.url, collection2, ctx.user);
9569
+ const filter = await pickerFilter(cms, ctx.url, collection2, ctx.user, ctx.context);
9301
9570
  if (filter) query.where = query.where ? { and: [query.where, filter] } : filter;
9302
9571
  return { body: await cms.find(collection2, { ...access, ...query, draft }) };
9303
9572
  }
@@ -9342,12 +9611,17 @@ async function route(cms, ctx, method, segments, options) {
9342
9611
  throw methodNotAllowed(ctx, "GET, PATCH, DELETE");
9343
9612
  }
9344
9613
  async function documentAction(cms, ctx, method, collection2, id, path) {
9345
- const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
9614
+ const access = {
9615
+ overrideAccess: false,
9616
+ user: ctx.user,
9617
+ context: ctx.context,
9618
+ ...parseLocale(ctx.url)
9619
+ };
9346
9620
  const depth = parseDepth(ctx.url);
9347
9621
  const [action, versionId, extra] = path;
9348
9622
  if (collection2 === USERS && action === "password-link" && path.length === 1) {
9349
9623
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
9350
- if (ctx.user?.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
9624
+ if (!isSystemAdmin(ctx.user)) throw new ForbiddenError();
9351
9625
  const body = await readJson(ctx.request).catch(() => ({}));
9352
9626
  const sent = await cms.auth.sendPasswordLink(/^\d+$/.test(id) ? Number(id) : id, {
9353
9627
  origin: ctx.url.origin,
@@ -9400,7 +9674,12 @@ async function documentAction(cms, ctx, method, collection2, id, path) {
9400
9674
  throw new HttpError("Not found", 404);
9401
9675
  }
9402
9676
  async function globalAction(cms, ctx, method, slug2, path) {
9403
- const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
9677
+ const access = {
9678
+ overrideAccess: false,
9679
+ user: ctx.user,
9680
+ context: ctx.context,
9681
+ ...parseLocale(ctx.url)
9682
+ };
9404
9683
  const depth = parseDepth(ctx.url);
9405
9684
  const [action, versionId, extra] = path;
9406
9685
  if (action === "versions" && versionId === void 0) {
@@ -9563,6 +9842,7 @@ async function servePrivateFile(cms, ctx, key2, head) {
9563
9842
  limit: 5,
9564
9843
  depth: 0,
9565
9844
  user: ctx.user,
9845
+ context: ctx.context,
9566
9846
  overrideAccess: false
9567
9847
  });
9568
9848
  const owns = found.docs.some(
@@ -9663,7 +9943,7 @@ async function ssoRoute(cms, ctx, method, segments) {
9663
9943
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
9664
9944
  const asked = ctx.url.searchParams.get("user");
9665
9945
  const target = asked === null ? ctx.user.id : parseId(asked) ?? asked;
9666
- if (String(target) !== String(ctx.user.id) && (ctx.user.role !== "admin" || ctx.user.apiKey))
9946
+ if (String(target) !== String(ctx.user.id) && !isSystemAdmin(ctx.user))
9667
9947
  throw new ForbiddenError();
9668
9948
  return { body: await sso.identities(target) };
9669
9949
  }
@@ -10061,6 +10341,7 @@ export {
10061
10341
  isAdmin,
10062
10342
  isHasMany,
10063
10343
  isLoggedIn,
10344
+ isSystemAdmin,
10064
10345
  keyAllows,
10065
10346
  loadConfig,
10066
10347
  localStorage,
@@ -10078,6 +10359,7 @@ export {
10078
10359
  singularize,
10079
10360
  slugify,
10080
10361
  sniffMimeType,
10362
+ uniqueWithinOf,
10081
10363
  validateConfig,
10082
10364
  validateFields,
10083
10365
  verifyPassword,