@easy-cms/core 1.31.0 → 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
  }
@@ -162,6 +163,7 @@ var OUTBOX = "outbox";
162
163
  var RATE_LIMITS = "rate-limits";
163
164
  var BULK_OPERATIONS = "bulk-operations";
164
165
  var SAVED_VIEWS = "saved-views";
166
+ var CONNECTIONS = "connections";
165
167
  var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
166
168
  SESSIONS,
167
169
  LOGIN_ATTEMPTS,
@@ -177,7 +179,8 @@ var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
177
179
  OUTBOX,
178
180
  RATE_LIMITS,
179
181
  BULK_OPERATIONS,
180
- SAVED_VIEWS
182
+ SAVED_VIEWS,
183
+ CONNECTIONS
181
184
  ]);
182
185
  var DEFAULT_ROLES = ["admin", "editor"];
183
186
  var CREATED_BY_FIELD = {
@@ -581,6 +584,24 @@ var savedViewsCollection = {
581
584
  { name: "target", type: "text" }
582
585
  ]
583
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
+ };
584
605
  var webhookDeliveriesCollection = {
585
606
  slug: WEBHOOK_DELIVERIES,
586
607
  access: { read: nobody, create: nobody, update: nobody, delete: nobody },
@@ -3023,6 +3044,8 @@ var Mailer = class {
3023
3044
  logger;
3024
3045
  queue;
3025
3046
  pending = /* @__PURE__ */ new Set();
3047
+ /** Told when an email is given up on (`delivery.failed`). */
3048
+ onGiveUp;
3026
3049
  get configured() {
3027
3050
  return this.adapter !== void 0;
3028
3051
  }
@@ -3080,6 +3103,8 @@ var Mailer = class {
3080
3103
  if (delay === void 0) {
3081
3104
  failed++;
3082
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 });
3083
3108
  }
3084
3109
  await this.queue.update(id, {
3085
3110
  ...queued,
@@ -3559,11 +3584,13 @@ async function runBackup(cms, record) {
3559
3584
  });
3560
3585
  cms.logger.info(`Backup ${key} written (${data.byteLength} bytes)`);
3561
3586
  } catch (error) {
3587
+ const message2 = (error.message || String(error)).slice(0, 500);
3562
3588
  await update(cms, row, {
3563
3589
  state: "failed",
3564
- error: (error.message || String(error)).slice(0, 500),
3590
+ error: message2,
3565
3591
  finishedAt: (/* @__PURE__ */ new Date()).toISOString()
3566
3592
  });
3593
+ cms.systemEvent("backup.failed", { backup: row.id, error: message2 });
3567
3594
  throw error;
3568
3595
  } finally {
3569
3596
  await rm2(dir, { recursive: true, force: true });
@@ -4309,7 +4336,7 @@ var VersionStore = class {
4309
4336
  };
4310
4337
 
4311
4338
  // src/local-api.ts
4312
- 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";
4313
4340
 
4314
4341
  // src/auth/common-passwords.ts
4315
4342
  var LIST = `
@@ -4826,6 +4853,649 @@ function toOperation(row) {
4826
4853
  };
4827
4854
  }
4828
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
+
4829
5499
  // src/localization.ts
4830
5500
  var ALL_LOCALES = "all";
4831
5501
  var isPlainObject2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
@@ -5480,7 +6150,7 @@ function read(response, maxBytes, deadline) {
5480
6150
 
5481
6151
  // src/config-keys.ts
5482
6152
  var keys = (list) => new Set(list.split(/\s+/).filter(Boolean));
5483
- 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
5484
6154
  upload auth collections globals endpoints cliCommands onRequest fieldTypes apiKeys email backups
5485
6155
  audit logs countCacheSeconds maxPage httpCache outbox rateLimit search telemetry plugins`);
5486
6156
  var ADMIN = keys("path locale brand siteURL nav commands modules pages dashboard switcher");
@@ -5663,7 +6333,8 @@ var BUILTIN_NAV = [
5663
6333
  children: [
5664
6334
  { id: "site", label: { en: "Site", th: "\u0E40\u0E27\u0E47\u0E1A\u0E44\u0E0B\u0E15\u0E4C" }, icon: "globe" },
5665
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" },
5666
- { 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" }
5667
6338
  ]
5668
6339
  }
5669
6340
  ];
@@ -6016,6 +6687,12 @@ function verifyWebhook(body, headers, secret, options = {}) {
6016
6687
  }
6017
6688
 
6018
6689
  // src/webhooks.ts
6690
+ var SYSTEM_EVENTS = [
6691
+ "scheduled.failed",
6692
+ "delivery.failed",
6693
+ "backup.failed",
6694
+ "integration.failed"
6695
+ ];
6019
6696
  var WEBHOOK_EVENTS = [
6020
6697
  "create",
6021
6698
  "update",
@@ -6207,10 +6884,13 @@ var Webhooks = class {
6207
6884
  return { ok: false, error: error.message };
6208
6885
  }
6209
6886
  }
6887
+ /** Told when a delivery is given up on (`delivery.failed`). */
6888
+ onGiveUp;
6210
6889
  giveUp(hook, event, error, attempts) {
6211
6890
  this.logger.error(
6212
6891
  `Webhook ${hook.url} failed for ${event} after ${attempts} attempts: ${error}`
6213
6892
  );
6893
+ this.onGiveUp?.({ url: hook.url, event, error, attempts });
6214
6894
  }
6215
6895
  };
