@easy-cms/core 0.35.0 → 0.36.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 +528 -357
  2. package/dist/index.js +912 -318
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -129,197 +129,9 @@ function cleanPermissions(value, known) {
129
129
  };
130
130
  }
131
131
 
132
- // src/errors.ts
133
- var ConfigError = class _ConfigError extends Error {
134
- issues;
135
- constructor(issues) {
136
- super(_ConfigError.format(issues));
137
- this.name = "ConfigError";
138
- this.issues = issues;
139
- }
140
- static format(issues) {
141
- const lines = issues.map((issue) => {
142
- const hint = issue.hint ? `
143
- \u2192 ${issue.hint}` : "";
144
- return ` \u2022 ${issue.path}: ${issue.message}${hint}`;
145
- });
146
- const noun = issues.length === 1 ? "problem" : "problems";
147
- return `Invalid Easy CMS config (${issues.length} ${noun}):
148
- ${lines.join("\n")}`;
149
- }
150
- };
151
- var EasyCMSError = class extends Error {
152
- status;
153
- constructor(message2, status) {
154
- super(message2);
155
- this.name = "EasyCMSError";
156
- this.status = status;
157
- }
158
- };
159
- var ValidationError = class extends EasyCMSError {
160
- errors;
161
- constructor(collection2, errors) {
162
- const list = errors.map((e) => `${e.field}: ${e.message}`).join("; ");
163
- super(`Invalid data for "${collection2}": ${list}`, 400);
164
- this.name = "ValidationError";
165
- this.errors = errors;
166
- }
167
- };
168
- var NotFoundError = class extends EasyCMSError {
169
- constructor(collection2, id) {
170
- super(`No document with id ${JSON.stringify(id)} in "${collection2}"`, 404);
171
- this.name = "NotFoundError";
172
- }
173
- };
174
- var QueryError = class extends EasyCMSError {
175
- constructor(message2) {
176
- super(message2, 400);
177
- this.name = "QueryError";
178
- }
179
- };
180
- var SchemaError = class extends EasyCMSError {
181
- constructor(message2) {
182
- super(message2, 500);
183
- this.name = "SchemaError";
184
- }
185
- };
186
- var UnauthorizedError = class extends EasyCMSError {
187
- constructor(message2 = "You must be logged in") {
188
- super(message2, 401);
189
- this.name = "UnauthorizedError";
190
- }
191
- };
192
- var ForbiddenError = class extends EasyCMSError {
193
- constructor(message2 = "You are not allowed to do this") {
194
- super(message2, 403);
195
- this.name = "ForbiddenError";
196
- }
197
- };
198
- var TooManyRequestsError = class extends EasyCMSError {
199
- constructor(message2) {
200
- super(message2, 429);
201
- this.name = "TooManyRequestsError";
202
- }
203
- };
204
- var PayloadTooLargeError = class extends EasyCMSError {
205
- constructor(message2) {
206
- super(message2, 413);
207
- this.name = "PayloadTooLargeError";
208
- }
209
- };
210
-
211
- // src/fields.ts
212
- var isHasMany = (field) => (field.type === "select" || field.type === "relationship" || field.type === "upload") && field.hasMany === true;
213
- function mimeAllowedBy(mimeType, patterns) {
214
- const type = mimeType.toLowerCase();
215
- return patterns.some((pattern) => {
216
- const p = pattern.toLowerCase();
217
- return p.endsWith("/*") ? type.startsWith(p.slice(0, -1)) : type === p;
218
- });
219
- }
220
- var hasRows = (field) => field.type === "array" || field.type === "blocks";
221
- function rowFields(field, row) {
222
- if (field.type === "array") return field.fields;
223
- const type = row && typeof row === "object" ? row.blockType : void 0;
224
- return field.blocks.find((block) => block.slug === type)?.fields;
225
- }
226
- var FIELD_TYPES = [
227
- "text",
228
- "textarea",
229
- "number",
230
- "boolean",
231
- "date",
232
- "email",
233
- "json",
234
- "select",
235
- "slug",
236
- "richText",
237
- "upload",
238
- "relationship",
239
- "array",
240
- "group",
241
- "blocks"
242
- ];
243
-
244
- // src/access-control.ts
245
- async function evaluateAccess(access, args) {
246
- const result = await (access ?? isLoggedIn)(args);
247
- if (typeof result === "boolean") return result;
248
- if (typeof result === "object" && result !== null) return result;
249
- throw new QueryError("Access functions must return a boolean or a where query");
250
- }
251
- function andWhere(where, constraint) {
252
- if (constraint === true) return where;
253
- return where === void 0 ? constraint : { and: [where, constraint] };
254
- }
255
- var FieldAccessChecker = class {
256
- /** `rules`: the user's role's rules for top-level fields (`auth.rbac`), on top of `access`. */
257
- constructor(kind, args, rules) {
258
- this.kind = kind;
259
- this.args = args;
260
- this.rules = rules;
261
- }
262
- kind;
263
- args;
264
- rules;
265
- cache = /* @__PURE__ */ new Map();
266
- async allows(field) {
267
- const rule = this.rules?.get(field);
268
- if (rule === "hidden" || rule === "read" && this.kind === "update") return false;
269
- const fn = field.access?.[this.kind];
270
- if (!fn) return true;
271
- let allowed2 = this.cache.get(field);
272
- if (allowed2 === void 0) {
273
- allowed2 = await fn(this.args) === true;
274
- this.cache.set(field, allowed2);
275
- }
276
- return allowed2;
277
- }
278
- };
279
- async function stripFields(fields, doc, read2) {
280
- const result = { ...doc };
281
- for (const field of fields) {
282
- if (!(field.name in result)) continue;
283
- if (field.hidden || read2 && !await read2.allows(field)) {
284
- delete result[field.name];
285
- continue;
286
- }
287
- const value = result[field.name];
288
- if (field.type === "group" && value && typeof value === "object") {
289
- result[field.name] = await stripFields(field.fields, value, read2);
290
- } else if (hasRows(field) && Array.isArray(value)) {
291
- result[field.name] = await Promise.all(
292
- value.map((row) => {
293
- const fields2 = rowFields(field, row);
294
- return fields2 && row && typeof row === "object" ? stripFields(fields2, row, read2) : row;
295
- })
296
- );
297
- }
298
- }
299
- return result;
300
- }
301
- async function filterInput(fields, data, update2) {
302
- const result = { ...data };
303
- for (const field of fields) {
304
- if (!(field.name in result)) continue;
305
- if (field.hidden || update2 && !await update2.allows(field)) {
306
- delete result[field.name];
307
- continue;
308
- }
309
- const value = result[field.name];
310
- if (field.type === "group" && value && typeof value === "object" && !Array.isArray(value)) {
311
- result[field.name] = await filterInput(field.fields, value, update2);
312
- } else if (hasRows(field) && Array.isArray(value)) {
313
- result[field.name] = await Promise.all(
314
- value.map((row) => {
315
- const fields2 = rowFields(field, row);
316
- return fields2 && row && typeof row === "object" ? filterInput(fields2, row, update2) : row;
317
- })
318
- );
319
- }
320
- }
321
- return result;
322
- }
132
+ // src/audit.ts
133
+ import { AsyncLocalStorage } from "async_hooks";
134
+ import { createHmac } from "crypto";
323
135
 
324
136
  // src/builtins.ts
325
137
  var USERS = "users";
@@ -333,6 +145,7 @@ var EMAIL_DELIVERIES = "email-deliveries";
333
145
  var DATABASE_BACKUPS = "database-backups";
334
146
  var ROLES = "user-roles";
335
147
  var USER_IDENTITIES = "user-identities";
148
+ var AUDIT_LOGS = "audit-logs";
336
149
  var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
337
150
  SESSIONS,
338
151
  LOGIN_ATTEMPTS,
@@ -342,7 +155,8 @@ var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
342
155
  EMAIL_DELIVERIES,
343
156
  DATABASE_BACKUPS,
344
157
  ROLES,
345
- USER_IDENTITIES
158
+ USER_IDENTITIES,
159
+ AUDIT_LOGS
346
160
  ]);
347
161
  var DEFAULT_ROLES = ["admin", "editor"];
348
162
  var CREATED_BY_FIELD = {
@@ -567,66 +381,588 @@ var scheduledJobsCollection = {
567
381
  { name: "author", type: "number" }
568
382
  ]
569
383
  };
