@easy-cms/core 1.30.1 → 1.32.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.
@@ -61,6 +61,7 @@ function hashToken(token) {
61
61
  function hmac(secret, value) {
62
62
  return createHmac("sha256", secret).update(value).digest("base64url");
63
63
  }
64
+ var signValue = (secret, value) => hmac(secret, value);
64
65
  function secretsOf(config) {
65
66
  return [config.secret, ...config.previousSecrets ?? []];
66
67
  }
@@ -161,6 +162,8 @@ var AUDIT_LOGS = "audit-logs";
161
162
  var OUTBOX = "outbox";
162
163
  var RATE_LIMITS = "rate-limits";
163
164
  var BULK_OPERATIONS = "bulk-operations";
165
+ var SAVED_VIEWS = "saved-views";
166
+ var CONNECTIONS = "connections";
164
167
  var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
165
168
  SESSIONS,
166
169
  LOGIN_ATTEMPTS,
@@ -175,7 +178,9 @@ var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
175
178
  AUDIT_LOGS,
176
179
  OUTBOX,
177
180
  RATE_LIMITS,
178
- BULK_OPERATIONS
181
+ BULK_OPERATIONS,
182
+ SAVED_VIEWS,
183
+ CONNECTIONS
179
184
  ]);
180
185
  var DEFAULT_ROLES = ["admin", "editor"];
181
186
  var CREATED_BY_FIELD = {
@@ -563,6 +568,40 @@ var bulkOperationsCollection = {
563
568
  { name: "finishedAt", type: "text" }
564
569
  ]
565
570
  };