6216
6896
  var later4 = (from, ms) => new Date(from.getTime() + ms).toISOString();
@@ -6299,6 +6979,8 @@ function validateConfig(config) {
6299
6979
  validateEndpoints(config, collectionSlugs, add);
6300
6980
  validateCommands(config, add);
6301
6981
  validateJobs(config, add);
6982
+ validateIntegrations(config, add);
6983
+ validateListeners(config, add);
6302
6984
  validateTimeZone(config, add);
6303
6985
  validateEvents(config, add);
6304
6986
  const apiKeys = config.apiKeys;
@@ -6826,7 +7508,7 @@ function validateWebhooks(config, add) {
6826
7508
  ...(config.collections ?? []).map((c) => c?.slug)
6827
7509
  ]);
6828
7510
  const globals = new Set((config.globals ?? []).map((g) => g?.slug));
6829
- const custom = new Set(config.events ?? []);
7511
+ const custom = /* @__PURE__ */ new Set([...config.events ?? [], ...SYSTEM_EVENTS]);
6830
7512
  for (const [i, hook] of hooks.entries()) {
6831
7513
  const path = `webhooks[${i}]`;
6832
7514
  if (typeof hook?.url !== "string" || !/^https?:\/\/\S+$/.test(hook.url)) {
@@ -7916,6 +8598,56 @@ function validateAdapterVersions(config, add) {
7916
8598
  );
7917
8599
  }
7918
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
+ }
7919
8651
 
7920
8652
  // src/resolve-config.ts
7921
8653
  var DEFAULT_ADMIN_PATH = "/admin";
@@ -8026,14 +8758,17 @@ async function resolveConfig(input) {
8026
8758
  databaseBackupsCollection,
8027
8759
  bulkOperationsCollection,
8028
8760
  savedViewsCollection,
8761
+ ...resolvedIntegrations(config).length > 0 ? [connectionsCollection] : [],
8029
8762
  ...config.auth?.rbac ? [rolesCollection] : [],
8030
8763
  ...(config.auth?.providers?.length ?? 0) > 0 ? [userIdentitiesCollection] : [],
8031
8764
  ...config.audit ? [auditLogsCollection] : []
8032
8765
  ],
8033
8766
  globals: config.globals ?? [],
8034
8767
  endpoints: config.endpoints ?? [],
8768
+ integrations: resolvedIntegrations(config),
8035
8769
  cliCommands: config.cliCommands ?? [],
