@easy-cms/core 0.34.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 -188
  2. package/dist/index.js +1284 -258
  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";
@@ -332,6 +144,8 @@ var WEBHOOK_DELIVERIES = "webhook-deliveries";
332
144
  var EMAIL_DELIVERIES = "email-deliveries";
333
145
  var DATABASE_BACKUPS = "database-backups";
334
146
  var ROLES = "user-roles";
147
+ var USER_IDENTITIES = "user-identities";
148
+ var AUDIT_LOGS = "audit-logs";
335
149
  var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
336
150
  SESSIONS,
337
151
  LOGIN_ATTEMPTS,
@@ -340,7 +154,9 @@ var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
340
154
  WEBHOOK_DELIVERIES,
341
155
  EMAIL_DELIVERIES,
342
156
  DATABASE_BACKUPS,
343
- ROLES
157
+ ROLES,
158
+ USER_IDENTITIES,
159
+ AUDIT_LOGS
344
160
  ]);
345
161
  var DEFAULT_ROLES = ["admin", "editor"];
346
162
  var CREATED_BY_FIELD = {
@@ -600,6 +416,38 @@ var databaseBackupsCollection = {
600
416
  { name: "downloadedAt", type: "text" }
601
417
  ]
602
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
+ };
603
451
  var emailDeliveriesCollection = {
604
452
  slug: EMAIL_DELIVERIES,
605
453
  access: { read: nobody, create: nobody, update: nobody, delete: nobody },
@@ -614,15 +462,517 @@ var emailDeliveriesCollection = {
614
462
  ]
615
463
  };
616
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
+ }
840
+ };
841
+ var TooManyRequestsError = class extends EasyCMSError {
842
+ constructor(message2) {
843
+ super(message2, 429);
844
+ this.name = "TooManyRequestsError";
845
+ }
846
+ };
847
+ var PayloadTooLargeError = class extends EasyCMSError {
848
+ constructor(message2) {
849
+ super(message2, 413);
850
+ this.name = "PayloadTooLargeError";
851
+ }
852
+ };
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
+ }
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
+ }
966
+
617
967
  // src/auth/emails.ts
