@easy-cms/core 1.18.0 → 1.20.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.
@@ -154,6 +154,7 @@ var ROLES = "user-roles";
154
154
  var USER_IDENTITIES = "user-identities";
155
155
  var AUDIT_LOGS = "audit-logs";
156
156
  var OUTBOX = "outbox";
157
+ var RATE_LIMITS = "rate-limits";
157
158
  var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
158
159
  SESSIONS,
159
160
  LOGIN_ATTEMPTS,
@@ -166,7 +167,8 @@ var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
166
167
  ROLES,
167
168
  USER_IDENTITIES,
168
169
  AUDIT_LOGS,
169
- OUTBOX
170
+ OUTBOX,
171
+ RATE_LIMITS
170
172
  ]);
171
173
  var DEFAULT_ROLES = ["admin", "editor"];
172
174
  var CREATED_BY_FIELD = {
@@ -640,6 +642,17 @@ var outboxCollection = {
640
642
  { name: "error", type: "text" }
641
643
  ]
642
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
+ };
643
656
  var emailDeliveriesCollection = {
644
657
  slug: EMAIL_DELIVERIES,
645
658
  access: { read: nobody, create: nobody, update: nobody, delete: nobody },
@@ -959,7 +972,10 @@ var AuditLog = class {
959
972
  }
960
973
  await auditContext.run({ via: "scheduler", user: null }, () => this.verify());
961
974
  }
962
- /** What the dashboard warns about: many failed logins, entries not written, a failed check. */
975
+ /**
976
+ * What the dashboard warns about: many failed logins, sign-ins of break-glass accounts in the
977
+ * last day, entries not written, a failed check.
978
+ */
963
979
  async attention(now = /* @__PURE__ */ new Date()) {
964
980
  if (!this.enabled) return [];
965
981
  const out = [];
@@ -971,6 +987,20 @@ var AuditLog = class {
971
987
  }
972
988
  });
973
989
  if (failed >= this.settings.failedLogins) out.push({ id: "failed-logins", count: failed });
990
+ const day = new Date(now.getTime() - 864e5).toISOString();
991
+ const breakGlass = await this.cms.db.find({
992
+ collection: AUDIT_LOGS,
993
+ where: { and: [{ action: { equals: "login.breakGlass" } }, { createdAt: { gte: day } }] },
994
+ sort: ["-id"],
995
+ limit: 0,
996
+ page: 1
997
+ });
998
+ if (breakGlass.docs.length > 0)
999
+ out.push({
1000
+ id: "break-glass",
1001
+ count: breakGlass.docs.length,
1002
+ emails: [...new Set(breakGlass.docs.map((row) => String(row.actorEmail ?? "")))]
1003
+ });
974
1004
  if (this.failures > 0) out.push({ id: "audit-failures", count: this.failures });
975
1005
  const last = await this.lastVerification();
976
1006
  if (last && last.invalid > 0)
@@ -1381,6 +1411,9 @@ function unseal(secrets, value) {
1381
1411
  return null;
1382
1412
  }
1383
1413
  }
1414
+ function passwordFor(policy) {
1415
+ return policy === true ? "everyone" : policy === false ? "admins" : "breakGlass";
1416
+ }
1384
1417
  var SingleSignOn = class {
1385
1418
  constructor(cms) {
1386
1419
  this.cms = cms;
@@ -1399,9 +1432,19 @@ var SingleSignOn = class {
1399
1432
  icon: p.icon ?? "key"
1400
1433
  }));
1401
1434
  }
1402
- /** Whether a user may sign in with a password (`auth.password: false` leaves only admins). */
1435
+ /**
1436
+ * Whether a user may sign in with a password: everyone, only admins (`auth.password: false`), or
1437
+ * only break-glass accounts (`{ breakGlass }`).
1438
+ */
1403
1439
  passwordAllowed(user) {
1404
- return this.config.auth.password || user.role === "admin";
1440
+ const policy = this.config.auth.password;
1441
+ if (policy === true) return true;
1442
+ if (policy === false) return user.role === "admin";
1443
+ return typeof user.email === "string" && policy.breakGlass.includes(user.email.toLowerCase());
1444
+ }
1445
+ /** Whether a user signs in with a password only as a break-glass account. */
1446
+ isBreakGlass(user) {
1447
+ return typeof this.config.auth.password === "object" && this.passwordAllowed(user);
1405
1448
  }
1406
1449
  /** `<serverURL><api>/auth/<id>/callback`, or `null` without `serverURL` in production. */
1407
1450
  callbackURL(provider, origin) {
@@ -1594,7 +1637,8 @@ var SingleSignOn = class {
1594
1637
  callbackURL: this.callbackURL(p.id, origin),
1595
1638
  details: { ...p.describe?.() ?? {} }
1596
1639
  })),
1597
- password: this.config.auth.password ? "everyone" : "admins",
1640
+ password: passwordFor(this.config.auth.password),
1641
+ ...typeof this.config.auth.password === "object" ? { breakGlass: [...this.config.auth.password.breakGlass] } : {},
1598
1642
  signUp: providerSignUp.domains.length > 0 ? {
1599
1643
  domains: [...providerSignUp.domains],
1600
1644
  role: providerSignUp.role ?? this.defaultRole()
@@ -1832,7 +1876,11 @@ var Auth = class {
1832
1876
  }
1833
1877
  await this.deleteExpiredSessions(user.id);
1834
1878
  const session = await this.startSession(user);
1835
- await this.cms.audit.record({ action: "login", target: "auth", user: session.user });
1879
+ await this.cms.audit.record({
1880
+ action: this.sso.isBreakGlass(user) ? "login.breakGlass" : "login",
1881
+ target: "auth",
1882
+ user: session.user
1883
+ });
1836
1884
  return session;
1837
1885
  }