8036
- events: config.events ?? [],
8770
+ // Easy CMS's own events (#356), for webhooks and `cms.on`.
8771
+ events: [.../* @__PURE__ */ new Set([...config.events ?? [], ...SYSTEM_EVENTS])],
8037
8772
  jobs: config.jobs ?? [],
8038
8773
  warnings,
8039
8774
  installedPlugins: (plugins ?? []).map(
@@ -8109,9 +8844,12 @@ function resolveAudit(audit) {
8109
8844
  scope: audit.scope ?? false
8110
8845
  };
8111
8846
  }
8847
+ function resolvedIntegrations(config) {
8848
+ return config.integrations ?? [];
8849
+ }
8112
8850
 
8113
8851
  // src/rest/http-cache.ts
8114
- import { createHash as createHash4 } from "crypto";
8852
+ import { createHash as createHash5 } from "crypto";
8115
8853
  var cacheTags = {
8116
8854
  collection: (slug2) => ["ecms", `ecms:${slug2}`],
8117
8855
  document: (slug2, id) => ["ecms", `ecms:${slug2}`, `ecms:${slug2}:${id}`],
@@ -8138,7 +8876,7 @@ function cacheableResponse(request, body, status, headers, times, tags) {
8138
8876
  );
8139
8877
  headers.set("cache-tag", tags.join(","));
8140
8878
  headers.set("surrogate-key", tags.join(" "));
8141
- const etag = `W/"${createHash4("sha1").update(body).digest("base64url")}"`;
8879
+ const etag = `W/"${createHash5("sha1").update(body).digest("base64url")}"`;
8142
8880
  headers.set("etag", etag);
8143
8881
  if (matches2(request.headers.get("if-none-match"), etag))
8144
8882
  return new Response(null, { status: 304, headers });
@@ -8146,7 +8884,7 @@ function cacheableResponse(request, body, status, headers, times, tags) {
8146
8884
  }
8147
8885
 
8148
8886
  // src/version.ts
8149
- var VERSION = true ? "1.31.0" : "0.0.0-dev";
8887
+ var VERSION = true ? "1.32.0" : "0.0.0-dev";
8150
8888
 
8151
8889
  // src/telemetry.ts
8152
8890
  var DB_CALLS = /* @__PURE__ */ new Set([
@@ -8423,6 +9161,8 @@ var EasyCMS = class {
8423
9161
  audit;
8424
9162
  /** Folders of the media library and their permissions (`upload.folders`). */
8425
9163
  folders;
9164
+ /** Accounts connected to plugins' integrations (`integrations`, Settings → Integrations). */
9165
+ integrations;
8426
9166
  versions;
8427
9167
  webhooks;
8428
9168
  mailer;
@@ -8450,6 +9190,12 @@ var EasyCMS = class {
8450
9190
  (entry) => this.audit.record(entry)
8451
9191
  );
8452
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
+ }
8453
9199
  this.folders = new MediaFolders(config, db, this.roles);
8454
9200
  this.webhooks = new Webhooks(
8455
9201
  config.webhooks ?? [],
@@ -8461,6 +9207,8 @@ var EasyCMS = class {
8461
9207
  logger,
8462
9208
  config.collections.some((c) => c.slug === EMAIL_DELIVERIES) ? this.emailQueue() : void 0
8463
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 });
8464
9212
  this.outbox = config.collections.some((c) => c.slug === OUTBOX) ? new Outbox(
8465
9213
  db,
8466
9214
  logger,
@@ -8653,7 +9401,7 @@ var EasyCMS = class {
8653
9401
  }
8654
9402
  ]);
8655
9403
  }
8656
- const random2 = randomBytes6(4).toString("hex");
9404
+ const random2 = randomBytes7(4).toString("hex");
8657
9405
  const isPrivate = await this.folders.isPrivate(folderOf(MEDIA, data));
8658
9406
  const storage = this.storageFor(isPrivate);
8659
9407
  const filename = withPrivacy(storageKey(file.name, mimeType, random2), isPrivate);
@@ -8721,7 +9469,7 @@ var EasyCMS = class {
8721
9469
  ]);