618
968
  var esc = (value) => value.replace(
619
969
  /[&<>"']/g,
620
970
  (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]
621
971
  );
622
972
  function email(subject, lines, button) {
623
- const text = [...lines, ...button ? ["", button.url] : []].join("\n");
973
+ const text2 = [...lines, ...button ? ["", button.url] : []].join("\n");
624
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>`;
625
- return { subject, text, html };
975
+ return { subject, text: text2, html };
626
976
  }
627
977
  var hours = (expiresAt) => Math.max(1, Math.round(((expiresAt?.getTime() ?? Date.now()) - Date.now()) / 36e5));
628
978
  var days = (expiresAt) => Math.max(1, Math.round(hours(expiresAt) / 24));
@@ -642,7 +992,17 @@ var DEFAULT_PASSWORD_EMAILS = {
642
992
  ],
643
993
  { label: "Set a new password", url }
644
994
  ),
645
- invite: ({ url = "", locale, expiresAt, user }) => locale === "th" ? email(
995
+ invite: ({ url = "", locale, expiresAt, user, sso }) => sso ? locale === "th" ? email(
996
+ "\u0E04\u0E38\u0E13\u0E44\u0E14\u0E49\u0E23\u0E31\u0E1A\u0E40\u0E0A\u0E34\u0E0D\u0E43\u0E2B\u0E49\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E23\u0E30\u0E1A\u0E1A\u0E08\u0E31\u0E14\u0E01\u0E32\u0E23\u0E40\u0E19\u0E37\u0E49\u0E2D\u0E2B\u0E32",
997
+ [`\u0E21\u0E35\u0E1A\u0E31\u0E0D\u0E0A\u0E35\u0E2A\u0E33\u0E2B\u0E23\u0E31\u0E1A ${user.email} \u0E23\u0E2D\u0E04\u0E38\u0E13\u0E2D\u0E22\u0E39\u0E48 \u0E40\u0E02\u0E49\u0E32\u0E2A\u0E39\u0E48\u0E23\u0E30\u0E1A\u0E1A\u0E14\u0E49\u0E27\u0E22 ${sso.join(" \u0E2B\u0E23\u0E37\u0E2D ")} \u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E40\u0E23\u0E34\u0E48\u0E21\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19`],
998
+ { label: "\u0E40\u0E02\u0E49\u0E32\u0E2A\u0E39\u0E48\u0E23\u0E30\u0E1A\u0E1A", url }
999
+ ) : email(
1000
+ "You're invited to the content admin",
1001
+ [
1002
+ `An account for ${user.email} is waiting for you. Sign in with ${sso.join(" or ")} to start.`
1003
+ ],
1004
+ { label: "Sign in", url }
1005
+ ) : locale === "th" ? email(
646
1006
  "\u0E04\u0E38\u0E13\u0E44\u0E14\u0E49\u0E23\u0E31\u0E1A\u0E40\u0E0A\u0E34\u0E0D\u0E43\u0E2B\u0E49\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E23\u0E30\u0E1A\u0E1A\u0E08\u0E31\u0E14\u0E01\u0E32\u0E23\u0E40\u0E19\u0E37\u0E49\u0E2D\u0E2B\u0E32",
647
1007
  [
648
1008
  `\u0E21\u0E35\u0E1A\u0E31\u0E0D\u0E0A\u0E35\u0E2A\u0E33\u0E2B\u0E23\u0E31\u0E1A ${user.email} \u0E23\u0E2D\u0E04\u0E38\u0E13\u0E2D\u0E22\u0E39\u0E48 \u0E15\u0E31\u0E49\u0E07\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E40\u0E23\u0E34\u0E48\u0E21\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19`,
@@ -711,8 +1071,11 @@ async function fakeVerify(password) {
711
1071
  return false;
712
1072
  }
713
1073
 
1074
+ // src/auth/sso.ts
1075
+ import { createHmac as createHmac3, randomBytes as randomBytes4 } from "crypto";
1076
+
714
1077
  // src/auth/tokens.ts
715
- 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";
716
1079
  function newToken() {
717
1080
  return randomBytes3(32).toString("base64url");
718
1081
  }
@@ -720,7 +1083,7 @@ function hashToken(token) {
720
1083
  return createHash2("sha256").update(token).digest("hex");
721
1084
  }
722
1085
  function hmac(secret, value) {
723
- return createHmac("sha256", secret).update(value).digest("base64url");
1086
+ return createHmac2("sha256", secret).update(value).digest("base64url");
724
1087
  }
725
1088
  function signToken(secret, token) {
726
1089
  return `${token}.${hmac(secret, `session:${token}`)}`;
@@ -792,14 +1155,309 @@ function passwordTokenMatches(secret, token, fingerprint) {
792
1155
  return safeEqual(token.slice(dot + 1), hmac(secret, `password:${payload}:${fingerprint}`));
793
1156
  }
794
1157
 
1158
+ // src/auth/sso.ts
1159
+ var SSO_COOKIE = "ecms-sso";
1160
+ var PENDING_TTL = 10 * 6e4;
1161
+ var random = () => randomBytes4(32).toString("base64url");
1162
+ function sign(secret, payload) {
1163
+ return createHmac3("sha256", secret).update(`sso:${payload}`).digest("base64url");
1164
+ }
1165
+ function seal(secret, pending) {
1166
+ const payload = Buffer.from(JSON.stringify(pending)).toString("base64url");
1167
+ return `${payload}.${sign(secret, payload)}`;
1168
+ }
1169
+ function unseal(secret, value) {
1170
+ if (!value) return null;
1171
+ const dot = value.lastIndexOf(".");
1172
+ if (dot <= 0) return null;
1173
+ const payload = value.slice(0, dot);
1174
+ if (!safeEqual(value.slice(dot + 1), sign(secret, payload))) return null;
1175
+ try {
1176
+ const pending = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
1177
+ return typeof pending.e === "number" && pending.e > Date.now() ? pending : null;
1178
+ } catch {
1179
+ return null;
1180
+ }
1181
+ }
1182
+ var SingleSignOn = class {
1183
+ constructor(cms) {
1184
+ this.cms = cms;
1185
+ }
1186
+ cms;
1187
+ get config() {
1188
+ return this.cms.config;
1189
+ }
1190
+ get enabled() {
1191
+ return this.config.auth.providers.length > 0;
1192
+ }
1193
+ providers() {
1194
+ return this.config.auth.providers.map((p) => ({
1195
+ id: p.id,
1196
+ name: p.name,
1197
+ icon: p.icon ?? "key"
1198
+ }));
1199
+ }
1200
+ /** Whether a user may sign in with a password (`auth.password: false` leaves only admins). */
1201
+ passwordAllowed(user) {
1202
+ return this.config.auth.password || user.role === "admin";
1203
+ }
1204
+ /** `<serverURL><api>/auth/<id>/callback`, or `null` without `serverURL` in production. */
1205
+ callbackURL(provider, origin) {
1206
+ const base = this.cms.auth.passwordLinkBase(origin);
1207
+ return base ? `${base}${this.config.routes.api}/auth/${provider}/callback` : null;
1208
+ }
1209
+ /**
1210
+ * Starts signing in (or, with `user`, linking an account): the provider's URL to send the
1211
+ * browser to, and the sealed value of the cookie that must come back with it.
1212
+ */
1213
+ async start(providerId, options) {
1214
+ const provider = this.provider(providerId);
1215
+ const redirectUri = this.callbackURL(provider.id, options.origin);
1216
+ if (!redirectUri)
1217
+ throw new QueryError("Signing in with providers needs `serverURL` in production");
1218
+ const pending = {
1219
+ p: provider.id,
1220
+ s: random(),
1221
+ v: random(),
1222
+ n: random(),
1223
+ r: this.adminPage(options.redirect),
1224
+ ...options.user ? { u: String(options.user.id) } : {},
1225
+ e: Date.now() + PENDING_TTL
1226
+ };
1227
+ const url = await provider.authorizationURL({
1228
+ redirectUri,
1229
+ state: pending.s,
1230
+ codeVerifier: pending.v,
1231
+ nonce: pending.n
1232
+ });
1233
+ return { url: url.toString(), cookie: seal(this.config.secret, pending) };
1234
+ }
1235
+ /**
1236
+ * Finishes at `<api>/auth/<id>/callback`: checks the response, finds (or makes) the user and
1237
+ * starts a session. Returns where to send the browser; failures end on the login page (or the
1238
+ * account page, when linking) with `?sso=<outcome>`.
1239
+ */
1240
+ async finish(providerId, options) {
1241
+ const admin = this.config.admin.path.replace(/\/+$/, "");
1242
+ const pending = unseal(this.config.secret, options.cookie);
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
+ };
1253
+ const page = pending?.u ? "account" : "login";
1254
+ if (!pending || pending.p !== providerId) return await back(page, "expired");
1255
+ const state = options.url.searchParams.get("state") ?? "";
1256
+ if (!safeEqual(state, pending.s)) return await back(page, "expired");
1257
+ if (options.url.searchParams.has("error")) return await back(page, "cancelled");
1258
+ const provider = this.provider(providerId);
1259
+ const redirectUri = this.callbackURL(provider.id, options.origin);
1260
+ let profile;
1261
+ try {
1262
+ if (!redirectUri) throw new Error("no callback URL");
1263
+ profile = await provider.callback({
1264
+ redirectUri,
1265
+ state: pending.s,
1266
+ codeVerifier: pending.v,
1267
+ nonce: pending.n,
1268
+ url: options.url
1269
+ });
1270
+ } catch (error) {
1271
+ this.cms.logger.warn(
1272
+ `Signing in with ${provider.name} failed: ${error.message || String(error)}`
1273
+ );
1274
+ return await back(page, "failed");
1275
+ }
1276
+ const subject = String(profile.subject);
1277
+ email2 = profile.email?.trim().toLowerCase() || null;
1278
+ if (pending.u) {
1279
+ if (!options.user || String(options.user.id) !== pending.u)
1280
+ return await back("account", "failed");
1281
+ const existing = await this.identity(provider.id, subject);
1282
+ if (existing && String(existing.user) !== pending.u) return await back("account", "taken");
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
+ });
1290
+ return { redirect: `${admin}/account?sso=linked` };
1291
+ }
1292
+ let user = null;
1293
+ const identity = await this.identity(provider.id, subject);
1294
+ if (identity) {
1295
+ user = await this.cms.db.findById({ collection: USERS, id: identity.user });
1296
+ } else {
1297
+ if (!email2 || !profile.emailVerified)
1298
+ return await back("login", email2 ? "unverified" : "no-account");
1299
+ user = await this.userByEmail(email2);
1300
+ if (!user) user = await this.signUp(email2, profile.name);
1301
+ if (!user) return await back("login", "no-account");
1302
+ await this.addIdentity(user.id, provider.id, subject, email2);
1303
+ }
1304
+ if (!user) return await back("login", "no-account");
1305
+ if (user.active === false) return await back("login", "inactive");
1306
+ const found = identity ?? await this.identity(provider.id, subject);
1307
+ if (found) {
1308
+ const { id, ...rest } = found;
1309
+ await this.cms.db.update({
1310
+ collection: USER_IDENTITIES,
1311
+ id,
1312
+ data: { ...rest, email: email2, lastUsedAt: (/* @__PURE__ */ new Date()).toISOString() }
1313
+ });
1314
+ }
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 };
1323
+ }
1324
+ /** A user's outside accounts. */
1325
+ async identities(userId) {
1326
+ if (!this.enabled) return [];
1327
+ const { docs } = await this.cms.db.find({
1328
+ collection: USER_IDENTITIES,
1329
+ where: { user: { equals: userId } },
1330
+ sort: ["id"],
1331
+ limit: 0,
1332
+ page: 1
1333
+ });
1334
+ return docs.map((row) => ({
1335
+ id: row.id,
1336
+ provider: String(row.provider),
1337
+ name: this.config.auth.providers.find((p) => p.id === row.provider)?.name ?? String(row.provider),
1338
+ email: typeof row.email === "string" ? row.email : null,
1339
+ createdAt: String(row.createdAt),
1340
+ lastUsedAt: typeof row.lastUsedAt === "string" ? row.lastUsedAt : null
1341
+ }));
1342
+ }
1343
+ /**
1344
+ * Unlinks an outside account: the user's own, or anyone's for admins. Refuses to unlink the
1345
+ * last way a user has to sign in.
1346
+ */
1347
+ async unlink(actor, identityId) {
1348
+ if (!this.enabled) throw new NotFoundError(USER_IDENTITIES, identityId);
1349
+ const row = await this.cms.db.findById({ collection: USER_IDENTITIES, id: identityId });
1350
+ if (!row) throw new NotFoundError(USER_IDENTITIES, identityId);
1351
+ const own = String(row.user) === String(actor.id);
1352
+ if (!own && (actor.role !== "admin" || actor.apiKey)) throw new ForbiddenError();
1353
+ const user = await this.cms.db.findById({ collection: USERS, id: row.user });
1354
+ const others = (await this.identities(row.user)).length - 1;
1355
+ const password = !!user?.passwordHash && this.passwordAllowed(user);
1356
+ if (others === 0 && !password)
1357
+ throw new ValidationError(USER_IDENTITIES, [
1358
+ { field: "provider", message: "is the only way left to sign in to this account" }
1359
+ ]);
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
+ });
1368
+ }
1369
+ /** Forgets a deleted user's outside accounts. */
1370
+ async forget(userId) {
1371
+ if (!this.enabled) return;
1372
+ for (const identity of await this.identities(userId))
1373
+ await this.cms.db.delete({ collection: USER_IDENTITIES, id: identity.id });
1374
+ }
1375
+ settings(origin) {
1376
+ const { allowSignUp } = this.config.auth;
1377
+ return {
1378
+ providers: this.config.auth.providers.map((p) => ({
1379
+ id: p.id,
1380
+ name: p.name,
1381
+ icon: p.icon ?? "key",
1382
+ callbackURL: this.callbackURL(p.id, origin),
1383
+ details: { ...p.describe?.() ?? {} }
1384
+ })),
1385
+ password: this.config.auth.password ? "everyone" : "admins",
1386
+ signUp: allowSignUp.domains.length > 0 ? { domains: [...allowSignUp.domains], role: allowSignUp.role ?? this.defaultRole() } : null,
1387
+ serverURL: !!this.config.serverURL
1388
+ };
1389
+ }
1390
+ // -------------------------------------------------------------------------
1391
+ provider(id) {
1392
+ const provider = this.config.auth.providers.find((p) => p.id === id);
1393
+ if (!provider) throw new NotFoundError("auth providers", id);
1394
+ return provider;
1395
+ }
1396
+ /** Only pages of the admin: anything else (another site) goes to the admin's home. */
1397
+ adminPage(redirect) {
1398
+ const admin = this.config.admin.path.replace(/\/+$/, "");
1399
+ if (typeof redirect === "string" && (redirect === admin || redirect.startsWith(`${admin}/`)) && !redirect.startsWith("//") && !redirect.includes("\\"))
1400
+ return redirect;
1401
+ return `${admin}/`;
1402
+ }
1403
+ async identity(provider, subject) {
1404
+ const { docs } = await this.cms.db.find({
1405
+ collection: USER_IDENTITIES,
1406
+ where: { and: [{ provider: { equals: provider } }, { subject: { equals: subject } }] },
1407
+ sort: [],
1408
+ limit: 1,
1409
+ page: 1
1410
+ });
1411
+ return docs[0];
1412
+ }
1413
+ async addIdentity(user, provider, subject, email2) {
1414
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1415
+ await this.cms.db.create({
1416
+ collection: USER_IDENTITIES,
1417
+ data: { user, provider, subject, email: email2, lastUsedAt: now, createdAt: now, updatedAt: now }
1418
+ });
1419
+ }
1420
+ async userByEmail(email2) {
1421
+ const { docs } = await this.cms.db.find({
1422
+ collection: USERS,
1423
+ where: { email: { equals: email2 } },
1424
+ sort: [],
1425
+ limit: 1,
1426
+ page: 1
1427
+ });
1428
+ return docs[0] ?? null;
1429
+ }
1430
+ /** A new account for people from `allowSignUp.domains`. */
1431
+ async signUp(email2, name) {
1432
+ const { domains, role } = this.config.auth.allowSignUp;
1433
+ const domain = email2.slice(email2.lastIndexOf("@") + 1);
1434
+ if (!domains.some((d) => d.toLowerCase() === domain)) return null;
1435
+ const created = await this.cms.create(USERS, {
1436
+ email: email2,
1437
+ role: role ?? this.defaultRole(),
1438
+ active: true,
1439
+ ...name ? { name } : {}
1440
+ });
1441
+ this.cms.logger.info(`${email2} signed up with a provider`);
1442
+ return this.cms.db.findById({ collection: USERS, id: created.id });
1443
+ }
1444
+ defaultRole() {
1445
+ const roles = this.config.auth.roles;
1446
+ return roles.includes("editor") ? "editor" : roles[roles.length - 1];
1447
+ }
1448
+ };
1449
+
795
1450
  // src/auth/auth.ts
796
1451
  var INVALID = "Invalid email or password";
797
1452
  var RESET_REQUESTS = 3;
798
1453
  var Auth = class {
799
1454
  constructor(cms) {
800
1455
  this.cms = cms;
1456
+ this.sso = new SingleSignOn(cms);
801
1457
  }
802
1458
  cms;
1459
+ /** Signing in with outside accounts (`auth.providers`). */
1460
+ sso;
803
1461
  get db() {
804
1462
  return this.cms.db;
805
1463
  }
@@ -811,7 +1469,12 @@ var Auth = class {
811
1469
  const email2 = typeof args.email === "string" ? args.email.trim().toLowerCase() : "";
812
1470
  const password = typeof args.password === "string" ? args.password : "";
813
1471
  const key2 = `${email2}|${args.ip ?? ""}`;
814
- 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
+ }
815
1478
  const [user] = email2 ? (await this.db.find({
816
1479
  collection: USERS,
817
1480
  where: { email: { equals: email2 } },
@@ -823,11 +1486,34 @@ var Auth = class {
823
1486
  const valid = user && hash && password ? await verifyPassword(password, hash) : await fakeVerify(password);
824
1487
  if (!user || !valid || user.active === false) {
825
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
+ });
826
1498
  throw new UnauthorizedError(INVALID);
827
1499
  }
828
1500
  await this.clearFailures(key2);
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
+ });
1509
+ throw new UnauthorizedError(
1510
+ `Sign in with ${this.config.auth.providers.map((p) => p.name).join(" or ")}`
1511
+ );
1512
+ }
829
1513
  await this.deleteExpiredSessions(user.id);
830
- 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;
831
1517
  }
832
1518
  /** Creates the first admin. Only works while there are no users. */
833
1519
  async registerFirstUser(args) {
@@ -850,7 +1536,15 @@ var Auth = class {
850
1536
  const token = unsignToken(this.config.secret, signedToken);
851
1537
  if (!token) return;
852
1538
  const session = await this.findSession(token);
853
- 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
+ });
854
1548
  }
855
1549
  /**
856
1550
  * Returns the user for a signed session token, or for an API key (`ecms_…`, with `apiKey` set
@@ -924,8 +1618,9 @@ var Auth = class {
924
1618
  });
925
1619
  if (recent >= RESET_REQUESTS) return;
926
1620
  await this.recordFailure(key2);
1621
+ await this.cms.audit.record({ action: "password.forgot", target: "auth", user: null, email: email2 });
927
1622
  const user = await this.findByEmail(email2);
928
- if (!user || user.active === false) return;
1623
+ if (!user || user.active === false || !this.sso.passwordAllowed(user)) return;
929
1624
  await this.mailPasswordLink(user, user.passwordHash ? "reset" : "invite", args);
930
1625
  }
931
1626
  /**
@@ -942,6 +1637,13 @@ var Auth = class {
942
1637
  if (user.active === false) throw new QueryError("This user is deactivated");
943
1638
  const purpose = user.passwordHash ? "reset" : "invite";
944
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
+ });
945
1647
  return purpose;
946
1648
  }
947
1649
  /** The user and purpose of a valid, unused password link, or `null`. */
@@ -967,6 +1669,12 @@ var Auth = class {
967
1669
  await this.clearFailures(key2);
968
1670
  const user = await this.db.findById({ collection: USERS, id: found.user.id });
969
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
+ });
970
1678
  if (found.purpose === "reset") {
971
1679
  const content = await (this.config.auth.emails.passwordChanged ?? DEFAULT_PASSWORD_EMAILS.passwordChanged)({
972
1680
  user: await this.toAuthUser(user),
@@ -981,7 +1689,7 @@ var Auth = class {
981
1689
  if (!claims || claims.expiresAt <= Date.now()) return null;
982
1690
  const id = /^\d+$/.test(claims.userId) ? Number(claims.userId) : claims.userId;
983
1691
  const user = await this.db.findById({ collection: USERS, id });
984
- if (!user || user.active === false) return null;
1692
+ if (!user || user.active === false || !this.sso.passwordAllowed(user)) return null;
985
1693
  const fingerprint = passwordFingerprint(user.passwordHash);
986
1694
  if (!passwordTokenMatches(this.config.secret, token, fingerprint)) return null;
987
1695
  return { user, purpose: claims.purpose };
@@ -996,13 +1704,14 @@ var Auth = class {
996
1704
  expiresAt,
997
1705
  fingerprint: passwordFingerprint(user.passwordHash)
998
1706
  });
999
- const url = `${base}${this.config.admin.path}/reset-password?token=${encodeURIComponent(token)}`;
1707
+ const sso = this.sso.passwordAllowed(user) ? void 0 : this.config.auth.providers.map((p) => p.name);
1708
+ const url = sso ? `${base}${this.config.admin.path}/login` : `${base}${this.config.admin.path}/reset-password?token=${encodeURIComponent(token)}`;
1000
1709
  const write = purpose === "invite" ? this.config.auth.emails.invite ?? DEFAULT_PASSWORD_EMAILS.invite : this.config.auth.emails.resetPassword ?? DEFAULT_PASSWORD_EMAILS.resetPassword;
1001
1710
  const content = await write({
1002
1711
  user: await this.toAuthUser(user),
1003
1712
  url,
1004
1713
  locale: this.localeOf(options.locale),
1005
- expiresAt: new Date(expiresAt)
1714
+ ...sso ? { sso } : { expiresAt: new Date(expiresAt) }
1006
1715
  });
1007
1716
  await this.cms.sendEmail({ to: String(user.email), ...content });
1008
1717
  }
@@ -1282,26 +1991,26 @@ async function writeBackupFile(cms, file) {
1282
1991
  });
1283
1992
  }
1284
1993
  }
1285
- 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";
1286
1995
  function filenameFor(cms, date) {
1287
1996
  const pad = (n) => String(n).padStart(2, "0");
1288
1997
  const stamp = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}`;
1289
1998
  return `${slug(cms.config.admin.brand.name || "easy-cms")}-${stamp}.db.gz`;
1290
1999
  }
1291
2000
  function toAdmin(row) {
1292
- const text = (value) => typeof value === "string" && value !== "" ? value : null;
2001
+ const text2 = (value) => typeof value === "string" && value !== "" ? value : null;
1293
2002
  return {
1294
2003
  id: row.id,
1295
2004
  state: ["pending", "running", "done", "failed"].includes(row.state) ? row.state : "failed",
1296
2005
  trigger: row.trigger === "scheduled" ? "scheduled" : "manual",
1297
- filename: text(row.filename),
2006
+ filename: text2(row.filename),
1298
2007
  size: typeof row.size === "number" ? row.size : null,
1299
- startedAt: text(row.startedAt),
1300
- finishedAt: text(row.finishedAt),
1301
- error: text(row.error),
1302
- author: text(row.author),
1303
- downloadedBy: text(row.downloadedBy),
1304
- 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)
1305
2014
  };
1306
2015
  }