1838
1886
  /**
@@ -2006,6 +2054,58 @@ var Auth = class {
2006
2054
  const path = target ?? `${this.config.admin.path}/${page === "verifyEmail" ? "verify-email" : "reset-password"}`;
2007
2055
  return `${base}${path.startsWith("/") ? "" : "/"}${path}${path.includes("?") ? "&" : "?"}${query}`;
2008
2056
  }
2057
+ /**
2058
+ * At start: when `auth.password` differs from the last start, records the change and ends the
2059
+ * sessions of accounts that may no longer use a password (single sign-on users sign in once
2060
+ * more). With break-glass accounts, warns when none of them can sign in with a password.
2061
+ * @internal
2062
+ */
2063
+ async applyPasswordPolicy() {
2064
+ const policy = passwordPolicyName(this.config.auth.password);
2065
+ const stored = await this.db.findGlobal({ slug: PASSWORD_POLICY });
2066
+ const before = typeof stored?.policy === "string" ? stored.policy : "everyone";
2067
+ if (before !== policy) {
2068
+ let ended = 0;
2069
+ if (policy !== "everyone") {
2070
+ const users = /* @__PURE__ */ new Map();
2071
+ const { docs } = await this.db.find({ collection: SESSIONS, sort: [], limit: 0, page: 1 });
2072
+ for (const session of docs) {
2073
+ const id = String(session.user);
2074
+ if (!users.has(id))
2075
+ users.set(id, await this.db.findById({ collection: USERS, id: session.user }));
2076
+ const user = users.get(id);
2077
+ if (user && this.sso.passwordAllowed(user)) continue;
2078
+ await this.db.delete({ collection: SESSIONS, id: session.id });
2079
+ ended++;
2080
+ }
2081
+ }
2082
+ const at = (/* @__PURE__ */ new Date()).toISOString();
2083
+ await this.db.updateGlobal({ slug: PASSWORD_POLICY, data: { policy, updatedAt: at } });
2084
+ await this.cms.audit.record({
2085
+ action: "auth.password.policy",
2086
+ target: "auth",
2087
+ user: null,
2088
+ detail: { from: before, to: policy, sessionsEnded: ended }
2089
+ });
2090
+ this.cms.logger.info(
2091
+ `Password sign-in: ${before} \u2192 ${policy}${ended ? `; ${ended} session(s) ended` : ""}.`
2092
+ );
2093
+ }
2094
+ const password = this.config.auth.password;
2095
+ if (typeof password === "object" && await this.hasUsers()) {
2096
+ const { docs } = await this.db.find({
2097
+ collection: USERS,
2098
+ where: { email: { in: [...password.breakGlass] } },
2099
+ sort: [],
2100
+ limit: 0,
2101
+ page: 1
2102
+ });
2103
+ if (!docs.some((user) => user.passwordHash && user.active !== false))
2104
+ this.cms.logger.warn(
2105
+ "auth.password.breakGlass: none of these accounts exists with a password, so nobody can sign in if the provider is down. Create one with `easy-cms admin:create`."
2106
+ );
2107
+ }
2108
+ }
2009
2109
  async hasUsers() {
2010
2110
  return await this.db.count({ collection: USERS }) > 0;
2011
2111
  }
@@ -2375,6 +2475,12 @@ var Auth = class {
2375
2475
  await this.db.delete({ collection: LOGIN_ATTEMPTS, id: attempt.id });
2376
2476
  }
2377
2477
  };
2478
+ var PASSWORD_POLICY = "easy-cms:password-policy";
2479
+ function passwordPolicyName(policy) {
2480
+ if (policy === true) return "everyone";
2481
+ if (policy === false) return "admins";
2482
+ return `breakGlass:${[...policy.breakGlass].sort().join(",")}`;
2483
+ }
2378
2484
 
2379
2485
  // src/database.ts
2380
2486
  var ADAPTER_API_VERSION = 1;
@@ -3977,7 +4083,7 @@ var VersionStore = class {
3977
4083
  };
3978
4084
 
3979
4085
  // src/local-api.ts
3980
- import { createHash as createHash5, createHmac as createHmac7, randomBytes as randomBytes6, randomUUID as randomUUID3 } from "crypto";
4086
+ import { createHash as createHash5, createHmac as createHmac8, randomBytes as randomBytes6, randomUUID as randomUUID3 } from "crypto";
3981
4087
 
3982
4088
  // src/auth/common-passwords.ts