8722
9470
  const isPrivate = await this.folders.isPrivate(folder);
8723
9471
  const storage = this.storageFor(isPrivate);
8724
- const key = withPrivacy(storageKey(name, type, randomBytes6(4).toString("hex")), isPrivate);
9472
+ const key = withPrivacy(storageKey(name, type, randomBytes7(4).toString("hex")), isPrivate);
8725
9473
  const upload = storage.uploadURL ? await storage.uploadURL(key, { contentType: type, size, expiresIn: UPLOAD_URL_SECONDS }) : null;
8726
9474
  const ticket = this.sign("upload", {
8727
9475
  key,
@@ -10125,6 +10873,37 @@ var EasyCMS = class {
10125
10873
  if (!this.config.events.includes(event))
10126
10874
  throw new QueryError(`Unknown event "${event}": add it to \`events\` in the config`);
10127
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);
10128
10907
  }
10129
10908
  /**
10130
10909
  * Tries webhook deliveries that are due for another attempt. Deliveries are kept in the
@@ -10283,6 +11062,13 @@ var EasyCMS = class {
10283
11062
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
10284
11063
  }
10285
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
+ });
10286
11072
  }
10287
11073
  }
10288
11074
  return { ran, failed };
@@ -10911,8 +11697,10 @@ var EasyCMS = class {
10911
11697
  * are logged.
10912
11698
  */
10913
11699
  emitChange(config, event, doc, before) {
10914
- for (const change of this.changeEvents(config, event, doc, before))
11700
+ for (const change of this.changeEvents(config, event, doc, before)) {
10915
11701
  this.webhooks.emit(change.event, change.target, change.body);
11702
+ this.dispatch(change.event, change.body, change.target);
11703
+ }
10916
11704
  if (this.searches(config, event)) this.searchIndex?.syncLater(config.slug, doc.id);
10917
11705
  const purge = this.config.httpCache?.purge;
10918
11706
  const tags = this.purgeTags(config, event, doc, before);
@@ -10962,8 +11750,10 @@ var EasyCMS = class {
10962
11750
  }
10963
11751
  if (!done.has("webhooks")) {
10964
11752
  const changes = this.changeEvents(config, event, doc, entry.before);
10965
- for (const change of changes)
11753
+ for (const change of changes) {
10966
11754
  await this.webhooks.emitSaved(change.event, change.target, change.body);
11755
+ this.dispatch(change.event, change.body, change.target);
11756
+ }
10967
11757
  if (changes.length > 0 && (this.config.webhooks?.length ?? 0) > 0) await mark("webhooks");
10968
11758
  }
10969
11759
  if (!done.has("search") && this.searchIndex && this.searches(config, event)) {
@@ -11454,7 +12244,7 @@ function durationSeconds(value) {
11454
12244
  }
11455
12245
  function cursorQuery(collection2, where, sort, locale, draft) {
11456
12246
  const key = JSON.stringify([collection2, where ?? null, sort, locale ?? null, draft]);
11457
- return createHash5("sha256").update(key).digest("base64url").slice(0, 16);
12247
+ return createHash6("sha256").update(key).digest("base64url").slice(0, 16);
11458
12248
  }
11459
12249
  function writeCursor(keys2, query) {
11460
12250
  return Buffer.from(JSON.stringify({ k: keys2, q: query })).toString("base64url");
@@ -11764,7 +12554,8 @@ var Roles = class {
11764
12554
  async canView(user, id) {
11765
12555
  if (isSystemAdmin(user)) return true;
11766
12556
  if (user.apiKey) return false;
11767
- if ((id === "deliveries" || id === "jobs") && user.scoped === true) return false;
12557
+ if ((id === "deliveries" || id === "jobs" || id === "integrations") && user.scoped === true)
12558
+ return false;
11768
12559
  if (id === "audit" && user.role === "admin" && user.scoped === true && this.config.audit !== false && typeof this.config.audit.scope === "function")
11769
12560
  return true;
11770
12561
  if (!this.enabled)
@@ -12052,6 +12843,7 @@ var Roles = class {
12052
12843
  (c) => c.slug === WEBHOOK_DELIVERIES || c.slug === EMAIL_DELIVERIES
12053
12844
  ) ? [{ id: "deliveries" }] : [],
12054
12845
  ...hasJobsPage(this.config) ? [{ id: "jobs" }] : [],
12846
+ ...this.config.integrations.length > 0 ? [{ id: "integrations" }] : [],
12055
12847
  ...hasCalendarPage(this.config) ? [{ id: "calendar" }] : [],
12056
12848
  ...this.config.admin.pages.map((page) => ({ id: viewId.page(page.path), label: page.label })),
12057
12849
  ...this.config.admin.dashboard.map((widget) => ({
@@ -12254,7 +13046,7 @@ function rank(role, config) {
12254
13046
  }
12255
13047
 
12256
13048
  // src/rest/admin-modules.ts
12257
- import { createHash as createHash6 } from "crypto";
13049
+ import { createHash as createHash7 } from "crypto";
12258
13050
  import { readFile as readFile3 } from "fs/promises";
12259
13051
  import { createRequire } from "module";
12260
13052
  import { join as join3 } from "path";
@@ -12295,7 +13087,7 @@ async function readAdminModule(cms, index) {
12295
13087
  if (!file) return void 0;
12296
13088
  const body = await readFile3(file, "utf8").catch(() => void 0);
12297
13089
  if (body === void 0) return void 0;
12298
- return { body, etag: `"${createHash6("sha1").update(body).digest("base64url")}"` };
13090
+ return { body, etag: `"${createHash7("sha1").update(body).digest("base64url")}"` };
12299
13091
  }
12300
13092
 
12301
13093
  // src/rest/handler.ts
@@ -12750,7 +13542,8 @@ async function adminSchema(cms, user, origin, context = {}) {
12750
13542
  roles: admin && cms.roles.enabled,
12751
13543
  sso: admin && cms.auth.sso.enabled,
12752
13544
  audit: cms.audit.enabled && await cms.roles.canView(user, "audit"),
12753
- 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")
12754
13547
  };
12755
13548
  const roles = await cms.roles.options();
12756
13549
  const adminCollections = await Promise.all(
@@ -12890,7 +13683,8 @@ var VIEW_GROUPS = [
12890
13683
  ["email", "settings.system"],
12891
13684
  ["deliveries", "settings.system"],
12892
13685
  ["jobs", "settings.system"],
12893
- ["audit", "settings.system"]
13686
+ ["audit", "settings.system"],
13687
+ ["integrations", "settings.integrations"]
12894
13688
  ];
12895
13689
  function buildNav(cms, collections, globals, pageRefs, views) {
12896
13690
  const groups = navGroups(cms.config.admin.nav);
@@ -13123,6 +13917,15 @@ async function adminStatus(cms) {
13123
13917
  if (failed.length > 0) attention.push({ id: "jobs", count: failed.length, names: failed });
13124
13918
  const paused = await pausedJobs(cms);
13125
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
+ }
13126
13929
  const backups = await backupAttention(cms);
13127
13930
  if (backups) attention.push(backups);
13128
13931
  attention.push(...await cms.audit.attention());
@@ -14294,6 +15097,91 @@ async function route(cms, ctx, method, path, options) {
14294
15097
  }
14295
15098
  throw methodNotAllowed(ctx, "PATCH, DELETE");
14296
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
+ }
14297
15185
  if (first === "admin" && second === "deliveries") {
14298
15186
  if (!ctx.user) throw new UnauthorizedError();
14299
15187
  if (!await cms.roles.canView(ctx.user, "deliveries")) throw new ForbiddenError();