@easy-cms/core 0.44.0 → 0.46.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.
Files changed (3) hide show
  1. package/dist/index.d.ts +325 -24
  2. package/dist/index.js +597 -95
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // src/access.ts
2
2
  var isSystemAdmin = (user) => user?.role === "admin" && !user.apiKey && user.scoped !== true;
3
3
  var anyone = () => true;
4
- var isLoggedIn = ({ user }) => user !== null;
4
+ var isLoggedIn = ({ user }) => user !== null && user.member !== true;
5
+ var isSignedIn = ({ user }) => user !== null;
5
6
  var isAdmin = ({ user }) => user?.role === "admin";
6
7
 
7
8
  // src/api-keys.ts
@@ -192,7 +193,12 @@ var adminOrSelf = ({ user }) => {
192
193
  var adminOnly = {
193
194
  update: ({ user }) => isSystemAdmin(user)
194
195
  };
195
- function userFields(roles, rbac) {
196
+ var staffOrSelf = ({ user }) => {
197
+ if (!user) return false;
198
+ if (user.member !== true) return true;
199
+ return { id: { equals: user.id } };
200
+ };
201
+ function userFields(roles, rbac, members) {
196
202
  const defaultRole = roles.includes("editor") ? "editor" : roles[roles.length - 1];
197
203
  return [
198
204
  {
@@ -227,6 +233,16 @@ function userFields(roles, rbac) {
227
233
  access: adminOnly,
228
234
  label: { en: "Active", th: "\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19" }
229
235
  },
236
+ // Accounts made by signing up (`auth.members.signup`) are `false` until the email is confirmed.
237
+ ...members ? [
238
+ {
239
+ name: "emailVerified",
240
+ type: "boolean",
241
+ access: adminOnly,
242
+ position: "sidebar",
243
+ label: { en: "Email confirmed", th: "\u0E22\u0E37\u0E19\u0E22\u0E31\u0E19\u0E2D\u0E35\u0E40\u0E21\u0E25\u0E41\u0E25\u0E49\u0E27" }
244
+ }
245
+ ] : [],
230
246
  { name: "passwordHash", type: "text", hidden: true }
231
247
  ];
232
248
  }
@@ -239,9 +255,12 @@ function withUsers(config) {
239
255
  useAsTitle: "email",
240
256
  icon: "users",
241
257
  ...custom,
242
- fields: [...userFields(roles, config.auth?.rbac === true), ...custom?.fields ?? []],
258
+ fields: [
259
+ ...userFields(roles, config.auth?.rbac === true, config.auth?.members?.signup !== void 0),
260
+ ...custom?.fields ?? []
261
+ ],
243
262
  access: {
244
- read: isLoggedIn,
263
+ read: staffOrSelf,
245
264
  create: ({ user }) => isSystemAdmin(user),
246
265
  update: adminOrSelf,
247
266
  delete: ({ user }) => isSystemAdmin(user),
@@ -427,7 +446,16 @@ function withMedia(config) {
427
446
  const customFolders = config.collections?.find((c) => c.slug === MEDIA_FOLDERS);
428
447
  const mediaFolders = customFolders ? {
429
448
  ...mediaFoldersCollection,
430
- fields: [...mediaFoldersCollection.fields, ...customFolders.fields],
449
+ // A field named like a built-in one changes it (e.g. `key` unique per tenant).
450
+ fields: [
451
+ ...mediaFoldersCollection.fields.map((field) => {
452
+ const own = customFolders.fields.find((f) => f.name === field.name);
453
+ return own ? { ...field, ...own } : field;
454
+ }),
455
+ ...customFolders.fields.filter(
456
+ (f) => !mediaFoldersCollection.fields.some((b) => b.name === f.name)
457
+ )
458
+ ],
431
459
  access: { ...mediaFoldersCollection.access, ...customFolders.access },
432
460
  hooks: {
433
461
  ...customFolders.hooks,
@@ -562,7 +590,9 @@ var auditLogsCollection = {
562
590
  { name: "userAgent", type: "text" },
563
591
  { name: "changes", type: "json" },
564
592
  { name: "detail", type: "json" },
565
- { name: "signature", type: "text" }
593
+ { name: "signature", type: "text" },
594
+ // The part of the site, e.g. the tenant (`audit.scope`).
595
+ { name: "scope", type: "text", index: true }
566
596
  ]
567
597
  };
568
598
  var emailDeliveriesCollection = {
@@ -635,7 +665,8 @@ var AuditLog = class {
635
665
  return this.cms.config.audit || {
636
666
  keep: DEFAULT_AUDIT_KEEP,
637
667
  values: true,
638
- failedLogins: DEFAULT_FAILED_LOGINS
668
+ failedLogins: DEFAULT_FAILED_LOGINS,
669
+ scope: null
639
670
  };
640
671
  }
641
672
  /**
@@ -646,6 +677,7 @@ var AuditLog = class {
646
677
  if (!this.enabled) return;
647
678
  try {
648
679
  const context = auditContext.getStore();
680
+ const scope = this.scopeOf(entry.context ?? context?.context);
649
681
  const user = entry.user !== void 0 ? entry.user : context?.user ?? null;
650
682
  const apiKey = user?.apiKey;
651
683
  const row = {
@@ -660,7 +692,8 @@ var AuditLog = class {
660
692
  userAgent: context?.userAgent ? context.userAgent.slice(0, 200) : null,
661
693
  changes: entry.changes && entry.changes.length > 0 ? entry.changes : null,
662
694
  detail: apiKey ? { ...entry.detail, apiKey: apiKey.name ?? "" } : entry.detail ?? null,
663
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
695
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
696
+ ...scope ? { scope } : {}
664
697
  };
665
698
  row.signature = this.sign(row);
666
699
  row.updatedAt = row.createdAt;
@@ -676,7 +709,7 @@ var AuditLog = class {
676
709
  * A change to a document or global: what changed between `before` and `after`. Internal
677
710
  * collections (sessions, versions…) are not logged.
678
711
  */
679
- async content(config, global2, action, after, before, user) {
712
+ async content(config, global2, action, after, before, user, context) {
680
713
  if (!this.enabled) return;
681
714
  if (!global2 && INTERNAL_COLLECTIONS.has(config.slug)) return;
682
715
  const doc = after ?? before;
@@ -692,7 +725,8 @@ var AuditLog = class {
692
725
  title,
693
726
  // A trusted call without a user keeps the request's (or is the system).
694
727
  ...user ? { user } : {},
695
- changes: action === "delete" ? null : this.diff(config.fields, before ?? {}, after ?? {})
728
+ changes: action === "delete" ? null : this.diff(config.fields, before ?? {}, after ?? {}),
729
+ ...context && Object.keys(context).length > 0 ? { context } : {}
696
730
  });
697
731
  }
698
732
  /** Fields whose stored value changed. Hidden ones never (the password only as "changed"). */
@@ -837,8 +871,15 @@ var AuditLog = class {
837
871
  return out;
838
872
  }
839
873
  // -------------------------------------------------------------------------
874
+ /** The part of the site a context is in (`scope`), or `null`. */
875
+ scopeOf(context) {
876
+ const scope = this.settings.scope;
877
+ return (scope && context ? scope(context) : null) ?? null;
878
+ }
840
879
  sign(row) {
841
- const payload = stable(SIGNED.map((key2) => row[key2] ?? null));
880
+ const values = SIGNED.map((key2) => row[key2] ?? null);
881
+ if (typeof row.scope === "string" && row.scope) values.push(row.scope);
882
+ const payload = stable(values);
842
883
  return createHmac("sha256", this.cms.config.secret).update(`audit:${payload}`).digest("base64url");
843
884
  }
844
885
  async lastVerification() {
@@ -866,6 +907,7 @@ var AuditLog = class {
866
907
  if (filter.actor) parts.push({ actorEmail: { like: filter.actor } });
867
908
  if (filter.from) parts.push({ createdAt: { gte: filter.from } });
868
909
  if (filter.to) parts.push({ createdAt: { lte: filter.to } });
910
+ if (filter.scope) parts.push({ scope: { equals: filter.scope } });
869
911
  return parts.length === 0 ? void 0 : parts.length === 1 ? parts[0] : { and: parts };
870
912
  }
871
913
  };
@@ -1137,6 +1179,7 @@ function sizeKey(filename, size) {
1137
1179
  var PRIVATE_KEY = /^[\p{L}\p{M}\p{N}-]+\.private(-[\p{L}\p{N}_-]+)?\.[a-z0-9]+$/u;
1138
1180
 
1139
1181
  // src/fields.ts
1182
+ var uniqueWithinOf = (field) => field.uniqueWithin === void 0 ? [] : [field.uniqueWithin].flat();
1140
1183
  var isHasMany = (field) => (field.type === "select" || field.type === "relationship" || field.type === "upload") && field.hasMany === true;
1141
1184
  function mimeAllowedBy(mimeType, patterns) {
1142
1185
  const type = mimeType.toLowerCase();
@@ -1249,6 +1292,48 @@ async function filterInput(fields, data, update2) {
1249
1292
  return result;
1250
1293
  }
1251
1294
 
1295
+ // src/spam.ts
1296
+ import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
1297
+ var MAX_AGE = 24 * 36e5;
1298
+ var sign = (secret, value) => createHmac2("sha256", secret).update(value).digest("base64url");
1299
+ function formToken(secret, purpose, now = Date.now()) {
1300
+ return `${now}.${sign(secret, `${purpose}:${now}`)}`;
1301
+ }
1302
+ function checkFormToken(secret, purpose, token, minTime, now = Date.now()) {
1303
+ if (typeof token !== "string") return "invalid";
1304
+ const [time, signature] = token.split(".");
1305
+ const issued = Number(time);
1306
+ if (!Number.isFinite(issued) || !signature) return "invalid";
1307
+ const expected = Buffer.from(sign(secret, `${purpose}:${issued}`));
1308
+ const given = Buffer.from(signature);
1309
+ if (expected.length !== given.length || !timingSafeEqual2(expected, given)) return "invalid";
1310
+ if (now - issued < minTime) return "too-fast";
1311
+ if (now - issued > MAX_AGE) return "expired";
1312
+ return "ok";
1313
+ }
1314
+ function rateKeys(secret, subject, window, now = Date.now()) {
1315
+ const bucket = Math.floor(now / (window * 1e3));
1316
+ const key2 = (b) => sign(secret, `${subject}:${b}`).slice(0, 32);
1317
+ return { current: key2(bucket), previous: key2(bucket - 1) };
1318
+ }
1319
+ function honeypotName(names2) {
1320
+ return ["website", "homepage", "url_address"].find((n) => !names2.includes(n)) ?? "hp_field";
1321
+ }
1322
+ async function verifyTurnstile(secretKey, response, ip, fetcher = fetch) {
1323
+ if (typeof response !== "string" || !response) return false;
1324
+ const body = new URLSearchParams({ secret: secretKey, response, ...ip ? { remoteip: ip } : {} });
1325
+ try {
1326
+ const result = await fetcher("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
1327
+ method: "POST",
1328
+ body,
1329
+ signal: AbortSignal.timeout(1e4)
1330
+ });
1331
+ return (await result.json()).success === true;
1332
+ } catch {
1333
+ return false;
1334
+ }
1335
+ }
1336
+
1252
1337
  // src/auth/cookie.ts
1253
1338
  var SESSION_COOKIE = "ecms-session";
1254
1339
 
@@ -1265,6 +1350,21 @@ function email(subject, lines, button) {
1265
1350
  var hours = (expiresAt) => Math.max(1, Math.round(((expiresAt?.getTime() ?? Date.now()) - Date.now()) / 36e5));
1266
1351
  var days = (expiresAt) => Math.max(1, Math.round(hours(expiresAt) / 24));
1267
1352
  var DEFAULT_PASSWORD_EMAILS = {
1353
+ verifyEmail: ({ url = "", locale, expiresAt }) => locale === "th" ? email(
1354
+ "\u0E22\u0E37\u0E19\u0E22\u0E31\u0E19\u0E2D\u0E35\u0E40\u0E21\u0E25\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13",
1355
+ [
1356
+ "\u0E02\u0E2D\u0E1A\u0E04\u0E38\u0E13\u0E17\u0E35\u0E48\u0E2A\u0E21\u0E31\u0E04\u0E23\u0E2A\u0E21\u0E32\u0E0A\u0E34\u0E01 \u0E01\u0E14\u0E1B\u0E38\u0E48\u0E21\u0E14\u0E49\u0E32\u0E19\u0E25\u0E48\u0E32\u0E07\u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E22\u0E37\u0E19\u0E22\u0E31\u0E19\u0E2D\u0E35\u0E40\u0E21\u0E25\u0E41\u0E25\u0E30\u0E40\u0E02\u0E49\u0E32\u0E2A\u0E39\u0E48\u0E23\u0E30\u0E1A\u0E1A",
1357
+ `\u0E25\u0E34\u0E07\u0E01\u0E4C\u0E19\u0E35\u0E49\u0E43\u0E0A\u0E49\u0E44\u0E14\u0E49\u0E20\u0E32\u0E22\u0E43\u0E19 ${days(expiresAt)} \u0E27\u0E31\u0E19 \u0E16\u0E49\u0E32\u0E04\u0E38\u0E13\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E2A\u0E21\u0E31\u0E04\u0E23 \u0E44\u0E21\u0E48\u0E15\u0E49\u0E2D\u0E07\u0E17\u0E33\u0E2D\u0E30\u0E44\u0E23`
1358
+ ],
1359
+ { label: "\u0E22\u0E37\u0E19\u0E22\u0E31\u0E19\u0E2D\u0E35\u0E40\u0E21\u0E25", url }
1360
+ ) : email(
1361
+ "Confirm your email",
1362
+ [
1363
+ "Thanks for signing up. Confirm your email to sign in.",
1364
+ `The link works within ${days(expiresAt)} day(s). If you didn't sign up, ignore this email.`
1365
+ ],
1366
+ { label: "Confirm email", url }
1367
+ ),
1268
1368
  resetPassword: ({ url = "", locale, expiresAt }) => locale === "th" ? email(
1269
1369
  "\u0E15\u0E31\u0E49\u0E07\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E43\u0E2B\u0E21\u0E48",
1270
1370
  [
@@ -1315,7 +1415,7 @@ var DEFAULT_PASSWORD_EMAILS = {
1315
1415
  };
1316
1416
 
1317
1417
  // src/auth/password.ts
1318
- import { randomBytes as randomBytes2, scrypt, timingSafeEqual as timingSafeEqual2 } from "crypto";
1418
+ import { randomBytes as randomBytes2, scrypt, timingSafeEqual as timingSafeEqual3 } from "crypto";
1319
1419
  var MIN_PASSWORD_LENGTH = 8;
1320
1420
  var N = 2 ** 17;
1321
1421
  var R = 8;
@@ -1350,7 +1450,7 @@ async function verifyPassword(password, stored) {
1350
1450
  Number(p),
1351
1451
  expected.length
1352
1452
  );
1353
- return key2.length === expected.length && timingSafeEqual2(key2, expected);
1453
+ return key2.length === expected.length && timingSafeEqual3(key2, expected);
1354
1454
  }
1355
1455
  var dummyHash;
1356
1456
  async function fakeVerify(password) {
@@ -1360,10 +1460,10 @@ async function fakeVerify(password) {
1360
1460
  }
1361
1461
 
1362
1462
  // src/auth/sso.ts
1363
- import { createHmac as createHmac3, randomBytes as randomBytes4 } from "crypto";
1463
+ import { createHmac as createHmac4, randomBytes as randomBytes4 } from "crypto";
1364
1464
 
1365
1465
  // src/auth/tokens.ts
1366
- import { createHash as createHash2, createHmac as createHmac2, randomBytes as randomBytes3, timingSafeEqual as timingSafeEqual3 } from "crypto";
1466
+ import { createHash as createHash2, createHmac as createHmac3, randomBytes as randomBytes3, timingSafeEqual as timingSafeEqual4 } from "crypto";
1367
1467
  function newToken() {
1368
1468
  return randomBytes3(32).toString("base64url");
1369
1469
  }
@@ -1371,7 +1471,7 @@ function hashToken(token) {
1371
1471
  return createHash2("sha256").update(token).digest("hex");
1372
1472
  }
1373
1473
  function hmac(secret, value) {
1374
- return createHmac2("sha256", secret).update(value).digest("base64url");
1474
+ return createHmac3("sha256", secret).update(value).digest("base64url");
1375
1475
  }
1376
1476
  function signToken(secret, token) {
1377
1477
  return `${token}.${hmac(secret, `session:${token}`)}`;
@@ -1388,7 +1488,7 @@ function csrfToken(secret, token) {
1388
1488
  function safeEqual(a, b) {
1389
1489
  const left = Buffer.from(a);
1390
1490
  const right = Buffer.from(b);
1391
- return left.length === right.length && timingSafeEqual3(left, right);
1491
+ return left.length === right.length && timingSafeEqual4(left, right);
1392
1492
  }
1393
1493
  function signPreviewToken(secret, target, expiresAt) {
1394
1494
  const payload = Buffer.from(
@@ -1430,7 +1530,7 @@ function readPasswordToken(token) {
1430
1530
  try {
1431
1531
  const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
1432
1532
  if (typeof data.u !== "string" || typeof data.e !== "number") return null;
1433
- if (data.p !== "reset" && data.p !== "invite") return null;
1533
+ if (data.p !== "reset" && data.p !== "invite" && data.p !== "verify") return null;
1434
1534
  return { userId: data.u, purpose: data.p, expiresAt: data.e, payload };
1435
1535
  } catch {
1436
1536
  return null;
@@ -1447,19 +1547,19 @@ function passwordTokenMatches(secret, token, fingerprint) {
1447
1547
  var SSO_COOKIE = "ecms-sso";
1448
1548
  var PENDING_TTL = 10 * 6e4;
1449
1549
  var random = () => randomBytes4(32).toString("base64url");
1450
- function sign(secret, payload) {
1451
- return createHmac3("sha256", secret).update(`sso:${payload}`).digest("base64url");
1550
+ function sign2(secret, payload) {
1551
+ return createHmac4("sha256", secret).update(`sso:${payload}`).digest("base64url");
1452
1552
  }
1453
1553
  function seal(secret, pending) {
1454
1554
  const payload = Buffer.from(JSON.stringify(pending)).toString("base64url");
1455
- return `${payload}.${sign(secret, payload)}`;
1555
+ return `${payload}.${sign2(secret, payload)}`;
1456
1556
  }
1457
1557
  function unseal(secret, value) {
1458
1558
  if (!value) return null;
1459
1559
  const dot = value.lastIndexOf(".");
1460
1560
  if (dot <= 0) return null;
1461
1561
  const payload = value.slice(0, dot);
1462
- if (!safeEqual(value.slice(dot + 1), sign(secret, payload))) return null;
1562
+ if (!safeEqual(value.slice(dot + 1), sign2(secret, payload))) return null;
1463
1563
  try {
1464
1564
  const pending = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
1465
1565
  return typeof pending.e === "number" && pending.e > Date.now() ? pending : null;
@@ -1737,6 +1837,16 @@ var SingleSignOn = class {
1737
1837
 
1738
1838
  // src/auth/auth.ts
1739
1839
  var INVALID = "Invalid email or password";
1840
+ var SIGNUP_FORM = "easy-cms-signup";
1841
+ var SIGNUP_MIN_TIME = 2e3;
1842
+ var SIGNUP_HONEYPOT = "website";
1843
+ function asMember(user, memberRoles) {
1844
+ const member = memberRoles.includes(String(user.role));
1845
+ if (member === (user.member === true)) return user;
1846
+ if (member) return { ...user, member: true };
1847
+ const { member: _member, ...rest } = user;
1848
+ return rest;
1849
+ }
1740
1850
  var RESET_REQUESTS = 3;
1741
1851
  var Auth = class {
1742
1852
  constructor(cms) {
@@ -1786,6 +1896,16 @@ var Auth = class {
1786
1896
  throw new UnauthorizedError(INVALID);
1787
1897
  }
1788
1898
  await this.clearFailures(key2);
1899
+ if (user.emailVerified === false) {
1900
+ await this.cms.audit.record({
1901
+ action: "login.failed",
1902
+ target: "auth",
1903
+ user: null,
1904
+ email: email2,
1905
+ detail: { reason: "email not confirmed" }
1906
+ });
1907
+ throw new UnauthorizedError("Confirm your email first: follow the link we sent you");
1908
+ }
1789
1909
  if (!this.sso.passwordAllowed(user)) {
1790
1910
  await this.cms.audit.record({
1791
1911
  action: "login.failed",
@@ -1828,6 +1948,136 @@ var Auth = class {
1828
1948
  const raw = await this.db.findById({ collection: USERS, id: user.id });
1829
1949
  return this.startSession(raw);
1830
1950
  }
1951
+ // --- Members signing up (`auth.members.signup`) ---------------------------------------------
1952
+ /** What a sign-up form needs: a token proving when it was loaded, and Turnstile's site key. */
1953
+ signupForm() {
1954
+ const signup = this.config.auth.members.signup;
1955
+ if (!signup) throw new NotFoundError(USERS, "signup");
1956
+ return {
1957
+ token: formToken(this.config.secret, SIGNUP_FORM),
1958
+ turnstile: signup.turnstile?.siteKey ?? null,
1959
+ verifyEmail: signup.verifyEmail !== false
1960
+ };
1961
+ }
1962
+ /**
1963
+ * Creates a member's account (`auth.members.signup`). With `verifyEmail` (the default), emails
1964
+ * a link to confirm the address and answers the same whether or not the email has an account;
1965
+ * otherwise starts a session. Throws `ValidationError` for spam checks and bad input.
1966
+ */
1967
+ async signup(args) {
1968
+ const signup = this.config.auth.members.signup;
1969
+ if (!signup) throw new NotFoundError(USERS, "signup");
1970
+ const key2 = `signup|${args.ip ?? ""}`;
1971
+ await this.checkRateLimit(key2);
1972
+ const refuse = (field, message2) => new ValidationError(USERS, [{ field, message: message2 }]);
1973
+ if (typeof args.honeypot === "string" && args.honeypot !== "") {
1974
+ await this.recordFailure(key2);
1975
+ throw refuse("form", "could not be sent");
1976
+ }
1977
+ const checked = checkFormToken(this.config.secret, SIGNUP_FORM, args.token, SIGNUP_MIN_TIME);
1978
+ if (checked !== "ok")
1979
+ throw refuse(
1980
+ "form",
1981
+ checked === "expired" ? "was open too long: reload the page" : "could not be sent"
1982
+ );
1983
+ if (signup.turnstile && !await verifyTurnstile(signup.turnstile.secretKey, args.turnstile, args.ip))
1984
+ throw refuse("turnstile", "is not complete");
1985
+ await this.recordFailure(key2);
1986
+ const email2 = typeof args.email === "string" ? args.email.trim().toLowerCase() : "";
1987
+ const verify = signup.verifyEmail !== false;
1988
+ if (verify && !this.canSendPasswordLinks(args.origin))
1989
+ throw new QueryError("Signing up needs `email` in the config, and a site URL in production");
1990
+ const existing = email2 ? await this.findByEmail(email2) : void 0;
1991
+ if (existing && verify) {
1992
+ if (existing.emailVerified === false && existing.active !== false)
1993
+ await this.mailVerifyLink(existing, args);
1994
+ return { verify: true };
1995
+ }
1996
+ const created = await this.cms.create(
1997
+ USERS,
1998
+ {
1999
+ email: email2,
2000
+ password: args.password,
2001
+ role: signup.role,
2002
+ active: true,
2003
+ emailVerified: !verify,
2004
+ ...typeof args.name === "string" && args.name.trim() ? { name: args.name.trim() } : {}
2005
+ },
2006
+ { context: args.context ?? {} }
2007
+ );
2008
+ const user = await this.db.findById({ collection: USERS, id: created.id });
2009
+ await this.cms.audit.record({
2010
+ action: "signup",
2011
+ target: "auth",
2012
+ user: await this.toAuthUser(user)
2013
+ });
2014
+ if (verify) {
2015
+ await this.mailVerifyLink(user, args);
2016
+ return { verify: true };
2017
+ }
2018
+ return { ...await this.startSession(user), verify: false };
2019
+ }
2020
+ /** Confirms a member's email from the link, and signs them in. */
2021
+ async verifyEmail(args) {
2022
+ const key2 = `verify-token|${args.ip ?? ""}`;
2023
+ await this.checkRateLimit(key2);
2024
+ const token = String(args.token ?? "");
2025
+ const claims = readPasswordToken(token);
2026
+ const id = claims && (/^\d+$/.test(claims.userId) ? Number(claims.userId) : claims.userId);
2027
+ const user = claims && claims.purpose === "verify" && claims.expiresAt > Date.now() && id !== null ? await this.db.findById({ collection: USERS, id }) : null;
2028
+ if (!user || user.active === false || !passwordTokenMatches(this.config.secret, token, passwordFingerprint(user.passwordHash))) {
2029
+ await this.recordFailure(key2);
2030
+ throw new ValidationError(USERS, [
2031
+ { field: "token", message: "This link has expired or is not valid" }
2032
+ ]);
2033
+ }
2034
+ await this.clearFailures(key2);
2035
+ if (user.emailVerified === false) {
2036
+ const { id: _id, ...rest } = user;
2037
+ await this.db.update({
2038
+ collection: USERS,
2039
+ id: user.id,
2040
+ data: { ...rest, emailVerified: true }
2041
+ });
2042
+ }
2043
+ const session = await this.startSession(
2044
+ await this.db.findById({ collection: USERS, id: user.id })
2045
+ );
2046
+ await this.cms.audit.record({ action: "email.verified", target: "auth", user: session.user });
2047
+ return session;
2048
+ }
2049
+ async mailVerifyLink(user, options) {
2050
+ const expiresAt = Date.now() + this.config.auth.inviteExpiration * 1e3;
2051
+ const token = signPasswordToken(this.config.secret, {
2052
+ userId: String(user.id),
2053
+ purpose: "verify",
2054
+ expiresAt,
2055
+ fingerprint: passwordFingerprint(user.passwordHash)
2056
+ });
2057
+ const url = this.memberLink("verifyEmail", token, options.origin);
2058
+ const write = this.config.auth.members.emails?.verifyEmail ?? DEFAULT_PASSWORD_EMAILS.verifyEmail;
2059
+ const content = await write({
2060
+ user: await this.toAuthUser(user),
2061
+ url,
2062
+ locale: this.localeOf(options.locale),
2063
+ expiresAt: new Date(expiresAt)
2064
+ });
2065
+ await this.cms.sendEmail({ to: String(user.email), ...content });
2066
+ }
2067
+ /**
2068
+ * A link to one of the site's pages for members (`auth.members.pages`), else to the admin's
2069
+ * page of the same purpose.
2070
+ */
2071
+ memberLink(page, token, origin) {
2072
+ const target = this.config.auth.members.pages?.[page];
2073
+ const query = `token=${encodeURIComponent(token)}`;
2074
+ if (target && /^https?:\/\//.test(target))
2075
+ return `${target}${target.includes("?") ? "&" : "?"}${query}`;
2076
+ const site = (this.config.admin.siteUrl || "").replace(/\/+$/, "");
2077
+ const base = site || this.passwordLinkBase(origin);
2078
+ const path = target ?? `${this.config.admin.path}/${page === "verifyEmail" ? "verify-email" : "reset-password"}`;
2079
+ return `${base}${path.startsWith("/") ? "" : "/"}${path}${path.includes("?") ? "&" : "?"}${query}`;
2080
+ }
1831
2081
  async hasUsers() {
1832
2082
  return await this.db.count({ collection: USERS }) > 0;
1833
2083
  }
@@ -2000,7 +2250,7 @@ var Auth = class {
2000
2250
  }
2001
2251
  async passwordTokenUser(token) {
2002
2252
  const claims = readPasswordToken(token);
2003
- if (!claims || claims.expiresAt <= Date.now()) return null;
2253
+ if (!claims || claims.purpose === "verify" || claims.expiresAt <= Date.now()) return null;
2004
2254
  const id = /^\d+$/.test(claims.userId) ? Number(claims.userId) : claims.userId;
2005
2255
  const user = await this.db.findById({ collection: USERS, id });
2006
2256
  if (!user || user.active === false || !this.sso.passwordAllowed(user)) return null;
@@ -2019,7 +2269,8 @@ var Auth = class {
2019
2269
  fingerprint: passwordFingerprint(user.passwordHash)
2020
2270
  });
2021
2271
  const sso = this.sso.passwordAllowed(user) ? void 0 : this.config.auth.providers.map((p) => p.name);
2022
- const url = sso ? `${base}${this.config.admin.path}/login` : `${base}${this.config.admin.path}/reset-password?token=${encodeURIComponent(token)}`;
2272
+ const member = this.config.auth.members.roles.includes(String(user.role));
2273
+ const url = sso ? `${base}${this.config.admin.path}/login` : member ? this.memberLink("resetPassword", token, options.origin) : `${base}${this.config.admin.path}/reset-password?token=${encodeURIComponent(token)}`;
2023
2274
  const write = purpose === "invite" ? this.config.auth.emails.invite ?? DEFAULT_PASSWORD_EMAILS.invite : this.config.auth.emails.resetPassword ?? DEFAULT_PASSWORD_EMAILS.resetPassword;
2024
2275
  const content = await write({
2025
2276
  user: await this.toAuthUser(user),
@@ -2112,7 +2363,8 @@ var Auth = class {
2112
2363
  }
2113
2364
  async toAuthUser(user) {
2114
2365
  const fields = this.cms.collection(USERS).fields;
2115
- return await stripFields(fields, user);
2366
+ const stripped = await stripFields(fields, user);
2367
+ return asMember(stripped, this.config.auth.members.roles);
2116
2368
  }
2117
2369
  async deleteExpiredSessions(userId) {
2118
2370
  const expired = await this.db.find({
@@ -2890,7 +3142,7 @@ async function normalizeValue(field, raw, path, options, fail, errors, reference
2890
3142
  field: path,
2891
3143
  collection: target,
2892
3144
  id,
2893
- ...field.type === "relationship" && field.filterOptions ? { filterOptions: field.filterOptions } : {},
3145
+ ...field.filterOptions ? { filterOptions: field.filterOptions } : {},
2894
3146
  ...field.type === "upload" && field.mimeTypes ? { mimeTypes: field.mimeTypes } : {},
2895
3147
  ...field.type === "upload" && field.folder && field.folderOnly ? { folderOnly: field.folder } : {}
2896
3148
  });
@@ -3221,7 +3473,7 @@ function applyFieldTypes(config) {
3221
3473
  }
3222
3474
 
3223
3475
  // src/local-api.ts
3224
- import { createHmac as createHmac5, randomBytes as randomBytes5, timingSafeEqual as timingSafeEqual4 } from "crypto";
3476
+ import { createHmac as createHmac6, randomBytes as randomBytes5, timingSafeEqual as timingSafeEqual5 } from "crypto";
3225
3477
 
3226
3478
  // src/localization.ts
3227
3479
  var ALL_LOCALES = "all";
@@ -3376,7 +3628,8 @@ var MediaFolders = class {
3376
3628
  name: String(doc.name ?? ""),
3377
3629
  key: typeof doc.key === "string" && doc.key ? doc.key : null,
3378
3630
  private: doc.private === true,
3379
- permissions: permissions && typeof permissions === "object" && !Array.isArray(permissions) ? permissions : null
3631
+ permissions: permissions && typeof permissions === "object" && !Array.isArray(permissions) ? permissions : null,
3632
+ doc
3380
3633
  });
3381
3634
  }
3382
3635
  this.cache = { at: Date.now(), rows };
@@ -3446,18 +3699,15 @@ var MediaFolders = class {
3446
3699
  });
3447
3700
  return this.within(rows, [id]).map((f) => ({ id: f, private: this.privateIn(rows, f) }));
3448
3701
  }
3449
- /**
3450
- * The folder with this key (`folder: 'banners'` on upload fields), made at the top level when
3451
- * there is none yet.
3452
- */
3453
- async keyed(key2) {
3454
- const find = async () => [...(await this.rows()).values()].find((r) => r.key === key2);
3702
+ async keyed(key2, scope = {}, create = true) {
3703
+ const find = async () => [...(await this.rows()).values()].find((r) => r.key === key2 && inScope(r.doc, scope));
3455
3704
  let found = await find();
3456
3705
  if (!found) {
3457
3706
  this.invalidate();
3458
3707
  found = await find();
3459
3708
  }
3460
3709
  if (found) return found.id;
3710
+ if (!create) return null;
3461
3711
  const now = (/* @__PURE__ */ new Date()).toISOString();
3462
3712
  try {
3463
3713
  const doc = await this.db.create({
@@ -3468,6 +3718,7 @@ var MediaFolders = class {
3468
3718
  parent: null,
3469
3719
  permissions: null,
3470
3720
  private: false,
3721
+ ...scope,
3471
3722
  createdAt: now,
3472
3723
  updatedAt: now
3473
3724
  }
@@ -3482,10 +3733,10 @@ var MediaFolders = class {
3482
3733
  }
3483
3734
  }
3484
3735
  /** Whether a file in `folder` is in the folder with this key, or below it. */
3485
- async inKeyed(key2, folder) {
3736
+ async inKeyed(key2, folder, scope = {}) {
3486
3737
  if (folder === null) return false;
3487
3738
  const rows = await this.rows();
3488
- const root = [...rows.values()].find((r) => r.key === key2);
3739
+ const root = [...rows.values()].find((r) => r.key === key2 && inScope(r.doc, scope));
3489
3740
  return !!root && this.within(rows, [root.id]).some((id) => String(id) === String(folder));
3490
3741
  }
3491
3742
  /** The user's level in a folder (`null`: the top level). */
@@ -3610,6 +3861,11 @@ var MediaFolders = class {
3610
3861
  return this.rows();
3611
3862
  }
3612
3863
  };
3864
+ function inScope(row, scope) {
3865
+ return Object.entries(scope).every(
3866
+ ([name, value]) => value === null || value === void 0 ? row[name] === null || row[name] === void 0 : String(row[name]) === String(value)
3867
+ );
3868
+ }
3613
3869
 
3614
3870
  // src/populate.ts
3615
3871
  var DEFAULT_DEPTH = 1;
@@ -4099,6 +4355,8 @@ var Roles = class {
4099
4355
  async canView(user, id) {
4100
4356
  if (isSystemAdmin(user)) return true;
4101
4357
  if (user.apiKey) return false;
4358
+ if (id === "audit" && user.role === "admin" && user.scoped === true && this.config.audit !== false && typeof this.config.audit.scope === "function")
4359
+ return true;
4102
4360
  if (!this.enabled) return id.startsWith("page:") || id.startsWith("widget:");
4103
4361
  return (await this.permissionsOf(user.role)).admin?.includes(id) ?? false;
4104
4362
  }
@@ -4580,7 +4838,7 @@ function rank(role, config) {
4580
4838
  }
4581
4839
 
4582
4840
  // src/webhooks.ts
4583
- import { createHmac as createHmac4, randomUUID as randomUUID2 } from "crypto";
4841
+ import { createHmac as createHmac5, randomUUID as randomUUID2 } from "crypto";
4584
4842
  var WEBHOOK_EVENTS = [
4585
4843
  "create",
4586
4844
  "update",
@@ -4724,7 +4982,7 @@ var Webhooks = class {
4724
4982
  ...hook.headers
4725
4983
  };
4726
4984
  if (hook.secret) {
4727
- headers["x-easy-cms-signature"] = `sha256=${createHmac4("sha256", hook.secret).update(body).digest("hex")}`;
4985
+ headers["x-easy-cms-signature"] = `sha256=${createHmac5("sha256", hook.secret).update(body).digest("hex")}`;
4728
4986
  }
4729
4987
  try {
4730
4988
  const response = await this.fetcher(hook.url, {
@@ -4750,7 +5008,9 @@ var Webhooks = class {
4750
5008
  }
4751
5009
  };
4752
5010
  var later2 = (from, ms) => new Date(from.getTime() + ms).toISOString();
5011
+ var isContentEvent = (event) => WEBHOOK_EVENTS.includes(event);
4753
5012
  function matches(hook, payload) {
5013
+ if (!isContentEvent(payload.event)) return hook.events?.includes(payload.event) === true;
4754
5014
  if (hook.events && !hook.events.includes(payload.event)) return false;
4755
5015
  if (payload.collection !== void 0)
4756
5016
  return !hook.collections || hook.collections.includes(payload.collection);
@@ -4827,6 +5087,8 @@ function validateConfig(config) {
4827
5087
  });
4828
5088
  validateEndpoints(config, collectionSlugs, add);
4829
5089
  validateCommands(config, add);
5090
+ validateJobs(config, add);
5091
+ validateEvents(config, add);
4830
5092
  const apiKeys = config.apiKeys;
4831
5093
  if (apiKeys !== void 0 && typeof apiKeys !== "boolean") add("apiKeys", "must be true or false");
4832
5094
  collections.forEach((collection2, i) => {
@@ -5088,6 +5350,7 @@ function validateAuth(config, add) {
5088
5350
  add("auth.roles", 'must include "admin"', "e.g. roles: ['admin', 'editor']");
5089
5351
  }
5090
5352
  }
5353
+ validateMembers(auth.members, auth.roles ?? ["admin", "editor"], add);
5091
5354
  for (const key2 of [
5092
5355
  "tokenExpiration",
5093
5356
  "maxLoginAttempts",
@@ -5146,6 +5409,31 @@ var LOCALIZABLE = /* @__PURE__ */ new Set([
5146
5409
  "blocks",
5147
5410
  "array"
5148
5411
  ]);
5412
+ function validateMembers(members, roles, add) {
5413
+ if (members === void 0) return;
5414
+ const { roles: memberRoles, signup, pages: pages2 } = members ?? {};
5415
+ if (!Array.isArray(memberRoles) || memberRoles.some((r) => typeof r !== "string")) {
5416
+ add("auth.members.roles", "must be an array of roles", "e.g. roles: ['customer']");
5417
+ return;
5418
+ }
5419
+ for (const role of memberRoles) {
5420
+ if (role === "admin") add("auth.members.roles", `can't include "admin"`);
5421
+ else if (!roles.includes(role))
5422
+ add("auth.members.roles", `"${role}" must be one of auth.roles too`);
5423
+ }
5424
+ if (signup !== void 0) {
5425
+ const { role, verifyEmail: verifyEmail2, turnstile } = signup ?? {};
5426
+ if (typeof role !== "string" || !memberRoles.includes(role))
5427
+ add("auth.members.signup.role", "must be one of auth.members.roles");
5428
+ if (verifyEmail2 !== void 0 && typeof verifyEmail2 !== "boolean")
5429
+ add("auth.members.signup.verifyEmail", "must be true or false");
5430
+ if (turnstile !== void 0 && (typeof turnstile?.siteKey !== "string" || typeof turnstile?.secretKey !== "string"))
5431
+ add("auth.members.signup.turnstile", "needs siteKey and secretKey");
5432
+ }
5433
+ for (const [key2, value] of Object.entries(pages2 ?? {}))
5434
+ if (value !== void 0 && typeof value !== "string")
5435
+ add(`auth.members.pages.${key2}`, "must be a path or URL", "e.g. '/account/verify'");
5436
+ }
5149
5437
  function validateLocalization(config, add) {
5150
5438
  const localization = config.localization;
5151
5439
  if (localization !== void 0 && localization !== null) {
@@ -5227,14 +5515,19 @@ function validateWebhooks(config, add) {
5227
5515
  ...(config.collections ?? []).map((c) => c?.slug)
5228
5516
  ]);
5229
5517
  const globals = new Set((config.globals ?? []).map((g) => g?.slug));
5518
+ const custom = new Set(config.events ?? []);
5230
5519
  for (const [i, hook] of hooks.entries()) {
5231
5520
  const path = `webhooks[${i}]`;
5232
5521
  if (typeof hook?.url !== "string" || !/^https?:\/\/\S+$/.test(hook.url)) {
5233
5522
  add(`${path}.url`, "must be an http(s) URL");
5234
5523
  }
5235
5524
  for (const event of hook?.events ?? []) {
5236
- if (!WEBHOOK_EVENTS.includes(event))
5237
- add(`${path}.events`, `unknown event "${event}"`, `use: ${WEBHOOK_EVENTS.join(", ")}`);
5525
+ if (!WEBHOOK_EVENTS.includes(event) && !custom.has(event))
5526
+ add(
5527
+ `${path}.events`,
5528
+ `unknown event "${event}"`,
5529
+ `use: ${[...WEBHOOK_EVENTS, ...custom].join(", ")}`
5530
+ );
5238
5531
  }
5239
5532
  for (const slug2 of hook?.collections ?? [])
5240
5533
  if (!collections.has(slug2)) add(`${path}.collections`, `unknown collection "${slug2}"`);
@@ -5428,8 +5721,12 @@ function validateContainer(container, path, collectionSlugs, add, { collection:
5428
5721
  const sidebar = admin.sidebar;
5429
5722
  if (sidebar !== void 0) validateComponents(sidebar, `${path}.admin.sidebar`, add);
5430
5723
  const group = admin.group;
5431
- if (group !== void 0 && group !== "settings")
5432
- add(`${path}.admin.group`, `must be 'settings' (got ${JSON.stringify(group)})`);
5724
+ if (group !== void 0 && group !== "settings" && !(collection2 && typeof group === "string" && group !== "") && !(collection2 && typeof group === "object" && group !== null && Object.values(group).every((v) => typeof v === "string")))
5725
+ add(
5726
+ `${path}.admin.group`,
5727
+ `must be 'settings' or a label (got ${JSON.stringify(group)})`,
5728
+ "e.g. group: { en: 'Shop', th: '\u0E23\u0E49\u0E32\u0E19\u0E04\u0E49\u0E32' }"
5729
+ );
5433
5730
  const list = admin.list;
5434
5731
  if (list !== void 0) validateList(list, container, `${path}.admin.list`, add);
5435
5732
  const owner = admin.ownerField;
@@ -5528,17 +5825,20 @@ function validateField(field, path, siblings, collectionSlugs, add) {
5528
5825
  }
5529
5826
  }
5530
5827
  if (field.uniqueWithin !== void 0) {
5531
- const scope = siblings.get(field.uniqueWithin);
5532
- if (!field.unique && field.type !== "slug") {
5828
+ if (!field.unique && field.type !== "slug")
5533
5829
  add(`${path}.uniqueWithin`, "needs `unique: true` (or a slug field)");
5534
- } else if (!scope || scope === field) {
5535
- add(`${path}.uniqueWithin`, `no sibling field named "${field.uniqueWithin}"`);
5536
- } else if (!["relationship", "select", "text", "number"].includes(scope.type) || "hasMany" in scope && scope.hasMany || scope.localized) {
5537
- add(
5538
- `${path}.uniqueWithin`,
5539
- `"${field.uniqueWithin}" must be a single, unlocalized relationship, select, text or number field`
5540
- );
5541
- }
5830
+ else
5831
+ for (const name of [field.uniqueWithin].flat()) {
5832
+ const scope = siblings.get(name);
5833
+ if (!scope || scope === field) {
5834
+ add(`${path}.uniqueWithin`, `no sibling field named "${name}"`);
5835
+ } else if (!["relationship", "select", "text", "number"].includes(scope.type) || "hasMany" in scope && scope.hasMany || scope.localized) {
5836
+ add(
5837
+ `${path}.uniqueWithin`,
5838
+ `"${name}" must be a single, unlocalized relationship, select, text or number field`
5839
+ );
5840
+ }
5841
+ }
5542
5842
  }
5543
5843
  switch (field.type) {
5544
5844
  case "text":
@@ -5697,6 +5997,38 @@ function validateCommands(config, add) {
5697
5997
  if (typeof run !== "function") add(`${path}.run`, "must be a function");
5698
5998
  });
5699
5999
  }
6000
+ function validateJobs(config, add) {
6001
+ const jobs = config.jobs;
6002
+ if (jobs === void 0) return;
6003
+ if (!Array.isArray(jobs)) {
6004
+ add("jobs", "must be an array", "e.g. jobs: [{ name: 'shop:clean-up', every: 3600, run }]");
6005
+ return;
6006
+ }
6007
+ const seen = /* @__PURE__ */ new Set();
6008
+ jobs.forEach((job, i) => {
6009
+ const path = `jobs[${i}]`;
6010
+ const { name, every, run } = job ?? {};
6011
+ if (typeof name !== "string" || !/^[a-z][a-z0-9-]*(:[a-z0-9-]+)*$/.test(name))
6012
+ add(`${path}.name`, "must be lowercase letters, digits, - and :", "e.g. 'shop:clean-up'");
6013
+ else if (seen.has(name)) add(`${path}.name`, `"${name}" is already used by another job`);
6014
+ else seen.add(name);
6015
+ if (every !== void 0 && (typeof every !== "number" || !(every > 0)))
6016
+ add(`${path}.every`, "must be a number of seconds above 0");
6017
+ if (typeof run !== "function") add(`${path}.run`, "must be a function");
6018
+ });
6019
+ }
6020
+ function validateEvents(config, add) {
6021
+ const events = config.events;
6022
+ if (events === void 0) return;
6023
+ if (!Array.isArray(events)) {
6024
+ add("events", "must be an array", "e.g. events: ['order.paid']");
6025
+ return;
6026
+ }
6027
+ events.forEach((event, i) => {
6028
+ if (typeof event !== "string" || !/^[a-z][a-z0-9-]*(\.[a-z0-9-]+)+$/.test(event))
6029
+ add(`events[${i}]`, "must be named <area>.<what> in lowercase", "e.g. 'order.paid'");
6030
+ });
6031
+ }
5700
6032
  function validateEndpoints(config, collectionSlugs, add) {
5701
6033
  const endpoints = config.endpoints;
5702
6034
  if (endpoints === void 0) return;
@@ -5876,7 +6208,8 @@ async function resolveConfig(input) {
5876
6208
  trustedOrigins: config.auth?.trustedOrigins ?? [],
5877
6209
  resetPasswordExpiration: config.auth?.resetPasswordExpiration ?? 60 * 60,
5878
6210
  inviteExpiration: config.auth?.inviteExpiration ?? 7 * 24 * 60 * 60,
5879
- emails: config.auth?.emails ?? {}
6211
+ emails: config.auth?.emails ?? {},
6212
+ members: config.auth?.members ?? { roles: [] }
5880
6213
  },
5881
6214
  audit: config.audit ? resolveAudit(config.audit === true ? {} : config.audit) : false,
5882
6215
  collections: [
@@ -5896,6 +6229,8 @@ async function resolveConfig(input) {
5896
6229
  globals: config.globals ?? [],
5897
6230
  endpoints: config.endpoints ?? [],
5898
6231
  commands: config.commands ?? [],
6232
+ events: config.events ?? [],
6233
+ jobs: config.jobs ?? [],
5899
6234
  installedPlugins: (plugins ?? []).map(
5900
6235
  (plugin) => typeof plugin === "function" && plugin.info ? { ...plugin.info } : {}
5901
6236
  ),
@@ -5914,7 +6249,8 @@ function resolveAudit(audit) {
5914
6249
  return {
5915
6250
  keep: audit.keep ?? DEFAULT_AUDIT_KEEP,
5916
6251
  values: audit.values !== false,
5917
- failedLogins: audit.failedLogins ?? DEFAULT_FAILED_LOGINS
6252
+ failedLogins: audit.failedLogins ?? DEFAULT_FAILED_LOGINS,
6253
+ scope: audit.scope ?? null
5918
6254
  };
5919
6255
  }
5920
6256
 
@@ -5934,7 +6270,7 @@ async function createEasyCMS(config, options = {}) {
5934
6270
  interactive: options.interactive ?? false
5935
6271
  });
5936
6272
  const cms = new EasyCMS(resolved3, db, logger, storage, cwd);
5937
- const scheduling = [...resolved3.collections, ...resolved3.globals].some((c) => c.schedule) || (resolved3.webhooks?.length ?? 0) > 0 || resolved3.email !== void 0;
6273
+ const scheduling = [...resolved3.collections, ...resolved3.globals].some((c) => c.schedule) || (resolved3.webhooks?.length ?? 0) > 0 || resolved3.email !== void 0 || resolved3.jobs.length > 0;
5938
6274
  if (scheduling && options.scheduler !== false) cms.startScheduler();
5939
6275
  return cms;
5940
6276
  }
@@ -6016,8 +6352,10 @@ var EasyCMS = class {
6016
6352
  user,
6017
6353
  cms: this
6018
6354
  });
6355
+ const changed = result?.user !== void 0 ? result.user : user;
6019
6356
  return {
6020
- user: result?.user !== void 0 ? result.user : user,
6357
+ // `onRequest` may change the role (e.g. per tenant): membership follows it.
6358
+ user: changed && asMember(changed, this.config.auth.members.roles),
6021
6359
  context: result?.context ?? NO_CONTEXT
6022
6360
  };
6023
6361
  }
@@ -6283,7 +6621,7 @@ var EasyCMS = class {
6283
6621
  /** A value with its HMAC (`secret`), for `purpose` only: `<base64url JSON>.<signature>`. */
6284
6622
  sign(purpose, value) {
6285
6623
  const payload = Buffer.from(JSON.stringify(value)).toString("base64url");
6286
- const mac = createHmac5("sha256", this.config.secret).update(`${purpose}:${payload}`);
6624
+ const mac = createHmac6("sha256", this.config.secret).update(`${purpose}:${payload}`);
6287
6625
  return `${payload}.${mac.digest("base64url")}`;
6288
6626
  }
6289
6627
  /** The value of a token made by `sign`, or `undefined` when it isn't genuine. */
@@ -6291,10 +6629,10 @@ var EasyCMS = class {
6291
6629
  const [payload, signature] = String(token).split(".");
6292
6630
  if (!payload || !signature) return void 0;
6293
6631
  const expected = Buffer.from(
6294
- createHmac5("sha256", this.config.secret).update(`${purpose}:${payload}`).digest("base64url")
6632
+ createHmac6("sha256", this.config.secret).update(`${purpose}:${payload}`).digest("base64url")
6295
6633
  );
6296
6634
  const given = Buffer.from(signature);
6297
- if (expected.length !== given.length || !timingSafeEqual4(expected, given)) return void 0;
6635
+ if (expected.length !== given.length || !timingSafeEqual5(expected, given)) return void 0;
6298
6636
  try {
6299
6637
  return JSON.parse(Buffer.from(payload, "base64url").toString());
6300
6638
  } catch {
@@ -6413,10 +6751,10 @@ var EasyCMS = class {
6413
6751
  if (!signature || !Number.isInteger(at) || at < Date.now() / 1e3) return false;
6414
6752
  const expected = Buffer.from(this.mediaSignature(key2, at));
6415
6753
  const given = Buffer.from(signature);
6416
- return expected.length === given.length && timingSafeEqual4(expected, given);
6754
+ return expected.length === given.length && timingSafeEqual5(expected, given);
6417
6755
  }
6418
6756
  mediaSignature(key2, expires) {
6419
- return createHmac5("sha256", this.config.secret).update(`media:${key2}:${expires}`).digest("base64url");
6757
+ return createHmac6("sha256", this.config.secret).update(`media:${key2}:${expires}`).digest("base64url");
6420
6758
  }
6421
6759
  /**
6422
6760
  * How many documents use any of these files in a top-level upload field (rich text and fields
@@ -6540,6 +6878,17 @@ var EasyCMS = class {
6540
6878
  async update(collection2, id, data, options = {}) {
6541
6879
  return await this.updateDocument(collection2, id, asObject(data, collection2), options);
6542
6880
  }
6881
+ /**
6882
+ * Adds `by` (negative to take away) to a top-level number field in one database statement, so
6883
+ * concurrent calls never lose a change, e.g. stock or a counter. Skips hooks, validation, access
6884
+ * and versions. Returns the new value, or `null` when `min` / `max` would be passed.
6885
+ */
6886
+ async increment(collection2, id, field, by, options = {}) {
6887
+ this.collection(collection2);
6888
+ const parsed = parseId(id);
6889
+ if (parsed === void 0) throw new NotFoundError(collection2, id);
6890
+ return this.db.increment({ collection: collection2, id: parsed, field, by, ...options });
6891
+ }
6543
6892
  /**
6544
6893
  * Takes a document off the site: its status becomes `draft`. With versions and drafts, a plain
6545
6894
  * `update` with `status: 'draft'` only saves a draft and leaves the published document live;
@@ -6812,7 +7161,7 @@ var EasyCMS = class {
6812
7161
  previousDoc: current,
6813
7162
  operation: "update"
6814
7163
  });
6815
- this.emit(config, "draft", draft);
7164
+ this.emitChange(config, "draft", draft);
6816
7165
  await this.audited(config, "draft", draft, current, guard, mode);
6817
7166
  const [out2] = await this.output(config, [draft], guard, {
6818
7167
  ...options,
@@ -6840,11 +7189,9 @@ var EasyCMS = class {
6840
7189
  Object.assign(prepared, moved.patch);
6841
7190
  }
6842
7191
  }
6843
- const doc = await this.db.update({
6844
- collection: collection2,
6845
- id: parsed,
6846
- data: { ...prepared, createdAt: existing.createdAt, updatedAt: now }
6847
- });
7192
+ const data = { ...prepared, createdAt: existing.createdAt, updatedAt: now };
7193
+ const doc = options.where ? await this.db.update({ collection: collection2, id: parsed, data, where: options.where }) : await this.db.update({ collection: collection2, id: parsed, data });
7194
+ if (!doc) return null;
6848
7195
  if (moved) await moved.cleanUp();
6849
7196
  if (collection2 === MEDIA_FOLDERS) {
6850
7197
  this.folders.invalidate();
@@ -6859,7 +7206,7 @@ var EasyCMS = class {
6859
7206
  previousDoc: current,
6860
7207
  operation: "update"
6861
7208
  });
6862
- this.emit(config, "update", doc, existing.status);
7209
+ this.emitChange(config, "update", doc, existing.status);
6863
7210
  await this.audited(config, "update", doc, current, guard, mode);
6864
7211
  const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
6865
7212
  return out;
@@ -6914,7 +7261,7 @@ var EasyCMS = class {
6914
7261
  id: parsed,
6915
7262
  doc: existing
6916
7263
  });
6917
- this.emit(config, "delete", existing);
7264
+ this.emitChange(config, "delete", existing);
6918
7265
  await this.audited(config, "delete", existing, existing, guard);
6919
7266
  const [out] = await this.output(config, [existing], guard, { depth: 0, draft: true });
6920
7267
  return out;
@@ -7072,7 +7419,7 @@ var EasyCMS = class {
7072
7419
  previousDoc: current,
7073
7420
  operation: "update"
7074
7421
  });
7075
- this.emit(config, "draft", draft);
7422
+ this.emitChange(config, "draft", draft);
7076
7423
  await this.audited(config, "draft", draft, current, guard, mode);
7077
7424
  return this.readGlobal(config, key2, guard, { ...options, draft: true });
7078
7425
  }
@@ -7084,7 +7431,7 @@ var EasyCMS = class {
7084
7431
  previousDoc: current,
7085
7432
  operation: "update"
7086
7433
  });
7087
- this.emit(config, "update", doc, existing.status);
7434
+ this.emitChange(config, "update", doc, existing.status);
7088
7435
  await this.audited(config, "update", doc, current, guard, mode);
7089
7436
  return this.readGlobal(config, key2, guard, { ...options, draft: true });
7090
7437
  }
@@ -7197,10 +7544,45 @@ var EasyCMS = class {
7197
7544
  return {
7198
7545
  ...scheduled,
7199
7546
  webhooks: await this.retryWebhooks(now),
7200
- emails: await this.mailer.retry(now)
7547
+ emails: await this.mailer.retry(now),
7548
+ jobs: await this.runConfigJobs(now)
7201
7549
  };
7202
7550
  });
7203
7551
  }
7552
+ /** `jobs` of the config that are due; when each last ran is kept in the database. */
7553
+ async runConfigJobs(now) {
7554
+ const { jobs } = this.config;
7555
+ if (jobs.length === 0) return { ran: 0, failed: 0 };
7556
+ const last = await this.db.findGlobal({ slug: JOB_RUNS }) ?? {};
7557
+ const runs = {};
7558
+ for (const [name, at] of Object.entries(last)) if (typeof at === "string") runs[name] = at;
7559
+ let ran = 0;
7560
+ let failed = 0;
7561
+ for (const job of jobs) {
7562
+ const previous = runs[job.name];
7563
+ if (job.every !== void 0 && previous !== void 0 && now.getTime() - Date.parse(previous) < job.every * 1e3)
7564
+ continue;
7565
+ runs[job.name] = now.toISOString();
7566
+ try {
7567
+ await job.run({ cms: this, now });
7568
+ ran++;
7569
+ } catch (error) {
7570
+ failed++;
7571
+ this.logger.error(`Job ${job.name} failed: ${error.message}`);
7572
+ }
7573
+ }
7574
+ await this.db.updateGlobal({ slug: JOB_RUNS, data: { ...runs, updatedAt: now.toISOString() } });
7575
+ return { ran, failed };
7576
+ }
7577
+ /**
7578
+ * Sends an event of the app or a plugin (`events` in the config, e.g. `order.paid`) to the
7579
+ * webhooks that list it. Returns at once; deliveries are queued and retried like content changes.
7580
+ */
7581
+ emit(event, data, about) {
7582
+ if (!this.config.events.includes(event))
7583
+ throw new QueryError(`Unknown event "${event}": add it to \`events\` in the config`);
7584
+ this.webhooks.emit(event, about ?? {}, data);
7585
+ }
7204
7586
  /**
7205
7587
  * Tries webhook deliveries that are due for another attempt. Deliveries are kept in the
7206
7588
  * database until they succeed, so they survive restarts.
@@ -7710,7 +8092,7 @@ var EasyCMS = class {
7710
8092
  doc,
7711
8093
  operation: "create"
7712
8094
  });
7713
- this.emit(config, "create", doc);
8095
+ this.emitChange(config, "create", doc);
7714
8096
  await this.audited(config, "create", doc, void 0, hookGuard ?? guard);
7715
8097
  const [out] = await this.output(config, [doc], hookGuard ?? guard, { ...options, draft: true });
7716
8098
  return out;
@@ -7768,7 +8150,7 @@ var EasyCMS = class {
7768
8150
  });
7769
8151
  }
7770
8152
  /** Sends webhook events for a change; `before` is the stored status before it, if any. */
7771
- emit(config, event, doc, before) {
8153
+ emitChange(config, event, doc, before) {
7772
8154
  const isGlobal = !this.config.collections.includes(config);
7773
8155
  if (!isGlobal && INTERNAL_COLLECTIONS.has(config.slug)) return;
7774
8156
  const body = isGlobal ? { ...snapshotOf(config.fields, doc), updatedAt: doc.updatedAt } : {
@@ -7809,7 +8191,8 @@ var EasyCMS = class {
7809
8191
  action,
7810
8192
  event === "delete" ? void 0 : doc,
7811
8193
  event === "create" ? void 0 : previous,
7812
- guard.user
8194
+ guard.user,
8195
+ guard.context
7813
8196
  );
7814
8197
  }
7815
8198
  /** Records a version after a save, when the collection or global keeps versions. */
@@ -7919,7 +8302,7 @@ var EasyCMS = class {
7919
8302
  await this.makeSlugsUnique(config, clean2, selfId);
7920
8303
  errors.push(...await this.checkUnique(config, clean2, selfId));
7921
8304
  }
7922
- errors.push(...await this.checkReferences(result.references));
8305
+ errors.push(...await this.checkReferences(result.references, guard));
7923
8306
  if (errors.length === 0)
7924
8307
  errors.push(...await this.checkFilterOptions(result.references, selfId, guard));
7925
8308
  if (errors.length > 0) throw new ValidationError(config.slug, errors);
@@ -7949,7 +8332,11 @@ var EasyCMS = class {
7949
8332
  async makeSlugsUnique(config, data, selfId) {
7950
8333
  for (const field of config.fields) {
7951
8334
  if (field.type !== "slug") continue;
7952
- const scope = field.uniqueWithin === void 0 ? void 0 : { [field.uniqueWithin]: { equals: data[field.uniqueWithin] ?? null } };
8335
+ const scope = field.uniqueWithin === void 0 ? void 0 : {
8336
+ and: uniqueWithinOf(field).map((name) => ({
8337
+ [name]: { equals: data[name] ?? null }
8338
+ }))
8339
+ };
7953
8340
  const unique = async (path, base) => {
7954
8341
  let candidate = base;
7955
8342
  for (let n = 2; await this.isTaken(config.slug, path, candidate, selfId, scope); n++) {
@@ -7980,7 +8367,11 @@ var EasyCMS = class {
7980
8367
  locale === this.config.localization?.defaultLocale ? field.name : `${field.name}.${locale}`,
7981
8368
  v
7982
8369
  ]) : [[field.name, field.name, value]];
7983
- const scope = field.uniqueWithin === void 0 ? void 0 : { [field.uniqueWithin]: { equals: data[field.uniqueWithin] ?? null } };
8370
+ const scope = field.uniqueWithin === void 0 ? void 0 : {
8371
+ and: uniqueWithinOf(field).map((name) => ({
8372
+ [name]: { equals: data[name] ?? null }
8373
+ }))
8374
+ };
7984
8375
  for (const [path, errorField, v] of checks) {
7985
8376
  if (v === null || v === void 0) continue;
7986
8377
  if (await this.isTaken(config.slug, path, v, selfId, scope)) {
@@ -8000,6 +8391,46 @@ var EasyCMS = class {
8000
8391
  };
8001
8392
  return await this.db.count({ collection: collection2, where }) > 0;
8002
8393
  }
8394
+ /**
8395
+ * What folder keys are unique within for a call (`uniqueWithin` of the `key` field of media
8396
+ * folders, e.g. the tenant): the value a folder made by this call would get from its hooks.
8397
+ */
8398
+ async folderScope(guard) {
8399
+ return this.uniqueScope(MEDIA_FOLDERS, "key", guard);
8400
+ }
8401
+ /**
8402
+ * Where a unique field's values must differ for a call (`uniqueWithin`, e.g. the tenant): the
8403
+ * value a document created by this call would get there from its hooks, as `{ [field]: value }`;
8404
+ * `{}` when the field is unique everywhere. For finding documents by such a field, e.g. a page
8405
+ * by its path in the current tenant.
8406
+ */
8407
+ async uniqueScope(collection2, field, options = {}) {
8408
+ const config = this.config.collections.find((c) => c.slug === collection2);
8409
+ const target = config?.fields.find((f) => f.name === field);
8410
+ const within = target ? uniqueWithinOf(target) : [];
8411
+ if (!config || within.length === 0) return {};
8412
+ const guard = {
8413
+ enforce: false,
8414
+ user: options.user ?? null,
8415
+ context: options.context ?? NO_CONTEXT
8416
+ };
8417
+ const data = await this.transform(
8418
+ config.hooks?.beforeValidate,
8419
+ "data",
8420
+ { ...this.hookArgs(config, guard), operation: "create" },
8421
+ {}
8422
+ ).catch(() => ({}));
8423
+ return Object.fromEntries(within.map((name) => [name, data[name] ?? null]));
8424
+ }
8425
+ /**
8426
+ * @internal The id of the media folder with this key (`folder` of upload fields), made when
8427
+ * needed; `null` when the call has no scope to make it in (e.g. all tenants) and none exists.
8428
+ */
8429
+ async keyedFolder(key2, options = {}) {
8430
+ const scope = await this.folderScope(guardOf(options));
8431
+ const complete = Object.values(scope).every((v) => v !== null && v !== void 0);
8432
+ return this.folders.keyed(key2, scope, complete);
8433
+ }
8003
8434
  /** Each reference is one its relationship's `filterOptions` allow. */
8004
8435
  async checkFilterOptions(references2, selfId, guard) {
8005
8436
  const errors = [];
@@ -8025,7 +8456,7 @@ var EasyCMS = class {
8025
8456
  }
8026
8457
  return errors;
8027
8458
  }
8028
- async checkReferences(references2) {
8459
+ async checkReferences(references2, guard) {
8029
8460
  const byCollection = /* @__PURE__ */ new Map();
8030
8461
  for (const ref of references2) {
8031
8462
  if (!this.config.collections.some((c) => c.slug === ref.collection)) continue;
@@ -8054,7 +8485,8 @@ var EasyCMS = class {
8054
8485
  }
8055
8486
  if (ref.folderOnly && this.folders.enabled && !await this.folders.inKeyed(
8056
8487
  ref.folderOnly,
8057
- doc.folder ?? null
8488
+ doc.folder ?? null,
8489
+ await this.folderScope(guard)
8058
8490
  ))
8059
8491
  errors.push({
8060
8492
  field: ref.field,
@@ -8144,6 +8576,7 @@ var UPLOAD_URL_SECONDS = 15 * 60;
8144
8576
  var UPLOAD_TICKET_SECONDS = 24 * 60 * 60;
8145
8577
  var SNIFF_BYTES = 64 * 1024;
8146
8578
  var MAX_FILES_TO_MOVE = 200;
8579
+ var JOB_RUNS = "easy-cms:jobs";
8147
8580
  function durationSeconds(value) {
8148
8581
  const match = typeof value === "string" ? /^(\d+)\s*([smhd])$/.exec(value.trim()) : null;
8149
8582
  const seconds = typeof value === "number" ? value : match ? Number(match[1]) * { s: 1, m: 60, h: 3600, d: 86400 }[match[2]] : Number.NaN;
@@ -8400,7 +8833,7 @@ function componentRef(component) {
8400
8833
  async function allowed(access, user, context) {
8401
8834
  return await evaluateAccess(access, { user, context }) !== false;
8402
8835
  }
8403
- async function serializeFields(fields, checks, localized, folderId) {
8836
+ async function serializeFields(fields, checks, localized, folderId, who) {
8404
8837
  const { update: update2 } = checks;
8405
8838
  const out = [];
8406
8839
  for (const field of fields) {
@@ -8410,6 +8843,13 @@ async function serializeFields(fields, checks, localized, folderId) {
8410
8843
  if (field.required) f.required = true;
8411
8844
  if (field.unique) f.unique = true;
8412
8845
  if (field.defaultValue !== void 0) f.defaultValue = field.defaultValue;
8846
+ if (field.admin?.defaultValue) {
8847
+ const value = await field.admin.defaultValue(who);
8848
+ if (value !== void 0) f.defaultValue = value;
8849
+ }
8850
+ const column = field.admin?.column;
8851
+ if (column === true || typeof column === "function" && await column(who)) f.column = true;
8852
+ if (field.admin?.allowCreate === false) f.noCreate = true;
8413
8853
  if (!await update2.allows(field)) f.readOnly = true;
8414
8854
  if (field.localized && localized) f.localized = true;
8415
8855
  if (field.position === "sidebar") f.position = "sidebar";
@@ -8450,16 +8890,19 @@ async function serializeFields(fields, checks, localized, folderId) {
8450
8890
  case "upload":
8451
8891
  f.to = "media";
8452
8892
  if (field.hasMany) f.hasMany = true;
8893
+ if (field.filterOptions) f.filtered = true;
8453
8894
  if (field.minRows !== void 0) f.minRows = field.minRows;
8454
8895
  if (field.maxRows !== void 0) f.maxRows = field.maxRows;
8455
8896
  if (field.mimeTypes) f.mimeTypes = expandMimeTypes(field.mimeTypes);
8456
- if (field.folder && folderId)
8457
- f.folder = { id: await folderId(field.folder), only: field.folderOnly === true };
8897
+ if (field.folder && folderId) {
8898
+ const id = await folderId(field.folder);
8899
+ if (id !== null) f.folder = { id, only: field.folderOnly === true };
8900
+ }
8458
8901
  break;
8459
8902
  case "array":
8460
8903
  if (field.minRows !== void 0) f.minRows = field.minRows;
8461
8904
  if (field.maxRows !== void 0) f.maxRows = field.maxRows;
8462
- f.fields = await serializeFields(field.fields, checks, localized, folderId);
8905
+ f.fields = await serializeFields(field.fields, checks, localized, folderId, who);
8463
8906
  break;
8464
8907
  case "blocks":
8465
8908
  if (field.minRows !== void 0) f.minRows = field.minRows;
@@ -8468,12 +8911,12 @@ async function serializeFields(fields, checks, localized, folderId) {
8468
8911
  field.blocks.map(async (block) => ({
8469
8912
  slug: block.slug,
8470
8913
  ...block.labels ? { labels: block.labels } : {},
8471
- fields: await serializeFields(block.fields, checks, localized, folderId)
8914
+ fields: await serializeFields(block.fields, checks, localized, folderId, who)
8472
8915
  }))
8473
8916
  );
8474
8917
  break;
8475
8918
  case "group":
8476
- f.fields = await serializeFields(field.fields, checks, localized, folderId);
8919
+ f.fields = await serializeFields(field.fields, checks, localized, folderId, who);
8477
8920
  break;
8478
8921
  }
8479
8922
  out.push(f);
@@ -8496,7 +8939,8 @@ async function collection(cms, config, user, localized, roles, context) {
8496
8939
  config.fields,
8497
8940
  await checkers(cms, user, context),
8498
8941
  localized,
8499
- folderIds(cms)
8942
+ folderIds(cms, user, context),
8943
+ { user, context }
8500
8944
  );
8501
8945
  if (config.slug === USERS && cms.roles.enabled) {
8502
8946
  const role = fields.find((f) => f.name === "role");
@@ -8524,9 +8968,12 @@ async function collection(cms, config, user, localized, roles, context) {
8524
8968
  if (config.useAsTitle) result.useAsTitle = config.useAsTitle;
8525
8969
  if (config.icon) result.icon = config.icon;
8526
8970
  if (config.admin?.sidebar?.length) result.sidebar = config.admin.sidebar.map(componentRef);
8527
- if (config.admin?.group === "settings" || SETTINGS.has(config.slug)) result.group = "settings";
8971
+ const group = config.admin?.group;
8972
+ if (group === "settings" || SETTINGS.has(config.slug)) result.group = "settings";
8973
+ else if (group !== void 0) result.section = group;
8528
8974
  if (config.admin?.list?.tree) result.tree = config.admin.list.tree;
8529
8975
  if (config.admin?.list?.sort) result.defaultSort = config.admin.list.sort;
8976
+ if (config.admin?.confirmDelete) result.confirmDelete = { ...config.admin.confirmDelete };
8530
8977
  const owner = cms.roles.ownerOf(config.slug);
8531
8978
  if (owner && fields.some((f) => f.name === owner)) result.owner = owner;
8532
8979
  if (config.editIn === "drawer" && !result.drafts && !result.versions && !result.preview)
@@ -8542,7 +8989,10 @@ async function global(cms, config, user, localized, context) {
8542
8989
  versions: Boolean(config.versions),
8543
8990
  preview: typeof config.preview === "function",
8544
8991
  schedule: config.schedule === true,
8545
- fields: await serializeFields(config.fields, checks, localized, folderIds(cms)),
8992
+ fields: await serializeFields(config.fields, checks, localized, folderIds(cms, user, context), {
8993
+ user,
8994
+ context
8995
+ }),
8546
8996
  permissions: {
8547
8997
  read: await may(cms, config.access?.read, user, target, "read", context),
8548
8998
  update: await may(cms, config.access?.update, user, target, "update", context),
@@ -8643,12 +9093,12 @@ async function widgets(cms, user) {
8643
9093
  }
8644
9094
  return out;
8645
9095
  }
8646
- function folderIds(cms) {
8647
- return cms.folders.enabled ? (key2) => cms.folders.keyed(key2) : void 0;
9096
+ function folderIds(cms, user, context) {
9097
+ return cms.folders.enabled ? (key2) => cms.keyedFolder(key2, { user, context }) : void 0;
8648
9098
  }
8649
9099
 
8650
9100
  // src/version.ts
8651
- var VERSION = true ? "0.44.0" : "0.0.0-dev";
9101
+ var VERSION = true ? "0.46.0" : "0.0.0-dev";
8652
9102
 
8653
9103
  // src/rest/admin-status.ts
8654
9104
  var DAY2 = 864e5;
@@ -8745,7 +9195,8 @@ async function pickerFilter(cms, url, target, user, context = {}) {
8745
9195
  const slug2 = dot > 0 ? spec.slice(0, dot) : "";
8746
9196
  const owner = forCollection !== null ? cms.config.collections.find((c) => c.slug === slug2) : cms.config.globals.find((g) => g.slug === slug2);
8747
9197
  const field = owner ? fieldAt(owner.fields, spec.slice(dot + 1)) : void 0;
8748
- if (field?.type !== "relationship" || field.to !== target)
9198
+ const to = field?.type === "upload" ? "media" : field?.type === "relationship" ? field.to : null;
9199
+ if (!field || field.type !== "relationship" && field.type !== "upload" || to !== target)
8749
9200
  throw new QueryError(`filterFor: no relationship to "${target}" at "${spec}"`);
8750
9201
  if (!field.filterOptions) return void 0;
8751
9202
  const rawId = url.searchParams.get("filterId");
@@ -8927,6 +9378,7 @@ async function respond(cms, request, url, path, root, options, production) {
8927
9378
  if (method !== "GET" && method !== "HEAD") checkCsrf(cms, ctx);
8928
9379
  const audit = {
8929
9380
  user,
9381
+ context,
8930
9382
  ip: options.getClientIp?.(request),
8931
9383
  userAgent: request.headers.get("user-agent")
8932
9384
  };
@@ -9019,8 +9471,9 @@ async function route(cms, ctx, method, segments, options) {
9019
9471
  context: ctx.context,
9020
9472
  ...parseLocale(ctx.url)
9021
9473
  };
9022
- const draft = ctx.user !== null && ctx.url.searchParams.get("draft") === "true";
9474
+ const draft = ctx.user !== null && ctx.user.member !== true && ctx.url.searchParams.get("draft") === "true";
9023
9475
  if (segments.length === 0) throw new HttpError("Not found", 404);
9476
+ if (first === "admin" && ctx.user?.member === true) throw new ForbiddenError();
9024
9477
  if (first === "auth") return ssoRoute(cms, ctx, method, segments);
9025
9478
  if (first === USERS && second !== void 0 && third === void 0 && !/^\d+$/.test(second)) {
9026
9479
  switch (`${method} ${second}`) {
@@ -9093,6 +9546,45 @@ async function route(cms, ctx, method, segments, options) {
9093
9546
  body: { user: session.user, exp: session.expiresAt, csrfToken: session.csrfToken }
9094
9547
  };
9095
9548
  }
9549
+ // Members signing up (`auth.members.signup`): the form's token, then the account.
9550
+ case "GET signup":
9551
+ return { body: cms.auth.signupForm() };
9552
+ case "POST signup": {
9553
+ const body = await readJson(ctx.request);
9554
+ const result = await cms.auth.signup({
9555
+ email: String(body.email ?? ""),
9556
+ password: String(body.password ?? ""),
9557
+ name: typeof body.name === "string" ? body.name : void 0,
9558
+ token: body.token,
9559
+ honeypot: body[SIGNUP_HONEYPOT],
9560
+ turnstile: body.turnstile,
9561
+ ip: options.getClientIp?.(ctx.request),
9562
+ origin: ctx.url.origin,
9563
+ locale: typeof body.locale === "string" ? body.locale : void 0,
9564
+ context: ctx.context
9565
+ });
9566
+ if (result.verify)
9567
+ return {
9568
+ status: 202,
9569
+ body: { verify: true, message: "Check your email for a link to confirm it." }
9570
+ };
9571
+ setSessionCookies(cms, ctx, result);
9572
+ return {
9573
+ status: 201,
9574
+ body: { user: result.user, exp: result.expiresAt, csrfToken: result.csrfToken }
9575
+ };
9576
+ }
9577
+ case "POST verify-email": {
9578
+ const body = await readJson(ctx.request);
9579
+ const session = await cms.auth.verifyEmail({
9580
+ token: String(body.token ?? ""),
9581
+ ip: options.getClientIp?.(ctx.request)
9582
+ });
9583
+ setSessionCookies(cms, ctx, session);
9584
+ return {
9585
+ body: { user: session.user, exp: session.expiresAt, csrfToken: session.csrfToken }
9586
+ };
9587
+ }
9096
9588
  case "POST first-register": {
9097
9589
  const body = await readJson(ctx.request);
9098
9590
  const session = await cms.auth.registerFirstUser({
@@ -9199,8 +9691,11 @@ async function route(cms, ctx, method, segments, options) {
9199
9691
  doc: q.get("doc"),
9200
9692
  actor: q.get("actor"),
9201
9693
  from: q.get("from"),
9202
- to: q.get("to")
9694
+ to: q.get("to"),
9695
+ // Users of a part of the site (e.g. one tenant) see its entries; others the chosen part's.
9696
+ scope: cms.audit.scopeOf(ctx.context)
9203
9697
  };
9698
+ if (ctx.user.scoped && !filter.scope) throw new ForbiddenError();
9204
9699
  if (second === "audit.csv" && third === void 0) {
9205
9700
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
9206
9701
  return {
@@ -10214,6 +10709,7 @@ export {
10214
10709
  anyone,
10215
10710
  applyDefaults,
10216
10711
  applyPlugins,
10712
+ checkFormToken,
10217
10713
  configSignature,
10218
10714
  consoleEmail,
10219
10715
  consoleLogger,
@@ -10228,15 +10724,18 @@ export {
10228
10724
  expandMimeTypes,
10229
10725
  fillMissing,
10230
10726
  findConfigFile,
10727
+ formToken,
10231
10728
  generateSlugs,
10232
10729
  generateTypes,
10233
10730
  hasRows,
10234
10731
  hashPassword,
10732
+ honeypotName,
10235
10733
  imageDimensions,
10236
10734
  importConfig,
10237
10735
  isAdmin,
10238
10736
  isHasMany,
10239
10737
  isLoggedIn,
10738
+ isSignedIn,
10240
10739
  isSystemAdmin,
10241
10740
  keyAllows,
10242
10741
  loadConfig,
@@ -10246,6 +10745,7 @@ export {
10246
10745
  mimeAllowedBy,
10247
10746
  parseId,
10248
10747
  populate,
10748
+ rateKeys,
10249
10749
  readCookie,
10250
10750
  resolveAdminModule,
10251
10751
  resolveConfig,
@@ -10255,8 +10755,10 @@ export {
10255
10755
  singularize,
10256
10756
  slugify,
10257
10757
  sniffMimeType,
10758
+ uniqueWithinOf,
10258
10759
  validateConfig,
10259
10760
  validateFields,
10260
10761
  verifyPassword,
10762
+ verifyTurnstile,
10261
10763
  writeBackupFile
10262
10764
  };