571
+ var savedViewsCollection = {
572
+ slug: SAVED_VIEWS,
573
+ access: { read: nobody, create: nobody, update: nobody, delete: nobody },
574
+ fields: [
575
+ { name: "collection", type: "text", required: true, index: true },
576
+ { name: "user", type: "text", required: true, index: true },
577
+ // view | default
578
+ { name: "kind", type: "text", required: true },
579
+ { name: "name", type: "text" },
580
+ { name: "shared", type: "boolean" },
581
+ // `{ query, columns?, pageSize?, density? }`: the list's URL query, and what the browser kept.
582
+ { name: "state", type: "json" },
583
+ // For `default`: a view's id, or `config:<id>` for one of `admin.views`.
584
+ { name: "target", type: "text" }
585
+ ]
586
+ };
587
+ var connectionsCollection = {
588
+ slug: CONNECTIONS,
589
+ access: { read: nobody, create: nobody, update: nobody, delete: nobody },
590
+ fields: [
591
+ // connection | integration | pending
592
+ { name: "kind", type: "text", required: true },
593
+ { name: "integration", type: "text", required: true, index: true },
594
+ { name: "name", type: "text" },
595
+ { name: "isDefault", type: "boolean" },
596
+ { name: "values", type: "json" },
597
+ // AES-256-GCM, with a key derived from the secret (see integrations.ts).
598
+ { name: "secret", type: "text" },
599
+ { name: "account", type: "text" },
600
+ { name: "lastUsedAt", type: "text" },
601
+ { name: "lastError", type: "text" },
602
+ { name: "enabled", type: "boolean" }
603
+ ]
604
+ };
566
605
  var webhookDeliveriesCollection = {
567
606
  slug: WEBHOOK_DELIVERIES,
568
607
  access: { read: nobody, create: nobody, update: nobody, delete: nobody },
@@ -705,6 +744,15 @@ var emailDeliveriesCollection = {
705
744
  // src/audit.ts
706
745
  import { AsyncLocalStorage } from "async_hooks";
707
746
  import { createHmac as createHmac2 } from "crypto";
747
+
748
+ // src/csv.ts
749
+ function csvCell(value) {
750
+ const s = value === null || value === void 0 ? "" : typeof value === "string" ? value : JSON.stringify(value);
751
+ const safe = /^[=+\-@\t\r]/.test(s) ? `'${s}` : s;
752
+ return /[",\n\r]/.test(safe) ? `"${safe.replaceAll('"', '""')}"` : safe;
753
+ }
754
+
755
+ // src/audit.ts
708
756
  var DEFAULT_AUDIT_KEEP = 365;
709
757
  var DEFAULT_FAILED_LOGINS = 20;
710
758
  var MAX_VALUE = 500;
@@ -877,11 +925,7 @@ var AuditLog = class {
877
925
  limit: CSV_LIMIT,
878
926
  page: 1
879
927
  });
880
- const cell = (value) => {
881
- const s = value === null || value === void 0 ? "" : typeof value === "string" ? value : JSON.stringify(value);
882
- const safe = /^[=+\-@\t\r]/.test(s) ? `'${s}` : s;
883
- return /[",\n\r]/.test(safe) ? `"${safe.replaceAll('"', '""')}"` : safe;
884
- };
928
+ const cell = csvCell;
885
929
  const lines = [
886
930
  ["at", "action", "target", "doc", "title", "actor", "via", "ip", "changes", "detail"].join(
887
931
  ","
@@ -1304,9 +1348,9 @@ var esc = (value) => value.replace(
1304
1348
  (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]
1305
1349
  );
1306
1350
  function email(subject, lines, button) {
1307
- const text3 = [...lines, ...button ? ["", button.url] : []].join("\n");
1351
+ const text4 = [...lines, ...button ? ["", button.url] : []].join("\n");
1308
1352
  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>`;
1309
- return { subject, text: text3, html };
1353
+ return { subject, text: text4, html };
1310
1354
  }
1311
1355
  var hours = (expiresAt) => Math.max(1, Math.round(((expiresAt?.getTime() ?? Date.now()) - Date.now()) / 36e5));
1312
1356
  var days = (expiresAt) => Math.max(1, Math.round(hours(expiresAt) / 24));
@@ -2597,8 +2641,8 @@ function itemsOf(field, n) {
2597
2641
  return `${n} ${unit}${n === 1 ? "" : "s"}`;
2598
2642
  }
2599
2643
  var isEmpty = (value) => value === void 0 || value === null || value === "";
2600
- function slugify(text3) {
2601
- return text3.normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{M}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "");
2644
+ function slugify(text4) {
2645
+ return text4.normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{M}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "");
2602
2646
  }
2603
2647
  function applyDefaults(fields, data, localization = null) {
2604
2648
  const result = { ...data };
@@ -2907,7 +2951,7 @@ async function normalizeValue(field, raw, path, options, fail, errors, reference
2907
2951
  return fail(`must have at least ${field.minRows} rows`);
2908
2952
  if (field.maxRows !== void 0 && raw.length > field.maxRows)
2909
2953
  return fail(`must have at most ${field.maxRows} rows`);
2910
- const rows = [];
2954
+ const rows2 = [];
2911
2955
  for (const [i, row] of raw.entries()) {
2912
2956
  if (!isPlainObject(row)) {
2913
2957
  errors.push({ field: `${path}.${i}`, message: "must be an object" });
@@ -2917,9 +2961,9 @@ async function normalizeValue(field, raw, path, options, fail, errors, reference
2917
2961
  errors.push(...sub.errors);
2918
2962
  references2.push(...sub.references);
2919
2963
  const id = typeof row.id === "string" && row.id !== "" ? row.id : randomUUID();
2920
- rows.push({ id, ...sub.data });
2964
+ rows2.push({ id, ...sub.data });
2921
2965
  }
2922
- return rows;
2966
+ return rows2;
2923
2967
  }
2924
2968
  case "blocks":
2925
2969
  return normalizeBlocks(field, raw, path, options, fail, errors, references2);
@@ -2933,7 +2977,7 @@ async function normalizeBlocks(field, raw, path, options, fail, errors, referenc
2933
2977
  return fail(`must have at least ${field.minRows} blocks`);
2934
2978
  if (field.maxRows !== void 0 && raw.length > field.maxRows)
2935
2979
  return fail(`must have at most ${field.maxRows} blocks`);
2936
- const rows = [];
2980
+ const rows2 = [];
2937
2981
  for (const [i, row] of raw.entries()) {
2938
2982
  const fields = rowFields(field, row);
2939
2983
  if (!isPlainObject(row) || !fields) {
@@ -2947,9 +2991,9 @@ async function normalizeBlocks(field, raw, path, options, fail, errors, referenc
2947
2991
  errors.push(...sub.errors);
2948
2992
  references2.push(...sub.references);
2949
2993
  const id = typeof row.id === "string" && row.id !== "" ? row.id : randomUUID();
2950
- rows.push({ id, blockType: row.blockType, ...sub.data });
2994
+ rows2.push({ id, blockType: row.blockType, ...sub.data });
2951
2995
  }
2952
- return rows;
2996
+ return rows2;
2953
2997
  }
2954
2998
  function normalizeSelect(field, raw, fail) {
2955
2999
  const allowed2 = field.options.map((o) => typeof o === "string" ? o : o.value);
@@ -3000,6 +3044,8 @@ var Mailer = class {
3000
3044
  logger;
3001
3045
  queue;
3002
3046
  pending = /* @__PURE__ */ new Set();
3047
+ /** Told when an email is given up on (`delivery.failed`). */
3048
+ onGiveUp;
3003
3049
  get configured() {
3004
3050
  return this.adapter !== void 0;
3005
3051
  }
@@ -3057,6 +3103,8 @@ var Mailer = class {
3057
3103
  if (delay === void 0) {
3058
3104
  failed++;
3059
3105
  this.logger.error(`Email gave up after ${attempts} attempts: ${error}`);
3106
+ const sent2 = JSON.parse(queued.message);
3107
+ this.onGiveUp?.({ to: [sent2.to].flat().join(", "), error, attempts });
3060
3108
  }
3061
3109
  await this.queue.update(id, {
3062
3110
  ...queued,
@@ -3198,11 +3246,11 @@ async function copyDatabase(source, target, options = {}) {
3198
3246
  for (const table of tables) {
3199
3247
  let copied = 0;
3200
3248
  for (; ; ) {
3201
- const rows = await from.read(table, copied, BATCH);
3202
- if (rows.length === 0) break;
3203
- await to.write(table, rows);
3204
- copied += rows.length;
3205
- if (rows.length < BATCH) break;
3249
+ const rows2 = await from.read(table, copied, BATCH);
3250
+ if (rows2.length === 0) break;
3251
+ await to.write(table, rows2);
3252
+ copied += rows2.length;
3253
+ if (rows2.length < BATCH) break;
3206
3254
  }
3207
3255
  total += copied;
3208
3256
  options.onTable?.({ table, rows: copied });
@@ -3413,26 +3461,26 @@ async function writeBackupFile(cms, file) {
3413
3461
  });
3414
3462
  }
3415
3463
  }
3416
- var slug = (text3) => text3.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "easy-cms";
3464
+ var slug = (text4) => text4.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "easy-cms";
3417
3465
  function filenameFor(cms, date) {
3418
3466
  const pad = (n) => String(n).padStart(2, "0");
3419
3467
  const stamp = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}`;
3420
3468
  return `${slug(cms.config.admin.brand.name || "easy-cms")}-${stamp}.db.gz`;
3421
3469
  }
3422
3470
  function toAdmin(row) {
3423
- const text3 = (value) => typeof value === "string" && value !== "" ? value : null;
3471
+ const text4 = (value) => typeof value === "string" && value !== "" ? value : null;
3424
3472
  return {
3425
3473
  id: row.id,
3426
3474
  state: ["pending", "running", "done", "failed"].includes(row.state) ? row.state : "failed",
3427
3475
  trigger: row.trigger === "scheduled" ? "scheduled" : "manual",
3428
- filename: text3(row.filename),
3476
+ filename: text4(row.filename),
3429
3477
  size: typeof row.size === "number" ? row.size : null,
3430
- startedAt: text3(row.startedAt),
3431
- finishedAt: text3(row.finishedAt),
3432
- error: text3(row.error),
3433
- author: text3(row.author),
3434
- downloadedBy: text3(row.downloadedBy),
3435
- downloadedAt: text3(row.downloadedAt)
3478
+ startedAt: text4(row.startedAt),
3479
+ finishedAt: text4(row.finishedAt),
3480
+ error: text4(row.error),
3481
+ author: text4(row.author),
3482
+ downloadedBy: text4(row.downloadedBy),
3483
+ downloadedAt: text4(row.downloadedAt)
3436
3484
  };
3437
3485
  }
3438
3486
  async function update(cms, row, data) {
@@ -3536,11 +3584,13 @@ async function runBackup(cms, record) {
3536
3584
  });
3537
3585
  cms.logger.info(`Backup ${key} written (${data.byteLength} bytes)`);
3538
3586
  } catch (error) {
3587
+ const message2 = (error.message || String(error)).slice(0, 500);
3539
3588
  await update(cms, row, {
3540
3589
  state: "failed",
3541
- error: (error.message || String(error)).slice(0, 500),
3590
+ error: message2,
3542
3591
  finishedAt: (/* @__PURE__ */ new Date()).toISOString()
3543
3592
  });
3593
+ cms.systemEvent("backup.failed", { backup: row.id, error: message2 });
3544
3594
  throw error;
3545
3595
  } finally {
3546
3596
  await rm2(dir, { recursive: true, force: true });
@@ -3771,10 +3821,10 @@ var MediaFolders = class {
3771
3821
  async rows() {
3772
3822
  if (this.cache && Date.now() - this.cache.at < TTL) return this.cache.rows;
3773
3823
  const found = await this.db.find({ collection: MEDIA_FOLDERS, sort: [], limit: 0, page: 1 });
3774
- const rows = /* @__PURE__ */ new Map();
3824
+ const rows2 = /* @__PURE__ */ new Map();
3775
3825
  for (const doc of found.docs) {
3776
3826
  const permissions = doc.permissions;
3777
- rows.set(String(doc.id), {
3827
+ rows2.set(String(doc.id), {
3778
3828
  id: doc.id,
3779
3829
  parent: doc.parent ?? null,
3780
3830
  name: String(doc.name ?? ""),
@@ -3784,31 +3834,31 @@ var MediaFolders = class {
3784
3834
  doc
3785
3835
  });
3786
3836
  }
3787
- this.cache = { at: Date.now(), rows };
3788
- return rows;
3837
+ this.cache = { at: Date.now(), rows: rows2 };
3838
+ return rows2;
3789
3839
  }
3790
3840
  /** A role's level in a folder (`null`: the top level): the nearest permissions up the tree. */
3791
- levelIn(rows, role, id) {
3841
+ levelIn(rows2, role, id) {
3792
3842
  const seen = /* @__PURE__ */ new Set();
3793
- let current = id === null ? void 0 : rows.get(String(id));
3843
+ let current = id === null ? void 0 : rows2.get(String(id));
3794
3844
  while (current && !seen.has(String(current.id))) {
3795
3845
  seen.add(String(current.id));
3796
3846
  if (current.permissions) {
3797
3847
  const level = current.permissions[role];
3798
3848
  return level && level in RANK ? level : "none";
3799
3849
  }
3800
- current = current.parent === null ? void 0 : rows.get(String(current.parent));
3850
+ current = current.parent === null ? void 0 : rows2.get(String(current.parent));
3801
3851
  }
3802
3852
  return "manage";
3803
3853
  }
3804
3854
  /** Whether files in a folder (`null`: the top level) are private: it or a folder above is. */
3805
- privateIn(rows, id) {
3855
+ privateIn(rows2, id) {
3806
3856
  const seen = /* @__PURE__ */ new Set();
3807
- let current = id === null ? void 0 : rows.get(String(id));
3857
+ let current = id === null ? void 0 : rows2.get(String(id));
3808
3858
  while (current && !seen.has(String(current.id))) {
3809
3859
  if (current.private) return true;
3810
3860
  seen.add(String(current.id));
3811
- current = current.parent === null ? void 0 : rows.get(String(current.parent));
3861
+ current = current.parent === null ? void 0 : rows2.get(String(current.parent));
3812
3862
  }
3813
3863
  return false;
3814
3864
  }
@@ -3818,26 +3868,26 @@ var MediaFolders = class {
3818
3868
  return this.privateIn(await this.rows(), id);
3819
3869
  }
3820
3870
  /** The folder and every folder inside it. */
3821
- within(rows, ids) {
3871
+ within(rows2, ids) {
3822
3872
  const out = new Set(ids.map(String));
3823
3873
  let grew = true;
3824
3874
  while (grew) {
3825
3875
  grew = false;
3826
- for (const row of rows.values())
3876
+ for (const row of rows2.values())
3827
3877
  if (row.parent !== null && out.has(String(row.parent)) && !out.has(String(row.id))) {
3828
3878
  out.add(String(row.id));
3829
3879
  grew = true;
3830
3880
  }
3831
3881
  }
3832
- return [...rows.values()].filter((r) => out.has(String(r.id))).map((r) => r.id);
3882
+ return [...rows2.values()].filter((r) => out.has(String(r.id))).map((r) => r.id);
3833
3883
  }
3834
3884
  /**
3835
3885
  * Folders whose privacy follows this one: it and those inside, with their privacy now.
3836
3886
  * @internal
3837
3887
  */
3838
3888
  async subtree(id) {
3839
- const rows = await this.fresh();
3840
- return this.within(rows, [id]).map((f) => ({ id: f, private: this.privateIn(rows, f) }));
3889
+ const rows2 = await this.fresh();
3890
+ return this.within(rows2, [id]).map((f) => ({ id: f, private: this.privateIn(rows2, f) }));
3841
3891
  }
3842
3892
  /**
3843
3893
  * The privacy each folder of a subtree would have after a change to its top folder: made
@@ -3845,15 +3895,15 @@ var MediaFolders = class {
3845
3895
  * @internal
3846
3896
  */
3847
3897
  async privacyAfter(id, change) {
3848
- const rows = new Map(await this.fresh());
3849
- const row = rows.get(String(id));
3898
+ const rows2 = new Map(await this.fresh());
3899
+ const row = rows2.get(String(id));
3850
3900
  if (!row) return [];
3851
- rows.set(String(id), {
3901
+ rows2.set(String(id), {
3852
3902
  ...row,
3853
3903
  ...change.private !== void 0 ? { private: change.private } : {},
3854
3904
  ...change.parent !== void 0 ? { parent: change.parent } : {}
3855
3905
  });
3856
- return this.within(rows, [id]).map((f) => ({ id: f, private: this.privateIn(rows, f) }));
3906
+ return this.within(rows2, [id]).map((f) => ({ id: f, private: this.privateIn(rows2, f) }));
3857
3907
  }
3858
3908
  async keyed(key, scope = {}, create = true) {
3859
3909
  const find = async () => [...(await this.rows()).values()].find((r) => r.key === key && inScope(r.doc, scope));
@@ -3891,9 +3941,9 @@ var MediaFolders = class {
3891
3941
  /** Whether a file in `folder` is in the folder with this key, or below it. @internal */
3892
3942
  async inKeyed(key, folder, scope = {}) {
3893
3943
  if (folder === null) return false;
3894
- const rows = await this.rows();
3895
- const root = [...rows.values()].find((r) => r.key === key && inScope(r.doc, scope));
3896
- return !!root && this.within(rows, [root.id]).some((id) => String(id) === String(folder));
3944
+ const rows2 = await this.rows();
3945
+ const root = [...rows2.values()].find((r) => r.key === key && inScope(r.doc, scope));
3946
+ return !!root && this.within(rows2, [root.id]).some((id) => String(id) === String(folder));
3897
3947
  }
3898
3948
  /** The user's level in a folder (`null`: the top level). */
3899
3949
  async level(user, id) {
@@ -3916,11 +3966,11 @@ var MediaFolders = class {
3916
3966
  if (!this.applies(user)) return true;
3917
3967
  if (operation === "create") return true;
3918
3968
  const need = RANK[needed(collection2, operation)];
3919
- const rows = await this.rows();
3969
+ const rows2 = await this.rows();
3920
3970
  const allowed2 = [];
3921
3971
  let all = true;
3922
- for (const row of rows.values()) {
3923
- if (RANK[this.levelIn(rows, user.role, row.id)] >= need) allowed2.push(row.id);
3972
+ for (const row of rows2.values()) {
3973
+ if (RANK[this.levelIn(rows2, user.role, row.id)] >= need) allowed2.push(row.id);
3924
3974
  else all = false;
3925
3975
  }
3926
3976
  if (all) return true;
@@ -3948,12 +3998,12 @@ var MediaFolders = class {
3948
3998
  * @internal
3949
3999
  */
3950
4000
  async validate(data, self) {
3951
- const rows = await this.fresh();
4001
+ const rows2 = await this.fresh();
3952
4002
  const name = typeof data.name === "string" ? data.name.trim() : "";
3953
4003
  const parent = data.parent ?? null;
3954
4004
  const errors = [];
3955
4005
  if (parent !== null && self !== void 0) {
3956
- let current = rows.get(String(parent));
4006
+ let current = rows2.get(String(parent));
3957
4007
  const seen = /* @__PURE__ */ new Set();
3958
4008
  while (current && !seen.has(String(current.id))) {
3959
4009
  if (String(current.id) === String(self)) {
@@ -3961,10 +4011,10 @@ var MediaFolders = class {
3961
4011
  break;
3962
4012
  }
3963
4013
  seen.add(String(current.id));
3964
- current = current.parent === null ? void 0 : rows.get(String(current.parent));
4014
+ current = current.parent === null ? void 0 : rows2.get(String(current.parent));
3965
4015
  }
3966
4016
  }
3967
- const taken = [...rows.values()].some(
4017
+ const taken = [...rows2.values()].some(
3968
4018
  (row) => String(row.id) !== String(self) && String(row.parent) === String(parent) && row.name.trim().toLocaleLowerCase() === name.toLocaleLowerCase()
3969
4019
  );
3970
4020
  if (name && taken) errors.push({ field: "name", message: "is already used in this folder" });
@@ -4218,7 +4268,7 @@ var VersionStore = class {
4218
4268
  async latest(parent, docs) {
4219
4269
  const result = /* @__PURE__ */ new Map();
4220
4270
  if (docs.length === 0) return result;
4221
- const rows = await this.db.find({
4271
+ const rows2 = await this.db.find({
4222
4272
  collection: VERSIONS,
4223
4273
  where: {
4224
4274
  and: [
@@ -4231,7 +4281,7 @@ var VersionStore = class {
4231
4281
  limit: 0,
4232
4282
  page: 1
4233
4283
  });
4234
- for (const row of rows.docs) result.set(String(row.doc), this.toVersion(row));
4284
+ for (const row of rows2.docs) result.set(String(row.doc), this.toVersion(row));
4235
4285
  return result;
4236
4286
  }
4237
4287
  async list(parent, doc, limit, page) {
@@ -4256,24 +4306,24 @@ var VersionStore = class {
4256
4306
  return row ? this.toVersion(row) : null;
4257
4307
  }
4258
4308
  async deleteAll(parent, doc) {
4259
- const rows = await this.db.find({
4309
+ const rows2 = await this.db.find({
4260
4310
  collection: VERSIONS,
4261
4311
  where: { and: this.of(parent, doc) },
4262
4312
  sort: [],
4263
4313
  limit: 0,
4264
4314
  page: 1
4265
4315
  });
4266
- for (const row of rows.docs) await this.db.delete({ collection: VERSIONS, id: row.id });
4316
+ for (const row of rows2.docs) await this.db.delete({ collection: VERSIONS, id: row.id });
4267
4317
  }
4268
4318
  async prune(parent, doc, max) {
4269
- const rows = await this.db.find({
4319
+ const rows2 = await this.db.find({
4270
4320
  collection: VERSIONS,
4271
4321
  where: { and: this.of(parent, doc) },
4272
4322
  sort: ["-id"],
4273
4323
  limit: 0,
4274
4324
  page: 1
4275
4325
  });
4276
- for (const row of rows.docs.slice(max)) {
4326
+ for (const row of rows2.docs.slice(max)) {
4277
4327
  await this.db.delete({ collection: VERSIONS, id: row.id });
4278
4328
  }
4279
4329
  }
@@ -4286,7 +4336,7 @@ var VersionStore = class {
4286
4336
  };
4287
4337
 
4288
4338
  // src/local-api.ts
4289
- import { createHash as createHash5, createHmac as createHmac8, randomBytes as randomBytes6, randomUUID as randomUUID3 } from "crypto";
4339
+ import { createHash as createHash6, createHmac as createHmac8, randomBytes as randomBytes7, randomUUID as randomUUID3 } from "crypto";
4290
4340
 
4291
4341
  // src/auth/common-passwords.ts
4292
4342
  var LIST = `
@@ -4803,6 +4853,649 @@ function toOperation(row) {
4803
4853
  };
4804
4854
  }
4805
4855
 
4856
+ // src/integrations.ts
4857
+ import { createCipheriv as createCipheriv2, createDecipheriv as createDecipheriv2, createHash as createHash4, hkdfSync, randomBytes as randomBytes6 } from "crypto";
4858
+ var ENV_NAME = "Environment";
4859
+ var PENDING_MS = 10 * 60 * 1e3;
4860
+ var keyOf2 = (secret) => Buffer.from(hkdfSync("sha256", secret, "", "easy-cms:connections", 32));
4861
+ var keyId = (key) => createHash4("sha256").update(key).digest("hex").slice(0, 12);
4862
+ function encryptValues(secret, values) {
4863
+ const key = keyOf2(secret);
4864
+ const iv = randomBytes6(12);
4865
+ const cipher = createCipheriv2("aes-256-gcm", key, iv);
4866
+ const data = Buffer.concat([cipher.update(JSON.stringify(values), "utf8"), cipher.final()]);
4867
+ const box = {
4868
+ k: keyId(key),
4869
+ iv: iv.toString("base64"),
4870
+ t: cipher.getAuthTag().toString("base64"),
4871
+ d: data.toString("base64")
4872
+ };
4873
+ return Buffer.from(JSON.stringify(box)).toString("base64url");
4874
+ }
4875
+ function decryptValues(secrets, sealed) {
4876
+ const box = JSON.parse(Buffer.from(sealed, "base64url").toString("utf8"));
4877
+ for (const [i, secret] of secrets.entries()) {
4878
+ const key = keyOf2(secret);
4879
+ if (keyId(key) !== box.k) continue;
4880
+ const decipher = createDecipheriv2("aes-256-gcm", key, Buffer.from(String(box.iv), "base64"));
4881
+ decipher.setAuthTag(Buffer.from(String(box.t), "base64"));
4882
+ const text4 = Buffer.concat([
4883
+ decipher.update(Buffer.from(String(box.d), "base64")),
4884
+ decipher.final()
4885
+ ]).toString("utf8");
4886
+ return { values: JSON.parse(text4), old: i > 0 };
4887
+ }
4888
+ throw new Error("A connection was encrypted with a secret that is no longer configured");
4889
+ }
4890
+ var keyIdOf = (sealed) => {
4891
+ try {
4892
+ return JSON.parse(Buffer.from(String(sealed), "base64url").toString("utf8")).k;
4893
+ } catch {
4894
+ return void 0;
4895
+ }
4896
+ };
4897
+ function signState(secret, payload) {
4898
+ const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
4899
+ return `${body}.${signValue(secret, `integration:${body}`)}`;
4900
+ }
4901
+ function readState(secrets, state) {
4902
+ const [body, signature] = state.split(".");
4903
+ if (!body || !signature || !signedWith(secrets, `integration:${body}`, signature)) return null;
4904
+ const payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
4905
+ return typeof payload.exp === "number" && payload.exp > Date.now() ? payload : null;
4906
+ }
4907
+ var OFFICIAL_PLUGINS = [
4908
+ {
4909
+ name: "@easy-cms/plugin-social",
4910
+ category: "social",
4911
+ description: {
4912
+ en: "Post to Facebook, LINE and webhooks from the admin",
4913
+ th: "\u0E42\u0E1E\u0E2A\u0E15\u0E4C\u0E44\u0E1B Facebook, LINE \u0E41\u0E25\u0E30 webhook \u0E08\u0E32\u0E01 admin"
4914
+ },
4915
+ docs: "https://easycms.dev/docs/integrations"
4916
+ },
4917
+ {
4918
+ name: "@easy-cms/plugin-notify",
4919
+ category: "notifications",
4920
+ description: { en: "Telegram notifications for admins", th: "\u0E41\u0E08\u0E49\u0E07\u0E40\u0E15\u0E37\u0E2D\u0E19\u0E1C\u0E39\u0E49\u0E14\u0E39\u0E41\u0E25\u0E17\u0E32\u0E07 Telegram" },
4921
+ docs: "https://easycms.dev/docs/notifications"
4922
+ },
4923
+ {
4924
+ name: "@easy-cms/plugin-ai",
4925
+ category: "ai",
4926
+ description: {
4927
+ en: "Claude, GPT and Gemini for alt text, SEO and translation",
4928
+ th: "Claude, GPT \u0E41\u0E25\u0E30 Gemini \u0E2A\u0E33\u0E2B\u0E23\u0E31\u0E1A alt text, SEO \u0E41\u0E25\u0E30\u0E41\u0E1B\u0E25\u0E20\u0E32\u0E29\u0E32"
4929
+ },
4930
+ docs: "https://easycms.dev/docs/integrations"
4931
+ },
4932
+ {
4933
+ name: "@easy-cms/plugin-ecommerce",
4934
+ category: "payments",
4935
+ description: { en: "A shop with Stripe payments", th: "\u0E23\u0E49\u0E32\u0E19\u0E04\u0E49\u0E32\u0E1E\u0E23\u0E49\u0E2D\u0E21\u0E0A\u0E33\u0E23\u0E30\u0E40\u0E07\u0E34\u0E19\u0E1C\u0E48\u0E32\u0E19 Stripe" },
4936
+ docs: "https://easycms.dev/docs/ecommerce"
4937
+ }
4938
+ ];
4939
+ var mask = (value) => value.length > 4 ? `\u2022\u2022\u2022\u2022${value.slice(-4)}` : "\u2022\u2022\u2022\u2022";
4940
+ var Integrations = class {
4941
+ constructor(cms) {
4942
+ this.cms = cms;
4943
+ }
4944
+ cms;
4945
+ get config() {
4946
+ return this.cms.config;
4947
+ }
4948
+ get secrets() {
4949
+ return secretsOf(this.config);
4950
+ }
4951
+ /** The integrations plugins declared. */
4952
+ get definitions() {
4953
+ return this.config.integrations;
4954
+ }
4955
+ definition(id) {
4956
+ const found = this.definitions.find((d) => d.id === id);
4957
+ if (!found) throw new NotFoundError("integrations", id);
4958
+ return found;
4959
+ }
4960
+ async rows(id, kind) {
4961
+ const found = await this.cms.db.find({
4962
+ collection: CONNECTIONS,
4963
+ where: { and: [{ integration: { equals: id } }, { kind: { equals: kind } }] },
4964
+ sort: ["id"],
4965
+ limit: 0,
4966
+ page: 1
4967
+ });
4968
+ return found.docs;
4969
+ }
4970
+ /** The values of the fields set in the environment. */
4971
+ envValues(def) {
4972
+ const out = {};
4973
+ for (const [field, name] of Object.entries(def.env ?? {})) {
4974
+ const value = process.env[name];
4975
+ if (value) out[field] = value;
4976
+ }
4977
+ return out;
4978
+ }
4979
+ /** Every field is in the environment: an "Environment" connection. */
4980
+ envComplete(def) {
4981
+ const env = this.envValues(def);
4982
+ const fields = def.fields ?? [];
4983
+ return fields.length > 0 && fields.every((f) => env[f.name] || !f.required) && Object.keys(env).length > 0;
4984
+ }
4985
+ async enabled(id) {
4986
+ const [row] = await this.rows(id, "integration");
4987
+ return row ? row.enabled !== false : true;
4988
+ }
4989
+ decrypt(row) {
4990
+ const plain = row.values ?? {};
4991
+ if (!row.secret) return { ...plain };
4992
+ const { values, old } = decryptValues(this.secrets, String(row.secret));
4993
+ if (old) void this.reseal(row, values);
4994
+ return { ...plain, ...values };
4995
+ }
4996
+ async reseal(row, secrets) {
4997
+ await this.cms.db.update({
4998
+ collection: CONNECTIONS,
4999
+ id: row.id,
5000
+ data: { ...row, secret: encryptValues(this.config.secret, secrets) }
5001
+ });
5002
+ }
5003
+ /** Splits values into the plain ones and the secret ones (encrypted). */
5004
+ seal(def, values) {
5005
+ const secretNames = new Set((def.fields ?? []).filter((f) => f.secret).map((f) => f.name));
5006
+ const plain = {};
5007
+ const secret = {};
5008
+ for (const [name, value] of Object.entries(values))
5009
+ (secretNames.has(name) || def.kind === "oauth" ? secret : plain)[name] = value;
5010
+ return {
5011
+ values: plain,
5012
+ secret: Object.keys(secret).length ? encryptValues(this.config.secret, secret) : null
5013
+ };
5014
+ }
5015
+ /**
5016
+ * A connection for a plugin: the default one, or the one named; `null` when there is none
5017
+ * or the integration is turned off. Values from the environment win.
5018
+ */
5019
+ async get(id, options = {}) {
5020
+ const def = this.definition(id);
5021
+ if (!await this.enabled(id)) return null;
5022
+ const env = this.envValues(def);
5023
+ if (this.envComplete(def) && (!options.name || options.name === ENV_NAME))
5024
+ return { name: ENV_NAME, values: env, env: true };
5025
+ const rows2 = await this.rows(id, "connection");
5026
+ const row = options.name ? rows2.find((r) => r.name === options.name) : rows2.find((r) => r.isDefault === true) ?? rows2[0];
5027
+ if (!row) return null;
5028
+ return { name: String(row.name), values: { ...this.decrypt(row), ...env }, env: false };
5029
+ }
5030
+ /**
5031
+ * Every connection of an integration (the environment's first), e.g. to notify each chat;
5032
+ * none when it is turned off.
5033
+ */
5034
+ async all(id) {
5035
+ const def = this.definition(id);
5036
+ if (!await this.enabled(id)) return [];
5037
+ const env = this.envValues(def);
5038
+ const out = this.envComplete(def) ? [{ name: ENV_NAME, values: env, env: true }] : [];
5039
+ for (const row of await this.rows(id, "connection"))
5040
+ out.push({ name: String(row.name), values: { ...this.decrypt(row), ...env }, env: false });
5041
+ return out;
5042
+ }
5043
+ /** The connection was used: its card says when, and an earlier error is gone. */
5044
+ async markUsed(id, name) {
5045
+ await this.mark(id, name, { lastUsedAt: (/* @__PURE__ */ new Date()).toISOString(), lastError: null });
5046
+ }
5047
+ /** It failed: the card and the dashboard's "needs attention" say why. */
5048
+ async markError(id, message2, name) {
5049
+ await this.mark(id, name, { lastError: message2.slice(0, 500) });
5050
+ this.cms.systemEvent("integration.failed", {
5051
+ integration: id,
5052
+ name: name ?? null,
5053
+ error: message2
5054
+ });
5055
+ }
5056
+ async mark(id, name, patch) {
5057
+ const rows2 = await this.rows(id, "connection");
5058
+ const row = name ? rows2.find((r) => r.name === name) : rows2.find((r) => r.isDefault === true) ?? rows2[0];
5059
+ if (!row) return;
5060
+ await this.cms.db.update({
5061
+ collection: CONNECTIONS,
5062
+ id: row.id,
5063
+ data: { ...row, ...patch, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
5064
+ });
5065
+ }
5066
+ // --- The admin's page ------------------------------------------------------------------------
5067
+ async overview() {
5068
+ const integrations = [];
5069
+ const current = keyId(keyOf2(this.config.secret));
5070
+ let stale = 0;
5071
+ for (const def of this.definitions) {
5072
+ const rows2 = await this.rows(def.id, "connection");
5073
+ const env = this.envValues(def);
5074
+ const connections = rows2.map((row) => {
5075
+ if (row.secret && keyIdOf(row.secret) !== current) stale++;
5076
+ const secretNames = (def.fields ?? []).filter((f) => f.secret).map((f) => f.name);
5077
+ let values = { ...row.values ?? {} };
5078
+ try {
5079
+ const all = this.decrypt(row);
5080
+ for (const name of secretNames) if (all[name]) values[name] = mask(all[name]);
5081
+ } catch {
5082
+ values = { ...values };
5083
+ }
5084
+ return {
5085
+ id: row.id,
5086
+ name: String(row.name),
5087
+ isDefault: row.isDefault === true,
5088
+ env: false,
5089
+ account: row.account ?? null,
5090
+ lastUsedAt: row.lastUsedAt ?? null,
5091
+ lastError: row.lastError ?? null,
5092
+ values
5093
+ };
5094
+ });
5095
+ if (this.envComplete(def)) {
5096
+ for (const c of connections) c.isDefault = false;
5097
+ connections.unshift({
5098
+ id: "env",
5099
+ name: ENV_NAME,
5100
+ isDefault: true,
5101
+ env: true,
5102
+ account: null,
5103
+ lastUsedAt: null,
5104
+ lastError: null,
5105
+ values: Object.fromEntries(
5106
+ Object.entries(env).map(
5107
+ ([k, v]) => (def.fields ?? []).find((f) => f.name === k)?.secret ? [k, mask(v)] : [k, v]
5108
+ )
5109
+ )
5110
+ });
5111
+ }
5112
+ integrations.push({
5113
+ id: def.id,
5114
+ label: def.label,
5115
+ category: def.category,
5116
+ ...def.description ? { description: def.description } : {},
5117
+ ...def.icon ? { icon: def.icon } : {},
5118
+ ...def.docs ? { docs: def.docs } : {},
5119
+ ...def.plugin ? { plugin: def.plugin } : {},
5120
+ kind: def.kind,
5121
+ // Functions don't travel: `findable` says a field has a Find button.
5122
+ fields: (def.fields ?? []).map(({ lookup, ...field }) => ({
5123
+ ...field,
5124
+ ...lookup ? { findable: true } : {}
5125
+ })),
5126
+ enabled: await this.enabled(def.id),
5127
+ envFields: Object.keys(env),
5128
+ testable: typeof def.test === "function",
5129
+ connections
5130
+ });
5131
+ }
5132
+ const installed = new Set(this.config.installedPlugins.map((p) => p.name));
5133
+ return {
5134
+ integrations,
5135
+ configured: this.configured(),
5136
+ catalog: OFFICIAL_PLUGINS.filter((p) => !installed.has(p.name)),
5137
+ staleSecrets: stale
5138
+ };
5139
+ }
5140
+ /** The adapters the config sets: email, storage, search, sign-in, payments. */
5141
+ configured() {
5142
+ const out = [];
5143
+ const { config } = this;
5144
+ if (config.email) out.push({ id: "email", kind: "email", name: config.email.name ?? "email" });
5145
+ out.push({ id: "storage", kind: "storage", name: config.upload.storage?.name ?? "disk" });
5146
+ if (config.search)
5147
+ out.push({ id: "search", kind: "search", name: config.search.name ?? "search" });
5148
+ for (const provider of config.auth.providers ?? [])
5149
+ out.push({ id: `sign-in:${provider.id}`, kind: "sign-in", name: provider.id });
5150
+ if (config.installedPlugins.some((p) => p.name === "@easy-cms/plugin-ecommerce"))
5151
+ out.push({ id: "payments", kind: "payments", name: "Stripe" });
5152
+ return out;
5153
+ }
5154
+ async audit(user, action, id, detail = {}) {
5155
+ await this.cms.audit.record({ action: `integration.${action}`, target: id, user, detail });
5156
+ }
5157
+ checkFields(def, values, partial) {
5158
+ const errors = (def.fields ?? []).filter((f) => f.required && !partial && !values[f.name]).map((f) => ({ field: f.name, message: "is required" }));
5159
+ if (errors.length) throw new ValidationError(CONNECTIONS, errors);
5160
+ }
5161
+ cleanValues(def, raw) {
5162
+ const input = raw && typeof raw === "object" ? raw : {};
5163
+ const out = {};
5164
+ for (const field of def.fields ?? []) {
5165
+ const raw2 = input[field.name];
5166
+ const value = Array.isArray(raw2) ? raw2.filter((v) => typeof v === "string").join(",") : raw2;
5167
+ if (typeof value === "string" && value.trim() !== "") out[field.name] = value.trim();
5168
+ }
5169
+ return out;
5170
+ }
5171
+ async uniqueName(id, name, self) {
5172
+ const trimmed = name.trim();
5173
+ if (!trimmed || trimmed.length > 60)
5174
+ throw new ValidationError(CONNECTIONS, [
5175
+ { field: "name", message: "must be 1 to 60 characters" }
5176
+ ]);
5177
+ if (trimmed === ENV_NAME || (await this.rows(id, "connection")).some((r) => r.name === trimmed && r.id !== self))
5178
+ throw new ValidationError(CONNECTIONS, [
5179
+ { field: "name", message: `there is already a connection named "${trimmed}"` }
5180
+ ]);
5181
+ return trimmed;
5182
+ }
5183
+ async create(def, name, values, account) {
5184
+ const existing = await this.rows(def.id, "connection");
5185
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5186
+ return await this.cms.db.create({
5187
+ collection: CONNECTIONS,
5188
+ data: {
5189
+ kind: "connection",
5190
+ integration: def.id,
5191
+ name,
5192
+ isDefault: existing.length === 0,
5193
+ ...this.seal(def, values),
5194
+ account,
5195
+ lastUsedAt: null,
5196
+ lastError: null,
5197
+ enabled: null,
5198
+ createdAt: now,
5199
+ updatedAt: now
5200
+ }
5201
+ });
5202
+ }
5203
+ /** Connects with pasted values (`kind: 'fields'`); tested first when the plugin can. */
5204
+ async connect(id, body, user) {
5205
+ const def = this.definition(id);
5206
+ if (def.kind !== "fields") throw new QueryError(`${id} connects with its Connect button`);
5207
+ const input = body ?? {};
5208
+ const name = await this.uniqueName(id, String(input.name ?? ""));
5209
+ const values = this.cleanValues(def, input.values);
5210
+ this.checkFields(def, values, false);
5211
+ const result = def.test ? await def.test(values, { cms: this.cms }) : null;
5212
+ if (result && !result.ok)
5213
+ throw new ValidationError(CONNECTIONS, [
5214
+ { field: "values", message: result.message ?? "the provider refused these values" }
5215
+ ]);
5216
+ const row = await this.create(def, name, values, result?.account ?? null);
5217
+ await this.audit(user, "connect", id, { name });
5218
+ return { id: row.id };
5219
+ }
5220
+ async row(id, cid) {
5221
+ const row = await this.cms.db.findById({ collection: CONNECTIONS, id: cid });
5222
+ if (!row || row.integration !== id || row.kind !== "connection")
5223
+ throw new NotFoundError(CONNECTIONS, cid);
5224
+ return row;
5225
+ }
5226
+ /** A new name or values (secrets left empty are kept), or the default. */
5227
+ async update(id, cid, body, user) {
5228
+ const def = this.definition(id);
5229
+ const row = await this.row(id, cid);
5230
+ const input = body ?? {};
5231
+ const next = { ...row, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
5232
+ if (input.name !== void 0) next.name = await this.uniqueName(id, String(input.name), cid);
5233
+ if (input.values !== void 0) {
5234
+ const values = { ...this.decrypt(row), ...this.cleanValues(def, input.values) };
5235
+ this.checkFields(def, values, false);
5236
+ Object.assign(next, this.seal(def, values), { lastError: null });
5237
+ }
5238
+ if (input.isDefault === true) {
5239
+ for (const other of await this.rows(id, "connection"))
5240
+ if (other.id !== cid && other.isDefault === true)
5241
+ await this.cms.db.update({
5242
+ collection: CONNECTIONS,
5243
+ id: other.id,
5244
+ data: { ...other, isDefault: false }
5245
+ });
5246
+ next.isDefault = true;
5247
+ }
5248
+ await this.cms.db.update({ collection: CONNECTIONS, id: cid, data: next });
5249
+ await this.audit(user, input.isDefault === true ? "default" : "update", id, { name: next.name });
5250
+ return { ok: true };
5251
+ }
5252
+ /** Removes the connection and its secrets; the oldest left becomes the default. */
5253
+ async disconnect(id, cid, user) {
5254
+ const row = await this.row(id, cid);
5255
+ await this.cms.db.delete({ collection: CONNECTIONS, id: cid });
5256
+ if (row.isDefault === true) {
5257
+ const [oldest] = await this.rows(id, "connection");
5258
+ if (oldest)
5259
+ await this.cms.db.update({
5260
+ collection: CONNECTIONS,
5261
+ id: oldest.id,
5262
+ data: { ...oldest, isDefault: true }
5263
+ });
5264
+ }
5265
+ await this.audit(user, "disconnect", id, { name: row.name });
5266
+ return { deleted: 1 };
5267
+ }
5268
+ /** Turns the integration on or off; its connections are kept. */
5269
+ async setEnabled(id, enabled, user) {
5270
+ this.definition(id);
5271
+ const [row] = await this.rows(id, "integration");
5272
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5273
+ if (row)
5274
+ await this.cms.db.update({
5275
+ collection: CONNECTIONS,
5276
+ id: row.id,
5277
+ data: { ...row, enabled, updatedAt: now }
5278
+ });
5279
+ else
5280
+ await this.cms.db.create({
5281
+ collection: CONNECTIONS,
5282
+ data: {
5283
+ kind: "integration",
5284
+ integration: id,
5285
+ name: null,
5286
+ isDefault: false,
5287
+ values: null,
5288
+ secret: null,
5289
+ account: null,
5290
+ lastUsedAt: null,
5291
+ lastError: null,
5292
+ enabled,
5293
+ createdAt: now,
5294
+ updatedAt: now
5295
+ }
5296
+ });
5297
+ await this.audit(user, enabled ? "enable" : "disable", id);
5298
+ return { enabled };
5299
+ }
5300
+ /** Checks a connection's values with the provider; the card shows the result. */
5301
+ async test(id, cid) {
5302
+ const def = this.definition(id);
5303
+ if (!def.test) throw new QueryError(`${id} can't be tested`);
5304
+ const values = cid === "env" ? this.envValues(def) : { ...this.decrypt(await this.row(id, cid)), ...this.envValues(def) };
5305
+ let result;
5306
+ try {
5307
+ result = await def.test(values, { cms: this.cms });
5308
+ } catch (error) {
5309
+ result = { ok: false, message: error.message };
5310
+ }
5311
+ if (cid !== "env") {
5312
+ const row = await this.row(id, cid);
5313
+ await this.cms.db.update({
5314
+ collection: CONNECTIONS,
5315
+ id: cid,
5316
+ data: {
5317
+ ...row,
5318
+ ...result.ok ? { lastError: null, ...result.account ? { account: result.account } : {} } : { lastError: result.message ?? "Test failed" },
5319
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
5320
+ }
5321
+ });
5322
+ if (!result.ok)
5323
+ this.cms.systemEvent("integration.failed", {
5324
+ integration: id,
5325
+ name: row.name ?? null,
5326
+ error: result.message ?? "Test failed"
5327
+ });
5328
+ }
5329
+ return result;
5330
+ }
5331
+ /**
5332
+ * A field's choices from the provider, with the values typed (and, editing a connection, its
5333
+ * stored secrets for the fields left empty).
5334
+ */
5335
+ async lookup(id, fieldName, body) {
5336
+ const def = this.definition(id);
5337
+ const field = (def.fields ?? []).find((f) => f.name === fieldName);
5338
+ if (!field?.lookup) throw new QueryError(`${id}.${fieldName} has nothing to look up`);
5339
+ const input = body ?? {};
5340
+ const stored = input.connection !== void 0 && input.connection !== null ? this.decrypt(await this.row(id, input.connection)) : {};
5341
+ const values = { ...stored, ...this.envValues(def), ...this.cleanValues(def, input.values) };
5342
+ try {
5343
+ return { choices: [...await field.lookup(values, { cms: this.cms })].slice(0, 100) };
5344
+ } catch (error) {
5345
+ throw new ValidationError(CONNECTIONS, [
5346
+ { field: fieldName, message: error.message }
5347
+ ]);
5348
+ }
5349
+ }
5350
+ // --- OAuth -----------------------------------------------------------------------------------
5351
+ /** The provider's sign-in page to send the admin to. */
5352
+ authorize(id, user, redirectUri, name) {
5353
+ const def = this.definition(id);
5354
+ if (def.kind !== "oauth" || !def.oauth)
5355
+ throw new QueryError(`${id} connects with pasted values`);
5356
+ const state = signState(this.config.secret, {
5357
+ i: id,
5358
+ u: String(user.id),
5359
+ n: name,
5360
+ exp: Date.now() + PENDING_MS
5361
+ });
5362
+ return { url: def.oauth.authorizeUrl({ redirectUri, state }) };
5363
+ }
5364
+ /**
5365
+ * Back from the provider: one account is connected at once; several wait (encrypted, 10
5366
+ * minutes) for the admin to choose. Returns where the admin goes next.
5367
+ */
5368
+ async callback(id, code, stateText, redirectUri, user) {
5369
+ const def = this.definition(id);
5370
+ const state = readState(this.secrets, stateText);
5371
+ if (!state || state.i !== id || state.u !== String(user.id) || !def.oauth)
5372
+ throw new ForbiddenError(
5373
+ "This connection link has expired: start again from the Integrations page"
5374
+ );
5375
+ const result = await def.oauth.exchange({ code, redirectUri });
5376
+ const name = typeof state.n === "string" && state.n ? state.n : String(def.label);
5377
+ if (result.accounts && result.accounts.length > 0) {
5378
+ if (result.accounts.length === 1) {
5379
+ const [account] = result.accounts;
5380
+ await this.create(
5381
+ def,
5382
+ await this.uniqueName(id, name),
5383
+ { ...account.values },
5384
+ account.name
5385
+ );
5386
+ await this.audit(user, "connect", id, { name });
5387
+ return { connected: true };
5388
+ }
5389
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5390
+ const pending = await this.cms.db.create({
5391
+ collection: CONNECTIONS,
5392
+ data: {
5393
+ kind: "pending",
5394
+ integration: id,
5395
+ name,
5396
+ isDefault: false,
5397
+ values: {
5398
+ user: String(user.id),
5399
+ expires: String(Date.now() + PENDING_MS),
5400
+ accounts: JSON.stringify(result.accounts.map((a) => ({ id: a.id, name: a.name })))
5401
+ },
5402
+ secret: encryptValues(
5403
+ this.config.secret,
5404
+ Object.fromEntries(result.accounts.map((a) => [a.id, JSON.stringify(a.values)]))
5405
+ ),
5406
+ account: null,
5407
+ lastUsedAt: null,
5408
+ lastError: null,
5409
+ enabled: null,
5410
+ createdAt: now,
5411
+ updatedAt: now
5412
+ }
5413
+ });
5414
+ return { pending: pending.id };
5415
+ }
5416
+ await this.create(
5417
+ def,
5418
+ await this.uniqueName(id, name),
5419
+ { ...result.values ?? {} },
5420
+ result.account ?? null
5421
+ );
5422
+ await this.audit(user, "connect", id, { name });
5423
+ return { connected: true };
5424
+ }
5425
+ /** The accounts waiting to be chosen from, by name only. */
5426
+ async pending(id, pid, user) {
5427
+ const row = await this.pendingRow(id, pid, user);
5428
+ const values = row.values;
5429
+ return {
5430
+ name: String(row.name),
5431
+ accounts: JSON.parse(values.accounts ?? "[]")
5432
+ };
5433
+ }
5434
+ async pendingRow(id, pid, user) {
5435
+ const row = await this.cms.db.findById({ collection: CONNECTIONS, id: pid });
5436
+ const values = row?.values ?? {};
5437
+ if (!row || row.kind !== "pending" || row.integration !== id || values.user !== String(user.id) || Number(values.expires) < Date.now())
5438
+ throw new NotFoundError(CONNECTIONS, pid);
5439
+ return row;
5440
+ }
5441
+ /** Connects the account chosen. */
5442
+ async choose(id, pid, accountId, user) {
5443
+ const def = this.definition(id);
5444
+ const row = await this.pendingRow(id, pid, user);
5445
+ const secrets = decryptValues(this.secrets, String(row.secret)).values;
5446
+ const chosen = secrets[accountId];
5447
+ const accounts = JSON.parse(
5448
+ String(row.values.accounts ?? "[]")
5449
+ );
5450
+ const account = accounts.find((a) => a.id === accountId);
5451
+ if (!chosen || !account) throw new NotFoundError("accounts", accountId);
5452
+ await this.cms.db.delete({ collection: CONNECTIONS, id: pid });
5453
+ const name = await this.uniqueName(id, String(row.name));
5454
+ const created = await this.create(
5455
+ def,
5456
+ name,
5457
+ JSON.parse(chosen),
5458
+ account.name
5459
+ );
5460
+ await this.audit(user, "connect", id, { name });
5461
+ return { id: created.id };
5462
+ }
5463
+ // --- Rotating the secret ---------------------------------------------------------------------
5464
+ /** Encrypts again, with the current secret, every connection still on an older one. */
5465
+ async rotate() {
5466
+ const current = keyId(keyOf2(this.config.secret));
5467
+ const found = await this.cms.db.find({
5468
+ collection: CONNECTIONS,
5469
+ limit: 0,
5470
+ page: 1,
5471
+ sort: ["id"]
5472
+ });
5473
+ let resealed = 0;
5474
+ for (const row of found.docs) {
5475
+ if (!row.secret || keyIdOf(row.secret) === current) continue;
5476
+ const { values } = decryptValues(this.secrets, String(row.secret));
5477
+ await this.reseal(row, values);
5478
+ resealed++;
5479
+ }
5480
+ return { resealed };
5481
+ }
5482
+ /** Connections whose test or last use failed, for the dashboard's "needs attention". */
5483
+ async broken() {
5484
+ const found = await this.cms.db.find({
5485
+ collection: CONNECTIONS,
5486
+ where: { and: [{ kind: { equals: "connection" } }, { lastError: { exists: true } }] },
5487
+ limit: 0,
5488
+ page: 1,
5489
+ sort: ["id"]
5490
+ });
5491
+ return found.docs.filter((r) => this.definitions.some((d) => d.id === r.integration)).map((r) => ({
5492
+ integration: String(r.integration),
5493
+ name: String(r.name),
5494
+ error: String(r.lastError)
5495
+ }));
5496
+ }
5497
+ };
5498
+
4806
5499
  // src/localization.ts
4807
5500
  var ALL_LOCALES = "all";
4808
5501
  var isPlainObject2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
@@ -4847,12 +5540,12 @@ function toLocaleMaps(fields, input, current, locale) {
4847
5540
  locale
4848
5541
  );
4849
5542
  } else if (hasRows(field) && Array.isArray(value)) {
4850
- const rows = new Map(
5543
+ const rows2 = new Map(
4851
5544
  (Array.isArray(previous) ? previous : []).filter(isPlainObject2).map((row) => [String(row.id), row])
4852
5545
  );
4853
5546
  result[field.name] = value.map((row) => {
4854
5547
  const fields2 = rowFields(field, row);
4855
- return fields2 && isPlainObject2(row) ? toLocaleMaps(fields2, row, rows.get(String(row.id)) ?? {}, locale) : row;
5548
+ return fields2 && isPlainObject2(row) ? toLocaleMaps(fields2, row, rows2.get(String(row.id)) ?? {}, locale) : row;
4856
5549
  });
4857
5550
  }
4858
5551
  }
@@ -5272,13 +5965,13 @@ function embeddedIPv4(address) {
5272
5965
  return bytes ? bytes.slice(12).join(".") : void 0;
5273
5966
  }
5274
5967
  function ipv6Bytes(address) {
5275
- let text3 = address;
5276
- const tail = /(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(text3);
5968
+ let text4 = address;
5969
+ const tail = /(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(text4);
5277
5970
  if (tail) {
5278
5971
  const [a, b, c, d] = tail.slice(1).map(Number);
5279
- text3 = `${text3.slice(0, tail.index)}${(a << 8 | b).toString(16)}:${(c << 8 | d).toString(16)}`;
5972
+ text4 = `${text4.slice(0, tail.index)}${(a << 8 | b).toString(16)}:${(c << 8 | d).toString(16)}`;
5280
5973
  }
5281
- const [head = "", rest] = text3.split("::");
5974
+ const [head = "", rest] = text4.split("::");
5282
5975
  const left = head ? head.split(":") : [];
5283
5976
  const right = rest === void 0 ? [] : rest ? rest.split(":") : [];
5284
5977
  const groups = rest === void 0 ? left : [...left, ...Array(8 - left.length - right.length).fill("0"), ...right];
@@ -5457,7 +6150,7 @@ function read(response, maxBytes, deadline) {
5457
6150
 
5458
6151
  // src/config-keys.ts
5459
6152
  var keys = (list) => new Set(list.split(/\s+/).filter(Boolean));
5460
- var CONFIG = keys(`secret previousSecrets db serverURL cors cronSecret timeZone webhooks events jobs localization routes admin
6153
+ var CONFIG = keys(`secret previousSecrets integrations listeners db serverURL cors cronSecret timeZone webhooks events jobs localization routes admin
5461
6154
  upload auth collections globals endpoints cliCommands onRequest fieldTypes apiKeys email backups
5462
6155
  audit logs countCacheSeconds maxPage httpCache outbox rateLimit search telemetry plugins`);
5463
6156
  var ADMIN = keys("path locale brand siteURL nav commands modules pages dashboard switcher");
@@ -5476,7 +6169,7 @@ var GLOBAL = keys(
5476
6169
  );
5477
6170
  var CONTAINER_ADMIN = keys("icon order sidebar layout group");
5478
6171
  var COLLECTION_ADMIN = keys(
5479
- "icon order sidebar layout group editIn badge count empty list calendar ownerField confirmDelete"
6172
+ "icon order sidebar layout group editIn badge count empty list calendar ownerField confirmDelete views export"
5480
6173
  );
5481
6174
  var FIELD_ADMIN = keys(
5482
6175
  "description width condition component after cell column allowCreate initialValue position filter"
@@ -5640,7 +6333,8 @@ var BUILTIN_NAV = [
5640
6333
  children: [
5641
6334
  { id: "site", label: { en: "Site", th: "\u0E40\u0E27\u0E47\u0E1A\u0E44\u0E0B\u0E15\u0E4C" }, icon: "globe" },
5642
6335
  { id: "people", label: { en: "Users & access", th: "\u0E1C\u0E39\u0E49\u0E43\u0E0A\u0E49\u0E41\u0E25\u0E30\u0E2A\u0E34\u0E17\u0E18\u0E34\u0E4C" }, icon: "users" },
5643
- { id: "system", label: { en: "System", th: "\u0E23\u0E30\u0E1A\u0E1A" }, icon: "sliders-horizontal" }
6336
+ { id: "system", label: { en: "System", th: "\u0E23\u0E30\u0E1A\u0E1A" }, icon: "sliders-horizontal" },
6337
+ { id: "integrations", label: { en: "Integrations", th: "\u0E01\u0E32\u0E23\u0E40\u0E0A\u0E37\u0E48\u0E2D\u0E21\u0E15\u0E48\u0E2D" }, icon: "link" }
5644
6338
  ]
5645
6339
  }
5646
6340
  ];
@@ -5747,26 +6441,26 @@ function fieldsText(fields, data) {
5747
6441
  }
5748
6442
  function richText(node) {
5749
6443
  if (!node || typeof node !== "object") return "";
5750
- const { text: text3, content } = node;
5751
- if (typeof text3 === "string") return text3;
6444
+ const { text: text4, content } = node;
6445
+ if (typeof text4 === "string") return text4;
5752
6446
  if (!Array.isArray(content)) return "";
5753
6447
  const inline = content.some((child) => typeof child?.text === "string");
5754
6448
  return content.map(richText).filter(Boolean).join(inline ? "" : "\n");
5755
6449
  }
5756
6450
  function searchText(fields, doc, paths) {
5757
- const text3 = {};
6451
+ const text4 = {};
5758
6452
  for (const path of paths) {
5759
6453
  const field = fieldAt(fields, path);
5760
6454
  let value = doc;
5761
6455
  for (const name of path.split("."))
5762
6456
  value = value && typeof value === "object" ? value[name] : void 0;
5763
- text3[path] = field ? textOf(field, value) : "";
6457
+ text4[path] = field ? textOf(field, value) : "";
5764
6458
  }
5765
- return text3;
6459
+ return text4;
5766
6460
  }
5767
6461
  var segmenter = new Intl.Segmenter(void 0, { granularity: "word" });
5768
- function words(text3) {
5769
- return [...segmenter.segment(text3.normalize("NFC").toLowerCase())].filter((s) => s.isWordLike).map((s) => s.segment);
6462
+ function words(text4) {
6463
+ return [...segmenter.segment(text4.normalize("NFC").toLowerCase())].filter((s) => s.isWordLike).map((s) => s.segment);
5770
6464
  }
5771
6465
  function memorySearch() {
5772
6466
  const entries = /* @__PURE__ */ new Map();
@@ -5993,6 +6687,12 @@ function verifyWebhook(body, headers, secret, options = {}) {
5993
6687
  }
5994
6688
 
5995
6689
  // src/webhooks.ts
6690
+ var SYSTEM_EVENTS = [
6691
+ "scheduled.failed",
6692
+ "delivery.failed",
6693
+ "backup.failed",
6694
+ "integration.failed"
6695
+ ];
5996
6696
  var WEBHOOK_EVENTS = [
5997
6697
  "create",
5998
6698
  "update",
@@ -6184,10 +6884,13 @@ var Webhooks = class {
6184
6884
  return { ok: false, error: error.message };
6185
6885
  }
6186
6886
  }
6887
+ /** Told when a delivery is given up on (`delivery.failed`). */
6888
+ onGiveUp;
6187
6889
  giveUp(hook, event, error, attempts) {
6188
6890
  this.logger.error(
6189
6891
  `Webhook ${hook.url} failed for ${event} after ${attempts} attempts: ${error}`
6190
6892
  );
6893
+ this.onGiveUp?.({ url: hook.url, event, error, attempts });
6191
6894
  }
6192
6895
  };
6193
6896
  var later4 = (from, ms) => new Date(from.getTime() + ms).toISOString();
@@ -6276,6 +6979,8 @@ function validateConfig(config) {
6276
6979
  validateEndpoints(config, collectionSlugs, add);
6277
6980
  validateCommands(config, add);
6278
6981
  validateJobs(config, add);
6982
+ validateIntegrations(config, add);
6983
+ validateListeners(config, add);
6279
6984
  validateTimeZone(config, add);
6280
6985
  validateEvents(config, add);
6281
6986
  const apiKeys = config.apiKeys;
@@ -6803,7 +7508,7 @@ function validateWebhooks(config, add) {
6803
7508
  ...(config.collections ?? []).map((c) => c?.slug)
6804
7509
  ]);
6805
7510
  const globals = new Set((config.globals ?? []).map((g) => g?.slug));
6806
- const custom = new Set(config.events ?? []);
7511
+ const custom = /* @__PURE__ */ new Set([...config.events ?? [], ...SYSTEM_EVENTS]);
6807
7512
  for (const [i, hook] of hooks.entries()) {
6808
7513
  const path = `webhooks[${i}]`;
6809
7514
  if (typeof hook?.url !== "string" || !/^https?:\/\/\S+$/.test(hook.url)) {
@@ -7207,7 +7912,26 @@ function validateLayout(layout, container, path, add) {
7207
7912
  nodes(layout, path, true);
7208
7913
  }
7209
7914
  function validateCollectionAdmin(admin, path, add) {
7210
- const { badge, count, empty } = admin;
7915
+ const { badge, count, empty, views } = admin;
7916
+ if (views !== void 0) {
7917
+ if (!Array.isArray(views)) add(`${path}.views`, "must be an array");
7918
+ const ids = /* @__PURE__ */ new Set();
7919
+ for (const [i, raw] of (Array.isArray(views) ? views : []).entries()) {
7920
+ const view = raw ?? {};
7921
+ const at = `${path}.views[${i}]`;
7922
+ if (typeof view.id !== "string" || !/^[a-z0-9][a-z0-9_-]*$/.test(view.id))
7923
+ add(`${at}.id`, 'must be lowercase letters, digits, "-" or "_"', "e.g. id: 'to-review'");
7924
+ else if (ids.has(view.id)) add(`${at}.id`, `duplicate view "${view.id}"`);
7925
+ else ids.add(view.id);
7926
+ if (!isLabel(view.label)) add(`${at}.label`, "must be a string or { en, th }");
7927
+ if (view.where !== void 0 && (typeof view.where !== "object" || view.where === null || Array.isArray(view.where)))
7928
+ add(`${at}.where`, "must be a where query", "e.g. { status: { equals: 'draft' } }");
7929
+ if (view.pageSize !== void 0 && ![10, 25, 50, 100].includes(view.pageSize))
7930
+ add(`${at}.pageSize`, "must be 10, 25, 50 or 100");
7931
+ if (view.density !== void 0 && !["comfortable", "compact"].includes(view.density))
7932
+ add(`${at}.density`, "must be 'comfortable' or 'compact'");
7933
+ }
7934
+ }
7211
7935
  if (badge !== void 0) {
7212
7936
  const b = badge ?? {};
7213
7937
  if (typeof b.where !== "object" || b.where === null || Array.isArray(b.where))
@@ -7217,6 +7941,8 @@ function validateCollectionAdmin(admin, path, add) {
7217
7941
  }
7218
7942
  if (count !== void 0 && typeof count !== "boolean")
7219
7943
  add(`${path}.count`, "must be true or false");
7944
+ if (admin.export !== void 0 && typeof admin.export !== "boolean")
7945
+ add(`${path}.export`, "must be true or false");
7220
7946
  if (empty !== void 0) {
7221
7947
  const e = empty ?? {};
7222
7948
  if (e.description !== void 0 && !isLabel(e.description))
@@ -7872,6 +8598,56 @@ function validateAdapterVersions(config, add) {
7872
8598
  );
7873
8599
  }
7874
8600
  }
8601
+ function validateIntegrations(config, add) {
8602
+ const list = config.integrations;
8603
+ if (list === void 0) return;
8604
+ if (!Array.isArray(list)) {
8605
+ add("integrations", "must be an array");
8606
+ return;
8607
+ }
8608
+ const ids = /* @__PURE__ */ new Set();
8609
+ for (const [i, raw] of list.entries()) {
8610
+ const at = `integrations[${i}]`;
8611
+ const item = raw ?? {};
8612
+ if (typeof item.id !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(item.id))
8613
+ add(`${at}.id`, 'must be lowercase letters, digits and "-"', "e.g. id: 'telegram'");
8614
+ else if (ids.has(item.id)) add(`${at}.id`, `two integrations are named "${item.id}"`);
8615
+ else ids.add(item.id);
8616
+ if (!isLabel(item.label)) add(`${at}.label`, "must be a string or { en, th }");
8617
+ if (!["social", "ai", "notifications", "automation"].includes(item.category))
8618
+ add(`${at}.category`, "must be 'social', 'ai', 'notifications' or 'automation'");
8619
+ if (item.kind === "fields") {
8620
+ if (!Array.isArray(item.fields) || item.fields.length === 0)
8621
+ add(`${at}.fields`, "needs at least one field to fill");
8622
+ } else if (item.kind === "oauth") {
8623
+ const oauth = item.oauth ?? {};
8624
+ if (typeof oauth.authorizeUrl !== "function" || typeof oauth.exchange !== "function")
8625
+ add(`${at}.oauth`, "needs authorizeUrl and exchange functions");
8626
+ } else add(`${at}.kind`, "must be 'fields' or 'oauth'");
8627
+ if (item.test !== void 0 && typeof item.test !== "function")
8628
+ add(`${at}.test`, "must be a function");
8629
+ for (const [f, field] of (Array.isArray(item.fields) ? item.fields : []).entries()) {
8630
+ const fieldItem = field ?? {};
8631
+ if (fieldItem.lookup !== void 0 && typeof fieldItem.lookup !== "function")
8632
+ add(`${at}.fields[${f}].lookup`, "must be a function");
8633
+ }
8634
+ }
8635
+ }
8636
+ function validateListeners(config, add) {
8637
+ const list = config.listeners;
8638
+ if (list === void 0) return;
8639
+ if (!Array.isArray(list)) {
8640
+ add("listeners", "must be an array");
8641
+ return;
8642
+ }
8643
+ for (const [i, raw] of list.entries()) {
8644
+ const item = raw ?? {};
8645
+ const events = item.events;
8646
+ if (events !== "*" && !(Array.isArray(events) && events.every((e) => typeof e === "string" && e.length > 0)))
8647
+ add(`listeners[${i}].events`, "must be event names or '*'", "e.g. events: ['order.created']");
8648
+ if (typeof item.handler !== "function") add(`listeners[${i}].handler`, "must be a function");
8649
+ }
8650
+ }
7875
8651
 
7876
8652
  // src/resolve-config.ts
7877
8653
  var DEFAULT_ADMIN_PATH = "/admin";
@@ -7981,14 +8757,18 @@ async function resolveConfig(input) {
7981
8757
  ...config.rateLimit ? [rateLimitsCollection] : [],
7982
8758
  databaseBackupsCollection,
7983
8759
  bulkOperationsCollection,
8760
+ savedViewsCollection,
8761
+ ...resolvedIntegrations(config).length > 0 ? [connectionsCollection] : [],
7984
8762
  ...config.auth?.rbac ? [rolesCollection] : [],
7985
8763
  ...(config.auth?.providers?.length ?? 0) > 0 ? [userIdentitiesCollection] : [],
7986
8764
  ...config.audit ? [auditLogsCollection] : []
7987
8765
  ],
7988
8766
  globals: config.globals ?? [],
7989
8767
  endpoints: config.endpoints ?? [],
8768
+ integrations: resolvedIntegrations(config),
7990
8769
  cliCommands: config.cliCommands ?? [],
7991
- events: config.events ?? [],
8770
+ // Easy CMS's own events (#356), for webhooks and `cms.on`.
8771
+ events: [.../* @__PURE__ */ new Set([...config.events ?? [], ...SYSTEM_EVENTS])],
7992
8772
  jobs: config.jobs ?? [],
7993
8773
  warnings,
7994
8774
  installedPlugins: (plugins ?? []).map(
@@ -8064,9 +8844,12 @@ function resolveAudit(audit) {
8064
8844
  scope: audit.scope ?? false
8065
8845
  };
8066
8846
  }
8847
+ function resolvedIntegrations(config) {
8848
+ return config.integrations ?? [];
8849
+ }
8067
8850
 
8068
8851
  // src/rest/http-cache.ts
8069
- import { createHash as createHash4 } from "crypto";
8852
+ import { createHash as createHash5 } from "crypto";
8070
8853
  var cacheTags = {
8071
8854
  collection: (slug2) => ["ecms", `ecms:${slug2}`],
8072
8855
  document: (slug2, id) => ["ecms", `ecms:${slug2}`, `ecms:${slug2}:${id}`],
@@ -8093,7 +8876,7 @@ function cacheableResponse(request, body, status, headers, times, tags) {
8093
8876
  );
8094
8877
  headers.set("cache-tag", tags.join(","));
8095
8878
  headers.set("surrogate-key", tags.join(" "));
8096
- const etag = `W/"${createHash4("sha1").update(body).digest("base64url")}"`;
8879
+ const etag = `W/"${createHash5("sha1").update(body).digest("base64url")}"`;
8097
8880
  headers.set("etag", etag);
8098
8881
  if (matches2(request.headers.get("if-none-match"), etag))
8099
8882
  return new Response(null, { status: 304, headers });
@@ -8101,7 +8884,7 @@ function cacheableResponse(request, body, status, headers, times, tags) {
8101
8884
  }
8102
8885
 
8103
8886
  // src/version.ts
8104
- var VERSION = true ? "1.30.1" : "0.0.0-dev";
8887
+ var VERSION = true ? "1.32.0" : "0.0.0-dev";
8105
8888
 
8106
8889
  // src/telemetry.ts
8107
8890
  var DB_CALLS = /* @__PURE__ */ new Set([
@@ -8378,6 +9161,8 @@ var EasyCMS = class {
8378
9161
  audit;
8379
9162
  /** Folders of the media library and their permissions (`upload.folders`). */
8380
9163
  folders;
9164
+ /** Accounts connected to plugins' integrations (`integrations`, Settings → Integrations). */
9165
+ integrations;
8381
9166
  versions;
8382
9167
  webhooks;
8383
9168
  mailer;
@@ -8405,6 +9190,12 @@ var EasyCMS = class {
8405
9190
  (entry) => this.audit.record(entry)
8406
9191
  );
8407
9192
  this.audit = new AuditLog(this);
9193
+ this.integrations = new Integrations(this);
9194
+ for (const listener of config.listeners ?? []) {
9195
+ const run = (message2) => listener.handler(message2, { cms: this });
9196
+ if (listener.events === "*") this.anyListeners.add(run);
9197
+ else for (const event of listener.events) this.on(event, run);
9198
+ }
8408
9199
  this.folders = new MediaFolders(config, db, this.roles);
8409
9200
  this.webhooks = new Webhooks(
8410
9201
  config.webhooks ?? [],
@@ -8416,6 +9207,8 @@ var EasyCMS = class {
8416
9207
  logger,
8417
9208
  config.collections.some((c) => c.slug === EMAIL_DELIVERIES) ? this.emailQueue() : void 0
8418
9209
  );
9210
+ this.webhooks.onGiveUp = ({ url, event, error, attempts }) => this.systemEvent("delivery.failed", { kind: "webhook", url, event, error, attempts });
9211
+ this.mailer.onGiveUp = ({ to, error, attempts }) => this.systemEvent("delivery.failed", { kind: "email", to, error, attempts });
8419
9212
  this.outbox = config.collections.some((c) => c.slug === OUTBOX) ? new Outbox(
8420
9213
  db,
8421
9214
  logger,
@@ -8608,7 +9401,7 @@ var EasyCMS = class {
8608
9401
  }
8609
9402
  ]);
8610
9403
  }
8611
- const random2 = randomBytes6(4).toString("hex");
9404
+ const random2 = randomBytes7(4).toString("hex");
8612
9405
  const isPrivate = await this.folders.isPrivate(folderOf(MEDIA, data));
8613
9406
  const storage = this.storageFor(isPrivate);
8614
9407
  const filename = withPrivacy(storageKey(file.name, mimeType, random2), isPrivate);
@@ -8676,7 +9469,7 @@ var EasyCMS = class {
8676
9469
  ]);
8677
9470
  const isPrivate = await this.folders.isPrivate(folder);
8678
9471
  const storage = this.storageFor(isPrivate);
8679
- const key = withPrivacy(storageKey(name, type, randomBytes6(4).toString("hex")), isPrivate);
9472
+ const key = withPrivacy(storageKey(name, type, randomBytes7(4).toString("hex")), isPrivate);
8680
9473
  const upload = storage.uploadURL ? await storage.uploadURL(key, { contentType: type, size, expiresIn: UPLOAD_URL_SECONDS }) : null;
8681
9474
  const ticket = this.sign("upload", {
8682
9475
  key,
@@ -9801,12 +10594,12 @@ var EasyCMS = class {
9801
10594
  if (asked !== void 0 && localization && !localization.locales.includes(asked))
9802
10595
  throw new QueryError(`Unknown locale "${asked}"`);
9803
10596
  const locale = localization ? asked ?? localization.defaultLocale ?? null : null;
9804
- const text3 = query.trim().slice(0, 200);
10597
+ const text4 = query.trim().slice(0, 200);
9805
10598
  const empty = { hits: [], total: 0, page, limit, hasNextPage: false };
9806
- if (!text3 || collections.length === 0) return empty;
10599
+ if (!text4 || collections.length === 0) return empty;
9807
10600
  const { collections: _, limit: __, page: ___, ...read2 } = options;
9808
10601
  const { hits, total } = await index.search(
9809
- { query: text3, collections, locale, limit, offset: (page - 1) * limit },
10602
+ { query: text4, collections, locale, limit, offset: (page - 1) * limit },
9810
10603
  async (collection2, ids) => (await this.find(collection2, {
9811
10604
  ...read2,
9812
10605
  ...locale ? { locale } : {},
@@ -9960,13 +10753,13 @@ var EasyCMS = class {
9960
10753
  const { jobs } = this.config;
9961
10754
  if (jobs.length === 0) return { ran: 0, failed: 0 };
9962
10755
  const { docs } = await this.db.find({ collection: JOB_RUNS, sort: ["name"], limit: 0, page: 1 });
9963
- const rows = new Map(docs.map((row) => [String(row.name), row]));
10756
+ const rows2 = new Map(docs.map((row) => [String(row.name), row]));
9964
10757
  let legacy;
9965
10758
  const stamp = now.toISOString();
9966
10759
  let ran = 0;
9967
10760
  let failed = 0;
9968
10761
  for (const job of jobs) {
9969
- const fresh = rows.has(job.name) ? await this.db.findById({ collection: JOB_RUNS, id: rows.get(job.name)?.id }) : null;
10762
+ const fresh = rows2.has(job.name) ? await this.db.findById({ collection: JOB_RUNS, id: rows2.get(job.name)?.id }) : null;
9970
10763
  const row = fresh ?? void 0;
9971
10764
  if (row?.pausedAt) continue;
9972
10765
  const schedule = jobSchedule(job, row);
@@ -10080,6 +10873,37 @@ var EasyCMS = class {
10080
10873
  if (!this.config.events.includes(event))
10081
10874
  throw new QueryError(`Unknown event "${event}": add it to \`events\` in the config`);
10082
10875
  this.webhooks.emit(event, about ?? {}, data);
10876
+ this.dispatch(event, data, about);
10877
+ }
10878
+ listeners = /* @__PURE__ */ new Map();
10879
+ /** Listeners of every event (`events: '*'` in the config's `listeners`). */
10880
+ anyListeners = /* @__PURE__ */ new Set();
10881
+ /**
10882
+ * Runs `handler` after each `event` on this server (#356): the config's and plugins' events, the
10883
+ * content changes webhooks get (`create`, `publish`…), and Easy CMS's own (`scheduled.failed`,
10884
+ * `delivery.failed`, `backup.failed`, `integration.failed`). It runs after the change, without
10885
+ * being waited for; its errors are logged. Returns a function that stops it.
10886
+ */
10887
+ on(event, handler) {
10888
+ const set = this.listeners.get(event) ?? /* @__PURE__ */ new Set();
10889
+ set.add(handler);
10890
+ this.listeners.set(event, set);
10891
+ return () => set.delete(handler);
10892
+ }
10893
+ dispatch(event, data, about) {
10894
+ const set = [...this.listeners.get(event) ?? [], ...this.anyListeners];
10895
+ if (set.length === 0) return;
10896
+ for (const handler of set)
10897
+ queueMicrotask(() => {
10898
+ Promise.resolve().then(() => handler({ event, data, ...about ? { about } : {} })).catch(
10899
+ (error) => this.logger.error(`A listener of ${event} failed: ${errorText(error)}`, { error })
10900
+ );
10901
+ });
10902
+ }
10903
+ /** @internal Easy CMS's own events (`SYSTEM_EVENTS`): to webhooks that list them and `cms.on`. */
10904
+ systemEvent(event, data) {
10905
+ this.webhooks.emit(event, {}, data);
10906
+ this.dispatch(event, data);
10083
10907
  }
10084
10908
  /**
10085
10909
  * Tries webhook deliveries that are due for another attempt. Deliveries are kept in the
@@ -10238,6 +11062,13 @@ var EasyCMS = class {
10238
11062
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
10239
11063
  }
10240
11064
  });
11065
+ this.systemEvent("scheduled.failed", {
11066
+ job: row.id,
11067
+ action,
11068
+ ...row.collection ? { collection: row.collection } : { global: row.global },
11069
+ doc: row.doc ?? null,
11070
+ error: message2
11071
+ });
10241
11072
  }
10242
11073
  }
10243
11074
  return { ran, failed };
@@ -10350,7 +11181,7 @@ var EasyCMS = class {
10350
11181
  ...options.from !== void 0 ? { gte: options.from } : {},
10351
11182
  ...options.to !== void 0 ? { lt: options.to } : {}
10352
11183
  };
10353
- const rows = await this.db.find({
11184
+ const rows2 = await this.db.find({
10354
11185
  collection: SCHEDULED_JOBS,
10355
11186
  where: {
10356
11187
  and: [
@@ -10385,7 +11216,7 @@ var EasyCMS = class {
10385
11216
  return result;
10386
11217
  };
10387
11218
  const jobs = [];
10388
- for (const row of rows.docs) {
11219
+ for (const row of rows2.docs) {
10389
11220
  const parent = String(row.parent);
10390
11221
  if (!await may2(parent)) continue;
10391
11222
  const target = parent.startsWith("global:") ? { global: splitGlobalKey(parent.slice("global:".length)).slug } : { collection: parent };
@@ -10395,7 +11226,7 @@ var EasyCMS = class {
10395
11226
  return jobs;
10396
11227
  }
10397
11228
  async pendingJobs(parent, doc) {
10398
- const rows = await this.db.find({
11229
+ const rows2 = await this.db.find({
10399
11230
  collection: SCHEDULED_JOBS,
10400
11231
  where: {
10401
11232
  and: [
@@ -10408,7 +11239,7 @@ var EasyCMS = class {
10408
11239
  limit: 0,
10409
11240
  page: 1
10410
11241
  });
10411
- return rows.docs.map(toJob);
11242
+ return rows2.docs.map(toJob);
10412
11243
  }
10413
11244
  async removeJob(parent, doc, jobId) {
10414
11245
  const job = (await this.pendingJobs(parent, doc)).find((j) => String(j.id) === String(jobId));
@@ -10866,8 +11697,10 @@ var EasyCMS = class {
10866
11697
  * are logged.
10867
11698
  */
10868
11699
  emitChange(config, event, doc, before) {
10869
- for (const change of this.changeEvents(config, event, doc, before))
11700
+ for (const change of this.changeEvents(config, event, doc, before)) {
10870
11701
  this.webhooks.emit(change.event, change.target, change.body);
11702
+ this.dispatch(change.event, change.body, change.target);
11703
+ }
10871
11704
  if (this.searches(config, event)) this.searchIndex?.syncLater(config.slug, doc.id);
10872
11705
  const purge = this.config.httpCache?.purge;
10873
11706
  const tags = this.purgeTags(config, event, doc, before);
@@ -10917,8 +11750,10 @@ var EasyCMS = class {
10917
11750
  }
10918
11751
  if (!done.has("webhooks")) {
10919
11752
  const changes = this.changeEvents(config, event, doc, entry.before);
10920
- for (const change of changes)
11753
+ for (const change of changes) {
10921
11754
  await this.webhooks.emitSaved(change.event, change.target, change.body);
11755
+ this.dispatch(change.event, change.body, change.target);
11756
+ }
10922
11757
  if (changes.length > 0 && (this.config.webhooks?.length ?? 0) > 0) await mark("webhooks");
10923
11758
  }
10924
11759
  if (!done.has("search") && this.searchIndex && this.searches(config, event)) {
@@ -11409,7 +12244,7 @@ function durationSeconds(value) {
11409
12244
  }
11410
12245
  function cursorQuery(collection2, where, sort, locale, draft) {
11411
12246
  const key = JSON.stringify([collection2, where ?? null, sort, locale ?? null, draft]);
11412
- return createHash5("sha256").update(key).digest("base64url").slice(0, 16);
12247
+ return createHash6("sha256").update(key).digest("base64url").slice(0, 16);
11413
12248
  }
11414
12249
  function writeCursor(keys2, query) {
11415
12250
  return Buffer.from(JSON.stringify({ k: keys2, q: query })).toString("base64url");
@@ -11441,8 +12276,8 @@ function hasCalendarPage(config) {
11441
12276
  var text2 = (value) => typeof value === "string" && value !== "" ? value : null;
11442
12277
  async function adminJobs(cms, user) {
11443
12278
  const { config } = cms;
11444
- const rows = has(config, JOB_RUNS) ? (await cms.db.find({ collection: JOB_RUNS, sort: ["name"], limit: 0, page: 1 })).docs : [];
11445
- const byName = new Map(rows.map((row) => [String(row.name), row]));
12279
+ const rows2 = has(config, JOB_RUNS) ? (await cms.db.find({ collection: JOB_RUNS, sort: ["name"], limit: 0, page: 1 })).docs : [];
12280
+ const byName = new Map(rows2.map((row) => [String(row.name), row]));
11446
12281
  const jobs = config.jobs.map((job) => {
11447
12282
  const row = byName.get(job.name);
11448
12283
  const lastRunAt = text2(row?.lastRunAt);
@@ -11719,7 +12554,8 @@ var Roles = class {
11719
12554
  async canView(user, id) {
11720
12555
  if (isSystemAdmin(user)) return true;
11721
12556
  if (user.apiKey) return false;
11722
- if ((id === "deliveries" || id === "jobs") && user.scoped === true) return false;
12557
+ if ((id === "deliveries" || id === "jobs" || id === "integrations") && user.scoped === true)
12558
+ return false;
11723
12559
  if (id === "audit" && user.role === "admin" && user.scoped === true && this.config.audit !== false && typeof this.config.audit.scope === "function")
11724
12560
  return true;
11725
12561
  if (!this.enabled)
@@ -11816,9 +12652,9 @@ var Roles = class {
11816
12652
  }
11817
12653
  async addMissing() {
11818
12654
  if (!this.enabled) return;
11819
- const rows = await this.rows();
11820
- const first = rows.length === 0;
11821
- const known = new Set(rows.map((r) => String(r.key)));
12655
+ const rows2 = await this.rows();
12656
+ const first = rows2.length === 0;
12657
+ const known = new Set(rows2.map((r) => String(r.key)));
11822
12658
  for (const key of this.config.auth.roles) {
11823
12659
  if (known.has(key)) continue;
11824
12660
  try {
@@ -11876,15 +12712,15 @@ var Roles = class {
11876
12712
  // --- Settings → Roles ----------------------------------------------------
11877
12713
  async list() {
11878
12714
  await this.sync();
11879
- const rows = await this.rows();
12715
+ const rows2 = await this.rows();
11880
12716
  const counts = /* @__PURE__ */ new Map();
11881
- for (const row of rows) {
12717
+ for (const row of rows2) {
11882
12718
  counts.set(
11883
12719
  String(row.key),
11884
12720
  await this.db.count({ collection: USERS, where: { role: { equals: row.key } } })
11885
12721
  );
11886
12722
  }
11887
- const roles = rows.map((row) => this.toRole(row, counts.get(String(row.key)) ?? 0)).sort((a, b) => rank(a, this.config) - rank(b, this.config) || a.key.localeCompare(b.key));
12723
+ const roles = rows2.map((row) => this.toRole(row, counts.get(String(row.key)) ?? 0)).sort((a, b) => rank(a, this.config) - rank(b, this.config) || a.key.localeCompare(b.key));
11888
12724
  const decided = roles.filter((r) => r.key !== "admin").map((r) => r.permissions);
11889
12725
  const seen = (group, slug2) => decided.some((p) => p[group]?.[slug2] !== void 0);
11890
12726
  return {
@@ -12007,6 +12843,7 @@ var Roles = class {
12007
12843
  (c) => c.slug === WEBHOOK_DELIVERIES || c.slug === EMAIL_DELIVERIES
12008
12844
  ) ? [{ id: "deliveries" }] : [],
12009
12845
  ...hasJobsPage(this.config) ? [{ id: "jobs" }] : [],
12846
+ ...this.config.integrations.length > 0 ? [{ id: "integrations" }] : [],
12010
12847
  ...hasCalendarPage(this.config) ? [{ id: "calendar" }] : [],
12011
12848
  ...this.config.admin.pages.map((page) => ({ id: viewId.page(page.path), label: page.label })),
12012
12849
  ...this.config.admin.dashboard.map((widget) => ({
@@ -12209,7 +13046,7 @@ function rank(role, config) {
12209
13046
  }
12210
13047
 
12211
13048
  // src/rest/admin-modules.ts
12212
- import { createHash as createHash6 } from "crypto";
13049
+ import { createHash as createHash7 } from "crypto";
12213
13050
  import { readFile as readFile3 } from "fs/promises";
12214
13051
  import { createRequire } from "module";
12215
13052
  import { join as join3 } from "path";
@@ -12250,7 +13087,7 @@ async function readAdminModule(cms, index) {
12250
13087
  if (!file) return void 0;
12251
13088
  const body = await readFile3(file, "utf8").catch(() => void 0);
12252
13089
  if (body === void 0) return void 0;
12253
- return { body, etag: `"${createHash6("sha1").update(body).digest("base64url")}"` };
13090
+ return { body, etag: `"${createHash7("sha1").update(body).digest("base64url")}"` };
12254
13091
  }
12255
13092
 
12256
13093
  // src/rest/handler.ts
@@ -12261,9 +13098,9 @@ var PER_PAGE = 50;
12261
13098
  var collectionOf = (kind) => kind === "webhook" ? WEBHOOK_DELIVERIES : EMAIL_DELIVERIES;
12262
13099
  var exists = (cms, kind) => cms.config.collections.some((c) => c.slug === collectionOf(kind));
12263
13100
  var isDeliveryKind = (value) => value === "webhook" || value === "email";
12264
- function parse3(text3) {
13101
+ function parse3(text4) {
12265
13102
  try {
12266
- const value = JSON.parse(String(text3 ?? ""));
13103
+ const value = JSON.parse(String(text4 ?? ""));
12267
13104
  return typeof value === "object" && value !== null ? value : {};
12268
13105
  } catch {
12269
13106
  return {};
@@ -12445,7 +13282,7 @@ async function sendTestEmail(cms, user, to, locale) {
12445
13282
  const th = locale === "th" || locale !== "en" && cms.config.admin.locale === "th";
12446
13283
  const time = (/* @__PURE__ */ new Date()).toISOString();
12447
13284
  const subject = th ? `\u0E2D\u0E35\u0E40\u0E21\u0E25\u0E17\u0E14\u0E2A\u0E2D\u0E1A\u0E08\u0E32\u0E01 ${site}` : `Test email from ${site}`;
12448
- const text3 = 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
13285
+ const text4 = 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
12449
13286
 
12450
13287
  \u0E2A\u0E48\u0E07\u0E42\u0E14\u0E22: ${user.email}
12451
13288
  Adapter: ${adapter.name ?? "custom"}
@@ -12457,7 +13294,7 @@ Adapter: ${adapter.name ?? "custom"}
12457
13294
  From: ${from}
12458
13295
  Time: ${time}`;
12459
13296
  try {
12460
- await adapter.send({ to: address, subject, text: text3, from });
13297
+ await adapter.send({ to: address, subject, text: text4, from });
12461
13298
  return logsOnly(cms) ? { ok: true, logged: true } : { ok: true };
12462
13299
  } catch (error) {
12463
13300
  return { ok: false, error: message(error) };
@@ -12647,6 +13484,8 @@ async function collection(cms, config, user, localized, roles, context) {
12647
13484
  if (config.admin?.empty) result.empty = JSON.parse(JSON.stringify(config.admin.empty));
12648
13485
  if (config.admin?.list?.tree) result.tree = config.admin.list.tree;
12649
13486
  if (config.admin?.list?.sort) result.defaultSort = config.admin.list.sort;
13487
+ if (config.admin?.views?.length) result.views = JSON.parse(JSON.stringify(config.admin.views));
13488
+ if (config.admin?.export === false) result.noExport = true;
12650
13489
  if (config.admin?.calendar) result.calendar = { ...config.admin.calendar };
12651
13490
  if (config.admin?.confirmDelete) result.confirmDelete = { ...config.admin.confirmDelete };
12652
13491
  const owner = cms.roles.ownerOf(config.slug);
@@ -12703,7 +13542,8 @@ async function adminSchema(cms, user, origin, context = {}) {
12703
13542
  roles: admin && cms.roles.enabled,
12704
13543
  sso: admin && cms.auth.sso.enabled,
12705
13544
  audit: cms.audit.enabled && await cms.roles.canView(user, "audit"),
12706
- calendar: hasCalendarPage(cms.config) && await cms.roles.canView(user, "calendar")
13545
+ calendar: hasCalendarPage(cms.config) && await cms.roles.canView(user, "calendar"),
13546
+ integrations: cms.config.integrations.length > 0 && await cms.roles.canView(user, "integrations")
12707
13547
  };
12708
13548
  const roles = await cms.roles.options();
12709
13549
  const adminCollections = await Promise.all(
@@ -12843,7 +13683,8 @@ var VIEW_GROUPS = [
12843
13683
  ["email", "settings.system"],
12844
13684
  ["deliveries", "settings.system"],
12845
13685
  ["jobs", "settings.system"],
12846
- ["audit", "settings.system"]
13686
+ ["audit", "settings.system"],
13687
+ ["integrations", "settings.integrations"]
12847
13688
  ];
12848
13689
  function buildNav(cms, collections, globals, pageRefs, views) {
12849
13690
  const groups = navGroups(cms.config.admin.nav);
@@ -13076,6 +13917,15 @@ async function adminStatus(cms) {
13076
13917
  if (failed.length > 0) attention.push({ id: "jobs", count: failed.length, names: failed });
13077
13918
  const paused = await pausedJobs(cms);
13078
13919
  if (paused.length > 0) attention.push({ id: "jobs-paused", count: paused.length, names: paused });
13920
+ if (config.integrations.length > 0) {
13921
+ const broken = await cms.integrations.broken();
13922
+ if (broken.length > 0)
13923
+ attention.push({
13924
+ id: "integrations",
13925
+ count: broken.length,
13926
+ names: broken.map((b) => `${b.integration}: ${b.name}`)
13927
+ });
13928
+ }
13079
13929
  const backups = await backupAttention(cms);
13080
13930
  if (backups) attention.push(backups);
13081
13931
  attention.push(...await cms.audit.attention());
@@ -13097,6 +13947,182 @@ async function adminStatus(cms) {
13097
13947
  };
13098
13948
  }
13099
13949
 
13950
+ // src/rest/export-csv.ts
13951
+ var EXPORT_MAX = 1e5;
13952
+ var STEP = 500;
13953
+ var SYSTEM = {
13954
+ id: "ID",
13955
+ status: { en: "Status", th: "\u0E2A\u0E16\u0E32\u0E19\u0E30" },
13956
+ createdAt: { en: "Created", th: "\u0E2A\u0E23\u0E49\u0E32\u0E07\u0E40\u0E21\u0E37\u0E48\u0E2D" },
13957
+ updatedAt: { en: "Updated", th: "\u0E41\u0E01\u0E49\u0E44\u0E02\u0E40\u0E21\u0E37\u0E48\u0E2D" }
13958
+ };
13959
+ var text3 = (label, fallback, lang) => label === void 0 ? fallback : typeof label === "string" ? label : label[lang] ?? label.en ?? Object.values(label)[0] ?? fallback;
13960
+ function isoInZone(value, timeZone) {
13961
+ const date = new Date(value);
13962
+ if (!timeZone || Number.isNaN(date.getTime())) return value;
13963
+ const parts = Object.fromEntries(
13964
+ new Intl.DateTimeFormat("en-CA", {
13965
+ timeZone,
13966
+ year: "numeric",
13967
+ month: "2-digit",
13968
+ day: "2-digit",
13969
+ hour: "2-digit",
13970
+ minute: "2-digit",
13971
+ second: "2-digit",
13972
+ hourCycle: "h23"
13973
+ }).formatToParts(date).map((p) => [p.type, p.value])
13974
+ );
13975
+ const local = `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}`;
13976
+ const offset = Math.round((Date.parse(`${local}Z`) - date.getTime()) / 6e4);
13977
+ const sign3 = offset < 0 ? "-" : "+";
13978
+ const abs = Math.abs(offset);
13979
+ return `${local}${sign3}${String(Math.floor(abs / 60)).padStart(2, "0")}:${String(abs % 60).padStart(2, "0")}`;
13980
+ }
13981
+ function cellOf(cms, field, value, key, lang) {
13982
+ if (value === null || value === void 0) return "";
13983
+ const timeZone = cms.config.timeZone;
13984
+ if (!field)
13985
+ return key === "createdAt" || key === "updatedAt" ? isoInZone(String(value), timeZone) : value;
13986
+ const titleOf = (slug2, doc) => {
13987
+ if (!doc || typeof doc !== "object") return String(doc);
13988
+ const d = doc;
13989
+ if (slug2 === "media" && typeof d.url === "string") return d.url;
13990
+ const collection2 = cms.config.collections.find((c) => c.slug === slug2);
13991
+ const title = collection2?.useAsTitle ? d[collection2.useAsTitle] : void 0;
13992
+ return typeof title === "string" && title !== "" ? title : String(d.id ?? "");
13993
+ };
13994
+ switch (field.type) {
13995
+ case "date":
13996
+ return isoInZone(String(value), timeZone);
13997
+ case "boolean":
13998
+ return value ? "true" : "false";
13999
+ case "select": {
14000
+ const label = (v) => {
14001
+ const option = field.options.find((o) => (typeof o === "string" ? o : o.value) === v);
14002
+ return option === void 0 || typeof option === "string" ? String(v) : text3(option.label, String(v), lang);
14003
+ };
14004
+ return Array.isArray(value) ? value.map(label).join("; ") : label(value);
14005
+ }
14006
+ case "richText":
14007
+ return richText(value);
14008
+ case "relationship":
14009
+ case "upload": {
14010
+ const one = (item) => {
14011
+ if (isPolymorphic(field)) {
14012
+ const pair = polymorphicValue(item);
14013
+ if (!pair) return "";
14014
+ const collection2 = cms.config.collections.find((c) => c.slug === pair.relationTo);
14015
+ const name = text3(collection2?.labels?.singular, pair.relationTo, lang);
14016
+ return `${name} \xB7 ${titleOf(pair.relationTo, pair.value)}`;
14017
+ }
14018
+ return titleOf(relationTargets(field)[0], item);
14019
+ };
14020
+ return Array.isArray(value) ? value.map(one).join("; ") : one(value);
14021
+ }
14022
+ default:
14023
+ return value;
14024
+ }
14025
+ }
14026
+ async function columnsOf(cms, config, asked, options) {
14027
+ const rules = await cms.roles.fieldRules(options.user);
14028
+ const read2 = new FieldAccessChecker(
14029
+ "read",
14030
+ { user: options.user, context: options.context },
14031
+ rules
14032
+ );
14033
+ const readable3 = [];
14034
+ for (const field of config.fields)
14035
+ if (!field.hidden && field.name !== "password" && await read2.allows(field))
14036
+ readable3.push(field);
14037
+ const system2 = [...config.drafts ? ["status"] : [], "createdAt", "updatedAt"];
14038
+ const of = (key) => {
14039
+ const field = readable3.find((f) => f.name === key);
14040
+ if (field) return { key, label: field.label ?? key, field };
14041
+ return key in SYSTEM && (key !== "status" || config.drafts) ? { key, label: SYSTEM[key] } : void 0;
14042
+ };
14043
+ const keys2 = asked ?? [...readable3.map((f) => f.name), ...system2];
14044
+ const columns = keys2.filter((k) => k !== "id").map(of).filter((c) => c !== void 0);
14045
+ return [{ key: "id", label: SYSTEM.id }, ...columns];
14046
+ }
14047
+ async function exportCsv(cms, slug2, url, options) {
14048
+ const config = cms.collection(slug2);
14049
+ if (config.admin?.export === false)
14050
+ throw new ForbiddenError("Export is turned off for this collection");
14051
+ const q = url.searchParams;
14052
+ const lang = q.get("lang") ?? "en";
14053
+ const names = q.get("headers") === "names";
14054
+ const columnsParam = q.get("columns");
14055
+ const asked = columnsParam && columnsParam !== "all" ? columnsParam.split(",").filter(Boolean) : null;
14056
+ const ids = q.get("ids")?.split(",").filter(Boolean);
14057
+ const where = ids?.length ? { and: [...options.where ? [options.where] : [], { id: { in: ids } }] } : options.where;
14058
+ const read2 = {
14059
+ user: options.user,
14060
+ context: options.context,
14061
+ overrideAccess: false,
14062
+ draft: options.draft,
14063
+ ...options.locale ? { locale: options.locale } : {},
14064
+ ...options.fallbackLocale === false ? { fallbackLocale: false } : {}
14065
+ };
14066
+ const total = await cms.count(slug2, { ...read2, ...where ? { where } : {}, max: EXPORT_MAX });
14067
+ if (total > EXPORT_MAX)
14068
+ throw new QueryError(
14069
+ `More than ${EXPORT_MAX.toLocaleString("en")} documents match: filter the list further`
14070
+ );
14071
+ const columns = await columnsOf(cms, config, asked, options);
14072
+ const header2 = columns.map((c) => names ? c.key : text3(c.label, c.key, lang));
14073
+ await cms.audit.record({
14074
+ action: "export",
14075
+ target: slug2,
14076
+ user: options.user,
14077
+ detail: { rows: total, columns: columns.map((c) => c.key) }
14078
+ });
14079
+ const encoder = new TextEncoder();
14080
+ let cursor = "";
14081
+ const stream = new ReadableStream({
14082
+ start(controller) {
14083
+ controller.enqueue(encoder.encode(`\uFEFF${header2.map(csvCell).join(",")}\r
14084
+ `));
14085
+ },
14086
+ async pull(controller) {
14087
+ if (cursor === null) {
14088
+ controller.close();
14089
+ return;
14090
+ }
14091
+ try {
14092
+ const page = await cms.find(
14093
+ slug2,
14094
+ {
14095
+ ...read2,
14096
+ ...where ? { where } : {},
14097
+ ...options.sort?.length ? { sort: options.sort } : {},
14098
+ limit: STEP,
14099
+ depth: 1,
14100
+ cursor
14101
+ }
14102
+ );
14103
+ const lines = page.docs.map(
14104
+ (doc) => columns.map((c) => csvCell(cellOf(cms, c.field, doc[c.key], c.key, lang))).join(",")
14105
+ );
14106
+ if (lines.length) controller.enqueue(encoder.encode(`${lines.join("\r\n")}\r
14107
+ `));
14108
+ cursor = page.nextCursor ?? null;
14109
+ if (cursor === null) controller.close();
14110
+ } catch (error) {
14111
+ controller.error(error);
14112
+ }
14113
+ }
14114
+ });
14115
+ const day = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
14116
+ return new Response(stream, {
14117
+ headers: {
14118
+ "content-type": "text/csv; charset=utf-8",
14119
+ "content-disposition": `attachment; filename="${slug2}-${day}.csv"`,
14120
+ "cache-control": "no-store",
14121
+ "x-content-type-options": "nosniff"
14122
+ }
14123
+ });
14124
+ }
14125
+
13100
14126
  // src/rest/filter-options.ts
13101
14127
  function fieldAt2(fields, path) {
13102
14128
  let current = fields;
@@ -13138,12 +14164,12 @@ async function blockSummaries(cms, user, body) {
13138
14164
  const summary = field.blocks.find((b) => b.slug === blockType)?.admin?.summary;
13139
14165
  if (typeof id !== "string" || typeof summary !== "function") continue;
13140
14166
  try {
13141
- const text3 = await summary({
14167
+ const text4 = await summary({
13142
14168
  fields: fields && typeof fields === "object" ? fields : {},
13143
14169
  user,
13144
14170
  cms
13145
14171
  });
13146
- if (typeof text3 === "string") summaries[id] = text3;
14172
+ if (typeof text4 === "string") summaries[id] = text4;
13147
14173
  } catch (error) {
13148
14174
  cms.logger.warn(`admin.summary of block "${String(blockType)}" failed: ${String(error)}`);
13149
14175
  }
@@ -13252,6 +14278,191 @@ function parseDepth(url) {
13252
14278
  return depth === void 0 ? {} : { depth };
13253
14279
  }
13254
14280
 
14281
+ // src/rest/saved-views.ts
14282
+ var MAX_OWN_VIEWS = 50;
14283
+ var MAX_STATE = 1e4;
14284
+ var QUERY_KEY = /^(where\[and\]\[\d+\]\[[^\]]+\]\[[a-z_]+\]|q|sort|scheduled|type|calendar)$/;
14285
+ var userKey = (user) => String(user.id);
14286
+ var stamps = () => {
14287
+ const now = (/* @__PURE__ */ new Date()).toISOString();
14288
+ return { createdAt: now, updatedAt: now };
14289
+ };
14290
+ async function readable2(cms, slug2, who) {
14291
+ if (INTERNAL_COLLECTIONS.has(slug2) || !cms.config.collections.some((c) => c.slug === slug2))
14292
+ throw new NotFoundError("collections", slug2);
14293
+ try {
14294
+ await cms.count(slug2, { ...who, overrideAccess: false, where: { id: { exists: false } } });
14295
+ } catch {
14296
+ throw new ForbiddenError();
14297
+ }
14298
+ }
14299
+ function cleanState(raw) {
14300
+ const input = raw && typeof raw === "object" ? raw : {};
14301
+ const query = {};
14302
+ const rawQuery = input.query && typeof input.query === "object" ? input.query : {};
14303
+ for (const [key, value] of Object.entries(rawQuery))
14304
+ if (QUERY_KEY.test(key) && typeof value === "string") query[key] = value;
14305
+ const columns = Array.isArray(input.columns) ? input.columns.filter((c) => typeof c === "string").slice(0, 50) : void 0;
14306
+ const pageSize = [10, 25, 50, 100].includes(input.pageSize) ? input.pageSize : void 0;
14307
+ const density = input.density === "compact" || input.density === "comfortable" ? input.density : void 0;
14308
+ const state = {
14309
+ query,
14310
+ ...columns ? { columns } : {},
14311
+ ...pageSize ? { pageSize } : {},
14312
+ ...density ? { density } : {}
14313
+ };
14314
+ if (JSON.stringify(state).length > MAX_STATE)
14315
+ throw new ValidationError(SAVED_VIEWS, [{ field: "state", message: "is too large" }]);
14316
+ return state;
14317
+ }
14318
+ function toView(row, user) {
14319
+ const mine2 = row.user === userKey(user);
14320
+ return {
14321
+ id: row.id,
14322
+ name: String(row.name ?? ""),
14323
+ shared: row.shared === true,
14324
+ mine: mine2,
14325
+ editable: mine2 || row.shared === true && isSystemAdmin(user),
14326
+ state: cleanState(row.state)
14327
+ };
14328
+ }
14329
+ async function rows(cms, slug2, where) {
14330
+ const found = await cms.db.find({
14331
+ collection: SAVED_VIEWS,
14332
+ where: { and: [{ collection: { equals: slug2 } }, where] },
14333
+ sort: ["id"],
14334
+ limit: 0,
14335
+ page: 1
14336
+ });
14337
+ return found.docs;
14338
+ }
14339
+ async function listViews(cms, slug2, who) {
14340
+ await readable2(cms, slug2, who);
14341
+ const key = userKey(who.user);
14342
+ const found = await rows(cms, slug2, {
14343
+ or: [
14344
+ { and: [{ kind: { equals: "view" } }, { shared: { equals: true } }] },
14345
+ { user: { equals: key } }
14346
+ ]
14347
+ });
14348
+ const views = found.filter((r) => r.kind === "view").map((r) => toView(r, who.user));
14349
+ const preference = found.find((r) => r.kind === "default" && r.user === key);
14350
+ return {
14351
+ views: [...views.filter((v) => v.shared), ...views.filter((v) => !v.shared)],
14352
+ default: typeof preference?.target === "string" ? preference.target : null
14353
+ };
14354
+ }
14355
+ function checkName(name) {
14356
+ const trimmed = typeof name === "string" ? name.trim() : "";
14357
+ if (trimmed === "" || trimmed.length > 60)
14358
+ throw new ValidationError(SAVED_VIEWS, [
14359
+ { field: "name", message: "must be 1 to 60 characters" }
14360
+ ]);
14361
+ return trimmed;
14362
+ }
14363
+ async function checkUnique(cms, slug2, who, name, shared, self) {
14364
+ const same = await rows(cms, slug2, {
14365
+ and: [
14366
+ { kind: { equals: "view" } },
14367
+ { name: { equals: name } },
14368
+ shared ? { shared: { equals: true } } : { user: { equals: userKey(who.user) } }
14369
+ ]
14370
+ });
14371
+ if (same.some((r) => r.id !== self && (shared || r.shared !== true)))
14372
+ throw new ValidationError(SAVED_VIEWS, [
14373
+ { field: "name", message: `there is already a view named "${name}"` }
14374
+ ]);
14375
+ }
14376
+ async function createView(cms, slug2, who, body) {
14377
+ await readable2(cms, slug2, who);
14378
+ const input = body ?? {};
14379
+ const name = checkName(input.name);
14380
+ const shared = input.shared === true;
14381
+ await checkUnique(cms, slug2, who, name, shared);
14382
+ const own = await rows(cms, slug2, {
14383
+ and: [{ kind: { equals: "view" } }, { user: { equals: userKey(who.user) } }]
14384
+ });
14385
+ if (own.length >= MAX_OWN_VIEWS)
14386
+ throw new ValidationError(SAVED_VIEWS, [
14387
+ { field: "name", message: `at most ${MAX_OWN_VIEWS} views per list` }
14388
+ ]);
14389
+ const row = await cms.db.create({
14390
+ collection: SAVED_VIEWS,
14391
+ data: {
14392
+ collection: slug2,
14393
+ user: userKey(who.user),
14394
+ kind: "view",
14395
+ name,
14396
+ shared,
14397
+ state: cleanState(input.state),
14398
+ target: null,
14399
+ ...stamps()
14400
+ }
14401
+ });
14402
+ return toView(row, who.user);
14403
+ }
14404
+ async function editable(cms, slug2, who, id) {
14405
+ await readable2(cms, slug2, who);
14406
+ const row = await cms.db.findById({ collection: SAVED_VIEWS, id });
14407
+ if (!row || row.collection !== slug2 || row.kind !== "view")
14408
+ throw new NotFoundError(SAVED_VIEWS, id);
14409
+ const view = toView(row, who.user);
14410
+ if (!view.mine && !view.shared) throw new NotFoundError(SAVED_VIEWS, id);
14411
+ if (!view.editable) throw new ForbiddenError("Only its owner or an admin can change this view");
14412
+ return row;
14413
+ }
14414
+ async function updateView(cms, slug2, who, id, body) {
14415
+ const row = await editable(cms, slug2, who, id);
14416
+ const input = body ?? {};
14417
+ const name = input.name === void 0 ? String(row.name) : checkName(input.name);
14418
+ const shared = input.shared === void 0 ? row.shared === true : input.shared === true;
14419
+ if (name !== row.name || shared !== (row.shared === true))
14420
+ await checkUnique(cms, slug2, who, name, shared, id);
14421
+ const updated = await cms.db.update({
14422
+ collection: SAVED_VIEWS,
14423
+ id,
14424
+ // The whole row, as the adapter writes it.
14425
+ data: {
14426
+ ...row,
14427
+ name,
14428
+ shared,
14429
+ ...input.state !== void 0 ? { state: cleanState(input.state) } : {},
14430
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
14431
+ }
14432
+ });
14433
+ return toView(updated, who.user);
14434
+ }
14435
+ async function deleteView(cms, slug2, who, id) {
14436
+ await editable(cms, slug2, who, id);
14437
+ await cms.db.delete({ collection: SAVED_VIEWS, id });
14438
+ return { deleted: 1 };
14439
+ }
14440
+ async function setDefaultView(cms, slug2, who, body) {
14441
+ await readable2(cms, slug2, who);
14442
+ const raw = body ?? {};
14443
+ const target = raw.view === null || raw.view === void 0 ? null : String(raw.view);
14444
+ const key = userKey(who.user);
14445
+ const current = await rows(cms, slug2, {
14446
+ and: [{ kind: { equals: "default" } }, { user: { equals: key } }]
14447
+ });
14448
+ for (const row of current) await cms.db.delete({ collection: SAVED_VIEWS, id: row.id });
14449
+ if (target !== null)
14450
+ await cms.db.create({
14451
+ collection: SAVED_VIEWS,
14452
+ data: {
14453
+ collection: slug2,
14454
+ user: key,
14455
+ kind: "default",
14456
+ name: null,
14457
+ shared: false,
14458
+ state: null,
14459
+ target,
14460
+ ...stamps()
14461
+ }
14462
+ });
14463
+ return { default: target };
14464
+ }
14465
+
13255
14466
  // src/rest/handler.ts
13256
14467
  var SCHEDULED_RANGE_LIMIT = 500;
13257
14468
  var CSRF_COOKIE = "ecms-csrf";
@@ -13443,7 +14654,8 @@ var ADMIN_UI_ROUTES = /* @__PURE__ */ new Set([
13443
14654
  "media-usage",
13444
14655
  "owned",
13445
14656
  "sso",
13446
- "block-summaries"
14657
+ "block-summaries",
14658
+ "views"
13447
14659
  ]);
13448
14660
  var CORS_METHODS = "GET, HEAD, POST, PATCH, PUT, DELETE";
13449
14661
  var CORS_HEADERS = `authorization, content-type, ${CSRF_HEADER}`;
@@ -13820,6 +15032,27 @@ async function route(cms, ctx, method, path, options) {
13820
15032
  if (parsed === void 0) throw new HttpError("Not found", 404);
13821
15033
  return { body: await cms.roles.owned(parsed) };
13822
15034
  }
15035
+ if (first === "admin" && second === "views" && third !== void 0 && segments.length <= 4) {
15036
+ if (!ctx.user || ctx.user.apiKey) throw new UnauthorizedError();
15037
+ const who = { user: ctx.user, context: ctx.context };
15038
+ const fourth = segments[3];
15039
+ if (fourth === void 0) {
15040
+ if (method === "GET") return { body: await listViews(cms, third, who) };
15041
+ if (method === "POST")
15042
+ return { status: 201, body: await createView(cms, third, who, await readJson(ctx.request)) };
15043
+ throw methodNotAllowed(ctx, "GET, POST");
15044
+ }
15045
+ if (fourth === "default") {
15046
+ if (method !== "PUT") throw methodNotAllowed(ctx, "PUT");
15047
+ return { body: await setDefaultView(cms, third, who, await readJson(ctx.request)) };
15048
+ }
15049
+ const id2 = parseId(fourth);
15050
+ if (id2 === void 0) throw new HttpError("Not found", 404);
15051
+ if (method === "PATCH")
15052
+ return { body: await updateView(cms, third, who, id2, await readJson(ctx.request)) };
15053
+ if (method === "DELETE") return { body: await deleteView(cms, third, who, id2) };
15054
+ throw methodNotAllowed(ctx, "PATCH, DELETE");
15055
+ }
13823
15056
  if (first === "admin" && second === "block-summaries" && third === void 0) {
13824
15057
  if (!ctx.user) throw new UnauthorizedError();
13825
15058
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
@@ -13864,6 +15097,91 @@ async function route(cms, ctx, method, path, options) {
13864
15097
  }
13865
15098
  throw methodNotAllowed(ctx, "PATCH, DELETE");
13866
15099
  }
15100
+ if (first === "admin" && second === "integrations") {
15101
+ if (!ctx.user) throw new UnauthorizedError();
15102
+ if (cms.config.integrations.length === 0) throw new HttpError("Not found", 404);
15103
+ if (!await cms.roles.canView(ctx.user, "integrations")) throw new ForbiddenError();
15104
+ const user = ctx.user;
15105
+ const [, , id2, part, cid, action, extra] = segments;
15106
+ const integrations = cms.integrations;
15107
+ if (id2 === void 0) {
15108
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
15109
+ return { body: await integrations.overview() };
15110
+ }
15111
+ const callbackUrl = `${cms.config.serverURL ?? ctx.url.origin}${cms.config.routes.api}/admin/integrations/${id2}/callback`;
15112
+ if (part === void 0) {
15113
+ if (method !== "PUT") throw methodNotAllowed(ctx, "PUT");
15114
+ const body = await readJson(ctx.request);
15115
+ return { body: await integrations.setEnabled(id2, body.enabled !== false, user) };
15116
+ }
15117
+ if (part === "authorize" && cid === void 0) {
15118
+ if (method !== "POST") throw methodNotAllowed(ctx, "POST");
15119
+ const body = await readJson(ctx.request);
15120
+ return { body: integrations.authorize(id2, user, callbackUrl, String(body.name ?? "")) };
15121
+ }
15122
+ if (part === "callback" && cid === void 0) {
15123
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
15124
+ const page = new URL(`${cms.config.admin.path}/integrations`, ctx.url.origin);
15125
+ page.searchParams.set("integration", id2);
15126
+ try {
15127
+ const error = ctx.url.searchParams.get("error_description") ?? ctx.url.searchParams.get("error");
15128
+ if (error) throw new Error(error);
15129
+ const result = await integrations.callback(
15130
+ id2,
15131
+ ctx.url.searchParams.get("code") ?? "",
15132
+ ctx.url.searchParams.get("state") ?? "",
15133
+ callbackUrl,
15134
+ user
15135
+ );
15136
+ if ("pending" in result) page.searchParams.set("pending", String(result.pending));
15137
+ else page.searchParams.set("connected", "1");
15138
+ } catch (error) {
15139
+ page.searchParams.set("error", error.message.slice(0, 300));
15140
+ }
15141
+ return { body: new Response(null, { status: 302, headers: { location: page.toString() } }) };
15142
+ }
15143
+ if (part === "lookup" && cid !== void 0 && action === void 0) {
15144
+ if (method !== "POST") throw methodNotAllowed(ctx, "POST");
15145
+ return { body: await integrations.lookup(id2, cid, await readJson(ctx.request)) };
15146
+ }
15147
+ if (part === "pending" && cid !== void 0 && action === void 0) {
15148
+ const pid = parseId(cid);
15149
+ if (pid === void 0) throw new HttpError("Not found", 404);
15150
+ if (method === "GET") return { body: await integrations.pending(id2, pid, user) };
15151
+ if (method === "POST") {
15152
+ const body = await readJson(ctx.request);
15153
+ return {
15154
+ status: 201,
15155
+ body: await integrations.choose(id2, pid, String(body.account ?? ""), user)
15156
+ };
15157
+ }
15158
+ throw methodNotAllowed(ctx, "GET, POST");
15159
+ }
15160
+ if (part === "connections" && extra === void 0) {
15161
+ if (cid === void 0) {
15162
+ if (method !== "POST") throw methodNotAllowed(ctx, "POST");
15163
+ return {
15164
+ status: 201,
15165
+ body: await integrations.connect(id2, await readJson(ctx.request), user)
15166
+ };
15167
+ }
15168
+ if (action === "test") {
15169
+ if (method !== "POST") throw methodNotAllowed(ctx, "POST");
15170
+ const target = cid === "env" ? "env" : parseId(cid);
15171
+ if (target === void 0) throw new HttpError("Not found", 404);
15172
+ return { body: await integrations.test(id2, target) };
15173
+ }
15174
+ const connection = parseId(cid);
15175
+ if (connection === void 0 || action !== void 0) throw new HttpError("Not found", 404);
15176
+ if (method === "PATCH")
15177
+ return {
15178
+ body: await integrations.update(id2, connection, await readJson(ctx.request), user)
15179
+ };
15180
+ if (method === "DELETE") return { body: await integrations.disconnect(id2, connection, user) };
15181
+ throw methodNotAllowed(ctx, "PATCH, DELETE");
15182
+ }
15183
+ throw new HttpError("Not found", 404);
15184
+ }
13867
15185
  if (first === "admin" && second === "deliveries") {
13868
15186
  if (!ctx.user) throw new UnauthorizedError();
13869
15187
  if (!await cms.roles.canView(ctx.user, "deliveries")) throw new ForbiddenError();
@@ -14166,6 +15484,21 @@ async function route(cms, ctx, method, path, options) {
14166
15484
  if (INTERNAL_COLLECTIONS.has(collection2) || !cms.config.collections.some((c) => c.slug === collection2)) {
14167
15485
  throw new HttpError(`Unknown collection "${collection2}"`, 404);
14168
15486
  }
15487
+ if (second === "export.csv" && third === void 0) {
15488
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
15489
+ if (!ctx.user) throw new UnauthorizedError();
15490
+ const { where, sort } = parseListQuery(ctx.url);
15491
+ return {
15492
+ body: await exportCsv(cms, collection2, ctx.url, {
15493
+ user: ctx.user,
15494
+ context: ctx.context,
15495
+ ...parseLocale(ctx.url),
15496
+ draft,
15497
+ ...where ? { where } : {},
15498
+ ...sort ? { sort } : {}
15499
+ })
15500
+ };
15501
+ }
14169
15502
  if (second === "bulk") return bulkRoute(cms, ctx, method, collection2, segments.slice(2), access);
14170
15503
  if (second !== void 0 && third !== void 0) {
14171
15504
  return documentAction(cms, ctx, method, collection2, second, segments.slice(2));
@@ -14623,10 +15956,10 @@ async function readJson(request) {
14623
15956
  if (!type.toLowerCase().startsWith("application/json")) {
14624
15957
  throw new HttpError("Content-Type must be application/json", 415);
14625
15958
  }
14626
- const text3 = new TextDecoder().decode(await readBody(request, MAX_BODY_BYTES));
15959
+ const text4 = new TextDecoder().decode(await readBody(request, MAX_BODY_BYTES));
14627
15960
  let body;
14628
15961
  try {
14629
- body = JSON.parse(text3);
15962
+ body = JSON.parse(text4);
14630
15963
  } catch {
14631
15964
  throw new HttpError("Request body is not valid JSON", 400);
14632
15965
  }