1307
2016
  async function update(cms, row, data) {
@@ -1618,8 +2327,8 @@ function itemsOf(field, n) {
1618
2327
  return `${n} ${unit}${n === 1 ? "" : "s"}`;
1619
2328
  }
1620
2329
  var isEmpty = (value) => value === void 0 || value === null || value === "";
1621
- function slugify(text) {
1622
- 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, "");
1623
2332
  }
1624
2333
  function applyDefaults(fields, data, localization = null) {
1625
2334
  const result = { ...data };
@@ -2369,16 +3078,18 @@ var viewId = {
2369
3078
  };
2370
3079
  var tagOf = (component) => typeof component === "string" ? component : component.tag;
2371
3080
  var Roles = class {
2372
- constructor(config, db, log) {
3081
+ constructor(config, db, log, audit) {
2373
3082
  this.config = config;
2374
3083
  this.db = db;
2375
3084
  this.log = log;
3085
+ this.audit = audit;
2376
3086
  this.enabled = config.auth.rbac;
2377
3087
  this.versions = new VersionStore(db);
2378
3088
  }
2379
3089
  config;
2380
3090
  db;
2381
3091
  log;
3092
+ audit;
2382
3093
  enabled;
2383
3094
  versions;
2384
3095
  cache;
@@ -2612,6 +3323,7 @@ var Roles = class {
2612
3323
  this.clean(input.permissions),
2613
3324
  author.id
2614
3325
  );
3326
+ await this.audit?.({ action: "role.create", target: "roles", doc: row.id, title: key2 });
2615
3327
  return this.toRole(row, 0);
2616
3328
  }
2617
3329
  async update(id, input, author) {
@@ -2626,6 +3338,18 @@ var Roles = class {
2626
3338
  const { id: _id, ...rest } = data;
2627
3339
  const saved = await this.db.update({ collection: ROLES, id: row.id, data: rest });
2628
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
+ });
2629
3353
  this.invalidate();
2630
3354
  return this.toRole(saved, await this.users(String(row.key)));
2631
3355
  }
@@ -2639,6 +3363,7 @@ var Roles = class {
2639
3363
  throw new EasyCMSError(`${users} user(s) have this role; give them another role first`, 409);
2640
3364
  await this.db.delete({ collection: ROLES, id: row.id });
2641
3365
  await this.versions.deleteAll(ROLES, row.id);
3366
+ await this.audit?.({ action: "role.delete", target: "roles", doc: row.id, title: key2 });
2642
3367
  this.invalidate();
2643
3368
  }
2644
3369
  async history(id) {
@@ -2673,6 +3398,7 @@ var Roles = class {
2673
3398
  views() {
2674
3399
  return [
2675
3400
  { id: "status" },
3401
+ ...this.config.audit ? [{ id: "audit" }] : [],
2676
3402
  ...this.config.collections.some(
2677
3403
  (c) => c.slug === WEBHOOK_DELIVERIES || c.slug === EMAIL_DELIVERIES
2678
3404
  ) ? [{ id: "deliveries" }] : [],
@@ -2877,7 +3603,7 @@ function rank(role, config) {
2877
3603
  }
2878
3604
 
2879
3605
  // src/webhooks.ts
2880
- import { createHmac as createHmac2, randomUUID as randomUUID2 } from "crypto";
3606
+ import { createHmac as createHmac4, randomUUID as randomUUID2 } from "crypto";
2881
3607
  var WEBHOOK_EVENTS = [
2882
3608
  "create",
2883
3609
  "update",
@@ -3021,7 +3747,7 @@ var Webhooks = class {
3021
3747
  ...hook.headers
3022
3748
  };
3023
3749
  if (hook.secret) {
3024
- headers["x-easy-cms-signature"] = `sha256=${createHmac2("sha256", hook.secret).update(body).digest("hex")}`;
3750
+ headers["x-easy-cms-signature"] = `sha256=${createHmac4("sha256", hook.secret).update(body).digest("hex")}`;
3025
3751
  }
3026
3752
  try {
3027
3753
  const response = await this.fetcher(hook.url, {
@@ -3068,8 +3794,11 @@ var RESERVED_SLUGS = /* @__PURE__ */ new Set([
3068
3794
  "email-deliveries",
3069
3795
  "database-backups",
3070
3796
  "user-roles",
3797
+ "user-identities",
3798
+ "audit-logs",
3071
3799
  "api-keys",
3072
3800
  "jobs",
3801
+ "auth",
3073
3802
  "migrations",
3074
3803
  "access"
3075
3804
  ]);
@@ -3091,6 +3820,7 @@ function validateConfig(config) {
3091
3820
  validateAdminViews(config, add);
3092
3821
  validateUpload(config, add);
3093
3822
  validateBackups(config, add);
3823
+ validateAudit(config, add);
3094
3824
  validateAuth(config, add);
3095
3825
  validateCors(config.cors, add);
3096
3826
  validateLocalization(config, add);
@@ -3317,6 +4047,52 @@ function validateAuth(config, add) {
3317
4047
  if (auth === void 0) return;
3318
4048
  if (auth.rbac !== void 0 && typeof auth.rbac !== "boolean")
3319
4049
  add("auth.rbac", "must be true or false");
4050
+ if (auth.password !== void 0 && typeof auth.password !== "boolean")
4051
+ add("auth.password", "must be true or false");
4052
+ const providers = auth.providers;
4053
+ if (providers !== void 0) {
4054
+ if (!Array.isArray(providers)) {
4055
+ add(
4056
+ "auth.providers",
4057
+ "must be an array",
4058
+ "e.g. providers: [google({ clientId, clientSecret })]"
4059
+ );
4060
+ } else {
4061
+ const ids = /* @__PURE__ */ new Set();
4062
+ providers.forEach((provider, i) => {
4063
+ const p = provider;
4064
+ const path = `auth.providers[${i}]`;
4065
+ if (typeof p !== "object" || p === null || typeof p.authorizationURL !== "function" || typeof p.callback !== "function") {
4066
+ add(
4067
+ path,
4068
+ "must be a provider",
4069
+ "e.g. google({ clientId, clientSecret }) from @easy-cms/auth-oauth"
4070
+ );
4071
+ return;
4072
+ }
4073
+ if (typeof p.id !== "string" || !PAGE_PATH.test(p.id))
4074
+ add(`${path}.id`, 'must be lowercase letters, digits and "-"');
4075
+ else if (ids.has(p.id))
4076
+ add(`${path}.id`, `"${p.id}" is used by another provider`, "give one of them its own id");
4077
+ else ids.add(p.id);
4078
+ if (typeof p.name !== "string" || p.name.trim() === "")
4079
+ add(`${path}.name`, "must be a name");
4080
+ });
4081
+ }
4082
+ }
4083
+ const signUp = auth.allowSignUp;
4084
+ if (signUp !== void 0) {
4085
+ if (typeof signUp !== "object" || signUp === null || !Array.isArray(signUp.domains) || signUp.domains.some((d) => typeof d !== "string" || !/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(d)))
4086
+ add("auth.allowSignUp.domains", "must be email domains", "e.g. domains: ['example.com']");
4087
+ if (signUp?.role === "admin")
4088
+ add("auth.allowSignUp.role", `can't be "admin"`, "make admins by hand, in Settings \u2192 Users");
4089
+ else if (signUp?.role !== void 0 && (typeof signUp.role !== "string" || !auth.rbac && !(auth.roles ?? ["admin", "editor"]).includes(signUp.role)))
4090
+ add("auth.allowSignUp.role", "must be one of auth.roles");
4091
+ if (!Array.isArray(providers) || providers.length === 0)
4092
+ add("auth.allowSignUp", "needs auth.providers");
4093
+ }
4094
+ if (auth.password === false && (!Array.isArray(providers) || providers.length === 0))
4095
+ add("auth.password", "false needs auth.providers", "otherwise only admins could sign in");
3320
4096
  if (auth.roles !== void 0) {
3321
4097
  const roles = auth.roles;
3322
4098
  if (!Array.isArray(roles) || roles.some((r) => typeof r !== "string" || r === "")) {
@@ -3529,6 +4305,22 @@ function validateUpload(config, add) {
3529
4305
  add("upload.fromURL.allowPrivate", "must be true or false");
3530
4306
  }
3531
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
+ }
3532
4324
  function validateBackups(config, add) {
3533
4325
  const backups = config.backups;
3534
4326
  if (backups === void 0) return;
@@ -3843,7 +4635,15 @@ function validateComponents(components, path, add) {
3843
4635
  }
3844
4636
  var ENDPOINT_METHODS = ["get", "post", "put", "patch", "delete"];
3845
4637
  var ENDPOINT_SEGMENT = /^(:[A-Za-z_][A-Za-z0-9_]*|[A-Za-z0-9._~-]+)$/;
3846
- var RESERVED_ENDPOINT_ROOTS = /* @__PURE__ */ new Set(["users", "globals", "admin", "jobs", "media", "api-keys"]);
4638
+ var RESERVED_ENDPOINT_ROOTS = /* @__PURE__ */ new Set([
4639
+ "users",
4640
+ "globals",
4641
+ "admin",
4642
+ "jobs",
4643
+ "media",
4644
+ "api-keys",
4645
+ "auth"
4646
+ ]);
3847
4647
  function validateCommands(config, add) {
3848
4648
  const commands = config.commands;
3849
4649
  if (commands === void 0) return;
@@ -4029,6 +4829,9 @@ async function resolveConfig(input) {
4029
4829
  auth: {
4030
4830
  roles: config.auth?.roles ?? DEFAULT_ROLES,
4031
4831
  rbac: config.auth?.rbac === true,
4832
+ providers: config.auth?.providers ?? [],
4833
+ allowSignUp: config.auth?.allowSignUp ?? { domains: [] },
4834
+ password: config.auth?.password !== false,
4032
4835
  tokenExpiration: config.auth?.tokenExpiration ?? DEFAULT_TOKEN_EXPIRATION,
4033
4836
  maxLoginAttempts: config.auth?.maxLoginAttempts ?? 5,
4034
4837
  lockWindow: config.auth?.lockWindow ?? 15 * 60,
@@ -4037,6 +4840,7 @@ async function resolveConfig(input) {
4037
4840
  inviteExpiration: config.auth?.inviteExpiration ?? 7 * 24 * 60 * 60,
4038
4841
  emails: config.auth?.emails ?? {}
4039
4842
  },
4843
+ audit: config.audit ? resolveAudit(config.audit === true ? {} : config.audit) : false,
4040
4844
  collections: [
4041
4845
  ...config.collections ?? [],
4042
4846
  ...internalCollections,
@@ -4047,7 +4851,9 @@ async function resolveConfig(input) {
4047
4851
  ...config.apiKeys ? [apiKeysCollection] : [],
4048
4852
  ...config.email ? [emailDeliveriesCollection] : [],
4049
4853
  databaseBackupsCollection,
4050
- ...config.auth?.rbac ? [rolesCollection] : []
4854
+ ...config.auth?.rbac ? [rolesCollection] : [],
4855
+ ...(config.auth?.providers?.length ?? 0) > 0 ? [userIdentitiesCollection] : [],
4856
+ ...config.audit ? [auditLogsCollection] : []
4051
4857
  ],
4052
4858
  globals: config.globals ?? [],
4053
4859
  endpoints: config.endpoints ?? [],
@@ -4066,6 +4872,13 @@ function usesVersions(config) {
4066
4872
  function usesSchedule(config) {
4067
4873
  return [...config.collections ?? [], ...config.globals ?? []].some((c) => c.schedule);
4068
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
+ }
4069
4882
 
4070
4883
  // src/load-config.ts
4071
4884
  var CONFIG_FILE_NAMES = [
@@ -4113,7 +4926,7 @@ async function loadConfig(options = {}) {
4113
4926
  }
4114
4927
 
4115
4928
  // src/local-api.ts
4116
- import { randomBytes as randomBytes4 } from "crypto";
4929
+ import { randomBytes as randomBytes5 } from "crypto";
4117
4930
 
4118
4931
  // src/localization.ts
4119
4932
  var ALL_LOCALES = "all";
@@ -4249,13 +5062,13 @@ function sniffMimeType(data) {
4249
5062
  if (ascii(data, 0, 5) === "%PDF-") return "application/pdf";
4250
5063
  const head = data.subarray(0, 4096);
4251
5064
  if (head.includes(0)) return void 0;
4252
- let text;
5065
+ let text2;
4253
5066
  try {
4254
- text = new TextDecoder("utf-8", { fatal: true }).decode(head);
5067
+ text2 = new TextDecoder("utf-8", { fatal: true }).decode(head);
4255
5068
  } catch {
4256
5069
  return void 0;
4257
5070
  }
4258
- const trimmed = text.replace(/^/, "").trimStart();
5071
+ const trimmed = text2.replace(/^/, "").trimStart();
4259
5072
  if (/^(<\?xml[^>]*>\s*)?(<!--[\s\S]*?-->\s*)*(<!DOCTYPE svg[^>]*>\s*)?<svg[\s>]/i.test(trimmed))
4260
5073
  return "image/svg+xml";
4261
5074
  return "text/plain";
@@ -4310,10 +5123,10 @@ function imageDimensions(data, type) {
4310
5123
  }
4311
5124
  return void 0;
4312
5125
  }
4313
- function storageKey(originalName, type, random) {
5126
+ function storageKey(originalName, type, random2) {
4314
5127
  const name = originalName.split(/[\\/]/).pop() ?? "";
4315
5128
  const base = name.replace(/(?<=.)\.[^.]*$/, "").normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{M}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "file";
4316
- return `${base}-${random}.${EXTENSIONS[type] ?? "bin"}`;
5129
+ return `${base}-${random2}.${EXTENSIONS[type] ?? "bin"}`;
4317
5130
  }
4318
5131
 
4319
5132
  // src/populate.ts
@@ -4631,6 +5444,8 @@ var EasyCMS = class {
4631
5444
  cwd;
4632
5445
  /** Roles and their permissions (`auth.rbac`, Settings → Roles). */
4633
5446
  roles;
5447
+ /** Who did what (`audit`, Settings → Audit log). */
5448
+ audit;
4634
5449
  versions;
4635
5450
  webhooks;
4636
5451
  mailer;
@@ -4641,7 +5456,13 @@ var EasyCMS = class {
4641
5456
  this.storage = storage;
4642
5457
  this.cwd = cwd;
4643
5458
  this.versions = new VersionStore(db);
4644
- 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);
4645
5466
  this.webhooks = new Webhooks(
4646
5467
  config.webhooks ?? [],
4647
5468
  logger,
@@ -4752,8 +5573,8 @@ var EasyCMS = class {
4752
5573
  }
4753
5574
  ]);
4754
5575
  }
4755
- const random = randomBytes4(4).toString("hex");
4756
- const filename = storageKey(file.name, mimeType, random);
5576
+ const random2 = randomBytes5(4).toString("hex");
5577
+ const filename = storageKey(file.name, mimeType, random2);
4757
5578
  const stored = [];
4758
5579
  try {
4759
5580
  await this.storage.put(filename, file.data, { contentType: mimeType });
@@ -5123,6 +5944,7 @@ var EasyCMS = class {
5123
5944
  operation: "update"
5124
5945
  });
5125
5946
  this.emit(config, "draft", draft);
5947
+ await this.audited(config, "draft", draft, current, guard, mode);
5126
5948
  const [out2] = await this.output(config, [draft], guard, {
5127
5949
  ...options,
5128
5950
  draft: true
@@ -5145,6 +5967,7 @@ var EasyCMS = class {
5145
5967
  operation: "update"
5146
5968
  });
5147
5969
  this.emit(config, "update", doc, existing.status);
5970
+ await this.audited(config, "update", doc, current, guard, mode);
5148
5971
  const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
5149
5972
  return out;
5150
5973
  }
@@ -5175,6 +5998,7 @@ var EasyCMS = class {
5175
5998
  await this.roles.transfer(parsed, heir.id);
5176
5999
  } else await this.roles.transfer(parsed, null);
5177
6000
  await this.auth.revokeSessions(parsed);
6001
+ await this.auth.sso.forget(parsed);
5178
6002
  }
5179
6003
  await this.db.delete({ collection: collection2, id: parsed });
5180
6004
  if (versionLimit(config)) await this.versions.deleteAll(collectionParent(collection2), parsed);
@@ -5188,6 +6012,7 @@ var EasyCMS = class {
5188
6012
  doc: existing
5189
6013
  });
5190
6014
  this.emit(config, "delete", existing);
6015
+ await this.audited(config, "delete", existing, existing, guard);
5191
6016
  const [out] = await this.output(config, [existing], guard, { depth: 0, draft: true });
5192
6017
  return out;
5193
6018
  }
@@ -5317,6 +6142,7 @@ var EasyCMS = class {
5317
6142
  operation: "update"
5318
6143
  });
5319
6144
  this.emit(config, "draft", draft);
6145
+ await this.audited(config, "draft", draft, current, guard, mode);
5320
6146
  return this.findGlobal(slug2, { ...options, draft: true });
5321
6147
  }
5322
6148
  const doc = await this.db.updateGlobal({ slug: slug2, data: { ...prepared, updatedAt: now } });
@@ -5328,6 +6154,7 @@ var EasyCMS = class {
5328
6154
  operation: "update"
5329
6155
  });
5330
6156
  this.emit(config, "update", doc, existing.status);
6157
+ await this.audited(config, "update", doc, current, guard, mode);
5331
6158
  return this.findGlobal(slug2, { ...options, draft: true });
5332
6159
  }
5333
6160
  /**
@@ -5429,16 +6256,19 @@ var EasyCMS = class {
5429
6256
  * scheduler and the cron endpoint (`GET <api>/jobs/run`) call.
5430
6257
  */
5431
6258
  async runJobs(now = /* @__PURE__ */ new Date()) {
5432
- const scheduled = await this.runScheduled(now);
5433
- await this.pruneFailedDeliveries(now);
5434
- await runDueBackups(this, now).catch(
5435
- (error) => this.logger.error(`Backups: ${error.message}`)
5436
- );
5437
- return {
5438
- ...scheduled,
5439
- webhooks: await this.retryWebhooks(now),
5440
- emails: await this.mailer.retry(now)
5441
- };
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
+ });
5442
6272
  }
5443
6273
  /**
5444
6274
  * Tries webhook deliveries that are due for another attempt. Deliveries are kept in the
@@ -5603,6 +6433,13 @@ var EasyCMS = class {
5603
6433
  updatedAt: now
5604
6434
  }
5605
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
+ });
5606
6443
  return toJob(row);
5607
6444
  }
5608
6445
  /**
@@ -5662,6 +6499,12 @@ var EasyCMS = class {
5662
6499
  const job = (await this.pendingJobs(parent, doc)).find((j) => String(j.id) === String(jobId));
5663
6500
  if (!job) throw new NotFoundError("scheduled job", jobId);
5664
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
+ });
5665
6508
  }
5666
6509
  /**
5667
6510
  * @internal One attempt now for a saved webhook delivery or email (the admin's Retry): removed
@@ -5906,6 +6749,7 @@ var EasyCMS = class {
5906
6749
  operation: "create"
5907
6750
  });
5908
6751
  this.emit(config, "create", doc);
6752
+ await this.audited(config, "create", doc, void 0, hookGuard ?? guard);
5909
6753
  const [out] = await this.output(config, [doc], hookGuard ?? guard, { ...options, draft: true });
5910
6754
  return out;
5911
6755
  }
@@ -5979,6 +6823,31 @@ var EasyCMS = class {
5979
6823
  if (after !== "published" && before === "published")
5980
6824
  this.webhooks.emit("unpublish", target, body);
5981
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
+ }
5982
6851
  /** Records a version after a save, when the collection or global keeps versions. */
5983
6852
  async saveVersion(config, parent, id, doc, guard) {
5984
6853
  const max = versionLimit(config);
@@ -6321,9 +7190,9 @@ var PER_PAGE = 50;
6321
7190
  var collectionOf = (kind) => kind === "webhook" ? WEBHOOK_DELIVERIES : EMAIL_DELIVERIES;
6322
7191
  var exists = (cms, kind) => cms.config.collections.some((c) => c.slug === collectionOf(kind));
6323
7192
  var isDeliveryKind = (value) => value === "webhook" || value === "email";
6324
- function parse(text) {
7193
+ function parse(text2) {
6325
7194
  try {
6326
- const value = JSON.parse(String(text ?? ""));
7195
+ const value = JSON.parse(String(text2 ?? ""));
6327
7196
  return typeof value === "object" && value !== null ? value : {};
6328
7197
  } catch {
6329
7198
  return {};
@@ -6490,7 +7359,7 @@ async function sendTestEmail(cms, user, to, locale) {
6490
7359
  const th = locale === "th" || locale !== "en" && cms.config.admin.locale === "th";
6491
7360
  const time = (/* @__PURE__ */ new Date()).toISOString();
6492
7361
  const subject = th ? `\u0E2D\u0E35\u0E40\u0E21\u0E25\u0E17\u0E14\u0E2A\u0E2D\u0E1A\u0E08\u0E32\u0E01 ${site}` : `Test email from ${site}`;
6493
- 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
6494
7363
 
6495
7364
  \u0E2A\u0E48\u0E07\u0E42\u0E14\u0E22: ${user.email}
6496
7365
  Adapter: ${adapter.name ?? "custom"}
@@ -6502,7 +7371,7 @@ Adapter: ${adapter.name ?? "custom"}
6502
7371
  From: ${from}
6503
7372
  Time: ${time}`;
6504
7373
  try {
6505
- await adapter.send({ to: address, subject, text, from });
7374
+ await adapter.send({ to: address, subject, text: text2, from });
6506
7375
  return { ok: true };
6507
7376
  } catch (error) {
6508
7377
  return { ok: false, error: message(error) };
@@ -6678,7 +7547,9 @@ async function adminSchema(cms, user, origin) {
6678
7547
  deliveries: deliveries && await cms.roles.canView(user, "deliveries"),
6679
7548
  backups: admin,
6680
7549
  email: admin,
6681
- roles: admin && cms.roles.enabled
7550
+ roles: admin && cms.roles.enabled,
7551
+ sso: admin && cms.auth.sso.enabled,
7552
+ audit: cms.audit.enabled && await cms.roles.canView(user, "audit")
6682
7553
  };
6683
7554
  const roles = await cms.roles.options();
6684
7555
  return {
@@ -6694,6 +7565,7 @@ async function adminSchema(cms, user, origin) {
6694
7565
  uploadFromURL: cms.config.upload.fromURL !== void 0,
6695
7566
  views,
6696
7567
  rbac: cms.roles.enabled,
7568
+ providers: cms.auth.sso.providers(),
6697
7569
  ...views.deliveries ? {
6698
7570
  deliveries: {
6699
7571
  webhook: cms.config.collections.some((c) => c.slug === WEBHOOK_DELIVERIES),
@@ -6739,16 +7611,16 @@ async function widgets(cms, user) {
6739
7611
  }
6740
7612
 
6741
7613
  // src/version.ts
6742
- var VERSION = true ? "0.34.0" : "0.0.0-dev";
7614
+ var VERSION = true ? "0.36.0" : "0.0.0-dev";
6743
7615
 
6744
7616
  // src/rest/admin-status.ts
6745
- var DAY = 864e5;
7617
+ var DAY2 = 864e5;
6746
7618
  var has = (cms, slug2) => cms.config.collections.some((c) => c.slug === slug2);
6747
7619
  async function adminStatus(cms) {
6748
7620
  const config = cms.config;
6749
7621
  const attention = [];
6750
7622
  const now = Date.now();
6751
- const weekAgo = new Date(now - 7 * DAY).toISOString();
7623
+ const weekAgo = new Date(now - 7 * DAY2).toISOString();
6752
7624
  if (has(cms, WEBHOOK_DELIVERIES)) {
6753
7625
  const failed = await cms.db.find({
6754
7626
  collection: WEBHOOK_DELIVERIES,
@@ -6798,6 +7670,7 @@ async function adminStatus(cms) {
6798
7670
  }
6799
7671
  const backups = await backupAttention(cms);
6800
7672
  if (backups) attention.push(backups);
7673
+ attention.push(...await cms.audit.attention());
6801
7674
  if (!config.email) attention.push({ id: "no-email" });
6802
7675
  if (!config.serverURL && process.env.NODE_ENV === "production")
6803
7676
  attention.push({ id: "no-server-url" });
@@ -7007,7 +7880,15 @@ async function respond(cms, request, url, path, root, options, production) {
7007
7880
  const ctx = { request, url, user, via: user ? via : null, token, headers };
7008
7881
  const method = request.method.toUpperCase();
7009
7882
  if (method !== "GET" && method !== "HEAD") checkCsrf(cms, ctx);
7010
- 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
+ );
7011
7892
  if (!result) throw new HttpError("Not found", 404);
7012
7893
  if (result.body instanceof Response) return result.body;
7013
7894
  return new Response(JSON.stringify(result.body), { status: result.status ?? 200, headers });
@@ -7089,6 +7970,7 @@ async function route(cms, ctx, method, segments, options) {
7089
7970
  const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
7090
7971
  const draft = ctx.user !== null && ctx.url.searchParams.get("draft") === "true";
7091
7972
  if (segments.length === 0) throw new HttpError("Not found", 404);
7973
+ if (first === "auth") return ssoRoute(cms, ctx, method, segments);
7092
7974
  if (first === USERS && second !== void 0 && third === void 0 && !/^\d+$/.test(second)) {
7093
7975
  switch (`${method} ${second}`) {
7094
7976
  case "POST login": {
@@ -7119,7 +8001,10 @@ async function route(cms, ctx, method, segments, options) {
7119
8001
  body: {
7120
8002
  hasUsers: await cms.auth.hasUsers(),
7121
8003
  // The login page offers "Forgot password?" only when the email can be sent.
7122
- passwordReset: cms.auth.canSendPasswordLinks(ctx.url.origin)
8004
+ passwordReset: cms.auth.canSendPasswordLinks(ctx.url.origin),
8005
+ // Buttons to sign in with outside accounts; `password: false`: only admins use one.
8006
+ providers: cms.auth.sso.providers(),
8007
+ password: cms.config.auth.password
7123
8008
  }
7124
8009
  };
7125
8010
  // Forgot password: the same answer whether or not the email has an account.
@@ -7188,8 +8073,11 @@ async function route(cms, ctx, method, segments, options) {
7188
8073
  if (extra !== void 0) throw new HttpError("Not found", 404);
7189
8074
  if (id2 === void 0) {
7190
8075
  if (method === "GET") return { body: await listBackups(cms) };
7191
- if (method === "POST")
7192
- 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
+ }
7193
8081
  throw methodNotAllowed(ctx, "GET, POST");
7194
8082
  }
7195
8083
  const parsed = parseId(id2);
@@ -7197,6 +8085,12 @@ async function route(cms, ctx, method, segments, options) {
7197
8085
  if (action === "download") {
7198
8086
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
7199
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
+ });
7200
8094
  return {
7201
8095
  body: new Response(file.body, {
7202
8096
  headers: {
@@ -7211,6 +8105,7 @@ async function route(cms, ctx, method, segments, options) {
7211
8105
  if (action !== void 0) throw new HttpError("Not found", 404);
7212
8106
  if (method !== "DELETE") throw methodNotAllowed(ctx, "DELETE");
7213
8107
  await deleteBackup(cms, parsed);
8108
+ await cms.audit.record({ action: "backup.delete", target: "backups", doc: parsed });
7214
8109
  return { body: { deleted: 1 } };
7215
8110
  }
7216
8111
  if (first === "admin" && second === "email") {
@@ -7226,10 +8121,53 @@ async function route(cms, ctx, method, segments, options) {
7226
8121
  if (action === "verify") return { body: await verifyEmail(cms) };
7227
8122
  if (action === "test") {
7228
8123
  const body = await readJson(ctx.request);
7229
- 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 };
7230
8127
  }
7231
8128
  throw new HttpError("Not found", 404);
7232
8129
  }
8130
+ if (first === "admin" && second === "sso" && third === void 0) {
8131
+ if (!ctx.user) throw new UnauthorizedError();
8132
+ if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
8133
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
8134
+ return { body: cms.auth.sso.settings(ctx.url.origin) };
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
+ }
7233
8171
  if (first === "admin" && second === "owned" && third !== void 0 && segments.length === 3) {
7234
8172
  if (!ctx.user) throw new UnauthorizedError();
7235
8173
  if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
@@ -7280,21 +8218,46 @@ async function route(cms, ctx, method, segments, options) {
7280
8218
  if (!isDeliveryKind(kind) || extra !== void 0) throw new HttpError("Not found", 404);
7281
8219
  if (id2 === "retry" && action === void 0) {
7282
8220
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
7283
- 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 };
7284
8228
  }
7285
8229
  if (id2 === void 0) {
7286
8230
  if (method !== "DELETE") throw methodNotAllowed(ctx, "DELETE");
7287
- 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 };
7288
8238
  }
7289
8239
  const parsed = parseId(id2);
7290
8240
  if (parsed === void 0) throw new HttpError("Not found", 404);
7291
8241
  if (action === "retry") {
7292
8242
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
7293
- 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 };
7294
8251
  }
7295
8252
  if (action !== void 0) throw new HttpError("Not found", 404);
7296
8253
  if (method !== "DELETE") throw methodNotAllowed(ctx, "DELETE");
7297
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
+ });
7298
8261
  return { body: { deleted: 1 } };
7299
8262
  }
7300
8263
  if (first === "admin") {
@@ -7680,11 +8643,11 @@ async function readJson(request) {
7680
8643
  }
7681
8644
  const declared = Number(request.headers.get("content-length") ?? 0);
7682
8645
  if (declared > MAX_BODY_BYTES) throw new HttpError("Request body too large", 413);
7683
- const text = await request.text();
7684
- 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);
7685
8648
  let body;
7686
8649
  try {
7687
- body = JSON.parse(text);
8650
+ body = JSON.parse(text2);
7688
8651
  } catch {
7689
8652
  throw new HttpError("Request body is not valid JSON", 400);
7690
8653
  }
@@ -7703,6 +8666,67 @@ function cookie(ctx, name, value, opts) {
7703
8666
  if (opts.maxAge !== void 0) parts.push(`Max-Age=${opts.maxAge}`);
7704
8667
  return parts.join("; ");
7705
8668
  }
8669
+ async function ssoRoute(cms, ctx, method, segments) {
8670
+ const sso = cms.auth.sso;
8671
+ if (!sso.enabled) throw new HttpError("Not found", 404);
8672
+ const [, second, third, extra] = segments;
8673
+ if (extra !== void 0 || second === void 0) throw new HttpError("Not found", 404);
8674
+ if (second === "identities") {
8675
+ if (!ctx.user) throw new UnauthorizedError();
8676
+ if (third === void 0) {
8677
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
8678
+ const asked = ctx.url.searchParams.get("user");
8679
+ const target = asked === null ? ctx.user.id : parseId(asked) ?? asked;
8680
+ if (String(target) !== String(ctx.user.id) && (ctx.user.role !== "admin" || ctx.user.apiKey))
8681
+ throw new ForbiddenError();
8682
+ return { body: await sso.identities(target) };
8683
+ }
8684
+ if (method !== "DELETE") throw methodNotAllowed(ctx, "DELETE");
8685
+ const id = parseId(third);
8686
+ if (id === void 0) throw new HttpError("Not found", 404);
8687
+ await sso.unlink(ctx.user, id);
8688
+ return { body: { deleted: 1 } };
8689
+ }
8690
+ const pendingCookie = (value, maxAge) => cookie(ctx, SSO_COOKIE, value, { httpOnly: true, maxAge });
8691
+ if (third === "login") {
8692
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
8693
+ const started = await sso.start(second, {
8694
+ origin: ctx.url.origin,
8695
+ redirect: ctx.url.searchParams.get("redirect")
8696
+ });
8697
+ ctx.headers.append("set-cookie", pendingCookie(started.cookie, 600));
8698
+ return { body: redirectResponse(ctx, started.url) };
8699
+ }
8700
+ if (third === "link") {
8701
+ if (method !== "POST") throw methodNotAllowed(ctx, "POST");
8702
+ if (!ctx.user || ctx.user.apiKey) throw new UnauthorizedError();
8703
+ const started = await sso.start(second, {
8704
+ origin: ctx.url.origin,
8705
+ redirect: null,
8706
+ user: ctx.user
8707
+ });
8708
+ ctx.headers.append("set-cookie", pendingCookie(started.cookie, 600));
8709
+ return { body: { url: started.url } };
8710
+ }
8711
+ if (third === "callback") {
8712
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
8713
+ const finished = await sso.finish(second, {
8714
+ url: ctx.url,
8715
+ cookie: readCookie(ctx.request, SSO_COOKIE),
8716
+ origin: ctx.url.origin,
8717
+ user: ctx.user
8718
+ });
8719
+ ctx.headers.append("set-cookie", pendingCookie("", 0));
8720
+ if (finished.session) setSessionCookies(cms, ctx, finished.session);
8721
+ return { body: redirectResponse(ctx, finished.redirect) };
8722
+ }
8723
+ throw new HttpError("Not found", 404);
8724
+ }
8725
+ function redirectResponse(ctx, location) {
8726
+ const headers = new Headers({ location, "cache-control": "no-store" });
8727
+ for (const value of ctx.headers.getSetCookie()) headers.append("set-cookie", value);
8728
+ return new Response(null, { status: 302, headers });
8729
+ }
7706
8730
  function setSessionCookies(cms, ctx, session) {
7707
8731
  const maxAge = cms.config.auth.tokenExpiration;
7708
8732
  ctx.headers.append(
@@ -7902,6 +8926,7 @@ export {
7902
8926
  API_KEYS,
7903
8927
  API_KEY_GLOBAL_OPERATIONS,
7904
8928
  API_KEY_OPERATIONS,
8929
+ AuditLog,
7905
8930
  Auth,
7906
8931
  BUILTIN_COLLECTIONS,
7907
8932
  CONFIG_FILE_NAMES,
@@ -7932,6 +8957,7 @@ export {
7932
8957
  ROLE_OPERATIONS,
7933
8958
  Roles,
7934
8959
  SESSION_COOKIE,
8960
+ SSO_COOKIE,
7935
8961
  SchemaError,
7936
8962
  TooManyRequestsError,
7937
8963
  UnauthorizedError,