570
- var webhookDeliveriesCollection = {
571
- slug: WEBHOOK_DELIVERIES,
572
- access: { read: nobody, create: nobody, update: nobody, delete: nobody },
573
- fields: [
574
- { name: "url", type: "text", required: true },
575
- { name: "event", type: "text", required: true },
576
- // The JSON body as first sent: retries are byte-for-byte the same, signature included.
577
- { name: "body", type: "textarea", required: true },
578
- { name: "delivery", type: "text", required: true },
579
- { name: "attempts", type: "number", required: true },
580
- { name: "nextAttemptAt", type: "text", required: true, index: true },
581
- // pending | failed
582
- { name: "state", type: "text", required: true, index: true },
583
- { name: "error", type: "text" }
584
- ]
384
+ var webhookDeliveriesCollection = {
385
+ slug: WEBHOOK_DELIVERIES,
386
+ access: { read: nobody, create: nobody, update: nobody, delete: nobody },
387
+ fields: [
388
+ { name: "url", type: "text", required: true },
389
+ { name: "event", type: "text", required: true },
390
+ // The JSON body as first sent: retries are byte-for-byte the same, signature included.
391
+ { name: "body", type: "textarea", required: true },
392
+ { name: "delivery", type: "text", required: true },
393
+ { name: "attempts", type: "number", required: true },
394
+ { name: "nextAttemptAt", type: "text", required: true, index: true },
395
+ // pending | failed
396
+ { name: "state", type: "text", required: true, index: true },
397
+ { name: "error", type: "text" }
398
+ ]
399
+ };
400
+ var databaseBackupsCollection = {
401
+ slug: DATABASE_BACKUPS,
402
+ access: { read: nobody, create: nobody, update: nobody, delete: nobody },
403
+ fields: [
404
+ // pending | running | done | failed
405
+ { name: "state", type: "text", required: true, index: true },
406
+ // manual | scheduled
407
+ { name: "trigger", type: "text", required: true },
408
+ { name: "filename", type: "text" },
409
+ { name: "size", type: "number" },
410
+ { name: "startedAt", type: "text", index: true },
411
+ { name: "finishedAt", type: "text" },
412
+ { name: "error", type: "text" },
413
+ // Who started it (email), for manual ones.
414
+ { name: "author", type: "text" },
415
+ { name: "downloadedBy", type: "text" },
416
+ { name: "downloadedAt", type: "text" }
417
+ ]
418
+ };
419
+ var userIdentitiesCollection = {
420
+ slug: USER_IDENTITIES,
421
+ access: { read: nobody, create: nobody, update: nobody, delete: nobody },
422
+ fields: [
423
+ { name: "user", type: "relationship", to: USERS, required: true, index: true },
424
+ // The provider's id (`google`) and its id for the account (`sub`).
425
+ { name: "provider", type: "text", required: true, index: true },
426
+ { name: "subject", type: "text", required: true, index: true },
427
+ { name: "email", type: "text" },
428
+ { name: "lastUsedAt", type: "text" }
429
+ ]
430
+ };
431
+ var auditLogsCollection = {
432
+ slug: AUDIT_LOGS,
433
+ access: { read: nobody, create: nobody, update: nobody, delete: nobody },
434
+ fields: [
435
+ { name: "action", type: "text", required: true, index: true },
436
+ // A collection slug, `global:<slug>`, or an area (auth, roles, backups…).
437
+ { name: "target", type: "text", index: true },
438
+ { name: "doc", type: "text", index: true },
439
+ { name: "title", type: "text" },
440
+ { name: "actorId", type: "text" },
441
+ { name: "actorEmail", type: "text", index: true },
442
+ // user | api-key | system | scheduler
443
+ { name: "via", type: "text" },
444
+ { name: "ip", type: "text" },
445
+ { name: "userAgent", type: "text" },
446
+ { name: "changes", type: "json" },
447
+ { name: "detail", type: "json" },
448
+ { name: "signature", type: "text" }
449
+ ]
450
+ };
451
+ var emailDeliveriesCollection = {
452
+ slug: EMAIL_DELIVERIES,
453
+ access: { read: nobody, create: nobody, update: nobody, delete: nobody },
454
+ fields: [
455
+ // The message as JSON.
456
+ { name: "message", type: "textarea", required: true },
457
+ { name: "attempts", type: "number", required: true },
458
+ { name: "nextAttemptAt", type: "text", required: true, index: true },
459
+ // pending | failed
460
+ { name: "state", type: "text", required: true, index: true },
461
+ { name: "error", type: "text" }
462
+ ]
463
+ };
464
+
465
+ // src/audit.ts
466
+ var DEFAULT_AUDIT_KEEP = 365;
467
+ var DEFAULT_FAILED_LOGINS = 20;
468
+ var MAX_VALUE = 500;
469
+ var PAGE = 50;
470
+ var CSV_LIMIT = 1e4;
471
+ var DAY = 864e5;
472
+ var BULKY = /* @__PURE__ */ new Set(["richText", "blocks", "array", "json"]);
473
+ var auditContext = new AsyncLocalStorage();
474
+ function stable(value) {
475
+ if (value === void 0 || value === null) return "null";
476
+ if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`;
477
+ if (typeof value === "object") {
478
+ const keys = Object.keys(value).filter((k) => value[k] !== void 0).sort();
479
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stable(value[k])}`).join(",")}}`;
480
+ }
481
+ return JSON.stringify(value);
482
+ }
483
+ var SIGNED = [
484
+ "action",
485
+ "target",
486
+ "doc",
487
+ "title",
488
+ "actorId",
489
+ "actorEmail",
490
+ "via",
491
+ "ip",
492
+ "userAgent",
493
+ "changes",
494
+ "detail",
495
+ "createdAt"
496
+ ];
497
+ function cut(value) {
498
+ if (typeof value === "string")
499
+ return value.length > MAX_VALUE ? `${value.slice(0, MAX_VALUE)}\u2026` : value;
500
+ if (value !== null && typeof value === "object") {
501
+ const json = JSON.stringify(value);
502
+ return json.length > MAX_VALUE ? "\u2026" : value;
503
+ }
504
+ return value;
505
+ }
506
+ var text = (value) => typeof value === "string" && value !== "" ? value : null;
507
+ var AuditLog = class {
508
+ constructor(cms) {
509
+ this.cms = cms;
510
+ }
511
+ cms;
512
+ /** Entries that could not be written since the server started. */
513
+ failures = 0;
514
+ get enabled() {
515
+ return this.cms.config.audit !== false;
516
+ }
517
+ get settings() {
518
+ return this.cms.config.audit || {
519
+ keep: DEFAULT_AUDIT_KEEP,
520
+ values: true,
521
+ failedLogins: DEFAULT_FAILED_LOGINS
522
+ };
523
+ }
524
+ /**
525
+ * Writes an entry. The user defaults to the request's. Never throws: a failure is logged and
526
+ * counted, and the dashboard says so.
527
+ */
528
+ async record(entry) {
529
+ if (!this.enabled) return;
530
+ try {
531
+ const context = auditContext.getStore();
532
+ const user = entry.user !== void 0 ? entry.user : context?.user ?? null;
533
+ const apiKey = user?.apiKey;
534
+ const row = {
535
+ action: entry.action,
536
+ target: entry.target ?? null,
537
+ doc: entry.doc === void 0 || entry.doc === null ? null : String(entry.doc),
538
+ title: entry.title ? cut(entry.title) : null,
539
+ actorId: user ? String(user.id) : null,
540
+ actorEmail: user?.email ?? entry.email ?? null,
541
+ via: apiKey ? "api-key" : context?.via === "scheduler" ? "scheduler" : user ? "user" : "system",
542
+ ip: context?.ip ?? null,
543
+ userAgent: context?.userAgent ? context.userAgent.slice(0, 200) : null,
544
+ changes: entry.changes && entry.changes.length > 0 ? entry.changes : null,
545
+ detail: apiKey ? { ...entry.detail, apiKey: apiKey.name ?? "" } : entry.detail ?? null,
546
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
547
+ };
548
+ row.signature = this.sign(row);
549
+ row.updatedAt = row.createdAt;
550
+ await this.cms.db.create({ collection: AUDIT_LOGS, data: row });
551
+ } catch (error) {
552
+ this.failures++;
553
+ this.cms.logger.error(
554
+ `Audit log: could not write "${entry.action}": ${error.message}`
555
+ );
556
+ }
557
+ }
558
+ /**
559
+ * A change to a document or global: what changed between `before` and `after`. Internal
560
+ * collections (sessions, versions…) are not logged.
561
+ */
562
+ async content(config, global2, action, after, before, user) {
563
+ if (!this.enabled) return;
564
+ if (!global2 && INTERNAL_COLLECTIONS.has(config.slug)) return;
565
+ const doc = after ?? before;
566
+ const titleField = global2 ? void 0 : config.useAsTitle;
567
+ const rawTitle = titleField ? doc?.[titleField] : void 0;
568
+ const title = typeof rawTitle === "string" ? rawTitle : rawTitle && typeof rawTitle === "object" ? text(
569
+ Object.values(rawTitle).find((v) => typeof v === "string")
570
+ ) : null;
571
+ await this.record({
572
+ action,
573
+ target: global2 ? `global:${config.slug}` : config.slug,
574
+ doc: global2 ? null : doc?.id ?? null,
575
+ title,
576
+ // A trusted call without a user keeps the request's (or is the system).
577
+ ...user ? { user } : {},
578
+ changes: action === "delete" ? null : this.diff(config.fields, before ?? {}, after ?? {})
579
+ });
580
+ }
581
+ /** Fields whose stored value changed. Hidden ones never (the password only as "changed"). */
582
+ diff(fields, before, after) {
583
+ const values = this.settings.values;
584
+ const out = [];
585
+ for (const field of fields) {
586
+ if (field.hidden) continue;
587
+ const a = before[field.name];
588
+ const b = after[field.name];
589
+ if (stable(a) === stable(b)) continue;
590
+ if (!values || BULKY.has(field.type)) out.push({ field: field.name });
591
+ else out.push({ field: field.name, before: cut(a ?? null), after: cut(b ?? null) });
592
+ }
593
+ if (before.passwordHash !== after.passwordHash && (before.passwordHash || after.passwordHash))
594
+ out.push({ field: "password" });
595
+ return out;
596
+ }
597
+ async list(filter, page = 1) {
598
+ const result = await this.cms.db.find({
599
+ collection: AUDIT_LOGS,
600
+ where: this.where(filter),
601
+ sort: ["-id"],
602
+ limit: PAGE,
603
+ page
604
+ });
605
+ return {
606
+ docs: result.docs.map(toEntry),
607
+ page: result.page,
608
+ totalPages: result.totalPages,
609
+ totalDocs: result.totalDocs
610
+ };
611
+ }
612
+ /** The filtered entries as CSV (the newest 10,000). */
613
+ async csv(filter) {
614
+ const { docs } = await this.cms.db.find({
615
+ collection: AUDIT_LOGS,
616
+ where: this.where(filter),
617
+ sort: ["-id"],
618
+ limit: CSV_LIMIT,
619
+ page: 1
620
+ });
621
+ const cell = (value) => {
622
+ const s = value === null || value === void 0 ? "" : typeof value === "string" ? value : JSON.stringify(value);
623
+ const safe = /^[=+\-@\t\r]/.test(s) ? `'${s}` : s;
624
+ return /[",\n\r]/.test(safe) ? `"${safe.replaceAll('"', '""')}"` : safe;
625
+ };
626
+ const lines = [
627
+ ["at", "action", "target", "doc", "title", "actor", "via", "ip", "changes", "detail"].join(
628
+ ","
629
+ ),
630
+ ...docs.map(toEntry).map(
631
+ (e) => [
632
+ e.at,
633
+ e.action,
634
+ e.target,
635
+ e.doc,
636
+ e.title,
637
+ e.actor.email,
638
+ e.actor.via,
639
+ e.actor.ip,
640
+ e.changes,
641
+ e.detail
642
+ ].map(cell).join(",")
643
+ )
644
+ ];
645
+ return `${lines.join("\r\n")}\r
646
+ `;
647
+ }
648
+ /** Checks every entry's signature, and that no ids are missing. */
649
+ async verify() {
650
+ const invalid = [];
651
+ let checked = 0;
652
+ let gaps = 0;
653
+ let previous;
654
+ for (let page = 1; ; page++) {
655
+ const result = await this.cms.db.find({
656
+ collection: AUDIT_LOGS,
657
+ sort: ["id"],
658
+ limit: 500,
659
+ page
660
+ });
661
+ for (const row of result.docs) {
662
+ checked++;
663
+ if (row.signature !== this.sign(row)) invalid.push(row.id);
664
+ const id = Number(row.id);
665
+ if (previous !== void 0 && Number.isInteger(id) && id > previous + 1)
666
+ gaps += id - previous - 1;
667
+ previous = id;
668
+ }
669
+ if (page >= result.totalPages) break;
670
+ }
671
+ const verification = { checked, invalid, gaps, at: (/* @__PURE__ */ new Date()).toISOString() };
672
+ await this.record({
673
+ action: "audit.verify",
674
+ target: "audit",
675
+ detail: { checked, invalid: invalid.length, gaps }
676
+ });
677
+ return verification;
678
+ }
679
+ /**
680
+ * Daily upkeep from `runJobs`: deletes entries older than `keep` days, then verifies the log.
681
+ * Runs when the last check is more than a day old.
682
+ */
683
+ async upkeep(now = /* @__PURE__ */ new Date()) {
684
+ if (!this.enabled) return;
685
+ const last = await this.lastVerification();
686
+ if (last && now.getTime() - Date.parse(last.at) < DAY) return;
687
+ const keep = this.settings.keep;
688
+ if (keep > 0) {
689
+ const before = new Date(now.getTime() - keep * DAY).toISOString();
690
+ for (; ; ) {
691
+ const { docs } = await this.cms.db.find({
692
+ collection: AUDIT_LOGS,
693
+ where: { createdAt: { lt: before } },
694
+ sort: ["id"],
695
+ limit: 500,
696
+ page: 1
697
+ });
698
+ for (const row of docs) await this.cms.db.delete({ collection: AUDIT_LOGS, id: row.id });
699
+ if (docs.length < 500) break;
700
+ }
701
+ }
702
+ await auditContext.run({ via: "scheduler", user: null }, () => this.verify());
703
+ }
704
+ /** What the dashboard warns about: many failed logins, entries not written, a failed check. */
705
+ async attention(now = /* @__PURE__ */ new Date()) {
706
+ if (!this.enabled) return [];
707
+ const out = [];
708
+ const hour = new Date(now.getTime() - 36e5).toISOString();
709
+ const failed = await this.cms.db.count({
710
+ collection: AUDIT_LOGS,
711
+ where: {
712
+ and: [{ action: { in: ["login.failed", "sso.failed"] } }, { createdAt: { gte: hour } }]
713
+ }
714
+ });
715
+ if (failed >= this.settings.failedLogins) out.push({ id: "failed-logins", count: failed });
716
+ if (this.failures > 0) out.push({ id: "audit-failures", count: this.failures });
717
+ const last = await this.lastVerification();
718
+ if (last && last.invalid > 0)
719
+ out.push({ id: "audit-tampered", invalid: last.invalid, at: last.at });
720
+ return out;
721
+ }
722
+ // -------------------------------------------------------------------------
723
+ sign(row) {
724
+ const payload = stable(SIGNED.map((key2) => row[key2] ?? null));
725
+ return createHmac("sha256", this.cms.config.secret).update(`audit:${payload}`).digest("base64url");
726
+ }
727
+ async lastVerification() {
728
+ const { docs } = await this.cms.db.find({
729
+ collection: AUDIT_LOGS,
730
+ where: { action: { equals: "audit.verify" } },
731
+ sort: ["-id"],
732
+ limit: 1,
733
+ page: 1
734
+ });
735
+ const row = docs[0];
736
+ if (!row) return null;
737
+ const detail = row.detail ?? {};
738
+ return { at: String(row.createdAt), invalid: detail.invalid ?? 0 };
739
+ }
740
+ where(filter) {
741
+ const parts = [];
742
+ if (filter.action) {
743
+ parts.push({
744
+ or: [{ action: { equals: filter.action } }, { action: { like: `${filter.action}.` } }]
745
+ });
746
+ }
747
+ if (filter.target) parts.push({ target: { equals: filter.target } });
748
+ if (filter.doc) parts.push({ doc: { equals: filter.doc } });
749
+ if (filter.actor) parts.push({ actorEmail: { like: filter.actor } });
750
+ if (filter.from) parts.push({ createdAt: { gte: filter.from } });
751
+ if (filter.to) parts.push({ createdAt: { lte: filter.to } });
752
+ return parts.length === 0 ? void 0 : parts.length === 1 ? parts[0] : { and: parts };
753
+ }
754
+ };
755
+ function toEntry(row) {
756
+ return {
757
+ id: row.id,
758
+ at: String(row.createdAt),
759
+ action: String(row.action),
760
+ target: text(row.target),
761
+ doc: text(row.doc),
762
+ title: text(row.title),
763
+ actor: {
764
+ id: text(row.actorId),
765
+ email: text(row.actorEmail),
766
+ via: String(row.via ?? "system"),
767
+ ip: text(row.ip),
768
+ userAgent: text(row.userAgent)
769
+ },
770
+ changes: row.changes ?? null,
771
+ detail: row.detail ?? null
772
+ };
773
+ }
774
+
775
+ // src/errors.ts
776
+ var ConfigError = class _ConfigError extends Error {
777
+ issues;
778
+ constructor(issues) {
779
+ super(_ConfigError.format(issues));
780
+ this.name = "ConfigError";
781
+ this.issues = issues;
782
+ }
783
+ static format(issues) {
784
+ const lines = issues.map((issue) => {
785
+ const hint = issue.hint ? `
786
+ \u2192 ${issue.hint}` : "";
787
+ return ` \u2022 ${issue.path}: ${issue.message}${hint}`;
788
+ });
789
+ const noun = issues.length === 1 ? "problem" : "problems";
790
+ return `Invalid Easy CMS config (${issues.length} ${noun}):
791
+ ${lines.join("\n")}`;
792
+ }
793
+ };
794
+ var EasyCMSError = class extends Error {
795
+ status;
796
+ constructor(message2, status) {
797
+ super(message2);
798
+ this.name = "EasyCMSError";
799
+ this.status = status;
800
+ }
801
+ };
802
+ var ValidationError = class extends EasyCMSError {
803
+ errors;
804
+ constructor(collection2, errors) {
805
+ const list = errors.map((e) => `${e.field}: ${e.message}`).join("; ");
806
+ super(`Invalid data for "${collection2}": ${list}`, 400);
807
+ this.name = "ValidationError";
808
+ this.errors = errors;
809
+ }
810
+ };
811
+ var NotFoundError = class extends EasyCMSError {
812
+ constructor(collection2, id) {
813
+ super(`No document with id ${JSON.stringify(id)} in "${collection2}"`, 404);
814
+ this.name = "NotFoundError";
815
+ }
816
+ };
817
+ var QueryError = class extends EasyCMSError {
818
+ constructor(message2) {
819
+ super(message2, 400);
820
+ this.name = "QueryError";
821
+ }
822
+ };
823
+ var SchemaError = class extends EasyCMSError {
824
+ constructor(message2) {
825
+ super(message2, 500);
826
+ this.name = "SchemaError";
827
+ }
828
+ };
829
+ var UnauthorizedError = class extends EasyCMSError {
830
+ constructor(message2 = "You must be logged in") {
831
+ super(message2, 401);
832
+ this.name = "UnauthorizedError";
833
+ }
834
+ };
835
+ var ForbiddenError = class extends EasyCMSError {
836
+ constructor(message2 = "You are not allowed to do this") {
837
+ super(message2, 403);
838
+ this.name = "ForbiddenError";
839
+ }
585
840
  };
