@easy-cms/core 0.31.0 → 0.33.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 +235 -5
  2. package/dist/index.js +1269 -425
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -116,10 +116,10 @@ function cleanPermissions(value, known) {
116
116
  const pick = (group, slugs, operations) => {
117
117
  const out = {};
118
118
  if (typeof group !== "object" || group === null) return out;
119
- for (const [slug, ops] of Object.entries(group)) {
120
- if (!slugs.includes(slug) || API_KEY_EXCLUDED.has(slug) || !Array.isArray(ops)) continue;
119
+ for (const [slug2, ops] of Object.entries(group)) {
120
+ if (!slugs.includes(slug2) || API_KEY_EXCLUDED.has(slug2) || !Array.isArray(ops)) continue;
121
121
  const valid = operations.filter((op) => ops.includes(op));
122
- if (valid.length) out[slug] = valid;
122
+ if (valid.length) out[slug2] = valid;
123
123
  }
124
124
  return out;
125
125
  };
@@ -293,22 +293,22 @@ async function stripFields(fields, doc, read2) {
293
293
  }
294
294
  return result;
295
295
  }
296
- async function filterInput(fields, data, update) {
296
+ async function filterInput(fields, data, update2) {
297
297
  const result = { ...data };
298
298
  for (const field of fields) {
299
299
  if (!(field.name in result)) continue;
300
- if (field.hidden || update && !await update.allows(field)) {
300
+ if (field.hidden || update2 && !await update2.allows(field)) {
301
301
  delete result[field.name];
302
302
  continue;
303
303
  }
304
304
  const value = result[field.name];
305
305
  if (field.type === "group" && value && typeof value === "object" && !Array.isArray(value)) {
306
- result[field.name] = await filterInput(field.fields, value, update);
306
+ result[field.name] = await filterInput(field.fields, value, update2);
307
307
  } else if (hasRows(field) && Array.isArray(value)) {
308
308
  result[field.name] = await Promise.all(
309
309
  value.map((row) => {
310
310
  const fields2 = rowFields(field, row);
311
- return fields2 && row && typeof row === "object" ? filterInput(fields2, row, update) : row;
311
+ return fields2 && row && typeof row === "object" ? filterInput(fields2, row, update2) : row;
312
312
  })
313
313
  );
314
314
  }
@@ -325,13 +325,17 @@ var VERSIONS = "document-versions";
325
325
  var SCHEDULED_JOBS = "scheduled-jobs";
326
326
  var WEBHOOK_DELIVERIES = "webhook-deliveries";
327
327
  var EMAIL_DELIVERIES = "email-deliveries";
328
+ var DATABASE_BACKUPS = "database-backups";
329
+ var ROLES = "user-roles";
328
330
  var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
329
331
  SESSIONS,
330
332
  LOGIN_ATTEMPTS,
331
333
  VERSIONS,
332
334
  SCHEDULED_JOBS,
333
335
  WEBHOOK_DELIVERIES,
334
- EMAIL_DELIVERIES
336
+ EMAIL_DELIVERIES,
337
+ DATABASE_BACKUPS,
338
+ ROLES
335
339
  ]);
336
340
  var DEFAULT_ROLES = ["admin", "editor"];
337
341
  var adminOrSelf = ({ user }) => {
@@ -342,7 +346,8 @@ var adminOrSelf = ({ user }) => {
342
346
  var adminOnly = {
343
347
  update: ({ user }) => user?.role === "admin"
344
348
  };
345
- function userFields(roles) {
349
+ function userFields(roles, rbac) {
350
+ const defaultRole = roles.includes("editor") ? "editor" : roles[roles.length - 1];
346
351
  return [
347
352
  {
348
353
  name: "email",
@@ -352,12 +357,20 @@ function userFields(roles) {
352
357
  label: { en: "Email", th: "\u0E2D\u0E35\u0E40\u0E21\u0E25" }
353
358
  },
354
359
  { name: "name", type: "text", label: { en: "Name", th: "\u0E0A\u0E37\u0E48\u0E2D" } },
355
- {
360
+ // With roles from the admin (`auth.rbac`), any role in Settings → Roles: checked on save.
361
+ rbac ? {
362
+ name: "role",
363
+ type: "text",
364
+ required: true,
365
+ defaultValue: defaultRole,
366
+ access: adminOnly,
367
+ label: { en: "Role", th: "\u0E1A\u0E17\u0E1A\u0E32\u0E17" }
368
+ } : {
356
369
  name: "role",
357
370
  type: "select",
358
371
  options: roles,
359
372
  required: true,
360
- defaultValue: roles.includes("editor") ? "editor" : roles[roles.length - 1],
373
+ defaultValue: defaultRole,
361
374
  access: adminOnly,
362
375
  label: { en: "Role", th: "\u0E1A\u0E17\u0E1A\u0E32\u0E17" }
363
376
  },
@@ -380,7 +393,7 @@ function withUsers(config) {
380
393
  useAsTitle: "email",
381
394
  icon: "users",
382
395
  ...custom,
383
- fields: [...userFields(roles), ...custom?.fields ?? []],
396
+ fields: [...userFields(roles, config.auth?.rbac === true), ...custom?.fields ?? []],
384
397
  access: {
385
398
  read: isLoggedIn,
386
399
  create: isAdmin,
@@ -546,6 +559,25 @@ var webhookDeliveriesCollection = {
546
559
  { name: "error", type: "text" }
547
560
  ]
548
561
  };
562
+ var databaseBackupsCollection = {
563
+ slug: DATABASE_BACKUPS,
564
+ access: { read: nobody, create: nobody, update: nobody, delete: nobody },
565
+ fields: [
566
+ // pending | running | done | failed
567
+ { name: "state", type: "text", required: true, index: true },
568
+ // manual | scheduled
569
+ { name: "trigger", type: "text", required: true },
570
+ { name: "filename", type: "text" },
571
+ { name: "size", type: "number" },
572
+ { name: "startedAt", type: "text", index: true },
573
+ { name: "finishedAt", type: "text" },
574
+ { name: "error", type: "text" },
575
+ // Who started it (email), for manual ones.
576
+ { name: "author", type: "text" },
577
+ { name: "downloadedBy", type: "text" },
578
+ { name: "downloadedAt", type: "text" }
579
+ ]
580
+ };
549
581
  var emailDeliveriesCollection = {
550
582
  slug: EMAIL_DELIVERIES,
551
583
  access: { read: nobody, create: nobody, update: nobody, delete: nobody },
@@ -1089,6 +1121,386 @@ var Auth = class {
1089
1121
  }
1090
1122
  };
1091
1123
 
1124
+ // src/backups.ts
1125
+ import { mkdtemp, readFile as readFile2, rm as rm2 } from "fs/promises";
1126
+ import { tmpdir } from "os";
1127
+ import { join as join2 } from "path";
1128
+ import { promisify } from "util";
1129
+ import { gzip } from "zlib";
1130
+
1131
+ // src/copy.ts
1132
+ var BATCH = 200;
1133
+ async function copyDatabase(source, target, options = {}) {
1134
+ const from = source.transfer;
1135
+ const to = target.transfer;
1136
+ if (!from || !to) throw new EasyCMSError("This database adapter cannot copy databases", 400);
1137
+ if (from.schemaHash !== to.schemaHash) {
1138
+ throw new EasyCMSError(
1139
+ "The two configs describe different collections or fields. Copy between configs with the same collections (import one config into the other and change only `db`).",
1140
+ 400
1141
+ );
1142
+ }
1143
+ const tables = from.tables();
1144
+ for (const table of tables) {
1145
+ if (await to.count(table) > 0) {
1146
+ throw new EasyCMSError(
1147
+ `The target database is not empty ("${table}" has rows). Copy into a new, empty database.`,
1148
+ 400
1149
+ );
1150
+ }
1151
+ }
1152
+ let total = 0;
1153
+ for (const table of tables) {
1154
+ let copied = 0;
1155
+ for (; ; ) {
1156
+ const rows = await from.read(table, copied, BATCH);
1157
+ if (rows.length === 0) break;
1158
+ await to.write(table, rows);
1159
+ copied += rows.length;
1160
+ if (rows.length < BATCH) break;
1161
+ }
1162
+ total += copied;
1163
+ options.onTable?.({ table, rows: copied });
1164
+ }
1165
+ await to.finish();
1166
+ return { tables: tables.length, rows: total };
1167
+ }
1168
+
1169
+ // src/storage.ts
1170
+ import { mkdir, readFile, rm, stat, writeFile } from "fs/promises";
1171
+ import { dirname, isAbsolute, join, normalize, resolve, sep } from "path";
1172
+ function localStorage(options = {}) {
1173
+ let root = resolve(
1174
+ /* turbopackIgnore: true */
1175
+ options.dir ?? "uploads"
1176
+ );
1177
+ const pathOf = (key2) => {
1178
+ const path = normalize(join(
1179
+ /* turbopackIgnore: true */
1180
+ root,
1181
+ key2
1182
+ ));
1183
+ if (!path.startsWith(root + sep)) throw new Error(`Invalid storage key "${key2}"`);
1184
+ return path;
1185
+ };
1186
+ return {
1187
+ name: "local",
1188
+ init({ cwd }) {
1189
+ const dir = options.dir ?? "uploads";
1190
+ root = isAbsolute(dir) ? dir : resolve(
1191
+ /* turbopackIgnore: true */
1192
+ cwd,
1193
+ dir
1194
+ );
1195
+ },
1196
+ async put(key2, data) {
1197
+ const path = pathOf(key2);
1198
+ await mkdir(dirname(path), { recursive: true });
1199
+ await writeFile(path, data, { flag: "wx" });
1200
+ },
1201
+ async get(key2) {
1202
+ const path = pathOf(key2);
1203
+ const info = await stat(path).catch(() => void 0);
1204
+ if (!info?.isFile()) return null;
1205
+ return { body: new Uint8Array(await readFile(path)), size: info.size };
1206
+ },
1207
+ async delete(key2) {
1208
+ await rm(pathOf(key2), { force: true });
1209
+ }
1210
+ };
1211
+ }
1212
+
1213
+ // src/backups.ts
1214
+ var gzipAsync = promisify(gzip);
1215
+ var DEFAULT_BACKUP_TIME = "03:00";
1216
+ var DEFAULT_BACKUP_KEEP = 7;
1217
+ var DEFAULT_DIR = "backups";
1218
+ var STALE = 30 * 6e4;
1219
+ var PERIOD = { day: 864e5, week: 7 * 864e5 };
1220
+ var storages = /* @__PURE__ */ new WeakMap();
1221
+ function backupStorage(cms) {
1222
+ let storage = storages.get(cms);
1223
+ if (!storage) {
1224
+ storage = (async () => {
1225
+ const config = cms.config.backups;
1226
+ const adapter = config?.storage ?? localStorage({ dir: config?.dir ?? DEFAULT_DIR });
1227
+ await adapter.init?.({ cwd: cms.cwd });
1228
+ return adapter;
1229
+ })();
1230
+ storages.set(cms, storage);
1231
+ }
1232
+ return storage;
1233
+ }
1234
+ function backupUnavailable(cms) {
1235
+ if (cms.db.backup || cms.config.backups?.sqlite) return null;
1236
+ return `The ${cms.config.db.name} database needs the SQLite adapter to write backups: backups: { sqlite } with sqlite from @easy-cms/db-sqlite`;
1237
+ }
1238
+ async function writeBackupFile(cms, file) {
1239
+ if (cms.db.backup) return cms.db.backup(file);
1240
+ const factory = cms.config.backups?.sqlite;
1241
+ if (!factory) throw new EasyCMSError(backupUnavailable(cms), 400);
1242
+ const prefix = cms.db.tablePrefix;
1243
+ const scratch = `${file}.copy`;
1244
+ const adapter = factory({ url: `file:${scratch}`, ...prefix ? { tablePrefix: prefix } : {} });
1245
+ const target = await adapter.init({
1246
+ config: { ...cms.config, db: adapter },
1247
+ cwd: cms.cwd,
1248
+ schema: "push",
1249
+ logger: { ...cms.logger, info() {
1250
+ } }
1251
+ });
1252
+ try {
1253
+ await copyDatabase(cms.db, target);
1254
+ if (!target.backup) throw new EasyCMSError("backups.sqlite must be the SQLite adapter", 400);
1255
+ await target.backup(file);
1256
+ } finally {
1257
+ await target.destroy();
1258
+ for (const suffix of ["", "-wal", "-shm"])
1259
+ await rm2(`${scratch}${suffix}`, { force: true }).catch(() => {
1260
+ });
1261
+ }
1262
+ }
1263
+ var slug = (text) => text.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "easy-cms";
1264
+ function filenameFor(cms, date) {
1265
+ const pad = (n) => String(n).padStart(2, "0");
1266
+ const stamp = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}`;
1267
+ return `${slug(cms.config.admin.brand.name || "easy-cms")}-${stamp}.db.gz`;
1268
+ }
1269
+ function toAdmin(row) {
1270
+ const text = (value) => typeof value === "string" && value !== "" ? value : null;
1271
+ return {
1272
+ id: row.id,
1273
+ state: ["pending", "running", "done", "failed"].includes(row.state) ? row.state : "failed",
1274
+ trigger: row.trigger === "scheduled" ? "scheduled" : "manual",
1275
+ filename: text(row.filename),
1276
+ size: typeof row.size === "number" ? row.size : null,
1277
+ startedAt: text(row.startedAt),
1278
+ finishedAt: text(row.finishedAt),
1279
+ error: text(row.error),
1280
+ author: text(row.author),
1281
+ downloadedBy: text(row.downloadedBy),
1282
+ downloadedAt: text(row.downloadedAt)
1283
+ };
1284
+ }
1285
+ async function update(cms, row, data) {
1286
+ const { id, ...rest } = row;
1287
+ const next = { ...rest, ...data, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
1288
+ await cms.db.update({ collection: DATABASE_BACKUPS, id, data: next });
1289
+ return { ...row, ...data };
1290
+ }
1291
+ async function listBackups(cms) {
1292
+ const config = cms.config.backups;
1293
+ const storage = await backupStorage(cms);
1294
+ const { docs } = await cms.db.find({
1295
+ collection: DATABASE_BACKUPS,
1296
+ sort: ["-createdAt"],
1297
+ limit: 0,
1298
+ page: 1
1299
+ });
1300
+ return {
1301
+ settings: {
1302
+ every: config?.every ?? null,
1303
+ at: config?.at ?? DEFAULT_BACKUP_TIME,
1304
+ keep: config?.keep ?? DEFAULT_BACKUP_KEEP,
1305
+ storage: storage.name,
1306
+ dir: config?.storage ? null : config?.dir ?? DEFAULT_DIR,
1307
+ database: cms.config.db.name,
1308
+ unavailable: backupUnavailable(cms)
1309
+ },
1310
+ backups: docs.map(toAdmin)
1311
+ };
1312
+ }
1313
+ async function inProgress(cms, now) {
1314
+ const { docs } = await cms.db.find({
1315
+ collection: DATABASE_BACKUPS,
1316
+ where: { state: { in: ["pending", "running"] } },
1317
+ sort: ["-createdAt"],
1318
+ limit: 10,
1319
+ page: 1
1320
+ });
1321
+ return docs.find((row) => now - Date.parse(String(row.createdAt)) < STALE);
1322
+ }
1323
+ async function startBackup(cms, trigger, author) {
1324
+ const unavailable = backupUnavailable(cms);
1325
+ if (unavailable) throw new EasyCMSError(unavailable, 400);
1326
+ const now = /* @__PURE__ */ new Date();
1327
+ if (await inProgress(cms, now.getTime()))
1328
+ throw new EasyCMSError("A backup is already running", 409);
1329
+ const stamp = now.toISOString();
1330
+ const row = await cms.db.create({
1331
+ collection: DATABASE_BACKUPS,
1332
+ data: {
1333
+ state: "pending",
1334
+ trigger,
1335
+ filename: null,
1336
+ size: null,
1337
+ startedAt: null,
1338
+ finishedAt: null,
1339
+ error: null,
1340
+ author: author ?? null,
1341
+ downloadedBy: null,
1342
+ downloadedAt: null,
1343
+ createdAt: stamp,
1344
+ updatedAt: stamp
1345
+ }
1346
+ });
1347
+ void runBackup(cms, row).catch(
1348
+ (error) => cms.logger.error(`Backup failed: ${error.message}`)
1349
+ );
1350
+ return toAdmin(row);
1351
+ }
1352
+ async function runBackup(cms, record) {
1353
+ const started = /* @__PURE__ */ new Date();
1354
+ let row = await update(cms, record, { state: "running", startedAt: started.toISOString() });
1355
+ const dir = await mkdtemp(join2(tmpdir(), "easy-cms-backup-"));
1356
+ try {
1357
+ const file = join2(dir, "backup.db");
1358
+ await writeBackupFile(cms, file);
1359
+ const data = new Uint8Array(await gzipAsync(await readFile2(file)));
1360
+ const storage = await backupStorage(cms);
1361
+ const filename = filenameFor(cms, started);
1362
+ const key2 = await storage.get(filename) ? filename.replace(/\.db\.gz$/, `-${row.id}.db.gz`) : filename;
1363
+ await storage.put(key2, data, { contentType: "application/gzip" });
1364
+ row = await update(cms, row, {
1365
+ state: "done",
1366
+ filename: key2,
1367
+ size: data.byteLength,
1368
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString()
1369
+ });
1370
+ cms.logger.info(`Backup ${key2} written (${data.byteLength} bytes)`);
1371
+ await pruneBackups(cms);
1372
+ } catch (error) {
1373
+ await update(cms, row, {
1374
+ state: "failed",
1375
+ error: (error.message || String(error)).slice(0, 500),
1376
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString()
1377
+ });
1378
+ throw error;
1379
+ } finally {
1380
+ await rm2(dir, { recursive: true, force: true });
1381
+ }
1382
+ }
1383
+ async function pruneBackups(cms) {
1384
+ const keep = cms.config.backups?.keep ?? DEFAULT_BACKUP_KEEP;
1385
+ const storage = await backupStorage(cms);
1386
+ const { docs } = await cms.db.find({
1387
+ collection: DATABASE_BACKUPS,
1388
+ where: { state: { equals: "done" } },
1389
+ sort: ["-createdAt"],
1390
+ limit: 0,
1391
+ page: 1
1392
+ });
1393
+ for (const row of docs.slice(keep)) {
1394
+ if (typeof row.filename === "string") await storage.delete(row.filename).catch(() => {
1395
+ });
1396
+ await cms.db.delete({ collection: DATABASE_BACKUPS, id: row.id });
1397
+ }
1398
+ const old = new Date(Date.now() - 30 * 864e5).toISOString();
1399
+ const failed = await cms.db.find({
1400
+ collection: DATABASE_BACKUPS,
1401
+ where: { and: [{ state: { equals: "failed" } }, { createdAt: { lt: old } }] },
1402
+ sort: ["createdAt"],
1403
+ limit: 100,
1404
+ page: 1
1405
+ });
1406
+ for (const row of failed.docs) await cms.db.delete({ collection: DATABASE_BACKUPS, id: row.id });
1407
+ }
1408
+ function lastSlot(now, at) {
1409
+ const [hours2, minutes] = at.split(":").map(Number);
1410
+ const slot = new Date(now);
1411
+ slot.setHours(hours2, minutes, 0, 0);
1412
+ if (slot.getTime() > now.getTime()) slot.setDate(slot.getDate() - 1);
1413
+ return slot;
1414
+ }
1415
+ async function runDueBackups(cms, now = /* @__PURE__ */ new Date()) {
1416
+ if (backupUnavailable(cms)) return;
1417
+ const { docs } = await cms.db.find({
1418
+ collection: DATABASE_BACKUPS,
1419
+ where: { state: { in: ["pending", "running"] } },
1420
+ sort: ["createdAt"],
1421
+ limit: 10,
1422
+ page: 1
1423
+ });
1424
+ for (const row of docs) {
1425
+ const age = now.getTime() - Date.parse(String(row.updatedAt ?? row.createdAt));
1426
+ if (age < STALE) continue;
1427
+ if (row.state === "pending") await runBackup(cms, row).catch(() => {
1428
+ });
1429
+ else
1430
+ await update(cms, row, {
1431
+ state: "failed",
1432
+ error: "Interrupted: the server stopped during the backup",
1433
+ finishedAt: now.toISOString()
1434
+ });
1435
+ }
1436
+ const every = cms.config.backups?.every;
1437
+ if (!every) return;
1438
+ const slot = lastSlot(now, cms.config.backups?.at ?? DEFAULT_BACKUP_TIME);
1439
+ const latest = await cms.db.find({
1440
+ collection: DATABASE_BACKUPS,
1441
+ where: { trigger: { equals: "scheduled" } },
1442
+ sort: ["-createdAt"],
1443
+ limit: 1,
1444
+ page: 1
1445
+ });
1446
+ const last = latest.docs[0] ? Date.parse(String(latest.docs[0].createdAt)) : 0;
1447
+ const due = every === "day" ? last < slot.getTime() : last < slot.getTime() - PERIOD.week + PERIOD.day;
1448
+ if (!due) return;
1449
+ if (await inProgress(cms, now.getTime())) return;
1450
+ await startBackup(cms, "scheduled").catch(
1451
+ (error) => cms.logger.error(`Scheduled backup could not start: ${error.message}`)
1452
+ );
1453
+ }
1454
+ async function backupAttention(cms, now = Date.now()) {
1455
+ const every = cms.config.backups?.every;
1456
+ if (!every) return null;
1457
+ const [scheduled, done] = await Promise.all([
1458
+ cms.db.find({
1459
+ collection: DATABASE_BACKUPS,
1460
+ where: { trigger: { equals: "scheduled" } },
1461
+ sort: ["-createdAt"],
1462
+ limit: 1,
1463
+ page: 1
1464
+ }),
1465
+ cms.db.find({
1466
+ collection: DATABASE_BACKUPS,
1467
+ where: { state: { equals: "done" } },
1468
+ sort: ["-createdAt"],
1469
+ limit: 1,
1470
+ page: 1
1471
+ })
1472
+ ]);
1473
+ const failed = scheduled.docs[0]?.state === "failed";
1474
+ const lastDone = done.docs[0] ? String(done.docs[0].createdAt) : null;
1475
+ let since = lastDone ? Date.parse(lastDone) : void 0;
1476
+ if (since === void 0) {
1477
+ const first = await cms.db.find({ collection: USERS, sort: ["createdAt"], limit: 1, page: 1 });
1478
+ since = first.docs[0] ? Date.parse(String(first.docs[0].createdAt)) : now;
1479
+ }
1480
+ const overdue = now - since > 2 * PERIOD[every];
1481
+ return failed || overdue ? { id: "backups", failed, lastDone } : null;
1482
+ }
1483
+ async function downloadBackup(cms, id, by) {
1484
+ const row = await cms.db.findById({ collection: DATABASE_BACKUPS, id });
1485
+ if (row?.state !== "done" || typeof row.filename !== "string")
1486
+ throw new NotFoundError(DATABASE_BACKUPS, id);
1487
+ const file = await (await backupStorage(cms)).get(row.filename);
1488
+ if (!file)
1489
+ throw new EasyCMSError(`The file ${row.filename} is no longer in the backup storage`, 404);
1490
+ await update(cms, row, { downloadedBy: by, downloadedAt: (/* @__PURE__ */ new Date()).toISOString() });
1491
+ return { filename: row.filename, body: file.body };
1492
+ }
1493
+ async function deleteBackup(cms, id) {
1494
+ const row = await cms.db.findById({ collection: DATABASE_BACKUPS, id });
1495
+ if (!row) throw new NotFoundError(DATABASE_BACKUPS, id);
1496
+ if (row.state === "running" || row.state === "pending")
1497
+ throw new EasyCMSError("A backup in progress cannot be deleted", 409);
1498
+ if (typeof row.filename === "string")
1499
+ await (await backupStorage(cms)).delete(row.filename).catch(() => {
1500
+ });
1501
+ await cms.db.delete({ collection: DATABASE_BACKUPS, id });
1502
+ }
1503
+
1092
1504
  // src/config.ts
1093
1505
  var ADMIN_ICONS = [
1094
1506
  "file-text",
@@ -1175,44 +1587,6 @@ function configSignature(config) {
1175
1587
  }
1176
1588
  var signatures = /* @__PURE__ */ new WeakMap();
1177
1589
 
1178
- // src/copy.ts
1179
- var BATCH = 200;
1180
- async function copyDatabase(source, target, options = {}) {
1181
- const from = source.transfer;
1182
- const to = target.transfer;
1183
- if (!from || !to) throw new EasyCMSError("This database adapter cannot copy databases", 400);
1184
- if (from.schemaHash !== to.schemaHash) {
1185
- throw new EasyCMSError(
1186
- "The two configs describe different collections or fields. Copy between configs with the same collections (import one config into the other and change only `db`).",
1187
- 400
1188
- );
1189
- }
1190
- const tables = from.tables();
1191
- for (const table of tables) {
1192
- if (await to.count(table) > 0) {
1193
- throw new EasyCMSError(
1194
- `The target database is not empty ("${table}" has rows). Copy into a new, empty database.`,
1195
- 400
1196
- );
1197
- }
1198
- }
1199
- let total = 0;
1200
- for (const table of tables) {
1201
- let copied = 0;
1202
- for (; ; ) {
1203
- const rows = await from.read(table, copied, BATCH);
1204
- if (rows.length === 0) break;
1205
- await to.write(table, rows);
1206
- copied += rows.length;
1207
- if (rows.length < BATCH) break;
1208
- }
1209
- total += copied;
1210
- options.onTable?.({ table, rows: copied });
1211
- }
1212
- await to.finish();
1213
- return { tables: tables.length, rows: total };
1214
- }
1215
-
1216
1590
  // src/document.ts
1217
1591
  import { randomUUID } from "crypto";
1218
1592
  var EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -1327,7 +1701,7 @@ function parseId(value) {
1327
1701
  async function validateFields(fields, input, options, prefix = "") {
1328
1702
  const data = {};
1329
1703
  const errors = [];
1330
- const references = [];
1704
+ const references2 = [];
1331
1705
  for (const field of fields) {
1332
1706
  const path = prefix + field.name;
1333
1707
  const raw = input[field.name];
@@ -1345,7 +1719,7 @@ async function validateFields(fields, input, options, prefix = "") {
1345
1719
  );
1346
1720
  data[field.name] = sub.data;
1347
1721
  errors.push(...sub.errors);
1348
- references.push(...sub.references);
1722
+ references2.push(...sub.references);
1349
1723
  continue;
1350
1724
  }
1351
1725
  const localization = options.localization;
@@ -1375,7 +1749,7 @@ async function validateFields(fields, input, options, prefix = "") {
1375
1749
  options,
1376
1750
  failLocale,
1377
1751
  errors,
1378
- references
1752
+ references2
1379
1753
  );
1380
1754
  if (normalized === void 0) continue;
1381
1755
  out[locale] = normalized;
@@ -1392,12 +1766,12 @@ async function validateFields(fields, input, options, prefix = "") {
1392
1766
  if (!field.required) await runCustom(field, null, options, fail);
1393
1767
  continue;
1394
1768
  }
1395
- const value = await normalizeValue(field, raw, path, options, fail, errors, references);
1769
+ const value = await normalizeValue(field, raw, path, options, fail, errors, references2);
1396
1770
  if (value === void 0) continue;
1397
1771
  data[field.name] = value;
1398
1772
  await runCustom(field, value, options, fail);
1399
1773
  }
1400
- return { data, errors, references };
1774
+ return { data, errors, references: references2 };
1401
1775
  }
1402
1776
  async function runCustom(field, value, options, fail) {
1403
1777
  if (!field.validate) return;
@@ -1405,7 +1779,7 @@ async function runCustom(field, value, options, fail) {
1405
1779
  const result = await validate(value, { data: options.root, operation: options.operation });
1406
1780
  if (result !== true) fail(typeof result === "string" ? result : "is invalid");
1407
1781
  }
1408
- async function normalizeValue(field, raw, path, options, fail, errors, references) {
1782
+ async function normalizeValue(field, raw, path, options, fail, errors, references2) {
1409
1783
  switch (field.type) {
1410
1784
  case "text":
1411
1785
  case "textarea":
@@ -1467,7 +1841,7 @@ async function normalizeValue(field, raw, path, options, fail, errors, reference
1467
1841
  if (id === void 0)
1468
1842
  return fail(`must be ${many ? "ids" : "an id"} of "${target}" documents`);
1469
1843
  ids.push(id);
1470
- references.push({
1844
+ references2.push({
1471
1845
  field: path,
1472
1846
  collection: target,
1473
1847
  id,
@@ -1491,19 +1865,19 @@ async function normalizeValue(field, raw, path, options, fail, errors, reference
1491
1865
  }
1492
1866
  const sub = await validateFields(field.fields, row, options, `${path}.${i}.`);
1493
1867
  errors.push(...sub.errors);
1494
- references.push(...sub.references);
1868
+ references2.push(...sub.references);
1495
1869
  const id = typeof row.id === "string" && row.id !== "" ? row.id : randomUUID();
1496
1870
  rows.push({ id, ...sub.data });
1497
1871
  }
1498
1872
  return rows;
1499
1873
  }
1500
1874
  case "blocks":
1501
- return normalizeBlocks(field, raw, path, options, fail, errors, references);
1875
+ return normalizeBlocks(field, raw, path, options, fail, errors, references2);
1502
1876
  case "group":
1503
1877
  return raw;
1504
1878
  }
1505
1879
  }
1506
- async function normalizeBlocks(field, raw, path, options, fail, errors, references) {
1880
+ async function normalizeBlocks(field, raw, path, options, fail, errors, references2) {
1507
1881
  if (!Array.isArray(raw)) return fail("must be an array of blocks");
1508
1882
  if (field.minRows !== void 0 && raw.length < field.minRows)
1509
1883
  return fail(`must have at least ${field.minRows} blocks`);
@@ -1521,7 +1895,7 @@ async function normalizeBlocks(field, raw, path, options, fail, errors, referenc
1521
1895
  }
1522
1896
  const sub = await validateFields(fields, row, options, `${path}.${i}.`);
1523
1897
  errors.push(...sub.errors);
1524
- references.push(...sub.references);
1898
+ references2.push(...sub.references);
1525
1899
  const id = typeof row.id === "string" && row.id !== "" ? row.id : randomUUID();
1526
1900
  rows.push({ id, blockType: row.blockType, ...sub.data });
1527
1901
  }
@@ -1779,32 +2153,513 @@ function applyFieldTypes(config) {
1779
2153
  }
1780
2154
  } : {}
1781
2155
  };
1782
- });
1783
- return {
1784
- config: {
1785
- ...config,
1786
- ...config.collections ? {
1787
- collections: config.collections.map((c, i) => ({
1788
- ...c,
1789
- fields: convert(c.fields, `collections[${i}].fields`)
1790
- }))
1791
- } : {},
1792
- ...config.globals ? {
1793
- globals: config.globals.map((g, i) => ({
1794
- ...g,
1795
- fields: convert(g.fields, `globals[${i}].fields`)
1796
- }))
1797
- } : {}
1798
- },
1799
- issues
1800
- };
2156
+ });
2157
+ return {
2158
+ config: {
2159
+ ...config,
2160
+ ...config.collections ? {
2161
+ collections: config.collections.map((c, i) => ({
2162
+ ...c,
2163
+ fields: convert(c.fields, `collections[${i}].fields`)
2164
+ }))
2165
+ } : {},
2166
+ ...config.globals ? {
2167
+ globals: config.globals.map((g, i) => ({
2168
+ ...g,
2169
+ fields: convert(g.fields, `globals[${i}].fields`)
2170
+ }))
2171
+ } : {}
2172
+ },
2173
+ issues
2174
+ };
2175
+ }
2176
+
2177
+ // src/load-config.ts
2178
+ import { existsSync } from "fs";
2179
+ import { resolve as resolve2 } from "path";
2180
+ import { createJiti } from "jiti";
2181
+
2182
+ // src/versions.ts
2183
+ var DEFAULT_MAX_VERSIONS = 50;
2184
+ function versionLimit(config) {
2185
+ const versions = config.versions;
2186
+ if (!versions) return void 0;
2187
+ return versions === true ? DEFAULT_MAX_VERSIONS : versions.max ?? DEFAULT_MAX_VERSIONS;
2188
+ }
2189
+ var collectionParent = (slug2) => slug2;
2190
+ var globalParent = (slug2) => `global:${slug2}`;
2191
+ function snapshotOf(fields, doc) {
2192
+ const hidden = new Set(fields.filter((f) => f.hidden).map((f) => f.name));
2193
+ const snapshot = {};
2194
+ for (const [key2, value] of Object.entries(doc)) {
2195
+ if (key2 === "id" || key2 === "createdAt" || key2 === "updatedAt" || hidden.has(key2)) continue;
2196
+ snapshot[key2] = value;
2197
+ }
2198
+ return snapshot;
2199
+ }
2200
+ var toSummary = (row) => ({
2201
+ id: row.id,
2202
+ status: row.status ?? null,
2203
+ latest: row.latest === true,
2204
+ author: row.author ?? null,
2205
+ createdAt: String(row.createdAt)
2206
+ });
2207
+ var VersionStore = class {
2208
+ constructor(db) {
2209
+ this.db = db;
2210
+ }
2211
+ db;
2212
+ /** Saves a new latest version and deletes the oldest beyond `max`. */
2213
+ async save(args) {
2214
+ const { parent, doc } = args;
2215
+ const previous = await this.db.find({
2216
+ collection: VERSIONS,
2217
+ where: { and: [...this.of(parent, doc), { latest: { equals: true } }] },
2218
+ sort: [],
2219
+ limit: 0,
2220
+ page: 1
2221
+ });
2222
+ for (const row of previous.docs) {
2223
+ await this.db.update({ collection: VERSIONS, id: row.id, data: { ...row, latest: false } });
2224
+ }
2225
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2226
+ const created = await this.db.create({
2227
+ collection: VERSIONS,
2228
+ data: {
2229
+ parent,
2230
+ doc,
2231
+ status: args.status,
2232
+ latest: true,
2233
+ author: args.author,
2234
+ snapshot: args.snapshot,
2235
+ createdAt: now,
2236
+ updatedAt: now
2237
+ }
2238
+ });
2239
+ await this.prune(parent, doc, args.max);
2240
+ return toSummary(created);
2241
+ }
2242
+ /** The latest version of each document, keyed by document id. */
2243
+ async latest(parent, docs) {
2244
+ const result = /* @__PURE__ */ new Map();
2245
+ if (docs.length === 0) return result;
2246
+ const rows = await this.db.find({
2247
+ collection: VERSIONS,
2248
+ where: {
2249
+ and: [
2250
+ { parent: { equals: parent } },
2251
+ { doc: { in: [...docs] } },
2252
+ { latest: { equals: true } }
2253
+ ]
2254
+ },
2255
+ sort: [],
2256
+ limit: 0,
2257
+ page: 1
2258
+ });
2259
+ for (const row of rows.docs) result.set(String(row.doc), this.toVersion(row));
2260
+ return result;
2261
+ }
2262
+ async list(parent, doc, limit, page) {
2263
+ const found = await this.db.find({
2264
+ collection: VERSIONS,
2265
+ where: { and: this.of(parent, doc) },
2266
+ sort: ["-id"],
2267
+ limit,
2268
+ page
2269
+ });
2270
+ return { ...found, docs: found.docs.map(toSummary) };
2271
+ }
2272
+ async get(parent, doc, id) {
2273
+ const found = await this.db.find({
2274
+ collection: VERSIONS,
2275
+ where: { and: [...this.of(parent, doc), { id: { equals: id } }] },
2276
+ sort: [],
2277
+ limit: 1,
2278
+ page: 1
2279
+ });
2280
+ const row = found.docs[0];
2281
+ return row ? this.toVersion(row) : null;
2282
+ }
2283
+ async deleteAll(parent, doc) {
2284
+ const rows = await this.db.find({
2285
+ collection: VERSIONS,
2286
+ where: { and: this.of(parent, doc) },
2287
+ sort: [],
2288
+ limit: 0,
2289
+ page: 1
2290
+ });
2291
+ for (const row of rows.docs) await this.db.delete({ collection: VERSIONS, id: row.id });
2292
+ }
2293
+ async prune(parent, doc, max) {
2294
+ const rows = await this.db.find({
2295
+ collection: VERSIONS,
2296
+ where: { and: this.of(parent, doc) },
2297
+ sort: ["-id"],
2298
+ limit: 0,
2299
+ page: 1
2300
+ });
2301
+ for (const row of rows.docs.slice(max)) {
2302
+ await this.db.delete({ collection: VERSIONS, id: row.id });
2303
+ }
2304
+ }
2305
+ of(parent, doc) {
2306
+ return [{ parent: { equals: parent } }, { doc: { equals: doc } }];
2307
+ }
2308
+ toVersion(row) {
2309
+ return { ...toSummary(row), data: row.snapshot ?? {} };
2310
+ }
2311
+ };
2312
+
2313
+ // src/roles.ts
2314
+ var ROLE_OPERATIONS = ["read", "create", "update", "delete", "publish"];
2315
+ var GLOBAL_OPERATIONS = ["read", "update", "publish"];
2316
+ var KEY = /^[a-z][a-z0-9-]{0,31}$/;
2317
+ var HISTORY = 50;
2318
+ var TTL = 3e4;
2319
+ var rolesCollection = {
2320
+ slug: ROLES,
2321
+ access: { read: () => false, create: () => false, update: () => false, delete: () => false },
2322
+ fields: [
2323
+ { name: "key", type: "text", required: true, unique: true },
2324
+ { name: "name", type: "text" },
2325
+ { name: "permissions", type: "json" }
2326
+ ]
2327
+ };
2328
+ function governed(slug2) {
2329
+ return !INTERNAL_COLLECTIONS.has(slug2) && slug2 !== API_KEYS;
2330
+ }
2331
+ function references(fields, out = /* @__PURE__ */ new Set()) {
2332
+ for (const field of fields) {
2333
+ if (field.type === "relationship") out.add(field.to);
2334
+ else if (field.type === "upload") out.add("media");
2335
+ else if (field.type === "group" || field.type === "array") references(field.fields, out);
2336
+ else if (field.type === "blocks") for (const b of field.blocks) references(b.fields, out);
2337
+ }
2338
+ return out;
2339
+ }
2340
+ var viewId = {
2341
+ page: (path) => `page:${path}`,
2342
+ widget: (tag) => `widget:${tag}`
2343
+ };
2344
+ var tagOf = (component) => typeof component === "string" ? component : component.tag;
2345
+ var Roles = class {
2346
+ constructor(config, db) {
2347
+ this.config = config;
2348
+ this.db = db;
2349
+ this.enabled = config.auth.rbac;
2350
+ this.versions = new VersionStore(db);
2351
+ }
2352
+ config;
2353
+ db;
2354
+ enabled;
2355
+ versions;
2356
+ cache;
2357
+ synced;
2358
+ /**
2359
+ * Whether the user's role allows an operation: `true`, `false`, or for their own account
2360
+ * (users without read or update on `users`) a constraint to it.
2361
+ */
2362
+ async allows(user, target, operation) {
2363
+ if (!this.enabled || !user || user.role === "admin") return true;
2364
+ if ("collection" in target && !governed(target.collection)) return true;
2365
+ const permissions = await this.permissionsOf(user.role);
2366
+ const ops = "collection" in target ? permissions.collections?.[target.collection] : permissions.globals?.[target.global];
2367
+ if (ops?.includes(operation)) return true;
2368
+ if ("collection" in target && target.collection === USERS) {
2369
+ if (operation === "read" || operation === "update") return { id: { equals: user.id } };
2370
+ }
2371
+ return false;
2372
+ }
2373
+ /**
2374
+ * Whether the user may open an admin page: admins always; with roles, those whose role has
2375
+ * it; without roles, nobody else (`status`, `deliveries`) or everybody (plugin pages).
2376
+ */
2377
+ async canView(user, id) {
2378
+ if (user.role === "admin" && !user.apiKey) return true;
2379
+ if (user.apiKey) return false;
2380
+ if (!this.enabled) return id.startsWith("page:") || id.startsWith("widget:");
2381
+ return (await this.permissionsOf(user.role)).admin?.includes(id) ?? false;
2382
+ }
2383
+ /** Forgets cached permissions, after a role changed. */
2384
+ invalidate() {
2385
+ this.cache = void 0;
2386
+ }
2387
+ /**
2388
+ * Adds the roles of `auth.roles` that are missing; on the first start, gives them everything.
2389
+ * Runs once, on first use (not on startup: `easy-cms migrate` opens the CMS before the table exists).
2390
+ */
2391
+ sync() {
2392
+ this.synced ??= this.addMissing().catch((error) => {
2393
+ this.synced = void 0;
2394
+ throw error;
2395
+ });
2396
+ return this.synced;
2397
+ }
2398
+ async addMissing() {
2399
+ if (!this.enabled) return;
2400
+ const rows = await this.rows();
2401
+ const first = rows.length === 0;
2402
+ const known = new Set(rows.map((r) => String(r.key)));
2403
+ for (const key2 of this.config.auth.roles) {
2404
+ if (known.has(key2)) continue;
2405
+ try {
2406
+ await this.insert(key2, "", first && key2 !== "admin" ? this.everything() : {}, null);
2407
+ } catch (error) {
2408
+ if (!(await this.rows()).some((r) => r.key === key2)) throw error;
2409
+ }
2410
+ }
2411
+ this.invalidate();
2412
+ }
2413
+ /** Whether a role exists (users can only be given existing roles). */
2414
+ async exists(key2) {
2415
+ if (!this.enabled) return this.config.auth.roles.includes(key2);
2416
+ return (await this.load()).has(key2);
2417
+ }
2418
+ /** Roles a user can be given, those of `auth.roles` first; `name` may be empty. */
2419
+ async options() {
2420
+ if (!this.enabled) return this.config.auth.roles.map((key2) => ({ key: key2, name: "" }));
2421
+ await this.sync();
2422
+ 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));
2423
+ }
2424
+ // --- Settings → Roles ----------------------------------------------------
2425
+ async list() {
2426
+ await this.sync();
2427
+ const rows = await this.rows();
2428
+ const counts = /* @__PURE__ */ new Map();
2429
+ for (const row of rows) {
2430
+ counts.set(
2431
+ String(row.key),
2432
+ await this.db.count({ collection: USERS, where: { role: { equals: row.key } } })
2433
+ );
2434
+ }
2435
+ 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));
2436
+ const decided = roles.filter((r) => r.key !== "admin").map((r) => r.permissions);
2437
+ const seen = (group, slug2) => decided.some((p) => p[group]?.[slug2] !== void 0);
2438
+ return {
2439
+ roles,
2440
+ collections: this.config.collections.filter((c) => governed(c.slug)).map((c) => ({
2441
+ slug: c.slug,
2442
+ ops: ROLE_OPERATIONS.filter((op) => op !== "publish" || c.drafts === true),
2443
+ new: !seen("collections", c.slug),
2444
+ references: [...references(c.fields)].filter((s) => s !== c.slug && governed(s))
2445
+ })),
2446
+ globals: this.config.globals.map((g) => ({
2447
+ slug: g.slug,
2448
+ ops: GLOBAL_OPERATIONS.filter((op) => op !== "publish" || g.drafts === true),
2449
+ new: !seen("globals", g.slug)
2450
+ })),
2451
+ views: this.views()
2452
+ };
2453
+ }
2454
+ async create(input, author) {
2455
+ await this.sync();
2456
+ const key2 = typeof input.key === "string" ? input.key.trim() : "";
2457
+ if (!KEY.test(key2)) {
2458
+ throw new ValidationError(ROLES, [
2459
+ { field: "key", message: 'lowercase letters, digits and "-", starting with a letter' }
2460
+ ]);
2461
+ }
2462
+ if ((await this.rows()).some((r) => r.key === key2))
2463
+ throw new ValidationError(ROLES, [{ field: "key", message: "is already used" }]);
2464
+ const row = await this.insert(
2465
+ key2,
2466
+ this.cleanName(input.name),
2467
+ this.clean(input.permissions),
2468
+ author.id
2469
+ );
2470
+ return this.toRole(row, 0);
2471
+ }
2472
+ async update(id, input, author) {
2473
+ const row = await this.row(id);
2474
+ const data = { ...row, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
2475
+ if (input.name !== void 0) data.name = this.cleanName(input.name);
2476
+ if (input.permissions !== void 0) {
2477
+ if (row.key === "admin")
2478
+ throw new EasyCMSError("Admins may always do everything; their role cannot be changed", 400);
2479
+ data.permissions = this.clean(input.permissions);
2480
+ }
2481
+ const { id: _id, ...rest } = data;
2482
+ const saved = await this.db.update({ collection: ROLES, id: row.id, data: rest });
2483
+ await this.record(saved, author.id);
2484
+ this.invalidate();
2485
+ return this.toRole(saved, await this.users(String(row.key)));
2486
+ }
2487
+ async delete(id) {
2488
+ const row = await this.row(id);
2489
+ const key2 = String(row.key);
2490
+ if (this.config.auth.roles.includes(key2))
2491
+ throw new EasyCMSError(`"${key2}" is in auth.roles and cannot be deleted`, 400);
2492
+ const users = await this.users(key2);
2493
+ if (users > 0)
2494
+ throw new EasyCMSError(`${users} user(s) have this role; give them another role first`, 409);
2495
+ await this.db.delete({ collection: ROLES, id: row.id });
2496
+ await this.versions.deleteAll(ROLES, row.id);
2497
+ this.invalidate();
2498
+ }
2499
+ async history(id) {
2500
+ const row = await this.row(id);
2501
+ const list = await this.versions.list(ROLES, row.id, HISTORY, 1);
2502
+ const emails = /* @__PURE__ */ new Map();
2503
+ const out = [];
2504
+ for (const summary of list.docs) {
2505
+ const version = await this.versions.get(ROLES, row.id, summary.id);
2506
+ if (!version) continue;
2507
+ let author = null;
2508
+ if (summary.author !== null) {
2509
+ const k = String(summary.author);
2510
+ if (!emails.has(k)) {
2511
+ const user = await this.db.findById({ collection: USERS, id: summary.author });
2512
+ emails.set(k, typeof user?.email === "string" ? user.email : null);
2513
+ }
2514
+ author = emails.get(k) ?? null;
2515
+ }
2516
+ out.push({
2517
+ id: summary.id,
2518
+ author,
2519
+ createdAt: summary.createdAt,
2520
+ name: String(version.data.name ?? ""),
2521
+ permissions: version.data.permissions ?? {}
2522
+ });
2523
+ }
2524
+ return out;
2525
+ }
2526
+ // -------------------------------------------------------------------------
2527
+ /** Pages and panels roles can be given. */
2528
+ views() {
2529
+ return [
2530
+ { id: "status" },
2531
+ ...this.config.collections.some(
2532
+ (c) => c.slug === WEBHOOK_DELIVERIES || c.slug === EMAIL_DELIVERIES
2533
+ ) ? [{ id: "deliveries" }] : [],
2534
+ ...this.config.admin.pages.map((page) => ({ id: viewId.page(page.path), label: page.label })),
2535
+ ...this.config.admin.dashboard.map((widget) => ({
2536
+ id: viewId.widget(tagOf(widget.component)),
2537
+ ...widget.label !== void 0 ? { label: widget.label } : {}
2538
+ }))
2539
+ ];
2540
+ }
2541
+ /** Every collection and global, and the plugin pages and panels: what roles had before roles. */
2542
+ everything() {
2543
+ const collections = {};
2544
+ for (const c of this.config.collections) {
2545
+ if (governed(c.slug))
2546
+ collections[c.slug] = ROLE_OPERATIONS.filter((op) => op !== "publish" || c.drafts === true);
2547
+ }
2548
+ const globals = {};
2549
+ for (const g of this.config.globals)
2550
+ globals[g.slug] = GLOBAL_OPERATIONS.filter((op) => op !== "publish" || g.drafts === true);
2551
+ const admin = this.views().map((v) => v.id).filter((id) => id.startsWith("page:") || id.startsWith("widget:"));
2552
+ return { collections, globals, admin };
2553
+ }
2554
+ /** Keeps only known collections, globals, operations and pages; `read` comes with the others. */
2555
+ clean(value) {
2556
+ const input = typeof value === "object" && value !== null ? value : {};
2557
+ const pick = (group, allowed2) => {
2558
+ const out = {};
2559
+ if (typeof group !== "object" || group === null) return out;
2560
+ for (const [slug2, ops] of Object.entries(group)) {
2561
+ const valid = allowed2.get(slug2);
2562
+ if (!valid || !Array.isArray(ops)) continue;
2563
+ const picked = valid.filter((op) => ops.includes(op));
2564
+ if (picked.length > 0 && !picked.includes("read")) picked.unshift("read");
2565
+ out[slug2] = picked;
2566
+ }
2567
+ return out;
2568
+ };
2569
+ const collections = new Map(
2570
+ this.config.collections.filter((c) => governed(c.slug)).map((c) => [
2571
+ c.slug,
2572
+ ROLE_OPERATIONS.filter((op) => op !== "publish" || c.drafts === true)
2573
+ ])
2574
+ );
2575
+ const globals = new Map(
2576
+ this.config.globals.map((g) => [
2577
+ g.slug,
2578
+ GLOBAL_OPERATIONS.filter((op) => op !== "publish" || g.drafts === true)
2579
+ ])
2580
+ );
2581
+ const views = new Set(this.views().map((v) => v.id));
2582
+ const admin = Array.isArray(input.admin) ? [
2583
+ ...new Set(
2584
+ input.admin.filter((id) => typeof id === "string" && views.has(id))
2585
+ )
2586
+ ] : [];
2587
+ return {
2588
+ collections: pick(input.collections, collections),
2589
+ globals: pick(input.globals, globals),
2590
+ admin
2591
+ };
2592
+ }
2593
+ cleanName(value) {
2594
+ if (value === void 0 || value === null) return "";
2595
+ if (typeof value !== "string" || value.trim().length > 60)
2596
+ throw new ValidationError(ROLES, [
2597
+ { field: "name", message: "must be at most 60 characters" }
2598
+ ]);
2599
+ return value.trim();
2600
+ }
2601
+ async permissionsOf(key2) {
2602
+ return (await this.load()).get(key2) ?? {};
2603
+ }
2604
+ async load() {
2605
+ if (this.cache && Date.now() - this.cache.at < TTL) return this.cache.byKey;
2606
+ await this.sync();
2607
+ const byKey = /* @__PURE__ */ new Map();
2608
+ for (const row of await this.rows())
2609
+ byKey.set(String(row.key), row.permissions ?? {});
2610
+ this.cache = { at: Date.now(), byKey };
2611
+ return byKey;
2612
+ }
2613
+ async rows() {
2614
+ return (await this.db.find({ collection: ROLES, sort: ["id"], limit: 0, page: 1 })).docs;
2615
+ }
2616
+ async row(id) {
2617
+ const row = await this.db.findById({ collection: ROLES, id });
2618
+ if (!row) throw new NotFoundError(ROLES, id);
2619
+ return row;
2620
+ }
2621
+ users(key2) {
2622
+ return this.db.count({ collection: USERS, where: { role: { equals: key2 } } });
2623
+ }
2624
+ async insert(key2, name, permissions, author) {
2625
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2626
+ const row = await this.db.create({
2627
+ collection: ROLES,
2628
+ data: { key: key2, name, permissions, createdAt: now, updatedAt: now }
2629
+ });
2630
+ await this.record(row, author);
2631
+ this.invalidate();
2632
+ return row;
2633
+ }
2634
+ /** Keeps the role as saved, for its history. */
2635
+ async record(row, author) {
2636
+ await this.versions.save({
2637
+ parent: ROLES,
2638
+ doc: row.id,
2639
+ status: null,
2640
+ snapshot: { name: row.name ?? "", permissions: row.permissions ?? {} },
2641
+ author,
2642
+ max: HISTORY
2643
+ });
2644
+ }
2645
+ toRole(row, users) {
2646
+ const key2 = String(row.key);
2647
+ return {
2648
+ id: row.id,
2649
+ key: key2,
2650
+ name: typeof row.name === "string" ? row.name : "",
2651
+ system: this.config.auth.roles.includes(key2),
2652
+ users,
2653
+ permissions: row.permissions ?? {},
2654
+ updatedAt: String(row.updatedAt)
2655
+ };
2656
+ }
2657
+ };
2658
+ function rank(role, config) {
2659
+ const index = config.auth.roles.indexOf(role.key);
2660
+ return index === -1 ? config.auth.roles.length : index;
1801
2661
  }
1802
2662
 
1803
- // src/load-config.ts
1804
- import { existsSync } from "fs";
1805
- import { resolve } from "path";
1806
- import { createJiti } from "jiti";
1807
-
1808
2663
  // src/webhooks.ts
1809
2664
  import { createHmac as createHmac2, randomUUID as randomUUID2 } from "crypto";
1810
2665
  var WEBHOOK_EVENTS = [
@@ -1994,6 +2849,9 @@ var RESERVED_SLUGS = /* @__PURE__ */ new Set([
1994
2849
  "document-versions",
1995
2850
  "scheduled-jobs",
1996
2851
  "webhook-deliveries",
2852
+ "email-deliveries",
2853
+ "database-backups",
2854
+ "user-roles",
1997
2855
  "api-keys",
1998
2856
  "jobs",
1999
2857
  "migrations",
@@ -2016,6 +2874,7 @@ function validateConfig(config) {
2016
2874
  validateAdmin(config, add);
2017
2875
  validateAdminViews(config, add);
2018
2876
  validateUpload(config, add);
2877
+ validateBackups(config, add);
2019
2878
  validateAuth(config, add);
2020
2879
  validateCors(config.cors, add);
2021
2880
  validateLocalization(config, add);
@@ -2025,17 +2884,17 @@ function validateConfig(config) {
2025
2884
  const collectionSlugs = /* @__PURE__ */ new Set([...BUILTIN_COLLECTIONS]);
2026
2885
  const seenCollections = /* @__PURE__ */ new Set();
2027
2886
  collections.forEach((collection2, i) => {
2028
- const slug = checkSlug(collection2, `collections[${i}]`, seenCollections, add);
2029
- if (slug) collectionSlugs.add(slug);
2887
+ const slug2 = checkSlug(collection2, `collections[${i}]`, seenCollections, add);
2888
+ if (slug2) collectionSlugs.add(slug2);
2030
2889
  });
2031
2890
  const menu = config.admin?.menu;
2032
2891
  if (menu !== void 0) {
2033
2892
  if (!Array.isArray(menu)) {
2034
2893
  add("admin.menu", "must be an array of collection slugs", "e.g. menu: ['posts', 'media']");
2035
2894
  } else {
2036
- for (const [i, slug] of menu.entries()) {
2037
- if (typeof slug !== "string" || !collectionSlugs.has(slug))
2038
- add(`admin.menu[${i}]`, `unknown collection ${JSON.stringify(slug)}`);
2895
+ for (const [i, slug2] of menu.entries()) {
2896
+ if (typeof slug2 !== "string" || !collectionSlugs.has(slug2))
2897
+ add(`admin.menu[${i}]`, `unknown collection ${JSON.stringify(slug2)}`);
2039
2898
  }
2040
2899
  }
2041
2900
  }
@@ -2197,6 +3056,7 @@ function validateAdminViews(config, add) {
2197
3056
  "e.g. dashboard: [{ component: 'ecms-stats-widget' }]"
2198
3057
  );
2199
3058
  } else {
3059
+ const tags = /* @__PURE__ */ new Set();
2200
3060
  widgets2.forEach((widget, i) => {
2201
3061
  const path = `admin.dashboard[${i}]`;
2202
3062
  if (typeof widget !== "object" || widget === null) {
@@ -2207,6 +3067,19 @@ function validateAdminViews(config, add) {
2207
3067
  validateComponent(w.component, `${path}.component`, add);
2208
3068
  if (w.width !== void 0 && w.width !== "half" && w.width !== "full")
2209
3069
  add(`${path}.width`, 'must be "half" or "full"');
3070
+ if (w.label !== void 0 && !(typeof w.label === "string" && w.label.trim() !== "") && !(typeof w.label === "object" && w.label !== null && !Array.isArray(w.label)))
3071
+ add(`${path}.label`, "must be a string or { en, th }");
3072
+ const component = w.component;
3073
+ const tag = typeof component === "string" ? component : component?.tag;
3074
+ if (config.auth?.rbac === true && typeof tag === "string") {
3075
+ if (tags.has(tag))
3076
+ add(
3077
+ `${path}.component`,
3078
+ `another panel uses "${tag}"`,
3079
+ "with auth.rbac, roles are given panels by their tag: use one tag per panel"
3080
+ );
3081
+ tags.add(tag);
3082
+ }
2210
3083
  access(w.access, path);
2211
3084
  });
2212
3085
  }
@@ -2220,6 +3093,8 @@ function validateIcon(container, path, add) {
2220
3093
  function validateAuth(config, add) {
2221
3094
  const auth = config.auth;
2222
3095
  if (auth === void 0) return;
3096
+ if (auth.rbac !== void 0 && typeof auth.rbac !== "boolean")
3097
+ add("auth.rbac", "must be true or false");
2223
3098
  if (auth.roles !== void 0) {
2224
3099
  const roles = auth.roles;
2225
3100
  if (!Array.isArray(roles) || roles.some((r) => typeof r !== "string" || r === "")) {
@@ -2376,10 +3251,10 @@ function validateWebhooks(config, add) {
2376
3251
  if (!WEBHOOK_EVENTS.includes(event))
2377
3252
  add(`${path}.events`, `unknown event "${event}"`, `use: ${WEBHOOK_EVENTS.join(", ")}`);
2378
3253
  }
2379
- for (const slug of hook?.collections ?? [])
2380
- if (!collections.has(slug)) add(`${path}.collections`, `unknown collection "${slug}"`);
2381
- for (const slug of hook?.globals ?? [])
2382
- if (!globals.has(slug)) add(`${path}.globals`, `unknown global "${slug}"`);
3254
+ for (const slug2 of hook?.collections ?? [])
3255
+ if (!collections.has(slug2)) add(`${path}.collections`, `unknown collection "${slug2}"`);
3256
+ for (const slug2 of hook?.globals ?? [])
3257
+ if (!globals.has(slug2)) add(`${path}.globals`, `unknown global "${slug2}"`);
2383
3258
  }
2384
3259
  }
2385
3260
  function validateCors(cors, add) {
@@ -2432,6 +3307,32 @@ function validateUpload(config, add) {
2432
3307
  add("upload.fromURL.allowPrivate", "must be true or false");
2433
3308
  }
2434
3309
  var HOST_PATTERN = /^(\*|(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*)$/i;
3310
+ function validateBackups(config, add) {
3311
+ const backups = config.backups;
3312
+ if (backups === void 0) return;
3313
+ if (typeof backups !== "object" || backups === null) {
3314
+ add("backups", "must be an object", "e.g. backups: { every: 'day', keep: 7 }");
3315
+ return;
3316
+ }
3317
+ const { every, at, keep, dir, storage, sqlite } = backups;
3318
+ if (every !== void 0 && every !== "day" && every !== "week")
3319
+ add("backups.every", 'must be "day" or "week"');
3320
+ if (at !== void 0 && (typeof at !== "string" || !/^([01]\d|2[0-3]):[0-5]\d$/.test(at)))
3321
+ add("backups.at", 'must be a time like "03:00"');
3322
+ if (keep !== void 0 && !(Number.isInteger(keep) && keep >= 1))
3323
+ add("backups.keep", "must be a whole number, 1 or more");
3324
+ if (dir !== void 0 && (typeof dir !== "string" || dir.trim() === ""))
3325
+ add("backups.dir", "must be a folder name");
3326
+ if (typeof dir === "string" && dir.replace(/^\.\//, "").replace(/\/+$/, "") === (config.upload?.dir ?? "uploads").replace(/^\.\//, "").replace(/\/+$/, ""))
3327
+ add("backups.dir", "must not be the uploads folder: uploads are served publicly");
3328
+ if (storage !== void 0 && (typeof storage !== "object" || storage === null || typeof storage.put !== "function"))
3329
+ add("backups.storage", "must be a storage adapter, e.g. s3Storage()");
3330
+ if (sqlite !== void 0 && typeof sqlite !== "function")
3331
+ add(
3332
+ "backups.sqlite",
3333
+ "must be the sqlite adapter, e.g. import { sqlite } from '@easy-cms/db-sqlite'"
3334
+ );
3335
+ }
2435
3336
  function asArray(value, path, add) {
2436
3337
  if (value === void 0) return [];
2437
3338
  if (!Array.isArray(value)) {
@@ -2441,24 +3342,24 @@ function asArray(value, path, add) {
2441
3342
  return value;
2442
3343
  }
2443
3344
  function checkSlug(container, path, seen, add) {
2444
- const slug = container.slug;
2445
- if (typeof slug !== "string" || !SLUG_PATTERN.test(slug)) {
3345
+ const slug2 = container.slug;
3346
+ if (typeof slug2 !== "string" || !SLUG_PATTERN.test(slug2)) {
2446
3347
  add(
2447
3348
  `${path}.slug`,
2448
- `must be lowercase letters, digits, "-" or "_", starting with a letter (got ${JSON.stringify(slug)})`
3349
+ `must be lowercase letters, digits, "-" or "_", starting with a letter (got ${JSON.stringify(slug2)})`
2449
3350
  );
2450
3351
  return void 0;
2451
3352
  }
2452
- if (RESERVED_SLUGS.has(slug)) {
2453
- add(`${path}.slug`, `"${slug}" is reserved by Easy CMS`, "choose another slug");
3353
+ if (RESERVED_SLUGS.has(slug2)) {
3354
+ add(`${path}.slug`, `"${slug2}" is reserved by Easy CMS`, "choose another slug");
2454
3355
  return void 0;
2455
3356
  }
2456
- if (seen.has(slug)) {
2457
- add(`${path}.slug`, `duplicate slug "${slug}"`);
3357
+ if (seen.has(slug2)) {
3358
+ add(`${path}.slug`, `duplicate slug "${slug2}"`);
2458
3359
  return void 0;
2459
3360
  }
2460
- seen.add(slug);
2461
- return slug;
3361
+ seen.add(slug2);
3362
+ return slug2;
2462
3363
  }
2463
3364
  function validateContainer(container, path, collectionSlugs, add) {
2464
3365
  if (!Array.isArray(container.fields)) {
@@ -2888,6 +3789,7 @@ async function resolveConfig(input) {
2888
3789
  },
2889
3790
  auth: {
2890
3791
  roles: config.auth?.roles ?? DEFAULT_ROLES,
3792
+ rbac: config.auth?.rbac === true,
2891
3793
  tokenExpiration: config.auth?.tokenExpiration ?? DEFAULT_TOKEN_EXPIRATION,
2892
3794
  maxLoginAttempts: config.auth?.maxLoginAttempts ?? 5,
2893
3795
  lockWindow: config.auth?.lockWindow ?? 15 * 60,
@@ -2899,11 +3801,14 @@ async function resolveConfig(input) {
2899
3801
  collections: [
2900
3802
  ...config.collections ?? [],
2901
3803
  ...internalCollections,
2902
- ...usesVersions(config) ? [versionsCollection] : [],
3804
+ // Roles keep their history as versions.
3805
+ ...usesVersions(config) || config.auth?.rbac ? [versionsCollection] : [],
2903
3806
  ...usesSchedule(config) ? [scheduledJobsCollection] : [],
2904
3807
  ...(config.webhooks?.length ?? 0) > 0 ? [webhookDeliveriesCollection] : [],
2905
3808
  ...config.apiKeys ? [apiKeysCollection] : [],
2906
- ...config.email ? [emailDeliveriesCollection] : []
3809
+ ...config.email ? [emailDeliveriesCollection] : [],
3810
+ databaseBackupsCollection,
3811
+ ...config.auth?.rbac ? [rolesCollection] : []
2907
3812
  ],
2908
3813
  globals: config.globals ?? [],
2909
3814
  endpoints: config.endpoints ?? [],
@@ -2932,7 +3837,7 @@ var CONFIG_FILE_NAMES = [
2932
3837
  ];
2933
3838
  function findConfigFile(cwd = process.cwd()) {
2934
3839
  for (const name of CONFIG_FILE_NAMES) {
2935
- const path = resolve(
3840
+ const path = resolve2(
2936
3841
  /* turbopackIgnore: true */
2937
3842
  cwd,
2938
3843
  name
@@ -2951,7 +3856,7 @@ async function importConfig(file) {
2951
3856
  }
2952
3857
  async function loadConfig(options = {}) {
2953
3858
  const cwd = options.cwd ?? process.cwd();
2954
- const file = options.configFile ? resolve(cwd, options.configFile) : findConfigFile(cwd);
3859
+ const file = options.configFile ? resolve2(cwd, options.configFile) : findConfigFile(cwd);
2955
3860
  if (!file || !existsSync(file)) {
2956
3861
  const looked = options.configFile ?? CONFIG_FILE_NAMES.join(", ");
2957
3862
  throw new Error(
@@ -3190,12 +4095,12 @@ async function populate(load, collections, fields, docs, depth) {
3190
4095
  }
3191
4096
  const found = /* @__PURE__ */ new Map();
3192
4097
  await Promise.all(
3193
- [...wanted].map(async ([slug, ids]) => {
3194
- const collection2 = bySlug.get(slug);
4098
+ [...wanted].map(async ([slug2, ids]) => {
4099
+ const collection2 = bySlug.get(slug2);
3195
4100
  if (!collection2) return;
3196
4101
  const raw = await load(collection2, [...ids]);
3197
4102
  const populated = await populate(load, collections, collection2.fields, raw, depth - 1);
3198
- found.set(slug, new Map(populated.map((d) => [d.id, d])));
4103
+ found.set(slug2, new Map(populated.map((d) => [d.id, d])));
3199
4104
  })
3200
4105
  );
3201
4106
  for (const slot of slots) slot.assign(found.get(slot.collection) ?? /* @__PURE__ */ new Map());
@@ -3448,181 +4353,6 @@ function read(response, maxBytes, deadline) {
3448
4353
  });
3449
4354
  }
3450
4355
 
3451
- // src/storage.ts
3452
- import { mkdir, readFile, rm, stat, writeFile } from "fs/promises";
3453
- import { dirname, isAbsolute, join, normalize, resolve as resolve2, sep } from "path";
3454
- function localStorage(options = {}) {
3455
- let root = resolve2(
3456
- /* turbopackIgnore: true */
3457
- options.dir ?? "uploads"
3458
- );
3459
- const pathOf = (key2) => {
3460
- const path = normalize(join(
3461
- /* turbopackIgnore: true */
3462
- root,
3463
- key2
3464
- ));
3465
- if (!path.startsWith(root + sep)) throw new Error(`Invalid storage key "${key2}"`);
3466
- return path;
3467
- };
3468
- return {
3469
- name: "local",
3470
- init({ cwd }) {
3471
- const dir = options.dir ?? "uploads";
3472
- root = isAbsolute(dir) ? dir : resolve2(
3473
- /* turbopackIgnore: true */
3474
- cwd,
3475
- dir
3476
- );
3477
- },
3478
- async put(key2, data) {
3479
- const path = pathOf(key2);
3480
- await mkdir(dirname(path), { recursive: true });
3481
- await writeFile(path, data, { flag: "wx" });
3482
- },
3483
- async get(key2) {
3484
- const path = pathOf(key2);
3485
- const info = await stat(path).catch(() => void 0);
3486
- if (!info?.isFile()) return null;
3487
- return { body: new Uint8Array(await readFile(path)), size: info.size };
3488
- },
3489
- async delete(key2) {
3490
- await rm(pathOf(key2), { force: true });
3491
- }
3492
- };
3493
- }
3494
-
3495
- // src/versions.ts
3496
- var DEFAULT_MAX_VERSIONS = 50;
3497
- function versionLimit(config) {
3498
- const versions = config.versions;
3499
- if (!versions) return void 0;
3500
- return versions === true ? DEFAULT_MAX_VERSIONS : versions.max ?? DEFAULT_MAX_VERSIONS;
3501
- }
3502
- var collectionParent = (slug) => slug;
3503
- var globalParent = (slug) => `global:${slug}`;
3504
- function snapshotOf(fields, doc) {
3505
- const hidden = new Set(fields.filter((f) => f.hidden).map((f) => f.name));
3506
- const snapshot = {};
3507
- for (const [key2, value] of Object.entries(doc)) {
3508
- if (key2 === "id" || key2 === "createdAt" || key2 === "updatedAt" || hidden.has(key2)) continue;
3509
- snapshot[key2] = value;
3510
- }
3511
- return snapshot;
3512
- }
3513
- var toSummary = (row) => ({
3514
- id: row.id,
3515
- status: row.status ?? null,
3516
- latest: row.latest === true,
3517
- author: row.author ?? null,
3518
- createdAt: String(row.createdAt)
3519
- });
3520
- var VersionStore = class {
3521
- constructor(db) {
3522
- this.db = db;
3523
- }
3524
- db;
3525
- /** Saves a new latest version and deletes the oldest beyond `max`. */
3526
- async save(args) {
3527
- const { parent, doc } = args;
3528
- const previous = await this.db.find({
3529
- collection: VERSIONS,
3530
- where: { and: [...this.of(parent, doc), { latest: { equals: true } }] },
3531
- sort: [],
3532
- limit: 0,
3533
- page: 1
3534
- });
3535
- for (const row of previous.docs) {
3536
- await this.db.update({ collection: VERSIONS, id: row.id, data: { ...row, latest: false } });
3537
- }
3538
- const now = (/* @__PURE__ */ new Date()).toISOString();
3539
- const created = await this.db.create({
3540
- collection: VERSIONS,
3541
- data: {
3542
- parent,
3543
- doc,
3544
- status: args.status,
3545
- latest: true,
3546
- author: args.author,
3547
- snapshot: args.snapshot,
3548
- createdAt: now,
3549
- updatedAt: now
3550
- }
3551
- });
3552
- await this.prune(parent, doc, args.max);
3553
- return toSummary(created);
3554
- }
3555
- /** The latest version of each document, keyed by document id. */
3556
- async latest(parent, docs) {
3557
- const result = /* @__PURE__ */ new Map();
3558
- if (docs.length === 0) return result;
3559
- const rows = await this.db.find({
3560
- collection: VERSIONS,
3561
- where: {
3562
- and: [
3563
- { parent: { equals: parent } },
3564
- { doc: { in: [...docs] } },
3565
- { latest: { equals: true } }
3566
- ]
3567
- },
3568
- sort: [],
3569
- limit: 0,
3570
- page: 1
3571
- });
3572
- for (const row of rows.docs) result.set(String(row.doc), this.toVersion(row));
3573
- return result;
3574
- }
3575
- async list(parent, doc, limit, page) {
3576
- const found = await this.db.find({
3577
- collection: VERSIONS,
3578
- where: { and: this.of(parent, doc) },
3579
- sort: ["-id"],
3580
- limit,
3581
- page
3582
- });
3583
- return { ...found, docs: found.docs.map(toSummary) };
3584
- }
3585
- async get(parent, doc, id) {
3586
- const found = await this.db.find({
3587
- collection: VERSIONS,
3588
- where: { and: [...this.of(parent, doc), { id: { equals: id } }] },
3589
- sort: [],
3590
- limit: 1,
3591
- page: 1
3592
- });
3593
- const row = found.docs[0];
3594
- return row ? this.toVersion(row) : null;
3595
- }
3596
- async deleteAll(parent, doc) {
3597
- const rows = await this.db.find({
3598
- collection: VERSIONS,
3599
- where: { and: this.of(parent, doc) },
3600
- sort: [],
3601
- limit: 0,
3602
- page: 1
3603
- });
3604
- for (const row of rows.docs) await this.db.delete({ collection: VERSIONS, id: row.id });
3605
- }
3606
- async prune(parent, doc, max) {
3607
- const rows = await this.db.find({
3608
- collection: VERSIONS,
3609
- where: { and: this.of(parent, doc) },
3610
- sort: ["-id"],
3611
- limit: 0,
3612
- page: 1
3613
- });
3614
- for (const row of rows.docs.slice(max)) {
3615
- await this.db.delete({ collection: VERSIONS, id: row.id });
3616
- }
3617
- }
3618
- of(parent, doc) {
3619
- return [{ parent: { equals: parent } }, { doc: { equals: doc } }];
3620
- }
3621
- toVersion(row) {
3622
- return { ...toSummary(row), data: row.snapshot ?? {} };
3623
- }
3624
- };
3625
-
3626
4356
  // src/local-api.ts
3627
4357
  async function createEasyCMS(config, options = {}) {
3628
4358
  const resolved3 = await resolveConfig(config);
@@ -3660,6 +4390,8 @@ var EasyCMS = class {
3660
4390
  storage;
3661
4391
  /** Project root: relative paths in the config (e.g. `admin.modules`) start here. */
3662
4392
  cwd;
4393
+ /** Roles and their permissions (`auth.rbac`, Settings → Roles). */
4394
+ roles;
3663
4395
  versions;
3664
4396
  webhooks;
3665
4397
  mailer;
@@ -3670,6 +4402,7 @@ var EasyCMS = class {
3670
4402
  this.storage = storage;
3671
4403
  this.cwd = cwd;
3672
4404
  this.versions = new VersionStore(db);
4405
+ this.roles = new Roles(config, db);
3673
4406
  this.webhooks = new Webhooks(
3674
4407
  config.webhooks ?? [],
3675
4408
  logger,
@@ -3760,7 +4493,7 @@ var EasyCMS = class {
3760
4493
  const config = this.collection(MEDIA);
3761
4494
  const guard = guardOf(options);
3762
4495
  if (guard.enforce) {
3763
- this.checkKey(guard, { collection: MEDIA }, "create");
4496
+ await this.checkGrant(guard, { collection: MEDIA }, "create");
3764
4497
  const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data });
3765
4498
  if (allowed2 !== true) throw deny(guard.user);
3766
4499
  }
@@ -3820,7 +4553,7 @@ var EasyCMS = class {
3820
4553
  const guard = guardOf(options);
3821
4554
  const fromURL = this.config.upload.fromURL;
3822
4555
  if (guard.enforce) {
3823
- this.checkKey(guard, { collection: MEDIA }, "create");
4556
+ await this.checkGrant(guard, { collection: MEDIA }, "create");
3824
4557
  const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data });
3825
4558
  if (allowed2 !== true) throw deny(guard.user);
3826
4559
  if (!fromURL)
@@ -3975,7 +4708,7 @@ var EasyCMS = class {
3975
4708
  let parsed;
3976
4709
  if (id === null) {
3977
4710
  if (guard.enforce) {
3978
- this.checkKey(guard, { collection: collection2 }, "create");
4711
+ await this.checkGrant(guard, { collection: collection2 }, "create");
3979
4712
  const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data: raw });
3980
4713
  if (allowed2 !== true) throw deny(guard.user);
3981
4714
  }
@@ -4022,17 +4755,17 @@ var EasyCMS = class {
4022
4755
  return token ? verifyPreviewToken(this.config.secret, token) : null;
4023
4756
  }
4024
4757
  /** Live preview of a global. See `preview`. */
4025
- async previewGlobal(slug, data, options = {}) {
4026
- const config = this.global(slug);
4758
+ async previewGlobal(slug2, data, options = {}) {
4759
+ const config = this.global(slug2);
4027
4760
  const guard = guardOf(options);
4028
4761
  await this.checkGlobalAccess(config, "update", guard);
4029
- const raw = asObject(data, slug);
4762
+ const raw = asObject(data, slug2);
4030
4763
  const input = await filterInput(
4031
4764
  config.fields,
4032
4765
  raw,
4033
4766
  this.fieldChecker("update", guard, void 0, raw)
4034
4767
  );
4035
- const saved = await this.db.findGlobal({ slug }) ?? {};
4768
+ const saved = await this.db.findGlobal({ slug: slug2 }) ?? {};
4036
4769
  const current = await this.globalDraft(config, saved);
4037
4770
  const merged = generateSlugs(
4038
4771
  config.fields,
@@ -4095,7 +4828,7 @@ var EasyCMS = class {
4095
4828
  if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
4096
4829
  await this.checkDocumentAccess(config, "update", guard, parsed, raw);
4097
4830
  if (config.drafts && (mode === "unpublish" || raw.status === "published"))
4098
- this.checkKey(guard, { collection: collection2 }, "publish");
4831
+ await this.checkGrant(guard, { collection: collection2 }, "publish");
4099
4832
  const [drafted] = await this.withDrafts(config, [existing]);
4100
4833
  const live = options.live === true && mode === "save";
4101
4834
  const current = live ? existing : drafted;
@@ -4202,11 +4935,11 @@ var EasyCMS = class {
4202
4935
  const [out] = await this.output(config, [existing], guard, { depth: 0, draft: true });
4203
4936
  return out;
4204
4937
  }
4205
- async findGlobal(slug, options = {}) {
4206
- const config = this.global(slug);
4938
+ async findGlobal(slug2, options = {}) {
4939
+ const config = this.global(slug2);
4207
4940
  const guard = guardOf(options);
4208
4941
  await this.checkGlobalAccess(config, "read", guard);
4209
- const saved = await this.db.findGlobal({ slug });
4942
+ const saved = await this.db.findGlobal({ slug: slug2 });
4210
4943
  const stored = saved && options.draft ? await this.globalDraft(config, saved) : saved;
4211
4944
  const data = fillMissing(
4212
4945
  config.fields,
@@ -4220,33 +4953,33 @@ var EasyCMS = class {
4220
4953
  const { id: _id, ...doc } = out;
4221
4954
  return doc;
4222
4955
  }
4223
- async updateGlobal(slug, data, options = {}) {
4224
- return this.saveGlobal(slug, asObject(data, slug), options);
4956
+ async updateGlobal(slug2, data, options = {}) {
4957
+ return this.saveGlobal(slug2, asObject(data, slug2), options);
4225
4958
  }
4226
4959
  /** Takes a global off the site (`status: 'draft'`). See `unpublish`. */
4227
- async unpublishGlobal(slug, options = {}) {
4228
- if (!this.global(slug).drafts) throw new QueryError(`"${slug}" has no drafts`);
4229
- return this.saveGlobal(slug, { status: "draft" }, options, "unpublish");
4960
+ async unpublishGlobal(slug2, options = {}) {
4961
+ if (!this.global(slug2).drafts) throw new QueryError(`"${slug2}" has no drafts`);
4962
+ return this.saveGlobal(slug2, { status: "draft" }, options, "unpublish");
4230
4963
  }
4231
4964
  /** Throws away the unpublished draft of a published global. */
4232
- async discardGlobalDraft(slug, options = {}) {
4233
- const config = this.global(slug);
4965
+ async discardGlobalDraft(slug2, options = {}) {
4966
+ const config = this.global(slug2);
4234
4967
  const guard = guardOf(options);
4235
4968
  await this.checkGlobalAccess(config, "update", guard);
4236
- const saved = await this.db.findGlobal({ slug });
4969
+ const saved = await this.db.findGlobal({ slug: slug2 });
4237
4970
  if (saved && await this.globalDraft(config, saved) !== saved) {
4238
- await this.saveVersion(config, globalParent(slug), 0, saved, guard);
4971
+ await this.saveVersion(config, globalParent(slug2), 0, saved, guard);
4239
4972
  }
4240
- return this.findGlobal(slug, { ...options, draft: true });
4973
+ return this.findGlobal(slug2, { ...options, draft: true });
4241
4974
  }
4242
4975
  /** Saved versions of a global, newest first. Needs update access. */
4243
- async findGlobalVersions(slug, options = {}) {
4244
- await this.globalVersionTarget(slug, options);
4245
- return this.versions.list(globalParent(slug), 0, options.limit ?? 20, options.page ?? 1);
4976
+ async findGlobalVersions(slug2, options = {}) {
4977
+ await this.globalVersionTarget(slug2, options);
4978
+ return this.versions.list(globalParent(slug2), 0, options.limit ?? 20, options.page ?? 1);
4246
4979
  }
4247
- async findGlobalVersion(slug, versionId, options = {}) {
4248
- const config = await this.globalVersionTarget(slug, options);
4249
- const version = await this.versions.get(globalParent(slug), 0, versionId);
4980
+ async findGlobalVersion(slug2, versionId, options = {}) {
4981
+ const config = await this.globalVersionTarget(slug2, options);
4982
+ const version = await this.versions.get(globalParent(slug2), 0, versionId);
4250
4983
  if (!version) return null;
4251
4984
  const data = fillMissing(
4252
4985
  config.fields,
@@ -4260,21 +4993,21 @@ var EasyCMS = class {
4260
4993
  return { ...version, data: { ...doc, updatedAt: version.createdAt } };
4261
4994
  }
4262
4995
  /** Makes an old version of a global current (as a draft when the global has drafts). */
4263
- async restoreGlobalVersion(slug, versionId, options = {}) {
4264
- const config = await this.globalVersionTarget(slug, options);
4265
- const version = await this.versions.get(globalParent(slug), 0, versionId);
4266
- if (!version) throw new NotFoundError(`${slug} version`, versionId);
4996
+ async restoreGlobalVersion(slug2, versionId, options = {}) {
4997
+ const config = await this.globalVersionTarget(slug2, options);
4998
+ const version = await this.versions.get(globalParent(slug2), 0, versionId);
4999
+ if (!version) throw new NotFoundError(`${slug2} version`, versionId);
4267
5000
  const { status: _status, ...data } = version.data;
4268
5001
  return this.saveGlobal(
4269
- slug,
5002
+ slug2,
4270
5003
  config.drafts ? { ...data, status: "draft" } : data,
4271
5004
  options,
4272
5005
  "restore"
4273
5006
  );
4274
5007
  }
4275
- async globalVersionTarget(slug, options) {
4276
- const config = this.global(slug);
4277
- if (!versionLimit(config)) throw new QueryError(`"${slug}" has no versions`);
5008
+ async globalVersionTarget(slug2, options) {
5009
+ const config = this.global(slug2);
5010
+ if (!versionLimit(config)) throw new QueryError(`"${slug2}" has no versions`);
4278
5011
  await this.checkGlobalAccess(config, "update", guardOf(options));
4279
5012
  return config;
4280
5013
  }
@@ -4285,18 +5018,18 @@ var EasyCMS = class {
4285
5018
  if (version?.status !== "draft") return saved;
4286
5019
  return { ...version.data, updatedAt: version.createdAt, status: "draft" };
4287
5020
  }
4288
- async saveGlobal(slug, raw, options, mode = "save") {
4289
- const config = this.global(slug);
5021
+ async saveGlobal(slug2, raw, options, mode = "save") {
5022
+ const config = this.global(slug2);
4290
5023
  const guard = guardOf(options);
4291
5024
  await this.checkGlobalAccess(config, "update", guard);
4292
5025
  if (config.drafts && (mode === "unpublish" || raw.status === "published"))
4293
- this.checkKey(guard, { global: slug }, "publish");
5026
+ await this.checkGrant(guard, { global: slug2 }, "publish");
4294
5027
  const input = await filterInput(
4295
5028
  config.fields,
4296
5029
  raw,
4297
5030
  this.fieldChecker("update", guard, void 0, raw)
4298
5031
  );
4299
- const existing = await this.db.findGlobal({ slug }) ?? {};
5032
+ const existing = await this.db.findGlobal({ slug: slug2 }) ?? {};
4300
5033
  const current = await this.globalDraft(config, existing);
4301
5034
  const localized = mode === "restore" ? input : this.toMaps(config, input, current, options);
4302
5035
  const merged = generateSlugs(
@@ -4320,26 +5053,26 @@ var EasyCMS = class {
4320
5053
  const now = (/* @__PURE__ */ new Date()).toISOString();
4321
5054
  if (mode !== "unpublish" && this.separateDrafts(config) && existing.status === "published" && prepared.status === "draft") {
4322
5055
  const draft = { ...prepared, updatedAt: now };
4323
- await this.saveVersion(config, globalParent(slug), 0, draft, guard);
4324
- await this.notify(config.hooks?.afterChange, "afterChange", slug, {
5056
+ await this.saveVersion(config, globalParent(slug2), 0, draft, guard);
5057
+ await this.notify(config.hooks?.afterChange, "afterChange", slug2, {
4325
5058
  ...base,
4326
5059
  doc: draft,
4327
5060
  previousDoc: current,
4328
5061
  operation: "update"
4329
5062
  });
4330
5063
  this.emit(config, "draft", draft);
4331
- return this.findGlobal(slug, { ...options, draft: true });
5064
+ return this.findGlobal(slug2, { ...options, draft: true });
4332
5065
  }
4333
- const doc = await this.db.updateGlobal({ slug, data: { ...prepared, updatedAt: now } });
4334
- await this.saveVersion(config, globalParent(slug), 0, doc, guard);
4335
- await this.notify(config.hooks?.afterChange, "afterChange", slug, {
5066
+ const doc = await this.db.updateGlobal({ slug: slug2, data: { ...prepared, updatedAt: now } });
5067
+ await this.saveVersion(config, globalParent(slug2), 0, doc, guard);
5068
+ await this.notify(config.hooks?.afterChange, "afterChange", slug2, {
4336
5069
  ...base,
4337
5070
  doc,
4338
5071
  previousDoc: current,
4339
5072
  operation: "update"
4340
5073
  });
4341
5074
  this.emit(config, "update", doc, existing.status);
4342
- return this.findGlobal(slug, { ...options, draft: true });
5075
+ return this.findGlobal(slug2, { ...options, draft: true });
4343
5076
  }
4344
5077
  /**
4345
5078
  * What `user` may do with one document. Resolves `where`-style access
@@ -4442,6 +5175,9 @@ var EasyCMS = class {
4442
5175
  async runJobs(now = /* @__PURE__ */ new Date()) {
4443
5176
  const scheduled = await this.runScheduled(now);
4444
5177
  await this.pruneFailedDeliveries(now);
5178
+ await runDueBackups(this, now).catch(
5179
+ (error) => this.logger.error(`Backups: ${error.message}`)
5180
+ );
4445
5181
  return {
4446
5182
  ...scheduled,
4447
5183
  webhooks: await this.retryWebhooks(now),
@@ -4511,9 +5247,9 @@ var EasyCMS = class {
4511
5247
  const action = row.action;
4512
5248
  try {
4513
5249
  if (parent.startsWith("global:")) {
4514
- const slug = parent.slice("global:".length);
4515
- if (action === "publish") await this.saveGlobal(slug, { status: "published" }, {});
4516
- else await this.saveGlobal(slug, { status: "draft" }, {}, "unpublish");
5250
+ const slug2 = parent.slice("global:".length);
5251
+ if (action === "publish") await this.saveGlobal(slug2, { status: "published" }, {});
5252
+ else await this.saveGlobal(slug2, { status: "draft" }, {}, "unpublish");
4517
5253
  } else if (action === "publish") {
4518
5254
  await this.updateDocument(parent, row.doc, { status: "published" }, { depth: 0 });
4519
5255
  } else {
@@ -4557,17 +5293,17 @@ var EasyCMS = class {
4557
5293
  const { parsed } = await this.scheduleTarget(collection2, id, options);
4558
5294
  await this.removeJob(collection2, parsed, jobId);
4559
5295
  }
4560
- async scheduleGlobal(slug, job, options = {}) {
4561
- await this.globalScheduleTarget(slug, options);
4562
- return this.addJob(`global:${slug}`, 0, job, options);
5296
+ async scheduleGlobal(slug2, job, options = {}) {
5297
+ await this.globalScheduleTarget(slug2, options);
5298
+ return this.addJob(`global:${slug2}`, 0, job, options);
4563
5299
  }
4564
- async scheduledGlobal(slug, options = {}) {
4565
- await this.globalScheduleTarget(slug, options);
4566
- return this.pendingJobs(`global:${slug}`, 0);
5300
+ async scheduledGlobal(slug2, options = {}) {
5301
+ await this.globalScheduleTarget(slug2, options);
5302
+ return this.pendingJobs(`global:${slug2}`, 0);
4567
5303
  }
4568
- async cancelGlobalSchedule(slug, jobId, options = {}) {
4569
- await this.globalScheduleTarget(slug, options);
4570
- await this.removeJob(`global:${slug}`, 0, jobId);
5304
+ async cancelGlobalSchedule(slug2, jobId, options = {}) {
5305
+ await this.globalScheduleTarget(slug2, options);
5306
+ await this.removeJob(`global:${slug2}`, 0, jobId);
4571
5307
  }
4572
5308
  async scheduleTarget(collection2, id, options) {
4573
5309
  const config = this.collection(collection2);
@@ -4576,14 +5312,14 @@ var EasyCMS = class {
4576
5312
  const existing = parsed === void 0 ? null : await this.db.findById({ collection: collection2, id: parsed });
4577
5313
  if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
4578
5314
  await this.checkDocumentAccess(config, "update", guardOf(options), parsed, void 0);
4579
- this.checkKey(guardOf(options), { collection: collection2 }, "publish");
5315
+ await this.checkGrant(guardOf(options), { collection: collection2 }, "publish");
4580
5316
  return { config, parsed };
4581
5317
  }
4582
- async globalScheduleTarget(slug, options) {
4583
- const config = this.global(slug);
4584
- if (!config.schedule) throw new QueryError(`"${slug}" has no schedule`);
5318
+ async globalScheduleTarget(slug2, options) {
5319
+ const config = this.global(slug2);
5320
+ if (!config.schedule) throw new QueryError(`"${slug2}" has no schedule`);
4585
5321
  await this.checkGlobalAccess(config, "update", guardOf(options));
4586
- this.checkKey(guardOf(options), { global: slug }, "publish");
5322
+ await this.checkGrant(guardOf(options), { global: slug2 }, "publish");
4587
5323
  }
4588
5324
  async addJob(parent, doc, job, options) {
4589
5325
  const runAt = new Date(job.at);
@@ -4629,20 +5365,21 @@ var EasyCMS = class {
4629
5365
  page: 1
4630
5366
  });
4631
5367
  const allowed2 = /* @__PURE__ */ new Map();
4632
- const may = async (parent) => {
5368
+ const may2 = async (parent) => {
4633
5369
  if (!guard.enforce) return true;
4634
5370
  const cached = allowed2.get(parent);
4635
5371
  if (cached !== void 0) return cached;
4636
5372
  const config = parent.startsWith("global:") ? this.config.globals.find((g) => g.slug === parent.slice("global:".length)) : this.config.collections.find((c) => c.slug === parent);
4637
5373
  const access = config ? await evaluateAccess(config.access?.update, { user: guard.user }) : false;
4638
- const result = access !== false;
5374
+ const target = parent.startsWith("global:") ? { global: parent.slice("global:".length) } : { collection: parent };
5375
+ const result = access !== false && await this.roles.allows(guard.user, target, "update") !== false;
4639
5376
  allowed2.set(parent, result);
4640
5377
  return result;
4641
5378
  };
4642
5379
  const jobs = [];
4643
5380
  for (const row of rows.docs) {
4644
5381
  const parent = String(row.parent);
4645
- if (!await may(parent)) continue;
5382
+ if (!await may2(parent)) continue;
4646
5383
  const target = parent.startsWith("global:") ? { global: parent.slice("global:".length) } : { collection: parent };
4647
5384
  jobs.push({ ...toJob(row), ...target, doc: row.doc });
4648
5385
  if (jobs.length === limit) break;
@@ -4717,38 +5454,46 @@ var EasyCMS = class {
4717
5454
  }
4718
5455
  // -------------------------------------------------------------------------
4719
5456
  /** @internal */
4720
- collection(slug) {
4721
- const config = this.config.collections.find((c) => c.slug === slug);
4722
- if (!config) throw new QueryError(`Unknown collection "${slug}"`);
5457
+ collection(slug2) {
5458
+ const config = this.config.collections.find((c) => c.slug === slug2);
5459
+ if (!config) throw new QueryError(`Unknown collection "${slug2}"`);
4723
5460
  return config;
4724
5461
  }
4725
- global(slug) {
4726
- const config = this.config.globals.find((g) => g.slug === slug);
4727
- if (!config) throw new QueryError(`Unknown global "${slug}"`);
5462
+ global(slug2) {
5463
+ const config = this.config.globals.find((g) => g.slug === slug2);
5464
+ if (!config) throw new QueryError(`Unknown global "${slug2}"`);
4728
5465
  return config;
4729
5466
  }
4730
- /** Refuses what a request's API key does not allow (see `ApiKeyPermissions`). */
4731
- checkKey(guard, target, operation) {
4732
- if (!guard.enforce || keyAllows(guard.user, target, operation)) return;
5467
+ /**
5468
+ * Refuses what a request's API key (see `ApiKeyPermissions`) or the user's role (`auth.rbac`)
5469
+ * does not allow. Returns a constraint when the role allows only the user's own account.
5470
+ */
5471
+ async checkGrant(guard, target, operation) {
5472
+ if (!guard.enforce) return true;
4733
5473
  const name = "collection" in target ? target.collection : target.global;
4734
- throw new ForbiddenError(`This API key may not ${operation} "${name}"`);
5474
+ if (!keyAllows(guard.user, target, operation))
5475
+ throw new ForbiddenError(`This API key may not ${operation} "${name}"`);
5476
+ const role = await this.roles.allows(guard.user, target, operation);
5477
+ if (role === false) throw deny(guard.user);
5478
+ return role;
4735
5479
  }
4736
5480
  /** The query constraint read access adds, or `undefined` when there is none. Throws when denied. */
4737
5481
  async readWhere(config, guard, where) {
4738
5482
  if (!guard.enforce) return where;
4739
- this.checkKey(guard, { collection: config.slug }, "read");
5483
+ const own = await this.checkGrant(guard, { collection: config.slug }, "read");
4740
5484
  const access = await evaluateAccess(config.access?.read, { user: guard.user });
4741
5485
  if (access === false) throw deny(guard.user);
4742
- return andWhere(where, access);
5486
+ return andWhere(andWhere(where, access), own);
4743
5487
  }
4744
5488
  async checkDocumentAccess(config, operation, guard, id, data) {
4745
5489
  if (!guard.enforce) return;
4746
- this.checkKey(guard, { collection: config.slug }, operation);
4747
- const access = await evaluateAccess(config.access?.[operation], {
5490
+ const own = await this.checkGrant(guard, { collection: config.slug }, operation);
5491
+ const allowed2 = await evaluateAccess(config.access?.[operation], {
4748
5492
  user: guard.user,
4749
5493
  id,
4750
5494
  ...data ? { data } : {}
4751
5495
  });
5496
+ const access = allowed2 === false ? false : andWhere(own === true ? void 0 : own, allowed2) ?? true;
4752
5497
  if (access === true) return;
4753
5498
  if (access !== false) {
4754
5499
  const matches2 = await this.db.count({
@@ -4761,7 +5506,7 @@ var EasyCMS = class {
4761
5506
  }
4762
5507
  async checkGlobalAccess(config, operation, guard) {
4763
5508
  if (!guard.enforce) return;
4764
- this.checkKey(guard, { global: config.slug }, operation);
5509
+ await this.checkGrant(guard, { global: config.slug }, operation);
4765
5510
  const access = await evaluateAccess(config.access?.[operation], { user: guard.user });
4766
5511
  if (typeof access === "object")
4767
5512
  throw new QueryError(`${operation} access of global "${config.slug}" must return a boolean`);
@@ -4815,9 +5560,9 @@ var EasyCMS = class {
4815
5560
  const guard = guardOf(options);
4816
5561
  const collection2 = config.slug;
4817
5562
  if (guard.enforce) {
4818
- this.checkKey(guard, { collection: collection2 }, "create");
5563
+ await this.checkGrant(guard, { collection: collection2 }, "create");
4819
5564
  if (config.drafts && raw.status === "published")
4820
- this.checkKey(guard, { collection: collection2 }, "publish");
5565
+ await this.checkGrant(guard, { collection: collection2 }, "publish");
4821
5566
  const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data: raw });
4822
5567
  if (typeof allowed2 === "object")
4823
5568
  throw new QueryError(`create access of "${collection2}" must return a boolean`);
@@ -4974,13 +5719,13 @@ var EasyCMS = class {
4974
5719
  return current;
4975
5720
  }
4976
5721
  /** Runs hooks after the change is saved: failures are logged, never undo the save (NFR-REL-03). */
4977
- async notify(hooks, name, slug, args) {
5722
+ async notify(hooks, name, slug2, args) {
4978
5723
  for (const hook of hooks ?? []) {
4979
5724
  try {
4980
5725
  await hook(args);
4981
5726
  } catch (error) {
4982
5727
  this.logger.error(
4983
- `${name} hook of "${slug}" failed: ${error instanceof Error ? error.message : String(error)}`
5728
+ `${name} hook of "${slug2}" failed: ${error instanceof Error ? error.message : String(error)}`
4984
5729
  );
4985
5730
  }
4986
5731
  }
@@ -5036,6 +5781,8 @@ var EasyCMS = class {
5036
5781
  }
5037
5782
  clean2.status = status;
5038
5783
  }
5784
+ if (config.slug === USERS && this.roles.enabled && typeof clean2.role === "string" && !await this.roles.exists(clean2.role))
5785
+ errors.push({ field: "role", message: `there is no role "${clean2.role}"` });
5039
5786
  const isCollection = this.config.collections.includes(config);
5040
5787
  if (isCollection && errors.length === 0) {
5041
5788
  await this.makeSlugsUnique(config, clean2, selfId);
@@ -5082,9 +5829,9 @@ var EasyCMS = class {
5082
5829
  const value = data[field.name];
5083
5830
  if (field.localized && value && typeof value === "object") {
5084
5831
  const map = { ...value };
5085
- for (const [locale, slug] of Object.entries(map)) {
5086
- if (typeof slug === "string" && slug !== "")
5087
- map[locale] = await unique(`${field.name}.${locale}`, slug);
5832
+ for (const [locale, slug2] of Object.entries(map)) {
5833
+ if (typeof slug2 === "string" && slug2 !== "")
5834
+ map[locale] = await unique(`${field.name}.${locale}`, slug2);
5088
5835
  }
5089
5836
  data[field.name] = map;
5090
5837
  } else if (typeof value === "string" && value !== "") {
@@ -5122,11 +5869,11 @@ var EasyCMS = class {
5122
5869
  return await this.db.count({ collection: collection2, where }) > 0;
5123
5870
  }
5124
5871
  /** Each reference is one its relationship's `filterOptions` allow. */
5125
- async checkFilterOptions(references, selfId, user) {
5872
+ async checkFilterOptions(references2, selfId, user) {
5126
5873
  const errors = [];
5127
5874
  const cms = this;
5128
5875
  const allowed2 = /* @__PURE__ */ new Map();
5129
- for (const ref of references) {
5876
+ for (const ref of references2) {
5130
5877
  const filter = ref.filterOptions;
5131
5878
  if (!filter) continue;
5132
5879
  if (!allowed2.has(filter)) allowed2.set(filter, await filter({ id: selfId, user, cms }));
@@ -5144,9 +5891,9 @@ var EasyCMS = class {
5144
5891
  }
5145
5892
  return errors;
5146
5893
  }
5147
- async checkReferences(references) {
5894
+ async checkReferences(references2) {
5148
5895
  const byCollection = /* @__PURE__ */ new Map();
5149
- for (const ref of references) {
5896
+ for (const ref of references2) {
5150
5897
  if (!this.config.collections.some((c) => c.slug === ref.collection)) continue;
5151
5898
  byCollection.set(ref.collection, [...byCollection.get(ref.collection) ?? [], ref]);
5152
5899
  }
@@ -5223,11 +5970,11 @@ function asObject(data, name) {
5223
5970
 
5224
5971
  // src/rest/admin-modules.ts
5225
5972
  import { createHash as createHash3 } from "crypto";
5226
- import { readFile as readFile2 } from "fs/promises";
5973
+ import { readFile as readFile3 } from "fs/promises";
5227
5974
  import { createRequire } from "module";
5228
- import { join as join2 } from "path";
5975
+ import { join as join3 } from "path";
5229
5976
  function resolveAdminModule(specifier, cwd = process.cwd()) {
5230
- const bases = [join2(cwd, "package.json")];
5977
+ const bases = [join3(cwd, "package.json")];
5231
5978
  const entry = process.argv[1];
5232
5979
  if (entry && !specifier.startsWith(".")) bases.push(entry);
5233
5980
  for (const base of bases) {
@@ -5261,7 +6008,7 @@ function adminModuleUrls(cms) {
5261
6008
  async function readAdminModule(cms, index) {
5262
6009
  const file = files(cms)[index];
5263
6010
  if (!file) return void 0;
5264
- const body = await readFile2(file, "utf8").catch(() => void 0);
6011
+ const body = await readFile3(file, "utf8").catch(() => void 0);
5265
6012
  if (body === void 0) return void 0;
5266
6013
  return { body, etag: `"${createHash3("sha1").update(body).digest("base64url")}"` };
5267
6014
  }
@@ -5279,7 +6026,7 @@ function parse(text) {
5279
6026
  return {};
5280
6027
  }
5281
6028
  }
5282
- function toAdmin(kind, row) {
6029
+ function toAdmin2(kind, row) {
5283
6030
  const base = {
5284
6031
  id: row.id,
5285
6032
  state: row.state === "failed" ? "failed" : "pending",
@@ -5327,7 +6074,7 @@ async function listDeliveries(cms, kind, state, page) {
5327
6074
  cms.db.count({ collection: collection2, where: { state: { equals: "pending" } } })
5328
6075
  ]);
5329
6076
  return {
5330
- docs: found.docs.map((row) => toAdmin(kind, row)),
6077
+ docs: found.docs.map((row) => toAdmin2(kind, row)),
5331
6078
  totalDocs: found.totalDocs,
5332
6079
  page: found.page,
5333
6080
  totalPages: found.totalPages,
@@ -5468,7 +6215,7 @@ function componentRef(component) {
5468
6215
  async function allowed(access, user) {
5469
6216
  return await evaluateAccess(access, { user }) !== false;
5470
6217
  }
5471
- async function serializeFields(fields, update, localized) {
6218
+ async function serializeFields(fields, update2, localized) {
5472
6219
  const out = [];
5473
6220
  for (const field of fields) {
5474
6221
  if (field.hidden) continue;
@@ -5477,7 +6224,7 @@ async function serializeFields(fields, update, localized) {
5477
6224
  if (field.required) f.required = true;
5478
6225
  if (field.unique) f.unique = true;
5479
6226
  if (field.defaultValue !== void 0) f.defaultValue = field.defaultValue;
5480
- if (!await update.allows(field)) f.readOnly = true;
6227
+ if (!await update2.allows(field)) f.readOnly = true;
5481
6228
  if (field.localized && localized) f.localized = true;
5482
6229
  if (field.position === "sidebar") f.position = "sidebar";
5483
6230
  if (field.admin?.component || field.admin?.after?.length || field.admin?.cell) {
@@ -5524,7 +6271,7 @@ async function serializeFields(fields, update, localized) {
5524
6271
  case "array":
5525
6272
  if (field.minRows !== void 0) f.minRows = field.minRows;
5526
6273
  if (field.maxRows !== void 0) f.maxRows = field.maxRows;
5527
- f.fields = await serializeFields(field.fields, update, localized);
6274
+ f.fields = await serializeFields(field.fields, update2, localized);
5528
6275
  break;
5529
6276
  case "blocks":
5530
6277
  if (field.minRows !== void 0) f.minRows = field.minRows;
@@ -5533,35 +6280,48 @@ async function serializeFields(fields, update, localized) {
5533
6280
  field.blocks.map(async (block) => ({
5534
6281
  slug: block.slug,
5535
6282
  ...block.labels ? { labels: block.labels } : {},
5536
- fields: await serializeFields(block.fields, update, localized)
6283
+ fields: await serializeFields(block.fields, update2, localized)
5537
6284
  }))
5538
6285
  );
5539
6286
  break;
5540
6287
  case "group":
5541
- f.fields = await serializeFields(field.fields, update, localized);
6288
+ f.fields = await serializeFields(field.fields, update2, localized);
5542
6289
  break;
5543
6290
  }
5544
6291
  out.push(f);
5545
6292
  }
5546
6293
  return out;
5547
6294
  }
5548
- async function collection(config, user, localized) {
6295
+ async function may(cms, access, user, target, operation) {
6296
+ return await allowed(access, user) && await cms.roles.allows(user, target, operation) !== false;
6297
+ }
6298
+ async function collection(cms, config, user, localized, roles) {
6299
+ const target = { collection: config.slug };
6300
+ const fields = await serializeFields(
6301
+ config.fields,
6302
+ new FieldAccessChecker("update", { user }),
6303
+ localized
6304
+ );
6305
+ if (config.slug === USERS && cms.roles.enabled) {
6306
+ const role = fields.find((f) => f.name === "role");
6307
+ if (role) {
6308
+ role.type = "select";
6309
+ role.options = roles.map((r) => ({ label: r.name || r.key, value: r.key }));
6310
+ }
6311
+ }
5549
6312
  const result = {
5550
6313
  slug: config.slug,
5551
6314
  drafts: config.drafts === true,
5552
6315
  versions: Boolean(config.versions),
5553
6316
  preview: typeof config.preview === "function",
5554
6317
  schedule: config.schedule === true,
5555
- fields: await serializeFields(
5556
- config.fields,
5557
- new FieldAccessChecker("update", { user }),
5558
- localized
5559
- ),
6318
+ fields,
5560
6319
  permissions: {
5561
- read: await allowed(config.access?.read, user),
5562
- create: await allowed(config.access?.create, user),
5563
- update: await allowed(config.access?.update, user),
5564
- delete: await allowed(config.access?.delete, user)
6320
+ read: await may(cms, config.access?.read, user, target, "read"),
6321
+ create: await may(cms, config.access?.create, user, target, "create"),
6322
+ update: await may(cms, config.access?.update, user, target, "update"),
6323
+ delete: await may(cms, config.access?.delete, user, target, "delete"),
6324
+ publish: config.drafts === true && await may(cms, config.access?.update, user, target, "publish")
5565
6325
  }
5566
6326
  };
5567
6327
  if (config.labels) result.labels = config.labels;
@@ -5575,7 +6335,8 @@ async function collection(config, user, localized) {
5575
6335
  result.editIn = "drawer";
5576
6336
  return result;
5577
6337
  }
5578
- async function global(config, user, localized) {
6338
+ async function global(cms, config, user, localized) {
6339
+ const target = { global: config.slug };
5579
6340
  const result = {
5580
6341
  slug: config.slug,
5581
6342
  drafts: config.drafts === true,
@@ -5588,8 +6349,9 @@ async function global(config, user, localized) {
5588
6349
  localized
5589
6350
  ),
5590
6351
  permissions: {
5591
- read: await allowed(config.access?.read, user),
5592
- update: await allowed(config.access?.update, user)
6352
+ read: await may(cms, config.access?.read, user, target, "read"),
6353
+ update: await may(cms, config.access?.update, user, target, "update"),
6354
+ publish: config.drafts === true && await may(cms, config.access?.update, user, target, "publish")
5593
6355
  }
5594
6356
  };
5595
6357
  if (config.label !== void 0) result.label = config.label;
@@ -5601,16 +6363,31 @@ async function adminSchema(cms, user, origin) {
5601
6363
  const collections = cms.config.collections.filter((c) => !INTERNAL_COLLECTIONS.has(c.slug));
5602
6364
  const localization = cms.config.localization;
5603
6365
  const localized = localization !== null;
6366
+ const admin = user.role === "admin" && !user.apiKey;
6367
+ const deliveries = cms.config.collections.some(
6368
+ (c) => c.slug === WEBHOOK_DELIVERIES || c.slug === EMAIL_DELIVERIES
6369
+ );
6370
+ const views = {
6371
+ status: await cms.roles.canView(user, "status"),
6372
+ deliveries: deliveries && await cms.roles.canView(user, "deliveries"),
6373
+ backups: admin,
6374
+ email: admin,
6375
+ roles: admin && cms.roles.enabled
6376
+ };
6377
+ const roles = await cms.roles.options();
5604
6378
  return {
5605
6379
  locale: cms.config.admin.locale,
5606
6380
  passwordLinks: cms.auth.canSendPasswordLinks(origin),
5607
6381
  menu: [...cms.config.admin.menu],
5608
6382
  localization: localization ? { locales: [...localization.locales], defaultLocale: localization.defaultLocale } : null,
5609
- collections: await Promise.all(collections.map((c) => collection(c, user, localized))),
5610
- globals: await Promise.all(cms.config.globals.map((g) => global(g, user, localized))),
6383
+ collections: await Promise.all(
6384
+ collections.map((c) => collection(cms, c, user, localized, roles))
6385
+ ),
6386
+ globals: await Promise.all(cms.config.globals.map((g) => global(cms, g, user, localized))),
5611
6387
  modules: adminModuleUrls(cms),
5612
6388
  uploadFromURL: cms.config.upload.fromURL !== void 0,
5613
- ...user.role === "admin" && !user.apiKey ? {
6389
+ views,
6390
+ ...views.deliveries ? {
5614
6391
  deliveries: {
5615
6392
  webhook: cms.config.collections.some((c) => c.slug === WEBHOOK_DELIVERIES),
5616
6393
  email: cms.config.collections.some((c) => c.slug === EMAIL_DELIVERIES)
@@ -5631,7 +6408,8 @@ async function shown(access, user) {
5631
6408
  async function pages(cms, user) {
5632
6409
  const out = [];
5633
6410
  for (const page of cms.config.admin.pages) {
5634
- if (!await shown(page.access, user)) continue;
6411
+ if (!await shown(page.access, user) || !await cms.roles.canView(user, `page:${page.path}`))
6412
+ continue;
5635
6413
  out.push({
5636
6414
  path: page.path,
5637
6415
  label: page.label,
@@ -5645,18 +6423,20 @@ async function pages(cms, user) {
5645
6423
  async function widgets(cms, user) {
5646
6424
  const out = [];
5647
6425
  for (const widget of cms.config.admin.dashboard) {
5648
- if (!await shown(widget.access, user)) continue;
6426
+ const tag = typeof widget.component === "string" ? widget.component : widget.component.tag;
6427
+ if (!await shown(widget.access, user) || !await cms.roles.canView(user, `widget:${tag}`))
6428
+ continue;
5649
6429
  out.push({ component: componentRef(widget.component), width: widget.width ?? "half" });
5650
6430
  }
5651
6431
  return out;
5652
6432
  }
5653
6433
 
5654
6434
  // src/version.ts
5655
- var VERSION = true ? "0.31.0" : "0.0.0-dev";
6435
+ var VERSION = true ? "0.33.0" : "0.0.0-dev";
5656
6436
 
5657
6437
  // src/rest/admin-status.ts
5658
6438
  var DAY = 864e5;
5659
- var has = (cms, slug) => cms.config.collections.some((c) => c.slug === slug);
6439
+ var has = (cms, slug2) => cms.config.collections.some((c) => c.slug === slug2);
5660
6440
  async function adminStatus(cms) {
5661
6441
  const config = cms.config;
5662
6442
  const attention = [];
@@ -5709,6 +6489,8 @@ async function adminStatus(cms) {
5709
6489
  });
5710
6490
  if (count > 0) attention.push({ id: "scheduled", count });
5711
6491
  }
6492
+ const backups = await backupAttention(cms);
6493
+ if (backups) attention.push(backups);
5712
6494
  if (!config.email) attention.push({ id: "no-email" });
5713
6495
  if (!config.serverURL && process.env.NODE_ENV === "production")
5714
6496
  attention.push({ id: "no-server-url" });
@@ -5743,8 +6525,8 @@ async function pickerFilter(cms, url, target, user) {
5743
6525
  const spec = forCollection ?? forGlobal;
5744
6526
  if (spec === null) return void 0;
5745
6527
  const dot = spec.indexOf(".");
5746
- const slug = dot > 0 ? spec.slice(0, dot) : "";
5747
- const owner = forCollection !== null ? cms.config.collections.find((c) => c.slug === slug) : cms.config.globals.find((g) => g.slug === slug);
6528
+ const slug2 = dot > 0 ? spec.slice(0, dot) : "";
6529
+ const owner = forCollection !== null ? cms.config.collections.find((c) => c.slug === slug2) : cms.config.globals.find((g) => g.slug === slug2);
5748
6530
  const field = owner ? fieldAt(owner.fields, spec.slice(dot + 1)) : void 0;
5749
6531
  if (field?.type !== "relationship" || field.to !== target)
5750
6532
  throw new QueryError(`filterFor: no relationship to "${target}" at "${spec}"`);
@@ -6092,6 +6874,38 @@ async function route(cms, ctx, method, segments, options) {
6092
6874
  }
6093
6875
  return { body: await cms.runJobs() };
6094
6876
  }
6877
+ if (first === "admin" && second === "backups") {
6878
+ if (!ctx.user) throw new UnauthorizedError();
6879
+ if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
6880
+ const [, , id2, action, extra] = segments;
6881
+ if (extra !== void 0) throw new HttpError("Not found", 404);
6882
+ if (id2 === void 0) {
6883
+ if (method === "GET") return { body: await listBackups(cms) };
6884
+ if (method === "POST")
6885
+ return { status: 202, body: await startBackup(cms, "manual", ctx.user.email) };
6886
+ throw methodNotAllowed(ctx, "GET, POST");
6887
+ }
6888
+ const parsed = parseId(id2);
6889
+ if (parsed === void 0) throw new HttpError("Not found", 404);
6890
+ if (action === "download") {
6891
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
6892
+ const file = await downloadBackup(cms, parsed, ctx.user.email);
6893
+ return {
6894
+ body: new Response(file.body, {
6895
+ headers: {
6896
+ "content-type": "application/gzip",
6897
+ "content-disposition": `attachment; filename="${file.filename}"`,
6898
+ "cache-control": "no-store",
6899
+ "x-content-type-options": "nosniff"
6900
+ }
6901
+ })
6902
+ };
6903
+ }
6904
+ if (action !== void 0) throw new HttpError("Not found", 404);
6905
+ if (method !== "DELETE") throw methodNotAllowed(ctx, "DELETE");
6906
+ await deleteBackup(cms, parsed);
6907
+ return { body: { deleted: 1 } };
6908
+ }
6095
6909
  if (first === "admin" && second === "email") {
6096
6910
  if (!ctx.user) throw new UnauthorizedError();
6097
6911
  if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
@@ -6109,9 +6923,36 @@ async function route(cms, ctx, method, segments, options) {
6109
6923
  }
6110
6924
  throw new HttpError("Not found", 404);
6111
6925
  }
6112
- if (first === "admin" && second === "deliveries") {
6926
+ if (first === "admin" && second === "roles") {
6113
6927
  if (!ctx.user) throw new UnauthorizedError();
6114
6928
  if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
6929
+ if (!cms.roles.enabled) throw new HttpError("Not found", 404);
6930
+ const [, , id2, action, extra] = segments;
6931
+ if (extra !== void 0) throw new HttpError("Not found", 404);
6932
+ if (id2 === void 0) {
6933
+ if (method === "GET") return { body: await cms.roles.list() };
6934
+ if (method === "POST")
6935
+ return { status: 201, body: await cms.roles.create(await readJson(ctx.request), ctx.user) };
6936
+ throw methodNotAllowed(ctx, "GET, POST");
6937
+ }
6938
+ const parsed = parseId(id2);
6939
+ if (parsed === void 0) throw new HttpError("Not found", 404);
6940
+ if (action === "history") {
6941
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
6942
+ return { body: await cms.roles.history(parsed) };
6943
+ }
6944
+ if (action !== void 0) throw new HttpError("Not found", 404);
6945
+ if (method === "PATCH")
6946
+ return { body: await cms.roles.update(parsed, await readJson(ctx.request), ctx.user) };
6947
+ if (method === "DELETE") {
6948
+ await cms.roles.delete(parsed);
6949
+ return { body: { deleted: 1 } };
6950
+ }
6951
+ throw methodNotAllowed(ctx, "PATCH, DELETE");
6952
+ }
6953
+ if (first === "admin" && second === "deliveries") {
6954
+ if (!ctx.user) throw new UnauthorizedError();
6955
+ if (!await cms.roles.canView(ctx.user, "deliveries")) throw new ForbiddenError();
6115
6956
  const [, , kind, id2, action, extra] = segments;
6116
6957
  if (kind === void 0) {
6117
6958
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
@@ -6161,7 +7002,7 @@ async function route(cms, ctx, method, segments, options) {
6161
7002
  return { body: new Response(file.body, { headers }) };
6162
7003
  }
6163
7004
  if (second === "status" && third === void 0) {
6164
- if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
7005
+ if (!await cms.roles.canView(ctx.user, "status")) throw new ForbiddenError();
6165
7006
  return { body: await adminStatus(cms) };
6166
7007
  }
6167
7008
  if (second === "scheduled" && third === void 0)
@@ -6348,47 +7189,47 @@ async function documentAction(cms, ctx, method, collection2, id, path) {
6348
7189
  }
6349
7190
  throw new HttpError("Not found", 404);
6350
7191
  }
6351
- async function globalAction(cms, ctx, method, slug, path) {
7192
+ async function globalAction(cms, ctx, method, slug2, path) {
6352
7193
  const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
6353
7194
  const depth = parseDepth(ctx.url);
6354
7195
  const [action, versionId, extra] = path;
6355
7196
  if (action === "versions" && versionId === void 0) {
6356
7197
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
6357
- return { body: await cms.findGlobalVersions(slug, { ...access, ...parsePage(ctx.url) }) };
7198
+ return { body: await cms.findGlobalVersions(slug2, { ...access, ...parsePage(ctx.url) }) };
6358
7199
  }
6359
7200
  if (action === "versions" && versionId !== void 0 && extra === void 0) {
6360
7201
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
6361
- const version = await cms.findGlobalVersion(slug, versionId, { ...access, ...depth });
6362
- if (!version) throw new NotFoundError(`${slug} version`, versionId);
7202
+ const version = await cms.findGlobalVersion(slug2, versionId, { ...access, ...depth });
7203
+ if (!version) throw new NotFoundError(`${slug2} version`, versionId);
6363
7204
  return { body: version };
6364
7205
  }
6365
7206
  if (action === "versions" && versionId !== void 0 && extra === "restore" && path.length === 3) {
6366
7207
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
6367
- return { body: await cms.restoreGlobalVersion(slug, versionId, { ...access, ...depth }) };
7208
+ return { body: await cms.restoreGlobalVersion(slug2, versionId, { ...access, ...depth }) };
6368
7209
  }
6369
7210
  if (action === "schedule" && path.length <= 2) {
6370
7211
  if (path.length === 2) {
6371
7212
  if (method !== "DELETE") throw methodNotAllowed(ctx, "DELETE");
6372
- await cms.cancelGlobalSchedule(slug, versionId, access);
7213
+ await cms.cancelGlobalSchedule(slug2, versionId, access);
6373
7214
  return { body: { message: "Cancelled" } };
6374
7215
  }
6375
- if (method === "GET") return { body: await cms.scheduledGlobal(slug, access) };
7216
+ if (method === "GET") return { body: await cms.scheduledGlobal(slug2, access) };
6376
7217
  if (method !== "POST") throw methodNotAllowed(ctx, "GET, POST");
6377
7218
  const body = await readJson(ctx.request);
6378
- return { status: 201, body: await cms.scheduleGlobal(slug, scheduleJob(body), access) };
7219
+ return { status: 201, body: await cms.scheduleGlobal(slug2, scheduleJob(body), access) };
6379
7220
  }
6380
7221
  if (path.length === 1 && action === "preview") {
6381
7222
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
6382
7223
  const body = await readJson(ctx.request);
6383
- const result = await cms.previewGlobal(slug, body, { ...access, ...depth });
6384
- const token = cms.createPreviewToken({ global: slug });
7224
+ const result = await cms.previewGlobal(slug2, body, { ...access, ...depth });
7225
+ const token = cms.createPreviewToken({ global: slug2 });
6385
7226
  return { body: { ...result, url: result.url && withPreviewToken(result.url, token) } };
6386
7227
  }
6387
7228
  if (path.length === 1 && (action === "unpublish" || action === "discard-draft")) {
6388
7229
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
6389
7230
  const options = { ...access, ...depth };
6390
7231
  return {
6391
- body: action === "unpublish" ? await cms.unpublishGlobal(slug, options) : await cms.discardGlobalDraft(slug, options)
7232
+ body: action === "unpublish" ? await cms.unpublishGlobal(slug2, options) : await cms.discardGlobalDraft(slug2, options)
6392
7233
  };
6393
7234
  }
6394
7235
  throw new HttpError("Not found", 404);
@@ -6721,7 +7562,7 @@ function generateTypes(config) {
6721
7562
  for (const global2 of config.globals) {
6722
7563
  parts.push("", globalInterface(global2, n.globals.get(global2.slug), n));
6723
7564
  }
6724
- const map = (entries) => [...entries].map(([slug, name]) => ` ${key(slug)}: ${name}`).join("\n");
7565
+ const map = (entries) => [...entries].map(([slug2, name]) => ` ${key(slug2)}: ${name}`).join("\n");
6725
7566
  parts.push(
6726
7567
  "",
6727
7568
  "/** Document type by collection slug. */",
@@ -6771,6 +7612,8 @@ export {
6771
7612
  NotFoundError,
6772
7613
  PayloadTooLargeError,
6773
7614
  QueryError,
7615
+ ROLE_OPERATIONS,
7616
+ Roles,
6774
7617
  SESSION_COOKIE,
6775
7618
  SchemaError,
6776
7619
  TooManyRequestsError,
@@ -6820,5 +7663,6 @@ export {
6820
7663
  sniffMimeType,
6821
7664
  validateConfig,
6822
7665
  validateFields,
6823
- verifyPassword
7666
+ verifyPassword,
7667
+ writeBackupFile
6824
7668
  };