@easy-cms/core 1.17.0 → 1.18.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";
@@ -563,7 +656,7 @@ var emailDeliveriesCollection = {
563
656
 
564
657
  // src/audit.ts
565
658
  import { AsyncLocalStorage } from "async_hooks";
566
- import { createHmac } from "crypto";
659
+ import { createHmac as createHmac2 } from "crypto";
567
660
  var DEFAULT_AUDIT_KEEP = 365;
568
661
  var DEFAULT_FAILED_LOGINS = 20;
569
662
  var MAX_VALUE = 500;
@@ -778,7 +871,7 @@ var AuditLog = class {
778
871
  });
779
872
  for (const row of result.docs) {
780
873
  checked++;
781
- if (row.signature !== this.sign(row)) invalid.push(row.id);
874
+ if (!this.signer(row)) invalid.push(row.id);
782
875
  const id = Number(row.id);
783
876
  if (previous !== void 0 && Number.isInteger(id) && id > previous + 1)
784
877
  gaps += id - previous - 1;
@@ -797,6 +890,49 @@ var AuditLog = class {
797
890
  );
798
891
  return verification;
799
892
  }
893
+ /** The secret an entry was signed with: the current one, a previous one, or none (changed). */
894
+ signer(row) {
895
+ return secretsOf(this.cms.config).find((secret) => row.signature === this.sign(row, secret));
896
+ }
897
+ /**
898
+ * After `secret` changed: signs again, with the current secret, the entries signed with one of
899
+ * `previousSecrets`, so they still verify once it is removed. Entries no secret verifies were
900
+ * changed: they are left as they are, and reported. Records `audit.resign`.
901
+ */
902
+ async resign() {
903
+ const invalid = [];
904
+ let checked = 0;
905
+ let resigned = 0;
906
+ for (let page = 1; ; page++) {
907
+ const result = await this.cms.db.find({
908
+ collection: AUDIT_LOGS,
909
+ sort: ["id"],
910
+ limit: 500,
911
+ page
912
+ });
913
+ for (const row of result.docs) {
914
+ checked++;
915
+ const secret = this.signer(row);
916
+ if (secret === void 0) invalid.push(row.id);
917
+ else if (secret !== this.cms.config.secret) {
918
+ const { id, ...data } = row;
919
+ await this.cms.db.update({
920
+ collection: AUDIT_LOGS,
921
+ id,
922
+ data: { ...data, signature: this.sign(row) }
923
+ });
924
+ resigned++;
925
+ }
926
+ }
927
+ if (page >= result.totalPages) break;
928
+ }
929
+ await this.record({
930
+ action: "audit.resign",
931
+ target: "audit",
932
+ detail: { checked, resigned, invalid: invalid.length }
933
+ });
934
+ return { checked, resigned, invalid };
935
+ }
800
936
  /**
801
937
  * Daily upkeep from `runJobs`: deletes entries older than `keep` days, then verifies the log.
802
938
  * Runs when the last check is more than a day old.
@@ -847,11 +983,11 @@ var AuditLog = class {
847
983
  const scope = this.settings.scope;
848
984
  return (scope && context ? scope({ context, user }) : null) ?? null;
849
985
  }
850
- sign(row) {
986
+ sign(row, secret = this.cms.config.secret) {
851
987
  const values = SIGNED.map((key) => row[key] ?? null);
852
988
  if (typeof row.scope === "string" && row.scope) values.push(row.scope);
853
989
  const payload = stable(values);
854
- return createHmac("sha256", this.cms.config.secret).update(`audit:${payload}`).digest("base64url");
990
+ return createHmac2("sha256", secret).update(`audit:${payload}`).digest("base64url");
855
991
  }
856
992
  async lastVerification() {
857
993
  const { docs } = await this.cms.db.find({
@@ -903,7 +1039,7 @@ function toEntry(row) {
903
1039
  }
904
1040
 
905
1041
  // src/api-keys.ts
906
- import { createHash, randomBytes, timingSafeEqual } from "crypto";
1042
+ import { createHash as createHash2, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "crypto";
907
1043
  var API_KEYS = "api-keys";
908
1044
  var API_KEY_OPERATIONS = ["read", "create", "update", "delete", "publish"];
909
1045
  var API_KEY_GLOBAL_OPERATIONS = ["read", "update", "publish"];
@@ -913,12 +1049,12 @@ function isApiKey(token) {
913
1049
  return token.startsWith("ecms_");
914
1050
  }
915
1051
  function newApiKey() {
916
- const prefix = randomBytes(4).toString("hex");
917
- const secret = randomBytes(32).toString("base64url");
1052
+ const prefix = randomBytes2(4).toString("hex");
1053
+ const secret = randomBytes2(32).toString("base64url");
918
1054
  return { key: `ecms_${prefix}_${secret}`, prefix, hash: hashSecret(secret) };
919
1055
  }
920
1056
  function hashSecret(secret) {
921
- return createHash("sha256").update(secret).digest("hex");
1057
+ return createHash2("sha256").update(secret).digest("hex");
922
1058
  }
923
1059
  function parseApiKey(token) {
924
1060
  const match = KEY_PATTERN.exec(token);
@@ -928,7 +1064,7 @@ function secretMatches(secret, hash) {
928
1064
  if (typeof hash !== "string") return false;
929
1065
  const a = Buffer.from(hashSecret(secret), "hex");
930
1066
  const b = Buffer.from(hash, "hex");
931
- return a.length === b.length && timingSafeEqual(a, b);
1067
+ return a.length === b.length && timingSafeEqual2(a, b);
932
1068
  }
933
1069
  function keyAllows(user, target, operation) {
934
1070
  const key = user?.apiKey;
@@ -1035,20 +1171,23 @@ function cleanPermissions(value, known) {
1035
1171
  }
1036
1172
 
1037
1173
  // src/spam.ts
1038
- import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
1174
+ import { createHmac as createHmac3, timingSafeEqual as timingSafeEqual3 } from "crypto";
1039
1175
  var MAX_AGE = 24 * 36e5;
1040
- var sign = (secret, value) => createHmac2("sha256", secret).update(value).digest("base64url");
1176
+ var sign = (secret, value) => createHmac3("sha256", secret).update(value).digest("base64url");
1041
1177
  function formToken(secret, purpose, now = Date.now()) {
1042
1178
  return `${now}.${sign(secret, `${purpose}:${now}`)}`;
1043
1179
  }
1044
- function checkFormToken(secret, purpose, token, minTime, now = Date.now()) {
1180
+ function checkFormToken(secrets, purpose, token, minTime, now = Date.now()) {
1045
1181
  if (typeof token !== "string") return "invalid";
1046
1182
  const [time, signature] = token.split(".");
1047
1183
  const issued = Number(time);
1048
1184
  if (!Number.isFinite(issued) || !signature) return "invalid";
1049
- const expected = Buffer.from(sign(secret, `${purpose}:${issued}`));
1050
1185
  const given = Buffer.from(signature);
1051
- if (expected.length !== given.length || !timingSafeEqual2(expected, given)) return "invalid";
1186
+ const genuine = [secrets].flat().some((secret) => {
1187
+ const expected = Buffer.from(sign(secret, `${purpose}:${issued}`));
1188
+ return expected.length === given.length && timingSafeEqual3(expected, given);
1189
+ });
1190
+ if (!genuine) return "invalid";
1052
1191
  if (now - issued < minTime) return "too-fast";
1053
1192
  if (now - issued > MAX_AGE) return "expired";
1054
1193
  return "ok";
@@ -1172,7 +1311,7 @@ var DEFAULT_PASSWORD_EMAILS = {
1172
1311
  };
1173
1312
 
1174
1313
  // src/auth/password.ts
1175
- import { randomBytes as randomBytes2, scrypt, timingSafeEqual as timingSafeEqual3 } from "crypto";
1314
+ import { randomBytes as randomBytes3, scrypt, timingSafeEqual as timingSafeEqual4 } from "crypto";
1176
1315
  var MIN_PASSWORD_LENGTH = 8;
1177
1316
  var N = 2 ** 17;
1178
1317
  var R = 8;
@@ -1191,7 +1330,7 @@ function derive(password, salt, n, r, p, length) {
1191
1330
  });
1192
1331
  }
1193
1332
  async function hashPassword(password) {
1194
- const salt = randomBytes2(16);
1333
+ const salt = randomBytes3(16);
1195
1334
  const key = await derive(password, salt, N, R, P, KEY_LENGTH);
1196
1335
  return ["scrypt", N, R, P, salt.toString("base64url"), key.toString("base64url")].join("$");
1197
1336
  }
@@ -1207,7 +1346,7 @@ async function verifyPassword(password, stored) {
1207
1346
  Number(p),
1208
1347
  expected.length
1209
1348
  );
1210
- return key.length === expected.length && timingSafeEqual3(key, expected);
1349
+ return key.length === expected.length && timingSafeEqual4(key, expected);
1211
1350
  }
1212
1351
  var dummyHash;
1213
1352
  async function fakeVerify(password) {
@@ -1218,92 +1357,6 @@ async function fakeVerify(password) {
1218
1357
 
1219
1358
  // src/auth/sso.ts
1220
1359
  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
1360
  var SSO_COOKIE = "ecms-sso";
1308
1361
  var PENDING_TTL = 10 * 6e4;
1309
1362
  var random = () => randomBytes4(32).toString("base64url");
@@ -1314,12 +1367,13 @@ function seal(secret, pending) {
1314
1367
  const payload = Buffer.from(JSON.stringify(pending)).toString("base64url");
1315
1368
  return `${payload}.${sign2(secret, payload)}`;
1316
1369
  }
1317
- function unseal(secret, value) {
1370
+ function unseal(secrets, value) {
1318
1371
  if (!value) return null;
1319
1372
  const dot = value.lastIndexOf(".");
1320
1373
  if (dot <= 0) return null;
1321
1374
  const payload = value.slice(0, dot);
1322
- if (!safeEqual(value.slice(dot + 1), sign2(secret, payload))) return null;
1375
+ const signature = value.slice(dot + 1);
1376
+ if (![secrets].flat().some((secret) => safeEqual(signature, sign2(secret, payload)))) return null;
1323
1377
  try {
1324
1378
  const pending = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
1325
1379
  return typeof pending.e === "number" && pending.e > Date.now() ? pending : null;
@@ -1387,7 +1441,7 @@ var SingleSignOn = class {
1387
1441
  */
