@easy-cms/core 0.32.0 → 0.34.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 +241 -6
  2. package/dist/index.js +1000 -226
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -253,14 +253,19 @@ function andWhere(where, constraint) {
253
253
  return where === void 0 ? constraint : { and: [where, constraint] };
254
254
  }
255
255
  var FieldAccessChecker = class {
256
- constructor(kind, args) {
256
+ /** `rules`: the user's role's rules for top-level fields (`auth.rbac`), on top of `access`. */
257
+ constructor(kind, args, rules) {
257
258
  this.kind = kind;
258
259
  this.args = args;
260
+ this.rules = rules;
259
261
  }
260
262
  kind;
261
263
  args;
264
+ rules;
262
265
  cache = /* @__PURE__ */ new Map();
263
266
  async allows(field) {
267
+ const rule = this.rules?.get(field);
268
+ if (rule === "hidden" || rule === "read" && this.kind === "update") return false;
264
269
  const fn = field.access?.[this.kind];
265
270
  if (!fn) return true;
266
271
  let allowed2 = this.cache.get(field);
@@ -326,6 +331,7 @@ var SCHEDULED_JOBS = "scheduled-jobs";
326
331
  var WEBHOOK_DELIVERIES = "webhook-deliveries";
327
332
  var EMAIL_DELIVERIES = "email-deliveries";
328
333
  var DATABASE_BACKUPS = "database-backups";
334
+ var ROLES = "user-roles";
329
335
  var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
330
336
  SESSIONS,
331
337
  LOGIN_ATTEMPTS,
@@ -333,9 +339,27 @@ var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
333
339
  SCHEDULED_JOBS,
334
340
  WEBHOOK_DELIVERIES,
335
341
  EMAIL_DELIVERIES,
336
- DATABASE_BACKUPS
342
+ DATABASE_BACKUPS,
343
+ ROLES
337
344
  ]);
338
345
  var DEFAULT_ROLES = ["admin", "editor"];
346
+ var CREATED_BY_FIELD = {
347
+ name: "createdBy",
348
+ type: "relationship",
349
+ to: USERS,
350
+ position: "sidebar",
351
+ access: { update: () => false },
352
+ label: { en: "Created by", th: "\u0E2A\u0E23\u0E49\u0E32\u0E07\u0E42\u0E14\u0E22" }
353
+ };
354
+ function withCreatedBy(config) {
355
+ if (config.auth?.rbac !== true) return config;
356
+ return {
357
+ ...config,
358
+ collections: (config.collections ?? []).map(
359
+ (c) => c.slug === USERS || c.fields?.some((f) => f.name === CREATED_BY_FIELD.name) ? c : { ...c, fields: [...c.fields, CREATED_BY_FIELD] }
360
+ )
361
+ };
362
+ }
339
363
  var adminOrSelf = ({ user }) => {
340
364
  if (!user) return false;
341
365
  if (user.role === "admin") return true;
@@ -344,7 +368,8 @@ var adminOrSelf = ({ user }) => {
344
368
  var adminOnly = {
345
369
  update: ({ user }) => user?.role === "admin"
346
370
  };
347
- function userFields(roles) {
371
+ function userFields(roles, rbac) {
372
+ const defaultRole = roles.includes("editor") ? "editor" : roles[roles.length - 1];
348
373
  return [
349
374
  {
350
375
  name: "email",
@@ -354,12 +379,20 @@ function userFields(roles) {
354
379
  label: { en: "Email", th: "\u0E2D\u0E35\u0E40\u0E21\u0E25" }
355
380
  },
356
381
  { name: "name", type: "text", label: { en: "Name", th: "\u0E0A\u0E37\u0E48\u0E2D" } },
357
- {
382
+ // With roles from the admin (`auth.rbac`), any role in Settings → Roles: checked on save.
383
+ rbac ? {
384
+ name: "role",
385
+ type: "text",
386
+ required: true,
387
+ defaultValue: defaultRole,
388
+ access: adminOnly,
389
+ label: { en: "Role", th: "\u0E1A\u0E17\u0E1A\u0E32\u0E17" }
390
+ } : {
358
391
  name: "role",
359
392
  type: "select",
360
393
  options: roles,
361
394
  required: true,
362
- defaultValue: roles.includes("editor") ? "editor" : roles[roles.length - 1],
395
+ defaultValue: defaultRole,
363
396
  access: adminOnly,
364
397
  label: { en: "Role", th: "\u0E1A\u0E17\u0E1A\u0E32\u0E17" }
365
398
  },
@@ -382,7 +415,7 @@ function withUsers(config) {
382
415
  useAsTitle: "email",
383
416
  icon: "users",
384
417
  ...custom,
385
- fields: [...userFields(roles), ...custom?.fields ?? []],
418
+ fields: [...userFields(roles, config.auth?.rbac === true), ...custom?.fields ?? []],
386
419
  access: {
387
420
  read: isLoggedIn,
388
421
  create: isAdmin,
@@ -1350,6 +1383,7 @@ async function runBackup(cms, record) {
1350
1383
  const filename = filenameFor(cms, started);
1351
1384
  const key2 = await storage.get(filename) ? filename.replace(/\.db\.gz$/, `-${row.id}.db.gz`) : filename;
1352
1385
  await storage.put(key2, data, { contentType: "application/gzip" });
1386
+ await pruneBackups(cms, 1);
1353
1387
  row = await update(cms, row, {
1354
1388
  state: "done",
1355
1389
  filename: key2,
@@ -1357,7 +1391,6 @@ async function runBackup(cms, record) {
1357
1391
  finishedAt: (/* @__PURE__ */ new Date()).toISOString()
1358
1392
  });
1359
1393
  cms.logger.info(`Backup ${key2} written (${data.byteLength} bytes)`);
1360
- await pruneBackups(cms);
1361
1394
  } catch (error) {
1362
1395
  await update(cms, row, {
1363
1396
  state: "failed",
@@ -1369,8 +1402,8 @@ async function runBackup(cms, record) {
1369
1402
  await rm2(dir, { recursive: true, force: true });
1370
1403
  }
1371
1404
  }
1372
- async function pruneBackups(cms) {
1373
- const keep = cms.config.backups?.keep ?? DEFAULT_BACKUP_KEEP;
1405
+ async function pruneBackups(cms, room = 0) {
1406
+ const keep = Math.max(0, (cms.config.backups?.keep ?? DEFAULT_BACKUP_KEEP) - room);
1374
1407
  const storage = await backupStorage(cms);
1375
1408
  const { docs } = await cms.db.find({
1376
1409
  collection: DATABASE_BACKUPS,
@@ -1690,7 +1723,7 @@ function parseId(value) {
1690
1723
  async function validateFields(fields, input, options, prefix = "") {
1691
1724
  const data = {};
1692
1725
  const errors = [];
1693
- const references = [];
1726
+ const references2 = [];
1694
1727
  for (const field of fields) {
1695
1728
  const path = prefix + field.name;
1696
1729
  const raw = input[field.name];
@@ -1708,7 +1741,7 @@ async function validateFields(fields, input, options, prefix = "") {
1708
1741
  );
1709
1742
  data[field.name] = sub.data;
1710
1743
  errors.push(...sub.errors);
1711
- references.push(...sub.references);
1744
+ references2.push(...sub.references);
1712
1745
  continue;
1713
1746
  }
1714
1747
  const localization = options.localization;
@@ -1738,7 +1771,7 @@ async function validateFields(fields, input, options, prefix = "") {
1738
1771
  options,
1739
1772
  failLocale,
1740
1773
  errors,
1741
- references
1774
+ references2
1742
1775
  );
1743
1776
  if (normalized === void 0) continue;
1744
1777
  out[locale] = normalized;
@@ -1755,12 +1788,12 @@ async function validateFields(fields, input, options, prefix = "") {
1755
1788
  if (!field.required) await runCustom(field, null, options, fail);
1756
1789
  continue;
1757
1790
  }
1758
- const value = await normalizeValue(field, raw, path, options, fail, errors, references);
1791
+ const value = await normalizeValue(field, raw, path, options, fail, errors, references2);
1759
1792
  if (value === void 0) continue;
1760
1793
  data[field.name] = value;
1761
1794
  await runCustom(field, value, options, fail);
1762
1795
  }
1763
- return { data, errors, references };
1796
+ return { data, errors, references: references2 };
1764
1797
  }
1765
1798
  async function runCustom(field, value, options, fail) {
1766
1799
  if (!field.validate) return;
@@ -1768,7 +1801,7 @@ async function runCustom(field, value, options, fail) {
1768
1801
  const result = await validate(value, { data: options.root, operation: options.operation });
1769
1802
  if (result !== true) fail(typeof result === "string" ? result : "is invalid");
1770
1803
  }
1771
- async function normalizeValue(field, raw, path, options, fail, errors, references) {
1804
+ async function normalizeValue(field, raw, path, options, fail, errors, references2) {
1772
1805
  switch (field.type) {
1773
1806
  case "text":
1774
1807
  case "textarea":
@@ -1830,7 +1863,7 @@ async function normalizeValue(field, raw, path, options, fail, errors, reference
1830
1863
  if (id === void 0)
1831
1864
  return fail(`must be ${many ? "ids" : "an id"} of "${target}" documents`);
1832
1865
  ids.push(id);
1833
- references.push({
1866
+ references2.push({
1834
1867
  field: path,
1835
1868
  collection: target,
1836
1869
  id,
@@ -1854,19 +1887,19 @@ async function normalizeValue(field, raw, path, options, fail, errors, reference
1854
1887
  }
1855
1888
  const sub = await validateFields(field.fields, row, options, `${path}.${i}.`);
1856
1889
  errors.push(...sub.errors);
1857
- references.push(...sub.references);
1890
+ references2.push(...sub.references);
1858
1891
  const id = typeof row.id === "string" && row.id !== "" ? row.id : randomUUID();
1859
1892
  rows.push({ id, ...sub.data });
1860
1893
  }
1861
1894
  return rows;
1862
1895
  }
1863
1896
  case "blocks":
1864
- return normalizeBlocks(field, raw, path, options, fail, errors, references);
1897
+ return normalizeBlocks(field, raw, path, options, fail, errors, references2);
1865
1898
  case "group":
1866
1899
  return raw;
1867
1900
  }
1868
1901
  }
1869
- async function normalizeBlocks(field, raw, path, options, fail, errors, references) {
1902
+ async function normalizeBlocks(field, raw, path, options, fail, errors, references2) {
1870
1903
  if (!Array.isArray(raw)) return fail("must be an array of blocks");
1871
1904
  if (field.minRows !== void 0 && raw.length < field.minRows)
1872
1905
  return fail(`must have at least ${field.minRows} blocks`);
@@ -1884,7 +1917,7 @@ async function normalizeBlocks(field, raw, path, options, fail, errors, referenc
1884
1917
  }
1885
1918
  const sub = await validateFields(fields, row, options, `${path}.${i}.`);
1886
1919
  errors.push(...sub.errors);
1887
- references.push(...sub.references);
1920
+ references2.push(...sub.references);
1888
1921
  const id = typeof row.id === "string" && row.id !== "" ? row.id : randomUUID();
1889
1922
  rows.push({ id, blockType: row.blockType, ...sub.data });
1890
1923
  }
@@ -2168,6 +2201,681 @@ import { existsSync } from "fs";
2168
2201
  import { resolve as resolve2 } from "path";
2169
2202
  import { createJiti } from "jiti";
2170
2203
 
2204
+ // src/versions.ts
2205
+ var DEFAULT_MAX_VERSIONS = 50;
2206
+ function versionLimit(config) {
2207
+ const versions = config.versions;
2208
+ if (!versions) return void 0;
2209
+ return versions === true ? DEFAULT_MAX_VERSIONS : versions.max ?? DEFAULT_MAX_VERSIONS;
2210
+ }
2211
+ var collectionParent = (slug2) => slug2;
2212
+ var globalParent = (slug2) => `global:${slug2}`;
2213
+ function snapshotOf(fields, doc) {
2214
+ const hidden = new Set(fields.filter((f) => f.hidden).map((f) => f.name));
2215
+ const snapshot = {};
2216
+ for (const [key2, value] of Object.entries(doc)) {
2217
+ if (key2 === "id" || key2 === "createdAt" || key2 === "updatedAt" || hidden.has(key2)) continue;
2218
+ snapshot[key2] = value;
2219
+ }
2220
+ return snapshot;
2221
+ }
2222
+ var toSummary = (row) => ({
2223
+ id: row.id,
2224
+ status: row.status ?? null,
2225
+ latest: row.latest === true,
2226
+ author: row.author ?? null,
2227
+ createdAt: String(row.createdAt)
2228
+ });
2229
+ var VersionStore = class {
2230
+ constructor(db) {
2231
+ this.db = db;
2232
+ }
2233
+ db;
2234
+ /** Saves a new latest version and deletes the oldest beyond `max`. */
2235
+ async save(args) {
2236
+ const { parent, doc } = args;
2237
+ const previous = await this.db.find({
2238
+ collection: VERSIONS,
2239
+ where: { and: [...this.of(parent, doc), { latest: { equals: true } }] },
2240
+ sort: [],
2241
+ limit: 0,
2242
+ page: 1
2243
+ });
2244
+ for (const row of previous.docs) {
2245
+ await this.db.update({ collection: VERSIONS, id: row.id, data: { ...row, latest: false } });
2246
+ }
2247
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2248
+ const created = await this.db.create({
2249
+ collection: VERSIONS,
2250
+ data: {
2251
+ parent,
2252
+ doc,
2253
+ status: args.status,
2254
+ latest: true,
2255
+ author: args.author,
2256
+ snapshot: args.snapshot,
2257
+ createdAt: now,
2258
+ updatedAt: now
2259
+ }
2260
+ });
2261
+ await this.prune(parent, doc, args.max);
2262
+ return toSummary(created);
2263
+ }
2264
+ /** The latest version of each document, keyed by document id. */
2265
+ async latest(parent, docs) {
2266
+ const result = /* @__PURE__ */ new Map();
2267
+ if (docs.length === 0) return result;
2268
+ const rows = await this.db.find({
2269
+ collection: VERSIONS,
2270
+ where: {
2271
+ and: [
2272
+ { parent: { equals: parent } },
2273
+ { doc: { in: [...docs] } },
2274
+ { latest: { equals: true } }
2275
+ ]
2276
+ },
2277
+ sort: [],
2278
+ limit: 0,
2279
+ page: 1
2280
+ });
2281
+ for (const row of rows.docs) result.set(String(row.doc), this.toVersion(row));
2282
+ return result;
2283
+ }
2284
+ async list(parent, doc, limit, page) {
2285
+ const found = await this.db.find({
2286
+ collection: VERSIONS,
2287
+ where: { and: this.of(parent, doc) },
2288
+ sort: ["-id"],
2289
+ limit,
2290
+ page
2291
+ });
2292
+ return { ...found, docs: found.docs.map(toSummary) };
2293
+ }
2294
+ async get(parent, doc, id) {
2295
+ const found = await this.db.find({
2296
+ collection: VERSIONS,
2297
+ where: { and: [...this.of(parent, doc), { id: { equals: id } }] },
2298
+ sort: [],
2299
+ limit: 1,
2300
+ page: 1
2301
+ });
2302
+ const row = found.docs[0];
2303
+ return row ? this.toVersion(row) : null;
2304
+ }
2305
+ async deleteAll(parent, doc) {
2306
+ const rows = await this.db.find({
2307
+ collection: VERSIONS,
2308
+ where: { and: this.of(parent, doc) },
2309
+ sort: [],
2310
+ limit: 0,
2311
+ page: 1
2312
+ });
2313
+ for (const row of rows.docs) await this.db.delete({ collection: VERSIONS, id: row.id });
2314
+ }
2315
+ async prune(parent, doc, max) {
2316
+ const rows = await this.db.find({
2317
+ collection: VERSIONS,
2318
+ where: { and: this.of(parent, doc) },
2319
+ sort: ["-id"],
2320
+ limit: 0,
2321
+ page: 1
2322
+ });
2323
+ for (const row of rows.docs.slice(max)) {
2324
+ await this.db.delete({ collection: VERSIONS, id: row.id });
2325
+ }
2326
+ }
2327
+ of(parent, doc) {
2328
+ return [{ parent: { equals: parent } }, { doc: { equals: doc } }];
2329
+ }
2330
+ toVersion(row) {
2331
+ return { ...toSummary(row), data: row.snapshot ?? {} };
2332
+ }
2333
+ };
2334
+
2335
+ // src/roles.ts
2336
+ var ROLE_OPERATIONS = ["read", "create", "update", "delete", "publish"];
2337
+ var GLOBAL_OPERATIONS = ["read", "update", "publish"];
2338
+ var OWN_OPERATIONS = ["read", "update", "delete", "publish"];
2339
+ var KEY = /^[a-z][a-z0-9-]{0,31}$/;
2340
+ var HISTORY = 50;
2341
+ var TTL = 3e4;
2342
+ var rolesCollection = {
2343
+ slug: ROLES,
2344
+ access: { read: () => false, create: () => false, update: () => false, delete: () => false },
2345
+ fields: [
2346
+ { name: "key", type: "text", required: true, unique: true },
2347
+ { name: "name", type: "text" },
2348
+ { name: "permissions", type: "json" }
2349
+ ]
2350
+ };
2351
+ function governed(slug2) {
2352
+ return !INTERNAL_COLLECTIONS.has(slug2) && slug2 !== API_KEYS;
2353
+ }
2354
+ function ruleFields(fields) {
2355
+ return fields.filter((f) => !f.hidden && f.name !== "createdBy");
2356
+ }
2357
+ function references(fields, out = /* @__PURE__ */ new Set()) {
2358
+ for (const field of fields) {
2359
+ if (field.type === "relationship") out.add(field.to);
2360
+ else if (field.type === "upload") out.add("media");
2361
+ else if (field.type === "group" || field.type === "array") references(field.fields, out);
2362
+ else if (field.type === "blocks") for (const b of field.blocks) references(b.fields, out);
2363
+ }
2364
+ return out;
2365
+ }
2366
+ var viewId = {
2367
+ page: (path) => `page:${path}`,
2368
+ widget: (tag) => `widget:${tag}`
2369
+ };
2370
+ var tagOf = (component) => typeof component === "string" ? component : component.tag;
2371
+ var Roles = class {
2372
+ constructor(config, db, log) {
2373
+ this.config = config;
2374
+ this.db = db;
2375
+ this.log = log;
2376
+ this.enabled = config.auth.rbac;
2377
+ this.versions = new VersionStore(db);
2378
+ }
2379
+ config;
2380
+ db;
2381
+ log;
2382
+ enabled;
2383
+ versions;
2384
+ cache;
2385
+ synced;
2386
+ /**
2387
+ * Whether the user's role allows an operation: `true`, `false`, or for their own account
2388
+ * (users without read or update on `users`) a constraint to it.
2389
+ */
2390
+ async allows(user, target, operation) {
2391
+ if (!this.enabled || !user || user.role === "admin") return true;
2392
+ if ("collection" in target && !governed(target.collection)) return true;
2393
+ const permissions = await this.permissionsOf(user.role);
2394
+ const ops = "collection" in target ? permissions.collections?.[target.collection] : permissions.globals?.[target.global];
2395
+ if (ops?.includes(operation)) {
2396
+ if ("collection" in target && permissions.own?.[target.collection]?.includes(operation)) {
2397
+ const owner = this.ownerOf(target.collection);
2398
+ if (owner && operation !== "create") return { [owner]: { equals: user.id } };
2399
+ }
2400
+ return true;
2401
+ }
2402
+ if ("collection" in target && target.collection === USERS) {
2403
+ if (operation === "read" || operation === "update") return { id: { equals: user.id } };
2404
+ }
2405
+ return false;
2406
+ }
2407
+ /**
2408
+ * Whether the user may open an admin page: admins always; with roles, those whose role has
2409
+ * it; without roles, nobody else (`status`, `deliveries`) or everybody (plugin pages).
2410
+ */
2411
+ async canView(user, id) {
2412
+ if (user.role === "admin" && !user.apiKey) return true;
2413
+ if (user.apiKey) return false;
2414
+ if (!this.enabled) return id.startsWith("page:") || id.startsWith("widget:");
2415
+ return (await this.permissionsOf(user.role)).admin?.includes(id) ?? false;
2416
+ }
2417
+ /**
2418
+ * The field that names a collection's owner, for "own documents only": `admin.ownerField`, or
2419
+ * `createdBy`. `undefined` without roles, and for users and collections roles don't cover.
2420
+ */
2421
+ ownerOf(slug2) {
2422
+ if (!this.enabled || slug2 === USERS || !governed(slug2)) return void 0;
2423
+ const config = this.config.collections.find((c) => c.slug === slug2);
2424
+ if (!config) return void 0;
2425
+ return config.admin?.ownerField ?? "createdBy";
2426
+ }
2427
+ /**
2428
+ * The user's field rules (top-level fields their role may only read, or not see), for field
2429
+ * access checks. Roles that may update only their own documents can't change the owner field.
2430
+ */
2431
+ async fieldRules(user) {
2432
+ if (!this.enabled || !user || user.role === "admin") return void 0;
2433
+ await this.load();
2434
+ const cache = this.cache;
2435
+ const cached = cache?.rules.get(user.role);
2436
+ if (cached) return cached;
2437
+ const permissions = await this.permissionsOf(user.role);
2438
+ const rules = /* @__PURE__ */ new Map();
2439
+ const add = (fields, set) => {
2440
+ for (const field of fields) {
2441
+ const rule = set?.[field.name];
2442
+ if (rule === "read" || rule === "hidden") rules.set(field, rule);
2443
+ }
2444
+ };
2445
+ for (const c of this.config.collections) {
2446
+ if (!governed(c.slug)) continue;
2447
+ add(c.fields, permissions.fields?.collections?.[c.slug]);
2448
+ const ownerField = c.admin?.ownerField;
2449
+ if (ownerField && permissions.own?.[c.slug]?.includes("update")) {
2450
+ const field = c.fields.find((f) => f.name === ownerField);
2451
+ if (field && !rules.has(field)) rules.set(field, "read");
2452
+ }
2453
+ }
2454
+ for (const g of this.config.globals) add(g.fields, permissions.fields?.globals?.[g.slug]);
2455
+ cache?.rules.set(user.role, rules);
2456
+ return rules;
2457
+ }
2458
+ /** A new document is its creator's: sets `createdBy`, and an empty owner field. */
2459
+ fillOwner(slug2, data, user) {
2460
+ if (!user || !this.ownerOf(slug2)) return;
2461
+ data.createdBy ??= user.id;
2462
+ const ownerField = this.config.collections.find((c) => c.slug === slug2)?.admin?.ownerField;
2463
+ if (ownerField && (data[ownerField] === void 0 || data[ownerField] === null))
2464
+ data[ownerField] = user.id;
2465
+ }
2466
+ /** Documents a user owns, by collection (`createdBy` and owner fields), for deleting them. */
2467
+ async owned(userId) {
2468
+ const out = [];
2469
+ for (const c of this.config.collections) {
2470
+ const columns = this.ownerColumns(c.slug);
2471
+ if (columns.length === 0) continue;
2472
+ const count = await this.db.count({
2473
+ collection: c.slug,
2474
+ where: { or: columns.map((column) => ({ [column]: { equals: userId } })) }
2475
+ });
2476
+ if (count > 0) out.push({ collection: c.slug, count });
2477
+ }
2478
+ return out;
2479
+ }
2480
+ /** Gives a deleted user's documents to another user, or to nobody (`null`). */
2481
+ async transfer(from, to) {
2482
+ for (const c of this.config.collections) {
2483
+ for (const column of this.ownerColumns(c.slug))
2484
+ await this.setWhere(c.slug, { [column]: { equals: from } }, column, to);
2485
+ }
2486
+ }
2487
+ /** Forgets cached permissions, after a role changed. */
2488
+ invalidate() {
2489
+ this.cache = void 0;
2490
+ }
2491
+ /**
2492
+ * Adds the roles of `auth.roles` that are missing; on the first start, gives them everything.
2493
+ * Runs once, on first use (not on startup: `easy-cms migrate` opens the CMS before the table exists).
2494
+ */
2495
+ sync() {
2496
+ this.synced ??= this.addMissing().catch((error) => {
2497
+ this.synced = void 0;
2498
+ throw error;
2499
+ });
2500
+ return this.synced;
2501
+ }
2502
+ async addMissing() {
2503
+ if (!this.enabled) return;
2504
+ const rows = await this.rows();
2505
+ const first = rows.length === 0;
2506
+ const known = new Set(rows.map((r) => String(r.key)));
2507
+ for (const key2 of this.config.auth.roles) {
2508
+ if (known.has(key2)) continue;
2509
+ try {
2510
+ await this.insert(key2, "", first && key2 !== "admin" ? this.everything() : {}, null);
2511
+ } catch (error) {
2512
+ if (!(await this.rows()).some((r) => r.key === key2)) throw error;
2513
+ }
2514
+ }
2515
+ this.invalidate();
2516
+ await this.backfillCreators();
2517
+ }
2518
+ /**
2519
+ * Documents from before roles have no `createdBy`: it is filled from who saved their first
2520
+ * version, for collections with versions. Only while no document of the collection has one,
2521
+ * so it runs once (and finds nothing to do afterwards).
2522
+ */
2523
+ async backfillCreators() {
2524
+ for (const c of this.config.collections) {
2525
+ if (!c.versions || !this.ownerOf(c.slug)) continue;
2526
+ if (await this.db.count({ collection: c.slug, where: { createdBy: { exists: true } } }) > 0)
2527
+ continue;
2528
+ const versions = await this.db.find({
2529
+ collection: VERSIONS,
2530
+ where: { and: [{ parent: { equals: c.slug } }, { author: { exists: true } }] },
2531
+ sort: ["id"],
2532
+ limit: 0,
2533
+ page: 1
2534
+ });
2535
+ const first = /* @__PURE__ */ new Map();
2536
+ for (const v of versions.docs) {
2537
+ if (!first.has(v.doc) && v.author !== null) first.set(v.doc, v.author);
2538
+ }
2539
+ let filled = 0;
2540
+ for (const [doc, author] of first) {
2541
+ const row = await this.db.findById({ collection: c.slug, id: doc });
2542
+ if (!row || row.createdBy !== null && row.createdBy !== void 0) continue;
2543
+ const { id, ...rest } = row;
2544
+ await this.db.update({ collection: c.slug, id, data: { ...rest, createdBy: author } });
2545
+ filled++;
2546
+ }
2547
+ if (filled > 0) this.log?.(`Set who created ${filled} ${c.slug} from their history`);
2548
+ }
2549
+ }
2550
+ /** Whether a role exists (users can only be given existing roles). */
2551
+ async exists(key2) {
2552
+ if (!this.enabled) return this.config.auth.roles.includes(key2);
2553
+ return (await this.load()).has(key2);
2554
+ }
2555
+ /** Roles a user can be given, those of `auth.roles` first; `name` may be empty. */
2556
+ async options() {
2557
+ if (!this.enabled) return this.config.auth.roles.map((key2) => ({ key: key2, name: "" }));
2558
+ await this.sync();
2559
+ return (await this.rows()).map((row) => ({ key: String(row.key), name: typeof row.name === "string" ? row.name : "" })).sort((a, b) => rank(a, this.config) - rank(b, this.config) || a.key.localeCompare(b.key));
2560
+ }
2561
+ // --- Settings → Roles ----------------------------------------------------
2562
+ async list() {
2563
+ await this.sync();
2564
+ const rows = await this.rows();
2565
+ const counts = /* @__PURE__ */ new Map();
2566
+ for (const row of rows) {
2567
+ counts.set(
2568
+ String(row.key),
2569
+ await this.db.count({ collection: USERS, where: { role: { equals: row.key } } })
2570
+ );
2571
+ }
2572
+ const roles = rows.map((row) => this.toRole(row, counts.get(String(row.key)) ?? 0)).sort((a, b) => rank(a, this.config) - rank(b, this.config) || a.key.localeCompare(b.key));
2573
+ const decided = roles.filter((r) => r.key !== "admin").map((r) => r.permissions);
2574
+ const seen = (group, slug2) => decided.some((p) => p[group]?.[slug2] !== void 0);
2575
+ return {
2576
+ roles,
2577
+ collections: this.config.collections.filter((c) => governed(c.slug)).map((c) => {
2578
+ const owner = this.ownerOf(c.slug);
2579
+ return {
2580
+ slug: c.slug,
2581
+ ops: ROLE_OPERATIONS.filter((op) => op !== "publish" || c.drafts === true),
2582
+ new: !seen("collections", c.slug),
2583
+ references: [...references(ruleFields(c.fields))].filter(
2584
+ (s) => s !== c.slug && governed(s)
2585
+ ),
2586
+ fields: adminFields(c.fields, c.admin?.ownerField),
2587
+ ...owner ? { owner } : {}
2588
+ };
2589
+ }),
2590
+ globals: this.config.globals.map((g) => ({
2591
+ slug: g.slug,
2592
+ ops: GLOBAL_OPERATIONS.filter((op) => op !== "publish" || g.drafts === true),
2593
+ new: !seen("globals", g.slug),
2594
+ fields: adminFields(g.fields, void 0)
2595
+ })),
2596
+ views: this.views()
2597
+ };
2598
+ }
2599
+ async create(input, author) {
2600
+ await this.sync();
2601
+ const key2 = typeof input.key === "string" ? input.key.trim() : "";
2602
+ if (!KEY.test(key2)) {
2603
+ throw new ValidationError(ROLES, [
2604
+ { field: "key", message: 'lowercase letters, digits and "-", starting with a letter' }
2605
+ ]);
2606
+ }
2607
+ if ((await this.rows()).some((r) => r.key === key2))
2608
+ throw new ValidationError(ROLES, [{ field: "key", message: "is already used" }]);
2609
+ const row = await this.insert(
2610
+ key2,
2611
+ this.cleanName(input.name),
2612
+ this.clean(input.permissions),
2613
+ author.id
2614
+ );
2615
+ return this.toRole(row, 0);
2616
+ }
2617
+ async update(id, input, author) {
2618
+ const row = await this.row(id);
2619
+ const data = { ...row, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
2620
+ if (input.name !== void 0) data.name = this.cleanName(input.name);
2621
+ if (input.permissions !== void 0) {
2622
+ if (row.key === "admin")
2623
+ throw new EasyCMSError("Admins may always do everything; their role cannot be changed", 400);
2624
+ data.permissions = this.clean(input.permissions);
2625
+ }
2626
+ const { id: _id, ...rest } = data;
2627
+ const saved = await this.db.update({ collection: ROLES, id: row.id, data: rest });
2628
+ await this.record(saved, author.id);
2629
+ this.invalidate();
2630
+ return this.toRole(saved, await this.users(String(row.key)));
2631
+ }
2632
+ async delete(id) {
2633
+ const row = await this.row(id);
2634
+ const key2 = String(row.key);
2635
+ if (this.config.auth.roles.includes(key2))
2636
+ throw new EasyCMSError(`"${key2}" is in auth.roles and cannot be deleted`, 400);
2637
+ const users = await this.users(key2);
2638
+ if (users > 0)
2639
+ throw new EasyCMSError(`${users} user(s) have this role; give them another role first`, 409);
2640
+ await this.db.delete({ collection: ROLES, id: row.id });
2641
+ await this.versions.deleteAll(ROLES, row.id);
2642
+ this.invalidate();
2643
+ }
2644
+ async history(id) {
2645
+ const row = await this.row(id);
2646
+ const list = await this.versions.list(ROLES, row.id, HISTORY, 1);
2647
+ const emails = /* @__PURE__ */ new Map();
2648
+ const out = [];
2649
+ for (const summary of list.docs) {
2650
+ const version = await this.versions.get(ROLES, row.id, summary.id);
2651
+ if (!version) continue;
2652
+ let author = null;
2653
+ if (summary.author !== null) {
2654
+ const k = String(summary.author);
2655
+ if (!emails.has(k)) {
2656
+ const user = await this.db.findById({ collection: USERS, id: summary.author });
2657
+ emails.set(k, typeof user?.email === "string" ? user.email : null);
2658
+ }
2659
+ author = emails.get(k) ?? null;
2660
+ }
2661
+ out.push({
2662
+ id: summary.id,
2663
+ author,
2664
+ createdAt: summary.createdAt,
2665
+ name: String(version.data.name ?? ""),
2666
+ permissions: version.data.permissions ?? {}
2667
+ });
2668
+ }
2669
+ return out;
2670
+ }
2671
+ // -------------------------------------------------------------------------
2672
+ /** Pages and panels roles can be given. */
2673
+ views() {
2674
+ return [
2675
+ { id: "status" },
2676
+ ...this.config.collections.some(
2677
+ (c) => c.slug === WEBHOOK_DELIVERIES || c.slug === EMAIL_DELIVERIES
2678
+ ) ? [{ id: "deliveries" }] : [],
2679
+ ...this.config.admin.pages.map((page) => ({ id: viewId.page(page.path), label: page.label })),
2680
+ ...this.config.admin.dashboard.map((widget) => ({
2681
+ id: viewId.widget(tagOf(widget.component)),
2682
+ ...widget.label !== void 0 ? { label: widget.label } : {}
2683
+ }))
2684
+ ];
2685
+ }
2686
+ /** Every collection and global, and the plugin pages and panels: what roles had before roles. */
2687
+ everything() {
2688
+ const collections = {};
2689
+ for (const c of this.config.collections) {
2690
+ if (governed(c.slug))
2691
+ collections[c.slug] = ROLE_OPERATIONS.filter((op) => op !== "publish" || c.drafts === true);
2692
+ }
2693
+ const globals = {};
2694
+ for (const g of this.config.globals)
2695
+ globals[g.slug] = GLOBAL_OPERATIONS.filter((op) => op !== "publish" || g.drafts === true);
2696
+ const admin = this.views().map((v) => v.id).filter((id) => id.startsWith("page:") || id.startsWith("widget:"));
2697
+ return { collections, globals, admin };
2698
+ }
2699
+ /** Keeps only known collections, globals, operations and pages; `read` comes with the others. */
2700
+ clean(value) {
2701
+ const input = typeof value === "object" && value !== null ? value : {};
2702
+ const pick = (group, allowed2) => {
2703
+ const out = {};
2704
+ if (typeof group !== "object" || group === null) return out;
2705
+ for (const [slug2, ops] of Object.entries(group)) {
2706
+ const valid = allowed2.get(slug2);
2707
+ if (!valid || !Array.isArray(ops)) continue;
2708
+ const picked2 = valid.filter((op) => ops.includes(op));
2709
+ if (picked2.length > 0 && !picked2.includes("read")) picked2.unshift("read");
2710
+ out[slug2] = picked2;
2711
+ }
2712
+ return out;
2713
+ };
2714
+ const collections = new Map(
2715
+ this.config.collections.filter((c) => governed(c.slug)).map((c) => [
2716
+ c.slug,
2717
+ ROLE_OPERATIONS.filter((op) => op !== "publish" || c.drafts === true)
2718
+ ])
2719
+ );
2720
+ const globals = new Map(
2721
+ this.config.globals.map((g) => [
2722
+ g.slug,
2723
+ GLOBAL_OPERATIONS.filter((op) => op !== "publish" || g.drafts === true)
2724
+ ])
2725
+ );
2726
+ const views = new Set(this.views().map((v) => v.id));
2727
+ const admin = Array.isArray(input.admin) ? [
2728
+ ...new Set(
2729
+ input.admin.filter((id) => typeof id === "string" && views.has(id))
2730
+ )
2731
+ ] : [];
2732
+ const picked = {
2733
+ collections: pick(input.collections, collections),
2734
+ globals: pick(input.globals, globals)
2735
+ };
2736
+ const own = {};
2737
+ if (typeof input.own === "object" && input.own !== null) {
2738
+ for (const [slug2, ops] of Object.entries(input.own)) {
2739
+ const granted = picked.collections[slug2];
2740
+ if (!granted || !this.ownerOf(slug2) || !Array.isArray(ops)) continue;
2741
+ const valid = OWN_OPERATIONS.filter((op) => granted.includes(op) && ops.includes(op));
2742
+ if (valid.length > 0) own[slug2] = valid;
2743
+ }
2744
+ }
2745
+ const fields = {
2746
+ collections: this.cleanRules(
2747
+ input.fields?.collections,
2748
+ this.config.collections.filter((c) => governed(c.slug)),
2749
+ picked.collections
2750
+ ),
2751
+ globals: this.cleanRules(
2752
+ input.fields?.globals,
2753
+ this.config.globals,
2754
+ void 0
2755
+ )
2756
+ };
2757
+ return { ...picked, admin, own, fields };
2758
+ }
2759
+ /**
2760
+ * Keeps rules for known top-level fields. A field that must be filled in (required, without a
2761
+ * default) can't be read-only or hidden for a role that creates documents.
2762
+ */
2763
+ cleanRules(value, containers, granted) {
2764
+ const out = {};
2765
+ if (typeof value !== "object" || value === null) return out;
2766
+ for (const [slug2, rules] of Object.entries(value)) {
2767
+ const container = containers.find((c) => c.slug === slug2);
2768
+ if (!container || typeof rules !== "object" || rules === null) continue;
2769
+ const set = {};
2770
+ for (const field of ruleFields(container.fields)) {
2771
+ const rule = rules[field.name];
2772
+ if (rule !== "read" && rule !== "hidden") continue;
2773
+ if (granted?.[slug2]?.includes("create") && mustFill(field))
2774
+ throw new ValidationError(ROLES, [
2775
+ {
2776
+ field: `fields.${slug2}.${field.name}`,
2777
+ message: "is required: roles that create documents must be able to fill it"
2778
+ }
2779
+ ]);
2780
+ set[field.name] = rule;
2781
+ }
2782
+ if (Object.keys(set).length > 0) out[slug2] = set;
2783
+ }
2784
+ return out;
2785
+ }
2786
+ /** Fields that name a collection's owner (`createdBy`, and `admin.ownerField`). */
2787
+ ownerColumns(slug2) {
2788
+ if (!this.ownerOf(slug2)) return [];
2789
+ const ownerField = this.config.collections.find((c) => c.slug === slug2)?.admin?.ownerField;
2790
+ return ownerField ? ["createdBy", ownerField] : ["createdBy"];
2791
+ }
2792
+ /** Sets one field of every matching document, keeping the rest as stored. */
2793
+ async setWhere(collection2, where, field, value) {
2794
+ const { docs } = await this.db.find({ collection: collection2, where, sort: ["id"], limit: 0, page: 1 });
2795
+ for (const { id, ...rest } of docs)
2796
+ await this.db.update({ collection: collection2, id, data: { ...rest, [field]: value } });
2797
+ }
2798
+ cleanName(value) {
2799
+ if (value === void 0 || value === null) return "";
2800
+ if (typeof value !== "string" || value.trim().length > 60)
2801
+ throw new ValidationError(ROLES, [
2802
+ { field: "name", message: "must be at most 60 characters" }
2803
+ ]);
2804
+ return value.trim();
2805
+ }
2806
+ async permissionsOf(key2) {
2807
+ return (await this.load()).get(key2) ?? {};
2808
+ }
2809
+ async load() {
2810
+ if (this.cache && Date.now() - this.cache.at < TTL) return this.cache.byKey;
2811
+ await this.sync();
2812
+ const byKey = /* @__PURE__ */ new Map();
2813
+ for (const row of await this.rows())
2814
+ byKey.set(String(row.key), row.permissions ?? {});
2815
+ this.cache = { at: Date.now(), byKey, rules: /* @__PURE__ */ new Map() };
2816
+ return byKey;
2817
+ }
2818
+ async rows() {
2819
+ return (await this.db.find({ collection: ROLES, sort: ["id"], limit: 0, page: 1 })).docs;
2820
+ }
2821
+ async row(id) {
2822
+ const row = await this.db.findById({ collection: ROLES, id });
2823
+ if (!row) throw new NotFoundError(ROLES, id);
2824
+ return row;
2825
+ }
2826
+ users(key2) {
2827
+ return this.db.count({ collection: USERS, where: { role: { equals: key2 } } });
2828
+ }
2829
+ async insert(key2, name, permissions, author) {
2830
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2831
+ const row = await this.db.create({
2832
+ collection: ROLES,
2833
+ data: { key: key2, name, permissions, createdAt: now, updatedAt: now }
2834
+ });
2835
+ await this.record(row, author);
2836
+ this.invalidate();
2837
+ return row;
2838
+ }
2839
+ /** Keeps the role as saved, for its history. */
2840
+ async record(row, author) {
2841
+ await this.versions.save({
2842
+ parent: ROLES,
2843
+ doc: row.id,
2844
+ status: null,
2845
+ snapshot: { name: row.name ?? "", permissions: row.permissions ?? {} },
2846
+ author,
2847
+ max: HISTORY
2848
+ });
2849
+ }
2850
+ toRole(row, users) {
2851
+ const key2 = String(row.key);
2852
+ return {
2853
+ id: row.id,
2854
+ key: key2,
2855
+ name: typeof row.name === "string" ? row.name : "",
2856
+ system: this.config.auth.roles.includes(key2),
2857
+ users,
2858
+ permissions: row.permissions ?? {},
2859
+ updatedAt: String(row.updatedAt)
2860
+ };
2861
+ }
2862
+ };
2863
+ function mustFill(field) {
2864
+ return field.required === true && field.defaultValue === void 0;
2865
+ }
2866
+ function adminFields(fields, ownerField) {
2867
+ return ruleFields(fields).map((f) => ({
2868
+ name: f.name,
2869
+ ...f.label !== void 0 ? { label: f.label } : {},
2870
+ required: mustFill(f),
2871
+ ...f.name === ownerField ? { owner: true } : {}
2872
+ }));
2873
+ }
2874
+ function rank(role, config) {
2875
+ const index = config.auth.roles.indexOf(role.key);
2876
+ return index === -1 ? config.auth.roles.length : index;
2877
+ }
2878
+
2171
2879
  // src/webhooks.ts
2172
2880
  import { createHmac as createHmac2, randomUUID as randomUUID2 } from "crypto";
2173
2881
  var WEBHOOK_EVENTS = [
@@ -2359,6 +3067,7 @@ var RESERVED_SLUGS = /* @__PURE__ */ new Set([
2359
3067
  "webhook-deliveries",
2360
3068
  "email-deliveries",
2361
3069
  "database-backups",
3070
+ "user-roles",
2362
3071
  "api-keys",
2363
3072
  "jobs",
2364
3073
  "migrations",
@@ -2415,7 +3124,10 @@ function validateConfig(config) {
2415
3124
  if (apiKeys !== void 0 && typeof apiKeys !== "boolean") add("apiKeys", "must be true or false");
2416
3125
  collections.forEach((collection2, i) => {
2417
3126
  const path = `collections.${collection2.slug ?? `[${i}]`}`;
2418
- validateContainer(collection2, path, collectionSlugs, add);
3127
+ validateContainer(collection2, path, collectionSlugs, add, {
3128
+ collection: true,
3129
+ rbac: config.auth?.rbac === true
3130
+ });
2419
3131
  validateUseAsTitle(collection2, path, add);
2420
3132
  validateIcon(collection2, path, add);
2421
3133
  const editIn = collection2.editIn;
@@ -2424,7 +3136,10 @@ function validateConfig(config) {
2424
3136
  });
2425
3137
  globals.forEach((global2, i) => {
2426
3138
  const path = `globals.${global2.slug ?? `[${i}]`}`;
2427
- validateContainer(global2, path, collectionSlugs, add);
3139
+ validateContainer(global2, path, collectionSlugs, add, {
3140
+ collection: false,
3141
+ rbac: config.auth?.rbac === true
3142
+ });
2428
3143
  validateIcon(global2, path, add);
2429
3144
  });
2430
3145
  const plugins = config.plugins;
@@ -2563,6 +3278,7 @@ function validateAdminViews(config, add) {
2563
3278
  "e.g. dashboard: [{ component: 'ecms-stats-widget' }]"
2564
3279
  );
2565
3280
  } else {
3281
+ const tags = /* @__PURE__ */ new Set();
2566
3282
  widgets2.forEach((widget, i) => {
2567
3283
  const path = `admin.dashboard[${i}]`;
2568
3284
  if (typeof widget !== "object" || widget === null) {
@@ -2573,6 +3289,19 @@ function validateAdminViews(config, add) {
2573
3289
  validateComponent(w.component, `${path}.component`, add);
2574
3290
  if (w.width !== void 0 && w.width !== "half" && w.width !== "full")
2575
3291
  add(`${path}.width`, 'must be "half" or "full"');
3292
+ if (w.label !== void 0 && !(typeof w.label === "string" && w.label.trim() !== "") && !(typeof w.label === "object" && w.label !== null && !Array.isArray(w.label)))
3293
+ add(`${path}.label`, "must be a string or { en, th }");
3294
+ const component = w.component;
3295
+ const tag = typeof component === "string" ? component : component?.tag;
3296
+ if (config.auth?.rbac === true && typeof tag === "string") {
3297
+ if (tags.has(tag))
3298
+ add(
3299
+ `${path}.component`,
3300
+ `another panel uses "${tag}"`,
3301
+ "with auth.rbac, roles are given panels by their tag: use one tag per panel"
3302
+ );
3303
+ tags.add(tag);
3304
+ }
2576
3305
  access(w.access, path);
2577
3306
  });
2578
3307
  }
@@ -2586,6 +3315,8 @@ function validateIcon(container, path, add) {
2586
3315
  function validateAuth(config, add) {
2587
3316
  const auth = config.auth;
2588
3317
  if (auth === void 0) return;
3318
+ if (auth.rbac !== void 0 && typeof auth.rbac !== "boolean")
3319
+ add("auth.rbac", "must be true or false");
2589
3320
  if (auth.roles !== void 0) {
2590
3321
  const roles = auth.roles;
2591
3322
  if (!Array.isArray(roles) || roles.some((r) => typeof r !== "string" || r === "")) {
@@ -2852,7 +3583,7 @@ function checkSlug(container, path, seen, add) {
2852
3583
  seen.add(slug2);
2853
3584
  return slug2;
2854
3585
  }
2855
- function validateContainer(container, path, collectionSlugs, add) {
3586
+ function validateContainer(container, path, collectionSlugs, add, { collection: collection2, rbac }) {
2856
3587
  if (!Array.isArray(container.fields)) {
2857
3588
  add(`${path}.fields`, "must be an array");
2858
3589
  return;
@@ -2889,8 +3620,25 @@ function validateContainer(container, path, collectionSlugs, add) {
2889
3620
  add(`${path}.admin.group`, `must be 'settings' (got ${JSON.stringify(group)})`);
2890
3621
  const list = admin.list;
2891
3622
  if (list !== void 0) validateList(list, container, `${path}.admin.list`, add);
3623
+ const owner = admin.ownerField;
3624
+ if (owner !== void 0) {
3625
+ const field = container.fields.find((f) => f.name === owner);
3626
+ if (!collection2 || container.slug === "users" || field?.type !== "relationship" || field.to !== "users" || field.hasMany)
3627
+ add(
3628
+ `${path}.admin.ownerField`,
3629
+ 'must name a relationship field to "users" (not hasMany) of a collection',
3630
+ "e.g. { name: 'author', type: 'relationship', to: 'users' }"
3631
+ );
3632
+ }
2892
3633
  }
2893
3634
  }
3635
+ const createdBy = container.fields.find((f) => f.name === CREATED_BY_FIELD.name);
3636
+ if (rbac && createdBy && createdBy !== CREATED_BY_FIELD)
3637
+ add(
3638
+ `${path}.fields.createdBy`,
3639
+ '"createdBy" is added by Easy CMS with auth.rbac',
3640
+ "rename the field; to use it as the owner, set admin.ownerField"
3641
+ );
2894
3642
  const reserved = new Set(SYSTEM_FIELD_NAMES);
2895
3643
  if (container.drafts) reserved.add("status");
2896
3644
  validateFields2(container.fields, `${path}.fields`, reserved, collectionSlugs, add);
@@ -3244,7 +3992,7 @@ async function resolveConfig(input) {
3244
3992
  if (resolved.has(input)) return input;
3245
3993
  let config = await applyPlugins(input);
3246
3994
  if (typeof config === "object" && config !== null && Array.isArray(config.collections ?? [])) {
3247
- config = withMedia(withUsers(config));
3995
+ config = withCreatedBy(withMedia(withUsers(config)));
3248
3996
  }
3249
3997
  const applied = applyFieldTypes(config);
3250
3998
  config = applied.config;
@@ -3280,6 +4028,7 @@ async function resolveConfig(input) {
3280
4028
  },
3281
4029
  auth: {
3282
4030
  roles: config.auth?.roles ?? DEFAULT_ROLES,
4031
+ rbac: config.auth?.rbac === true,
3283
4032
  tokenExpiration: config.auth?.tokenExpiration ?? DEFAULT_TOKEN_EXPIRATION,
3284
4033
  maxLoginAttempts: config.auth?.maxLoginAttempts ?? 5,
3285
4034
  lockWindow: config.auth?.lockWindow ?? 15 * 60,
@@ -3291,12 +4040,14 @@ async function resolveConfig(input) {
3291
4040
  collections: [
3292
4041
  ...config.collections ?? [],
3293
4042
  ...internalCollections,
3294
- ...usesVersions(config) ? [versionsCollection] : [],
4043
+ // Roles keep their history as versions.
4044
+ ...usesVersions(config) || config.auth?.rbac ? [versionsCollection] : [],
3295
4045
  ...usesSchedule(config) ? [scheduledJobsCollection] : [],
3296
4046
  ...(config.webhooks?.length ?? 0) > 0 ? [webhookDeliveriesCollection] : [],
3297
4047
  ...config.apiKeys ? [apiKeysCollection] : [],
3298
4048
  ...config.email ? [emailDeliveriesCollection] : [],
3299
- databaseBackupsCollection
4049
+ databaseBackupsCollection,
4050
+ ...config.auth?.rbac ? [rolesCollection] : []
3300
4051
  ],
3301
4052
  globals: config.globals ?? [],
3302
4053
  endpoints: config.endpoints ?? [],
@@ -3841,137 +4592,6 @@ function read(response, maxBytes, deadline) {
3841
4592
  });
3842
4593
  }
3843
4594
 
3844
- // src/versions.ts
3845
- var DEFAULT_MAX_VERSIONS = 50;
3846
- function versionLimit(config) {
3847
- const versions = config.versions;
3848
- if (!versions) return void 0;
3849
- return versions === true ? DEFAULT_MAX_VERSIONS : versions.max ?? DEFAULT_MAX_VERSIONS;
3850
- }
3851
- var collectionParent = (slug2) => slug2;
3852
- var globalParent = (slug2) => `global:${slug2}`;
3853
- function snapshotOf(fields, doc) {
3854
- const hidden = new Set(fields.filter((f) => f.hidden).map((f) => f.name));
3855
- const snapshot = {};
3856
- for (const [key2, value] of Object.entries(doc)) {
3857
- if (key2 === "id" || key2 === "createdAt" || key2 === "updatedAt" || hidden.has(key2)) continue;
3858
- snapshot[key2] = value;
3859
- }
3860
- return snapshot;
3861
- }
3862
- var toSummary = (row) => ({
3863
- id: row.id,
3864
- status: row.status ?? null,
3865
- latest: row.latest === true,
3866
- author: row.author ?? null,
3867
- createdAt: String(row.createdAt)
3868
- });
3869
- var VersionStore = class {
3870
- constructor(db) {
3871
- this.db = db;
3872
- }
3873
- db;
3874
- /** Saves a new latest version and deletes the oldest beyond `max`. */
3875
- async save(args) {
3876
- const { parent, doc } = args;
3877
- const previous = await this.db.find({
3878
- collection: VERSIONS,
3879
- where: { and: [...this.of(parent, doc), { latest: { equals: true } }] },
3880
- sort: [],
3881
- limit: 0,
3882
- page: 1
3883
- });
3884
- for (const row of previous.docs) {
3885
- await this.db.update({ collection: VERSIONS, id: row.id, data: { ...row, latest: false } });
3886
- }
3887
- const now = (/* @__PURE__ */ new Date()).toISOString();
3888
- const created = await this.db.create({
3889
- collection: VERSIONS,
3890
- data: {
3891
- parent,
3892
- doc,
3893
- status: args.status,
3894
- latest: true,
3895
- author: args.author,
3896
- snapshot: args.snapshot,
3897
- createdAt: now,
3898
- updatedAt: now
3899
- }
3900
- });
3901
- await this.prune(parent, doc, args.max);
3902
- return toSummary(created);
3903
- }
3904
- /** The latest version of each document, keyed by document id. */
3905
- async latest(parent, docs) {
3906
- const result = /* @__PURE__ */ new Map();
3907
- if (docs.length === 0) return result;
3908
- const rows = await this.db.find({
3909
- collection: VERSIONS,
3910
- where: {
3911
- and: [
3912
- { parent: { equals: parent } },
3913
- { doc: { in: [...docs] } },
3914
- { latest: { equals: true } }
3915
- ]
3916
- },
3917
- sort: [],
3918
- limit: 0,
3919
- page: 1
3920
- });
3921
- for (const row of rows.docs) result.set(String(row.doc), this.toVersion(row));
3922
- return result;
3923
- }
3924
- async list(parent, doc, limit, page) {
3925
- const found = await this.db.find({
3926
- collection: VERSIONS,
3927
- where: { and: this.of(parent, doc) },
3928
- sort: ["-id"],
3929
- limit,
3930
- page
3931
- });
3932
- return { ...found, docs: found.docs.map(toSummary) };
3933
- }
3934
- async get(parent, doc, id) {
3935
- const found = await this.db.find({
3936
- collection: VERSIONS,
3937
- where: { and: [...this.of(parent, doc), { id: { equals: id } }] },
3938
- sort: [],
3939
- limit: 1,
3940
- page: 1
3941
- });
3942
- const row = found.docs[0];
3943
- return row ? this.toVersion(row) : null;
3944
- }
3945
- async deleteAll(parent, doc) {
3946
- const rows = await this.db.find({
3947
- collection: VERSIONS,
3948
- where: { and: this.of(parent, doc) },
3949
- sort: [],
3950
- limit: 0,
3951
- page: 1
3952
- });
3953
- for (const row of rows.docs) await this.db.delete({ collection: VERSIONS, id: row.id });
3954
- }
3955
- async prune(parent, doc, max) {
3956
- const rows = await this.db.find({
3957
- collection: VERSIONS,
3958
- where: { and: this.of(parent, doc) },
3959
- sort: ["-id"],
3960
- limit: 0,
3961
- page: 1
3962
- });
3963
- for (const row of rows.docs.slice(max)) {
3964
- await this.db.delete({ collection: VERSIONS, id: row.id });
3965
- }
3966
- }
3967
- of(parent, doc) {
3968
- return [{ parent: { equals: parent } }, { doc: { equals: doc } }];
3969
- }
3970
- toVersion(row) {
3971
- return { ...toSummary(row), data: row.snapshot ?? {} };
3972
- }
3973
- };
3974
-
3975
4595
  // src/local-api.ts
3976
4596
  async function createEasyCMS(config, options = {}) {
3977
4597
  const resolved3 = await resolveConfig(config);
@@ -4009,6 +4629,8 @@ var EasyCMS = class {
4009
4629
  storage;
4010
4630
  /** Project root: relative paths in the config (e.g. `admin.modules`) start here. */
4011
4631
  cwd;
4632
+ /** Roles and their permissions (`auth.rbac`, Settings → Roles). */
4633
+ roles;
4012
4634
  versions;
4013
4635
  webhooks;
4014
4636
  mailer;
@@ -4019,6 +4641,7 @@ var EasyCMS = class {
4019
4641
  this.storage = storage;
4020
4642
  this.cwd = cwd;
4021
4643
  this.versions = new VersionStore(db);
4644
+ this.roles = new Roles(config, db, (message2) => logger.info(message2));
4022
4645
  this.webhooks = new Webhooks(
4023
4646
  config.webhooks ?? [],
4024
4647
  logger,
@@ -4045,6 +4668,7 @@ var EasyCMS = class {
4045
4668
  options
4046
4669
  );
4047
4670
  const requested = options.sort === void 0 ? ["-createdAt"] : [options.sort].flat();
4671
+ await this.checkQueryFields(config, guard, void 0, requested);
4048
4672
  const locale = this.localeOf(options);
4049
4673
  const sort = locale === void 0 ? requested : localizeSort(
4050
4674
  config.fields,
@@ -4109,7 +4733,7 @@ var EasyCMS = class {
4109
4733
  const config = this.collection(MEDIA);
4110
4734
  const guard = guardOf(options);
4111
4735
  if (guard.enforce) {
4112
- this.checkKey(guard, { collection: MEDIA }, "create");
4736
+ await this.checkGrant(guard, { collection: MEDIA }, "create");
4113
4737
  const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data });
4114
4738
  if (allowed2 !== true) throw deny(guard.user);
4115
4739
  }
@@ -4169,7 +4793,7 @@ var EasyCMS = class {
4169
4793
  const guard = guardOf(options);
4170
4794
  const fromURL = this.config.upload.fromURL;
4171
4795
  if (guard.enforce) {
4172
- this.checkKey(guard, { collection: MEDIA }, "create");
4796
+ await this.checkGrant(guard, { collection: MEDIA }, "create");
4173
4797
  const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data });
4174
4798
  if (allowed2 !== true) throw deny(guard.user);
4175
4799
  if (!fromURL)
@@ -4324,7 +4948,7 @@ var EasyCMS = class {
4324
4948
  let parsed;
4325
4949
  if (id === null) {
4326
4950
  if (guard.enforce) {
4327
- this.checkKey(guard, { collection: collection2 }, "create");
4951
+ await this.checkGrant(guard, { collection: collection2 }, "create");
4328
4952
  const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data: raw });
4329
4953
  if (allowed2 !== true) throw deny(guard.user);
4330
4954
  }
@@ -4339,7 +4963,7 @@ var EasyCMS = class {
4339
4963
  const filtered = await filterInput(
4340
4964
  config.fields,
4341
4965
  input,
4342
- this.fieldChecker("update", guard, parsed, input)
4966
+ await this.fieldChecker("update", guard, parsed, input)
4343
4967
  );
4344
4968
  const localized = this.toMaps(config, filtered, base ?? {}, options);
4345
4969
  const merged = base ? mergeForUpdate(config.fields, base, localized) : applyDefaults(config.fields, localized, this.config.localization);
@@ -4379,7 +5003,7 @@ var EasyCMS = class {
4379
5003
  const input = await filterInput(
4380
5004
  config.fields,
4381
5005
  raw,
4382
- this.fieldChecker("update", guard, void 0, raw)
5006
+ await this.fieldChecker("update", guard, void 0, raw)
4383
5007
  );
4384
5008
  const saved = await this.db.findGlobal({ slug: slug2 }) ?? {};
4385
5009
  const current = await this.globalDraft(config, saved);
@@ -4444,7 +5068,7 @@ var EasyCMS = class {
4444
5068
  if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
4445
5069
  await this.checkDocumentAccess(config, "update", guard, parsed, raw);
4446
5070
  if (config.drafts && (mode === "unpublish" || raw.status === "published"))
4447
- this.checkKey(guard, { collection: collection2 }, "publish");
5071
+ await this.checkDocumentGrant(guard, collection2, "publish", parsed);
4448
5072
  const [drafted] = await this.withDrafts(config, [existing]);
4449
5073
  const live = options.live === true && mode === "save";
4450
5074
  const current = live ? existing : drafted;
@@ -4456,7 +5080,7 @@ var EasyCMS = class {
4456
5080
  const filtered = await filterInput(
4457
5081
  config.fields,
4458
5082
  input,
4459
- this.fieldChecker("update", guard, parsed, input)
5083
+ await this.fieldChecker("update", guard, parsed, input)
4460
5084
  );
4461
5085
  const localized = mode === "restore" ? filtered : this.toMaps(config, filtered, current, options);
4462
5086
  let merged = mergeForUpdate(config.fields, current, localized);
@@ -4524,6 +5148,10 @@ var EasyCMS = class {
4524
5148
  const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
4525
5149
  return out;
4526
5150
  }
5151
+ /**
5152
+ * Deletes a document. Deleting a user with roles from the admin (`auth.rbac`): `transferTo`
5153
+ * gives the documents they own to another user; without it they have no owner.
5154
+ */
4527
5155
  async delete(collection2, id, options = {}) {
4528
5156
  const config = this.collection(collection2);
4529
5157
  const guard = guardOf(options);
@@ -4535,7 +5163,19 @@ var EasyCMS = class {
4535
5163
  await this.guardLastAdmin(parsed, existing, { ...existing, active: false });
4536
5164
  const base = this.hookArgs(config, guard);
4537
5165
  for (const hook of config.hooks?.beforeDelete ?? []) await hook({ ...base, id: parsed });
4538
- if (config.slug === USERS) await this.auth.revokeSessions(parsed);
5166
+ if (config.slug === USERS) {
5167
+ const to = options.transferTo;
5168
+ if (to !== void 0 && to !== null) {
5169
+ const target = parseId(to);
5170
+ const heir = target === void 0 ? null : await this.db.findById({ collection: USERS, id: target });
5171
+ if (!heir || String(heir.id) === String(parsed))
5172
+ throw new ValidationError(USERS, [
5173
+ { field: "transferTo", message: "must be another existing user" }
5174
+ ]);
5175
+ await this.roles.transfer(parsed, heir.id);
5176
+ } else await this.roles.transfer(parsed, null);
5177
+ await this.auth.revokeSessions(parsed);
5178
+ }
4539
5179
  await this.db.delete({ collection: collection2, id: parsed });
4540
5180
  if (versionLimit(config)) await this.versions.deleteAll(collectionParent(collection2), parsed);
4541
5181
  if (config.schedule) {
@@ -4639,11 +5279,11 @@ var EasyCMS = class {
4639
5279
  const guard = guardOf(options);
4640
5280
  await this.checkGlobalAccess(config, "update", guard);
4641
5281
  if (config.drafts && (mode === "unpublish" || raw.status === "published"))
4642
- this.checkKey(guard, { global: slug2 }, "publish");
5282
+ await this.checkGrant(guard, { global: slug2 }, "publish");
4643
5283
  const input = await filterInput(
4644
5284
  config.fields,
4645
5285
  raw,
4646
- this.fieldChecker("update", guard, void 0, raw)
5286
+ await this.fieldChecker("update", guard, void 0, raw)
4647
5287
  );
4648
5288
  const existing = await this.db.findGlobal({ slug: slug2 }) ?? {};
4649
5289
  const current = await this.globalDraft(config, existing);
@@ -4928,14 +5568,14 @@ var EasyCMS = class {
4928
5568
  const existing = parsed === void 0 ? null : await this.db.findById({ collection: collection2, id: parsed });
4929
5569
  if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
4930
5570
  await this.checkDocumentAccess(config, "update", guardOf(options), parsed, void 0);
4931
- this.checkKey(guardOf(options), { collection: collection2 }, "publish");
5571
+ await this.checkDocumentGrant(guardOf(options), collection2, "publish", parsed);
4932
5572
  return { config, parsed };
4933
5573
  }
4934
5574
  async globalScheduleTarget(slug2, options) {
4935
5575
  const config = this.global(slug2);
4936
5576
  if (!config.schedule) throw new QueryError(`"${slug2}" has no schedule`);
4937
5577
  await this.checkGlobalAccess(config, "update", guardOf(options));
4938
- this.checkKey(guardOf(options), { global: slug2 }, "publish");
5578
+ await this.checkGrant(guardOf(options), { global: slug2 }, "publish");
4939
5579
  }
4940
5580
  async addJob(parent, doc, job, options) {
4941
5581
  const runAt = new Date(job.at);
@@ -4981,20 +5621,21 @@ var EasyCMS = class {
4981
5621
  page: 1
4982
5622
  });
4983
5623
  const allowed2 = /* @__PURE__ */ new Map();
4984
- const may = async (parent) => {
5624
+ const may2 = async (parent) => {
4985
5625
  if (!guard.enforce) return true;
4986
5626
  const cached = allowed2.get(parent);
4987
5627
  if (cached !== void 0) return cached;
4988
5628
  const config = parent.startsWith("global:") ? this.config.globals.find((g) => g.slug === parent.slice("global:".length)) : this.config.collections.find((c) => c.slug === parent);
4989
5629
  const access = config ? await evaluateAccess(config.access?.update, { user: guard.user }) : false;
4990
- const result = access !== false;
5630
+ const target = parent.startsWith("global:") ? { global: parent.slice("global:".length) } : { collection: parent };
5631
+ const result = access !== false && await this.roles.allows(guard.user, target, "update") !== false;
4991
5632
  allowed2.set(parent, result);
4992
5633
  return result;
4993
5634
  };
4994
5635
  const jobs = [];
4995
5636
  for (const row of rows.docs) {
4996
5637
  const parent = String(row.parent);
4997
- if (!await may(parent)) continue;
5638
+ if (!await may2(parent)) continue;
4998
5639
  const target = parent.startsWith("global:") ? { global: parent.slice("global:".length) } : { collection: parent };
4999
5640
  jobs.push({ ...toJob(row), ...target, doc: row.doc });
5000
5641
  if (jobs.length === limit) break;
@@ -5079,28 +5720,37 @@ var EasyCMS = class {
5079
5720
  if (!config) throw new QueryError(`Unknown global "${slug2}"`);
5080
5721
  return config;
5081
5722
  }
5082
- /** Refuses what a request's API key does not allow (see `ApiKeyPermissions`). */
5083
- checkKey(guard, target, operation) {
5084
- if (!guard.enforce || keyAllows(guard.user, target, operation)) return;
5723
+ /**
5724
+ * Refuses what a request's API key (see `ApiKeyPermissions`) or the user's role (`auth.rbac`)
5725
+ * does not allow. Returns a constraint when the role allows only the user's own account.
5726
+ */
5727
+ async checkGrant(guard, target, operation) {
5728
+ if (!guard.enforce) return true;
5085
5729
  const name = "collection" in target ? target.collection : target.global;
5086
- throw new ForbiddenError(`This API key may not ${operation} "${name}"`);
5730
+ if (!keyAllows(guard.user, target, operation))
5731
+ throw new ForbiddenError(`This API key may not ${operation} "${name}"`);
5732
+ const role = await this.roles.allows(guard.user, target, operation);
5733
+ if (role === false) throw deny(guard.user);
5734
+ return role;
5087
5735
  }
5088
5736
  /** The query constraint read access adds, or `undefined` when there is none. Throws when denied. */
5089
5737
  async readWhere(config, guard, where) {
5090
5738
  if (!guard.enforce) return where;
5091
- this.checkKey(guard, { collection: config.slug }, "read");
5739
+ const own = await this.checkGrant(guard, { collection: config.slug }, "read");
5092
5740
  const access = await evaluateAccess(config.access?.read, { user: guard.user });
5093
5741
  if (access === false) throw deny(guard.user);
5094
- return andWhere(where, access);
5742
+ await this.checkQueryFields(config, guard, where);
5743
+ return andWhere(andWhere(where, access), own);
5095
5744
  }
5096
5745
  async checkDocumentAccess(config, operation, guard, id, data) {
5097
5746
  if (!guard.enforce) return;
5098
- this.checkKey(guard, { collection: config.slug }, operation);
5099
- const access = await evaluateAccess(config.access?.[operation], {
5747
+ const own = await this.checkGrant(guard, { collection: config.slug }, operation);
5748
+ const allowed2 = await evaluateAccess(config.access?.[operation], {
5100
5749
  user: guard.user,
5101
5750
  id,
5102
5751
  ...data ? { data } : {}
5103
5752
  });
5753
+ const access = allowed2 === false ? false : andWhere(own === true ? void 0 : own, allowed2) ?? true;
5104
5754
  if (access === true) return;
5105
5755
  if (access !== false) {
5106
5756
  const matches2 = await this.db.count({
@@ -5113,23 +5763,58 @@ var EasyCMS = class {
5113
5763
  }
5114
5764
  async checkGlobalAccess(config, operation, guard) {
5115
5765
  if (!guard.enforce) return;
5116
- this.checkKey(guard, { global: config.slug }, operation);
5766
+ await this.checkGrant(guard, { global: config.slug }, operation);
5117
5767
  const access = await evaluateAccess(config.access?.[operation], { user: guard.user });
5118
5768
  if (typeof access === "object")
5119
5769
  throw new QueryError(`${operation} access of global "${config.slug}" must return a boolean`);
5120
5770
  if (!access) throw deny(guard.user);
5121
5771
  }
5122
- fieldChecker(kind, guard, id, data) {
5772
+ async fieldChecker(kind, guard, id, data) {
5123
5773
  if (!guard.enforce) return void 0;
5124
- return new FieldAccessChecker(kind, {
5125
- user: guard.user,
5126
- ...id !== void 0 ? { id } : {},
5127
- ...data ? { data } : {}
5774
+ return new FieldAccessChecker(
5775
+ kind,
5776
+ {
5777
+ user: guard.user,
5778
+ ...id !== void 0 ? { id } : {},
5779
+ ...data ? { data } : {}
5780
+ },
5781
+ await this.roles.fieldRules(guard.user)
5782
+ );
5783
+ }
5784
+ /**
5785
+ * Refuses filtering or sorting by fields the caller may not read: the results would tell
5786
+ * their values. Checks each field along a dotted path, through groups, arrays and blocks.
5787
+ */
5788
+ async checkQueryFields(config, guard, where, sort = []) {
5789
+ if (!guard.enforce) return;
5790
+ const paths = [...wherePaths(where), ...sort.map((s) => s.replace(/^-/, ""))];
5791
+ if (paths.length === 0) return;
5792
+ const read2 = await this.fieldChecker("read", guard, void 0, void 0);
5793
+ for (const path of paths) {
5794
+ let fields = config.fields;
5795
+ for (const name of path.split(".")) {
5796
+ if (!fields) break;
5797
+ const field = fields.find((f) => f.name === name);
5798
+ if (!field) break;
5799
+ if (field.hidden || read2 && !await read2.allows(field))
5800
+ throw new ForbiddenError(`You may not filter or sort by "${path}"`);
5801
+ fields = field.type === "group" || field.type === "array" ? field.fields : field.type === "blocks" ? field.blocks.flatMap((b) => b.fields) : void 0;
5802
+ }
5803
+ }
5804
+ }
5805
+ /** `checkGrant` for one document: a role limited to its own documents must own it. */
5806
+ async checkDocumentGrant(guard, collection2, operation, id) {
5807
+ const own = await this.checkGrant(guard, { collection: collection2 }, operation);
5808
+ if (own === true) return;
5809
+ const matches2 = await this.db.count({
5810
+ collection: collection2,
5811
+ where: andWhere(own, { id: { equals: id } })
5128
5812
  });
5813
+ if (matches2 === 0) throw deny(guard.user);
5129
5814
  }
5130
5815
  /** Runs afterRead hooks, populates relationships and removes what the caller may not see. */
5131
5816
  async output(config, docs, guard, options) {
5132
- const read2 = this.fieldChecker("read", guard, void 0, void 0);
5817
+ const read2 = await this.fieldChecker("read", guard, void 0, void 0);
5133
5818
  const finish = async (target, doc) => {
5134
5819
  const hooked = await this.transform(
5135
5820
  target.hooks?.afterRead,
@@ -5167,9 +5852,9 @@ var EasyCMS = class {
5167
5852
  const guard = guardOf(options);
5168
5853
  const collection2 = config.slug;
5169
5854
  if (guard.enforce) {
5170
- this.checkKey(guard, { collection: collection2 }, "create");
5855
+ await this.checkGrant(guard, { collection: collection2 }, "create");
5171
5856
  if (config.drafts && raw.status === "published")
5172
- this.checkKey(guard, { collection: collection2 }, "publish");
5857
+ await this.checkGrant(guard, { collection: collection2 }, "publish");
5173
5858
  const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data: raw });
5174
5859
  if (typeof allowed2 === "object")
5175
5860
  throw new QueryError(`create access of "${collection2}" must return a boolean`);
@@ -5179,8 +5864,9 @@ var EasyCMS = class {
5179
5864
  const filtered = await filterInput(
5180
5865
  config.fields,
5181
5866
  input,
5182
- this.fieldChecker("update", guard, void 0, input)
5867
+ await this.fieldChecker("update", guard, void 0, input)
5183
5868
  );
5869
+ this.roles.fillOwner(collection2, filtered, guard.user ?? hookGuard?.user ?? null);
5184
5870
  const base = this.hookArgs(config, hookGuard ?? guard);
5185
5871
  let data = applyDefaults(
5186
5872
  config.fields,
@@ -5388,6 +6074,8 @@ var EasyCMS = class {
5388
6074
  }
5389
6075
  clean2.status = status;
5390
6076
  }
6077
+ if (config.slug === USERS && this.roles.enabled && typeof clean2.role === "string" && !await this.roles.exists(clean2.role))
6078
+ errors.push({ field: "role", message: `there is no role "${clean2.role}"` });
5391
6079
  const isCollection = this.config.collections.includes(config);
5392
6080
  if (isCollection && errors.length === 0) {
5393
6081
  await this.makeSlugsUnique(config, clean2, selfId);
@@ -5474,11 +6162,11 @@ var EasyCMS = class {
5474
6162
  return await this.db.count({ collection: collection2, where }) > 0;
5475
6163
  }
5476
6164
  /** Each reference is one its relationship's `filterOptions` allow. */
5477
- async checkFilterOptions(references, selfId, user) {
6165
+ async checkFilterOptions(references2, selfId, user) {
5478
6166
  const errors = [];
5479
6167
  const cms = this;
5480
6168
  const allowed2 = /* @__PURE__ */ new Map();
5481
- for (const ref of references) {
6169
+ for (const ref of references2) {
5482
6170
  const filter = ref.filterOptions;
5483
6171
  if (!filter) continue;
5484
6172
  if (!allowed2.has(filter)) allowed2.set(filter, await filter({ id: selfId, user, cms }));
@@ -5496,9 +6184,9 @@ var EasyCMS = class {
5496
6184
  }
5497
6185
  return errors;
5498
6186
  }
5499
- async checkReferences(references) {
6187
+ async checkReferences(references2) {
5500
6188
  const byCollection = /* @__PURE__ */ new Map();
5501
- for (const ref of references) {
6189
+ for (const ref of references2) {
5502
6190
  if (!this.config.collections.some((c) => c.slug === ref.collection)) continue;
5503
6191
  byCollection.set(ref.collection, [...byCollection.get(ref.collection) ?? [], ref]);
5504
6192
  }
@@ -5549,6 +6237,16 @@ function loadSharp() {
5549
6237
  );
5550
6238
  return sharpModule;
5551
6239
  }
6240
+ function wherePaths(where) {
6241
+ if (!where) return [];
6242
+ const out = [];
6243
+ for (const [key2, value] of Object.entries(where)) {
6244
+ if (key2 === "and" || key2 === "or") {
6245
+ for (const sub of value ?? []) out.push(...wherePaths(sub));
6246
+ } else out.push(key2);
6247
+ }
6248
+ return out;
6249
+ }
5552
6250
  function guardOf(options) {
5553
6251
  return { enforce: options.overrideAccess === false, user: options.user ?? null };
5554
6252
  }
@@ -5820,10 +6518,11 @@ function componentRef(component) {
5820
6518
  async function allowed(access, user) {
5821
6519
  return await evaluateAccess(access, { user }) !== false;
5822
6520
  }
5823
- async function serializeFields(fields, update2, localized) {
6521
+ async function serializeFields(fields, checks, localized) {
6522
+ const { update: update2 } = checks;
5824
6523
  const out = [];
5825
6524
  for (const field of fields) {
5826
- if (field.hidden) continue;
6525
+ if (field.hidden || !await checks.read.allows(field)) continue;
5827
6526
  const f = { name: field.name, type: field.type };
5828
6527
  if (field.label !== void 0) f.label = field.label;
5829
6528
  if (field.required) f.required = true;
@@ -5876,7 +6575,7 @@ async function serializeFields(fields, update2, localized) {
5876
6575
  case "array":
5877
6576
  if (field.minRows !== void 0) f.minRows = field.minRows;
5878
6577
  if (field.maxRows !== void 0) f.maxRows = field.maxRows;
5879
- f.fields = await serializeFields(field.fields, update2, localized);
6578
+ f.fields = await serializeFields(field.fields, checks, localized);
5880
6579
  break;
5881
6580
  case "blocks":
5882
6581
  if (field.minRows !== void 0) f.minRows = field.minRows;
@@ -5885,35 +6584,51 @@ async function serializeFields(fields, update2, localized) {
5885
6584
  field.blocks.map(async (block) => ({
5886
6585
  slug: block.slug,
5887
6586
  ...block.labels ? { labels: block.labels } : {},
5888
- fields: await serializeFields(block.fields, update2, localized)
6587
+ fields: await serializeFields(block.fields, checks, localized)
5889
6588
  }))
5890
6589
  );
5891
6590
  break;
5892
6591
  case "group":
5893
- f.fields = await serializeFields(field.fields, update2, localized);
6592
+ f.fields = await serializeFields(field.fields, checks, localized);
5894
6593
  break;
5895
6594
  }
5896
6595
  out.push(f);
5897
6596
  }
5898
6597
  return out;
5899
6598
  }
5900
- async function collection(config, user, localized) {
6599
+ async function checkers(cms, user) {
6600
+ const rules = await cms.roles.fieldRules(user);
6601
+ return {
6602
+ update: new FieldAccessChecker("update", { user }, rules),
6603
+ read: new FieldAccessChecker("read", { user }, rules)
6604
+ };
6605
+ }
6606
+ async function may(cms, access, user, target, operation) {
6607
+ return await allowed(access, user) && await cms.roles.allows(user, target, operation) !== false;
6608
+ }
6609
+ async function collection(cms, config, user, localized, roles) {
6610
+ const target = { collection: config.slug };
6611
+ const fields = await serializeFields(config.fields, await checkers(cms, user), localized);
6612
+ if (config.slug === USERS && cms.roles.enabled) {
6613
+ const role = fields.find((f) => f.name === "role");
6614
+ if (role) {
6615
+ role.type = "select";
6616
+ role.options = roles.map((r) => ({ label: r.name || r.key, value: r.key }));
6617
+ }
6618
+ }
5901
6619
  const result = {
5902
6620
  slug: config.slug,
5903
6621
  drafts: config.drafts === true,
5904
6622
  versions: Boolean(config.versions),
5905
6623
  preview: typeof config.preview === "function",
5906
6624
  schedule: config.schedule === true,
5907
- fields: await serializeFields(
5908
- config.fields,
5909
- new FieldAccessChecker("update", { user }),
5910
- localized
5911
- ),
6625
+ fields,
5912
6626
  permissions: {
5913
- read: await allowed(config.access?.read, user),
5914
- create: await allowed(config.access?.create, user),
5915
- update: await allowed(config.access?.update, user),
5916
- delete: await allowed(config.access?.delete, user)
6627
+ read: await may(cms, config.access?.read, user, target, "read"),
6628
+ create: await may(cms, config.access?.create, user, target, "create"),
6629
+ update: await may(cms, config.access?.update, user, target, "update"),
6630
+ delete: await may(cms, config.access?.delete, user, target, "delete"),
6631
+ publish: config.drafts === true && await may(cms, config.access?.update, user, target, "publish")
5917
6632
  }
5918
6633
  };
5919
6634
  if (config.labels) result.labels = config.labels;
@@ -5923,25 +6638,26 @@ async function collection(config, user, localized) {
5923
6638
  if (config.admin?.group === "settings" || SETTINGS.has(config.slug)) result.group = "settings";
5924
6639
  if (config.admin?.list?.tree) result.tree = config.admin.list.tree;
5925
6640
  if (config.admin?.list?.sort) result.defaultSort = config.admin.list.sort;
6641
+ const owner = cms.roles.ownerOf(config.slug);
6642
+ if (owner && fields.some((f) => f.name === owner)) result.owner = owner;
5926
6643
  if (config.editIn === "drawer" && !result.drafts && !result.versions && !result.preview)
5927
6644
  result.editIn = "drawer";
5928
6645
  return result;
5929
6646
  }
5930
- async function global(config, user, localized) {
6647
+ async function global(cms, config, user, localized) {
6648
+ const target = { global: config.slug };
6649
+ const checks = await checkers(cms, user);
5931
6650
  const result = {
5932
6651
  slug: config.slug,
5933
6652
  drafts: config.drafts === true,
5934
6653
  versions: Boolean(config.versions),
5935
6654
  preview: typeof config.preview === "function",
5936
6655
  schedule: config.schedule === true,
5937
- fields: await serializeFields(
5938
- config.fields,
5939
- new FieldAccessChecker("update", { user }),
5940
- localized
5941
- ),
6656
+ fields: await serializeFields(config.fields, checks, localized),
5942
6657
  permissions: {
5943
- read: await allowed(config.access?.read, user),
5944
- update: await allowed(config.access?.update, user)
6658
+ read: await may(cms, config.access?.read, user, target, "read"),
6659
+ update: await may(cms, config.access?.update, user, target, "update"),
6660
+ publish: config.drafts === true && await may(cms, config.access?.update, user, target, "publish")
5945
6661
  }
5946
6662
  };
5947
6663
  if (config.label !== void 0) result.label = config.label;
@@ -5953,16 +6669,32 @@ async function adminSchema(cms, user, origin) {
5953
6669
  const collections = cms.config.collections.filter((c) => !INTERNAL_COLLECTIONS.has(c.slug));
5954
6670
  const localization = cms.config.localization;
5955
6671
  const localized = localization !== null;
6672
+ const admin = user.role === "admin" && !user.apiKey;
6673
+ const deliveries = cms.config.collections.some(
6674
+ (c) => c.slug === WEBHOOK_DELIVERIES || c.slug === EMAIL_DELIVERIES
6675
+ );
6676
+ const views = {
6677
+ status: await cms.roles.canView(user, "status"),
6678
+ deliveries: deliveries && await cms.roles.canView(user, "deliveries"),
6679
+ backups: admin,
6680
+ email: admin,
6681
+ roles: admin && cms.roles.enabled
6682
+ };
6683
+ const roles = await cms.roles.options();
5956
6684
  return {
5957
6685
  locale: cms.config.admin.locale,
5958
6686
  passwordLinks: cms.auth.canSendPasswordLinks(origin),
5959
6687
  menu: [...cms.config.admin.menu],
5960
6688
  localization: localization ? { locales: [...localization.locales], defaultLocale: localization.defaultLocale } : null,
5961
- collections: await Promise.all(collections.map((c) => collection(c, user, localized))),
5962
- globals: await Promise.all(cms.config.globals.map((g) => global(g, user, localized))),
6689
+ collections: await Promise.all(
6690
+ collections.map((c) => collection(cms, c, user, localized, roles))
6691
+ ),
6692
+ globals: await Promise.all(cms.config.globals.map((g) => global(cms, g, user, localized))),
5963
6693
  modules: adminModuleUrls(cms),
5964
6694
  uploadFromURL: cms.config.upload.fromURL !== void 0,
5965
- ...user.role === "admin" && !user.apiKey ? {
6695
+ views,
6696
+ rbac: cms.roles.enabled,
6697
+ ...views.deliveries ? {
5966
6698
  deliveries: {
5967
6699
  webhook: cms.config.collections.some((c) => c.slug === WEBHOOK_DELIVERIES),
5968
6700
  email: cms.config.collections.some((c) => c.slug === EMAIL_DELIVERIES)
@@ -5983,7 +6715,8 @@ async function shown(access, user) {
5983
6715
  async function pages(cms, user) {
5984
6716
  const out = [];
5985
6717
  for (const page of cms.config.admin.pages) {
5986
- if (!await shown(page.access, user)) continue;
6718
+ if (!await shown(page.access, user) || !await cms.roles.canView(user, `page:${page.path}`))
6719
+ continue;
5987
6720
  out.push({
5988
6721
  path: page.path,
5989
6722
  label: page.label,
@@ -5997,14 +6730,16 @@ async function pages(cms, user) {
5997
6730
  async function widgets(cms, user) {
5998
6731
  const out = [];
5999
6732
  for (const widget of cms.config.admin.dashboard) {
6000
- if (!await shown(widget.access, user)) continue;
6733
+ const tag = typeof widget.component === "string" ? widget.component : widget.component.tag;
6734
+ if (!await shown(widget.access, user) || !await cms.roles.canView(user, `widget:${tag}`))
6735
+ continue;
6001
6736
  out.push({ component: componentRef(widget.component), width: widget.width ?? "half" });
6002
6737
  }
6003
6738
  return out;
6004
6739
  }
6005
6740
 
6006
6741
  // src/version.ts
6007
- var VERSION = true ? "0.32.0" : "0.0.0-dev";
6742
+ var VERSION = true ? "0.34.0" : "0.0.0-dev";
6008
6743
 
6009
6744
  // src/rest/admin-status.ts
6010
6745
  var DAY = 864e5;
@@ -6495,9 +7230,44 @@ async function route(cms, ctx, method, segments, options) {
6495
7230
  }
6496
7231
  throw new HttpError("Not found", 404);
6497
7232
  }
6498
- if (first === "admin" && second === "deliveries") {
7233
+ if (first === "admin" && second === "owned" && third !== void 0 && segments.length === 3) {
6499
7234
  if (!ctx.user) throw new UnauthorizedError();
6500
7235
  if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
7236
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
7237
+ const parsed = parseId(third);
7238
+ if (parsed === void 0) throw new HttpError("Not found", 404);
7239
+ return { body: await cms.roles.owned(parsed) };
7240
+ }
7241
+ if (first === "admin" && second === "roles") {
7242
+ if (!ctx.user) throw new UnauthorizedError();
7243
+ if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
7244
+ if (!cms.roles.enabled) throw new HttpError("Not found", 404);
7245
+ const [, , id2, action, extra] = segments;
7246
+ if (extra !== void 0) throw new HttpError("Not found", 404);
7247
+ if (id2 === void 0) {
7248
+ if (method === "GET") return { body: await cms.roles.list() };
7249
+ if (method === "POST")
7250
+ return { status: 201, body: await cms.roles.create(await readJson(ctx.request), ctx.user) };
7251
+ throw methodNotAllowed(ctx, "GET, POST");
7252
+ }
7253
+ const parsed = parseId(id2);
7254
+ if (parsed === void 0) throw new HttpError("Not found", 404);
7255
+ if (action === "history") {
7256
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
7257
+ return { body: await cms.roles.history(parsed) };
7258
+ }
7259
+ if (action !== void 0) throw new HttpError("Not found", 404);
7260
+ if (method === "PATCH")
7261
+ return { body: await cms.roles.update(parsed, await readJson(ctx.request), ctx.user) };
7262
+ if (method === "DELETE") {
7263
+ await cms.roles.delete(parsed);
7264
+ return { body: { deleted: 1 } };
7265
+ }
7266
+ throw methodNotAllowed(ctx, "PATCH, DELETE");
7267
+ }
7268
+ if (first === "admin" && second === "deliveries") {
7269
+ if (!ctx.user) throw new UnauthorizedError();
7270
+ if (!await cms.roles.canView(ctx.user, "deliveries")) throw new ForbiddenError();
6501
7271
  const [, , kind, id2, action, extra] = segments;
6502
7272
  if (kind === void 0) {
6503
7273
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
@@ -6547,7 +7317,7 @@ async function route(cms, ctx, method, segments, options) {
6547
7317
  return { body: new Response(file.body, { headers }) };
6548
7318
  }
6549
7319
  if (second === "status" && third === void 0) {
6550
- if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
7320
+ if (!await cms.roles.canView(ctx.user, "status")) throw new ForbiddenError();
6551
7321
  return { body: await adminStatus(cms) };
6552
7322
  }
6553
7323
  if (second === "scheduled" && third === void 0)
@@ -6672,7 +7442,9 @@ async function route(cms, ctx, method, segments, options) {
6672
7442
  return { body: doc };
6673
7443
  }
6674
7444
  if (method === "DELETE") {
6675
- return { body: await cms.delete(collection2, id, access) };
7445
+ const to = collection2 === USERS ? ctx.url.searchParams.get("transferTo") : null;
7446
+ const transferTo = to === null ? {} : { transferTo: to === "none" ? null : parseId(to) ?? to };
7447
+ return { body: await cms.delete(collection2, id, { ...access, ...transferTo }) };
6676
7448
  }
6677
7449
  throw methodNotAllowed(ctx, "GET, PATCH, DELETE");
6678
7450
  }
@@ -7157,6 +7929,8 @@ export {
7157
7929
  NotFoundError,
7158
7930
  PayloadTooLargeError,
7159
7931
  QueryError,
7932
+ ROLE_OPERATIONS,
7933
+ Roles,
7160
7934
  SESSION_COOKIE,
7161
7935
  SchemaError,
7162
7936
  TooManyRequestsError,