3983
4089
  var LIST = `
@@ -4473,6 +4579,141 @@ function entryOf(row) {
4473
4579
  };
4474
4580
  }
4475
4581
 
4582
+ // src/rate-limit.ts
4583
+ import { createHmac as createHmac5 } from "crypto";
4584
+ var SYNC_MS = 5e3;
4585
+ var DEFAULTS = { window: 60, anonymous: 300, users: 1200, apiKeys: 1200 };
4586
+ var RateLimiter = class {
4587
+ constructor(db, secret, logger, settings) {
4588
+ this.db = db;
4589
+ this.secret = secret;
4590
+ this.logger = logger;
4591
+ this.settings = { ...DEFAULTS, ...settings };
4592
+ }
4593
+ db;
4594
+ secret;
4595
+ logger;
4596
+ counts = /* @__PURE__ */ new Map();
4597
+ settings;
4598
+ lastSync = Date.now();
4599
+ syncing;
4600
+ warnedNoIp = false;
4601
+ /**
4602
+ * Counts a request, or returns the seconds until the client may send another when it is over
4603
+ * its limit. Health checks and the cron's `jobs/run` aren't counted (the caller skips them).
4604
+ */
4605
+ hit(user, ip, now = Date.now()) {
4606
+ const client = this.client(user, ip);
4607
+ if (!client) return void 0;
4608
+ const [kind, id, limit] = client;
4609
+ const windowMs = this.settings.window * 1e3;
4610
+ const bucket = Math.floor(now / windowMs);
4611
+ const key = `${kind}:${id}:${bucket}`;
4612
+ let count = this.counts.get(key);
4613
+ if (!count) {
4614
+ count = { total: 0, pending: 0, ends: (bucket + 1) * windowMs };
4615
+ this.counts.set(key, count);
4616
+ }
4617
+ if (now - this.lastSync >= SYNC_MS && !this.syncing) void this.sync(now);
4618
+ if (count.total + count.pending >= limit)
4619
+ return Math.max(1, Math.ceil((count.ends - now) / 1e3));
4620
+ count.pending++;
4621
+ return void 0;
4622
+ }
4623
+ /** Who is counted, and their limit; `undefined` when nothing limits them. */
4624
+ client(user, ip) {
4625
+ const { anonymous, users, apiKeys } = this.settings;
4626
+ const key = user?.apiKey;
4627
+ if (key) return apiKeys === false ? void 0 : ["apiKey", String(key.id), apiKeys];
4628
+ if (user) return users === false ? void 0 : ["user", String(user.id), users];
4629
+ if (anonymous === false) return void 0;
4630
+ if (!ip) {
4631
+ if (!this.warnedNoIp) {
4632
+ this.warnedNoIp = true;
4633
+ this.logger.warn(
4634
+ "rateLimit: the client IP is unknown, so visitors who are not signed in are not limited. Behind a proxy, set trustProxy on the adapter."
4635
+ );
4636
+ }
4637
+ return void 0;
4638
+ }
4639
+ const hashed = createHmac5("sha256", this.secret).update(`rate:${ip}`).digest("base64url");
4640
+ return ["anonymous", hashed.slice(0, 22), anonymous];
4641
+ }
4642
+ /**
4643
+ * Adds what this instance counted to the database and reads the totals back; forgets windows
4644
+ * that are over. Runs in the background every few seconds; `await` it in tests.
4645
+ */
4646
+ sync(now = Date.now()) {
4647
+ this.lastSync = now;
4648
+ this.syncing ??= this.write(now).catch((error) => this.logger.warn(`rateLimit: counts not saved: ${String(error)}`)).finally(() => {
4649
+ this.syncing = void 0;
4650
+ });
4651
+ return this.syncing;
4652
+ }
4653
+ async write(now) {
4654
+ for (const [key, count] of this.counts) {
4655
+ if (count.ends <= now) {
4656
+ this.counts.delete(key);
4657
+ continue;
4658
+ }
4659
+ const added = count.pending;
4660
+ count.pending = 0;
4661
+ count.total = await this.add(key, count, added) ?? count.total + added;
4662
+ }
4663
+ }
4664
+ /** Adds `by` to the key's row (made if missing) and returns the new total. */
4665
+ async add(key, count, by) {
4666
+ count.id ??= await this.rowId(key);
4667
+ if (count.id === void 0) {
4668
+ const stamp = (/* @__PURE__ */ new Date()).toISOString();
4669
+ try {
4670
+ const created = await this.db.create({
4671
+ collection: RATE_LIMITS,
4672
+ data: {
4673
+ key,
4674
+ count: by,
4675
+ expiresAt: new Date(count.ends).toISOString(),
4676
+ createdAt: stamp,
4677
+ updatedAt: stamp
4678
+ }
4679
+ });
4680
+ count.id = created.id;
4681
+ return by;
4682
+ } catch (error) {
4683
+ count.id = await this.rowId(key);
4684
+ if (count.id === void 0) throw error;
4685
+ }
4686
+ }
4687
+ return this.db.increment({ collection: RATE_LIMITS, id: count.id, field: "count", by });
4688
+ }
4689
+ async rowId(key) {
4690
+ const { docs } = await this.db.find({
4691
+ collection: RATE_LIMITS,
4692
+ where: { key: { equals: key } },
4693
+ sort: [],
4694
+ limit: 1,
4695
+ page: 1
4696
+ });
4697
+ return docs[0]?.id;
4698
+ }
4699
+ /** Deletes the rows of windows that are over. From `runJobs`. */
4700
+ async clear(now = /* @__PURE__ */ new Date()) {
4701
+ let deleted = 0;
4702
+ for (; ; ) {
4703
+ const { docs } = await this.db.find({
4704
+ collection: RATE_LIMITS,
4705
+ where: { expiresAt: { lt: now.toISOString() } },
4706
+ sort: ["id"],
4707
+ limit: 500,
4708
+ page: 1
4709
+ });
4710
+ for (const row of docs) await this.db.delete({ collection: RATE_LIMITS, id: row.id });
4711
+ deleted += docs.length;
4712
+ if (docs.length < 500) return deleted;
4713
+ }
4714
+ }
4715
+ };
4716
+
4476
4717
  // src/remote-file.ts
4477
4718
  import { lookup as dnsLookup } from "dns";
4478
4719
  import { request as httpRequest } from "http";
@@ -4715,7 +4956,7 @@ function read(response, maxBytes, deadline) {
4715
4956
  var keys = (list) => new Set(list.split(/\s+/).filter(Boolean));
4716
4957
  var CONFIG = keys(`secret previousSecrets db serverURL cors cronSecret timeZone webhooks events jobs localization routes admin
4717
4958
  upload auth collections globals endpoints cliCommands onRequest fieldTypes apiKeys email backups