1388
1442
  async finish(providerId, options) {
1389
1443
  const admin = this.config.admin.path.replace(/\/+$/, "");
1390
- const pending = unseal(this.config.secret, options.cookie);
1444
+ const pending = unseal(secretsOf(this.config), options.cookie);
1391
1445
  let email2 = null;
1392
1446
  const back = async (page2, outcome) => {
1393
1447
  await this.cms.audit.record({
@@ -1839,7 +1893,7 @@ var Auth = class {
1839
1893
  await this.recordFailure(key);
1840
1894
  throw refuse("form", "could not be sent");
1841
1895
  }
1842
- const checked = checkFormToken(this.config.secret, SIGNUP_FORM, args.token, SIGNUP_MIN_TIME);
1896
+ const checked = checkFormToken(this.secrets, SIGNUP_FORM, args.token, SIGNUP_MIN_TIME);
1843
1897
  if (checked !== "ok")
1844
1898
  throw refuse(
1845
1899
  "form",
@@ -1899,7 +1953,7 @@ var Auth = class {
1899
1953
  const id = claims && (/^\d+$/.test(claims.userId) ? Number(claims.userId) : claims.userId);
1900
1954
  const user = claims && claims.purpose === "verify" && claims.expiresAt > Date.now() && id !== null ? await this.db.findById({ collection: USERS, id }) : null;
1901
1955
  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))) {
1956
+ user.emailVerified !== false || !passwordTokenMatches(this.secrets, token, passwordFingerprint(user.passwordHash))) {
1903
1957
  await this.recordFailure(key);
1904
1958
  throw new ValidationError(USERS, [
1905
1959
  { field: "token", message: "This link has expired or is not valid" }
@@ -1969,7 +2023,7 @@ var Auth = class {
1969
2023
  }
1970
2024
  /** Ends the session for a signed token. Unknown tokens are ignored. */
1971
2025
  async logout(signedToken) {
1972
- const token = unsignToken(this.config.secret, signedToken);
2026
+ const token = unsignToken(this.secrets, signedToken);
1973
2027
  if (!token) return;
1974
2028
  const session = await this.findSession(token);
1975
2029
  if (!session) return;
@@ -1999,7 +2053,7 @@ var Auth = class {
1999
2053
  async verify(signedToken) {
2000
2054
  if (!signedToken) return null;
2001
2055
  if (isApiKey(signedToken)) return this.verifyApiKey(signedToken);
2002
- const token = unsignToken(this.config.secret, signedToken);
2056
+ const token = unsignToken(this.secrets, signedToken);
2003
2057
  if (!token) return null;
2004
2058
  const session = await this.findSession(token);
2005
2059
  if (!session) return null;
@@ -2019,9 +2073,21 @@ var Auth = class {
2019
2073
  }
2020
2074
  /** The CSRF token clients must echo for a signed session token. @internal */
2021
2075
  csrfFor(signedToken) {
2022
- const token = unsignToken(this.config.secret, signedToken);
2076
+ const token = unsignToken(this.secrets, signedToken);
2023
2077
  return token ? csrfToken(this.config.secret, token) : void 0;
2024
2078
  }
2079
+ /**
2080
+ * Whether `given` is the session's CSRF token, made with the current secret or a previous one
2081
+ * (a client that fetched it before the secret changed). @internal
2082
+ */
2083
+ csrfMatches(signedToken, given) {
2084
+ const token = unsignToken(this.secrets, signedToken);
2085
+ return token !== void 0 && csrfMatches(this.secrets, token, given);
2086
+ }
2087
+ /** The current secret and `previousSecrets`, which still verify. */
2088
+ get secrets() {
2089
+ return secretsOf(this.config);
2090
+ }
2025
2091
  /** Starts a new session for a user, e.g. after they changed their own password. */
2026
2092
  async createSession(userId) {
2027
2093
  const user = await this.db.findById({ collection: USERS, id: userId });
@@ -2144,7 +2210,7 @@ var Auth = class {
2144
2210
  const user = await this.db.findById({ collection: USERS, id });
2145
2211
  if (!user || user.active === false || !this.sso.passwordAllowed(user)) return null;
2146
2212
  const fingerprint = passwordFingerprint(user.passwordHash);
2147
- if (!passwordTokenMatches(this.config.secret, token, fingerprint)) return null;
2213
+ if (!passwordTokenMatches(this.secrets, token, fingerprint)) return null;
2148
2214
  return { user, purpose: claims.purpose };
2149
2215
  }
2150
2216
  async mailPasswordLink(user, purpose, options) {
@@ -3911,7 +3977,7 @@ var VersionStore = class {
3911
3977
  };
3912
3978
 
3913
3979
  // src/local-api.ts
3914
- import { createHash as createHash5, createHmac as createHmac7, randomBytes as randomBytes6, randomUUID as randomUUID3, timingSafeEqual as timingSafeEqual6 } from "crypto";
3980
+ import { createHash as createHash5, createHmac as createHmac7, randomBytes as randomBytes6, randomUUID as randomUUID3 } from "crypto";
3915
3981
 
3916
3982
  // src/auth/common-passwords.ts
3917
3983
  var LIST = `
@@ -4647,7 +4713,7 @@ function read(response, maxBytes, deadline) {
4647
4713
 
4648
4714
  // src/config-keys.ts
4649
4715
  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
4716
+ var CONFIG = keys(`secret previousSecrets db serverURL cors cronSecret timeZone webhooks events jobs localization routes admin
4651
4717
  upload auth collections globals endpoints cliCommands onRequest fieldTypes apiKeys email backups
4652
4718
  audit logs countCacheSeconds maxPage httpCache outbox search telemetry plugins`);
4653
4719
  var ADMIN = keys("path locale brand siteURL nav commands modules pages dashboard switcher");
@@ -5417,6 +5483,7 @@ function validateConfig(config) {
5417
5483
  return issues;
5418
5484
  }
5419
5485
  validateSecret(config.secret, add);
5486
+ validatePreviousSecrets(config.previousSecrets, config.secret, add);
5420
5487
  validateCronSecret(config.cronSecret, (issue) => issues.push(issue));
5421
5488
  checkConfigKeys(config, (issue) => issues.push(issue));
5422
5489
  validateAdapterVersions(config, add);
@@ -5502,6 +5569,22 @@ function validateSecret(secret, add) {
5502
5569
  add("secret", "looks like an example or a repeated pattern, not a random string", hint);
5503
5570
  }
5504
5571
  }
5572
+ function validatePreviousSecrets(value, secret, add) {
5573
+ if (value === void 0) return;
5574
+ if (!Array.isArray(value)) {
5575
+ add("previousSecrets", "must be an array of strings", "e.g. previousSecrets: [oldSecret]");
5576
+ return;
5577
+ }
5578
+ for (const [i, previous] of value.entries()) {
5579
+ if (typeof previous !== "string" || previous.length < MIN_SECRET_LENGTH)
5580
+ add(
5581
+ `previousSecrets[${i}]`,
5582
+ `must be a string of at least ${MIN_SECRET_LENGTH} characters, like secret`
5583
+ );
5584
+ else if (previous === secret)
5585
+ add(`previousSecrets[${i}]`, "is the same as secret", "list only secrets used before it");
5586
+ }
5587
+ }
5505
5588
  function validateCronSecret(value, push) {
5506
5589
  const hint = `use a random string of at least ${MIN_SECRET_LENGTH} characters, e.g. \`openssl rand -hex 32\``;
5507
5590
  if (value !== void 0) {
@@ -7083,7 +7166,7 @@ function cacheableResponse(request, body, status, headers, times, tags) {
7083
7166
  }
7084
7167
 
7085
7168
  // src/version.ts
7086
- var VERSION = true ? "1.17.0" : "0.0.0-dev";
7169
+ var VERSION = true ? "1.18.0" : "0.0.0-dev";
7087
7170
 
7088
7171
  // src/telemetry.ts
7089
7172
  var DB_CALLS = /* @__PURE__ */ new Set([
@@ -7754,11 +7837,7 @@ var EasyCMS = class {
7754
7837
  verify(purpose, token) {
7755
7838
  const [payload, signature] = String(token).split(".");
7756
7839
  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;
7840
+ if (!signedWith(secretsOf(this.config), `${purpose}:${payload}`, signature)) return void 0;
7762
7841
  try {
7763
7842
  return JSON.parse(Buffer.from(payload, "base64url").toString());
7764
7843
  } catch {
@@ -7875,9 +7954,7 @@ var EasyCMS = class {
7875
7954
  verifyMediaSignature(key, expires, signature) {
7876
7955
  const at = Number(expires);
7877
7956
  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);
7957
+ return signedWith(secretsOf(this.config), `media:${key}:${at}`, signature);
7881
7958
  }
7882
7959
  mediaSignature(key, expires) {
7883
7960
  return createHmac7("sha256", this.config.secret).update(`media:${key}:${expires}`).digest("base64url");
@@ -8170,7 +8247,7 @@ var EasyCMS = class {
8170
8247
  }
8171
8248
  /** What a preview token opens, or `null` when it is invalid or expired. */
8172
8249
  verifyPreviewToken(token) {
8173
- return token ? verifyPreviewToken(this.config.secret, token) : null;
8250
+ return token ? verifyPreviewToken(secretsOf(this.config), token) : null;
8174
8251
  }
8175
8252
  /** Live preview of a global. See `preview`. */
8176
8253
  async previewGlobal(slug2, data, options = {}) {
@@ -13136,9 +13213,8 @@ function checkCsrf(cms, ctx) {
13136
13213
  if (origin === null && fetchSite === "cross-site")
13137
13214
  throw new ForbiddenError("CSRF check failed: cross-site request");
13138
13215
  if (ctx.via === "cookie" && ctx.token) {
13139
- const expected = cms.auth.csrfFor(ctx.token);
13140
13216
  const given = ctx.request.headers.get(CSRF_HEADER);
13141
- if (!expected || !given || !safeEqual(given, expected)) {
13217
+ if (!given || !cms.auth.csrfMatches(ctx.token, given)) {
13142
13218
  throw new ForbiddenError(
13143
13219
  `CSRF check failed: send the ${CSRF_HEADER} header from GET /auth/me`
13144
13220
  );
@@ -13538,6 +13614,7 @@ export {
13538
13614
  isStaff,
13539
13615
  isSignedIn,
13540
13616
  isAdmin,
13617
+ secretsOf,
13541
13618
  MEDIA,
13542
13619
  MEDIA_FOLDERS,
13543
13620
  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';
@@ -3531,6 +3555,13 @@ interface BackupsConfig {
3531
3555
  interface Config {
3532
3556
  /** Signs sessions. At least 32 characters; read it from `process.env.EASY_CMS_SECRET`. */
3533
3557
  readonly secret: string;
3558
+ /**
3559
+ * Secrets used before `secret`, while it is replaced: what they signed (sessions, links,
3560
+ * tokens, the audit log) still verifies; new values are signed with `secret` only. Keep one
3561
+ * until sessions and links made with it have expired (7 days by default), and run
3562
+ * `easy-cms audit:resign` before removing it. At least 32 characters each, like `secret`.
3563
+ */
3564
+ readonly previousSecrets?: readonly string[];
3534
3565
  readonly db: DatabaseAdapter;
3535
3566
  /**
3536
3567
  * Public origin of the app, e.g. `https://example.com`. When set, media URLs are absolute
@@ -3687,5 +3718,5 @@ interface ResolvedConfig extends Omit<Config, 'cors' | 'localization' | 'routes'
3687
3718
  */
3688
3719
  declare function defineConfig<const TConfig extends Config>(config: TConfig): TConfig;
3689
3720
 
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 };
3721
+ 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 };
3722
+ 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-CpeC7EvC.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-CpeC7EvC.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-CpeC7EvC.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-_XG-lqA5.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-BpKo6gh0.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-NTAFZ4M6.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-CpeC7EvC.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-BpKo6gh0.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-NTAFZ4M6.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-CpeC7EvC.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-CpeC7EvC.js';
2
+ export { A as ADMIN_API_VERSION, c as AdminElementProps, k as AdminPageRoute, S as SetFieldDetail, w as warnDeprecated } from './plugin-_XG-lqA5.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.18.0",
4
4
  "description": "Embedded, code-first headless CMS for Nuxt and Next.js",
5
5
  "license": "MIT",
6
6
  "type": "module",