586
- var databaseBackupsCollection = {
587
- slug: DATABASE_BACKUPS,
588
- access: { read: nobody, create: nobody, update: nobody, delete: nobody },
589
- fields: [
590
- // pending | running | done | failed
591
- { name: "state", type: "text", required: true, index: true },
592
- // manual | scheduled
593
- { name: "trigger", type: "text", required: true },
594
- { name: "filename", type: "text" },
595
- { name: "size", type: "number" },
596
- { name: "startedAt", type: "text", index: true },
597
- { name: "finishedAt", type: "text" },
598
- { name: "error", type: "text" },
599
- // Who started it (email), for manual ones.
600
- { name: "author", type: "text" },
601
- { name: "downloadedBy", type: "text" },
602
- { name: "downloadedAt", type: "text" }
603
- ]
841
+ var TooManyRequestsError = class extends EasyCMSError {
842
+ constructor(message2) {
843
+ super(message2, 429);
844
+ this.name = "TooManyRequestsError";
845
+ }
604
846
  };
605
- var userIdentitiesCollection = {
606
- slug: USER_IDENTITIES,
607
- access: { read: nobody, create: nobody, update: nobody, delete: nobody },
608
- fields: [
609
- { name: "user", type: "relationship", to: USERS, required: true, index: true },
610
- // The provider's id (`google`) and its id for the account (`sub`).
611
- { name: "provider", type: "text", required: true, index: true },
612
- { name: "subject", type: "text", required: true, index: true },
613
- { name: "email", type: "text" },
614
- { name: "lastUsedAt", type: "text" }
615
- ]
847
+ var PayloadTooLargeError = class extends EasyCMSError {
848
+ constructor(message2) {
849
+ super(message2, 413);
850
+ this.name = "PayloadTooLargeError";
851
+ }
616
852
  };
617
- var emailDeliveriesCollection = {
618
- slug: EMAIL_DELIVERIES,
619
- access: { read: nobody, create: nobody, update: nobody, delete: nobody },
620
- fields: [
621
- // The message as JSON.
622
- { name: "message", type: "textarea", required: true },
623
- { name: "attempts", type: "number", required: true },
624
- { name: "nextAttemptAt", type: "text", required: true, index: true },
625
- // pending | failed
626
- { name: "state", type: "text", required: true, index: true },
627
- { name: "error", type: "text" }
628
- ]
853
+
854
+ // src/fields.ts
855
+ var isHasMany = (field) => (field.type === "select" || field.type === "relationship" || field.type === "upload") && field.hasMany === true;
856
+ function mimeAllowedBy(mimeType, patterns) {
857
+ const type = mimeType.toLowerCase();
858
+ return patterns.some((pattern) => {
859
+ const p = pattern.toLowerCase();
860
+ return p.endsWith("/*") ? type.startsWith(p.slice(0, -1)) : type === p;
861
+ });
862
+ }
863
+ var hasRows = (field) => field.type === "array" || field.type === "blocks";
864
+ function rowFields(field, row) {
865
+ if (field.type === "array") return field.fields;
866
+ const type = row && typeof row === "object" ? row.blockType : void 0;
867
+ return field.blocks.find((block) => block.slug === type)?.fields;
868
+ }
869
+ var FIELD_TYPES = [
870
+ "text",
871
+ "textarea",
872
+ "number",
873
+ "boolean",
874
+ "date",
875
+ "email",
876
+ "json",
877
+ "select",
878
+ "slug",
879
+ "richText",
880
+ "upload",
881
+ "relationship",
882
+ "array",
883
+ "group",
884
+ "blocks"
885
+ ];
886
+
887
+ // src/access-control.ts
888
+ async function evaluateAccess(access, args) {
889
+ const result = await (access ?? isLoggedIn)(args);
890
+ if (typeof result === "boolean") return result;
891
+ if (typeof result === "object" && result !== null) return result;
892
+ throw new QueryError("Access functions must return a boolean or a where query");
893
+ }
894
+ function andWhere(where, constraint) {
895
+ if (constraint === true) return where;
896
+ return where === void 0 ? constraint : { and: [where, constraint] };
897
+ }
898
+ var FieldAccessChecker = class {
899
+ /** `rules`: the user's role's rules for top-level fields (`auth.rbac`), on top of `access`. */
900
+ constructor(kind, args, rules) {
901
+ this.kind = kind;
902
+ this.args = args;
903
+ this.rules = rules;
904
+ }
905
+ kind;
906
+ args;
907
+ rules;
908
+ cache = /* @__PURE__ */ new Map();
909
+ async allows(field) {
910
+ const rule = this.rules?.get(field);
911
+ if (rule === "hidden" || rule === "read" && this.kind === "update") return false;
912
+ const fn = field.access?.[this.kind];
913
+ if (!fn) return true;
914
+ let allowed2 = this.cache.get(field);
915
+ if (allowed2 === void 0) {
916
+ allowed2 = await fn(this.args) === true;
917
+ this.cache.set(field, allowed2);
918
+ }
919
+ return allowed2;
920
+ }
629
921
  };
922
+ async function stripFields(fields, doc, read2) {
923
+ const result = { ...doc };
924
+ for (const field of fields) {
925
+ if (!(field.name in result)) continue;
926
+ if (field.hidden || read2 && !await read2.allows(field)) {
927
+ delete result[field.name];
928
+ continue;
929
+ }
930
+ const value = result[field.name];
931
+ if (field.type === "group" && value && typeof value === "object") {
932
+ result[field.name] = await stripFields(field.fields, value, read2);
933
+ } else if (hasRows(field) && Array.isArray(value)) {
934
+ result[field.name] = await Promise.all(
935
+ value.map((row) => {
936
+ const fields2 = rowFields(field, row);
937
+ return fields2 && row && typeof row === "object" ? stripFields(fields2, row, read2) : row;
938
+ })
939
+ );
940
+ }
941
+ }
942
+ return result;
943
+ }
944
+ async function filterInput(fields, data, update2) {
945
+ const result = { ...data };
946
+ for (const field of fields) {
947
+ if (!(field.name in result)) continue;
948
+ if (field.hidden || update2 && !await update2.allows(field)) {
949
+ delete result[field.name];
950
+ continue;
951
+ }
952
+ const value = result[field.name];
953
+ if (field.type === "group" && value && typeof value === "object" && !Array.isArray(value)) {
954
+ result[field.name] = await filterInput(field.fields, value, update2);
955
+ } else if (hasRows(field) && Array.isArray(value)) {
956
+ result[field.name] = await Promise.all(
957
+ value.map((row) => {
958
+ const fields2 = rowFields(field, row);
959
+ return fields2 && row && typeof row === "object" ? filterInput(fields2, row, update2) : row;
960
+ })
961
+ );
962
+ }
963
+ }
964
+ return result;
965
+ }
630
966
 
631
967
  // src/auth/emails.ts
632
968
  var esc = (value) => value.replace(
@@ -634,9 +970,9 @@ var esc = (value) => value.replace(
634
970
  (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]
635
971
  );
636
972
  function email(subject, lines, button) {
637
- const text = [...lines, ...button ? ["", button.url] : []].join("\n");
973
+ const text2 = [...lines, ...button ? ["", button.url] : []].join("\n");
638
974
  const html = `<div style="font-family:system-ui,sans-serif;font-size:15px;line-height:1.5;color:#1f2328">${lines.map((line) => `<p>${esc(line)}</p>`).join("")}${button ? `<p><a href="${esc(button.url)}" style="display:inline-block;padding:10px 18px;border-radius:8px;background:#2f6f5e;color:#fff;text-decoration:none;font-weight:600">${esc(button.label)}</a></p><p style="font-size:13px;color:#656d76">${esc(button.url)}</p>` : ""}</div>`;
639
- return { subject, text, html };
975
+ return { subject, text: text2, html };
640
976
  }
641
977
  var hours = (expiresAt) => Math.max(1, Math.round(((expiresAt?.getTime() ?? Date.now()) - Date.now()) / 36e5));
642
978
  var days = (expiresAt) => Math.max(1, Math.round(hours(expiresAt) / 24));
@@ -736,10 +1072,10 @@ async function fakeVerify(password) {
736
1072
  }
737
1073
 
738
1074
  // src/auth/sso.ts
739
- import { createHmac as createHmac2, randomBytes as randomBytes4 } from "crypto";
1075
+ import { createHmac as createHmac3, randomBytes as randomBytes4 } from "crypto";
740
1076
 
741
1077
  // src/auth/tokens.ts
742
- import { createHash as createHash2, createHmac, randomBytes as randomBytes3, timingSafeEqual as timingSafeEqual3 } from "crypto";
1078
+ import { createHash as createHash2, createHmac as createHmac2, randomBytes as randomBytes3, timingSafeEqual as timingSafeEqual3 } from "crypto";
743
1079
  function newToken() {
744
1080
  return randomBytes3(32).toString("base64url");
745
1081
  }
@@ -747,7 +1083,7 @@ function hashToken(token) {
747
1083
  return createHash2("sha256").update(token).digest("hex");
748
1084
  }
749
1085
  function hmac(secret, value) {
750
- return createHmac("sha256", secret).update(value).digest("base64url");
1086
+ return createHmac2("sha256", secret).update(value).digest("base64url");
751
1087
  }
752
1088
  function signToken(secret, token) {
753
1089
  return `${token}.${hmac(secret, `session:${token}`)}`;
@@ -824,7 +1160,7 @@ var SSO_COOKIE = "ecms-sso";
824
1160
  var PENDING_TTL = 10 * 6e4;
825
1161
  var random = () => randomBytes4(32).toString("base64url");
826
1162
  function sign(secret, payload) {
827
- return createHmac2("sha256", secret).update(`sso:${payload}`).digest("base64url");
1163
+ return createHmac3("sha256", secret).update(`sso:${payload}`).digest("base64url");
828
1164
  }
829
1165
  function seal(secret, pending) {
830
1166
  const payload = Buffer.from(JSON.stringify(pending)).toString("base64url");
@@ -904,12 +1240,21 @@ var SingleSignOn = class {
904
1240
  async finish(providerId, options) {
905
1241
  const admin = this.config.admin.path.replace(/\/+$/, "");
906
1242
  const pending = unseal(this.config.secret, options.cookie);
907
- const back = (page2, outcome) => ({ redirect: `${admin}/${page2}?sso=${outcome}` });
1243
+ let email2 = null;
1244
+ const back = async (page2, outcome) => {
1245
+ await this.cms.audit.record({
1246
+ action: "sso.failed",
1247
+ target: "auth",
1248
+ ...page2 === "login" ? { user: null, email: email2 } : {},
1249
+ detail: { provider: providerId, outcome }
1250
+ });
1251
+ return { redirect: `${admin}/${page2}?sso=${outcome}` };
1252
+ };
908
1253
  const page = pending?.u ? "account" : "login";
909
- if (!pending || pending.p !== providerId) return back(page, "expired");
1254
+ if (!pending || pending.p !== providerId) return await back(page, "expired");
910
1255
  const state = options.url.searchParams.get("state") ?? "";
911
- if (!safeEqual(state, pending.s)) return back(page, "expired");
912
- if (options.url.searchParams.has("error")) return back(page, "cancelled");
1256
+ if (!safeEqual(state, pending.s)) return await back(page, "expired");
1257
+ if (options.url.searchParams.has("error")) return await back(page, "cancelled");
913
1258
  const provider = this.provider(providerId);
914
1259
  const redirectUri = this.callbackURL(provider.id, options.origin);
915
1260
  let profile;
@@ -926,15 +1271,22 @@ var SingleSignOn = class {
926
1271
  this.cms.logger.warn(
927
1272
  `Signing in with ${provider.name} failed: ${error.message || String(error)}`
928
1273
  );
929
- return back(page, "failed");
1274
+ return await back(page, "failed");
930
1275
  }
931
1276
  const subject = String(profile.subject);
932
- const email2 = profile.email?.trim().toLowerCase() || null;
1277
+ email2 = profile.email?.trim().toLowerCase() || null;
933
1278
  if (pending.u) {
934
- if (!options.user || String(options.user.id) !== pending.u) return back("account", "failed");
1279
+ if (!options.user || String(options.user.id) !== pending.u)
1280
+ return await back("account", "failed");
935
1281
  const existing = await this.identity(provider.id, subject);
936
- if (existing && String(existing.user) !== pending.u) return back("account", "taken");
1282
+ if (existing && String(existing.user) !== pending.u) return await back("account", "taken");
937
1283
  if (!existing) await this.addIdentity(options.user.id, provider.id, subject, email2);
1284
+ await this.cms.audit.record({
1285
+ action: "sso.link",
1286
+ target: "auth",
1287
+ user: options.user,
1288
+ detail: { provider: provider.id, email: email2 }
1289
+ });
938
1290
  return { redirect: `${admin}/account?sso=linked` };
939
1291
  }
940
1292
  let user = null;
@@ -943,14 +1295,14 @@ var SingleSignOn = class {
943
1295
  user = await this.cms.db.findById({ collection: USERS, id: identity.user });
944
1296
  } else {
945
1297
  if (!email2 || !profile.emailVerified)
946
- return back("login", email2 ? "unverified" : "no-account");
1298
+ return await back("login", email2 ? "unverified" : "no-account");
947
1299
  user = await this.userByEmail(email2);
948
1300
  if (!user) user = await this.signUp(email2, profile.name);
949
- if (!user) return back("login", "no-account");
1301
+ if (!user) return await back("login", "no-account");
950
1302
  await this.addIdentity(user.id, provider.id, subject, email2);
951
1303
  }
952
- if (!user) return back("login", "no-account");
953
- if (user.active === false) return back("login", "inactive");
1304
+ if (!user) return await back("login", "no-account");
1305
+ if (user.active === false) return await back("login", "inactive");
954
1306
  const found = identity ?? await this.identity(provider.id, subject);
955
1307
  if (found) {
956
1308
  const { id, ...rest } = found;
@@ -960,7 +1312,14 @@ var SingleSignOn = class {
960
1312
  data: { ...rest, email: email2, lastUsedAt: (/* @__PURE__ */ new Date()).toISOString() }
961
1313
  });
962
1314
  }
963
- return { redirect: pending.r, session: await this.cms.auth.createSession(user.id) };
1315
+ const session = await this.cms.auth.createSession(user.id);
1316
+ await this.cms.audit.record({
1317
+ action: "sso.login",
1318
+ target: "auth",
1319
+ user: session.user,
1320
+ detail: { provider: provider.id, email: email2 }
1321
+ });
1322
+ return { redirect: pending.r, session };
964
1323
  }
965
1324
  /** A user's outside accounts. */
966
1325
  async identities(userId) {
@@ -999,6 +1358,13 @@ var SingleSignOn = class {
999
1358
  { field: "provider", message: "is the only way left to sign in to this account" }
1000
1359
  ]);
1001
1360
  await this.cms.db.delete({ collection: USER_IDENTITIES, id: row.id });
1361
+ await this.cms.audit.record({
1362
+ action: "sso.unlink",
1363
+ target: USERS,
1364
+ doc: row.user,
1365
+ title: typeof user?.email === "string" ? user.email : null,
1366
+ detail: { provider: String(row.provider), email: row.email ?? null }
1367
+ });
1002
1368
  }
1003
1369
  /** Forgets a deleted user's outside accounts. */
1004
1370
  async forget(userId) {
@@ -1103,7 +1469,12 @@ var Auth = class {
1103
1469
  const email2 = typeof args.email === "string" ? args.email.trim().toLowerCase() : "";
1104
1470
  const password = typeof args.password === "string" ? args.password : "";
1105
1471
  const key2 = `${email2}|${args.ip ?? ""}`;
1106
- await this.checkRateLimit(key2);
1472
+ try {
1473
+ await this.checkRateLimit(key2);
1474
+ } catch (error) {
1475
+ await this.cms.audit.record({ action: "login.locked", target: "auth", user: null, email: email2 });
1476
+ throw error;
1477
+ }
1107
1478
  const [user] = email2 ? (await this.db.find({
1108
1479
  collection: USERS,
1109
1480
  where: { email: { equals: email2 } },
@@ -1115,15 +1486,34 @@ var Auth = class {
1115
1486
  const valid = user && hash && password ? await verifyPassword(password, hash) : await fakeVerify(password);
1116
1487
  if (!user || !valid || user.active === false) {
1117
1488
  await this.recordFailure(key2);
1489
+ await this.cms.audit.record({
1490
+ action: "login.failed",
1491
+ target: "auth",
1492
+ user: null,
1493
+ email: email2,
1494
+ detail: {
1495
+ reason: !user ? "unknown email" : user.active === false ? "deactivated" : "wrong password"
1496
+ }
1497
+ });
1118
1498
  throw new UnauthorizedError(INVALID);
1119
1499
  }
1120
1500
  await this.clearFailures(key2);
1121
- if (!this.sso.passwordAllowed(user))
1501
+ if (!this.sso.passwordAllowed(user)) {
1502
+ await this.cms.audit.record({
1503
+ action: "login.failed",
1504
+ target: "auth",
1505
+ user: null,
1506
+ email: email2,
1507
+ detail: { reason: "passwords are off" }
1508
+ });
1122
1509
  throw new UnauthorizedError(
1123
1510
  `Sign in with ${this.config.auth.providers.map((p) => p.name).join(" or ")}`
1124
1511
  );
1512
+ }
1125
1513
  await this.deleteExpiredSessions(user.id);
1126
- return this.startSession(user);
1514
+ const session = await this.startSession(user);
1515
+ await this.cms.audit.record({ action: "login", target: "auth", user: session.user });
1516
+ return session;
1127
1517
  }
1128
1518
  /** Creates the first admin. Only works while there are no users. */
1129
1519
  async registerFirstUser(args) {
@@ -1146,7 +1536,15 @@ var Auth = class {
1146
1536
  const token = unsignToken(this.config.secret, signedToken);
1147
1537
  if (!token) return;
1148
1538
  const session = await this.findSession(token);
1149
- if (session) await this.db.delete({ collection: SESSIONS, id: session.id });
1539
+ if (!session) return;
1540
+ await this.db.delete({ collection: SESSIONS, id: session.id });
1541
+ const user = await this.db.findById({ collection: USERS, id: session.user });
1542
+ if (user)
1543
+ await this.cms.audit.record({
1544
+ action: "logout",
1545
+ target: "auth",
1546
+ user: await this.toAuthUser(user)
1547
+ });
1150
1548
  }
1151
1549
  /**
1152
1550
  * Returns the user for a signed session token, or for an API key (`ecms_…`, with `apiKey` set
@@ -1220,6 +1618,7 @@ var Auth = class {
1220
1618
  });
1221
1619
  if (recent >= RESET_REQUESTS) return;
1222
1620
  await this.recordFailure(key2);
1621
+ await this.cms.audit.record({ action: "password.forgot", target: "auth", user: null, email: email2 });
1223
1622
  const user = await this.findByEmail(email2);
1224
1623
  if (!user || user.active === false || !this.sso.passwordAllowed(user)) return;
1225
1624
  await this.mailPasswordLink(user, user.passwordHash ? "reset" : "invite", args);
@@ -1238,6 +1637,13 @@ var Auth = class {
1238
1637
  if (user.active === false) throw new QueryError("This user is deactivated");
1239
1638
  const purpose = user.passwordHash ? "reset" : "invite";
1240
1639
  await this.mailPasswordLink(user, purpose, options);
1640
+ await this.cms.audit.record({
1641
+ action: "password.link",
1642
+ target: USERS,
1643
+ doc: user.id,
1644
+ title: String(user.email),
1645
+ detail: { purpose }
1646
+ });
1241
1647
  return purpose;
1242
1648
  }
1243
1649
  /** The user and purpose of a valid, unused password link, or `null`. */
@@ -1263,6 +1669,12 @@ var Auth = class {
1263
1669
  await this.clearFailures(key2);
1264
1670
  const user = await this.db.findById({ collection: USERS, id: found.user.id });
1265
1671
  const session = await this.startSession(user);
1672
+ await this.cms.audit.record({
1673
+ action: "password.reset",
1674
+ target: "auth",
1675
+ user: session.user,
1676
+ detail: { purpose: found.purpose }
1677
+ });
1266
1678
  if (found.purpose === "reset") {
1267
1679
  const content = await (this.config.auth.emails.passwordChanged ?? DEFAULT_PASSWORD_EMAILS.passwordChanged)({
1268
1680
  user: await this.toAuthUser(user),
@@ -1579,26 +1991,26 @@ async function writeBackupFile(cms, file) {
1579
1991
  });
1580
1992
  }
1581
1993
  }
1582
- var slug = (text) => text.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "easy-cms";
1994
+ var slug = (text2) => text2.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "easy-cms";
1583
1995
  function filenameFor(cms, date) {
1584
1996
  const pad = (n) => String(n).padStart(2, "0");
1585
1997
  const stamp = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}`;
1586
1998
  return `${slug(cms.config.admin.brand.name || "easy-cms")}-${stamp}.db.gz`;
1587
1999
  }
1588
2000
  function toAdmin(row) {
1589
- const text = (value) => typeof value === "string" && value !== "" ? value : null;
2001
+ const text2 = (value) => typeof value === "string" && value !== "" ? value : null;
1590
2002
  return {
1591
2003
  id: row.id,
1592
2004
  state: ["pending", "running", "done", "failed"].includes(row.state) ? row.state : "failed",
1593
2005
  trigger: row.trigger === "scheduled" ? "scheduled" : "manual",
1594
- filename: text(row.filename),
2006
+ filename: text2(row.filename),
1595
2007
  size: typeof row.size === "number" ? row.size : null,
1596
- startedAt: text(row.startedAt),
1597
- finishedAt: text(row.finishedAt),
1598
- error: text(row.error),
1599
- author: text(row.author),
1600
- downloadedBy: text(row.downloadedBy),
1601
- downloadedAt: text(row.downloadedAt)
2008
+ startedAt: text2(row.startedAt),
2009
+ finishedAt: text2(row.finishedAt),
2010
+ error: text2(row.error),
2011
+ author: text2(row.author),
2012
+ downloadedBy: text2(row.downloadedBy),
2013
+ downloadedAt: text2(row.downloadedAt)
1602
2014
  };
1603
2015
  }
1604
2016
  async function update(cms, row, data) {
@@ -1915,8 +2327,8 @@ function itemsOf(field, n) {
1915
2327
  return `${n} ${unit}${n === 1 ? "" : "s"}`;
1916
2328
  }
1917
2329
  var isEmpty = (value) => value === void 0 || value === null || value === "";
1918
- function slugify(text) {
1919
- return text.normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{M}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "");
2330
+ function slugify(text2) {
2331
+ return text2.normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{M}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "");
1920
2332
  }
1921
2333
  function applyDefaults(fields, data, localization = null) {
1922
2334
  const result = { ...data };
@@ -2666,16 +3078,18 @@ var viewId = {
2666
3078
  };
2667
3079
  var tagOf = (component) => typeof component === "string" ? component : component.tag;
2668
3080
  var Roles = class {
2669
- constructor(config, db, log) {
3081
+ constructor(config, db, log, audit) {
2670
3082
  this.config = config;
2671
3083
  this.db = db;
2672
3084
  this.log = log;
3085
+ this.audit = audit;
2673
3086
  this.enabled = config.auth.rbac;
2674
3087
  this.versions = new VersionStore(db);
2675
3088
  }
2676
3089
  config;
2677
3090
  db;
2678
3091
  log;
3092
+ audit;
2679
3093
  enabled;
2680
3094
  versions;
2681
3095
  cache;
@@ -2909,6 +3323,7 @@ var Roles = class {
2909
3323
  this.clean(input.permissions),
2910
3324
  author.id
2911
3325
  );
3326
+ await this.audit?.({ action: "role.create", target: "roles", doc: row.id, title: key2 });
2912
3327
  return this.toRole(row, 0);
2913
3328
  }
2914
3329
  async update(id, input, author) {
@@ -2923,6 +3338,18 @@ var Roles = class {
2923
3338
  const { id: _id, ...rest } = data;
2924
3339
  const saved = await this.db.update({ collection: ROLES, id: row.id, data: rest });
2925
3340
  await this.record(saved, author.id);
3341
+ const changes = [];
3342
+ if ((row.name ?? "") !== (saved.name ?? ""))
3343
+ changes.push({ field: "name", before: row.name ?? "", after: saved.name ?? "" });
3344
+ if (JSON.stringify(row.permissions ?? {}) !== JSON.stringify(saved.permissions ?? {}))
3345
+ changes.push({ field: "permissions" });
3346
+ await this.audit?.({
3347
+ action: "role.update",
3348
+ target: "roles",
3349
+ doc: row.id,
3350
+ title: String(row.key),
3351
+ changes
3352
+ });
2926
3353
  this.invalidate();
2927
3354
  return this.toRole(saved, await this.users(String(row.key)));
2928
3355
  }
@@ -2936,6 +3363,7 @@ var Roles = class {
2936
3363
  throw new EasyCMSError(`${users} user(s) have this role; give them another role first`, 409);
2937
3364
  await this.db.delete({ collection: ROLES, id: row.id });
2938
3365
  await this.versions.deleteAll(ROLES, row.id);
3366
+ await this.audit?.({ action: "role.delete", target: "roles", doc: row.id, title: key2 });
2939
3367
  this.invalidate();
2940
3368
  }
2941
3369
  async history(id) {
@@ -2970,6 +3398,7 @@ var Roles = class {
2970
3398
  views() {
2971
3399
  return [
2972
3400
  { id: "status" },
3401
+ ...this.config.audit ? [{ id: "audit" }] : [],
2973
3402
  ...this.config.collections.some(
2974
3403
  (c) => c.slug === WEBHOOK_DELIVERIES || c.slug === EMAIL_DELIVERIES
2975
3404
  ) ? [{ id: "deliveries" }] : [],
@@ -3174,7 +3603,7 @@ function rank(role, config) {
3174
3603
  }
3175
3604
 
3176
3605
  // src/webhooks.ts
3177
- import { createHmac as createHmac3, randomUUID as randomUUID2 } from "crypto";
3606
+ import { createHmac as createHmac4, randomUUID as randomUUID2 } from "crypto";
3178
3607
  var WEBHOOK_EVENTS = [
3179
3608
  "create",
3180
3609
  "update",
@@ -3318,7 +3747,7 @@ var Webhooks = class {
3318
3747
  ...hook.headers
3319
3748
  };
3320
3749
  if (hook.secret) {
3321
- headers["x-easy-cms-signature"] = `sha256=${createHmac3("sha256", hook.secret).update(body).digest("hex")}`;
3750
+ headers["x-easy-cms-signature"] = `sha256=${createHmac4("sha256", hook.secret).update(body).digest("hex")}`;
3322
3751
  }
3323
3752
  try {
3324
3753
  const response = await this.fetcher(hook.url, {
@@ -3366,6 +3795,7 @@ var RESERVED_SLUGS = /* @__PURE__ */ new Set([
3366
3795
  "database-backups",
3367
3796
  "user-roles",
3368
3797
  "user-identities",
3798
+ "audit-logs",
3369
3799
  "api-keys",
3370
3800
  "jobs",
3371
3801
  "auth",
@@ -3390,6 +3820,7 @@ function validateConfig(config) {
3390
3820
  validateAdminViews(config, add);
3391
3821
  validateUpload(config, add);
3392
3822
  validateBackups(config, add);
3823
+ validateAudit(config, add);
3393
3824
  validateAuth(config, add);
3394
3825
  validateCors(config.cors, add);
3395
3826
  validateLocalization(config, add);
@@ -3874,6 +4305,22 @@ function validateUpload(config, add) {
3874
4305
  add("upload.fromURL.allowPrivate", "must be true or false");
3875
4306
  }
3876
4307
  var HOST_PATTERN = /^(\*|(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*)$/i;
4308
+ function validateAudit(config, add) {
4309
+ const audit = config.audit;
4310
+ if (audit !== void 0 && typeof audit !== "boolean") {
4311
+ const a = audit;
4312
+ if (typeof a !== "object" || a === null)
4313
+ add("audit", "must be true or { keep, values, failedLogins }");
4314
+ else {
4315
+ if (a.keep !== void 0 && !(Number.isInteger(a.keep) && a.keep >= 0))
4316
+ add("audit.keep", "must be a number of days (0: keep all)");
4317
+ if (a.values !== void 0 && typeof a.values !== "boolean")
4318
+ add("audit.values", "must be true or false");
4319
+ if (a.failedLogins !== void 0 && !(Number.isInteger(a.failedLogins) && a.failedLogins > 0))
4320
+ add("audit.failedLogins", "must be a positive number");
4321
+ }
4322
+ }
4323
+ }
3877
4324
  function validateBackups(config, add) {
3878
4325
  const backups = config.backups;
3879
4326
  if (backups === void 0) return;
@@ -4393,6 +4840,7 @@ async function resolveConfig(input) {
4393
4840
  inviteExpiration: config.auth?.inviteExpiration ?? 7 * 24 * 60 * 60,
4394
4841
  emails: config.auth?.emails ?? {}
4395
4842
  },
4843
+ audit: config.audit ? resolveAudit(config.audit === true ? {} : config.audit) : false,
4396
4844
  collections: [
4397
4845
  ...config.collections ?? [],
4398
4846
  ...internalCollections,
@@ -4404,7 +4852,8 @@ async function resolveConfig(input) {
4404
4852
  ...config.email ? [emailDeliveriesCollection] : [],
4405
4853
  databaseBackupsCollection,
4406
4854
  ...config.auth?.rbac ? [rolesCollection] : [],
4407
- ...(config.auth?.providers?.length ?? 0) > 0 ? [userIdentitiesCollection] : []
4855
+ ...(config.auth?.providers?.length ?? 0) > 0 ? [userIdentitiesCollection] : [],
4856
+ ...config.audit ? [auditLogsCollection] : []
4408
4857
  ],
4409
4858
  globals: config.globals ?? [],
4410
4859
  endpoints: config.endpoints ?? [],
@@ -4423,6 +4872,13 @@ function usesVersions(config) {
4423
4872
  function usesSchedule(config) {
4424
4873
  return [...config.collections ?? [], ...config.globals ?? []].some((c) => c.schedule);
4425
4874
  }
4875
+ function resolveAudit(audit) {
4876
+ return {
4877
+ keep: audit.keep ?? DEFAULT_AUDIT_KEEP,
4878
+ values: audit.values !== false,
4879
+ failedLogins: audit.failedLogins ?? DEFAULT_FAILED_LOGINS
4880
+ };
4881
+ }
4426
4882
 
4427
4883
  // src/load-config.ts
4428
4884
  var CONFIG_FILE_NAMES = [
@@ -4606,13 +5062,13 @@ function sniffMimeType(data) {
4606
5062
  if (ascii(data, 0, 5) === "%PDF-") return "application/pdf";
4607
5063
  const head = data.subarray(0, 4096);
4608
5064
  if (head.includes(0)) return void 0;
4609
- let text;
5065
+ let text2;
4610
5066
  try {
4611
- text = new TextDecoder("utf-8", { fatal: true }).decode(head);
5067
+ text2 = new TextDecoder("utf-8", { fatal: true }).decode(head);
4612
5068
  } catch {
4613
5069
  return void 0;
4614
5070
  }
4615
- const trimmed = text.replace(/^/, "").trimStart();
5071
+ const trimmed = text2.replace(/^/, "").trimStart();
4616
5072
  if (/^(<\?xml[^>]*>\s*)?(<!--[\s\S]*?-->\s*)*(<!DOCTYPE svg[^>]*>\s*)?<svg[\s>]/i.test(trimmed))
4617
5073
  return "image/svg+xml";
4618
5074
  return "text/plain";
@@ -4988,6 +5444,8 @@ var EasyCMS = class {
4988
5444
  cwd;
4989
5445
  /** Roles and their permissions (`auth.rbac`, Settings → Roles). */
4990
5446
  roles;
5447
+ /** Who did what (`audit`, Settings → Audit log). */
5448
+ audit;
4991
5449
  versions;
4992
5450
  webhooks;
4993
5451
  mailer;
@@ -4998,7 +5456,13 @@ var EasyCMS = class {
4998
5456
  this.storage = storage;
4999
5457
  this.cwd = cwd;
5000
5458
  this.versions = new VersionStore(db);
5001
- this.roles = new Roles(config, db, (message2) => logger.info(message2));
5459
+ this.roles = new Roles(
5460
+ config,
5461
+ db,
5462
+ (message2) => logger.info(message2),
5463
+ (entry) => this.audit.record(entry)
5464
+ );
5465
+ this.audit = new AuditLog(this);
5002
5466
  this.webhooks = new Webhooks(
5003
5467
  config.webhooks ?? [],
5004
5468
  logger,
@@ -5480,6 +5944,7 @@ var EasyCMS = class {
5480
5944
  operation: "update"
5481
5945
  });
5482
5946
  this.emit(config, "draft", draft);
5947
+ await this.audited(config, "draft", draft, current, guard, mode);
5483
5948
  const [out2] = await this.output(config, [draft], guard, {
5484
5949
  ...options,
5485
5950
  draft: true
@@ -5502,6 +5967,7 @@ var EasyCMS = class {
5502
5967
  operation: "update"
5503
5968
  });
5504
5969
  this.emit(config, "update", doc, existing.status);
5970
+ await this.audited(config, "update", doc, current, guard, mode);
5505
5971
  const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
5506
5972
  return out;
5507
5973
  }
@@ -5546,6 +6012,7 @@ var EasyCMS = class {
5546
6012
  doc: existing
5547
6013
  });
5548
6014
  this.emit(config, "delete", existing);
6015
+ await this.audited(config, "delete", existing, existing, guard);
5549
6016
  const [out] = await this.output(config, [existing], guard, { depth: 0, draft: true });
5550
6017
  return out;
5551
6018
  }
@@ -5675,6 +6142,7 @@ var EasyCMS = class {
5675
6142
  operation: "update"
5676
6143
  });
5677
6144
  this.emit(config, "draft", draft);
6145
+ await this.audited(config, "draft", draft, current, guard, mode);
5678
6146
  return this.findGlobal(slug2, { ...options, draft: true });
5679
6147
  }
5680
6148
  const doc = await this.db.updateGlobal({ slug: slug2, data: { ...prepared, updatedAt: now } });
@@ -5686,6 +6154,7 @@ var EasyCMS = class {
5686
6154
  operation: "update"
5687
6155
  });
5688
6156
  this.emit(config, "update", doc, existing.status);
6157
+ await this.audited(config, "update", doc, current, guard, mode);
5689
6158
  return this.findGlobal(slug2, { ...options, draft: true });
5690
6159
  }
5691
6160
  /**
@@ -5787,16 +6256,19 @@ var EasyCMS = class {
5787
6256
  * scheduler and the cron endpoint (`GET <api>/jobs/run`) call.
5788
6257
  */
5789
6258
  async runJobs(now = /* @__PURE__ */ new Date()) {
5790
- const scheduled = await this.runScheduled(now);
5791
- await this.pruneFailedDeliveries(now);
5792
- await runDueBackups(this, now).catch(
5793
- (error) => this.logger.error(`Backups: ${error.message}`)
5794
- );
5795
- return {
5796
- ...scheduled,
5797
- webhooks: await this.retryWebhooks(now),
5798
- emails: await this.mailer.retry(now)
5799
- };
6259
+ return auditContext.run({ via: "scheduler", user: null }, async () => {
6260
+ const scheduled = await this.runScheduled(now);
6261
+ await this.pruneFailedDeliveries(now);
6262
+ await runDueBackups(this, now).catch(
6263
+ (error) => this.logger.error(`Backups: ${error.message}`)
6264
+ );
6265
+ await this.audit.upkeep(now).catch((error) => this.logger.error(`Audit log: ${error.message}`));
6266
+ return {
6267
+ ...scheduled,
6268
+ webhooks: await this.retryWebhooks(now),
6269
+ emails: await this.mailer.retry(now)
6270
+ };
6271
+ });
5800
6272
  }
5801
6273
  /**
5802
6274
  * Tries webhook deliveries that are due for another attempt. Deliveries are kept in the
@@ -5961,6 +6433,13 @@ var EasyCMS = class {
5961
6433
  updatedAt: now
5962
6434
  }
5963
6435
  });
6436
+ await this.audit.record({
6437
+ action: "schedule",
6438
+ target: parent,
6439
+ ...parent.startsWith("global:") ? {} : { doc },
6440
+ ...guardOf(options).user ? { user: guardOf(options).user } : {},
6441
+ detail: { action: job.action, at: runAt.toISOString() }
6442
+ });
5964
6443
  return toJob(row);
5965
6444
  }
5966
6445
  /**
@@ -6020,6 +6499,12 @@ var EasyCMS = class {
6020
6499
  const job = (await this.pendingJobs(parent, doc)).find((j) => String(j.id) === String(jobId));
6021
6500
  if (!job) throw new NotFoundError("scheduled job", jobId);
6022
6501
  await this.db.delete({ collection: SCHEDULED_JOBS, id: job.id });
6502
+ await this.audit.record({
6503
+ action: "unschedule",
6504
+ target: parent,
6505
+ ...parent.startsWith("global:") ? {} : { doc },
6506
+ detail: { action: job.action, at: job.runAt }
6507
+ });
6023
6508
  }
6024
6509
  /**
6025
6510
  * @internal One attempt now for a saved webhook delivery or email (the admin's Retry): removed
@@ -6264,6 +6749,7 @@ var EasyCMS = class {
6264
6749
  operation: "create"
6265
6750
  });
6266
6751
  this.emit(config, "create", doc);
6752
+ await this.audited(config, "create", doc, void 0, hookGuard ?? guard);
6267
6753
  const [out] = await this.output(config, [doc], hookGuard ?? guard, { ...options, draft: true });
6268
6754
  return out;
6269
6755
  }
@@ -6337,6 +6823,31 @@ var EasyCMS = class {
6337
6823
  if (after !== "published" && before === "published")
6338
6824
  this.webhooks.emit("unpublish", target, body);
6339
6825
  }
6826
+ /**
6827
+ * Writes the audit log entry for a change (`audit`): publishing, unpublishing and restoring
6828
+ * are told apart from other saves.
6829
+ */
6830
+ async audited(config, event, doc, previous, guard, mode = "save") {
6831
+ if (!this.audit.enabled) return;
6832
+ let action = event;
6833
+ if (event === "update" || event === "draft") {
6834
+ const was = previous?.status;
6835
+ if (mode === "restore") action = "restore";
6836
+ else if (config.drafts && doc.status === "published" && was !== "published")
6837
+ action = "publish";
6838
+ else if (config.drafts && event === "update" && doc.status !== "published" && was === "published")
6839
+ action = "unpublish";
6840
+ }
6841
+ const global2 = !this.config.collections.includes(config);
6842
+ await this.audit.content(
6843
+ config,
6844
+ global2,
6845
+ action,
6846
+ event === "delete" ? void 0 : doc,
6847
+ event === "create" ? void 0 : previous,
6848
+ guard.user
6849
+ );
6850
+ }
6340
6851
  /** Records a version after a save, when the collection or global keeps versions. */
6341
6852
  async saveVersion(config, parent, id, doc, guard) {
6342
6853
  const max = versionLimit(config);
@@ -6679,9 +7190,9 @@ var PER_PAGE = 50;
6679
7190
  var collectionOf = (kind) => kind === "webhook" ? WEBHOOK_DELIVERIES : EMAIL_DELIVERIES;
6680
7191
  var exists = (cms, kind) => cms.config.collections.some((c) => c.slug === collectionOf(kind));
6681
7192
  var isDeliveryKind = (value) => value === "webhook" || value === "email";
6682
- function parse(text) {
7193
+ function parse(text2) {
6683
7194
  try {
6684
- const value = JSON.parse(String(text ?? ""));
7195
+ const value = JSON.parse(String(text2 ?? ""));
6685
7196
  return typeof value === "object" && value !== null ? value : {};
6686
7197
  } catch {
6687
7198
  return {};
@@ -6848,7 +7359,7 @@ async function sendTestEmail(cms, user, to, locale) {
6848
7359
  const th = locale === "th" || locale !== "en" && cms.config.admin.locale === "th";
6849
7360
  const time = (/* @__PURE__ */ new Date()).toISOString();
6850
7361
  const subject = th ? `\u0E2D\u0E35\u0E40\u0E21\u0E25\u0E17\u0E14\u0E2A\u0E2D\u0E1A\u0E08\u0E32\u0E01 ${site}` : `Test email from ${site}`;
6851
- const text = th ? `\u0E2D\u0E35\u0E40\u0E21\u0E25\u0E19\u0E35\u0E49\u0E2A\u0E48\u0E07\u0E08\u0E32\u0E01\u0E2B\u0E19\u0E49\u0E32 \u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32 \u2192 \u0E2D\u0E35\u0E40\u0E21\u0E25 \u0E02\u0E2D\u0E07 ${site} \u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E17\u0E14\u0E2A\u0E2D\u0E1A\u0E01\u0E32\u0E23\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32
7362
+ const text2 = th ? `\u0E2D\u0E35\u0E40\u0E21\u0E25\u0E19\u0E35\u0E49\u0E2A\u0E48\u0E07\u0E08\u0E32\u0E01\u0E2B\u0E19\u0E49\u0E32 \u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32 \u2192 \u0E2D\u0E35\u0E40\u0E21\u0E25 \u0E02\u0E2D\u0E07 ${site} \u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E17\u0E14\u0E2A\u0E2D\u0E1A\u0E01\u0E32\u0E23\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32
6852
7363
 
6853
7364
  \u0E2A\u0E48\u0E07\u0E42\u0E14\u0E22: ${user.email}
6854
7365
  Adapter: ${adapter.name ?? "custom"}
@@ -6860,7 +7371,7 @@ Adapter: ${adapter.name ?? "custom"}
6860
7371
  From: ${from}
6861
7372
  Time: ${time}`;
6862
7373
  try {
6863
- await adapter.send({ to: address, subject, text, from });
7374
+ await adapter.send({ to: address, subject, text: text2, from });
6864
7375
  return { ok: true };
6865
7376
  } catch (error) {
6866
7377
  return { ok: false, error: message(error) };
@@ -7037,7 +7548,8 @@ async function adminSchema(cms, user, origin) {
7037
7548
  backups: admin,
7038
7549
  email: admin,
7039
7550
  roles: admin && cms.roles.enabled,
7040
- sso: admin && cms.auth.sso.enabled
7551
+ sso: admin && cms.auth.sso.enabled,
7552
+ audit: cms.audit.enabled && await cms.roles.canView(user, "audit")
7041
7553
  };
7042
7554
  const roles = await cms.roles.options();
7043
7555
  return {
@@ -7099,16 +7611,16 @@ async function widgets(cms, user) {
7099
7611
  }
7100
7612
 
7101
7613
  // src/version.ts
7102
- var VERSION = true ? "0.35.0" : "0.0.0-dev";
7614
+ var VERSION = true ? "0.36.0" : "0.0.0-dev";
7103
7615
 
7104
7616
  // src/rest/admin-status.ts
7105
- var DAY = 864e5;
7617
+ var DAY2 = 864e5;
7106
7618
  var has = (cms, slug2) => cms.config.collections.some((c) => c.slug === slug2);
7107
7619
  async function adminStatus(cms) {
7108
7620
  const config = cms.config;
7109
7621
  const attention = [];
7110
7622
  const now = Date.now();
7111
- const weekAgo = new Date(now - 7 * DAY).toISOString();
7623
+ const weekAgo = new Date(now - 7 * DAY2).toISOString();
7112
7624
  if (has(cms, WEBHOOK_DELIVERIES)) {
7113
7625
  const failed = await cms.db.find({
7114
7626
  collection: WEBHOOK_DELIVERIES,
@@ -7158,6 +7670,7 @@ async function adminStatus(cms) {
7158
7670
  }
7159
7671
  const backups = await backupAttention(cms);
7160
7672
  if (backups) attention.push(backups);
7673
+ attention.push(...await cms.audit.attention());
7161
7674
  if (!config.email) attention.push({ id: "no-email" });
7162
7675
  if (!config.serverURL && process.env.NODE_ENV === "production")
7163
7676
  attention.push({ id: "no-server-url" });
@@ -7367,7 +7880,15 @@ async function respond(cms, request, url, path, root, options, production) {
7367
7880
  const ctx = { request, url, user, via: user ? via : null, token, headers };
7368
7881
  const method = request.method.toUpperCase();
7369
7882
  if (method !== "GET" && method !== "HEAD") checkCsrf(cms, ctx);
7370
- const result = root ? await customEndpoint(cms, ctx, method, segments, true, options) : await customEndpoint(cms, ctx, method, segments, false, options) ?? await route(cms, ctx, method, segments, options);
7883
+ const audit = {
7884
+ user,
7885
+ ip: options.getClientIp?.(request),
7886
+ userAgent: request.headers.get("user-agent")
7887
+ };
7888
+ const result = await auditContext.run(
7889
+ audit,
7890
+ async () => root ? await customEndpoint(cms, ctx, method, segments, true, options) : await customEndpoint(cms, ctx, method, segments, false, options) ?? await route(cms, ctx, method, segments, options)
7891
+ );
7371
7892
  if (!result) throw new HttpError("Not found", 404);
7372
7893
  if (result.body instanceof Response) return result.body;
7373
7894
  return new Response(JSON.stringify(result.body), { status: result.status ?? 200, headers });
@@ -7552,8 +8073,11 @@ async function route(cms, ctx, method, segments, options) {
7552
8073
  if (extra !== void 0) throw new HttpError("Not found", 404);
7553
8074
  if (id2 === void 0) {
7554
8075
  if (method === "GET") return { body: await listBackups(cms) };
7555
- if (method === "POST")
7556
- return { status: 202, body: await startBackup(cms, "manual", ctx.user.email) };
8076
+ if (method === "POST") {
8077
+ const started = await startBackup(cms, "manual", ctx.user.email);
8078
+ await cms.audit.record({ action: "backup.start", target: "backups", doc: started.id });
8079
+ return { status: 202, body: started };
8080
+ }
7557
8081
  throw methodNotAllowed(ctx, "GET, POST");
7558
8082
  }
7559
8083
  const parsed = parseId(id2);
@@ -7561,6 +8085,12 @@ async function route(cms, ctx, method, segments, options) {
7561
8085
  if (action === "download") {
7562
8086
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
7563
8087
  const file = await downloadBackup(cms, parsed, ctx.user.email);
8088
+ await cms.audit.record({
8089
+ action: "backup.download",
8090
+ target: "backups",
8091
+ doc: parsed,
8092
+ title: file.filename
8093
+ });
7564
8094
  return {
7565
8095
  body: new Response(file.body, {
7566
8096
  headers: {
@@ -7575,6 +8105,7 @@ async function route(cms, ctx, method, segments, options) {
7575
8105
  if (action !== void 0) throw new HttpError("Not found", 404);
7576
8106
  if (method !== "DELETE") throw methodNotAllowed(ctx, "DELETE");
7577
8107
  await deleteBackup(cms, parsed);
8108
+ await cms.audit.record({ action: "backup.delete", target: "backups", doc: parsed });
7578
8109
  return { body: { deleted: 1 } };
7579
8110
  }
7580
8111
  if (first === "admin" && second === "email") {
@@ -7590,7 +8121,9 @@ async function route(cms, ctx, method, segments, options) {
7590
8121
  if (action === "verify") return { body: await verifyEmail(cms) };
7591
8122
  if (action === "test") {
7592
8123
  const body = await readJson(ctx.request);
7593
- return { body: await sendTestEmail(cms, ctx.user, body.to, body.locale) };
8124
+ const sent = await sendTestEmail(cms, ctx.user, body.to, body.locale);
8125
+ await cms.audit.record({ action: "email.test", target: "email", detail: { ...sent } });
8126
+ return { body: sent };
7594
8127
  }
7595
8128
  throw new HttpError("Not found", 404);
7596
8129
  }
@@ -7600,6 +8133,41 @@ async function route(cms, ctx, method, segments, options) {
7600
8133
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
7601
8134
  return { body: cms.auth.sso.settings(ctx.url.origin) };
7602
8135
  }
8136
+ if (first === "admin" && (second === "audit" || second === "audit.csv")) {
8137
+ if (!ctx.user) throw new UnauthorizedError();
8138
+ if (!cms.audit.enabled) throw new HttpError("Not found", 404);
8139
+ if (!await cms.roles.canView(ctx.user, "audit")) throw new ForbiddenError();
8140
+ const q = ctx.url.searchParams;
8141
+ const filter = {
8142
+ action: q.get("action"),
8143
+ target: q.get("target"),
8144
+ doc: q.get("doc"),
8145
+ actor: q.get("actor"),
8146
+ from: q.get("from"),
8147
+ to: q.get("to")
8148
+ };
8149
+ if (second === "audit.csv" && third === void 0) {
8150
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
8151
+ return {
8152
+ body: new Response(await cms.audit.csv(filter), {
8153
+ headers: {
8154
+ "content-type": "text/csv; charset=utf-8",
8155
+ "content-disposition": `attachment; filename="audit-log-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.csv"`,
8156
+ "cache-control": "no-store",
8157
+ "x-content-type-options": "nosniff"
8158
+ }
8159
+ })
8160
+ };
8161
+ }
8162
+ if (second === "audit" && third === "verify" && segments.length === 3) {
8163
+ if (method !== "POST") throw methodNotAllowed(ctx, "POST");
8164
+ return { body: await cms.audit.verify() };
8165
+ }
8166
+ if (second !== "audit" || third !== void 0) throw new HttpError("Not found", 404);
8167
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
8168
+ const page = Math.max(1, Number.parseInt(q.get("page") ?? "1", 10) || 1);
8169
+ return { body: await cms.audit.list(filter, page) };
8170
+ }
7603
8171
  if (first === "admin" && second === "owned" && third !== void 0 && segments.length === 3) {
7604
8172
  if (!ctx.user) throw new UnauthorizedError();
7605
8173
  if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
@@ -7650,21 +8218,46 @@ async function route(cms, ctx, method, segments, options) {
7650
8218
  if (!isDeliveryKind(kind) || extra !== void 0) throw new HttpError("Not found", 404);
7651
8219
  if (id2 === "retry" && action === void 0) {
7652
8220
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
7653
- return { body: await retryFailedDeliveries(cms, kind) };
8221
+ const retried = await retryFailedDeliveries(cms, kind);
8222
+ await cms.audit.record({
8223
+ action: "delivery.retry",
8224
+ target: "deliveries",
8225
+ detail: { kind, all: true }
8226
+ });
8227
+ return { body: retried };
7654
8228
  }
7655
8229
  if (id2 === void 0) {
7656
8230
  if (method !== "DELETE") throw methodNotAllowed(ctx, "DELETE");
7657
- return { body: await deleteFailedDeliveries(cms, kind) };
8231
+ const deleted = await deleteFailedDeliveries(cms, kind);
8232
+ await cms.audit.record({
8233
+ action: "delivery.delete",
8234
+ target: "deliveries",
8235
+ detail: { kind, all: true }
8236
+ });
8237
+ return { body: deleted };
7658
8238
  }
7659
8239
  const parsed = parseId(id2);
7660
8240
  if (parsed === void 0) throw new HttpError("Not found", 404);
7661
8241
  if (action === "retry") {
7662
8242
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
7663
- return { body: await cms.retryDelivery(kind, parsed) };
8243
+ const retried = await cms.retryDelivery(kind, parsed);
8244
+ await cms.audit.record({
8245
+ action: "delivery.retry",
8246
+ target: "deliveries",
8247
+ doc: parsed,
8248
+ detail: { kind }
8249
+ });
8250
+ return { body: retried };
7664
8251
  }
7665
8252
  if (action !== void 0) throw new HttpError("Not found", 404);
7666
8253
  if (method !== "DELETE") throw methodNotAllowed(ctx, "DELETE");
7667
8254
  await deleteDelivery(cms, kind, parsed);
8255
+ await cms.audit.record({
8256
+ action: "delivery.delete",
8257
+ target: "deliveries",
8258
+ doc: parsed,
8259
+ detail: { kind }
8260
+ });
7668
8261
  return { body: { deleted: 1 } };
7669
8262
  }
7670
8263
  if (first === "admin") {
@@ -8050,11 +8643,11 @@ async function readJson(request) {
8050
8643
  }
8051
8644
  const declared = Number(request.headers.get("content-length") ?? 0);
8052
8645
  if (declared > MAX_BODY_BYTES) throw new HttpError("Request body too large", 413);
8053
- const text = await request.text();
8054
- if (Buffer.byteLength(text) > MAX_BODY_BYTES) throw new HttpError("Request body too large", 413);
8646
+ const text2 = await request.text();
8647
+ if (Buffer.byteLength(text2) > MAX_BODY_BYTES) throw new HttpError("Request body too large", 413);
8055
8648
  let body;
8056
8649
  try {
8057
- body = JSON.parse(text);
8650
+ body = JSON.parse(text2);
8058
8651
  } catch {
8059
8652
  throw new HttpError("Request body is not valid JSON", 400);
8060
8653
  }
@@ -8333,6 +8926,7 @@ export {
8333
8926
  API_KEYS,
8334
8927
  API_KEY_GLOBAL_OPERATIONS,
8335
8928
  API_KEY_OPERATIONS,
8929
+ AuditLog,
8336
8930
  Auth,
8337
8931
  BUILTIN_COLLECTIONS,
8338
8932
  CONFIG_FILE_NAMES,