4718
- audit logs countCacheSeconds maxPage httpCache outbox search telemetry plugins`);
4959
+ audit logs countCacheSeconds maxPage httpCache outbox rateLimit search telemetry plugins`);
4719
4960
  var ADMIN = keys("path locale brand siteURL nav commands modules pages dashboard switcher");
4720
4961
  var AUTH = keys(`roles rbac tokenExpiration maxLoginAttempts lockWindow trustedOrigins
4721
4962
  resetPasswordExpiration inviteExpiration providers providerSignUp password setupCode members emails`);
@@ -4723,6 +4964,7 @@ var MEMBERS = keys("roles signUp pages emails");
4723
4964
  var UPLOAD = keys("dir maxFileSize mimeTypes storage imageSizes fromURL folders privateStorage");
4724
4965
  var BACKUPS = keys("frequency at keep dir storage sqlite encryptionKey");
4725
4966
  var AUDIT = keys("keepDays values failedLogins scope");
4967
+ var RATE_LIMIT = keys("window anonymous users apiKeys");
4726
4968
  var LOGS = keys("format requests");
4727
4969
  var COLLECTION = keys(`slug labels fields useAsTitle drafts versions schedule preview access hooks
4728
4970
  admin httpCache search`);
@@ -4848,6 +5090,7 @@ function checkConfigKeys(config, add) {
4848
5090
  check2(c.upload, "upload", "upload", UPLOAD, add);
4849
5091
  check2(c.backups, "backups", "backups", BACKUPS, add);
4850
5092
  if (isObject(c.audit)) check2(c.audit, "audit", "audit", AUDIT, add);
5093
+ if (isObject(c.rateLimit)) check2(c.rateLimit, "rateLimit", "rateLimit", RATE_LIMIT, add);
4851
5094
  check2(c.logs, "logs", "logs", LOGS, add);
4852
5095
  for (const [kind, list] of [
4853
5096
  ["collections", c.collections],
@@ -5204,13 +5447,13 @@ var SearchIndex = class {
5204
5447
  };
5205
5448
 
5206
5449
  // src/webhooks.ts
5207
- import { createHmac as createHmac6, randomUUID as randomUUID2 } from "crypto";
5450
+ import { createHmac as createHmac7, randomUUID as randomUUID2 } from "crypto";
5208
5451
 
5209
5452
  // src/webhook-signature.ts
5210
- import { createHmac as createHmac5, timingSafeEqual as timingSafeEqual5 } from "crypto";
5453
+ import { createHmac as createHmac6, timingSafeEqual as timingSafeEqual5 } from "crypto";
5211
5454
  var TOLERANCE_SECONDS = 5 * 60;
5212
5455
  function signWebhook(secret, delivery, timestamp, body) {
5213
- return `v1=${createHmac5("sha256", secret).update(`${delivery}.${timestamp}.${body}`).digest("hex")}`;
5456
+ return `v1=${createHmac6("sha256", secret).update(`${delivery}.${timestamp}.${body}`).digest("hex")}`;
5214
5457
  }
5215
5458
  function header(headers, name) {
5216
5459
  if (headers instanceof Headers) return headers.get(name) ?? void 0;
@@ -5411,7 +5654,7 @@ var Webhooks = class {
5411
5654
  const timestamp = Math.floor(Date.now() / 1e3);
5412
5655
  headers["x-easy-cms-timestamp"] = String(timestamp);
5413
5656
  headers["x-easy-cms-signature-v2"] = signWebhook(hook.secret, delivery, timestamp, body);
5414
- headers["x-easy-cms-signature"] = `sha256=${createHmac6("sha256", hook.secret).update(body).digest("hex")}`;
5657
+ headers["x-easy-cms-signature"] = `sha256=${createHmac7("sha256", hook.secret).update(body).digest("hex")}`;
5415
5658
  }
5416
5659
  try {
5417
5660
  const response = await this.fetcher(hook.url, {
@@ -5810,8 +6053,16 @@ function validateAuth(config, add) {
5810
6053
  if (auth === void 0) return;
5811
6054
  if (auth.rbac !== void 0 && typeof auth.rbac !== "boolean")
5812
6055
  add("auth.rbac", "must be true or false");
5813
- if (auth.password !== void 0 && typeof auth.password !== "boolean")
5814
- add("auth.password", "must be true or false");
6056
+ if (typeof auth.password === "object" && auth.password !== null) {
6057
+ const { breakGlass } = auth.password;
6058
+ if (!Array.isArray(breakGlass) || breakGlass.length === 0 || breakGlass.some((email2) => typeof email2 !== "string" || !email2.includes("@")))
6059
+ add(
6060
+ "auth.password.breakGlass",
6061
+ "must list the emails of the accounts that may still use a password",
6062
+ "e.g. { breakGlass: ['emergency@example.com'] }"
6063
+ );
6064
+ } else if (auth.password !== void 0 && typeof auth.password !== "boolean")
6065
+ add("auth.password", "must be true, false or { breakGlass: [emails] }");
5815
6066
  if (auth.setupCode !== void 0 && typeof auth.setupCode !== "string")
5816
6067
  add("auth.setupCode", "must be a string");
5817
6068
  const providers = auth.providers;
@@ -5862,8 +6113,12 @@ function validateAuth(config, add) {
5862
6113
  if (!Array.isArray(providers) || providers.length === 0)
5863
6114
  add("auth.providerSignUp", "needs auth.providers");
5864
6115
  }
5865
- if (auth.password === false && (!Array.isArray(providers) || providers.length === 0))
5866
- add("auth.password", "false needs auth.providers", "otherwise only admins could sign in");
6116
+ if (auth.password !== void 0 && auth.password !== true && (!Array.isArray(providers) || providers.length === 0))
6117
+ add(
6118
+ "auth.password",
6119
+ "turning passwords off needs auth.providers",
6120
+ "otherwise nobody else could sign in"
6121
+ );
5867
6122
  if (auth.roles !== void 0) {
5868
6123
  const roles = auth.roles;
5869
6124
  if (!Array.isArray(roles) || roles.some((r) => typeof r !== "string" || r === "")) {
@@ -7073,7 +7328,7 @@ async function resolveConfig(input) {
7073
7328
  rbac: config.auth?.rbac === true,
7074
7329
  providers: config.auth?.providers ?? [],
7075
7330
  providerSignUp: config.auth?.providerSignUp ?? { domains: [] },
7076
- password: config.auth?.password !== false,
7331
+ password: typeof config.auth?.password === "object" ? { breakGlass: config.auth.password.breakGlass.map((email2) => email2.toLowerCase()) } : config.auth?.password !== false,
7077
7332
  setupCode: config.auth?.setupCode ?? process.env.EASY_CMS_SETUP_CODE ?? "",
7078
7333
  tokenExpiration: config.auth?.tokenExpiration ?? DEFAULT_TOKEN_EXPIRATION,
7079
7334
  maxLoginAttempts: config.auth?.maxLoginAttempts ?? 5,
@@ -7096,6 +7351,7 @@ async function resolveConfig(input) {
7096
7351
  ...config.email ? [emailDeliveriesCollection] : [],
7097
7352
  ...(config.jobs?.length ?? 0) > 0 ? [jobRunsCollection] : [],
7098
7353
  ...config.outbox ? [outboxCollection] : [],
7354
+ ...config.rateLimit ? [rateLimitsCollection] : [],
7099
7355
  databaseBackupsCollection,
7100
7356
  ...config.auth?.rbac ? [rolesCollection] : [],
7101
7357
  ...(config.auth?.providers?.length ?? 0) > 0 ? [userIdentitiesCollection] : [],
@@ -7166,7 +7422,7 @@ function cacheableResponse(request, body, status, headers, times, tags) {
7166
7422
  }
7167
7423
 
7168
7424
  // src/version.ts
7169
- var VERSION = true ? "1.18.0" : "0.0.0-dev";
7425
+ var VERSION = true ? "1.20.0" : "0.0.0-dev";
7170
7426
 
7171
7427
  // src/telemetry.ts
7172
7428
  var DB_CALLS = /* @__PURE__ */ new Set([
@@ -7400,6 +7656,10 @@ async function createEasyCMS(config, options = {}) {
7400
7656
  if (options.scheduler !== false) cms.startSearch();
7401
7657
  const scheduling = [...resolved3.collections, ...resolved3.globals].some((c) => c.schedule) || (resolved3.webhooks?.length ?? 0) > 0 || resolved3.email !== void 0 || resolved3.jobs.length > 0 || resolved3.outbox === true;
7402
7658
  if (scheduling && options.scheduler !== false) cms.startScheduler();
7659
+ if (options.schema !== "skip")
7660
+ await cms.auth.applyPasswordPolicy().catch(
7661
+ (error) => logger.error(`Password sign-in policy: ${error.message}`, { error })
7662
+ );
7403
7663
  if (process.env.NODE_ENV === "production" && !resolved3.auth.setupCode && await cms.auth.hasUsers().catch(() => true) === false)
7404
7664
  logger.warn(
7405
7665
  "No admin exists yet and no setup code is set: whoever opens the admin first becomes the admin. Set EASY_CMS_SETUP_CODE, or create the admin with `easy-cms admin:create`."
@@ -7444,6 +7704,8 @@ var EasyCMS = class {
7444
7704
  mailer;
7445
7705
  /** With `outbox: true`: runs after hooks and events from rows saves write. */
7446
7706
  outbox;
7707
+ /** @internal Requests per client (`rateLimit`), for the REST API. */
7708
+ rateLimiter;
7447
7709
  /** With `search`: keeps the index in step with published documents. */
7448
7710
  searchIndex;
7449
7711
  /** @internal With `telemetry`: OpenTelemetry spans and metrics (set by `createEasyCMS`). */
@@ -7484,6 +7746,7 @@ var EasyCMS = class {
7484
7746
  () => this.runOutbox(entry, done, mark)
7485
7747
  ) : this.runOutbox(entry, done, mark)
7486
7748
  ) : void 0;
7749
+ this.rateLimiter = config.rateLimit ? new RateLimiter(db, config.secret, logger, config.rateLimit) : void 0;
7487
7750
  this.searchIndex = config.search ? new SearchIndex(
7488
7751
  config.search,
7489
7752
  config.collections,
@@ -7830,7 +8093,7 @@ var EasyCMS = class {
7830
8093
  /** A value with its HMAC (`secret`), for `purpose` only: `<base64url JSON>.<signature>`. */
7831
8094
  sign(purpose, value) {
7832
8095
  const payload = Buffer.from(JSON.stringify(value)).toString("base64url");
7833
- const mac = createHmac7("sha256", this.config.secret).update(`${purpose}:${payload}`);
8096
+ const mac = createHmac8("sha256", this.config.secret).update(`${purpose}:${payload}`);
7834
8097
  return `${payload}.${mac.digest("base64url")}`;
7835
8098
  }
7836
8099
  /** The value of a token made by `sign`, or `undefined` when it isn't genuine. */
@@ -7957,7 +8220,7 @@ var EasyCMS = class {
7957
8220
  return signedWith(secretsOf(this.config), `media:${key}:${at}`, signature);
7958
8221
  }
7959
8222
  mediaSignature(key, expires) {
7960
- return createHmac7("sha256", this.config.secret).update(`media:${key}:${expires}`).digest("base64url");
8223
+ return createHmac8("sha256", this.config.secret).update(`media:${key}:${expires}`).digest("base64url");
7961
8224
  }
7962
8225
  /**
7963
8226
  * How many documents use any of these files in a top-level upload field (rich text and fields
@@ -8402,7 +8665,10 @@ var EasyCMS = class {
8402
8665
  return out2;
8403
8666
  }
8404
8667
  if (config.slug === USERS) await this.guardLastAdmin(parsed, existing, prepared);
8405
- if (password !== void 0) prepared.passwordHash = await hashPassword(password);
8668
+ if (password !== void 0) {
8669
+ await this.guardPassword({ ...existing, ...prepared });
8670
+ prepared.passwordHash = await hashPassword(password);
8671
+ }
8406
8672
  if (collection2 === MEDIA_FOLDERS) {
8407
8673
  await this.validateFolder(prepared, parsed);
8408
8674
  if (prepared.private !== existing.private || String(prepared.parent ?? null) !== String(existing.parent ?? null))
@@ -8974,6 +9240,7 @@ var EasyCMS = class {
8974
9240
  (error) => this.logger.error(`Backups: ${error.message}`, { error })
8975
9241
  );
8976
9242
  await this.audit.upkeep(now).catch((error) => this.logger.error(`Audit log: ${error.message}`, { error }));
9243
+ await this.rateLimiter?.clear(now).catch((error) => this.logger.error(`Rate limits: ${error.message}`, { error }));
8977
9244
  const result = {
8978
9245
  scheduled,
8979
9246
  webhooks: await this.retryWebhooks(now),
@@ -9740,7 +10007,10 @@ var EasyCMS = class {
9740
10007
  { ...base, operation: "create" },
9741
10008
  prepared
9742
10009
  );
9743
- if (password !== void 0) prepared.passwordHash = await hashPassword(password);
10010
+ if (password !== void 0) {
10011
+ await this.guardPassword(prepared);
10012
+ prepared.passwordHash = await hashPassword(password);
10013
+ }
9744
10014
  if (collection2 === MEDIA_FOLDERS) await this.validateFolder(prepared, void 0);
9745
10015
  const now = (/* @__PURE__ */ new Date()).toISOString();
9746
10016
  const row = { ...prepared, createdAt: now, updatedAt: now };
@@ -10060,6 +10330,20 @@ var EasyCMS = class {
10060
10330
  return clean2;
10061
10331
  }
10062
10332
  /** Refuses changes that would leave no active admin (and lock everyone out). */
10333
+ /**
10334
+ * With break-glass accounts (`auth.password: { breakGlass }`), other accounts get no password:
10335
+ * they couldn't use it. The first account, made before anyone can sign in with a provider, may.
10336
+ */
10337
+ async guardPassword(user) {
10338
+ if (typeof this.config.auth.password !== "object" || this.auth.sso.passwordAllowed(user)) return;
10339
+ if (!await this.auth.hasUsers()) return;
10340
+ throw new ValidationError(USERS, [
10341
+ {
10342
+ field: "password",
10343
+ message: "passwords are off: this account signs in with single sign-on"
10344
+ }
10345
+ ]);
10346
+ }
10063
10347
  async guardLastAdmin(id, before, after) {
10064
10348
  const wasAdmin = before.role === "admin" && before.active !== false;
10065
10349
  const staysAdmin = after.role === "admin" && after.active !== false;
@@ -11622,6 +11906,8 @@ async function adminSchema(cms, user, origin, context = {}) {
11622
11906
  locale: cms.config.admin.locale,
11623
11907
  switcher: cms.config.admin.switcher || null,
11624
11908
  passwordLinks: cms.auth.canSendPasswordLinks(origin),
11909
+ passwordFor: passwordFor(cms.config.auth.password),
11910
+ ...typeof cms.config.auth.password === "object" && isSystemAdmin(user) ? { breakGlass: [...cms.config.auth.password.breakGlass] } : {},
11625
11911
  nav: buildNav(cms, adminCollections, adminGlobals, adminPages, views),
11626
11912
  commands: cms.config.admin.commands.map((c) => JSON.parse(JSON.stringify(c))),
11627
11913
  localization: localization ? { locales: [...localization.locales], defaultLocale: localization.defaultLocale } : null,
@@ -12242,6 +12528,10 @@ async function respondNow(cms, request, url, path, root, options, production, re
12242
12528
  trustProxy: options.trustProxy === true
12243
12529
  };
12244
12530
  const method = request.method.toUpperCase();
12531
+ const isJobsRun = !root && segments[0] === "jobs" && segments[1] === "run";
12532
+ const wait = isJobsRun ? void 0 : cms.rateLimiter?.hit(user, options.getClientIp?.(request));
12533
+ if (wait !== void 0)
12534
+ throw new TooManyRequestsError("Too many requests: try again later", wait);
12245
12535
  if (method !== "GET" && method !== "HEAD") checkCsrf(cms, ctx);
12246
12536
  const audit = {
12247
12537
  user,
@@ -12434,7 +12724,14 @@ async function route(cms, ctx, method, path, options) {
12434
12724
  const csrfToken2 = ctx.via === "cookie" && ctx.token ? cms.auth.csrfFor(ctx.token) : void 0;
12435
12725
  if (csrfToken2)
12436
12726
  ctx.headers.append("set-cookie", cookie(ctx, CSRF_COOKIE, csrfToken2, { httpOnly: false }));
12437
- return { body: { user: ctx.user, ...csrfToken2 ? { csrfToken: csrfToken2 } : {} } };
12727
+ return {
12728
+ body: {
12729
+ user: ctx.user,
12730
+ // Whether this user may set and use a password (`auth.password`).
12731
+ ...ctx.user ? { password: cms.auth.sso.passwordAllowed(ctx.user) } : {},
12732
+ ...csrfToken2 ? { csrfToken: csrfToken2 } : {}
12733
+ }
12734
+ };
12438
12735
  }
12439
12736
  case "GET init":
12440
12737
  return {
@@ -12442,9 +12739,11 @@ async function route(cms, ctx, method, path, options) {
12442
12739
  hasUsers: await cms.auth.hasUsers(),
12443
12740
  // The login page offers "Forgot password?" only when the email can be sent.
12444
12741
  passwordReset: cms.auth.canSendPasswordLinks(ctx.url.origin),
12445
- // Buttons to sign in with outside accounts; `password: false`: only admins use one.
12742
+ // Buttons to sign in with outside accounts; `password: false`: only some use one
12743
+ // (`passwordFor`: admins, or break-glass accounts), behind a folded form.
12446
12744
  providers: cms.auth.sso.providers(),
12447
- password: cms.config.auth.password,
12745
+ password: cms.config.auth.password === true,
12746
+ passwordFor: passwordFor(cms.config.auth.password),
12448
12747
  // The first admin needs the setup code (`EASY_CMS_SETUP_CODE`).
12449
12748
  setupCode: cms.config.auth.setupCode !== ""
12450
12749
  }
@@ -432,8 +432,10 @@ interface AdminSso {
432
432
  callbackURL: string | null;
433
433
  details: Record<string, string>;
434
434
  })[];
435
- /** Who may sign in with a password: everyone, or only admins. */
436
- password: 'everyone' | 'admins';
435
+ /** Who may sign in with a password: everyone, only admins, or only break-glass accounts. */
436
+ password: 'everyone' | 'admins' | 'breakGlass';
437
+ /** The accounts that may still use a password (`auth.password: { breakGlass }`). */
438
+ breakGlass?: string[];
437
439
  /** Email domains whose people get an account when they first sign in. */
438
440
  signUp: {
439
441
  domains: string[];
@@ -461,10 +463,17 @@ declare class SingleSignOn {
461
463
  private get config();
462
464
  get enabled(): boolean;
463
465
  providers(): SsoProviderRef[];
464
- /** Whether a user may sign in with a password (`auth.password: false` leaves only admins). */
466
+ /**
467
+ * Whether a user may sign in with a password: everyone, only admins (`auth.password: false`), or
468
+ * only break-glass accounts (`{ breakGlass }`).
469
+ */
465
470
  passwordAllowed(user: {
466
471
  readonly [key: string]: unknown;
467
472
  }): boolean;
473
+ /** Whether a user signs in with a password only as a break-glass account. */
474
+ isBreakGlass(user: {
475
+ readonly [key: string]: unknown;
476
+ }): boolean;
468
477
  /** `<serverURL><api>/auth/<id>/callback`, or `null` without `serverURL` in production. */
469
478
  callbackURL(provider: string, origin?: string): string | null;
470
479
  /**
@@ -2331,6 +2340,11 @@ declare class EasyCMS<C extends Config = Config> {
2331
2340
  /** Validates, checks uniqueness and references. Returns clean data or throws `ValidationError`. */
2332
2341
  private prepare;
2333
2342
  /** Refuses changes that would leave no active admin (and lock everyone out). */
2343
+ /**
2344
+ * With break-glass accounts (`auth.password: { breakGlass }`), other accounts get no password:
2345
+ * they couldn't use it. The first account, made before anyone can sign in with a provider, may.
2346
+ */
2347
+ private guardPassword;
2334
2348
  private guardLastAdmin;
2335
2349
  private makeSlugsUnique;
2336
2350
  private checkUnique;
@@ -2484,10 +2498,17 @@ declare class AuditLog {
2484
2498
  resigned: number;
2485
2499
  invalid: ID[];
2486
2500
  }>;
2487
- /** What the dashboard warns about: many failed logins, entries not written, a failed check. */
2501
+ /**
2502
+ * What the dashboard warns about: many failed logins, sign-ins of break-glass accounts in the
2503
+ * last day, entries not written, a failed check.
2504
+ */
2488
2505
  attention(now?: Date): Promise<({
2489
2506
  id: 'failed-logins';
2490
2507
  count: number;
2508
+ } | {
2509
+ id: 'break-glass';
2510
+ count: number;
2511
+ emails: string[];
2491
2512
  } | {
2492
2513
  id: 'audit-failures';
2493
2514
  count: number;
@@ -3344,10 +3365,13 @@ interface AuthConfig {
3344
3365
  readonly role?: string;
3345
3366
  };
3346
3367
  /**
3347
- * `false`: only admins may sign in with a password (a way in when the provider is down);
3348
- * everyone else signs in with `providers`. Default `true`.
3368
+ * Who may sign in with a password; everyone else signs in with `providers`. Default `true`:
3369
+ * everyone. `false`: only admins (a way in when the provider is down). `{ breakGlass: [emails] }`:
3370
+ * only those accounts, for organisations that require single sign-on (and its MFA) for every
3371
+ * other account, admins included. A change ends the sessions of accounts that may no longer use a
3372
+ * password, and is recorded in the audit log, as is each sign-in of a break-glass account.
3349
3373
  */
3350
- readonly password?: boolean;
3374
+ readonly password?: boolean | PasswordPolicy;
3351
3375
  /**
3352
3376
  * A code the first admin must enter to create their account, so nobody else can claim a
3353
3377
  * freshly deployed site. Default: the `EASY_CMS_SETUP_CODE` environment variable; without it,
@@ -3366,6 +3390,25 @@ interface AuthConfig {
3366
3390
  readonly passwordChanged?: PasswordEmailFn;
3367
3391
  };
3368
3392
  }
3393
+ /** `auth.password` with break-glass accounts: the only ones that sign in with a password. */
3394
+ interface PasswordPolicy {
3395
+ /** Emails of the accounts that may still use a password, e.g. one kept for when the provider is down. */
3396
+ readonly breakGlass: readonly string[];
3397
+ }
3398
+ interface RateLimitConfig {
3399
+ /** Seconds each count lasts. Default 60. */
3400
+ readonly window?: number;
3401
+ /**
3402
+ * Requests per window from one IP without signing in. Default 300; `false`: no limit. Needs the
3403
+ * client's IP: behind a proxy, set `trustProxy` on the adapter (without it, visitors aren't
3404
+ * limited, and the log says so once).
3405
+ */
3406
+ readonly anonymous?: number | false;
3407
+ /** Requests per window from one signed-in user (the admin included). Default 1200; `false`: no limit. */
3408
+ readonly users?: number | false;
3409
+ /** Requests per window with one API key. Default 1200; `false`: no limit. */
3410
+ readonly apiKeys?: number | false;
3411
+ }
3369
3412
  /** What `onRequest` receives. */
3370
3413
  interface OnRequestArgs {
3371
3414
  readonly headers: Headers;
@@ -3673,6 +3716,13 @@ interface Config {
3673
3716
  * Default `false`: hooks run in the request, after the save.
3674
3717
  */
3675
3718
  readonly outbox?: boolean;
3719
+ /**
3720
+ * Limits requests to the API per client and minute, counted across instances: each visitor's
3721
+ * IP, each signed-in user and each API key. Over the limit, the API answers `429` with
3722
+ * `Retry-After`. `{}` uses the defaults. Needs a migration for the `rate-limits` table. Default
3723
+ * off.
3724
+ */
3725
+ readonly rateLimit?: RateLimitConfig;
3676
3726
  /**
3677
3727
  * Relevance-ranked search of the collections that have `search`, e.g. `memorySearch()` for
3678
3728
  * development and small sites, or an adapter for a search engine. Published documents are
@@ -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-CpeC7EvC.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-BuKlngv6.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-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';
1
+ import { P as PaginatedDocs, I as ID, d as EmailSetting, W as WebhookPayload } from './config-BuKlngv6.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-BuKlngv6.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-zOdLJ1oh.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-DuOjJu_X.js';
5
5
  export { FieldCondition, matchesCondition } from './conditions.js';
6
6
 
7
7
  type DeliveryKind = 'webhook' | 'email';
@@ -196,6 +196,12 @@ type AdminAttention =
196
196
  id: 'failed-logins';
197
197
  count: number;
198
198
  }
199
+ /** Break-glass accounts signed in with a password in the last day (`auth.password`). */
200
+ | {
201
+ id: 'break-glass';
202
+ count: number;
203
+ emails: string[];
204
+ }
199
205
  /** Audit log entries that could not be written since the server started. */
200
206
  | {
201
207
  id: 'audit-failures';
package/dist/index.js CHANGED
@@ -28,7 +28,7 @@ import {
28
28
  silentLogger,
29
29
  slugify,
30
30
  verifyWebhook
31
- } from "./chunk-NTAFZ4M6.js";
31
+ } from "./chunk-QP6PENQI.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, 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';
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-BuKlngv6.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-DuOjJu_X.js';
4
4
  export { conditionIssues } from './conditions.js';
5
5
 
6
6
  /** The session cookie the REST API sets at login. */
package/dist/internal.js CHANGED
@@ -58,7 +58,7 @@ import {
58
58
  verifyPassword,
59
59
  verifyTurnstile,
60
60
  writeBackupFile
61
- } from "./chunk-NTAFZ4M6.js";
61
+ } from "./chunk-QP6PENQI.js";
62
62
  import {
63
63
  conditionIssues
64
64
  } from "./chunk-U266LV4M.js";
@@ -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-CpeC7EvC.js';
1
+ import { bh as Label, F as Field, I as ID, o as AdminLocale, x as AdminSwitcher, ch as SsoProviderRef } from './config-BuKlngv6.js';
2
2
  import { FieldCondition } from './conditions.js';
3
3
 
4
4
  /** A Web Component from an admin module, with its `props` (plain JSON). */
@@ -211,6 +211,12 @@ interface AdminSchema {
211
211
  switcher: AdminSwitcher | null;
212
212
  /** Admins can email users links to set their password (needs `email` and `serverURL`). */
213
213
  passwordLinks: boolean;
214
+ /**
215
+ * Who may have a password (`auth.password`); with `breakGlass`, the accounts, for admins of the
216
+ * whole system: others get no password field.
217
+ */
218
+ passwordFor: 'everyone' | 'admins' | 'breakGlass';
219
+ breakGlass?: string[];
214
220
  /** The menu: groups and what this user may open in them, in order. */
215
221
  nav: AdminNavNode[];
216
222
  /** More entries of the command palette (`admin.commands`). */
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-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';
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-BuKlngv6.js';
2
+ export { A as ADMIN_API_VERSION, c as AdminElementProps, k as AdminPageRoute, S as SetFieldDetail, w as warnDeprecated } from './plugin-zOdLJ1oh.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.18.0",
3
+ "version": "1.20.0",
4
4
  "description": "Embedded, code-first headless CMS for Nuxt and Next.js",
5
5
  "license": "MIT",
6
6
  "type": "module",