@easy-cms/core 1.14.0 → 1.15.1

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.
@@ -35,7 +35,7 @@ import {
35
35
  warnDeprecated,
36
36
  withCharset,
37
37
  withPrivacy
38
- } from "./chunk-RTKWMVAC.js";
38
+ } from "./chunk-SKGKGKUN.js";
39
39
 
40
40
  // src/access.ts
41
41
  var isSystemAdmin = (user) => user?.role === "admin" && !user.apiKey && user.scoped !== true;
@@ -2314,7 +2314,10 @@ var ADAPTER_API_VERSION = 1;
2314
2314
 
2315
2315
  // src/document.ts
2316
2316
  import { randomUUID } from "crypto";
2317
- var EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
2317
+ function isEmail(value) {
2318
+ const at = value.indexOf("@");
2319
+ return at > 0 && !value.includes("@", at + 1) && !/\s/.test(value) && value.slice(at + 2, -1).includes(".");
2320
+ }
2318
2321
  var isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2319
2322
  function itemsOf(field, n) {
2320
2323
  const unit = field.type === "upload" ? "file" : field.type === "relationship" ? "document" : "row";
@@ -2522,8 +2525,7 @@ async function normalizeValue(field, raw, path, options, fail, errors, reference
2522
2525
  return raw;
2523
2526
  }
2524
2527
  case "email": {
2525
- if (typeof raw !== "string" || !EMAIL_PATTERN.test(raw))
2526
- return fail("must be a valid email address");
2528
+ if (typeof raw !== "string" || !isEmail(raw)) return fail("must be a valid email address");
2527
2529
  return raw.trim().toLowerCase();
2528
2530
  }
2529
2531
  case "number": {
@@ -3377,6 +3379,10 @@ function errorFields(error) {
3377
3379
  return { name: error.name, message: error.message, stack: error.stack };
3378
3380
  return { message: String(error) };
3379
3381
  }
3382
+ var traceIds;
3383
+ function setTraceIds(read2) {
3384
+ traceIds = read2;
3385
+ }
3380
3386
  function jsonLogger(write = (line, level) => (level === "info" ? process.stdout : process.stderr).write(`${line}
3381
3387
  `)) {
3382
3388
  const log = (level) => (message2, details) => {
@@ -3389,6 +3395,7 @@ function jsonLogger(write = (line, level) => (level === "info" ? process.stdout
3389
3395
  message: message2,
3390
3396
  ...store2?.requestId ? { requestId: store2.requestId } : {},
3391
3397
  ...store2?.runId ? { runId: store2.runId } : {},
3398
+ ...traceIds?.(),
3392
3399
  ...rest,
3393
3400
  ...error === void 0 ? {} : { error: errorFields(error) }
3394
3401
  };
@@ -3730,6 +3737,7 @@ async function populate(load, collections, fields, docs, depth) {
3730
3737
  }
3731
3738
  function collectSlots(fields, data, bySlug, slots) {
3732
3739
  for (const field of fields) {
3740
+ if (field.name === "__proto__") continue;
3733
3741
  const value = data[field.name];
3734
3742
  if (value === null || value === void 0) continue;
3735
3743
  if (field.type === "group") {
@@ -4435,7 +4443,10 @@ for (const [net, prefix] of [
4435
4443
  ["::1", 128],
4436
4444
  ["64:ff9b:1::", 48],
4437
4445
  ["100::", 64],
4446
+ // Teredo and 6to4 tunnel to an IPv4 address the IPv6 one carries: any of them, private too.
4447
+ ["2001::", 32],
4438
4448
  ["2001:db8::", 32],
4449
+ ["2002::", 16],
4439
4450
  ["fc00::", 7],
4440
4451
  ["fe80::", 10],
4441
4452
  ["ff00::", 8]
@@ -4637,7 +4648,7 @@ function read(response, maxBytes, deadline) {
4637
4648
  var keys = (list) => new Set(list.split(/\s+/).filter(Boolean));
4638
4649
  var CONFIG = keys(`secret db serverURL cors cronSecret timeZone webhooks events jobs localization routes admin
4639
4650
  upload auth collections globals endpoints cliCommands onRequest fieldTypes apiKeys email backups
4640
- audit logs countCacheSeconds maxPage httpCache outbox search plugins`);
4651
+ audit logs countCacheSeconds maxPage httpCache outbox search telemetry plugins`);
4641
4652
  var ADMIN = keys("path locale brand siteURL nav commands modules pages dashboard switcher");
4642
4653
  var AUTH = keys(`roles rbac tokenExpiration maxLoginAttempts lockWindow trustedOrigins
4643
4654
  resetPasswordExpiration inviteExpiration providers providerSignUp password setupCode members emails`);
@@ -5383,6 +5394,7 @@ function validateConfig(config) {
5383
5394
  validateCountCache(config, add);
5384
5395
  validateOutbox(config, add);
5385
5396
  validateSearch(config, add);
5397
+ validateTelemetry(config, add);
5386
5398
  validateMaxPage(config, add);
5387
5399
  validateHttpCache(config, add);
5388
5400
  validateAuth(config, add);
@@ -6036,6 +6048,18 @@ function validateOutbox(config, add) {
6036
6048
  if (config.outbox !== void 0 && typeof config.outbox !== "boolean")
6037
6049
  add("outbox", "must be true or false");
6038
6050
  }
6051
+ function validateTelemetry(config, add) {
6052
+ const value = config.telemetry;
6053
+ if (value === void 0 || typeof value === "boolean") return;
6054
+ if (typeof value !== "object" || value === null) {
6055
+ add("telemetry", "must be true, false or { traces, metrics }");
6056
+ return;
6057
+ }
6058
+ for (const [key, v] of Object.entries(value))
6059
+ if (key !== "traces" && key !== "metrics")
6060
+ add(`telemetry.${key}`, "is not an option", "traces or metrics");
6061
+ else if (typeof v !== "boolean") add(`telemetry.${key}`, "must be true or false");
6062
+ }
6039
6063
  function validateSearch(config, add) {
6040
6064
  const adapter = config.search;
6041
6065
  if (adapter !== void 0) {
@@ -6388,6 +6412,15 @@ function validateFields2(fields, path, reserved, collectionSlugs, add) {
6388
6412
  add(`${path}[${i}].name`, `must be a valid identifier (got ${JSON.stringify(name)})`);
6389
6413
  return;
6390
6414
  }
6415
+ if (name === "__proto__") {
6416
+ add(`${path}[${i}].name`, '"__proto__" is reserved by JavaScript');
6417
+ return;
6418
+ }
6419
+ if (name === "constructor" || name === "prototype")
6420
+ warnDeprecated(
6421
+ "EASY_CMS_DEP006",
6422
+ `A field named "${name}" (${path}.${name}) works through 1.x; rename it before 2.0.`
6423
+ );
6391
6424
  if (reserved.has(name)) {
6392
6425
  add(`${path}.${name}`, `"${name}" is reserved`, "Easy CMS adds this field automatically");
6393
6426
  }
@@ -7011,6 +7044,201 @@ function cacheableResponse(request, body, status, headers, times, tags) {
7011
7044
  return new Response(request.method.toUpperCase() === "HEAD" ? null : body, { status, headers });
7012
7045
  }
7013
7046
 
7047
+ // src/version.ts
7048
+ var VERSION = true ? "1.15.1" : "0.0.0-dev";
7049
+
7050
+ // src/telemetry.ts
7051
+ var DB_CALLS = /* @__PURE__ */ new Set([
7052
+ "find",
7053
+ "findPage",
7054
+ "findAfter",
7055
+ "findById",
7056
+ "findByIds",
7057
+ "count",
7058
+ "create",
7059
+ "update",
7060
+ "increment",
7061
+ "delete",
7062
+ "findGlobal",
7063
+ "updateGlobal"
7064
+ ]);
7065
+ var Telemetry = class _Telemetry {
7066
+ constructor(api, traces, metrics) {
7067
+ this.api = api;
7068
+ this.tracer = traces ? api.trace.getTracer("easy-cms", VERSION) : void 0;
7069
+ if (metrics) {
7070
+ const meter = api.metrics.getMeter("easy-cms", VERSION);
7071
+ this.httpDuration = meter.createHistogram("http.server.request.duration", {
7072
+ unit: "s",
7073
+ description: "Duration of API requests"
7074
+ });
7075
+ this.dbDuration = meter.createHistogram("db.client.operation.duration", {
7076
+ unit: "s",
7077
+ description: "Duration of database calls"
7078
+ });
7079
+ this.jobRuns = meter.createCounter("easy_cms.jobs.runs", {
7080
+ description: "Runs of the config\u2019s jobs, by job and result"
7081
+ });
7082
+ this.jobDuration = meter.createHistogram("easy_cms.jobs.duration", {
7083
+ unit: "s",
7084
+ description: "Duration of the config\u2019s jobs"
7085
+ });
7086
+ meter.createObservableGauge("easy_cms.queue.size", {
7087
+ description: "Background work waiting or failed, as of the last job run"
7088
+ }).addCallback((result) => {
7089
+ for (const [queue, sizes] of Object.entries(this.queues))
7090
+ for (const state of ["pending", "failed"])
7091
+ result.observe(sizes[state], { queue, state });
7092
+ });
7093
+ meter.createObservableGauge("easy_cms.scheduled.late", {
7094
+ description: "Scheduled publishes more than 10 minutes late, as of the last job run"
7095
+ }).addCallback((result) => result.observe(this.late));
7096
+ }
7097
+ if (traces)
7098
+ setTraceIds(() => {
7099
+ const context = api.trace.getActiveSpan()?.spanContext();
7100
+ return context && api.isSpanContextValid(context) ? { traceId: context.traceId, spanId: context.spanId } : void 0;
7101
+ });
7102
+ }
7103
+ api;
7104
+ tracer;
7105
+ httpDuration;
7106
+ dbDuration;
7107
+ jobRuns;
7108
+ jobDuration;
7109
+ queues = {};
7110
+ late = 0;
7111
+ /** `undefined` without `telemetry`; throws when `@opentelemetry/api` isn't installed. */
7112
+ static async load(config) {
7113
+ if (!config) return void 0;
7114
+ const { traces = true, metrics = true } = config === true ? {} : config;
7115
+ if (!traces && !metrics) return void 0;
7116
+ let api;
7117
+ try {
7118
+ api = await import("@opentelemetry/api");
7119
+ } catch {
7120
+ throw new ConfigError([
7121
+ {
7122
+ path: "telemetry",
7123
+ message: "needs @opentelemetry/api",
7124
+ hint: "npm install @opentelemetry/api, and set up the OpenTelemetry SDK in your app"
7125
+ }
7126
+ ]);
7127
+ }
7128
+ return new _Telemetry(api, traces, metrics);
7129
+ }
7130
+ /** Runs `fn` in a span; with traces off, just runs it. Errors mark the span and go on. */
7131
+ async span(name, attributes, fn, kind = "internal") {
7132
+ if (!this.tracer) return fn(void 0);
7133
+ const spanKind = { internal: 0, server: 1, client: 2 }[kind];
7134
+ return this.tracer.startActiveSpan(name, { kind: spanKind, attributes }, async (span) => {
7135
+ try {
7136
+ return await fn(span);
7137
+ } catch (error) {
7138
+ span.setStatus({ code: this.api.SpanStatusCode.ERROR });
7139
+ span.setAttribute("error.type", errorType(error));
7140
+ throw error;
7141
+ } finally {
7142
+ span.end();
7143
+ }
7144
+ });
7145
+ }
7146
+ /**
7147
+ * An API request: a server span when nothing traces it yet (`easy-cms serve`), else a span
7148
+ * inside the framework's (Next.js, Nuxt with an SDK).
7149
+ */
7150
+ async request(method, route2, fn) {
7151
+ const started = performance.now();
7152
+ const kind = this.api.trace.getActiveSpan() ? "internal" : "server";
7153
+ const attributes = { "http.request.method": method, "http.route": route2 };
7154
+ let status = 500;
7155
+ try {
7156
+ return await this.span(
7157
+ `easy-cms ${method} ${route2}`,
7158
+ attributes,
7159
+ async (span) => {
7160
+ const response = await fn();
7161
+ status = response.status;
7162
+ span?.setAttribute("http.response.status_code", status);
7163
+ if (status >= 500) span?.setStatus({ code: this.api.SpanStatusCode.ERROR });
7164
+ return response;
7165
+ },
7166
+ kind
7167
+ );
7168
+ } finally {
7169
+ this.httpDuration?.record((performance.now() - started) / 1e3, {
7170
+ ...attributes,
7171
+ "http.response.status_code": status
7172
+ });
7173
+ }
7174
+ }
7175
+ /** The database with a client span and a duration per call (`db <call> <collection>`). */
7176
+ database(db, system2) {
7177
+ return new Proxy(db, {
7178
+ get: (target, key, receiver) => {
7179
+ const value = Reflect.get(target, key, receiver);
7180
+ if (typeof key !== "string" || !DB_CALLS.has(key) || typeof value !== "function")
7181
+ return value;
7182
+ return (args, ...rest) => {
7183
+ const name = args?.collection ?? args?.slug ?? "";
7184
+ const attributes = {
7185
+ "db.system.name": system2,
7186
+ "db.operation.name": key,
7187
+ "db.collection.name": name
7188
+ };
7189
+ const started = performance.now();
7190
+ return this.span(
7191
+ `db ${key} ${name}`,
7192
+ attributes,
7193
+ () => Promise.resolve(value.call(target, args, ...rest)),
7194
+ "client"
7195
+ ).finally(() => this.dbDuration?.record((performance.now() - started) / 1e3, attributes));
7196
+ };
7197
+ }
7198
+ });
7199
+ }
7200
+ /** A run of one of the config's jobs, for `easy_cms.jobs.*`. */
7201
+ jobRan(name, ok, ms) {
7202
+ const attributes = { job: name, result: ok ? "ok" : "failed" };
7203
+ this.jobRuns?.add(1, attributes);
7204
+ this.jobDuration?.record(ms / 1e3, { job: name });
7205
+ }
7206
+ /** Whether the queue gauges are reported (and worth counting for). */
7207
+ get measuresQueues() {
7208
+ return this.jobRuns !== void 0;
7209
+ }
7210
+ /** Queue sizes found by the last job run, for the gauges. */
7211
+ queuesCounted(queues, late) {
7212
+ this.queues = queues;
7213
+ this.late = late;
7214
+ }
7215
+ };
7216
+ function errorType(error) {
7217
+ if (error && typeof error === "object") {
7218
+ const code = error.code;
7219
+ if (typeof code === "string" && code) return code;
7220
+ const name = error.name;
7221
+ if (typeof name === "string" && name) return name;
7222
+ }
7223
+ return "Error";
7224
+ }
7225
+ function routeOf(path, root) {
7226
+ if (path === null) return "other";
7227
+ if (root) return path;
7228
+ const parts = path.split("/").filter(Boolean);
7229
+ const [first] = parts;
7230
+ const fixed = /* @__PURE__ */ new Set(["auth", "admin", "jobs", "search", "healthz", "readyz", "api-keys"]);
7231
+ const word = /^[a-z][a-z.-]{0,39}$/;
7232
+ const route2 = parts.map((part, i) => {
7233
+ if (i === 0) return fixed.has(part) || part === "globals" ? part : ":collection";
7234
+ if (first === "globals" && i === 1) return ":global";
7235
+ if (first === "admin" || first === "auth") return word.test(part) ? part : ":param";
7236
+ if (i === 1) return /^\d+$/.test(part) || !word.test(part) ? ":id" : part;
7237
+ return word.test(part) ? part : ":id";
7238
+ });
7239
+ return `/${route2.join("/")}`;
7240
+ }
7241
+
7014
7242
  // src/local-api.ts
7015
7243
  async function createEasyCMS(config, options = {}) {
7016
7244
  const resolved3 = await resolveConfig(config);
@@ -7023,13 +7251,15 @@ async function createEasyCMS(config, options = {}) {
7023
7251
  const storage = resolved3.upload.storage ?? diskStorage({ dir: resolved3.upload.dir });
7024
7252
  await storage.init?.({ cwd });
7025
7253
  if (resolved3.upload.privateStorage) await resolved3.upload.privateStorage.init?.({ cwd });
7026
- const db = await resolved3.db.init({
7254
+ const telemetry = await Telemetry.load(resolved3.telemetry);
7255
+ const connected = await resolved3.db.init({
7027
7256
  config: resolved3,
7028
7257
  cwd,
7029
7258
  schema: options.schema ?? (process.env.NODE_ENV === "production" ? "verify" : "push"),
7030
7259
  logger,
7031
7260
  interactive: options.interactive ?? false
7032
7261
  });
7262
+ const db = telemetry ? telemetry.database(connected, dbSystem(resolved3.db.name)) : connected;
7033
7263
  if (resolved3.outbox && db.outbox !== true) {
7034
7264
  await db.destroy();
7035
7265
  throw new ConfigError([
@@ -7041,6 +7271,7 @@ async function createEasyCMS(config, options = {}) {
7041
7271
  ]);
7042
7272
  }
7043
7273
  const cms = new EasyCMS(resolved3, db, logger, storage, cwd);
7274
+ cms.telemetry = telemetry;
7044
7275
  if (resolved3.search?.ephemeral && process.env.NODE_ENV === "production")
7045
7276
  logger.warn(
7046
7277
  `Search: the "${resolved3.search.name ?? "custom"}" index lives in this process's memory: it is built again at each start, and each instance has its own. Use a search engine adapter for large sites or several instances.`
@@ -7054,6 +7285,10 @@ async function createEasyCMS(config, options = {}) {
7054
7285
  );
7055
7286
  return cms;
7056
7287
  }
7288
+ function dbSystem(name) {
7289
+ if (name.includes("postgres") || name.includes("pglite")) return "postgresql";
7290
+ return name.includes("sqlite") ? "sqlite" : name;
7291
+ }
7057
7292
  var toJob = (row) => ({
7058
7293
  id: row.id,
7059
7294
  action: row.action,
@@ -7090,6 +7325,8 @@ var EasyCMS = class {
7090
7325
  outbox;
7091
7326
  /** With `search`: keeps the index in step with published documents. */
7092
7327
  searchIndex;
7328
+ /** @internal With `telemetry`: OpenTelemetry spans and metrics (set by `createEasyCMS`). */
7329
+ telemetry;
7093
7330
  /** @internal */
7094
7331
  constructor(config, db, logger = consoleLogger, storage = diskStorage({ dir: config.upload.dir }), cwd = process.cwd(), privateStorage) {
7095
7332
  this.config = config;
@@ -7117,7 +7354,15 @@ var EasyCMS = class {
7117
7354
  logger,
7118
7355
  config.collections.some((c) => c.slug === EMAIL_DELIVERIES) ? this.emailQueue() : void 0
7119
7356
  );
7120
- this.outbox = config.collections.some((c) => c.slug === OUTBOX) ? new Outbox(db, logger, (entry, done, mark) => this.runOutbox(entry, done, mark)) : void 0;
7357
+ this.outbox = config.collections.some((c) => c.slug === OUTBOX) ? new Outbox(
7358
+ db,
7359
+ logger,
7360
+ (entry, done, mark) => this.telemetry ? this.telemetry.span(
7361
+ `easy-cms outbox ${entry.event} ${entry.target}`,
7362
+ { "easy_cms.event": entry.event, "easy_cms.collection": entry.target },
7363
+ () => this.runOutbox(entry, done, mark)
7364
+ ) : this.runOutbox(entry, done, mark)
7365
+ ) : void 0;
7121
7366
  this.searchIndex = config.search ? new SearchIndex(
7122
7367
  config.search,
7123
7368
  config.collections,
@@ -7164,99 +7409,112 @@ var EasyCMS = class {
7164
7409
  };
7165
7410
  }
7166
7411
  async find(collection2, options = {}) {
7167
- const config = this.collection(collection2);
7168
- const guard = guardOf(options);
7169
- const limit = options.limit ?? 10;
7170
- const page = options.page ?? 1;
7171
- if (!Number.isInteger(limit) || limit < 0)
7172
- throw new QueryError("limit must be a non-negative integer");
7173
- if (!Number.isInteger(page) || page < 1) throw new QueryError("page must be a positive integer");
7174
- const draft = readsDrafts(guard, options.draft);
7175
- const where = this.whereFor(
7176
- config,
7177
- draftWhere(config, draft, await this.readWhere(config, guard, options.where)),
7178
- options
7179
- );
7180
- const requested = options.sort === void 0 ? ["-createdAt"] : [options.sort].flat();
7181
- await this.checkQueryFields(config, guard, void 0, requested);
7182
- const locale = this.localeOf(options);
7183
- const sort = locale === void 0 ? requested : localizeSort(
7184
- config.fields,
7185
- requested,
7186
- locale === ALL_LOCALES ? this.config.localization?.defaultLocale ?? locale : locale,
7187
- this.config.localization?.locales ?? []
7188
- );
7189
- if (options.cursor !== void 0) {
7190
- if (options.page !== void 0) throw new QueryError("Use either cursor or page, not both");
7191
- if (limit === 0) throw new QueryError("A cursor needs a limit above 0");
7192
- if (!this.db.findAfter) throw new QueryError("This database adapter has no cursor pagination");
7193
- const query = cursorQuery(collection2, options.where, requested, locale, draft);
7194
- const after = readCursor(options.cursor, query);
7195
- const result2 = await this.db.findAfter({ collection: collection2, where, sort, limit, after });
7196
- const found2 = draft ? await this.withDrafts(config, result2.docs) : result2.docs;
7197
- const docs2 = await this.output(config, found2, guard, { ...options, draft });
7198
- return {
7199
- docs: docs2,
7200
- limit,
7201
- nextCursor: result2.hasNextPage && result2.next ? writeCursor(result2.next, query) : null,
7202
- hasNextPage: result2.hasNextPage,
7203
- hasPrevPage: after !== null,
7204
- totalDocs: null,
7205
- totalPages: null,
7206
- page: null
7207
- };
7208
- }
7209
- const args = { collection: collection2, where, sort, limit, page };
7210
- const count = options.count !== false || limit === 0;
7211
- const result = count ? await this.db.find(args) : this.db.findPage ? await this.db.findPage(args) : { ...await this.db.find(args), totalDocs: null, totalPages: null };
7212
- const found = draft ? await this.withDrafts(config, result.docs) : result.docs;
7213
- const docs = await this.output(config, found, guard, { ...options, draft });
7214
- return { ...result, docs };
7412
+ return this.traced("find", String(collection2), async () => {
7413
+ const config = this.collection(collection2);
7414
+ const guard = guardOf(options);
7415
+ const limit = options.limit ?? 10;
7416
+ const page = options.page ?? 1;
7417
+ if (!Number.isInteger(limit) || limit < 0)
7418
+ throw new QueryError("limit must be a non-negative integer");
7419
+ if (!Number.isInteger(page) || page < 1)
7420
+ throw new QueryError("page must be a positive integer");
7421
+ const draft = readsDrafts(guard, options.draft);
7422
+ const where = this.whereFor(
7423
+ config,
7424
+ draftWhere(config, draft, await this.readWhere(config, guard, options.where)),
7425
+ options
7426
+ );
7427
+ const requested = options.sort === void 0 ? ["-createdAt"] : [options.sort].flat();
7428
+ await this.checkQueryFields(config, guard, void 0, requested);
7429
+ const locale = this.localeOf(options);
7430
+ const sort = locale === void 0 ? requested : localizeSort(
7431
+ config.fields,
7432
+ requested,
7433
+ locale === ALL_LOCALES ? this.config.localization?.defaultLocale ?? locale : locale,
7434
+ this.config.localization?.locales ?? []
7435
+ );
7436
+ if (options.cursor !== void 0) {
7437
+ if (options.page !== void 0) throw new QueryError("Use either cursor or page, not both");
7438
+ if (limit === 0) throw new QueryError("A cursor needs a limit above 0");
7439
+ if (!this.db.findAfter)
7440
+ throw new QueryError("This database adapter has no cursor pagination");
7441
+ const query = cursorQuery(collection2, options.where, requested, locale, draft);
7442
+ const after = readCursor(options.cursor, query);
7443
+ const result2 = await this.db.findAfter({ collection: collection2, where, sort, limit, after });
7444
+ const found2 = draft ? await this.withDrafts(config, result2.docs) : result2.docs;
7445
+ const docs2 = await this.output(config, found2, guard, { ...options, draft });
7446
+ return {
7447
+ docs: docs2,
7448
+ limit,
7449
+ nextCursor: result2.hasNextPage && result2.next ? writeCursor(result2.next, query) : null,
7450
+ hasNextPage: result2.hasNextPage,
7451
+ hasPrevPage: after !== null,
7452
+ totalDocs: null,
7453
+ totalPages: null,
7454
+ page: null
7455
+ };
7456
+ }
7457
+ const args = { collection: collection2, where, sort, limit, page };
7458
+ const count = options.count !== false || limit === 0;
7459
+ const result = count ? await this.db.find(args) : this.db.findPage ? await this.db.findPage(args) : { ...await this.db.find(args), totalDocs: null, totalPages: null };
7460
+ const found = draft ? await this.withDrafts(config, result.docs) : result.docs;
7461
+ const docs = await this.output(config, found, guard, { ...options, draft });
7462
+ return { ...result, docs };
7463
+ });
7215
7464
  }
7216
7465
  async findById(collection2, id, options = {}) {
7217
- const config = this.collection(collection2);
7218
- const guard = guardOf(options);
7219
- const parsed = parseId(id);
7220
- if (parsed === void 0) return null;
7221
- const draft = readsDrafts(guard, options.draft);
7222
- const where = this.whereFor(
7223
- config,
7224
- draftWhere(config, draft, await this.readWhere(config, guard, void 0)),
7225
- options
7226
- );
7227
- const doc = where ? (await this.db.find({
7228
- collection: collection2,
7229
- where: andWhere(where, { id: { equals: parsed } }),
7230
- sort: [],
7231
- limit: 1,
7232
- page: 1
7233
- })).docs[0] : await this.db.findById({ collection: collection2, id: parsed });
7234
- if (!doc) return null;
7235
- const [current] = draft ? await this.withDrafts(config, [doc]) : [doc];
7236
- const [out] = await this.output(config, [current], guard, { ...options, draft });
7237
- return out ?? null;
7466
+ return this.traced("findById", String(collection2), async () => {
7467
+ const config = this.collection(collection2);
7468
+ const guard = guardOf(options);
7469
+ const parsed = parseId(id);
7470
+ if (parsed === void 0) return null;
7471
+ const draft = readsDrafts(guard, options.draft);
7472
+ const where = this.whereFor(
7473
+ config,
7474
+ draftWhere(config, draft, await this.readWhere(config, guard, void 0)),
7475
+ options
7476
+ );
7477
+ const doc = where ? (await this.db.find({
7478
+ collection: collection2,
7479
+ where: andWhere(where, { id: { equals: parsed } }),
7480
+ sort: [],
7481
+ limit: 1,
7482
+ page: 1
7483
+ })).docs[0] : await this.db.findById({ collection: collection2, id: parsed });
7484
+ if (!doc) return null;
7485
+ const [current] = draft ? await this.withDrafts(config, [doc]) : [doc];
7486
+ const [out] = await this.output(config, [current], guard, {
7487
+ ...options,
7488
+ draft
7489
+ });
7490
+ return out ?? null;
7491
+ });
7238
7492
  }
7239
7493
  async count(collection2, options = {}) {
7240
- const config = this.collection(collection2);
7241
- const where = this.whereFor(
7242
- config,
7243
- draftWhere(
7494
+ return this.traced("count", String(collection2), async () => {
7495
+ const config = this.collection(collection2);
7496
+ const where = this.whereFor(
7244
7497
  config,
7245
- readsDrafts(guardOf(options), options.draft),
7246
- await this.readWhere(config, guardOf(options), options.where)
7247
- ),
7248
- options
7249
- );
7250
- return this.db.count({ collection: collection2, where });
7498
+ draftWhere(
7499
+ config,
7500
+ readsDrafts(guardOf(options), options.draft),
7501
+ await this.readWhere(config, guardOf(options), options.where)
7502
+ ),
7503
+ options
7504
+ );
7505
+ return this.db.count({ collection: collection2, where });
7506
+ });
7251
7507
  }
7252
7508
  async create(collection2, data, options = {}) {
7253
- const config = this.collection(collection2);
7254
- if (config.slug === MEDIA) {
7255
- throw new ValidationError(MEDIA, [
7256
- { field: "file", message: "upload files with cms.upload() or POST multipart" }
7257
- ]);
7258
- }
7259
- return await this.createDocument(config, asObject(data, collection2), options);
7509
+ return this.traced("create", String(collection2), async () => {
7510
+ const config = this.collection(collection2);
7511
+ if (config.slug === MEDIA) {
7512
+ throw new ValidationError(MEDIA, [
7513
+ { field: "file", message: "upload files with cms.upload() or POST multipart" }
7514
+ ]);
7515
+ }
7516
+ return await this.createDocument(config, asObject(data, collection2), options);
7517
+ });
7260
7518
  }
7261
7519
  /**
7262
7520
  * Stores a file and creates its `media` document (FR-UPL). The type is detected from
@@ -7711,7 +7969,14 @@ var EasyCMS = class {
7711
7969
  }
7712
7970
  }
7713
7971
  async update(collection2, id, data, options = {}) {
7714
- return await this.updateDocument(collection2, id, asObject(data, collection2), options);
7972
+ return this.traced("update", String(collection2), async () => {
7973
+ return await this.updateDocument(
7974
+ collection2,
7975
+ id,
7976
+ asObject(data, collection2),
7977
+ options
7978
+ );
7979
+ });
7715
7980
  }
7716
7981
  /**
7717
7982
  * Adds `by` (negative to take away) to a top-level number field in one database statement, so
@@ -8082,65 +8347,69 @@ var EasyCMS = class {
8082
8347
  * gives the documents they own to another user; without it they have no owner.
8083
8348
  */
8084
8349
  async delete(collection2, id, options = {}) {
8085
- const config = this.collection(collection2);
8086
- const guard = guardOf(options);
8087
- const parsed = parseId(id);
8088
- const existing = parsed === void 0 ? null : await this.db.findById({ collection: collection2, id: parsed });
8089
- if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
8090
- await this.checkDocumentAccess(config, "delete", guard, parsed, void 0);
8091
- if (config.slug === USERS)
8092
- await this.guardLastAdmin(parsed, existing, { ...existing, active: false });
8093
- const base = this.hookArgs(config, guard);
8094
- for (const hook of config.hooks?.beforeDelete ?? []) await hook({ ...base, id: parsed });
8095
- if (config.slug === USERS) {
8096
- const to = options.transferTo;
8097
- if (to !== void 0 && to !== null) {
8098
- const target = parseId(to);
8099
- const heir = target === void 0 ? null : await this.db.findById({ collection: USERS, id: target });
8100
- if (!heir || String(heir.id) === String(parsed))
8101
- throw new ValidationError(USERS, [
8102
- { field: "transferTo", message: "must be another existing user" }
8103
- ]);
8104
- await this.roles.transfer(parsed, heir.id);
8105
- } else await this.roles.transfer(parsed, null);
8106
- await this.auth.revokeSessions(parsed);
8107
- await this.auth.sso.forget(parsed);
8108
- }
8109
- if (collection2 === MEDIA_FOLDERS) {
8110
- await this.checkMoveLimit(await this.folders.privacyAfter(parsed, { private: false }));
8111
- await this.folders.release(existing);
8112
- }
8113
- const outbox = this.outboxRow(config, "delete", guard, existing, { docId: parsed });
8114
- await this.db.delete({ collection: collection2, id: parsed, outbox });
8115
- if (collection2 === MEDIA_FOLDERS) {
8116
- this.folders.invalidate();
8117
- const parent = existing.parent ?? null;
8118
- if (parent !== null) await this.settlePrivacy(parent);
8119
- else await this.settleTopLevel();
8120
- }
8121
- if (versionLimit(config)) await this.versions.deleteAll(collectionParent(collection2), parsed);
8122
- if (config.schedule) {
8123
- for (const job of await this.pendingJobs(collection2, parsed))
8124
- await this.db.delete({ collection: SCHEDULED_JOBS, id: job.id });
8125
- }
8126
- if (outbox) this.outbox?.kick();
8127
- else {
8128
- await this.notify(config.hooks?.afterDelete, "afterDelete", config.slug, {
8129
- ...base,
8130
- id: parsed,
8131
- doc: existing
8132
- });
8133
- this.emitChange(config, "delete", existing);
8134
- }
8135
- await this.audited(config, "delete", existing, existing, guard);
8136
- const [out] = await this.output(config, [existing], guard, { depth: 0, draft: true });
8137
- return out;
8350
+ return this.traced("delete", String(collection2), async () => {
8351
+ const config = this.collection(collection2);
8352
+ const guard = guardOf(options);
8353
+ const parsed = parseId(id);
8354
+ const existing = parsed === void 0 ? null : await this.db.findById({ collection: collection2, id: parsed });
8355
+ if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
8356
+ await this.checkDocumentAccess(config, "delete", guard, parsed, void 0);
8357
+ if (config.slug === USERS)
8358
+ await this.guardLastAdmin(parsed, existing, { ...existing, active: false });
8359
+ const base = this.hookArgs(config, guard);
8360
+ for (const hook of config.hooks?.beforeDelete ?? []) await hook({ ...base, id: parsed });
8361
+ if (config.slug === USERS) {
8362
+ const to = options.transferTo;
8363
+ if (to !== void 0 && to !== null) {
8364
+ const target = parseId(to);
8365
+ const heir = target === void 0 ? null : await this.db.findById({ collection: USERS, id: target });
8366
+ if (!heir || String(heir.id) === String(parsed))
8367
+ throw new ValidationError(USERS, [
8368
+ { field: "transferTo", message: "must be another existing user" }
8369
+ ]);
8370
+ await this.roles.transfer(parsed, heir.id);
8371
+ } else await this.roles.transfer(parsed, null);
8372
+ await this.auth.revokeSessions(parsed);
8373
+ await this.auth.sso.forget(parsed);
8374
+ }
8375
+ if (collection2 === MEDIA_FOLDERS) {
8376
+ await this.checkMoveLimit(await this.folders.privacyAfter(parsed, { private: false }));
8377
+ await this.folders.release(existing);
8378
+ }
8379
+ const outbox = this.outboxRow(config, "delete", guard, existing, { docId: parsed });
8380
+ await this.db.delete({ collection: collection2, id: parsed, outbox });
8381
+ if (collection2 === MEDIA_FOLDERS) {
8382
+ this.folders.invalidate();
8383
+ const parent = existing.parent ?? null;
8384
+ if (parent !== null) await this.settlePrivacy(parent);
8385
+ else await this.settleTopLevel();
8386
+ }
8387
+ if (versionLimit(config)) await this.versions.deleteAll(collectionParent(collection2), parsed);
8388
+ if (config.schedule) {
8389
+ for (const job of await this.pendingJobs(collection2, parsed))
8390
+ await this.db.delete({ collection: SCHEDULED_JOBS, id: job.id });
8391
+ }
8392
+ if (outbox) this.outbox?.kick();
8393
+ else {
8394
+ await this.notify(config.hooks?.afterDelete, "afterDelete", config.slug, {
8395
+ ...base,
8396
+ id: parsed,
8397
+ doc: existing
8398
+ });
8399
+ this.emitChange(config, "delete", existing);
8400
+ }
8401
+ await this.audited(config, "delete", existing, existing, guard);
8402
+ const [out] = await this.output(config, [existing], guard, { depth: 0, draft: true });
8403
+ return out;
8404
+ });
8138
8405
  }
8139
8406
  async findGlobal(slug2, options = {}) {
8140
- const config = this.global(slug2);
8141
- const guard = guardOf(options);
8142
- await this.checkGlobalAccess(config, "read", guard);
8143
- return this.readGlobal(config, this.globalKey(config, guard), guard, options);
8407
+ return this.traced("findGlobal", String(slug2), async () => {
8408
+ const config = this.global(slug2);
8409
+ const guard = guardOf(options);
8410
+ await this.checkGlobalAccess(config, "read", guard);
8411
+ return this.readGlobal(config, this.globalKey(config, guard), guard, options);
8412
+ });
8144
8413
  }
8145
8414
  /** A global as stored under `key` (see `globalKey`); empty when there is no key. */
8146
8415
  async readGlobal(config, key, guard, options) {
@@ -8180,7 +8449,9 @@ var EasyCMS = class {
8180
8449
  return key;
8181
8450
  }
8182
8451
  async updateGlobal(slug2, data, options = {}) {
8183
- return this.saveGlobal(slug2, asObject(data, slug2), options);
8452
+ return this.traced("updateGlobal", String(slug2), async () => {
8453
+ return this.saveGlobal(slug2, asObject(data, slug2), options);
8454
+ });
8184
8455
  }
8185
8456
  /** Takes a global off the site (`status: 'draft'`). See `unpublish`. */
8186
8457
  async unpublishGlobal(slug2, options = {}) {
@@ -8447,36 +8718,39 @@ var EasyCMS = class {
8447
8718
  * hold fewer documents than `limit`.
8448
8719
  */
8449
8720
  async search(query, options = {}) {
8450
- const index = this.searchIndex;
8451
- if (!index) throw new QueryError("Search needs a `search` adapter in the config");
8452
- const limit = options.limit ?? 10;
8453
- const page = options.page ?? 1;
8454
- if (!Number.isInteger(limit) || limit < 1 || limit > 100)
8455
- throw new QueryError("limit must be an integer from 1 to 100");
8456
- if (!Number.isInteger(page) || page < 1) throw new QueryError("page must be an integer from 1");
8457
- const collections = options.collections ?? index.searchable;
8458
- for (const slug2 of collections)
8459
- if (!index.configOf(slug2)) throw new QueryError(`"${slug2}" is not searchable`);
8460
- const localization = this.config.localization;
8461
- const asked = options.locale && options.locale !== ALL_LOCALES ? options.locale : void 0;
8462
- if (asked !== void 0 && localization && !localization.locales.includes(asked))
8463
- throw new QueryError(`Unknown locale "${asked}"`);
8464
- const locale = localization ? asked ?? localization.defaultLocale ?? null : null;
8465
- const text3 = query.trim().slice(0, 200);
8466
- const empty = { hits: [], total: 0, page, limit, hasNextPage: false };
8467
- if (!text3 || collections.length === 0) return empty;
8468
- const { collections: _, limit: __, page: ___, ...read2 } = options;
8469
- const { hits, total } = await index.search(
8470
- { query: text3, collections, locale, limit, offset: (page - 1) * limit },
8471
- async (collection2, ids) => (await this.find(collection2, {
8472
- ...read2,
8473
- ...locale ? { locale } : {},
8474
- where: { id: { in: ids.map((id) => parseId(id) ?? id) } },
8475
- limit: ids.length,
8476
- draft: false
8477
- })).docs
8478
- );
8479
- return { hits, total, page, limit, hasNextPage: page * limit < total };
8721
+ return this.traced("search", "", async () => {
8722
+ const index = this.searchIndex;
8723
+ if (!index) throw new QueryError("Search needs a `search` adapter in the config");
8724
+ const limit = options.limit ?? 10;
8725
+ const page = options.page ?? 1;
8726
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100)
8727
+ throw new QueryError("limit must be an integer from 1 to 100");
8728
+ if (!Number.isInteger(page) || page < 1)
8729
+ throw new QueryError("page must be an integer from 1");
8730
+ const collections = options.collections ?? index.searchable;
8731
+ for (const slug2 of collections)
8732
+ if (!index.configOf(slug2)) throw new QueryError(`"${slug2}" is not searchable`);
8733
+ const localization = this.config.localization;
8734
+ const asked = options.locale && options.locale !== ALL_LOCALES ? options.locale : void 0;
8735
+ if (asked !== void 0 && localization && !localization.locales.includes(asked))
8736
+ throw new QueryError(`Unknown locale "${asked}"`);
8737
+ const locale = localization ? asked ?? localization.defaultLocale ?? null : null;
8738
+ const text3 = query.trim().slice(0, 200);
8739
+ const empty = { hits: [], total: 0, page, limit, hasNextPage: false };
8740
+ if (!text3 || collections.length === 0) return empty;
8741
+ const { collections: _, limit: __, page: ___, ...read2 } = options;
8742
+ const { hits, total } = await index.search(
8743
+ { query: text3, collections, locale, limit, offset: (page - 1) * limit },
8744
+ async (collection2, ids) => (await this.find(collection2, {
8745
+ ...read2,
8746
+ ...locale ? { locale } : {},
8747
+ where: { id: { in: ids.map((id) => parseId(id) ?? id) } },
8748
+ limit: ids.length,
8749
+ draft: false
8750
+ })).docs
8751
+ );
8752
+ return { hits, total, page, limit, hasNextPage: page * limit < total };
8753
+ });
8480
8754
  }
8481
8755
  /**
8482
8756
  * Builds the search index again from the database, for every searchable collection or one:
@@ -8515,6 +8789,18 @@ var EasyCMS = class {
8515
8789
  if (!found.hasNextPage) return;
8516
8790
  }
8517
8791
  }
8792
+ /** A Local API call in a span (`easy-cms <operation> <collection>`) with `telemetry`. */
8793
+ traced(operation, target, fn) {
8794
+ if (!this.telemetry) return fn();
8795
+ return this.telemetry.span(
8796
+ `easy-cms ${operation}${target ? ` ${target}` : ""}`,
8797
+ {
8798
+ "easy_cms.operation": operation,
8799
+ ...target ? { "easy_cms.collection": target } : {}
8800
+ },
8801
+ fn
8802
+ );
8803
+ }
8518
8804
  /** @internal Puts a failed outbox entry back in line (Settings → Jobs). */
8519
8805
  retryOutbox(id) {
8520
8806
  return this.outbox?.retry(id) ?? Promise.resolve(false);
@@ -8546,7 +8832,9 @@ var EasyCMS = class {
8546
8832
  * scheduler and the cron endpoint (`GET <api>/jobs/run`) call.
8547
8833
  */
8548
8834
  runJobs(now = /* @__PURE__ */ new Date()) {
8549
- const run = this.running.then(() => this.runJobsNow(now));
8835
+ const run = this.running.then(
8836
+ () => this.telemetry ? this.telemetry.span("easy-cms jobs.run", {}, () => this.runJobsNow(now)) : this.runJobsNow(now)
8837
+ );
8550
8838
  this.running = run.catch(() => {
8551
8839
  });
8552
8840
  return run;
@@ -8573,6 +8861,10 @@ var EasyCMS = class {
8573
8861
  jobs: await this.runConfigJobs(now),
8574
8862
  outbox: await this.outbox?.run(now) ?? { ran: 0, failed: 0 }
8575
8863
  };
8864
+ if (this.telemetry?.measuresQueues)
8865
+ await this.countQueues(now).catch(
8866
+ (error) => this.logger.error(`Telemetry: ${error.message}`, { error })
8867
+ );
8576
8868
  const ranAt = (/* @__PURE__ */ new Date()).toISOString();
8577
8869
  await this.db.updateGlobal({ slug: JOBS_RAN, data: { at: ranAt, updatedAt: ranAt } }).catch((error) => this.logger.error(`Jobs: ${error.message}`, { error }));
8578
8870
  return result;
@@ -8641,19 +8933,47 @@ var EasyCMS = class {
8641
8933
  const started = Date.now();
8642
8934
  let error = null;
8643
8935
  try {
8644
- await job.run({ cms: this, now });
8936
+ const work = async () => job.run({ cms: this, now });
8937
+ await (this.telemetry ? this.telemetry.span(`easy-cms job ${job.name}`, { "easy_cms.job": job.name }, work) : work());
8645
8938
  ran++;
8646
8939
  } catch (caught) {
8647
8940
  failed++;
8648
8941
  error = (caught.message || String(caught)).slice(0, 500);
8649
8942
  this.logger.error(`Job ${job.name} failed: ${error}`, { error: caught, job: job.name });
8650
8943
  }
8944
+ this.telemetry?.jobRan(job.name, error === null, Date.now() - started);
8651
8945
  await this.recordJobRun(job.name, stamp, error, Date.now() - started).catch(
8652
8946
  (caught) => this.logger.error(`Job ${job.name}: ${caught.message}`, { error: caught })
8653
8947
  );
8654
8948
  }
8655
8949
  return { ran, failed };
8656
8950
  }
8951
+ /** Background work waiting and failed, for `telemetry`'s queue gauges. */
8952
+ async countQueues(now) {
8953
+ const has3 = (slug2) => this.config.collections.some((c) => c.slug === slug2);
8954
+ const sizes = async (collection2, pending) => ({
8955
+ pending: await this.db.count({ collection: collection2, where: { state: { in: pending } } }),
8956
+ failed: await this.db.count({ collection: collection2, where: { state: { equals: "failed" } } })
8957
+ });
8958
+ const queues = {};
8959
+ if (has3(OUTBOX)) queues.outbox = await sizes(OUTBOX, ["pending", "running"]);
8960
+ if (has3(WEBHOOK_DELIVERIES)) queues.webhooks = await sizes(WEBHOOK_DELIVERIES, ["pending"]);
8961
+ if (has3(EMAIL_DELIVERIES)) queues.emails = await sizes(EMAIL_DELIVERIES, ["pending"]);
8962
+ let late = 0;
8963
+ if (has3(SCHEDULED_JOBS)) {
8964
+ queues.scheduled = await sizes(SCHEDULED_JOBS, ["pending", "running"]);
8965
+ late = await this.db.count({
8966
+ collection: SCHEDULED_JOBS,
8967
+ where: {
8968
+ and: [
8969
+ { state: { equals: "pending" } },
8970
+ { runAt: { lt: new Date(now.getTime() - 10 * 6e4).toISOString() } }
8971
+ ]
8972
+ }
8973
+ });
8974
+ }
8975
+ this.telemetry?.queuesCounted(queues, late);
8976
+ }
8657
8977
  async recordJobRun(name, at, error, ms) {
8658
8978
  const { docs } = await this.db.find({
8659
8979
  collection: JOB_RUNS,
@@ -10750,9 +11070,6 @@ async function readAdminModule(cms, index) {
10750
11070
  return { body, etag: `"${createHash6("sha1").update(body).digest("base64url")}"` };
10751
11071
  }
10752
11072
 
10753
- // src/version.ts
10754
- var VERSION = true ? "1.14.0" : "0.0.0-dev";
10755
-
10756
11073
  // src/rest/handler.ts
10757
11074
  import { randomUUID as randomUUID4 } from "crypto";
10758
11075
 
@@ -10881,7 +11198,6 @@ async function deleteFailedDeliveries(cms, kind) {
10881
11198
  }
10882
11199
 
10883
11200
  // src/rest/admin-email.ts
10884
- var EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
10885
11201
  var LIMIT = 5;
10886
11202
  var WINDOW = 10 * 6e4;
10887
11203
  var sentAt = /* @__PURE__ */ new WeakMap();
@@ -10930,7 +11246,7 @@ async function sendTestEmail(cms, user, to, locale) {
10930
11246
  const adapter = cms.config.email;
10931
11247
  if (!adapter) return { ok: false, error: "Email is not set up (`email` in the config)" };
10932
11248
  const address = typeof to === "string" && to.trim() ? to.trim() : user.email;
10933
- if (!EMAIL.test(address))
11249
+ if (!isEmail(address))
10934
11250
  throw new ValidationError("email", [{ field: "to", message: "must be an email address" }]);
10935
11251
  const times = sentAt.get(cms) ?? /* @__PURE__ */ new Map();
10936
11252
  sentAt.set(cms, times);
@@ -11528,6 +11844,7 @@ async function adminStatus(cms) {
11528
11844
  storage: config.upload.storage?.name ?? "local",
11529
11845
  email: config.email ? config.email.name ?? "custom" : null,
11530
11846
  search: config.search ? config.search.name ?? "custom" : null,
11847
+ telemetry: !!config.telemetry,
11531
11848
  plugins: config.installedPlugins.map((p) => ({ ...p })),
11532
11849
  fieldTypes: config.fieldTypes.map((t) => t.name)
11533
11850
  },
@@ -11726,7 +12043,14 @@ var HEALTH_CHECKS = /* @__PURE__ */ new Map([
11726
12043
  ["readyz", "readyz"]
11727
12044
  ]);
11728
12045
  var REQUEST_ID = /^[\w.-]{1,128}$/;
11729
- async function respond(cms, request, url, path, root, options, production) {
12046
+ function respond(cms, request, url, path, root, options, production) {
12047
+ const run = () => respondLogged(cms, request, url, path, root, options, production);
12048
+ const probe = !root && (path === "/healthz" || path === "/readyz");
12049
+ if (!cms.telemetry || probe) return run();
12050
+ const route2 = root || path === null ? routeOf(path, root) : `${cms.config.routes.api}${routeOf(path, root)}`;
12051
+ return cms.telemetry.request(request.method, route2, run);
12052
+ }
12053
+ async function respondLogged(cms, request, url, path, root, options, production) {
11730
12054
  const started = performance.now();
11731
12055
  const incoming = request.headers.get("x-request-id");
11732
12056
  const requestId = incoming && REQUEST_ID.test(incoming) ? incoming : randomUUID4();
@@ -13044,13 +13368,14 @@ function errorResponse(cms, error, headers, production) {
13044
13368
  }
13045
13369
  return new Response(JSON.stringify({ errors }), { status: error.status, headers });
13046
13370
  }
13047
- const message2 = error instanceof Error ? error.message : String(error);
13048
- cms.logger.error(`REST request failed: ${message2}`, { error });
13049
- const body = {
13050
- errors: [
13051
- { message: production ? "Internal Server Error" : message2, code: "INTERNAL_SERVER_ERROR" }
13052
- ]
13053
- };
13371
+ cms.logger.error(
13372
+ `REST request failed: ${error instanceof Error ? error.message : String(error)}`,
13373
+ {
13374
+ error
13375
+ }
13376
+ );
13377
+ const message2 = !production && error instanceof Error ? error.message : "Internal Server Error";
13378
+ const body = { errors: [{ message: message2, code: "INTERNAL_SERVER_ERROR" }] };
13054
13379
  return new Response(JSON.stringify(body), { status: 500, headers });
13055
13380
  }
13056
13381
 
@@ -13186,6 +13511,7 @@ export {
13186
13511
  SSO_COOKIE,
13187
13512
  Auth,
13188
13513
  ADAPTER_API_VERSION,
13514
+ isEmail,
13189
13515
  slugify,
13190
13516
  applyDefaults,
13191
13517
  fillMissing,
@@ -13227,10 +13553,10 @@ export {
13227
13553
  applyPlugins,
13228
13554
  resolveConfig,
13229
13555
  cacheTags,
13556
+ VERSION,
13230
13557
  createEasyCMS,
13231
13558
  EasyCMS,
13232
13559
  resolveAdminModule,
13233
- VERSION,
13234
13560
  CSRF_COOKIE,
13235
13561
  CSRF_HEADER,
13236
13562
  createRestHandler,