@easy-cms/core 1.17.0 → 1.19.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.
@@ -45,6 +45,99 @@ var isStaff = ({ user }) => user !== null && user.member !== true;
45
45
  var isSignedIn = ({ user }) => user !== null;
46
46
  var isAdmin = ({ user }) => user?.role === "admin" && user.scoped !== true;
47
47
 
48
+ // src/auth/tokens.ts
49
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto";
50
+ function newToken() {
51
+ return randomBytes(32).toString("base64url");
52
+ }
53
+ function hashToken(token) {
54
+ return createHash("sha256").update(token).digest("hex");
55
+ }
56
+ function hmac(secret, value) {
57
+ return createHmac("sha256", secret).update(value).digest("base64url");
58
+ }
59
+ function secretsOf(config) {
60
+ return [config.secret, ...config.previousSecrets ?? []];
61
+ }
62
+ function signedWith(secrets, value, signature) {
63
+ return [secrets].flat().some((secret) => safeEqual(signature, hmac(secret, value)));
64
+ }
65
+ function signToken(secret, token) {
66
+ return `${token}.${hmac(secret, `session:${token}`)}`;
67
+ }
68
+ function unsignToken(secrets, value) {
69
+ const dot = value.lastIndexOf(".");
70
+ if (dot <= 0) return void 0;
71
+ const token = value.slice(0, dot);
72
+ return signedWith(secrets, `session:${token}`, value.slice(dot + 1)) ? token : void 0;
73
+ }
74
+ function csrfToken(secret, token) {
75
+ return hmac(secret, `csrf:${token}`);
76
+ }
77
+ function csrfMatches(secrets, token, given) {
78
+ return signedWith(secrets, `csrf:${token}`, given);
79
+ }
80
+ function safeEqual(a, b) {
81
+ const left = Buffer.from(a);
82
+ const right = Buffer.from(b);
83
+ return left.length === right.length && timingSafeEqual(left, right);
84
+ }
85
+ function signPreviewToken(secret, target, expiresAt, user) {
86
+ const payload = Buffer.from(
87
+ JSON.stringify({
88
+ ..."global" in target ? { g: target.global } : { c: target.collection, i: target.id },
89
+ e: expiresAt,
90
+ ...user !== void 0 ? { u: user } : {}
91
+ })
92
+ ).toString("base64url");
93
+ return `${payload}.${hmac(secret, `preview:${payload}`)}`;
94
+ }
95
+ function verifyPreviewToken(secrets, token, now = Date.now()) {
96
+ const dot = token.lastIndexOf(".");
97
+ if (dot <= 0) return null;
98
+ const payload = token.slice(0, dot);
99
+ if (!signedWith(secrets, `preview:${payload}`, token.slice(dot + 1))) return null;
100
+ try {
101
+ const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
102
+ if (typeof data.e !== "number" || data.e <= now) return null;
103
+ const user = typeof data.u === "string" ? { user: data.u } : {};
104
+ if (typeof data.g === "string") return { global: data.g, expiresAt: data.e, ...user };
105
+ if (typeof data.c === "string" && typeof data.i === "string")
106
+ return { collection: data.c, id: data.i, expiresAt: data.e, ...user };
107
+ return null;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+ function passwordFingerprint(passwordHash) {
113
+ return createHash("sha256").update(typeof passwordHash === "string" ? passwordHash : "no-password").digest("base64url").slice(0, 22);
114
+ }
115
+ function signPasswordToken(secret, args) {
116
+ const payload = Buffer.from(
117
+ JSON.stringify({ u: args.userId, p: args.purpose, e: args.expiresAt })
118
+ ).toString("base64url");
119
+ return `${payload}.${hmac(secret, `password:${payload}:${args.fingerprint}`)}`;
120
+ }
121
+ function readPasswordToken(token) {
122
+ const dot = token.lastIndexOf(".");
123
+ if (dot <= 0) return null;
124
+ const payload = token.slice(0, dot);
125
+ try {
126
+ const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
127
+ if (typeof data.u !== "string" || typeof data.e !== "number") return null;
128
+ if (data.p !== "reset" && data.p !== "invite" && data.p !== "verify") return null;
129
+ return { userId: data.u, purpose: data.p, expiresAt: data.e, payload };
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+ function passwordTokenMatches(secrets, token, fingerprint) {
135
+ const dot = token.lastIndexOf(".");
136
+ if (dot <= 0) return false;
137
+ const payload = token.slice(0, dot);
138
+ return signedWith(secrets, `password:${payload}:${fingerprint}`, token.slice(dot + 1));
139
+ }
140
+
48
141
  // src/builtins.ts
49
142
  var USERS = "users";
50
143
  var MEDIA = "media";
@@ -61,6 +154,7 @@ var ROLES = "user-roles";
61
154
  var USER_IDENTITIES = "user-identities";
62
155
  var AUDIT_LOGS = "audit-logs";
63
156
  var OUTBOX = "outbox";
157
+ var RATE_LIMITS = "rate-limits";
64
158
  var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
65
159
  SESSIONS,
66
160
  LOGIN_ATTEMPTS,
@@ -73,7 +167,8 @@ var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
73
167
  ROLES,
74
168
  USER_IDENTITIES,
75
169
  AUDIT_LOGS,
76
- OUTBOX
170
+ OUTBOX,
171
+ RATE_LIMITS
77
172
  ]);
78
173
  var DEFAULT_ROLES = ["admin", "editor"];
79
174
  var CREATED_BY_FIELD = {
@@ -547,6 +642,17 @@ var outboxCollection = {
547
642
  { name: "error", type: "text" }
548
643
  ]
549
644
  };
645
+ var rateLimitsCollection = {
646
+ slug: RATE_LIMITS,
647
+ access: { read: nobody, create: nobody, update: nobody, delete: nobody },
648
+ fields: [
649
+ // `<anonymous|user|apiKey>:<id or HMAC of the IP>:<window number>`
650
+ { name: "key", type: "text", required: true, unique: true },
651
+ { name: "count", type: "number", required: true },
652
+ // When the window is over, for clearing old rows.
653
+ { name: "expiresAt", type: "text", required: true, index: true }
654
+ ]
655
+ };
550
656
  var emailDeliveriesCollection = {
551
657
  slug: EMAIL_DELIVERIES,
552
658
  access: { read: nobody, create: nobody, update: nobody, delete: nobody },
@@ -563,7 +669,7 @@ var emailDeliveriesCollection = {
563
669
 
564
670
  // src/audit.ts
565
671
  import { AsyncLocalStorage } from "async_hooks";
566
- import { createHmac } from "crypto";
672
+ import { createHmac as createHmac2 } from "crypto";
567
673
  var DEFAULT_AUDIT_KEEP = 365;
568
674
  var DEFAULT_FAILED_LOGINS = 20;
569
675
  var MAX_VALUE = 500;
@@ -778,7 +884,7 @@ var AuditLog = class {
778
884
  });
779
885
  for (const row of result.docs) {
780
886
  checked++;
781
- if (row.signature !== this.sign(row)) invalid.push(row.id);
887
+ if (!this.signer(row)) invalid.push(row.id);
782
888
  const id = Number(row.id);
783
889
  if (previous !== void 0 && Number.isInteger(id) && id > previous + 1)
784
890
  gaps += id - previous - 1;
@@ -797,6 +903,49 @@ var AuditLog = class {
797
903
  );
798
904
  return verification;
799
905
  }
906
+ /** The secret an entry was signed with: the current one, a previous one, or none (changed). */
907
+ signer(row) {
908
+ return secretsOf(this.cms.config).find((secret) => row.signature === this.sign(row, secret));
909
+ }
910
+ /**
911
+ * After `secret` changed: signs again, with the current secret, the entries signed with one of
912
+ * `previousSecrets`, so they still verify once it is removed. Entries no secret verifies were
913
+ * changed: they are left as they are, and reported. Records `audit.resign`.
914
+ */
915
+ async resign() {
916
+ const invalid = [];
917
+ let checked = 0;
918
+ let resigned = 0;
919
+ for (let page = 1; ; page++) {
920
+ const result = await this.cms.db.find({
921
+ collection: AUDIT_LOGS,
922
+ sort: ["id"],
923
+ limit: 500,
924
+ page
925
+ });
926
+ for (const row of result.docs) {
927
+ checked++;
928
+ const secret = this.signer(row);
929
+ if (secret === void 0) invalid.push(row.id);
930
+ else if (secret !== this.cms.config.secret) {
931
+ const { id, ...data } = row;
932
+ await this.cms.db.update({
933
+ collection: AUDIT_LOGS,
934
+ id,
935
+ data: { ...data, signature: this.sign(row) }
936
+ });
937
+ resigned++;
938
+ }
939
+ }
940
+ if (page >= result.totalPages) break;
941
+ }
942
+ await this.record({
943
+ action: "audit.resign",
944
+ target: "audit",
945
+ detail: { checked, resigned, invalid: invalid.length }
946
+ });
947
+ return { checked, resigned, invalid };
948
+ }
800
949
  /**
801
950
  * Daily upkeep from `runJobs`: deletes entries older than `keep` days, then verifies the log.
802
951
  * Runs when the last check is more than a day old.
@@ -847,11 +996,11 @@ var AuditLog = class {
847
996
  const scope = this.settings.scope;
848
997
  return (scope && context ? scope({ context, user }) : null) ?? null;
849
998
  }
850
- sign(row) {
999
+ sign(row, secret = this.cms.config.secret) {
851
1000
  const values = SIGNED.map((key) => row[key] ?? null);
852
1001
  if (typeof row.scope === "string" && row.scope) values.push(row.scope);
853
1002
  const payload = stable(values);
854
- return createHmac("sha256", this.cms.config.secret).update(`audit:${payload}`).digest("base64url");
1003
+ return createHmac2("sha256", secret).update(`audit:${payload}`).digest("base64url");
855
1004
  }
856
1005
  async lastVerification() {
857
1006
  const { docs } = await this.cms.db.find({
@@ -903,7 +1052,7 @@ function toEntry(row) {
903
1052
  }
904
1053
 
905
1054
  // src/api-keys.ts
906
- import { createHash, randomBytes, timingSafeEqual } from "crypto";
1055
+ import { createHash as createHash2, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "crypto";
907
1056
  var API_KEYS = "api-keys";
908
1057
  var API_KEY_OPERATIONS = ["read", "create", "update", "delete", "publish"];
909
1058
  var API_KEY_GLOBAL_OPERATIONS = ["read", "update", "publish"];
@@ -913,12 +1062,12 @@ function isApiKey(token) {
913
1062
  return token.startsWith("ecms_");
914
1063
  }
915
1064
  function newApiKey() {
916
- const prefix = randomBytes(4).toString("hex");
917
- const secret = randomBytes(32).toString("base64url");
1065
+ const prefix = randomBytes2(4).toString("hex");
1066
+ const secret = randomBytes2(32).toString("base64url");
918
1067
  return { key: `ecms_${prefix}_${secret}`, prefix, hash: hashSecret(secret) };
919
1068
  }
920
1069
  function hashSecret(secret) {
921
- return createHash("sha256").update(secret).digest("hex");
1070
+ return createHash2("sha256").update(secret).digest("hex");
922
1071
  }
923
1072
  function parseApiKey(token) {
924
1073
  const match = KEY_PATTERN.exec(token);
@@ -928,7 +1077,7 @@ function secretMatches(secret, hash) {
928
1077
  if (typeof hash !== "string") return false;
929
1078
  const a = Buffer.from(hashSecret(secret), "hex");
930
1079
  const b = Buffer.from(hash, "hex");
931
- return a.length === b.length && timingSafeEqual(a, b);
1080
+ return a.length === b.length && timingSafeEqual2(a, b);
932
1081
  }
933
1082
  function keyAllows(user, target, operation) {
934
1083
  const key = user?.apiKey;
@@ -1035,20 +1184,23 @@ function cleanPermissions(value, known) {
1035
1184
  }
1036
1185
 
1037
1186
  // src/spam.ts
1038
- import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
1187
+ import { createHmac as createHmac3, timingSafeEqual as timingSafeEqual3 } from "crypto";
1039
1188
  var MAX_AGE = 24 * 36e5;
1040
- var sign = (secret, value) => createHmac2("sha256", secret).update(value).digest("base64url");
1189
+ var sign = (secret, value) => createHmac3("sha256", secret).update(value).digest("base64url");
1041
1190
  function formToken(secret, purpose, now = Date.now()) {
1042
1191
  return `${now}.${sign(secret, `${purpose}:${now}`)}`;
1043
1192
  }
1044
- function checkFormToken(secret, purpose, token, minTime, now = Date.now()) {
1193
+ function checkFormToken(secrets, purpose, token, minTime, now = Date.now()) {
1045
1194
  if (typeof token !== "string") return "invalid";
1046
1195
  const [time, signature] = token.split(".");
1047
1196
  const issued = Number(time);
1048
1197
  if (!Number.isFinite(issued) || !signature) return "invalid";
1049
- const expected = Buffer.from(sign(secret, `${purpose}:${issued}`));
1050
1198
  const given = Buffer.from(signature);
1051
- if (expected.length !== given.length || !timingSafeEqual2(expected, given)) return "invalid";
1199
+ const genuine = [secrets].flat().some((secret) => {
1200
+ const expected = Buffer.from(sign(secret, `${purpose}:${issued}`));
1201
+ return expected.length === given.length && timingSafeEqual3(expected, given);
1202
+ });
1203
+ if (!genuine) return "invalid";
1052
1204
  if (now - issued < minTime) return "too-fast";
1053
1205
  if (now - issued > MAX_AGE) return "expired";
1054
1206
  return "ok";
@@ -1172,7 +1324,7 @@ var DEFAULT_PASSWORD_EMAILS = {
1172
1324
  };
1173
1325
 
1174
1326
  // src/auth/password.ts
1175
- import { randomBytes as randomBytes2, scrypt, timingSafeEqual as timingSafeEqual3 } from "crypto";
1327
+ import { randomBytes as randomBytes3, scrypt, timingSafeEqual as timingSafeEqual4 } from "crypto";
1176
1328
  var MIN_PASSWORD_LENGTH = 8;
1177
1329
  var N = 2 ** 17;
1178
1330
  var R = 8;
@@ -1191,7 +1343,7 @@ function derive(password, salt, n, r, p, length) {
1191
1343
  });
1192
1344
  }
1193
1345
  async function hashPassword(password) {
1194
- const salt = randomBytes2(16);
1346
+ const salt = randomBytes3(16);
1195
1347
  const key = await derive(password, salt, N, R, P, KEY_LENGTH);
1196
1348
  return ["scrypt", N, R, P, salt.toString("base64url"), key.toString("base64url")].join("$");
1197
1349
  }
@@ -1207,7 +1359,7 @@ async function verifyPassword(password, stored) {
1207
1359
  Number(p),
1208
1360
  expected.length
1209
1361
  );
1210
- return key.length === expected.length && timingSafeEqual3(key, expected);
1362
+ return key.length === expected.length && timingSafeEqual4(key, expected);
1211
1363
  }
1212
1364
  var dummyHash;
1213
1365
  async function fakeVerify(password) {
@@ -1218,92 +1370,6 @@ async function fakeVerify(password) {
1218
1370
 
1219
1371
  // src/auth/sso.ts
1220
1372
  import { createHmac as createHmac4, randomBytes as randomBytes4 } from "crypto";
1221
-
1222
- // src/auth/tokens.ts
1223
- import { createHash as createHash2, createHmac as createHmac3, randomBytes as randomBytes3, timingSafeEqual as timingSafeEqual4 } from "crypto";
1224
- function newToken() {
1225
- return randomBytes3(32).toString("base64url");
1226
- }
1227
- function hashToken(token) {
1228
- return createHash2("sha256").update(token).digest("hex");
1229
- }
1230
- function hmac(secret, value) {
1231
- return createHmac3("sha256", secret).update(value).digest("base64url");
1232
- }
1233
- function signToken(secret, token) {
1234
- return `${token}.${hmac(secret, `session:${token}`)}`;
1235
- }
1236
- function unsignToken(secret, value) {
1237
- const dot = value.lastIndexOf(".");
1238
- if (dot <= 0) return void 0;
1239
- const token = value.slice(0, dot);
1240
- return safeEqual(value.slice(dot + 1), hmac(secret, `session:${token}`)) ? token : void 0;
1241
- }
1242
- function csrfToken(secret, token) {
1243
- return hmac(secret, `csrf:${token}`);
1244
- }
1245
- function safeEqual(a, b) {
1246
- const left = Buffer.from(a);
1247
- const right = Buffer.from(b);
1248
- return left.length === right.length && timingSafeEqual4(left, right);
1249
- }
1250
- function signPreviewToken(secret, target, expiresAt, user) {
1251
- const payload = Buffer.from(
1252
- JSON.stringify({
1253
- ..."global" in target ? { g: target.global } : { c: target.collection, i: target.id },
1254
- e: expiresAt,
1255
- ...user !== void 0 ? { u: user } : {}
1256
- })
1257
- ).toString("base64url");
1258
- return `${payload}.${hmac(secret, `preview:${payload}`)}`;
1259
- }
1260
- function verifyPreviewToken(secret, token, now = Date.now()) {
1261
- const dot = token.lastIndexOf(".");
1262
- if (dot <= 0) return null;
1263
- const payload = token.slice(0, dot);
1264
- if (!safeEqual(token.slice(dot + 1), hmac(secret, `preview:${payload}`))) return null;
1265
- try {
1266
- const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
1267
- if (typeof data.e !== "number" || data.e <= now) return null;
1268
- const user = typeof data.u === "string" ? { user: data.u } : {};
1269
- if (typeof data.g === "string") return { global: data.g, expiresAt: data.e, ...user };
1270
- if (typeof data.c === "string" && typeof data.i === "string")
1271
- return { collection: data.c, id: data.i, expiresAt: data.e, ...user };
1272
- return null;
1273
- } catch {
1274
- return null;
1275
- }
1276
- }
1277
- function passwordFingerprint(passwordHash) {
1278
- return createHash2("sha256").update(typeof passwordHash === "string" ? passwordHash : "no-password").digest("base64url").slice(0, 22);
1279
- }
1280
- function signPasswordToken(secret, args) {
1281
- const payload = Buffer.from(
1282
- JSON.stringify({ u: args.userId, p: args.purpose, e: args.expiresAt })
1283
- ).toString("base64url");
1284
- return `${payload}.${hmac(secret, `password:${payload}:${args.fingerprint}`)}`;
1285
- }
1286
- function readPasswordToken(token) {
1287
- const dot = token.lastIndexOf(".");
1288
- if (dot <= 0) return null;
1289
- const payload = token.slice(0, dot);
1290
- try {
1291
- const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
1292
- if (typeof data.u !== "string" || typeof data.e !== "number") return null;
1293
- if (data.p !== "reset" && data.p !== "invite" && data.p !== "verify") return null;
1294
- return { userId: data.u, purpose: data.p, expiresAt: data.e, payload };
1295
- } catch {
1296
- return null;
1297
- }
1298
- }
1299
- function passwordTokenMatches(secret, token, fingerprint) {
1300
- const dot = token.lastIndexOf(".");
1301
- if (dot <= 0) return false;
1302
- const payload = token.slice(0, dot);
1303
- return safeEqual(token.slice(dot + 1), hmac(secret, `password:${payload}:${fingerprint}`));
1304
- }
1305
-
1306
- // src/auth/sso.ts
1307
1373
  var SSO_COOKIE = "ecms-sso";
1308
1374
  var PENDING_TTL = 10 * 6e4;
1309
1375
  var random = () => randomBytes4(32).toString("base64url");
@@ -1314,12 +1380,13 @@ function seal(secret, pending) {
1314
1380
  const payload = Buffer.from(JSON.stringify(pending)).toString("base64url");
1315
1381
  return `${payload}.${sign2(secret, payload)}`;
1316
1382
  }
1317
- function unseal(secret, value) {
1383
+ function unseal(secrets, value) {
1318
1384
  if (!value) return null;
1319
1385
  const dot = value.lastIndexOf(".");
1320
1386
  if (dot <= 0) return null;
1321
1387
  const payload = value.slice(0, dot);
1322
- if (!safeEqual(value.slice(dot + 1), sign2(secret, payload))) return null;
1388
+ const signature = value.slice(dot + 1);
1389
+ if (![secrets].flat().some((secret) => safeEqual(signature, sign2(secret, payload)))) return null;
1323
1390
  try {
1324
1391
  const pending = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
1325
1392
  return typeof pending.e === "number" && pending.e > Date.now() ? pending : null;
@@ -1387,7 +1454,7 @@ var SingleSignOn = class {
1387
1454
  */
1388
1455
  async finish(providerId, options) {
1389
1456
  const admin = this.config.admin.path.replace(/\/+$/, "");
1390
- const pending = unseal(this.config.secret, options.cookie);
1457
+ const pending = unseal(secretsOf(this.config), options.cookie);
1391
1458
  let email2 = null;
1392
1459
  const back = async (page2, outcome) => {
1393
1460
  await this.cms.audit.record({
@@ -1839,7 +1906,7 @@ var Auth = class {
1839
1906
  await this.recordFailure(key);
1840
1907
  throw refuse("form", "could not be sent");
1841
1908
  }
1842
- const checked = checkFormToken(this.config.secret, SIGNUP_FORM, args.token, SIGNUP_MIN_TIME);
1909
+ const checked = checkFormToken(this.secrets, SIGNUP_FORM, args.token, SIGNUP_MIN_TIME);
1843
1910
  if (checked !== "ok")
1844
1911
  throw refuse(
1845
1912
  "form",
@@ -1899,7 +1966,7 @@ var Auth = class {
1899
1966
  const id = claims && (/^\d+$/.test(claims.userId) ? Number(claims.userId) : claims.userId);
1900
1967
  const user = claims && claims.purpose === "verify" && claims.expiresAt > Date.now() && id !== null ? await this.db.findById({ collection: USERS, id }) : null;
1901
1968
  if (!user || user.active === false || // A link confirms once: it doesn't sign in again afterwards.
1902
- user.emailVerified !== false || !passwordTokenMatches(this.config.secret, token, passwordFingerprint(user.passwordHash))) {
1969
+ user.emailVerified !== false || !passwordTokenMatches(this.secrets, token, passwordFingerprint(user.passwordHash))) {
1903
1970
  await this.recordFailure(key);
1904
1971
  throw new ValidationError(USERS, [
1905
1972
  { field: "token", message: "This link has expired or is not valid" }
@@ -1969,7 +2036,7 @@ var Auth = class {
1969
2036
  }
1970
2037
  /** Ends the session for a signed token. Unknown tokens are ignored. */
1971
2038
  async logout(signedToken) {
1972
- const token = unsignToken(this.config.secret, signedToken);
2039
+ const token = unsignToken(this.secrets, signedToken);
1973
2040
  if (!token) return;
1974
2041
  const session = await this.findSession(token);
1975
2042
  if (!session) return;
@@ -1999,7 +2066,7 @@ var Auth = class {
1999
2066
  async verify(signedToken) {
2000
2067
  if (!signedToken) return null;
2001
2068
  if (isApiKey(signedToken)) return this.verifyApiKey(signedToken);
2002
- const token = unsignToken(this.config.secret, signedToken);
2069
+ const token = unsignToken(this.secrets, signedToken);
2003
2070
  if (!token) return null;
2004
2071
  const session = await this.findSession(token);
2005
2072
  if (!session) return null;
@@ -2019,9 +2086,21 @@ var Auth = class {
2019
2086
  }
2020
2087
  /** The CSRF token clients must echo for a signed session token. @internal */
2021
2088
  csrfFor(signedToken) {
2022
- const token = unsignToken(this.config.secret, signedToken);
2089
+ const token = unsignToken(this.secrets, signedToken);
2023
2090
  return token ? csrfToken(this.config.secret, token) : void 0;
2024
2091
  }
2092
+ /**
2093
+ * Whether `given` is the session's CSRF token, made with the current secret or a previous one
2094
+ * (a client that fetched it before the secret changed). @internal
2095
+ */
2096
+ csrfMatches(signedToken, given) {
2097
+ const token = unsignToken(this.secrets, signedToken);
2098
+ return token !== void 0 && csrfMatches(this.secrets, token, given);
2099
+ }
2100
+ /** The current secret and `previousSecrets`, which still verify. */
2101
+ get secrets() {
2102
+ return secretsOf(this.config);
2103
+ }
2025
2104
  /** Starts a new session for a user, e.g. after they changed their own password. */
2026
2105
  async createSession(userId) {
2027
2106
  const user = await this.db.findById({ collection: USERS, id: userId });
@@ -2144,7 +2223,7 @@ var Auth = class {
2144
2223
  const user = await this.db.findById({ collection: USERS, id });
2145
2224
  if (!user || user.active === false || !this.sso.passwordAllowed(user)) return null;
2146
2225
  const fingerprint = passwordFingerprint(user.passwordHash);
2147
- if (!passwordTokenMatches(this.config.secret, token, fingerprint)) return null;
2226
+ if (!passwordTokenMatches(this.secrets, token, fingerprint)) return null;
2148
2227
  return { user, purpose: claims.purpose };
2149
2228
  }
2150
2229
  async mailPasswordLink(user, purpose, options) {
@@ -3911,7 +3990,7 @@ var VersionStore = class {
3911
3990
  };
3912
3991
 
3913
3992
  // src/local-api.ts
3914
- import { createHash as createHash5, createHmac as createHmac7, randomBytes as randomBytes6, randomUUID as randomUUID3, timingSafeEqual as timingSafeEqual6 } from "crypto";
3993
+ import { createHash as createHash5, createHmac as createHmac8, randomBytes as randomBytes6, randomUUID as randomUUID3 } from "crypto";
3915
3994
 
3916
3995
  // src/auth/common-passwords.ts
3917
3996
  var LIST = `
@@ -4407,6 +4486,141 @@ function entryOf(row) {
4407
4486
  };
4408
4487
  }
4409
4488
 
4489
+ // src/rate-limit.ts
4490
+ import { createHmac as createHmac5 } from "crypto";
4491
+ var SYNC_MS = 5e3;
4492
+ var DEFAULTS = { window: 60, anonymous: 300, users: 1200, apiKeys: 1200 };
4493
+ var RateLimiter = class {
4494
+ constructor(db, secret, logger, settings) {
4495
+ this.db = db;
4496
+ this.secret = secret;
4497
+ this.logger = logger;
4498
+ this.settings = { ...DEFAULTS, ...settings };
4499
+ }
4500
+ db;
4501
+ secret;
4502
+ logger;
4503
+ counts = /* @__PURE__ */ new Map();
4504
+ settings;
4505
+ lastSync = Date.now();
4506
+ syncing;
4507
+ warnedNoIp = false;
4508
+ /**
4509
+ * Counts a request, or returns the seconds until the client may send another when it is over
4510
+ * its limit. Health checks and the cron's `jobs/run` aren't counted (the caller skips them).
4511
+ */
4512
+ hit(user, ip, now = Date.now()) {
4513
+ const client = this.client(user, ip);
4514
+ if (!client) return void 0;
4515
+ const [kind, id, limit] = client;
4516
+ const windowMs = this.settings.window * 1e3;
4517
+ const bucket = Math.floor(now / windowMs);
4518
+ const key = `${kind}:${id}:${bucket}`;
4519
+ let count = this.counts.get(key);
4520
+ if (!count) {
4521
+ count = { total: 0, pending: 0, ends: (bucket + 1) * windowMs };
4522
+ this.counts.set(key, count);
4523
+ }
4524
+ if (now - this.lastSync >= SYNC_MS && !this.syncing) void this.sync(now);
4525
+ if (count.total + count.pending >= limit)
4526
+ return Math.max(1, Math.ceil((count.ends - now) / 1e3));
4527
+ count.pending++;
4528
+ return void 0;
4529
+ }
4530
+ /** Who is counted, and their limit; `undefined` when nothing limits them. */
4531
+ client(user, ip) {
4532
+ const { anonymous, users, apiKeys } = this.settings;
4533
+ const key = user?.apiKey;
4534
+ if (key) return apiKeys === false ? void 0 : ["apiKey", String(key.id), apiKeys];
4535
+ if (user) return users === false ? void 0 : ["user", String(user.id), users];
4536
+ if (anonymous === false) return void 0;
4537
+ if (!ip) {
4538
+ if (!this.warnedNoIp) {
4539
+ this.warnedNoIp = true;
4540
+ this.logger.warn(
4541
+ "rateLimit: the client IP is unknown, so visitors who are not signed in are not limited. Behind a proxy, set trustProxy on the adapter."
4542
+ );
4543
+ }
4544
+ return void 0;
4545
+ }
4546
+ const hashed = createHmac5("sha256", this.secret).update(`rate:${ip}`).digest("base64url");
4547
+ return ["anonymous", hashed.slice(0, 22), anonymous];
4548
+ }
4549
+ /**
4550
+ * Adds what this instance counted to the database and reads the totals back; forgets windows
4551
+ * that are over. Runs in the background every few seconds; `await` it in tests.
4552
+ */
4553
+ sync(now = Date.now()) {
4554
+ this.lastSync = now;
4555
+ this.syncing ??= this.write(now).catch((error) => this.logger.warn(`rateLimit: counts not saved: ${String(error)}`)).finally(() => {
4556
+ this.syncing = void 0;
4557
+ });
4558
+ return this.syncing;
4559
+ }
4560
+ async write(now) {
4561
+ for (const [key, count] of this.counts) {
4562
+ if (count.ends <= now) {
4563
+ this.counts.delete(key);
4564
+ continue;
4565
+ }
4566
+ const added = count.pending;
4567
+ count.pending = 0;
4568
+ count.total = await this.add(key, count, added) ?? count.total + added;
4569
+ }
4570
+ }
4571
+ /** Adds `by` to the key's row (made if missing) and returns the new total. */
4572
+ async add(key, count, by) {
4573
+ count.id ??= await this.rowId(key);
4574
+ if (count.id === void 0) {
4575
+ const stamp = (/* @__PURE__ */ new Date()).toISOString();
4576
+ try {
4577
+ const created = await this.db.create({
4578
+ collection: RATE_LIMITS,
4579
+ data: {
4580
+ key,
4581
+ count: by,
4582
+ expiresAt: new Date(count.ends).toISOString(),
4583
+ createdAt: stamp,
4584
+ updatedAt: stamp
4585
+ }
4586
+ });
4587
+ count.id = created.id;
4588
+ return by;
4589
+ } catch (error) {
4590
+ count.id = await this.rowId(key);
4591
+ if (count.id === void 0) throw error;
4592
+ }
4593
+ }
4594
+ return this.db.increment({ collection: RATE_LIMITS, id: count.id, field: "count", by });
4595
+ }
4596
+ async rowId(key) {
4597
+ const { docs } = await this.db.find({
4598
+ collection: RATE_LIMITS,
4599
+ where: { key: { equals: key } },
4600
+ sort: [],
4601
+ limit: 1,
4602
+ page: 1
4603
+ });
4604
+ return docs[0]?.id;
4605
+ }
4606
+ /** Deletes the rows of windows that are over. From `runJobs`. */
4607
+ async clear(now = /* @__PURE__ */ new Date()) {
4608
+ let deleted = 0;
4609
+ for (; ; ) {
4610
+ const { docs } = await this.db.find({
4611
+ collection: RATE_LIMITS,
4612
+ where: { expiresAt: { lt: now.toISOString() } },
4613
+ sort: ["id"],
4614
+ limit: 500,
4615
+ page: 1
4616
+ });
4617
+ for (const row of docs) await this.db.delete({ collection: RATE_LIMITS, id: row.id });
4618
+ deleted += docs.length;
4619
+ if (docs.length < 500) return deleted;
4620
+ }
4621
+ }
4622
+ };
4623
+
4410
4624
  // src/remote-file.ts
4411
4625
  import { lookup as dnsLookup } from "dns";
4412
4626
  import { request as httpRequest } from "http";
@@ -4647,9 +4861,9 @@ function read(response, maxBytes, deadline) {
4647
4861
 
4648
4862
  // src/config-keys.ts
4649
4863
  var keys = (list) => new Set(list.split(/\s+/).filter(Boolean));
4650
- var CONFIG = keys(`secret db serverURL cors cronSecret timeZone webhooks events jobs localization routes admin
4864
+ var CONFIG = keys(`secret previousSecrets db serverURL cors cronSecret timeZone webhooks events jobs localization routes admin
4651
4865
  upload auth collections globals endpoints cliCommands onRequest fieldTypes apiKeys email backups
4652
- audit logs countCacheSeconds maxPage httpCache outbox search telemetry plugins`);
4866
+ audit logs countCacheSeconds maxPage httpCache outbox rateLimit search telemetry plugins`);
4653
4867
  var ADMIN = keys("path locale brand siteURL nav commands modules pages dashboard switcher");
4654
4868
  var AUTH = keys(`roles rbac tokenExpiration maxLoginAttempts lockWindow trustedOrigins
4655
4869
  resetPasswordExpiration inviteExpiration providers providerSignUp password setupCode members emails`);
@@ -4657,6 +4871,7 @@ var MEMBERS = keys("roles signUp pages emails");
4657
4871
  var UPLOAD = keys("dir maxFileSize mimeTypes storage imageSizes fromURL folders privateStorage");
4658
4872
  var BACKUPS = keys("frequency at keep dir storage sqlite encryptionKey");
4659
4873
  var AUDIT = keys("keepDays values failedLogins scope");
4874
+ var RATE_LIMIT = keys("window anonymous users apiKeys");
4660
4875
  var LOGS = keys("format requests");
4661
4876
  var COLLECTION = keys(`slug labels fields useAsTitle drafts versions schedule preview access hooks
4662
4877
  admin httpCache search`);
@@ -4782,6 +4997,7 @@ function checkConfigKeys(config, add) {
4782
4997
  check2(c.upload, "upload", "upload", UPLOAD, add);
4783
4998
  check2(c.backups, "backups", "backups", BACKUPS, add);
4784
4999
  if (isObject(c.audit)) check2(c.audit, "audit", "audit", AUDIT, add);
5000
+ if (isObject(c.rateLimit)) check2(c.rateLimit, "rateLimit", "rateLimit", RATE_LIMIT, add);
4785
5001
  check2(c.logs, "logs", "logs", LOGS, add);
4786
5002
  for (const [kind, list] of [
4787
5003
  ["collections", c.collections],
@@ -5138,13 +5354,13 @@ var SearchIndex = class {
5138
5354
  };
5139
5355
 
5140
5356
  // src/webhooks.ts
5141
- import { createHmac as createHmac6, randomUUID as randomUUID2 } from "crypto";
5357
+ import { createHmac as createHmac7, randomUUID as randomUUID2 } from "crypto";
5142
5358
 
5143
5359
  // src/webhook-signature.ts
5144
- import { createHmac as createHmac5, timingSafeEqual as timingSafeEqual5 } from "crypto";
5360
+ import { createHmac as createHmac6, timingSafeEqual as timingSafeEqual5 } from "crypto";
5145
5361
  var TOLERANCE_SECONDS = 5 * 60;
5146
5362
  function signWebhook(secret, delivery, timestamp, body) {
5147
- return `v1=${createHmac5("sha256", secret).update(`${delivery}.${timestamp}.${body}`).digest("hex")}`;
5363
+ return `v1=${createHmac6("sha256", secret).update(`${delivery}.${timestamp}.${body}`).digest("hex")}`;
5148
5364
  }
5149
5365
  function header(headers, name) {
5150
5366
  if (headers instanceof Headers) return headers.get(name) ?? void 0;
@@ -5345,7 +5561,7 @@ var Webhooks = class {
5345
5561
  const timestamp = Math.floor(Date.now() / 1e3);
5346
5562
  headers["x-easy-cms-timestamp"] = String(timestamp);
5347
5563
  headers["x-easy-cms-signature-v2"] = signWebhook(hook.secret, delivery, timestamp, body);
5348
- headers["x-easy-cms-signature"] = `sha256=${createHmac6("sha256", hook.secret).update(body).digest("hex")}`;
5564
+ headers["x-easy-cms-signature"] = `sha256=${createHmac7("sha256", hook.secret).update(body).digest("hex")}`;
5349
5565
  }
5350
5566
  try {
5351
5567
  const response = await this.fetcher(hook.url, {
@@ -5417,6 +5633,7 @@ function validateConfig(config) {
5417
5633
  return issues;
5418
5634
  }
5419
5635
  validateSecret(config.secret, add);
5636
+ validatePreviousSecrets(config.previousSecrets, config.secret, add);
5420
5637
  validateCronSecret(config.cronSecret, (issue) => issues.push(issue));
5421
5638
  checkConfigKeys(config, (issue) => issues.push(issue));
5422
5639
  validateAdapterVersions(config, add);
@@ -5502,6 +5719,22 @@ function validateSecret(secret, add) {
5502
5719
  add("secret", "looks like an example or a repeated pattern, not a random string", hint);
5503
5720
  }
5504
5721
  }
5722
+ function validatePreviousSecrets(value, secret, add) {
5723
+ if (value === void 0) return;
5724
+ if (!Array.isArray(value)) {
5725
+ add("previousSecrets", "must be an array of strings", "e.g. previousSecrets: [oldSecret]");
5726
+ return;
5727
+ }
5728
+ for (const [i, previous] of value.entries()) {
5729
+ if (typeof previous !== "string" || previous.length < MIN_SECRET_LENGTH)
5730
+ add(
5731
+ `previousSecrets[${i}]`,
5732
+ `must be a string of at least ${MIN_SECRET_LENGTH} characters, like secret`
5733
+ );
5734
+ else if (previous === secret)
5735
+ add(`previousSecrets[${i}]`, "is the same as secret", "list only secrets used before it");
5736
+ }
5737
+ }
5505
5738
  function validateCronSecret(value, push) {
5506
5739
  const hint = `use a random string of at least ${MIN_SECRET_LENGTH} characters, e.g. \`openssl rand -hex 32\``;
5507
5740
  if (value !== void 0) {
@@ -7013,6 +7246,7 @@ async function resolveConfig(input) {
7013
7246
  ...config.email ? [emailDeliveriesCollection] : [],
7014
7247
  ...(config.jobs?.length ?? 0) > 0 ? [jobRunsCollection] : [],
7015
7248
  ...config.outbox ? [outboxCollection] : [],
7249
+ ...config.rateLimit ? [rateLimitsCollection] : [],
7016
7250
  databaseBackupsCollection,
7017
7251
  ...config.auth?.rbac ? [rolesCollection] : [],
7018
7252
  ...(config.auth?.providers?.length ?? 0) > 0 ? [userIdentitiesCollection] : [],
@@ -7083,7 +7317,7 @@ function cacheableResponse(request, body, status, headers, times, tags) {
7083
7317
  }
7084
7318
 
7085
7319
  // src/version.ts
7086
- var VERSION = true ? "1.17.0" : "0.0.0-dev";
7320
+ var VERSION = true ? "1.19.0" : "0.0.0-dev";
7087
7321
 
7088
7322
  // src/telemetry.ts
7089
7323
  var DB_CALLS = /* @__PURE__ */ new Set([
@@ -7361,6 +7595,8 @@ var EasyCMS = class {
7361
7595
  mailer;
7362
7596
  /** With `outbox: true`: runs after hooks and events from rows saves write. */
7363
7597
  outbox;
7598
+ /** @internal Requests per client (`rateLimit`), for the REST API. */
7599
+ rateLimiter;
7364
7600
  /** With `search`: keeps the index in step with published documents. */
7365
7601
  searchIndex;
7366
7602
  /** @internal With `telemetry`: OpenTelemetry spans and metrics (set by `createEasyCMS`). */
@@ -7401,6 +7637,7 @@ var EasyCMS = class {
7401
7637
  () => this.runOutbox(entry, done, mark)
7402
7638
  ) : this.runOutbox(entry, done, mark)
7403
7639
  ) : void 0;
7640
+ this.rateLimiter = config.rateLimit ? new RateLimiter(db, config.secret, logger, config.rateLimit) : void 0;
7404
7641
  this.searchIndex = config.search ? new SearchIndex(
7405
7642
  config.search,
7406
7643
  config.collections,
@@ -7747,18 +7984,14 @@ var EasyCMS = class {
7747
7984
  /** A value with its HMAC (`secret`), for `purpose` only: `<base64url JSON>.<signature>`. */
7748
7985
  sign(purpose, value) {
7749
7986
  const payload = Buffer.from(JSON.stringify(value)).toString("base64url");
7750
- const mac = createHmac7("sha256", this.config.secret).update(`${purpose}:${payload}`);
7987
+ const mac = createHmac8("sha256", this.config.secret).update(`${purpose}:${payload}`);
7751
7988
  return `${payload}.${mac.digest("base64url")}`;
7752
7989
  }
7753
7990
  /** The value of a token made by `sign`, or `undefined` when it isn't genuine. */
7754
7991
  verify(purpose, token) {
7755
7992
  const [payload, signature] = String(token).split(".");
7756
7993
  if (!payload || !signature) return void 0;
7757
- const expected = Buffer.from(
7758
- createHmac7("sha256", this.config.secret).update(`${purpose}:${payload}`).digest("base64url")
7759
- );
7760
- const given = Buffer.from(signature);
7761
- if (expected.length !== given.length || !timingSafeEqual6(expected, given)) return void 0;
7994
+ if (!signedWith(secretsOf(this.config), `${purpose}:${payload}`, signature)) return void 0;
7762
7995
  try {
7763
7996
  return JSON.parse(Buffer.from(payload, "base64url").toString());
7764
7997
  } catch {
@@ -7875,12 +8108,10 @@ var EasyCMS = class {
7875
8108
  verifyMediaSignature(key, expires, signature) {
7876
8109
  const at = Number(expires);
7877
8110
  if (!signature || !Number.isInteger(at) || at < Date.now() / 1e3) return false;
7878
- const expected = Buffer.from(this.mediaSignature(key, at));
7879
- const given = Buffer.from(signature);
7880
- return expected.length === given.length && timingSafeEqual6(expected, given);
8111
+ return signedWith(secretsOf(this.config), `media:${key}:${at}`, signature);
7881
8112
  }
7882
8113
  mediaSignature(key, expires) {
7883
- return createHmac7("sha256", this.config.secret).update(`media:${key}:${expires}`).digest("base64url");
8114
+ return createHmac8("sha256", this.config.secret).update(`media:${key}:${expires}`).digest("base64url");
7884
8115
  }
7885
8116
  /**
7886
8117
  * How many documents use any of these files in a top-level upload field (rich text and fields
@@ -8170,7 +8401,7 @@ var EasyCMS = class {
8170
8401
  }
8171
8402
  /** What a preview token opens, or `null` when it is invalid or expired. */
8172
8403
  verifyPreviewToken(token) {
8173
- return token ? verifyPreviewToken(this.config.secret, token) : null;
8404
+ return token ? verifyPreviewToken(secretsOf(this.config), token) : null;
8174
8405
  }
8175
8406
  /** Live preview of a global. See `preview`. */
8176
8407
  async previewGlobal(slug2, data, options = {}) {
@@ -8897,6 +9128,7 @@ var EasyCMS = class {
8897
9128
  (error) => this.logger.error(`Backups: ${error.message}`, { error })
8898
9129
  );
8899
9130
  await this.audit.upkeep(now).catch((error) => this.logger.error(`Audit log: ${error.message}`, { error }));
9131
+ await this.rateLimiter?.clear(now).catch((error) => this.logger.error(`Rate limits: ${error.message}`, { error }));
8900
9132
  const result = {
8901
9133
  scheduled,
8902
9134
  webhooks: await this.retryWebhooks(now),
@@ -12165,6 +12397,10 @@ async function respondNow(cms, request, url, path, root, options, production, re
12165
12397
  trustProxy: options.trustProxy === true
12166
12398
  };
12167
12399
  const method = request.method.toUpperCase();
12400
+ const isJobsRun = !root && segments[0] === "jobs" && segments[1] === "run";
12401
+ const wait = isJobsRun ? void 0 : cms.rateLimiter?.hit(user, options.getClientIp?.(request));
12402
+ if (wait !== void 0)
12403
+ throw new TooManyRequestsError("Too many requests: try again later", wait);
12168
12404
  if (method !== "GET" && method !== "HEAD") checkCsrf(cms, ctx);
12169
12405
  const audit = {
12170
12406
  user,
@@ -13136,9 +13372,8 @@ function checkCsrf(cms, ctx) {
13136
13372
  if (origin === null && fetchSite === "cross-site")
13137
13373
  throw new ForbiddenError("CSRF check failed: cross-site request");
13138
13374
  if (ctx.via === "cookie" && ctx.token) {
13139
- const expected = cms.auth.csrfFor(ctx.token);
13140
13375
  const given = ctx.request.headers.get(CSRF_HEADER);
13141
- if (!expected || !given || !safeEqual(given, expected)) {
13376
+ if (!given || !cms.auth.csrfMatches(ctx.token, given)) {
13142
13377
  throw new ForbiddenError(
13143
13378
  `CSRF check failed: send the ${CSRF_HEADER} header from GET /auth/me`
13144
13379
  );
@@ -13538,6 +13773,7 @@ export {
13538
13773
  isStaff,
13539
13774
  isSignedIn,
13540
13775
  isAdmin,
13776
+ secretsOf,
13541
13777
  MEDIA,
13542
13778
  MEDIA_FOLDERS,
13543
13779
  INTERNAL_COLLECTIONS,
@@ -356,6 +356,16 @@ interface BundleHints {
356
356
  readonly traceInclude: readonly string[];
357
357
  }
358
358
 
359
+ /**
360
+ * Secrets that verify: the current one first, then `previousSecrets` while one is replaced. New
361
+ * values are always signed with the current one.
362
+ */
363
+ type Secrets = string | readonly string[];
364
+ /** The secrets of a config, the current one first. */
365
+ declare function secretsOf(config: {
366
+ readonly secret: string;
367
+ readonly previousSecrets?: readonly string[] | undefined;
368
+ }): readonly string[];
359
369
  /** What a preview token opens: one document of a collection, or one global. */
360
370
  type PreviewTarget = {
361
371
  readonly collection: string;
@@ -613,6 +623,8 @@ declare class Auth {
613
623
  * on the user), or `null` if it is invalid or expired.
614
624
  */
615
625
  verify(signedToken: string | undefined | null): Promise<AuthUser | null>;
626
+ /** The current secret and `previousSecrets`, which still verify. */
627
+ private get secrets();
616
628
  /** Starts a new session for a user, e.g. after they changed their own password. */
617
629
  createSession(userId: ID): Promise<Session>;
618
630
  /** Signs a user out everywhere. */
@@ -2460,6 +2472,18 @@ declare class AuditLog {
2460
2472
  csv(filter: AuditFilter): Promise<string>;
2461
2473
  /** Checks every entry's signature, and that no ids are missing. */
2462
2474
  verify(): Promise<AuditVerification>;
2475
+ /** The secret an entry was signed with: the current one, a previous one, or none (changed). */
2476
+ private signer;
2477
+ /**
2478
+ * After `secret` changed: signs again, with the current secret, the entries signed with one of
2479
+ * `previousSecrets`, so they still verify once it is removed. Entries no secret verifies were
2480
+ * changed: they are left as they are, and reported. Records `audit.resign`.
2481
+ */
2482
+ resign(): Promise<{
2483
+ checked: number;
2484
+ resigned: number;
2485
+ invalid: ID[];
2486
+ }>;
2463
2487
  /** What the dashboard warns about: many failed logins, entries not written, a failed check. */
2464
2488
  attention(now?: Date): Promise<({
2465
2489
  id: 'failed-logins';
@@ -3342,6 +3366,20 @@ interface AuthConfig {
3342
3366
  readonly passwordChanged?: PasswordEmailFn;
3343
3367
  };
3344
3368
  }
3369
+ interface RateLimitConfig {
3370
+ /** Seconds each count lasts. Default 60. */
3371
+ readonly window?: number;
3372
+ /**
3373
+ * Requests per window from one IP without signing in. Default 300; `false`: no limit. Needs the
3374
+ * client's IP: behind a proxy, set `trustProxy` on the adapter (without it, visitors aren't
3375
+ * limited, and the log says so once).
3376
+ */
3377
+ readonly anonymous?: number | false;
3378
+ /** Requests per window from one signed-in user (the admin included). Default 1200; `false`: no limit. */
3379
+ readonly users?: number | false;
3380
+ /** Requests per window with one API key. Default 1200; `false`: no limit. */
3381
+ readonly apiKeys?: number | false;
3382
+ }
3345
3383
  /** What `onRequest` receives. */
3346
3384
  interface OnRequestArgs {
3347
3385
  readonly headers: Headers;
@@ -3531,6 +3569,13 @@ interface BackupsConfig {
3531
3569
  interface Config {
3532
3570
  /** Signs sessions. At least 32 characters; read it from `process.env.EASY_CMS_SECRET`. */
3533
3571
  readonly secret: string;
3572
+ /**
3573
+ * Secrets used before `secret`, while it is replaced: what they signed (sessions, links,
3574
+ * tokens, the audit log) still verifies; new values are signed with `secret` only. Keep one
3575
+ * until sessions and links made with it have expired (7 days by default), and run
3576
+ * `easy-cms audit:resign` before removing it. At least 32 characters each, like `secret`.
3577
+ */
3578
+ readonly previousSecrets?: readonly string[];
3534
3579
  readonly db: DatabaseAdapter;
3535
3580
  /**
3536
3581
  * Public origin of the app, e.g. `https://example.com`. When set, media URLs are absolute
@@ -3642,6 +3687,13 @@ interface Config {
3642
3687
  * Default `false`: hooks run in the request, after the save.
3643
3688
  */
3644
3689
  readonly outbox?: boolean;
3690
+ /**
3691
+ * Limits requests to the API per client and minute, counted across instances: each visitor's
3692
+ * IP, each signed-in user and each API key. Over the limit, the API answers `429` with
3693
+ * `Retry-After`. `{}` uses the defaults. Needs a migration for the `rate-limits` table. Default
3694
+ * off.
3695
+ */
3696
+ readonly rateLimit?: RateLimitConfig;
3645
3697
  /**
3646
3698
  * Relevance-ranked search of the collections that have `search`, e.g. `memorySearch()` for
3647
3699
  * development and small sites, or an adapter for a search engine. Published documents are
@@ -3687,5 +3739,5 @@ interface ResolvedConfig extends Omit<Config, 'cors' | 'localization' | 'routes'
3687
3739
  */
3688
3740
  declare function defineConfig<const TConfig extends Config>(config: TConfig): TConfig;
3689
3741
 
3690
- export { ADAPTER_API_VERSION as A, EasyCMS as E, AuditLog as S, Auth as V, EasyCMSError as aD, FIELD_TYPES as aK, ConfigError as al, ForbiddenError as b2, PLUGIN_API_VERSION as bB, PayloadTooLargeError as bG, QueryError as bM, ROLE_OPERATIONS as bN, Roles as bX, SchemaError as b_, NotFoundError as bv, API_KEYS as c$, WEBHOOK_EVENTS as cC, anyone as cH, consoleEmail as cI, consoleLogger as cJ, createEasyCMS as cK, defineConfig as cL, defineFieldType as cM, definePlugin as cN, diskStorage as cO, hasRows as cP, isAdmin as cQ, isHasMany as cR, isSignedIn as cS, isStaff as cT, isSystemAdmin as cU, jsonLogger as cV, memorySearch as cW, mimeAllowedBy as cX, rowFields as cY, silentLogger as cZ, uniqueWithinOf as c_, SingleSignOn as cd, TRIGRAM_LIKE_MIN as ck, TooManyRequestsError as co, UnauthorizedError as cq, ValidationError as cy, API_KEY_GLOBAL_OPERATIONS as d0, API_KEY_OPERATIONS as d1, DEFAULT_MAX_VERSIONS as d2, DEFAULT_PASSWORD_EMAILS as d3, FIELD_TYPE_BASES as d4, FOLDER_LEVELS as d5, MediaFolders as d6, SSO_COOKIE as d7, codeOfStatus as d8, isJsonLogger as d9, keyAllows as da, logFormat as db, ADMIN_ICONS as e };
3691
- export type { AuthUser as $, AfterDeleteHook as B, Config as C, AfterReadHook as D, Field as F, ApiKeyContext as G, ApiKeyGlobalOperation as H, ID as I, ApiKeyOperation as J, ApiKeyPermissions as K, LocalizationConfig as L, ArrayField as M, AuditChange as N, AuditConfig as O, PaginatedDocs as P, AuditEntry as Q, ResolvedConfig as R, AuditPage as T, AuditVerification as U, WebhookPayload as W, AuthConfig as X, AuthProvider as Y, AuthProviderProfile as Z, AuthProviderRequest as _, FieldError as a, FindOptions as a$, BackupsConfig as a0, BeforeChangeHook as a1, BeforeDeleteHook as a2, BeforeValidateHook as a3, Block as a4, BlocksField as a5, BooleanField as a6, BuiltinApiKeysCollection as a7, BuiltinMediaCollection as a8, BuiltinMediaFoldersCollection as a9, DirectUpload as aA, DiskStorageOptions as aB, DocumentOf as aC, EmailAdapter as aE, EmailField as aF, EmailMessage as aG, Endpoint as aH, EndpointRequest as aI, ErrorCode as aJ, FieldAccess as aL, FieldAccessFn as aM, FieldAdmin as aN, FieldRule as aO, FieldType as aP, FieldTypeBase as aQ, FieldTypeDefinition as aR, FieldTypeValidateContext as aS, FieldValidate as aT, FieldValidateContext as aU, FieldValue as aV, FieldsInput as aW, FieldsValue as aX, FilterOptionsArgs as aY, FindAfterArgs as aZ, FindArgs as a_, BuiltinUsersCollection as aa, BundleHints as ab, CliCommand as ac, CollectionAccess as ad, CollectionAdmin as ae, CollectionConfig as af, CollectionDocument as ag, CollectionHooks as ah, CollectionListAdmin as ai, CollectionSearch as aj, CollectionSlug as ak, ConfigIssue as am, ContainerAdmin as an, CreateInput as ao, CreateMigrationResult as ap, CursorDocs as aq, CustomField as ar, CustomFieldTypes as as, DashboardWidget as at, Database as au, DatabaseAdapter as av, DatabaseInitArgs as aw, DatabaseTransfer as ax, DateField as ay, DepthOptions as az, FilterOptions as b, SchemaMode as b$, FolderLevel as b0, FolderPermissions as b1, GlobalAccess as b3, GlobalConfig as b4, GlobalDocument as b5, GlobalHooks as b6, GlobalInput as b7, GlobalSlug as b8, GroupField as b9, OutboxRow as bA, PasswordEmail as bC, PasswordEmailArgs as bD, PasswordEmailFn as bE, PasswordLinkOptions as bF, Plugin as bH, PluginInfo as bI, PluginTypes as bJ, PreviewTarget as bK, PreviewURL as bL, RawDocument as bO, ReadOptions as bP, RelationshipField as bQ, RequestContext as bR, RichTextDocument as bS, RichTextField as bT, Role as bU, RoleOperation as bV, RolePermissions as bW, RoutesConfig as bY, ScheduledPublish as bZ, ImageSize as ba, IncrementOptions as bb, InferCollection as bc, InferGlobal as bd, JobConfig as be, JsonField as bf, KeysetPage as bg, Label as bh, LayoutNode as bi, LivePreview as bj, LocaleOf as bk, LogDetails as bl, Logger as bm, LoginArgs as bn, LogsConfig as bo, MediaDocument as bp, MediaSize as bq, MembersConfig as br, MembersSignup as bs, MigrationInfo as bt, NavGroup as bu, NumberField as bw, OnRequest as bx, OnRequestArgs as by, Operation as bz, CreateEasyCMSOptions as c, SearchAdapter as c0, SearchEntry as c1, SearchHits as c2, SearchOptions as c3, SearchQuery as c4, SearchResults as c5, SearchSetting as c6, SelectField as c7, SelectOption as c8, Session as c9, VersionSummary as cA, VersionsConfig as cB, WebhookConfig as cD, WebhookEvent as cE, Where as cF, WhereOperators as cG, SidebarPanel as ca, SignupArgs as cb, SignupResult as cc, SlugField as ce, SlugOf as cf, SsoOutcome as cg, SsoProviderRef as ch, StorageAdapter as ci, StoredFile as cj, TelemetryConfig as cl, TextField as cm, TextareaField as cn, TypedPlugin as cp, UncountedDocs as cr, UpdateInput as cs, UpdateOptions as ct, UploadConfig as cu, UploadField as cv, UploadFromURLConfig as cw, UserIdentity as cx, Version as cz, EmailSetting as d, Access as f, AccessArgs as g, AccessOptions as h, AdminBrand as i, AdminCommand as j, AdminComponent as k, AdminConfig as l, AdminFieldArgs as m, AdminIcon as n, AdminLocale as o, AdminPage as p, AdminRole as q, AdminRoleChange as r, AdminRoleField as s, AdminRoleTarget as t, AdminRoleView as u, AdminRoles as v, AdminSso as w, AdminSwitcher as x, AdminViewAccess as y, AfterChangeHook as z };
3742
+ export { ADAPTER_API_VERSION as A, EasyCMS as E, AuditLog as S, Auth as V, EasyCMSError as aD, FIELD_TYPES as aK, ConfigError as al, ForbiddenError as b2, PLUGIN_API_VERSION as bB, PayloadTooLargeError as bG, QueryError as bM, ROLE_OPERATIONS as bN, Roles as bX, SchemaError as b_, NotFoundError as bv, API_KEYS as c$, WEBHOOK_EVENTS as cC, anyone as cH, consoleEmail as cI, consoleLogger as cJ, createEasyCMS as cK, defineConfig as cL, defineFieldType as cM, definePlugin as cN, diskStorage as cO, hasRows as cP, isAdmin as cQ, isHasMany as cR, isSignedIn as cS, isStaff as cT, isSystemAdmin as cU, jsonLogger as cV, memorySearch as cW, mimeAllowedBy as cX, rowFields as cY, silentLogger as cZ, uniqueWithinOf as c_, SingleSignOn as cd, TRIGRAM_LIKE_MIN as ck, TooManyRequestsError as co, UnauthorizedError as cq, ValidationError as cy, API_KEY_GLOBAL_OPERATIONS as d0, API_KEY_OPERATIONS as d1, DEFAULT_MAX_VERSIONS as d2, DEFAULT_PASSWORD_EMAILS as d3, FIELD_TYPE_BASES as d4, FOLDER_LEVELS as d5, MediaFolders as d6, SSO_COOKIE as d7, codeOfStatus as d9, isJsonLogger as da, keyAllows as db, logFormat as dc, secretsOf as dd, ADMIN_ICONS as e };
3743
+ export type { AuthUser as $, AfterDeleteHook as B, Config as C, AfterReadHook as D, Field as F, ApiKeyContext as G, ApiKeyGlobalOperation as H, ID as I, ApiKeyOperation as J, ApiKeyPermissions as K, LocalizationConfig as L, ArrayField as M, AuditChange as N, AuditConfig as O, PaginatedDocs as P, AuditEntry as Q, ResolvedConfig as R, AuditPage as T, AuditVerification as U, WebhookPayload as W, AuthConfig as X, AuthProvider as Y, AuthProviderProfile as Z, AuthProviderRequest as _, FieldError as a, FindOptions as a$, BackupsConfig as a0, BeforeChangeHook as a1, BeforeDeleteHook as a2, BeforeValidateHook as a3, Block as a4, BlocksField as a5, BooleanField as a6, BuiltinApiKeysCollection as a7, BuiltinMediaCollection as a8, BuiltinMediaFoldersCollection as a9, DirectUpload as aA, DiskStorageOptions as aB, DocumentOf as aC, EmailAdapter as aE, EmailField as aF, EmailMessage as aG, Endpoint as aH, EndpointRequest as aI, ErrorCode as aJ, FieldAccess as aL, FieldAccessFn as aM, FieldAdmin as aN, FieldRule as aO, FieldType as aP, FieldTypeBase as aQ, FieldTypeDefinition as aR, FieldTypeValidateContext as aS, FieldValidate as aT, FieldValidateContext as aU, FieldValue as aV, FieldsInput as aW, FieldsValue as aX, FilterOptionsArgs as aY, FindAfterArgs as aZ, FindArgs as a_, BuiltinUsersCollection as aa, BundleHints as ab, CliCommand as ac, CollectionAccess as ad, CollectionAdmin as ae, CollectionConfig as af, CollectionDocument as ag, CollectionHooks as ah, CollectionListAdmin as ai, CollectionSearch as aj, CollectionSlug as ak, ConfigIssue as am, ContainerAdmin as an, CreateInput as ao, CreateMigrationResult as ap, CursorDocs as aq, CustomField as ar, CustomFieldTypes as as, DashboardWidget as at, Database as au, DatabaseAdapter as av, DatabaseInitArgs as aw, DatabaseTransfer as ax, DateField as ay, DepthOptions as az, FilterOptions as b, SchemaMode as b$, FolderLevel as b0, FolderPermissions as b1, GlobalAccess as b3, GlobalConfig as b4, GlobalDocument as b5, GlobalHooks as b6, GlobalInput as b7, GlobalSlug as b8, GroupField as b9, OutboxRow as bA, PasswordEmail as bC, PasswordEmailArgs as bD, PasswordEmailFn as bE, PasswordLinkOptions as bF, Plugin as bH, PluginInfo as bI, PluginTypes as bJ, PreviewTarget as bK, PreviewURL as bL, RawDocument as bO, ReadOptions as bP, RelationshipField as bQ, RequestContext as bR, RichTextDocument as bS, RichTextField as bT, Role as bU, RoleOperation as bV, RolePermissions as bW, RoutesConfig as bY, ScheduledPublish as bZ, ImageSize as ba, IncrementOptions as bb, InferCollection as bc, InferGlobal as bd, JobConfig as be, JsonField as bf, KeysetPage as bg, Label as bh, LayoutNode as bi, LivePreview as bj, LocaleOf as bk, LogDetails as bl, Logger as bm, LoginArgs as bn, LogsConfig as bo, MediaDocument as bp, MediaSize as bq, MembersConfig as br, MembersSignup as bs, MigrationInfo as bt, NavGroup as bu, NumberField as bw, OnRequest as bx, OnRequestArgs as by, Operation as bz, CreateEasyCMSOptions as c, SearchAdapter as c0, SearchEntry as c1, SearchHits as c2, SearchOptions as c3, SearchQuery as c4, SearchResults as c5, SearchSetting as c6, SelectField as c7, SelectOption as c8, Session as c9, VersionSummary as cA, VersionsConfig as cB, WebhookConfig as cD, WebhookEvent as cE, Where as cF, WhereOperators as cG, SidebarPanel as ca, SignupArgs as cb, SignupResult as cc, SlugField as ce, SlugOf as cf, SsoOutcome as cg, SsoProviderRef as ch, StorageAdapter as ci, StoredFile as cj, TelemetryConfig as cl, TextField as cm, TextareaField as cn, TypedPlugin as cp, UncountedDocs as cr, UpdateInput as cs, UpdateOptions as ct, UploadConfig as cu, UploadField as cv, UploadFromURLConfig as cw, UserIdentity as cx, Version as cz, EmailSetting as d, Secrets as d8, Access as f, AccessArgs as g, AccessOptions as h, AdminBrand as i, AdminCommand as j, AdminComponent as k, AdminConfig as l, AdminFieldArgs as m, AdminIcon as n, AdminLocale as o, AdminPage as p, AdminRole as q, AdminRoleChange as r, AdminRoleField as s, AdminRoleTarget as t, AdminRoleView as u, AdminRoles as v, AdminSso as w, AdminSwitcher as x, AdminViewAccess as y, AfterChangeHook as z };
@@ -1,4 +1,4 @@
1
- import { I as ID, C as Config, E as EasyCMS, L as LocalizationConfig, F as Field, a as FieldError, b as FilterOptions, c as CreateEasyCMSOptions, R as ResolvedConfig } from './config-CWTw3zFm.js';
1
+ import { I as ID, C as Config, E as EasyCMS, L as LocalizationConfig, F as Field, a as FieldError, b as FilterOptions, c as CreateEasyCMSOptions, R as ResolvedConfig } from './config-U1mRwD4t.js';
2
2
 
3
3
  /** Decrypts a file from `encryptBackup`; throws for another key or a changed file. */
4
4
  declare function decryptBackup(data: Uint8Array, encryptionKey: string): Uint8Array;
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { P as PaginatedDocs, I as ID, d as EmailSetting, W as WebhookPayload } from './config-CWTw3zFm.js';
2
- export { A as ADAPTER_API_VERSION, e as ADMIN_ICONS, f as Access, g as AccessArgs, h as AccessOptions, i as AdminBrand, j as AdminCommand, k as AdminComponent, l as AdminConfig, m as AdminFieldArgs, n as AdminIcon, o as AdminLocale, p as AdminPage, q as AdminRole, r as AdminRoleChange, s as AdminRoleField, t as AdminRoleTarget, u as AdminRoleView, v as AdminRoles, w as AdminSso, x as AdminSwitcher, y as AdminViewAccess, z as AfterChangeHook, B as AfterDeleteHook, D as AfterReadHook, G as ApiKeyContext, H as ApiKeyGlobalOperation, J as ApiKeyOperation, K as ApiKeyPermissions, M as ArrayField, N as AuditChange, O as AuditConfig, Q as AuditEntry, S as AuditLog, T as AuditPage, U as AuditVerification, V as Auth, X as AuthConfig, Y as AuthProvider, Z as AuthProviderProfile, _ as AuthProviderRequest, $ as AuthUser, a0 as BackupsConfig, a1 as BeforeChangeHook, a2 as BeforeDeleteHook, a3 as BeforeValidateHook, a4 as Block, a5 as BlocksField, a6 as BooleanField, a7 as BuiltinApiKeysCollection, a8 as BuiltinMediaCollection, a9 as BuiltinMediaFoldersCollection, aa as BuiltinUsersCollection, ab as BundleHints, ac as CliCommand, ad as CollectionAccess, ae as CollectionAdmin, af as CollectionConfig, ag as CollectionDocument, ah as CollectionHooks, ai as CollectionListAdmin, aj as CollectionSearch, ak as CollectionSlug, C as Config, al as ConfigError, am as ConfigIssue, an as ContainerAdmin, c as CreateEasyCMSOptions, ao as CreateInput, ap as CreateMigrationResult, aq as CursorDocs, ar as CustomField, as as CustomFieldTypes, at as DashboardWidget, au as Database, av as DatabaseAdapter, aw as DatabaseInitArgs, ax as DatabaseTransfer, ay as DateField, az as DepthOptions, aA as DirectUpload, aB as DiskStorageOptions, aC as DocumentOf, E as EasyCMS, aD as EasyCMSError, aE as EmailAdapter, aF as EmailField, aG as EmailMessage, aH as Endpoint, aI as EndpointRequest, aJ as ErrorCode, aK as FIELD_TYPES, F as Field, aL as FieldAccess, aM as FieldAccessFn, aN as FieldAdmin, a as FieldError, aO as FieldRule, aP as FieldType, aQ as FieldTypeBase, aR as FieldTypeDefinition, aS as FieldTypeValidateContext, aT as FieldValidate, aU as FieldValidateContext, aV as FieldValue, aW as FieldsInput, aX as FieldsValue, b as FilterOptions, aY as FilterOptionsArgs, aZ as FindAfterArgs, a_ as FindArgs, a$ as FindOptions, b0 as FolderLevel, b1 as FolderPermissions, b2 as ForbiddenError, b3 as GlobalAccess, b4 as GlobalConfig, b5 as GlobalDocument, b6 as GlobalHooks, b7 as GlobalInput, b8 as GlobalSlug, b9 as GroupField, ba as ImageSize, bb as IncrementOptions, bc as InferCollection, bd as InferGlobal, be as JobConfig, bf as JsonField, bg as KeysetPage, bh as Label, bi as LayoutNode, bj as LivePreview, bk as LocaleOf, L as LocalizationConfig, bl as LogDetails, bm as Logger, bn as LoginArgs, bo as LogsConfig, bp as MediaDocument, bq as MediaSize, br as MembersConfig, bs as MembersSignup, bt as MigrationInfo, bu as NavGroup, bv as NotFoundError, bw as NumberField, bx as OnRequest, by as OnRequestArgs, bz as Operation, bA as OutboxRow, bB as PLUGIN_API_VERSION, bC as PasswordEmail, bD as PasswordEmailArgs, bE as PasswordEmailFn, bF as PasswordLinkOptions, bG as PayloadTooLargeError, bH as Plugin, bI as PluginInfo, bJ as PluginTypes, bK as PreviewTarget, bL as PreviewURL, bM as QueryError, bN as ROLE_OPERATIONS, bO as RawDocument, bP as ReadOptions, bQ as RelationshipField, bR as RequestContext, R as ResolvedConfig, bS as RichTextDocument, bT as RichTextField, bU as Role, bV as RoleOperation, bW as RolePermissions, bX as Roles, bY as RoutesConfig, bZ as ScheduledPublish, b_ as SchemaError, b$ as SchemaMode, c0 as SearchAdapter, c1 as SearchEntry, c2 as SearchHits, c3 as SearchOptions, c4 as SearchQuery, c5 as SearchResults, c6 as SearchSetting, c7 as SelectField, c8 as SelectOption, c9 as Session, ca as SidebarPanel, cb as SignupArgs, cc as SignupResult, cd as SingleSignOn, ce as SlugField, cf as SlugOf, cg as SsoOutcome, ch as SsoProviderRef, ci as StorageAdapter, cj as StoredFile, ck as TRIGRAM_LIKE_MIN, cl as TelemetryConfig, cm as TextField, cn as TextareaField, co as TooManyRequestsError, cp as TypedPlugin, cq as UnauthorizedError, cr as UncountedDocs, cs as UpdateInput, ct as UpdateOptions, cu as UploadConfig, cv as UploadField, cw as UploadFromURLConfig, cx as UserIdentity, cy as ValidationError, cz as Version, cA as VersionSummary, cB as VersionsConfig, cC as WEBHOOK_EVENTS, cD as WebhookConfig, cE as WebhookEvent, cF as Where, cG as WhereOperators, cH as anyone, cI as consoleEmail, cJ as consoleLogger, cK as createEasyCMS, cL as defineConfig, cM as defineFieldType, cN as definePlugin, cO as diskStorage, cP as hasRows, cQ as isAdmin, cR as isHasMany, cS as isSignedIn, cT as isStaff, cU as isSystemAdmin, cV as jsonLogger, cW as memorySearch, cX as mimeAllowedBy, cY as rowFields, cZ as silentLogger, c_ as uniqueWithinOf } from './config-CWTw3zFm.js';
3
- export { A as ADMIN_API_VERSION, a as AdminCollection, b as AdminComponentRef, c as AdminElementProps, d as AdminField, e as AdminGlobal, f as AdminLayoutNode, g as AdminNavGroup, h as AdminNavItem, i as AdminNavNode, j as AdminPageRef, k as AdminPageRoute, l as AdminSchema, m as AdminWidgetRef, S as SetFieldDetail, w as warnDeprecated } from './plugin-BYaJUVpz.js';
4
- export { A as AdminBackup, a as AdminBackups, b as ApiHandlerOptions, B as BackupState, L as LoadConfigOptions, R as RestHandler, c as RestHandlerOptions, d as createApiHandler, e as createRestHandler, l as loadConfig, r as resolveConfig, s as sharedEasyCMS, f as slugify } from './handler-DzAqr9gY.js';
1
+ import { P as PaginatedDocs, I as ID, d as EmailSetting, W as WebhookPayload } from './config-U1mRwD4t.js';
2
+ export { A as ADAPTER_API_VERSION, e as ADMIN_ICONS, f as Access, g as AccessArgs, h as AccessOptions, i as AdminBrand, j as AdminCommand, k as AdminComponent, l as AdminConfig, m as AdminFieldArgs, n as AdminIcon, o as AdminLocale, p as AdminPage, q as AdminRole, r as AdminRoleChange, s as AdminRoleField, t as AdminRoleTarget, u as AdminRoleView, v as AdminRoles, w as AdminSso, x as AdminSwitcher, y as AdminViewAccess, z as AfterChangeHook, B as AfterDeleteHook, D as AfterReadHook, G as ApiKeyContext, H as ApiKeyGlobalOperation, J as ApiKeyOperation, K as ApiKeyPermissions, M as ArrayField, N as AuditChange, O as AuditConfig, Q as AuditEntry, S as AuditLog, T as AuditPage, U as AuditVerification, V as Auth, X as AuthConfig, Y as AuthProvider, Z as AuthProviderProfile, _ as AuthProviderRequest, $ as AuthUser, a0 as BackupsConfig, a1 as BeforeChangeHook, a2 as BeforeDeleteHook, a3 as BeforeValidateHook, a4 as Block, a5 as BlocksField, a6 as BooleanField, a7 as BuiltinApiKeysCollection, a8 as BuiltinMediaCollection, a9 as BuiltinMediaFoldersCollection, aa as BuiltinUsersCollection, ab as BundleHints, ac as CliCommand, ad as CollectionAccess, ae as CollectionAdmin, af as CollectionConfig, ag as CollectionDocument, ah as CollectionHooks, ai as CollectionListAdmin, aj as CollectionSearch, ak as CollectionSlug, C as Config, al as ConfigError, am as ConfigIssue, an as ContainerAdmin, c as CreateEasyCMSOptions, ao as CreateInput, ap as CreateMigrationResult, aq as CursorDocs, ar as CustomField, as as CustomFieldTypes, at as DashboardWidget, au as Database, av as DatabaseAdapter, aw as DatabaseInitArgs, ax as DatabaseTransfer, ay as DateField, az as DepthOptions, aA as DirectUpload, aB as DiskStorageOptions, aC as DocumentOf, E as EasyCMS, aD as EasyCMSError, aE as EmailAdapter, aF as EmailField, aG as EmailMessage, aH as Endpoint, aI as EndpointRequest, aJ as ErrorCode, aK as FIELD_TYPES, F as Field, aL as FieldAccess, aM as FieldAccessFn, aN as FieldAdmin, a as FieldError, aO as FieldRule, aP as FieldType, aQ as FieldTypeBase, aR as FieldTypeDefinition, aS as FieldTypeValidateContext, aT as FieldValidate, aU as FieldValidateContext, aV as FieldValue, aW as FieldsInput, aX as FieldsValue, b as FilterOptions, aY as FilterOptionsArgs, aZ as FindAfterArgs, a_ as FindArgs, a$ as FindOptions, b0 as FolderLevel, b1 as FolderPermissions, b2 as ForbiddenError, b3 as GlobalAccess, b4 as GlobalConfig, b5 as GlobalDocument, b6 as GlobalHooks, b7 as GlobalInput, b8 as GlobalSlug, b9 as GroupField, ba as ImageSize, bb as IncrementOptions, bc as InferCollection, bd as InferGlobal, be as JobConfig, bf as JsonField, bg as KeysetPage, bh as Label, bi as LayoutNode, bj as LivePreview, bk as LocaleOf, L as LocalizationConfig, bl as LogDetails, bm as Logger, bn as LoginArgs, bo as LogsConfig, bp as MediaDocument, bq as MediaSize, br as MembersConfig, bs as MembersSignup, bt as MigrationInfo, bu as NavGroup, bv as NotFoundError, bw as NumberField, bx as OnRequest, by as OnRequestArgs, bz as Operation, bA as OutboxRow, bB as PLUGIN_API_VERSION, bC as PasswordEmail, bD as PasswordEmailArgs, bE as PasswordEmailFn, bF as PasswordLinkOptions, bG as PayloadTooLargeError, bH as Plugin, bI as PluginInfo, bJ as PluginTypes, bK as PreviewTarget, bL as PreviewURL, bM as QueryError, bN as ROLE_OPERATIONS, bO as RawDocument, bP as ReadOptions, bQ as RelationshipField, bR as RequestContext, R as ResolvedConfig, bS as RichTextDocument, bT as RichTextField, bU as Role, bV as RoleOperation, bW as RolePermissions, bX as Roles, bY as RoutesConfig, bZ as ScheduledPublish, b_ as SchemaError, b$ as SchemaMode, c0 as SearchAdapter, c1 as SearchEntry, c2 as SearchHits, c3 as SearchOptions, c4 as SearchQuery, c5 as SearchResults, c6 as SearchSetting, c7 as SelectField, c8 as SelectOption, c9 as Session, ca as SidebarPanel, cb as SignupArgs, cc as SignupResult, cd as SingleSignOn, ce as SlugField, cf as SlugOf, cg as SsoOutcome, ch as SsoProviderRef, ci as StorageAdapter, cj as StoredFile, ck as TRIGRAM_LIKE_MIN, cl as TelemetryConfig, cm as TextField, cn as TextareaField, co as TooManyRequestsError, cp as TypedPlugin, cq as UnauthorizedError, cr as UncountedDocs, cs as UpdateInput, ct as UpdateOptions, cu as UploadConfig, cv as UploadField, cw as UploadFromURLConfig, cx as UserIdentity, cy as ValidationError, cz as Version, cA as VersionSummary, cB as VersionsConfig, cC as WEBHOOK_EVENTS, cD as WebhookConfig, cE as WebhookEvent, cF as Where, cG as WhereOperators, cH as anyone, cI as consoleEmail, cJ as consoleLogger, cK as createEasyCMS, cL as defineConfig, cM as defineFieldType, cN as definePlugin, cO as diskStorage, cP as hasRows, cQ as isAdmin, cR as isHasMany, cS as isSignedIn, cT as isStaff, cU as isSystemAdmin, cV as jsonLogger, cW as memorySearch, cX as mimeAllowedBy, cY as rowFields, cZ as silentLogger, c_ as uniqueWithinOf } from './config-U1mRwD4t.js';
3
+ export { A as ADMIN_API_VERSION, a as AdminCollection, b as AdminComponentRef, c as AdminElementProps, d as AdminField, e as AdminGlobal, f as AdminLayoutNode, g as AdminNavGroup, h as AdminNavItem, i as AdminNavNode, j as AdminPageRef, k as AdminPageRoute, l as AdminSchema, m as AdminWidgetRef, S as SetFieldDetail, w as warnDeprecated } from './plugin-DRV5HA4a.js';
4
+ export { A as AdminBackup, a as AdminBackups, b as ApiHandlerOptions, B as BackupState, L as LoadConfigOptions, R as RestHandler, c as RestHandlerOptions, d as createApiHandler, e as createRestHandler, l as loadConfig, r as resolveConfig, s as sharedEasyCMS, f as slugify } from './handler-D2y4WSSM.js';
5
5
  export { FieldCondition, matchesCondition } from './conditions.js';
6
6
 
7
7
  type DeliveryKind = 'webhook' | 'email';
package/dist/index.js CHANGED
@@ -28,7 +28,7 @@ import {
28
28
  silentLogger,
29
29
  slugify,
30
30
  verifyWebhook
31
- } from "./chunk-LPIABPBA.js";
31
+ } from "./chunk-FZCQXH5M.js";
32
32
  import {
33
33
  matchesCondition
34
34
  } from "./chunk-U266LV4M.js";
@@ -1,6 +1,6 @@
1
1
  import { Config, Database, EasyCMS, NavGroup, CollectionConfig, ID, RawDocument, Field, ResolvedConfig, ConfigIssue } from './index.js';
2
- export { c$ as API_KEYS, d0 as API_KEY_GLOBAL_OPERATIONS, d1 as API_KEY_OPERATIONS, d2 as DEFAULT_MAX_VERSIONS, d3 as DEFAULT_PASSWORD_EMAILS, d4 as FIELD_TYPE_BASES, d5 as FOLDER_LEVELS, d6 as MediaFolders, d7 as SSO_COOKIE, ck as TRIGRAM_LIKE_MIN, d8 as codeOfStatus, cP as hasRows, cR as isHasMany, d9 as isJsonLogger, da as keyAllows, db as logFormat, cX as mimeAllowedBy, cY as rowFields, c_ as uniqueWithinOf } from './config-CWTw3zFm.js';
3
- export { C as CONFIG_FILE_NAMES, g as CSRF_COOKIE, h as CSRF_HEADER, D as DEFAULT_ADMIN_PATH, i as DEFAULT_API_PATH, j as DEFAULT_MAX_FILE_SIZE, k as DEFAULT_TOKEN_EXPIRATION, m as applyDefaults, n as applyPlugins, o as createRootEndpointHandler, p as decryptBackup, q as fillMissing, t as findConfigFile, u as forwardedClientIp, v as generateSlugs, w as importConfig, x as isEmail, y as mergeForUpdate, z as parseId, E as platformClientIp, F as readCookie, G as validateFields, H as writeBackupFile } from './handler-DzAqr9gY.js';
2
+ export { c$ as API_KEYS, d0 as API_KEY_GLOBAL_OPERATIONS, d1 as API_KEY_OPERATIONS, d2 as DEFAULT_MAX_VERSIONS, d3 as DEFAULT_PASSWORD_EMAILS, d4 as FIELD_TYPE_BASES, d5 as FOLDER_LEVELS, d6 as MediaFolders, d7 as SSO_COOKIE, d8 as Secrets, ck as TRIGRAM_LIKE_MIN, d9 as codeOfStatus, cP as hasRows, cR as isHasMany, da as isJsonLogger, db as keyAllows, dc as logFormat, cX as mimeAllowedBy, cY as rowFields, dd as secretsOf, c_ as uniqueWithinOf } from './config-U1mRwD4t.js';
3
+ export { C as CONFIG_FILE_NAMES, g as CSRF_COOKIE, h as CSRF_HEADER, D as DEFAULT_ADMIN_PATH, i as DEFAULT_API_PATH, j as DEFAULT_MAX_FILE_SIZE, k as DEFAULT_TOKEN_EXPIRATION, m as applyDefaults, n as applyPlugins, o as createRootEndpointHandler, p as decryptBackup, q as fillMissing, t as findConfigFile, u as forwardedClientIp, v as generateSlugs, w as importConfig, x as isEmail, y as mergeForUpdate, z as parseId, E as platformClientIp, F as readCookie, G as validateFields, H as writeBackupFile } from './handler-D2y4WSSM.js';
4
4
  export { conditionIssues } from './conditions.js';
5
5
 
6
6
  /** The session cookie the REST API sets at login. */
@@ -112,7 +112,7 @@ declare function formToken(secret: string, purpose: string, now?: number): strin
112
112
  * Checks a token: `ok` when the form was loaded at least `minTime` ms ago (people take a few
113
113
  * seconds; bots don't) and less than a day ago.
114
114
  */
115
- declare function checkFormToken(secret: string, purpose: string, token: unknown, minTime: number, now?: number): 'ok' | 'invalid' | 'too-fast' | 'expired';
115
+ declare function checkFormToken(secrets: string | readonly string[], purpose: string, token: unknown, minTime: number, now?: number): 'ok' | 'invalid' | 'too-fast' | 'expired';
116
116
  /**
117
117
  * Rate-limit keys for `subject` (e.g. an IP and a form) in the current and previous window of
118
118
  * `window` seconds. A hash with the window in it: no IP is kept, and keys from different
package/dist/internal.js CHANGED
@@ -52,12 +52,13 @@ import {
52
52
  rateKeys,
53
53
  readCookie,
54
54
  resolveAdminModule,
55
+ secretsOf,
55
56
  validateConfig,
56
57
  validateFields,
57
58
  verifyPassword,
58
59
  verifyTurnstile,
59
60
  writeBackupFile
60
- } from "./chunk-LPIABPBA.js";
61
+ } from "./chunk-FZCQXH5M.js";
61
62
  import {
62
63
  conditionIssues
63
64
  } from "./chunk-U266LV4M.js";
@@ -311,6 +312,7 @@ export {
311
312
  readCookie,
312
313
  resolveAdminModule,
313
314
  rowFields,
315
+ secretsOf,
314
316
  singularize,
315
317
  sniffMimeType,
316
318
  uniqueWithinOf,
@@ -1,4 +1,4 @@
1
- import { bh as Label, F as Field, I as ID, o as AdminLocale, x as AdminSwitcher, ch as SsoProviderRef } from './config-CWTw3zFm.js';
1
+ import { bh as Label, F as Field, I as ID, o as AdminLocale, x as AdminSwitcher, ch as SsoProviderRef } from './config-U1mRwD4t.js';
2
2
  import { FieldCondition } from './conditions.js';
3
3
 
4
4
  /** A Web Component from an admin module, with its `props` (plain JSON). */
package/dist/plugin.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- export { e as ADMIN_ICONS, k as AdminComponent, n as AdminIcon, $ as AuthUser, ac as CliCommand, ad as CollectionAccess, af as CollectionConfig, C as Config, al as ConfigError, at as DashboardWidget, aD as EasyCMSError, aH as Endpoint, aI as EndpointRequest, aJ as ErrorCode, F as Field, aL as FieldAccess, a as FieldError, aQ as FieldTypeBase, aR as FieldTypeDefinition, aS as FieldTypeValidateContext, b2 as ForbiddenError, b4 as GlobalConfig, I as ID, be as JobConfig, bh as Label, bu as NavGroup, bv as NotFoundError, bx as OnRequest, by as OnRequestArgs, bB as PLUGIN_API_VERSION, bG as PayloadTooLargeError, bH as Plugin, bI as PluginInfo, bJ as PluginTypes, bM as QueryError, bR as RequestContext, co as TooManyRequestsError, cp as TypedPlugin, cq as UnauthorizedError, cy as ValidationError, cF as Where, cM as defineFieldType, cN as definePlugin } from './config-CWTw3zFm.js';
2
- export { A as ADMIN_API_VERSION, c as AdminElementProps, k as AdminPageRoute, S as SetFieldDetail, w as warnDeprecated } from './plugin-BYaJUVpz.js';
1
+ export { e as ADMIN_ICONS, k as AdminComponent, n as AdminIcon, $ as AuthUser, ac as CliCommand, ad as CollectionAccess, af as CollectionConfig, C as Config, al as ConfigError, at as DashboardWidget, aD as EasyCMSError, aH as Endpoint, aI as EndpointRequest, aJ as ErrorCode, F as Field, aL as FieldAccess, a as FieldError, aQ as FieldTypeBase, aR as FieldTypeDefinition, aS as FieldTypeValidateContext, b2 as ForbiddenError, b4 as GlobalConfig, I as ID, be as JobConfig, bh as Label, bu as NavGroup, bv as NotFoundError, bx as OnRequest, by as OnRequestArgs, bB as PLUGIN_API_VERSION, bG as PayloadTooLargeError, bH as Plugin, bI as PluginInfo, bJ as PluginTypes, bM as QueryError, bR as RequestContext, co as TooManyRequestsError, cp as TypedPlugin, cq as UnauthorizedError, cy as ValidationError, cF as Where, cM as defineFieldType, cN as definePlugin } from './config-U1mRwD4t.js';
2
+ export { A as ADMIN_API_VERSION, c as AdminElementProps, k as AdminPageRoute, S as SetFieldDetail, w as warnDeprecated } from './plugin-DRV5HA4a.js';
3
3
  import './conditions.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/core",
3
- "version": "1.17.0",
3
+ "version": "1.19.0",
4
4
  "description": "Embedded, code-first headless CMS for Nuxt and Next.js",
5
5
  "license": "MIT",
6
6
  "type": "module",