@easy-cms/core 0.45.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 +244 -18
  2. package/dist/index.js +446 -49
  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),
@@ -1273,6 +1292,48 @@ async function filterInput(fields, data, update2) {
1273
1292
  return result;
1274
1293
  }
1275
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
+
1276
1337
  // src/auth/cookie.ts
1277
1338
  var SESSION_COOKIE = "ecms-session";
1278
1339
 
@@ -1289,6 +1350,21 @@ function email(subject, lines, button) {
1289
1350
  var hours = (expiresAt) => Math.max(1, Math.round(((expiresAt?.getTime() ?? Date.now()) - Date.now()) / 36e5));
1290
1351
  var days = (expiresAt) => Math.max(1, Math.round(hours(expiresAt) / 24));
1291
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
+ ),
1292
1368
  resetPassword: ({ url = "", locale, expiresAt }) => locale === "th" ? email(
1293
1369
  "\u0E15\u0E31\u0E49\u0E07\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E43\u0E2B\u0E21\u0E48",
1294
1370
  [
@@ -1339,7 +1415,7 @@ var DEFAULT_PASSWORD_EMAILS = {
1339
1415
  };
1340
1416
 
1341
1417
  // src/auth/password.ts
1342
- import { randomBytes as randomBytes2, scrypt, timingSafeEqual as timingSafeEqual2 } from "crypto";
1418
+ import { randomBytes as randomBytes2, scrypt, timingSafeEqual as timingSafeEqual3 } from "crypto";
1343
1419
  var MIN_PASSWORD_LENGTH = 8;
1344
1420
  var N = 2 ** 17;
1345
1421
  var R = 8;
@@ -1374,7 +1450,7 @@ async function verifyPassword(password, stored) {
1374
1450
  Number(p),
1375
1451
  expected.length
1376
1452
  );
1377
- return key2.length === expected.length && timingSafeEqual2(key2, expected);
1453
+ return key2.length === expected.length && timingSafeEqual3(key2, expected);
1378
1454
  }
1379
1455
  var dummyHash;
1380
1456
  async function fakeVerify(password) {
@@ -1384,10 +1460,10 @@ async function fakeVerify(password) {
1384
1460
  }
1385
1461
 
1386
1462
  // src/auth/sso.ts
1387
- import { createHmac as createHmac3, randomBytes as randomBytes4 } from "crypto";
1463
+ import { createHmac as createHmac4, randomBytes as randomBytes4 } from "crypto";
1388
1464
 
1389
1465
  // src/auth/tokens.ts
1390
- 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";
1391
1467
  function newToken() {
1392
1468
  return randomBytes3(32).toString("base64url");
1393
1469
  }
@@ -1395,7 +1471,7 @@ function hashToken(token) {
1395
1471
  return createHash2("sha256").update(token).digest("hex");
1396
1472
  }
1397
1473
  function hmac(secret, value) {
1398
- return createHmac2("sha256", secret).update(value).digest("base64url");
1474
+ return createHmac3("sha256", secret).update(value).digest("base64url");
1399
1475
  }
1400
1476
  function signToken(secret, token) {
1401
1477
  return `${token}.${hmac(secret, `session:${token}`)}`;
@@ -1412,7 +1488,7 @@ function csrfToken(secret, token) {
1412
1488
  function safeEqual(a, b) {
1413
1489
  const left = Buffer.from(a);
1414
1490
  const right = Buffer.from(b);
1415
- return left.length === right.length && timingSafeEqual3(left, right);
1491
+ return left.length === right.length && timingSafeEqual4(left, right);
1416
1492
  }
1417
1493
  function signPreviewToken(secret, target, expiresAt) {
1418
1494
  const payload = Buffer.from(
@@ -1454,7 +1530,7 @@ function readPasswordToken(token) {
1454
1530
  try {
1455
1531
  const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
1456
1532
  if (typeof data.u !== "string" || typeof data.e !== "number") return null;
1457
- if (data.p !== "reset" && data.p !== "invite") return null;
1533
+ if (data.p !== "reset" && data.p !== "invite" && data.p !== "verify") return null;
1458
1534
  return { userId: data.u, purpose: data.p, expiresAt: data.e, payload };
1459
1535
  } catch {
1460
1536
  return null;
@@ -1471,19 +1547,19 @@ function passwordTokenMatches(secret, token, fingerprint) {
1471
1547
  var SSO_COOKIE = "ecms-sso";
1472
1548
  var PENDING_TTL = 10 * 6e4;
1473
1549
  var random = () => randomBytes4(32).toString("base64url");
1474
- function sign(secret, payload) {
1475
- return createHmac3("sha256", secret).update(`sso:${payload}`).digest("base64url");
1550
+ function sign2(secret, payload) {
1551
+ return createHmac4("sha256", secret).update(`sso:${payload}`).digest("base64url");
1476
1552
  }
1477
1553
  function seal(secret, pending) {
1478
1554
  const payload = Buffer.from(JSON.stringify(pending)).toString("base64url");
1479
- return `${payload}.${sign(secret, payload)}`;
1555
+ return `${payload}.${sign2(secret, payload)}`;
1480
1556
  }
1481
1557
  function unseal(secret, value) {
1482
1558
  if (!value) return null;
1483
1559
  const dot = value.lastIndexOf(".");
1484
1560
  if (dot <= 0) return null;
1485
1561
  const payload = value.slice(0, dot);
1486
- if (!safeEqual(value.slice(dot + 1), sign(secret, payload))) return null;
1562
+ if (!safeEqual(value.slice(dot + 1), sign2(secret, payload))) return null;
1487
1563
  try {
1488
1564
  const pending = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
1489
1565
  return typeof pending.e === "number" && pending.e > Date.now() ? pending : null;
@@ -1761,6 +1837,16 @@ var SingleSignOn = class {
1761
1837
 
1762
1838
  // src/auth/auth.ts
1763
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
+ }
1764
1850
  var RESET_REQUESTS = 3;
1765
1851
  var Auth = class {
1766
1852
  constructor(cms) {
@@ -1810,6 +1896,16 @@ var Auth = class {
1810
1896
  throw new UnauthorizedError(INVALID);
1811
1897
  }
1812
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
+ }
1813
1909
  if (!this.sso.passwordAllowed(user)) {
1814
1910
  await this.cms.audit.record({
1815
1911
  action: "login.failed",
@@ -1852,6 +1948,136 @@ var Auth = class {
1852
1948
  const raw = await this.db.findById({ collection: USERS, id: user.id });
1853
1949
  return this.startSession(raw);
1854
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
+ }
1855
2081
  async hasUsers() {
1856
2082
  return await this.db.count({ collection: USERS }) > 0;
1857
2083
  }
@@ -2024,7 +2250,7 @@ var Auth = class {
2024
2250
  }
2025
2251
  async passwordTokenUser(token) {
2026
2252
  const claims = readPasswordToken(token);
2027
- if (!claims || claims.expiresAt <= Date.now()) return null;
2253
+ if (!claims || claims.purpose === "verify" || claims.expiresAt <= Date.now()) return null;
2028
2254
  const id = /^\d+$/.test(claims.userId) ? Number(claims.userId) : claims.userId;
2029
2255
  const user = await this.db.findById({ collection: USERS, id });
2030
2256
  if (!user || user.active === false || !this.sso.passwordAllowed(user)) return null;
@@ -2043,7 +2269,8 @@ var Auth = class {
2043
2269
  fingerprint: passwordFingerprint(user.passwordHash)
2044
2270
  });
2045
2271
  const sso = this.sso.passwordAllowed(user) ? void 0 : this.config.auth.providers.map((p) => p.name);
2046
- 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)}`;
2047
2274
  const write = purpose === "invite" ? this.config.auth.emails.invite ?? DEFAULT_PASSWORD_EMAILS.invite : this.config.auth.emails.resetPassword ?? DEFAULT_PASSWORD_EMAILS.resetPassword;
2048
2275
  const content = await write({
2049
2276
  user: await this.toAuthUser(user),
@@ -2136,7 +2363,8 @@ var Auth = class {
2136
2363
  }
2137
2364
  async toAuthUser(user) {
2138
2365
  const fields = this.cms.collection(USERS).fields;
2139
- return await stripFields(fields, user);
2366
+ const stripped = await stripFields(fields, user);
2367
+ return asMember(stripped, this.config.auth.members.roles);
2140
2368
  }
2141
2369
  async deleteExpiredSessions(userId) {
2142
2370
  const expired = await this.db.find({
@@ -3245,7 +3473,7 @@ function applyFieldTypes(config) {
3245
3473
  }
3246
3474
 
3247
3475
  // src/local-api.ts
3248
- 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";
3249
3477
 
3250
3478
  // src/localization.ts
3251
3479
  var ALL_LOCALES = "all";
@@ -4610,7 +4838,7 @@ function rank(role, config) {
4610
4838
  }
4611
4839
 
4612
4840
  // src/webhooks.ts
4613
- import { createHmac as createHmac4, randomUUID as randomUUID2 } from "crypto";
4841
+ import { createHmac as createHmac5, randomUUID as randomUUID2 } from "crypto";
4614
4842
  var WEBHOOK_EVENTS = [
4615
4843
  "create",
4616
4844
  "update",
@@ -4754,7 +4982,7 @@ var Webhooks = class {
4754
4982
  ...hook.headers
4755
4983
  };
4756
4984
  if (hook.secret) {
4757
- 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")}`;
4758
4986
  }
4759
4987
  try {
4760
4988
  const response = await this.fetcher(hook.url, {
@@ -4780,7 +5008,9 @@ var Webhooks = class {
4780
5008
  }
4781
5009
  };
4782
5010
  var later2 = (from, ms) => new Date(from.getTime() + ms).toISOString();
5011
+ var isContentEvent = (event) => WEBHOOK_EVENTS.includes(event);
4783
5012
  function matches(hook, payload) {
5013
+ if (!isContentEvent(payload.event)) return hook.events?.includes(payload.event) === true;
4784
5014
  if (hook.events && !hook.events.includes(payload.event)) return false;
4785
5015
  if (payload.collection !== void 0)
4786
5016
  return !hook.collections || hook.collections.includes(payload.collection);
@@ -4857,6 +5087,8 @@ function validateConfig(config) {
4857
5087
  });
4858
5088
  validateEndpoints(config, collectionSlugs, add);
4859
5089
  validateCommands(config, add);
5090
+ validateJobs(config, add);
5091
+ validateEvents(config, add);
4860
5092
  const apiKeys = config.apiKeys;
4861
5093
  if (apiKeys !== void 0 && typeof apiKeys !== "boolean") add("apiKeys", "must be true or false");
4862
5094
  collections.forEach((collection2, i) => {
@@ -5118,6 +5350,7 @@ function validateAuth(config, add) {
5118
5350
  add("auth.roles", 'must include "admin"', "e.g. roles: ['admin', 'editor']");
5119
5351
  }
5120
5352
  }
5353
+ validateMembers(auth.members, auth.roles ?? ["admin", "editor"], add);
5121
5354
  for (const key2 of [
5122
5355
  "tokenExpiration",
5123
5356
  "maxLoginAttempts",
@@ -5176,6 +5409,31 @@ var LOCALIZABLE = /* @__PURE__ */ new Set([
5176
5409
  "blocks",
5177
5410
  "array"
5178
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
+ }
5179
5437
  function validateLocalization(config, add) {
5180
5438
  const localization = config.localization;
5181
5439
  if (localization !== void 0 && localization !== null) {
@@ -5257,14 +5515,19 @@ function validateWebhooks(config, add) {
5257
5515
  ...(config.collections ?? []).map((c) => c?.slug)
5258
5516
  ]);
5259
5517
  const globals = new Set((config.globals ?? []).map((g) => g?.slug));
5518
+ const custom = new Set(config.events ?? []);
5260
5519
  for (const [i, hook] of hooks.entries()) {
5261
5520
  const path = `webhooks[${i}]`;
5262
5521
  if (typeof hook?.url !== "string" || !/^https?:\/\/\S+$/.test(hook.url)) {
5263
5522
  add(`${path}.url`, "must be an http(s) URL");
5264
5523
  }
5265
5524
  for (const event of hook?.events ?? []) {
5266
- if (!WEBHOOK_EVENTS.includes(event))
5267
- 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
+ );
5268
5531
  }
5269
5532
  for (const slug2 of hook?.collections ?? [])
5270
5533
  if (!collections.has(slug2)) add(`${path}.collections`, `unknown collection "${slug2}"`);
@@ -5458,8 +5721,12 @@ function validateContainer(container, path, collectionSlugs, add, { collection:
5458
5721
  const sidebar = admin.sidebar;
5459
5722
  if (sidebar !== void 0) validateComponents(sidebar, `${path}.admin.sidebar`, add);
5460
5723
  const group = admin.group;
5461
- if (group !== void 0 && group !== "settings")
5462
- 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
+ );
5463
5730
  const list = admin.list;
5464
5731
  if (list !== void 0) validateList(list, container, `${path}.admin.list`, add);
5465
5732
  const owner = admin.ownerField;
@@ -5730,6 +5997,38 @@ function validateCommands(config, add) {
5730
5997
  if (typeof run !== "function") add(`${path}.run`, "must be a function");
5731
5998
  });
5732
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
+ }
5733
6032
  function validateEndpoints(config, collectionSlugs, add) {
5734
6033
  const endpoints = config.endpoints;
5735
6034
  if (endpoints === void 0) return;
@@ -5909,7 +6208,8 @@ async function resolveConfig(input) {
5909
6208
  trustedOrigins: config.auth?.trustedOrigins ?? [],
5910
6209
  resetPasswordExpiration: config.auth?.resetPasswordExpiration ?? 60 * 60,
5911
6210
  inviteExpiration: config.auth?.inviteExpiration ?? 7 * 24 * 60 * 60,
5912
- emails: config.auth?.emails ?? {}
6211
+ emails: config.auth?.emails ?? {},
6212
+ members: config.auth?.members ?? { roles: [] }
5913
6213
  },
5914
6214
  audit: config.audit ? resolveAudit(config.audit === true ? {} : config.audit) : false,
5915
6215
  collections: [
@@ -5929,6 +6229,8 @@ async function resolveConfig(input) {
5929
6229
  globals: config.globals ?? [],
5930
6230
  endpoints: config.endpoints ?? [],
5931
6231
  commands: config.commands ?? [],
6232
+ events: config.events ?? [],
6233
+ jobs: config.jobs ?? [],
5932
6234
  installedPlugins: (plugins ?? []).map(
5933
6235
  (plugin) => typeof plugin === "function" && plugin.info ? { ...plugin.info } : {}
5934
6236
  ),
@@ -5968,7 +6270,7 @@ async function createEasyCMS(config, options = {}) {
5968
6270
  interactive: options.interactive ?? false
5969
6271
  });
5970
6272
  const cms = new EasyCMS(resolved3, db, logger, storage, cwd);
5971
- 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;
5972
6274
  if (scheduling && options.scheduler !== false) cms.startScheduler();
5973
6275
  return cms;
5974
6276
  }
@@ -6050,8 +6352,10 @@ var EasyCMS = class {
6050
6352
  user,
6051
6353
  cms: this
6052
6354
  });
6355
+ const changed = result?.user !== void 0 ? result.user : user;
6053
6356
  return {
6054
- 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),
6055
6359
  context: result?.context ?? NO_CONTEXT
6056
6360
  };
6057
6361
  }
@@ -6317,7 +6621,7 @@ var EasyCMS = class {
6317
6621
  /** A value with its HMAC (`secret`), for `purpose` only: `<base64url JSON>.<signature>`. */
6318
6622
  sign(purpose, value) {
6319
6623
  const payload = Buffer.from(JSON.stringify(value)).toString("base64url");
6320
- const mac = createHmac5("sha256", this.config.secret).update(`${purpose}:${payload}`);
6624
+ const mac = createHmac6("sha256", this.config.secret).update(`${purpose}:${payload}`);
6321
6625
  return `${payload}.${mac.digest("base64url")}`;
6322
6626
  }
6323
6627
  /** The value of a token made by `sign`, or `undefined` when it isn't genuine. */
@@ -6325,10 +6629,10 @@ var EasyCMS = class {
6325
6629
  const [payload, signature] = String(token).split(".");
6326
6630
  if (!payload || !signature) return void 0;
6327
6631
  const expected = Buffer.from(
6328
- createHmac5("sha256", this.config.secret).update(`${purpose}:${payload}`).digest("base64url")
6632
+ createHmac6("sha256", this.config.secret).update(`${purpose}:${payload}`).digest("base64url")
6329
6633
  );
6330
6634
  const given = Buffer.from(signature);
6331
- if (expected.length !== given.length || !timingSafeEqual4(expected, given)) return void 0;
6635
+ if (expected.length !== given.length || !timingSafeEqual5(expected, given)) return void 0;
6332
6636
  try {
6333
6637
  return JSON.parse(Buffer.from(payload, "base64url").toString());
6334
6638
  } catch {
@@ -6447,10 +6751,10 @@ var EasyCMS = class {
6447
6751
  if (!signature || !Number.isInteger(at) || at < Date.now() / 1e3) return false;
6448
6752
  const expected = Buffer.from(this.mediaSignature(key2, at));
6449
6753
  const given = Buffer.from(signature);
6450
- return expected.length === given.length && timingSafeEqual4(expected, given);
6754
+ return expected.length === given.length && timingSafeEqual5(expected, given);
6451
6755
  }
6452
6756
  mediaSignature(key2, expires) {
6453
- 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");
6454
6758
  }
6455
6759
  /**
6456
6760
  * How many documents use any of these files in a top-level upload field (rich text and fields
@@ -6574,6 +6878,17 @@ var EasyCMS = class {
6574
6878
  async update(collection2, id, data, options = {}) {
6575
6879
  return await this.updateDocument(collection2, id, asObject(data, collection2), options);
6576
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
+ }
6577
6892
  /**
6578
6893
  * Takes a document off the site: its status becomes `draft`. With versions and drafts, a plain
6579
6894
  * `update` with `status: 'draft'` only saves a draft and leaves the published document live;
@@ -6846,7 +7161,7 @@ var EasyCMS = class {
6846
7161
  previousDoc: current,
6847
7162
  operation: "update"
6848
7163
  });
6849
- this.emit(config, "draft", draft);
7164
+ this.emitChange(config, "draft", draft);
6850
7165
  await this.audited(config, "draft", draft, current, guard, mode);
6851
7166
  const [out2] = await this.output(config, [draft], guard, {
6852
7167
  ...options,
@@ -6874,11 +7189,9 @@ var EasyCMS = class {
6874
7189
  Object.assign(prepared, moved.patch);
6875
7190
  }
6876
7191
  }
6877
- const doc = await this.db.update({
6878
- collection: collection2,
6879
- id: parsed,
6880
- data: { ...prepared, createdAt: existing.createdAt, updatedAt: now }
6881
- });
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;
6882
7195
  if (moved) await moved.cleanUp();
6883
7196
  if (collection2 === MEDIA_FOLDERS) {
6884
7197
  this.folders.invalidate();
@@ -6893,7 +7206,7 @@ var EasyCMS = class {
6893
7206
  previousDoc: current,
6894
7207
  operation: "update"
6895
7208
  });
6896
- this.emit(config, "update", doc, existing.status);
7209
+ this.emitChange(config, "update", doc, existing.status);
6897
7210
  await this.audited(config, "update", doc, current, guard, mode);
6898
7211
  const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
6899
7212
  return out;
@@ -6948,7 +7261,7 @@ var EasyCMS = class {
6948
7261
  id: parsed,
6949
7262
  doc: existing
6950
7263
  });
6951
- this.emit(config, "delete", existing);
7264
+ this.emitChange(config, "delete", existing);
6952
7265
  await this.audited(config, "delete", existing, existing, guard);
6953
7266
  const [out] = await this.output(config, [existing], guard, { depth: 0, draft: true });
6954
7267
  return out;
@@ -7106,7 +7419,7 @@ var EasyCMS = class {
7106
7419
  previousDoc: current,
7107
7420
  operation: "update"
7108
7421
  });
7109
- this.emit(config, "draft", draft);
7422
+ this.emitChange(config, "draft", draft);
7110
7423
  await this.audited(config, "draft", draft, current, guard, mode);
7111
7424
  return this.readGlobal(config, key2, guard, { ...options, draft: true });
7112
7425
  }
@@ -7118,7 +7431,7 @@ var EasyCMS = class {
7118
7431
  previousDoc: current,
7119
7432
  operation: "update"
7120
7433
  });
7121
- this.emit(config, "update", doc, existing.status);
7434
+ this.emitChange(config, "update", doc, existing.status);
7122
7435
  await this.audited(config, "update", doc, current, guard, mode);
7123
7436
  return this.readGlobal(config, key2, guard, { ...options, draft: true });
7124
7437
  }
@@ -7231,10 +7544,45 @@ var EasyCMS = class {
7231
7544
  return {
7232
7545
  ...scheduled,
7233
7546
  webhooks: await this.retryWebhooks(now),
7234
- emails: await this.mailer.retry(now)
7547
+ emails: await this.mailer.retry(now),
7548
+ jobs: await this.runConfigJobs(now)
7235
7549
  };
7236
7550
  });
7237
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
+ }
7238
7586
  /**
7239
7587
  * Tries webhook deliveries that are due for another attempt. Deliveries are kept in the
7240
7588
  * database until they succeed, so they survive restarts.
@@ -7744,7 +8092,7 @@ var EasyCMS = class {
7744
8092
  doc,
7745
8093
  operation: "create"
7746
8094
  });
7747
- this.emit(config, "create", doc);
8095
+ this.emitChange(config, "create", doc);
7748
8096
  await this.audited(config, "create", doc, void 0, hookGuard ?? guard);
7749
8097
  const [out] = await this.output(config, [doc], hookGuard ?? guard, { ...options, draft: true });
7750
8098
  return out;
@@ -7802,7 +8150,7 @@ var EasyCMS = class {
7802
8150
  });
7803
8151
  }
7804
8152
  /** Sends webhook events for a change; `before` is the stored status before it, if any. */
7805
- emit(config, event, doc, before) {
8153
+ emitChange(config, event, doc, before) {
7806
8154
  const isGlobal = !this.config.collections.includes(config);
7807
8155
  if (!isGlobal && INTERNAL_COLLECTIONS.has(config.slug)) return;
7808
8156
  const body = isGlobal ? { ...snapshotOf(config.fields, doc), updatedAt: doc.updatedAt } : {
@@ -8228,6 +8576,7 @@ var UPLOAD_URL_SECONDS = 15 * 60;
8228
8576
  var UPLOAD_TICKET_SECONDS = 24 * 60 * 60;
8229
8577
  var SNIFF_BYTES = 64 * 1024;
8230
8578
  var MAX_FILES_TO_MOVE = 200;
8579
+ var JOB_RUNS = "easy-cms:jobs";
8231
8580
  function durationSeconds(value) {
8232
8581
  const match = typeof value === "string" ? /^(\d+)\s*([smhd])$/.exec(value.trim()) : null;
8233
8582
  const seconds = typeof value === "number" ? value : match ? Number(match[1]) * { s: 1, m: 60, h: 3600, d: 86400 }[match[2]] : Number.NaN;
@@ -8619,7 +8968,9 @@ async function collection(cms, config, user, localized, roles, context) {
8619
8968
  if (config.useAsTitle) result.useAsTitle = config.useAsTitle;
8620
8969
  if (config.icon) result.icon = config.icon;
8621
8970
  if (config.admin?.sidebar?.length) result.sidebar = config.admin.sidebar.map(componentRef);
8622
- 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;
8623
8974
  if (config.admin?.list?.tree) result.tree = config.admin.list.tree;
8624
8975
  if (config.admin?.list?.sort) result.defaultSort = config.admin.list.sort;
8625
8976
  if (config.admin?.confirmDelete) result.confirmDelete = { ...config.admin.confirmDelete };
@@ -8747,7 +9098,7 @@ function folderIds(cms, user, context) {
8747
9098
  }
8748
9099
 
8749
9100
  // src/version.ts
8750
- var VERSION = true ? "0.45.0" : "0.0.0-dev";
9101
+ var VERSION = true ? "0.46.0" : "0.0.0-dev";
8751
9102
 
8752
9103
  // src/rest/admin-status.ts
8753
9104
  var DAY2 = 864e5;
@@ -9120,8 +9471,9 @@ async function route(cms, ctx, method, segments, options) {
9120
9471
  context: ctx.context,
9121
9472
  ...parseLocale(ctx.url)
9122
9473
  };
9123
- 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";
9124
9475
  if (segments.length === 0) throw new HttpError("Not found", 404);
9476
+ if (first === "admin" && ctx.user?.member === true) throw new ForbiddenError();
9125
9477
  if (first === "auth") return ssoRoute(cms, ctx, method, segments);
9126
9478
  if (first === USERS && second !== void 0 && third === void 0 && !/^\d+$/.test(second)) {
9127
9479
  switch (`${method} ${second}`) {
@@ -9194,6 +9546,45 @@ async function route(cms, ctx, method, segments, options) {
9194
9546
  body: { user: session.user, exp: session.expiresAt, csrfToken: session.csrfToken }
9195
9547
  };
9196
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
+ }
9197
9588
  case "POST first-register": {
9198
9589
  const body = await readJson(ctx.request);
9199
9590
  const session = await cms.auth.registerFirstUser({
@@ -10318,6 +10709,7 @@ export {
10318
10709
  anyone,
10319
10710
  applyDefaults,
10320
10711
  applyPlugins,
10712
+ checkFormToken,
10321
10713
  configSignature,
10322
10714
  consoleEmail,
10323
10715
  consoleLogger,
@@ -10332,15 +10724,18 @@ export {
10332
10724
  expandMimeTypes,
10333
10725
  fillMissing,
10334
10726
  findConfigFile,
10727
+ formToken,
10335
10728
  generateSlugs,
10336
10729
  generateTypes,
10337
10730
  hasRows,
10338
10731
  hashPassword,
10732
+ honeypotName,
10339
10733
  imageDimensions,
10340
10734
  importConfig,
10341
10735
  isAdmin,
10342
10736
  isHasMany,
10343
10737
  isLoggedIn,
10738
+ isSignedIn,
10344
10739
  isSystemAdmin,
10345
10740
  keyAllows,
10346
10741
  loadConfig,
@@ -10350,6 +10745,7 @@ export {
10350
10745
  mimeAllowedBy,
10351
10746
  parseId,
10352
10747
  populate,
10748
+ rateKeys,
10353
10749
  readCookie,
10354
10750
  resolveAdminModule,
10355
10751
  resolveConfig,
@@ -10363,5 +10759,6 @@ export {
10363
10759
  validateConfig,
10364
10760
  validateFields,
10365
10761
  verifyPassword,
10762
+ verifyTurnstile,
10366
10763
  writeBackupFile
10367
10764
  };