@easy-cms/core 0.45.0 → 0.47.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.
package/dist/index.js CHANGED
@@ -1,7 +1,13 @@
1
+ import {
2
+ conditionIssues,
3
+ matchesCondition
4
+ } from "./chunk-C4KJ5TFK.js";
5
+
1
6
  // src/access.ts
2
7
  var isSystemAdmin = (user) => user?.role === "admin" && !user.apiKey && user.scoped !== true;
3
8
  var anyone = () => true;
4
- var isLoggedIn = ({ user }) => user !== null;
9
+ var isLoggedIn = ({ user }) => user !== null && user.member !== true;
10
+ var isSignedIn = ({ user }) => user !== null;
5
11
  var isAdmin = ({ user }) => user?.role === "admin";
6
12
 
7
13
  // src/api-keys.ts
@@ -192,7 +198,12 @@ var adminOrSelf = ({ user }) => {
192
198
  var adminOnly = {
193
199
  update: ({ user }) => isSystemAdmin(user)
194
200
  };
195
- function userFields(roles, rbac) {
201
+ var staffOrSelf = ({ user }) => {
202
+ if (!user) return false;
203
+ if (user.member !== true) return true;
204
+ return { id: { equals: user.id } };
205
+ };
206
+ function userFields(roles, rbac, members) {
196
207
  const defaultRole = roles.includes("editor") ? "editor" : roles[roles.length - 1];
197
208
  return [
198
209
  {
@@ -227,6 +238,16 @@ function userFields(roles, rbac) {
227
238
  access: adminOnly,
228
239
  label: { en: "Active", th: "\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19" }
229
240
  },
241
+ // Accounts made by signing up (`auth.members.signup`) are `false` until the email is confirmed.
242
+ ...members ? [
243
+ {
244
+ name: "emailVerified",
245
+ type: "boolean",
246
+ access: adminOnly,
247
+ position: "sidebar",
248
+ label: { en: "Email confirmed", th: "\u0E22\u0E37\u0E19\u0E22\u0E31\u0E19\u0E2D\u0E35\u0E40\u0E21\u0E25\u0E41\u0E25\u0E49\u0E27" }
249
+ }
250
+ ] : [],
230
251
  { name: "passwordHash", type: "text", hidden: true }
231
252
  ];
232
253
  }
@@ -239,9 +260,12 @@ function withUsers(config) {
239
260
  useAsTitle: "email",
240
261
  icon: "users",
241
262
  ...custom,
242
- fields: [...userFields(roles, config.auth?.rbac === true), ...custom?.fields ?? []],
263
+ fields: [
264
+ ...userFields(roles, config.auth?.rbac === true, config.auth?.members?.signup !== void 0),
265
+ ...custom?.fields ?? []
266
+ ],
243
267
  access: {
244
- read: isLoggedIn,
268
+ read: staffOrSelf,
245
269
  create: ({ user }) => isSystemAdmin(user),
246
270
  update: adminOrSelf,
247
271
  delete: ({ user }) => isSystemAdmin(user),
@@ -1273,6 +1297,48 @@ async function filterInput(fields, data, update2) {
1273
1297
  return result;
1274
1298
  }
1275
1299
 
1300
+ // src/spam.ts
1301
+ import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
1302
+ var MAX_AGE = 24 * 36e5;
1303
+ var sign = (secret, value) => createHmac2("sha256", secret).update(value).digest("base64url");
1304
+ function formToken(secret, purpose, now = Date.now()) {
1305
+ return `${now}.${sign(secret, `${purpose}:${now}`)}`;
1306
+ }
1307
+ function checkFormToken(secret, purpose, token, minTime, now = Date.now()) {
1308
+ if (typeof token !== "string") return "invalid";
1309
+ const [time, signature] = token.split(".");
1310
+ const issued = Number(time);
1311
+ if (!Number.isFinite(issued) || !signature) return "invalid";
1312
+ const expected = Buffer.from(sign(secret, `${purpose}:${issued}`));
1313
+ const given = Buffer.from(signature);
1314
+ if (expected.length !== given.length || !timingSafeEqual2(expected, given)) return "invalid";
1315
+ if (now - issued < minTime) return "too-fast";
1316
+ if (now - issued > MAX_AGE) return "expired";
1317
+ return "ok";
1318
+ }
1319
+ function rateKeys(secret, subject, window, now = Date.now()) {
1320
+ const bucket = Math.floor(now / (window * 1e3));
1321
+ const key2 = (b) => sign(secret, `${subject}:${b}`).slice(0, 32);
1322
+ return { current: key2(bucket), previous: key2(bucket - 1) };
1323
+ }
1324
+ function honeypotName(names2) {
1325
+ return ["website", "homepage", "url_address"].find((n) => !names2.includes(n)) ?? "hp_field";
1326
+ }
1327
+ async function verifyTurnstile(secretKey, response, ip, fetcher = fetch) {
1328
+ if (typeof response !== "string" || !response) return false;
1329
+ const body = new URLSearchParams({ secret: secretKey, response, ...ip ? { remoteip: ip } : {} });
1330
+ try {
1331
+ const result = await fetcher("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
1332
+ method: "POST",
1333
+ body,
1334
+ signal: AbortSignal.timeout(1e4)
1335
+ });
1336
+ return (await result.json()).success === true;
1337
+ } catch {
1338
+ return false;
1339
+ }
1340
+ }
1341
+
1276
1342
  // src/auth/cookie.ts
1277
1343
  var SESSION_COOKIE = "ecms-session";
1278
1344
 
@@ -1289,6 +1355,21 @@ function email(subject, lines, button) {
1289
1355
  var hours = (expiresAt) => Math.max(1, Math.round(((expiresAt?.getTime() ?? Date.now()) - Date.now()) / 36e5));
1290
1356
  var days = (expiresAt) => Math.max(1, Math.round(hours(expiresAt) / 24));
1291
1357
  var DEFAULT_PASSWORD_EMAILS = {
1358
+ verifyEmail: ({ url = "", locale, expiresAt }) => locale === "th" ? email(
1359
+ "\u0E22\u0E37\u0E19\u0E22\u0E31\u0E19\u0E2D\u0E35\u0E40\u0E21\u0E25\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13",
1360
+ [
1361
+ "\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",
1362
+ `\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`
1363
+ ],
1364
+ { label: "\u0E22\u0E37\u0E19\u0E22\u0E31\u0E19\u0E2D\u0E35\u0E40\u0E21\u0E25", url }
1365
+ ) : email(
1366
+ "Confirm your email",
1367
+ [
1368
+ "Thanks for signing up. Confirm your email to sign in.",
1369
+ `The link works within ${days(expiresAt)} day(s). If you didn't sign up, ignore this email.`
1370
+ ],
1371
+ { label: "Confirm email", url }
1372
+ ),
1292
1373
  resetPassword: ({ url = "", locale, expiresAt }) => locale === "th" ? email(
1293
1374
  "\u0E15\u0E31\u0E49\u0E07\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E43\u0E2B\u0E21\u0E48",
1294
1375
  [
@@ -1339,7 +1420,7 @@ var DEFAULT_PASSWORD_EMAILS = {
1339
1420
  };
1340
1421
 
1341
1422
  // src/auth/password.ts
1342
- import { randomBytes as randomBytes2, scrypt, timingSafeEqual as timingSafeEqual2 } from "crypto";
1423
+ import { randomBytes as randomBytes2, scrypt, timingSafeEqual as timingSafeEqual3 } from "crypto";
1343
1424
  var MIN_PASSWORD_LENGTH = 8;
1344
1425
  var N = 2 ** 17;
1345
1426
  var R = 8;
@@ -1374,7 +1455,7 @@ async function verifyPassword(password, stored) {
1374
1455
  Number(p),
1375
1456
  expected.length
1376
1457
  );
1377
- return key2.length === expected.length && timingSafeEqual2(key2, expected);
1458
+ return key2.length === expected.length && timingSafeEqual3(key2, expected);
1378
1459
  }
1379
1460
  var dummyHash;
1380
1461
  async function fakeVerify(password) {
@@ -1384,10 +1465,10 @@ async function fakeVerify(password) {
1384
1465
  }
1385
1466
 
1386
1467
  // src/auth/sso.ts
1387
- import { createHmac as createHmac3, randomBytes as randomBytes4 } from "crypto";
1468
+ import { createHmac as createHmac4, randomBytes as randomBytes4 } from "crypto";
1388
1469
 
1389
1470
  // src/auth/tokens.ts
1390
- import { createHash as createHash2, createHmac as createHmac2, randomBytes as randomBytes3, timingSafeEqual as timingSafeEqual3 } from "crypto";
1471
+ import { createHash as createHash2, createHmac as createHmac3, randomBytes as randomBytes3, timingSafeEqual as timingSafeEqual4 } from "crypto";
1391
1472
  function newToken() {
1392
1473
  return randomBytes3(32).toString("base64url");
1393
1474
  }
@@ -1395,7 +1476,7 @@ function hashToken(token) {
1395
1476
  return createHash2("sha256").update(token).digest("hex");
1396
1477
  }
1397
1478
  function hmac(secret, value) {
1398
- return createHmac2("sha256", secret).update(value).digest("base64url");
1479
+ return createHmac3("sha256", secret).update(value).digest("base64url");
1399
1480
  }
1400
1481
  function signToken(secret, token) {
1401
1482
  return `${token}.${hmac(secret, `session:${token}`)}`;
@@ -1412,7 +1493,7 @@ function csrfToken(secret, token) {
1412
1493
  function safeEqual(a, b) {
1413
1494
  const left = Buffer.from(a);
1414
1495
  const right = Buffer.from(b);
1415
- return left.length === right.length && timingSafeEqual3(left, right);
1496
+ return left.length === right.length && timingSafeEqual4(left, right);
1416
1497
  }
1417
1498
  function signPreviewToken(secret, target, expiresAt) {
1418
1499
  const payload = Buffer.from(
@@ -1454,7 +1535,7 @@ function readPasswordToken(token) {
1454
1535
  try {
1455
1536
  const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
1456
1537
  if (typeof data.u !== "string" || typeof data.e !== "number") return null;
1457
- if (data.p !== "reset" && data.p !== "invite") return null;
1538
+ if (data.p !== "reset" && data.p !== "invite" && data.p !== "verify") return null;
1458
1539
  return { userId: data.u, purpose: data.p, expiresAt: data.e, payload };
1459
1540
  } catch {
1460
1541
  return null;
@@ -1471,19 +1552,19 @@ function passwordTokenMatches(secret, token, fingerprint) {
1471
1552
  var SSO_COOKIE = "ecms-sso";
1472
1553
  var PENDING_TTL = 10 * 6e4;
1473
1554
  var random = () => randomBytes4(32).toString("base64url");
1474
- function sign(secret, payload) {
1475
- return createHmac3("sha256", secret).update(`sso:${payload}`).digest("base64url");
1555
+ function sign2(secret, payload) {
1556
+ return createHmac4("sha256", secret).update(`sso:${payload}`).digest("base64url");
1476
1557
  }
1477
1558
  function seal(secret, pending) {
1478
1559
  const payload = Buffer.from(JSON.stringify(pending)).toString("base64url");
1479
- return `${payload}.${sign(secret, payload)}`;
1560
+ return `${payload}.${sign2(secret, payload)}`;
1480
1561
  }
1481
1562
  function unseal(secret, value) {
1482
1563
  if (!value) return null;
1483
1564
  const dot = value.lastIndexOf(".");
1484
1565
  if (dot <= 0) return null;
1485
1566
  const payload = value.slice(0, dot);
1486
- if (!safeEqual(value.slice(dot + 1), sign(secret, payload))) return null;
1567
+ if (!safeEqual(value.slice(dot + 1), sign2(secret, payload))) return null;
1487
1568
  try {
1488
1569
  const pending = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
1489
1570
  return typeof pending.e === "number" && pending.e > Date.now() ? pending : null;
@@ -1761,6 +1842,16 @@ var SingleSignOn = class {
1761
1842
 
1762
1843
  // src/auth/auth.ts
1763
1844
  var INVALID = "Invalid email or password";
1845
+ var SIGNUP_FORM = "easy-cms-signup";
1846
+ var SIGNUP_MIN_TIME = 2e3;
1847
+ var SIGNUP_HONEYPOT = "website";
1848
+ function asMember(user, memberRoles) {
1849
+ const member = memberRoles.includes(String(user.role));
1850
+ if (member === (user.member === true)) return user;
1851
+ if (member) return { ...user, member: true };
1852
+ const { member: _member, ...rest } = user;
1853
+ return rest;
1854
+ }
1764
1855
  var RESET_REQUESTS = 3;
1765
1856
  var Auth = class {
1766
1857
  constructor(cms) {
@@ -1810,6 +1901,16 @@ var Auth = class {
1810
1901
  throw new UnauthorizedError(INVALID);
1811
1902
  }
1812
1903
  await this.clearFailures(key2);
1904
+ if (user.emailVerified === false) {
1905
+ await this.cms.audit.record({
1906
+ action: "login.failed",
1907
+ target: "auth",
1908
+ user: null,
1909
+ email: email2,
1910
+ detail: { reason: "email not confirmed" }
1911
+ });
1912
+ throw new UnauthorizedError("Confirm your email first: follow the link we sent you");
1913
+ }
1813
1914
  if (!this.sso.passwordAllowed(user)) {
1814
1915
  await this.cms.audit.record({
1815
1916
  action: "login.failed",
@@ -1852,6 +1953,136 @@ var Auth = class {
1852
1953
  const raw = await this.db.findById({ collection: USERS, id: user.id });
1853
1954
  return this.startSession(raw);
1854
1955
  }
1956
+ // --- Members signing up (`auth.members.signup`) ---------------------------------------------
1957
+ /** What a sign-up form needs: a token proving when it was loaded, and Turnstile's site key. */
1958
+ signupForm() {
1959
+ const signup = this.config.auth.members.signup;
1960
+ if (!signup) throw new NotFoundError(USERS, "signup");
1961
+ return {
1962
+ token: formToken(this.config.secret, SIGNUP_FORM),
1963
+ turnstile: signup.turnstile?.siteKey ?? null,
1964
+ verifyEmail: signup.verifyEmail !== false
1965
+ };
1966
+ }
1967
+ /**
1968
+ * Creates a member's account (`auth.members.signup`). With `verifyEmail` (the default), emails
1969
+ * a link to confirm the address and answers the same whether or not the email has an account;
1970
+ * otherwise starts a session. Throws `ValidationError` for spam checks and bad input.
1971
+ */
1972
+ async signup(args) {
1973
+ const signup = this.config.auth.members.signup;
1974
+ if (!signup) throw new NotFoundError(USERS, "signup");
1975
+ const key2 = `signup|${args.ip ?? ""}`;
1976
+ await this.checkRateLimit(key2);
1977
+ const refuse = (field, message2) => new ValidationError(USERS, [{ field, message: message2 }]);
1978
+ if (typeof args.honeypot === "string" && args.honeypot !== "") {
1979
+ await this.recordFailure(key2);
1980
+ throw refuse("form", "could not be sent");
1981
+ }
1982
+ const checked = checkFormToken(this.config.secret, SIGNUP_FORM, args.token, SIGNUP_MIN_TIME);
1983
+ if (checked !== "ok")
1984
+ throw refuse(
1985
+ "form",
1986
+ checked === "expired" ? "was open too long: reload the page" : "could not be sent"
1987
+ );
1988
+ if (signup.turnstile && !await verifyTurnstile(signup.turnstile.secretKey, args.turnstile, args.ip))
1989
+ throw refuse("turnstile", "is not complete");
1990
+ await this.recordFailure(key2);
1991
+ const email2 = typeof args.email === "string" ? args.email.trim().toLowerCase() : "";
1992
+ const verify = signup.verifyEmail !== false;
1993
+ if (verify && !this.canSendPasswordLinks(args.origin))
1994
+ throw new QueryError("Signing up needs `email` in the config, and a site URL in production");
1995
+ const existing = email2 ? await this.findByEmail(email2) : void 0;
1996
+ if (existing && verify) {
1997
+ if (existing.emailVerified === false && existing.active !== false)
1998
+ await this.mailVerifyLink(existing, args);
1999
+ return { verify: true };
2000
+ }
2001
+ const created = await this.cms.create(
2002
+ USERS,
2003
+ {
2004
+ email: email2,
2005
+ password: args.password,
2006
+ role: signup.role,
2007
+ active: true,
2008
+ emailVerified: !verify,
2009
+ ...typeof args.name === "string" && args.name.trim() ? { name: args.name.trim() } : {}
2010
+ },
2011
+ { context: args.context ?? {} }
2012
+ );
2013
+ const user = await this.db.findById({ collection: USERS, id: created.id });
2014
+ await this.cms.audit.record({
2015
+ action: "signup",
2016
+ target: "auth",
2017
+ user: await this.toAuthUser(user)
2018
+ });
2019
+ if (verify) {
2020
+ await this.mailVerifyLink(user, args);
2021
+ return { verify: true };
2022
+ }
2023
+ return { ...await this.startSession(user), verify: false };
2024
+ }
2025
+ /** Confirms a member's email from the link, and signs them in. */
2026
+ async verifyEmail(args) {
2027
+ const key2 = `verify-token|${args.ip ?? ""}`;
2028
+ await this.checkRateLimit(key2);
2029
+ const token = String(args.token ?? "");
2030
+ const claims = readPasswordToken(token);
2031
+ const id = claims && (/^\d+$/.test(claims.userId) ? Number(claims.userId) : claims.userId);
2032
+ const user = claims && claims.purpose === "verify" && claims.expiresAt > Date.now() && id !== null ? await this.db.findById({ collection: USERS, id }) : null;
2033
+ if (!user || user.active === false || !passwordTokenMatches(this.config.secret, token, passwordFingerprint(user.passwordHash))) {
2034
+ await this.recordFailure(key2);
2035
+ throw new ValidationError(USERS, [
2036
+ { field: "token", message: "This link has expired or is not valid" }
2037
+ ]);
2038
+ }
2039
+ await this.clearFailures(key2);
2040
+ if (user.emailVerified === false) {
2041
+ const { id: _id, ...rest } = user;
2042
+ await this.db.update({
2043
+ collection: USERS,
2044
+ id: user.id,
2045
+ data: { ...rest, emailVerified: true }
2046
+ });
2047
+ }
2048
+ const session = await this.startSession(
2049
+ await this.db.findById({ collection: USERS, id: user.id })
2050
+ );
2051
+ await this.cms.audit.record({ action: "email.verified", target: "auth", user: session.user });
2052
+ return session;
2053
+ }
2054
+ async mailVerifyLink(user, options) {
2055
+ const expiresAt = Date.now() + this.config.auth.inviteExpiration * 1e3;
2056
+ const token = signPasswordToken(this.config.secret, {
2057
+ userId: String(user.id),
2058
+ purpose: "verify",
2059
+ expiresAt,
2060
+ fingerprint: passwordFingerprint(user.passwordHash)
2061
+ });
2062
+ const url = this.memberLink("verifyEmail", token, options.origin);
2063
+ const write = this.config.auth.members.emails?.verifyEmail ?? DEFAULT_PASSWORD_EMAILS.verifyEmail;
2064
+ const content = await write({
2065
+ user: await this.toAuthUser(user),
2066
+ url,
2067
+ locale: this.localeOf(options.locale),
2068
+ expiresAt: new Date(expiresAt)
2069
+ });
2070
+ await this.cms.sendEmail({ to: String(user.email), ...content });
2071
+ }
2072
+ /**
2073
+ * A link to one of the site's pages for members (`auth.members.pages`), else to the admin's
2074
+ * page of the same purpose.
2075
+ */
2076
+ memberLink(page, token, origin) {
2077
+ const target = this.config.auth.members.pages?.[page];
2078
+ const query = `token=${encodeURIComponent(token)}`;
2079
+ if (target && /^https?:\/\//.test(target))
2080
+ return `${target}${target.includes("?") ? "&" : "?"}${query}`;
2081
+ const site = (this.config.admin.siteUrl || "").replace(/\/+$/, "");
2082
+ const base = site || this.passwordLinkBase(origin);
2083
+ const path = target ?? `${this.config.admin.path}/${page === "verifyEmail" ? "verify-email" : "reset-password"}`;
2084
+ return `${base}${path.startsWith("/") ? "" : "/"}${path}${path.includes("?") ? "&" : "?"}${query}`;
2085
+ }
1855
2086
  async hasUsers() {
1856
2087
  return await this.db.count({ collection: USERS }) > 0;
1857
2088
  }
@@ -2024,7 +2255,7 @@ var Auth = class {
2024
2255
  }
2025
2256
  async passwordTokenUser(token) {
2026
2257
  const claims = readPasswordToken(token);
2027
- if (!claims || claims.expiresAt <= Date.now()) return null;
2258
+ if (!claims || claims.purpose === "verify" || claims.expiresAt <= Date.now()) return null;
2028
2259
  const id = /^\d+$/.test(claims.userId) ? Number(claims.userId) : claims.userId;
2029
2260
  const user = await this.db.findById({ collection: USERS, id });
2030
2261
  if (!user || user.active === false || !this.sso.passwordAllowed(user)) return null;
@@ -2043,7 +2274,8 @@ var Auth = class {
2043
2274
  fingerprint: passwordFingerprint(user.passwordHash)
2044
2275
  });
2045
2276
  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)}`;
2277
+ const member = this.config.auth.members.roles.includes(String(user.role));
2278
+ 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
2279
  const write = purpose === "invite" ? this.config.auth.emails.invite ?? DEFAULT_PASSWORD_EMAILS.invite : this.config.auth.emails.resetPassword ?? DEFAULT_PASSWORD_EMAILS.resetPassword;
2048
2280
  const content = await write({
2049
2281
  user: await this.toAuthUser(user),
@@ -2136,7 +2368,8 @@ var Auth = class {
2136
2368
  }
2137
2369
  async toAuthUser(user) {
2138
2370
  const fields = this.cms.collection(USERS).fields;
2139
- return await stripFields(fields, user);
2371
+ const stripped = await stripFields(fields, user);
2372
+ return asMember(stripped, this.config.auth.members.roles);
2140
2373
  }
2141
2374
  async deleteExpiredSessions(userId) {
2142
2375
  const expired = await this.db.find({
@@ -2775,6 +3008,9 @@ async function validateFields(fields, input, options, prefix = "") {
2775
3008
  const path = prefix + field.name;
2776
3009
  const raw = input[field.name];
2777
3010
  const fail = (message2) => errors.push({ field: path, message: message2 });
3011
+ const condition = field.admin?.condition;
3012
+ const skipRequired = options.skipRequired === true || condition !== void 0 && !matchesCondition(condition, input);
3013
+ const fieldOptions = skipRequired === (options.skipRequired === true) ? options : { ...options, skipRequired };
2778
3014
  if (field.type === "group") {
2779
3015
  if (!isEmpty(raw) && !isPlainObject(raw)) {
2780
3016
  fail("must be an object");
@@ -2783,7 +3019,7 @@ async function validateFields(fields, input, options, prefix = "") {
2783
3019
  const sub = await validateFields(
2784
3020
  field.fields,
2785
3021
  isPlainObject(raw) ? raw : {},
2786
- options,
3022
+ fieldOptions,
2787
3023
  `${path}.`
2788
3024
  );
2789
3025
  data[field.name] = sub.data;
@@ -2806,7 +3042,7 @@ async function validateFields(fields, input, options, prefix = "") {
2806
3042
  const failLocale = (message2) => errors.push({ field: localePath, message: message2 });
2807
3043
  const value2 = map[locale];
2808
3044
  if (isEmpty(value2) || Array.isArray(value2) && value2.length === 0) {
2809
- if (own && field.required && !options.skipRequired) failLocale("is required");
3045
+ if (own && field.required && !skipRequired) failLocale("is required");
2810
3046
  out[locale] = hasRows(field) || isHasMany(field) ? [] : null;
2811
3047
  if (own && !field.required) await runCustom(field, null, options, failLocale);
2812
3048
  continue;
@@ -2815,7 +3051,7 @@ async function validateFields(fields, input, options, prefix = "") {
2815
3051
  field,
2816
3052
  value2,
2817
3053
  localePath,
2818
- options,
3054
+ fieldOptions,
2819
3055
  failLocale,
2820
3056
  errors,
2821
3057
  references2
@@ -2828,14 +3064,14 @@ async function validateFields(fields, input, options, prefix = "") {
2828
3064
  continue;
2829
3065
  }
2830
3066
  if (isEmpty(raw) || Array.isArray(raw) && raw.length === 0) {
2831
- if (field.required && !options.skipRequired) fail("is required");
2832
- else if (!options.skipRequired && "minRows" in field && field.minRows)
3067
+ if (field.required && !skipRequired) fail("is required");
3068
+ else if (!skipRequired && "minRows" in field && field.minRows)
2833
3069
  fail(`must have at least ${itemsOf(field, field.minRows)}`);
2834
3070
  data[field.name] = hasRows(field) || isHasMany(field) ? [] : null;
2835
3071
  if (!field.required) await runCustom(field, null, options, fail);
2836
3072
  continue;
2837
3073
  }
2838
- const value = await normalizeValue(field, raw, path, options, fail, errors, references2);
3074
+ const value = await normalizeValue(field, raw, path, fieldOptions, fail, errors, references2);
2839
3075
  if (value === void 0) continue;
2840
3076
  data[field.name] = value;
2841
3077
  await runCustom(field, value, options, fail);
@@ -3245,7 +3481,7 @@ function applyFieldTypes(config) {
3245
3481
  }
3246
3482
 
3247
3483
  // src/local-api.ts
3248
- import { createHmac as createHmac5, randomBytes as randomBytes5, timingSafeEqual as timingSafeEqual4 } from "crypto";
3484
+ import { createHmac as createHmac6, randomBytes as randomBytes5, timingSafeEqual as timingSafeEqual5 } from "crypto";
3249
3485
 
3250
3486
  // src/localization.ts
3251
3487
  var ALL_LOCALES = "all";
@@ -4609,8 +4845,72 @@ function rank(role, config) {
4609
4845
  return index === -1 ? config.auth.roles.length : index;
4610
4846
  }
4611
4847
 
4848
+ // src/nav.ts
4849
+ var BUILTIN_NAV = [
4850
+ { id: "content", label: { en: "Content", th: "\u0E40\u0E19\u0E37\u0E49\u0E2D\u0E2B\u0E32" }, icon: "file-text", order: 0 },
4851
+ {
4852
+ id: "settings",
4853
+ label: { en: "Settings", th: "\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32" },
4854
+ icon: "settings",
4855
+ order: 1e3,
4856
+ children: [
4857
+ { id: "site", label: { en: "Site", th: "\u0E40\u0E27\u0E47\u0E1A\u0E44\u0E0B\u0E15\u0E4C" }, icon: "globe" },
4858
+ { id: "people", label: { en: "Users & access", th: "\u0E1C\u0E39\u0E49\u0E43\u0E0A\u0E49\u0E41\u0E25\u0E30\u0E2A\u0E34\u0E17\u0E18\u0E34\u0E4C" }, icon: "users" },
4859
+ { id: "system", label: { en: "System", th: "\u0E23\u0E30\u0E1A\u0E1A" }, icon: "sliders-horizontal" }
4860
+ ]
4861
+ }
4862
+ ];
4863
+ var MEDIA_ORDER = 10;
4864
+ var LABEL_ORDER = 500;
4865
+ var NAV_ID = /^[a-z][a-z0-9-]*$/;
4866
+ function mergeGroups(lists, parent = "") {
4867
+ const byId = /* @__PURE__ */ new Map();
4868
+ let index = 0;
4869
+ for (const list of lists)
4870
+ for (const group of list) {
4871
+ const found = byId.get(group.id);
4872
+ if (found) {
4873
+ found.group = { ...group, ...stripUndefined(found.group) };
4874
+ if (group.children) found.children.push([...group.children]);
4875
+ } else {
4876
+ byId.set(group.id, {
4877
+ group,
4878
+ children: group.children ? [[...group.children]] : [],
4879
+ index: index++
4880
+ });
4881
+ }
4882
+ }
4883
+ return [...byId.values()].map(({ group, children, index: index2 }) => ({
4884
+ id: parent ? `${parent}.${group.id}` : group.id,
4885
+ label: group.label,
4886
+ ...group.icon ? { icon: group.icon } : {},
4887
+ order: group.order ?? 100 + index2,
4888
+ children: mergeGroups(children, parent ? `${parent}.${group.id}` : group.id)
4889
+ })).sort((a, b) => a.order - b.order);
4890
+ }
4891
+ function stripUndefined(value) {
4892
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== void 0));
4893
+ }
4894
+ function navGroups(declared) {
4895
+ return mergeGroups([declared, BUILTIN_NAV]);
4896
+ }
4897
+ function navIds(groups) {
4898
+ const ids = /* @__PURE__ */ new Set();
4899
+ for (const g of groups) {
4900
+ ids.add(g.id);
4901
+ for (const c of g.children) ids.add(c.id);
4902
+ }
4903
+ return ids;
4904
+ }
4905
+ function groupOf(group, ids, fallback) {
4906
+ if (group === void 0 || group === null) return { id: fallback };
4907
+ if (group === "settings") return { id: "settings.site" };
4908
+ if (typeof group === "string" && ids.has(group)) return { id: group };
4909
+ return { id: `label:${JSON.stringify(group)}`, label: group };
4910
+ }
4911
+
4612
4912
  // src/webhooks.ts
4613
- import { createHmac as createHmac4, randomUUID as randomUUID2 } from "crypto";
4913
+ import { createHmac as createHmac5, randomUUID as randomUUID2 } from "crypto";
4614
4914
  var WEBHOOK_EVENTS = [
4615
4915
  "create",
4616
4916
  "update",
@@ -4754,7 +5054,7 @@ var Webhooks = class {
4754
5054
  ...hook.headers
4755
5055
  };
4756
5056
  if (hook.secret) {
4757
- headers["x-easy-cms-signature"] = `sha256=${createHmac4("sha256", hook.secret).update(body).digest("hex")}`;
5057
+ headers["x-easy-cms-signature"] = `sha256=${createHmac5("sha256", hook.secret).update(body).digest("hex")}`;
4758
5058
  }
4759
5059
  try {
4760
5060
  const response = await this.fetcher(hook.url, {
@@ -4780,7 +5080,9 @@ var Webhooks = class {
4780
5080
  }
4781
5081
  };
4782
5082
  var later2 = (from, ms) => new Date(from.getTime() + ms).toISOString();
5083
+ var isContentEvent = (event) => WEBHOOK_EVENTS.includes(event);
4783
5084
  function matches(hook, payload) {
5085
+ if (!isContentEvent(payload.event)) return hook.events?.includes(payload.event) === true;
4784
5086
  if (hook.events && !hook.events.includes(payload.event)) return false;
4785
5087
  if (payload.collection !== void 0)
4786
5088
  return !hook.collections || hook.collections.includes(payload.collection);
@@ -4857,13 +5159,17 @@ function validateConfig(config) {
4857
5159
  });
4858
5160
  validateEndpoints(config, collectionSlugs, add);
4859
5161
  validateCommands(config, add);
5162
+ validateJobs(config, add);
5163
+ validateEvents(config, add);
4860
5164
  const apiKeys = config.apiKeys;
4861
5165
  if (apiKeys !== void 0 && typeof apiKeys !== "boolean") add("apiKeys", "must be true or false");
5166
+ const nav = validateNav(config, add);
4862
5167
  collections.forEach((collection2, i) => {
4863
5168
  const path = `collections.${collection2.slug ?? `[${i}]`}`;
4864
5169
  validateContainer(collection2, path, collectionSlugs, add, {
4865
5170
  collection: true,
4866
- rbac: config.auth?.rbac === true
5171
+ rbac: config.auth?.rbac === true,
5172
+ nav
4867
5173
  });
4868
5174
  validateUseAsTitle(collection2, path, add);
4869
5175
  validateIcon(collection2, path, add);
@@ -4875,7 +5181,8 @@ function validateConfig(config) {
4875
5181
  const path = `globals.${global2.slug ?? `[${i}]`}`;
4876
5182
  validateContainer(global2, path, collectionSlugs, add, {
4877
5183
  collection: false,
4878
- rbac: config.auth?.rbac === true
5184
+ rbac: config.auth?.rbac === true,
5185
+ nav
4879
5186
  });
4880
5187
  validateIcon(global2, path, add);
4881
5188
  });
@@ -5008,8 +5315,8 @@ function validateAdminViews(config, add) {
5008
5315
  "e.g. label: { en: 'Stats', th: '\u0E2A\u0E16\u0E34\u0E15\u0E34' }"
5009
5316
  );
5010
5317
  validateIcon(p, path, add);
5011
- if (p.group !== void 0 && p.group !== "content" && p.group !== "settings" && p.group !== false)
5012
- add(`${path}.group`, 'must be "content", "settings" or false');
5318
+ if (p.group !== "content" && p.group !== false)
5319
+ validateGroup(p.group, navIds(navGroups(config.admin?.nav ?? [])), `${path}.group`, add);
5013
5320
  access(p.access, path);
5014
5321
  });
5015
5322
  }
@@ -5118,6 +5425,7 @@ function validateAuth(config, add) {
5118
5425
  add("auth.roles", 'must include "admin"', "e.g. roles: ['admin', 'editor']");
5119
5426
  }
5120
5427
  }
5428
+ validateMembers(auth.members, auth.roles ?? ["admin", "editor"], add);
5121
5429
  for (const key2 of [
5122
5430
  "tokenExpiration",
5123
5431
  "maxLoginAttempts",
@@ -5176,6 +5484,31 @@ var LOCALIZABLE = /* @__PURE__ */ new Set([
5176
5484
  "blocks",
5177
5485
  "array"
5178
5486
  ]);
5487
+ function validateMembers(members, roles, add) {
5488
+ if (members === void 0) return;
5489
+ const { roles: memberRoles, signup, pages: pages2 } = members ?? {};
5490
+ if (!Array.isArray(memberRoles) || memberRoles.some((r) => typeof r !== "string")) {
5491
+ add("auth.members.roles", "must be an array of roles", "e.g. roles: ['customer']");
5492
+ return;
5493
+ }
5494
+ for (const role of memberRoles) {
5495
+ if (role === "admin") add("auth.members.roles", `can't include "admin"`);
5496
+ else if (!roles.includes(role))
5497
+ add("auth.members.roles", `"${role}" must be one of auth.roles too`);
5498
+ }
5499
+ if (signup !== void 0) {
5500
+ const { role, verifyEmail: verifyEmail2, turnstile } = signup ?? {};
5501
+ if (typeof role !== "string" || !memberRoles.includes(role))
5502
+ add("auth.members.signup.role", "must be one of auth.members.roles");
5503
+ if (verifyEmail2 !== void 0 && typeof verifyEmail2 !== "boolean")
5504
+ add("auth.members.signup.verifyEmail", "must be true or false");
5505
+ if (turnstile !== void 0 && (typeof turnstile?.siteKey !== "string" || typeof turnstile?.secretKey !== "string"))
5506
+ add("auth.members.signup.turnstile", "needs siteKey and secretKey");
5507
+ }
5508
+ for (const [key2, value] of Object.entries(pages2 ?? {}))
5509
+ if (value !== void 0 && typeof value !== "string")
5510
+ add(`auth.members.pages.${key2}`, "must be a path or URL", "e.g. '/account/verify'");
5511
+ }
5179
5512
  function validateLocalization(config, add) {
5180
5513
  const localization = config.localization;
5181
5514
  if (localization !== void 0 && localization !== null) {
@@ -5257,14 +5590,19 @@ function validateWebhooks(config, add) {
5257
5590
  ...(config.collections ?? []).map((c) => c?.slug)
5258
5591
  ]);
5259
5592
  const globals = new Set((config.globals ?? []).map((g) => g?.slug));
5593
+ const custom = new Set(config.events ?? []);
5260
5594
  for (const [i, hook] of hooks.entries()) {
5261
5595
  const path = `webhooks[${i}]`;
5262
5596
  if (typeof hook?.url !== "string" || !/^https?:\/\/\S+$/.test(hook.url)) {
5263
5597
  add(`${path}.url`, "must be an http(s) URL");
5264
5598
  }
5265
5599
  for (const event of hook?.events ?? []) {
5266
- if (!WEBHOOK_EVENTS.includes(event))
5267
- add(`${path}.events`, `unknown event "${event}"`, `use: ${WEBHOOK_EVENTS.join(", ")}`);
5600
+ if (!WEBHOOK_EVENTS.includes(event) && !custom.has(event))
5601
+ add(
5602
+ `${path}.events`,
5603
+ `unknown event "${event}"`,
5604
+ `use: ${[...WEBHOOK_EVENTS, ...custom].join(", ")}`
5605
+ );
5268
5606
  }
5269
5607
  for (const slug2 of hook?.collections ?? [])
5270
5608
  if (!collections.has(slug2)) add(`${path}.collections`, `unknown collection "${slug2}"`);
@@ -5425,7 +5763,142 @@ function checkSlug(container, path, seen, add) {
5425
5763
  seen.add(slug2);
5426
5764
  return slug2;
5427
5765
  }
5428
- function validateContainer(container, path, collectionSlugs, add, { collection: collection2, rbac }) {
5766
+ var isLabel = (value) => typeof value === "string" && value.trim() !== "" || typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).length > 0 && Object.values(value).every((v) => typeof v === "string");
5767
+ function validateNav(config, add) {
5768
+ const nav = config.admin?.nav;
5769
+ if (nav !== void 0) {
5770
+ if (!Array.isArray(nav))
5771
+ add("admin.nav", "must be a list of groups", "e.g. nav: [{ id: 'shop', label: 'Shop' }]");
5772
+ else
5773
+ nav.forEach((group, i) => {
5774
+ const check2 = (g, path, child) => {
5775
+ const { id, label, children, order } = g ?? {};
5776
+ if (typeof id !== "string" || !NAV_ID.test(id))
5777
+ add(`${path}.id`, "must be lowercase letters, digits and -", "e.g. 'shop'");
5778
+ if (!isLabel(label)) add(`${path}.label`, "must be a string or { en, th }");
5779
+ if (order !== void 0 && typeof order !== "number")
5780
+ add(`${path}.order`, "must be a number");
5781
+ validateIcon(g, path, add);
5782
+ if (children !== void 0) {
5783
+ if (child) add(`${path}.children`, "groups go one level deep");
5784
+ else if (!Array.isArray(children)) add(`${path}.children`, "must be a list of groups");
5785
+ else
5786
+ children.forEach((c, j) => {
5787
+ check2(c, `${path}.children[${j}]`, true);
5788
+ });
5789
+ }
5790
+ };
5791
+ check2(group, `admin.nav[${i}]`, false);
5792
+ });
5793
+ }
5794
+ const commands = config.admin?.commands;
5795
+ if (commands !== void 0) {
5796
+ if (!Array.isArray(commands)) add("admin.commands", "must be a list");
5797
+ else
5798
+ commands.forEach((c, i) => {
5799
+ const { label, to } = c ?? {};
5800
+ if (!isLabel(label)) add(`admin.commands[${i}].label`, "must be a string or { en, th }");
5801
+ if (typeof to !== "string" || !to.startsWith("/") || to.startsWith("//"))
5802
+ add(
5803
+ `admin.commands[${i}].to`,
5804
+ "must be a path in the admin",
5805
+ "e.g. '/collections/orders'"
5806
+ );
5807
+ });
5808
+ }
5809
+ return navIds(navGroups(Array.isArray(nav) ? nav : []));
5810
+ }
5811
+ function validateGroup(group, ids, path, add) {
5812
+ if (group === void 0 || group === "settings") return;
5813
+ if (typeof group === "string" && group.includes(".") && !ids.has(group)) {
5814
+ add(
5815
+ path,
5816
+ `no menu group "${group}" in admin.nav`,
5817
+ "declare it: nav: [{ id: 'shop', label: 'Shop', children: [{ id: 'catalog', label: 'Catalog' }] }]"
5818
+ );
5819
+ return;
5820
+ }
5821
+ if (!isLabel(group))
5822
+ add(
5823
+ path,
5824
+ `must be a group's id, 'settings' or a label (got ${JSON.stringify(group)})`,
5825
+ "e.g. group: 'shop' or { en: 'Shop', th: '\u0E23\u0E49\u0E32\u0E19\u0E04\u0E49\u0E32' }"
5826
+ );
5827
+ }
5828
+ function validateLayout(layout, container, path, add) {
5829
+ if (!Array.isArray(layout)) {
5830
+ add(path, "must be a list", "e.g. layout: [{ tab: 'Content', fields: ['title'] }]");
5831
+ return;
5832
+ }
5833
+ const fields = new Map((container.fields ?? []).map((f) => [f.name, f]));
5834
+ const seen = /* @__PURE__ */ new Set();
5835
+ const name = (value, at) => {
5836
+ if (typeof value !== "string" || !fields.has(value)) {
5837
+ add(at, `no top-level field ${JSON.stringify(value)}`);
5838
+ return;
5839
+ }
5840
+ if (fields.get(value)?.position === "sidebar")
5841
+ add(at, `"${value}" is in the sidebar (position: 'sidebar')`);
5842
+ if (seen.has(value)) add(at, `"${value}" is placed twice`);
5843
+ seen.add(value);
5844
+ };
5845
+ const nodes = (list, at, top) => {
5846
+ if (!Array.isArray(list)) {
5847
+ add(at, "must be a list");
5848
+ return;
5849
+ }
5850
+ const tabs = list.filter((n) => typeof n === "object" && n !== null && "tab" in n).length;
5851
+ if (top && tabs > 0 && tabs !== list.length)
5852
+ add(at, "with tabs, every entry at the top is a tab");
5853
+ list.forEach((node, i) => {
5854
+ const p = `${at}[${i}]`;
5855
+ if (typeof node === "string") {
5856
+ name(node, p);
5857
+ return;
5858
+ }
5859
+ const n = node ?? {};
5860
+ if ("tab" in n) {
5861
+ if (!top) add(p, "tabs go at the top of the layout only");
5862
+ if (!isLabel(n.tab)) add(`${p}.tab`, "must be a string or { en, th }");
5863
+ nodes(n.fields, `${p}.fields`, false);
5864
+ } else if ("collapsible" in n) {
5865
+ if (!isLabel(n.collapsible)) add(`${p}.collapsible`, "must be a string or { en, th }");
5866
+ nodes(n.fields, `${p}.fields`, false);
5867
+ } else if ("row" in n) {
5868
+ if (!Array.isArray(n.row) || n.row.length === 0)
5869
+ add(`${p}.row`, "must be a list of field names");
5870
+ else
5871
+ n.row.forEach((f, j) => {
5872
+ name(f, `${p}.row[${j}]`);
5873
+ });
5874
+ } else add(p, "must be a field name, { tab }, { collapsible } or { row }");
5875
+ });
5876
+ };
5877
+ nodes(layout, path, true);
5878
+ }
5879
+ function validateCollectionAdmin(admin, path, add) {
5880
+ const { badge, count, empty } = admin;
5881
+ if (badge !== void 0) {
5882
+ const b = badge ?? {};
5883
+ if (typeof b.where !== "object" || b.where === null || Array.isArray(b.where))
5884
+ add(`${path}.badge.where`, "must be a where query", "e.g. { status: { equals: 'paid' } }");
5885
+ if (b.tone !== void 0 && !["accent", "warning", "danger"].includes(b.tone))
5886
+ add(`${path}.badge.tone`, "must be 'accent', 'warning' or 'danger'");
5887
+ }
5888
+ if (count !== void 0 && typeof count !== "boolean")
5889
+ add(`${path}.count`, "must be true or false");
5890
+ if (empty !== void 0) {
5891
+ const e = empty ?? {};
5892
+ if (e.description !== void 0 && !isLabel(e.description))
5893
+ add(`${path}.empty.description`, "must be a string or { en, th }");
5894
+ if (e.link !== void 0) {
5895
+ const l = e.link ?? {};
5896
+ if (!isLabel(l.label) || typeof l.href !== "string")
5897
+ add(`${path}.empty.link`, "needs label and href");
5898
+ }
5899
+ }
5900
+ }
5901
+ function validateContainer(container, path, collectionSlugs, add, { collection: collection2, rbac, nav }) {
5429
5902
  if (!Array.isArray(container.fields)) {
5430
5903
  add(`${path}.fields`, "must be an array");
5431
5904
  return;
@@ -5457,9 +5930,11 @@ function validateContainer(container, path, collectionSlugs, add, { collection:
5457
5930
  } else {
5458
5931
  const sidebar = admin.sidebar;
5459
5932
  if (sidebar !== void 0) validateComponents(sidebar, `${path}.admin.sidebar`, add);
5460
- const group = admin.group;
5461
- if (group !== void 0 && group !== "settings")
5462
- add(`${path}.admin.group`, `must be 'settings' (got ${JSON.stringify(group)})`);
5933
+ validateGroup(admin.group, nav, `${path}.admin.group`, add);
5934
+ const layout = admin.layout;
5935
+ if (layout !== void 0) validateLayout(layout, container, `${path}.admin.layout`, add);
5936
+ if (collection2)
5937
+ validateCollectionAdmin(admin, `${path}.admin`, add);
5463
5938
  const list = admin.list;
5464
5939
  if (list !== void 0) validateList(list, container, `${path}.admin.list`, add);
5465
5940
  const owner = admin.ownerField;
@@ -5551,7 +6026,14 @@ function validateField(field, path, siblings, collectionSlugs, add) {
5551
6026
  if (typeof admin !== "object" || admin === null) {
5552
6027
  add(`${path}.admin`, "must be an object", "e.g. admin: { component: 'ecms-color-picker' }");
5553
6028
  } else {
5554
- const { component, after, cell } = admin;
6029
+ const { component, after, cell, width, condition, description } = admin;
6030
+ if (width !== void 0 && !["1/4", "1/3", "1/2", "2/3", "3/4", "full"].includes(width))
6031
+ add(`${path}.admin.width`, "must be '1/4', '1/3', '1/2', '2/3', '3/4' or 'full'");
6032
+ if (description !== void 0 && !isLabel(description))
6033
+ add(`${path}.admin.description`, "must be a string or { en, th }");
6034
+ if (condition !== void 0)
6035
+ for (const issue of conditionIssues(condition, [...siblings.keys()]))
6036
+ add(`${path}.admin.condition`, issue);
5555
6037
  if (component !== void 0) validateComponent(component, `${path}.admin.component`, add);
5556
6038
  if (after !== void 0) validateComponents(after, `${path}.admin.after`, add);
5557
6039
  if (cell !== void 0) validateComponent(cell, `${path}.admin.cell`, add);
@@ -5730,6 +6212,38 @@ function validateCommands(config, add) {
5730
6212
  if (typeof run !== "function") add(`${path}.run`, "must be a function");
5731
6213
  });
5732
6214
  }
6215
+ function validateJobs(config, add) {
6216
+ const jobs = config.jobs;
6217
+ if (jobs === void 0) return;
6218
+ if (!Array.isArray(jobs)) {
6219
+ add("jobs", "must be an array", "e.g. jobs: [{ name: 'shop:clean-up', every: 3600, run }]");
6220
+ return;
6221
+ }
6222
+ const seen = /* @__PURE__ */ new Set();
6223
+ jobs.forEach((job, i) => {
6224
+ const path = `jobs[${i}]`;
6225
+ const { name, every, run } = job ?? {};
6226
+ if (typeof name !== "string" || !/^[a-z][a-z0-9-]*(:[a-z0-9-]+)*$/.test(name))
6227
+ add(`${path}.name`, "must be lowercase letters, digits, - and :", "e.g. 'shop:clean-up'");
6228
+ else if (seen.has(name)) add(`${path}.name`, `"${name}" is already used by another job`);
6229
+ else seen.add(name);
6230
+ if (every !== void 0 && (typeof every !== "number" || !(every > 0)))
6231
+ add(`${path}.every`, "must be a number of seconds above 0");
6232
+ if (typeof run !== "function") add(`${path}.run`, "must be a function");
6233
+ });
6234
+ }
6235
+ function validateEvents(config, add) {
6236
+ const events = config.events;
6237
+ if (events === void 0) return;
6238
+ if (!Array.isArray(events)) {
6239
+ add("events", "must be an array", "e.g. events: ['order.paid']");
6240
+ return;
6241
+ }
6242
+ events.forEach((event, i) => {
6243
+ if (typeof event !== "string" || !/^[a-z][a-z0-9-]*(\.[a-z0-9-]+)+$/.test(event))
6244
+ add(`events[${i}]`, "must be named <area>.<what> in lowercase", "e.g. 'order.paid'");
6245
+ });
6246
+ }
5733
6247
  function validateEndpoints(config, collectionSlugs, add) {
5734
6248
  const endpoints = config.endpoints;
5735
6249
  if (endpoints === void 0) return;
@@ -5884,7 +6398,9 @@ async function resolveConfig(input) {
5884
6398
  modules: config.admin?.modules ?? [],
5885
6399
  pages: config.admin?.pages ?? [],
5886
6400
  dashboard: config.admin?.dashboard ?? [],
5887
- switcher: config.admin?.switcher ?? null
6401
+ switcher: config.admin?.switcher ?? null,
6402
+ nav: config.admin?.nav ?? [],
6403
+ commands: config.admin?.commands ?? []
5888
6404
  },
5889
6405
  upload: {
5890
6406
  dir: config.upload?.dir ?? "uploads",
@@ -5909,7 +6425,8 @@ async function resolveConfig(input) {
5909
6425
  trustedOrigins: config.auth?.trustedOrigins ?? [],
5910
6426
  resetPasswordExpiration: config.auth?.resetPasswordExpiration ?? 60 * 60,
5911
6427
  inviteExpiration: config.auth?.inviteExpiration ?? 7 * 24 * 60 * 60,
5912
- emails: config.auth?.emails ?? {}
6428
+ emails: config.auth?.emails ?? {},
6429
+ members: config.auth?.members ?? { roles: [] }
5913
6430
  },
5914
6431
  audit: config.audit ? resolveAudit(config.audit === true ? {} : config.audit) : false,
5915
6432
  collections: [
@@ -5929,6 +6446,8 @@ async function resolveConfig(input) {
5929
6446
  globals: config.globals ?? [],
5930
6447
  endpoints: config.endpoints ?? [],
5931
6448
  commands: config.commands ?? [],
6449
+ events: config.events ?? [],
6450
+ jobs: config.jobs ?? [],
5932
6451
  installedPlugins: (plugins ?? []).map(
5933
6452
  (plugin) => typeof plugin === "function" && plugin.info ? { ...plugin.info } : {}
5934
6453
  ),
@@ -5968,7 +6487,7 @@ async function createEasyCMS(config, options = {}) {
5968
6487
  interactive: options.interactive ?? false
5969
6488
  });
5970
6489
  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;
6490
+ const scheduling = [...resolved3.collections, ...resolved3.globals].some((c) => c.schedule) || (resolved3.webhooks?.length ?? 0) > 0 || resolved3.email !== void 0 || resolved3.jobs.length > 0;
5972
6491
  if (scheduling && options.scheduler !== false) cms.startScheduler();
5973
6492
  return cms;
5974
6493
  }
@@ -6050,8 +6569,10 @@ var EasyCMS = class {
6050
6569
  user,
6051
6570
  cms: this
6052
6571
  });
6572
+ const changed = result?.user !== void 0 ? result.user : user;
6053
6573
  return {
6054
- user: result?.user !== void 0 ? result.user : user,
6574
+ // `onRequest` may change the role (e.g. per tenant): membership follows it.
6575
+ user: changed && asMember(changed, this.config.auth.members.roles),
6055
6576
  context: result?.context ?? NO_CONTEXT
6056
6577
  };
6057
6578
  }
@@ -6317,7 +6838,7 @@ var EasyCMS = class {
6317
6838
  /** A value with its HMAC (`secret`), for `purpose` only: `<base64url JSON>.<signature>`. */
6318
6839
  sign(purpose, value) {
6319
6840
  const payload = Buffer.from(JSON.stringify(value)).toString("base64url");
6320
- const mac = createHmac5("sha256", this.config.secret).update(`${purpose}:${payload}`);
6841
+ const mac = createHmac6("sha256", this.config.secret).update(`${purpose}:${payload}`);
6321
6842
  return `${payload}.${mac.digest("base64url")}`;
6322
6843
  }
6323
6844
  /** The value of a token made by `sign`, or `undefined` when it isn't genuine. */
@@ -6325,10 +6846,10 @@ var EasyCMS = class {
6325
6846
  const [payload, signature] = String(token).split(".");
6326
6847
  if (!payload || !signature) return void 0;
6327
6848
  const expected = Buffer.from(
6328
- createHmac5("sha256", this.config.secret).update(`${purpose}:${payload}`).digest("base64url")
6849
+ createHmac6("sha256", this.config.secret).update(`${purpose}:${payload}`).digest("base64url")
6329
6850
  );
6330
6851
  const given = Buffer.from(signature);
6331
- if (expected.length !== given.length || !timingSafeEqual4(expected, given)) return void 0;
6852
+ if (expected.length !== given.length || !timingSafeEqual5(expected, given)) return void 0;
6332
6853
  try {
6333
6854
  return JSON.parse(Buffer.from(payload, "base64url").toString());
6334
6855
  } catch {
@@ -6447,10 +6968,10 @@ var EasyCMS = class {
6447
6968
  if (!signature || !Number.isInteger(at) || at < Date.now() / 1e3) return false;
6448
6969
  const expected = Buffer.from(this.mediaSignature(key2, at));
6449
6970
  const given = Buffer.from(signature);
6450
- return expected.length === given.length && timingSafeEqual4(expected, given);
6971
+ return expected.length === given.length && timingSafeEqual5(expected, given);
6451
6972
  }
6452
6973
  mediaSignature(key2, expires) {
6453
- return createHmac5("sha256", this.config.secret).update(`media:${key2}:${expires}`).digest("base64url");
6974
+ return createHmac6("sha256", this.config.secret).update(`media:${key2}:${expires}`).digest("base64url");
6454
6975
  }
6455
6976
  /**
6456
6977
  * How many documents use any of these files in a top-level upload field (rich text and fields
@@ -6574,6 +7095,17 @@ var EasyCMS = class {
6574
7095
  async update(collection2, id, data, options = {}) {
6575
7096
  return await this.updateDocument(collection2, id, asObject(data, collection2), options);
6576
7097
  }
7098
+ /**
7099
+ * Adds `by` (negative to take away) to a top-level number field in one database statement, so
7100
+ * concurrent calls never lose a change, e.g. stock or a counter. Skips hooks, validation, access
7101
+ * and versions. Returns the new value, or `null` when `min` / `max` would be passed.
7102
+ */
7103
+ async increment(collection2, id, field, by, options = {}) {
7104
+ this.collection(collection2);
7105
+ const parsed = parseId(id);
7106
+ if (parsed === void 0) throw new NotFoundError(collection2, id);
7107
+ return this.db.increment({ collection: collection2, id: parsed, field, by, ...options });
7108
+ }
6577
7109
  /**
6578
7110
  * Takes a document off the site: its status becomes `draft`. With versions and drafts, a plain
6579
7111
  * `update` with `status: 'draft'` only saves a draft and leaves the published document live;
@@ -6846,7 +7378,7 @@ var EasyCMS = class {
6846
7378
  previousDoc: current,
6847
7379
  operation: "update"
6848
7380
  });
6849
- this.emit(config, "draft", draft);
7381
+ this.emitChange(config, "draft", draft);
6850
7382
  await this.audited(config, "draft", draft, current, guard, mode);
6851
7383
  const [out2] = await this.output(config, [draft], guard, {
6852
7384
  ...options,
@@ -6874,11 +7406,9 @@ var EasyCMS = class {
6874
7406
  Object.assign(prepared, moved.patch);
6875
7407
  }
6876
7408
  }
6877
- const doc = await this.db.update({
6878
- collection: collection2,
6879
- id: parsed,
6880
- data: { ...prepared, createdAt: existing.createdAt, updatedAt: now }
6881
- });
7409
+ const data = { ...prepared, createdAt: existing.createdAt, updatedAt: now };
7410
+ 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 });
7411
+ if (!doc) return null;
6882
7412
  if (moved) await moved.cleanUp();
6883
7413
  if (collection2 === MEDIA_FOLDERS) {
6884
7414
  this.folders.invalidate();
@@ -6893,7 +7423,7 @@ var EasyCMS = class {
6893
7423
  previousDoc: current,
6894
7424
  operation: "update"
6895
7425
  });
6896
- this.emit(config, "update", doc, existing.status);
7426
+ this.emitChange(config, "update", doc, existing.status);
6897
7427
  await this.audited(config, "update", doc, current, guard, mode);
6898
7428
  const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
6899
7429
  return out;
@@ -6948,7 +7478,7 @@ var EasyCMS = class {
6948
7478
  id: parsed,
6949
7479
  doc: existing
6950
7480
  });
6951
- this.emit(config, "delete", existing);
7481
+ this.emitChange(config, "delete", existing);
6952
7482
  await this.audited(config, "delete", existing, existing, guard);
6953
7483
  const [out] = await this.output(config, [existing], guard, { depth: 0, draft: true });
6954
7484
  return out;
@@ -7106,7 +7636,7 @@ var EasyCMS = class {
7106
7636
  previousDoc: current,
7107
7637
  operation: "update"
7108
7638
  });
7109
- this.emit(config, "draft", draft);
7639
+ this.emitChange(config, "draft", draft);
7110
7640
  await this.audited(config, "draft", draft, current, guard, mode);
7111
7641
  return this.readGlobal(config, key2, guard, { ...options, draft: true });
7112
7642
  }
@@ -7118,7 +7648,7 @@ var EasyCMS = class {
7118
7648
  previousDoc: current,
7119
7649
  operation: "update"
7120
7650
  });
7121
- this.emit(config, "update", doc, existing.status);
7651
+ this.emitChange(config, "update", doc, existing.status);
7122
7652
  await this.audited(config, "update", doc, current, guard, mode);
7123
7653
  return this.readGlobal(config, key2, guard, { ...options, draft: true });
7124
7654
  }
@@ -7231,10 +7761,45 @@ var EasyCMS = class {
7231
7761
  return {
7232
7762
  ...scheduled,
7233
7763
  webhooks: await this.retryWebhooks(now),
7234
- emails: await this.mailer.retry(now)
7764
+ emails: await this.mailer.retry(now),
7765
+ jobs: await this.runConfigJobs(now)
7235
7766
  };
7236
7767
  });
7237
7768
  }
7769
+ /** `jobs` of the config that are due; when each last ran is kept in the database. */
7770
+ async runConfigJobs(now) {
7771
+ const { jobs } = this.config;
7772
+ if (jobs.length === 0) return { ran: 0, failed: 0 };
7773
+ const last = await this.db.findGlobal({ slug: JOB_RUNS }) ?? {};
7774
+ const runs = {};
7775
+ for (const [name, at] of Object.entries(last)) if (typeof at === "string") runs[name] = at;
7776
+ let ran = 0;
7777
+ let failed = 0;
7778
+ for (const job of jobs) {
7779
+ const previous = runs[job.name];
7780
+ if (job.every !== void 0 && previous !== void 0 && now.getTime() - Date.parse(previous) < job.every * 1e3)
7781
+ continue;
7782
+ runs[job.name] = now.toISOString();
7783
+ try {
7784
+ await job.run({ cms: this, now });
7785
+ ran++;
7786
+ } catch (error) {
7787
+ failed++;
7788
+ this.logger.error(`Job ${job.name} failed: ${error.message}`);
7789
+ }
7790
+ }
7791
+ await this.db.updateGlobal({ slug: JOB_RUNS, data: { ...runs, updatedAt: now.toISOString() } });
7792
+ return { ran, failed };
7793
+ }
7794
+ /**
7795
+ * Sends an event of the app or a plugin (`events` in the config, e.g. `order.paid`) to the
7796
+ * webhooks that list it. Returns at once; deliveries are queued and retried like content changes.
7797
+ */
7798
+ emit(event, data, about) {
7799
+ if (!this.config.events.includes(event))
7800
+ throw new QueryError(`Unknown event "${event}": add it to \`events\` in the config`);
7801
+ this.webhooks.emit(event, about ?? {}, data);
7802
+ }
7238
7803
  /**
7239
7804
  * Tries webhook deliveries that are due for another attempt. Deliveries are kept in the
7240
7805
  * database until they succeed, so they survive restarts.
@@ -7744,7 +8309,7 @@ var EasyCMS = class {
7744
8309
  doc,
7745
8310
  operation: "create"
7746
8311
  });
7747
- this.emit(config, "create", doc);
8312
+ this.emitChange(config, "create", doc);
7748
8313
  await this.audited(config, "create", doc, void 0, hookGuard ?? guard);
7749
8314
  const [out] = await this.output(config, [doc], hookGuard ?? guard, { ...options, draft: true });
7750
8315
  return out;
@@ -7802,7 +8367,7 @@ var EasyCMS = class {
7802
8367
  });
7803
8368
  }
7804
8369
  /** Sends webhook events for a change; `before` is the stored status before it, if any. */
7805
- emit(config, event, doc, before) {
8370
+ emitChange(config, event, doc, before) {
7806
8371
  const isGlobal = !this.config.collections.includes(config);
7807
8372
  if (!isGlobal && INTERNAL_COLLECTIONS.has(config.slug)) return;
7808
8373
  const body = isGlobal ? { ...snapshotOf(config.fields, doc), updatedAt: doc.updatedAt } : {
@@ -8228,6 +8793,7 @@ var UPLOAD_URL_SECONDS = 15 * 60;
8228
8793
  var UPLOAD_TICKET_SECONDS = 24 * 60 * 60;
8229
8794
  var SNIFF_BYTES = 64 * 1024;
8230
8795
  var MAX_FILES_TO_MOVE = 200;
8796
+ var JOB_RUNS = "easy-cms:jobs";
8231
8797
  function durationSeconds(value) {
8232
8798
  const match = typeof value === "string" ? /^(\d+)\s*([smhd])$/.exec(value.trim()) : null;
8233
8799
  const seconds = typeof value === "number" ? value : match ? Number(match[1]) * { s: 1, m: 60, h: 3600, d: 86400 }[match[2]] : Number.NaN;
@@ -8476,7 +9042,7 @@ async function readAdminModule(cms, index) {
8476
9042
  }
8477
9043
 
8478
9044
  // src/rest/admin-schema.ts
8479
- var SETTINGS = /* @__PURE__ */ new Set([USERS, API_KEYS]);
9045
+ var PEOPLE = /* @__PURE__ */ new Set([USERS, API_KEYS]);
8480
9046
  function componentRef(component) {
8481
9047
  if (typeof component === "string") return { tag: component };
8482
9048
  return component.props ? { tag: component.tag, props: JSON.parse(JSON.stringify(component.props)) } : { tag: component.tag };
@@ -8512,6 +9078,9 @@ async function serializeFields(fields, checks, localized, folderId, who) {
8512
9078
  };
8513
9079
  }
8514
9080
  if (field.customType) f.customType = field.customType;
9081
+ if (field.admin?.description !== void 0) f.description = field.admin.description;
9082
+ if (field.admin?.width) f.width = field.admin.width;
9083
+ if (field.admin?.condition) f.condition = JSON.parse(JSON.stringify(field.admin.condition));
8515
9084
  switch (field.type) {
8516
9085
  case "text":
8517
9086
  case "textarea":
@@ -8618,8 +9187,13 @@ async function collection(cms, config, user, localized, roles, context) {
8618
9187
  if (config.labels) result.labels = config.labels;
8619
9188
  if (config.useAsTitle) result.useAsTitle = config.useAsTitle;
8620
9189
  if (config.icon) result.icon = config.icon;
8621
- 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";
9190
+ if (config.admin?.sidebar?.length) result.sidebar = config.admin.sidebar.map(panelRef);
9191
+ if (config.admin?.layout?.length) result.layout = layoutOf(config.admin.layout, fields);
9192
+ const badge = config.admin?.badge;
9193
+ if (badge)
9194
+ result.badge = { tone: badge.tone ?? "accent", ...badge.label ? { label: badge.label } : {} };
9195
+ if (config.admin?.count === false) result.noCount = true;
9196
+ if (config.admin?.empty) result.empty = JSON.parse(JSON.stringify(config.admin.empty));
8623
9197
  if (config.admin?.list?.tree) result.tree = config.admin.list.tree;
8624
9198
  if (config.admin?.list?.sort) result.defaultSort = config.admin.list.sort;
8625
9199
  if (config.admin?.confirmDelete) result.confirmDelete = { ...config.admin.confirmDelete };
@@ -8650,7 +9224,8 @@ async function global(cms, config, user, localized, context) {
8650
9224
  };
8651
9225
  if (config.label !== void 0) result.label = config.label;
8652
9226
  if (config.icon) result.icon = config.icon;
8653
- if (config.admin?.sidebar?.length) result.sidebar = config.admin.sidebar.map(componentRef);
9227
+ if (config.admin?.sidebar?.length) result.sidebar = config.admin.sidebar.map(panelRef);
9228
+ if (config.admin?.layout?.length) result.layout = layoutOf(config.admin.layout, result.fields);
8654
9229
  return result;
8655
9230
  }
8656
9231
  async function adminSchema(cms, user, origin, context = {}) {
@@ -8671,18 +9246,23 @@ async function adminSchema(cms, user, origin, context = {}) {
8671
9246
  audit: cms.audit.enabled && await cms.roles.canView(user, "audit")
8672
9247
  };
8673
9248
  const roles = await cms.roles.options();
9249
+ const adminCollections = await Promise.all(
9250
+ collections.map((c) => collection(cms, c, user, localized, roles, context))
9251
+ );
9252
+ const adminGlobals = await Promise.all(
9253
+ cms.config.globals.map((g) => global(cms, g, user, localized, context))
9254
+ );
9255
+ const adminPages = await pages(cms, user);
8674
9256
  return {
8675
9257
  locale: cms.config.admin.locale,
8676
9258
  switcher: cms.config.admin.switcher,
8677
9259
  passwordLinks: cms.auth.canSendPasswordLinks(origin),
8678
9260
  menu: [...cms.config.admin.menu],
9261
+ nav: buildNav(cms, adminCollections, adminGlobals, adminPages, views),
9262
+ commands: cms.config.admin.commands.map((c) => JSON.parse(JSON.stringify(c))),
8679
9263
  localization: localization ? { locales: [...localization.locales], defaultLocale: localization.defaultLocale } : null,
8680
- collections: await Promise.all(
8681
- collections.map((c) => collection(cms, c, user, localized, roles, context))
8682
- ),
8683
- globals: await Promise.all(
8684
- cms.config.globals.map((g) => global(cms, g, user, localized, context))
8685
- ),
9264
+ collections: adminCollections,
9265
+ globals: adminGlobals,
8686
9266
  modules: adminModuleUrls(cms),
8687
9267
  uploadFromURL: cms.config.upload.fromURL !== void 0,
8688
9268
  upload: {
@@ -8705,7 +9285,7 @@ async function adminSchema(cms, user, origin, context = {}) {
8705
9285
  email: cms.config.collections.some((c) => c.slug === EMAIL_DELIVERIES)
8706
9286
  }
8707
9287
  } : {},
8708
- pages: await pages(cms, user),
9288
+ pages: adminPages,
8709
9289
  dashboard: await widgets(cms, user)
8710
9290
  };
8711
9291
  }
@@ -8726,7 +9306,6 @@ async function pages(cms, user) {
8726
9306
  path: page.path,
8727
9307
  label: page.label,
8728
9308
  ...page.icon ? { icon: page.icon } : {},
8729
- group: page.group ?? "content",
8730
9309
  component: componentRef(page.component)
8731
9310
  });
8732
9311
  }
@@ -8745,9 +9324,200 @@ async function widgets(cms, user) {
8745
9324
  function folderIds(cms, user, context) {
8746
9325
  return cms.folders.enabled ? (key2) => cms.keyedFolder(key2, { user, context }) : void 0;
8747
9326
  }
9327
+ function panelRef(panel) {
9328
+ const ref = componentRef(panel);
9329
+ if (typeof panel === "object" && "position" in panel && panel.position === "top")
9330
+ ref.position = "top";
9331
+ return ref;
9332
+ }
9333
+ function layoutOf(layout, fields) {
9334
+ const shown2 = new Set(fields.filter((f) => f.position !== "sidebar").map((f) => f.name));
9335
+ const placed = /* @__PURE__ */ new Set();
9336
+ const convert = (nodes) => nodes.flatMap((node) => {
9337
+ if (typeof node === "string") {
9338
+ if (!shown2.has(node)) return [];
9339
+ placed.add(node);
9340
+ return [{ type: "field", name: node }];
9341
+ }
9342
+ if ("row" in node) {
9343
+ const names2 = node.row.filter((n) => shown2.has(n));
9344
+ for (const n of names2) placed.add(n);
9345
+ return names2.length ? [{ type: "row", fields: names2 }] : [];
9346
+ }
9347
+ if ("collapsible" in node) {
9348
+ const inner2 = convert(node.fields);
9349
+ return inner2.length ? [
9350
+ {
9351
+ type: "collapsible",
9352
+ label: node.collapsible,
9353
+ ...node.collapsed ? { collapsed: true } : {},
9354
+ ...node.description !== void 0 ? { description: node.description } : {},
9355
+ nodes: inner2
9356
+ }
9357
+ ] : [];
9358
+ }
9359
+ const inner = convert(node.fields);
9360
+ return [
9361
+ {
9362
+ type: "tab",
9363
+ label: node.tab,
9364
+ ...node.description !== void 0 ? { description: node.description } : {},
9365
+ nodes: inner
9366
+ }
9367
+ ];
9368
+ });
9369
+ const out = convert(layout);
9370
+ const rest = [...shown2].filter((n) => !placed.has(n)).map((name) => ({ type: "field", name }));
9371
+ const first = out[0];
9372
+ if (first?.type === "tab") first.nodes.push(...rest);
9373
+ else out.push(...rest);
9374
+ return out.filter((n) => n.type !== "tab" || n.nodes.length > 0);
9375
+ }
9376
+ var VIEW_GROUPS = [
9377
+ ["roles", "settings.people"],
9378
+ ["sso", "settings.people"],
9379
+ ["backups", "settings.system"],
9380
+ ["email", "settings.system"],
9381
+ ["deliveries", "settings.system"],
9382
+ ["audit", "settings.system"]
9383
+ ];
9384
+ function buildNav(cms, collections, globals, pageRefs, views) {
9385
+ const groups = navGroups(cms.config.admin.nav);
9386
+ const ids = navIds(groups);
9387
+ const items = /* @__PURE__ */ new Map();
9388
+ const labelGroups = /* @__PURE__ */ new Map();
9389
+ const put = (group, item) => {
9390
+ if (group.label !== void 0 && !labelGroups.has(group.id))
9391
+ labelGroups.set(group.id, group.label);
9392
+ const list = items.get(group.id) ?? [];
9393
+ list.push(item);
9394
+ items.set(group.id, list);
9395
+ };
9396
+ const configOf = (slug2) => cms.config.collections.find((c) => c.slug === slug2);
9397
+ const menu = cms.config.admin.menu;
9398
+ const rank2 = (slug2, index) => {
9399
+ const at = menu.indexOf(slug2);
9400
+ return at === -1 ? menu.length + index : at;
9401
+ };
9402
+ const ordered = collections.map((c, index) => ({ c, key: rank2(c.slug, index) })).sort((a, b) => a.key - b.key).map(({ c }) => c);
9403
+ let media = false;
9404
+ for (const c of ordered) {
9405
+ if (!c.permissions.read || c.slug === MEDIA_FOLDERS) continue;
9406
+ if (c.slug === MEDIA) {
9407
+ media = true;
9408
+ continue;
9409
+ }
9410
+ const group = PEOPLE.has(c.slug) ? { id: "settings.people" } : groupOf(configOf(c.slug)?.admin?.group, ids, "content");
9411
+ put(group, { kind: "collection", slug: c.slug });
9412
+ }
9413
+ for (const g of globals) {
9414
+ if (!g.permissions.read) continue;
9415
+ const config = cms.config.globals.find((x) => x.slug === g.slug);
9416
+ put(groupOf(config?.admin?.group, ids, "settings.site"), { kind: "global", slug: g.slug });
9417
+ }
9418
+ for (const p of pageRefs) {
9419
+ const config = cms.config.admin.pages.find((x) => x.path === p.path);
9420
+ const group = config?.group ?? "content";
9421
+ if (group === false) continue;
9422
+ put(groupOf(group, ids, "content"), { kind: "page", path: p.path });
9423
+ }
9424
+ for (const [view, group] of VIEW_GROUPS)
9425
+ if (views[view]) put({ id: group }, { kind: "view", view });
9426
+ const toGroup = (g) => {
9427
+ const children = g.children.map(toGroup).filter((c) => c !== null);
9428
+ const own = items.get(g.id) ?? [];
9429
+ if (own.length === 0 && children.length === 0) return null;
9430
+ return {
9431
+ kind: "group",
9432
+ id: g.id,
9433
+ label: g.label,
9434
+ ...g.icon ? { icon: g.icon } : {},
9435
+ items: [...own, ...children]
9436
+ };
9437
+ };
9438
+ const root = [];
9439
+ for (const g of groups) {
9440
+ const node = toGroup(g);
9441
+ if (node) root.push({ order: g.order, node });
9442
+ }
9443
+ let i = 0;
9444
+ for (const [id, label] of labelGroups) {
9445
+ const own = items.get(id) ?? [];
9446
+ if (own.length)
9447
+ root.push({ order: LABEL_ORDER + i++, node: { kind: "group", id, label, items: own } });
9448
+ }
9449
+ if (media) root.push({ order: MEDIA_ORDER, node: { kind: "collection", slug: MEDIA } });
9450
+ return root.sort((a, b) => a.order - b.order).map((r) => r.node);
9451
+ }
9452
+
9453
+ // src/rest/admin-search.ts
9454
+ var listed = (cms) => cms.config.collections.filter(
9455
+ (c) => !INTERNAL_COLLECTIONS.has(c.slug) && c.slug !== MEDIA_FOLDERS
9456
+ );
9457
+ async function readable(cms, c, who) {
9458
+ try {
9459
+ await cms.count(c.slug, { ...who, overrideAccess: false, where: { id: { exists: false } } });
9460
+ return true;
9461
+ } catch {
9462
+ return false;
9463
+ }
9464
+ }
9465
+ async function adminCounts(cms, who) {
9466
+ const counts = {};
9467
+ const badges = {};
9468
+ await Promise.all(
9469
+ listed(cms).map(async (c) => {
9470
+ const read2 = { ...who, overrideAccess: false, draft: true };
9471
+ try {
9472
+ if (c.admin?.count !== false) counts[c.slug] = await cms.count(c.slug, read2);
9473
+ if (c.admin?.badge)
9474
+ badges[c.slug] = await cms.count(c.slug, { ...read2, where: c.admin.badge.where });
9475
+ } catch {
9476
+ }
9477
+ })
9478
+ );
9479
+ return { counts, badges };
9480
+ }
9481
+ async function adminSearch(cms, who, q, perCollection = 5) {
9482
+ const query = q.trim().slice(0, 100);
9483
+ if (query.length < 1) return [];
9484
+ const results = await Promise.all(
9485
+ listed(cms).map(async (c) => {
9486
+ const title = c.useAsTitle ?? (c.fields.some((f) => f.name === "title") ? "title" : void 0);
9487
+ const conditions = [];
9488
+ if (title) conditions.push({ [title]: { like: query } });
9489
+ if (/^\d+$/.test(query)) conditions.push({ id: { equals: Number(query) } });
9490
+ if (conditions.length === 0 || !await readable(cms, c, who)) return [];
9491
+ try {
9492
+ const found = await cms.find(c.slug, {
9493
+ ...who,
9494
+ overrideAccess: false,
9495
+ draft: true,
9496
+ depth: 0,
9497
+ limit: perCollection,
9498
+ where: conditions.length === 1 ? conditions[0] : { or: conditions },
9499
+ sort: "-updatedAt"
9500
+ });
9501
+ return found.docs.map((doc) => {
9502
+ const d = doc;
9503
+ const value = title ? d[title] : void 0;
9504
+ return {
9505
+ collection: c.slug,
9506
+ id: d.id,
9507
+ title: typeof value === "string" && value ? value : `#${String(d.id)}`,
9508
+ ...typeof d.status === "string" ? { status: d.status } : {}
9509
+ };
9510
+ });
9511
+ } catch {
9512
+ return [];
9513
+ }
9514
+ })
9515
+ );
9516
+ return results.flat();
9517
+ }
8748
9518
 
8749
9519
  // src/version.ts
8750
- var VERSION = true ? "0.45.0" : "0.0.0-dev";
9520
+ var VERSION = true ? "0.47.0" : "0.0.0-dev";
8751
9521
 
8752
9522
  // src/rest/admin-status.ts
8753
9523
  var DAY2 = 864e5;
@@ -9120,8 +9890,9 @@ async function route(cms, ctx, method, segments, options) {
9120
9890
  context: ctx.context,
9121
9891
  ...parseLocale(ctx.url)
9122
9892
  };
9123
- const draft = ctx.user !== null && ctx.url.searchParams.get("draft") === "true";
9893
+ const draft = ctx.user !== null && ctx.user.member !== true && ctx.url.searchParams.get("draft") === "true";
9124
9894
  if (segments.length === 0) throw new HttpError("Not found", 404);
9895
+ if (first === "admin" && ctx.user?.member === true) throw new ForbiddenError();
9125
9896
  if (first === "auth") return ssoRoute(cms, ctx, method, segments);
9126
9897
  if (first === USERS && second !== void 0 && third === void 0 && !/^\d+$/.test(second)) {
9127
9898
  switch (`${method} ${second}`) {
@@ -9194,6 +9965,45 @@ async function route(cms, ctx, method, segments, options) {
9194
9965
  body: { user: session.user, exp: session.expiresAt, csrfToken: session.csrfToken }
9195
9966
  };
9196
9967
  }
9968
+ // Members signing up (`auth.members.signup`): the form's token, then the account.
9969
+ case "GET signup":
9970
+ return { body: cms.auth.signupForm() };
9971
+ case "POST signup": {
9972
+ const body = await readJson(ctx.request);
9973
+ const result = await cms.auth.signup({
9974
+ email: String(body.email ?? ""),
9975
+ password: String(body.password ?? ""),
9976
+ name: typeof body.name === "string" ? body.name : void 0,
9977
+ token: body.token,
9978
+ honeypot: body[SIGNUP_HONEYPOT],
9979
+ turnstile: body.turnstile,
9980
+ ip: options.getClientIp?.(ctx.request),
9981
+ origin: ctx.url.origin,
9982
+ locale: typeof body.locale === "string" ? body.locale : void 0,
9983
+ context: ctx.context
9984
+ });
9985
+ if (result.verify)
9986
+ return {
9987
+ status: 202,
9988
+ body: { verify: true, message: "Check your email for a link to confirm it." }
9989
+ };
9990
+ setSessionCookies(cms, ctx, result);
9991
+ return {
9992
+ status: 201,
9993
+ body: { user: result.user, exp: result.expiresAt, csrfToken: result.csrfToken }
9994
+ };
9995
+ }
9996
+ case "POST verify-email": {
9997
+ const body = await readJson(ctx.request);
9998
+ const session = await cms.auth.verifyEmail({
9999
+ token: String(body.token ?? ""),
10000
+ ip: options.getClientIp?.(ctx.request)
10001
+ });
10002
+ setSessionCookies(cms, ctx, session);
10003
+ return {
10004
+ body: { user: session.user, exp: session.expiresAt, csrfToken: session.csrfToken }
10005
+ };
10006
+ }
9197
10007
  case "POST first-register": {
9198
10008
  const body = await readJson(ctx.request);
9199
10009
  const session = await cms.auth.registerFirstUser({
@@ -9430,6 +10240,18 @@ async function route(cms, ctx, method, segments, options) {
9430
10240
  if (!ctx.user) throw new UnauthorizedError();
9431
10241
  if (second === "schema" && third === void 0)
9432
10242
  return { body: await adminSchema(cms, ctx.user, ctx.url.origin, ctx.context) };
10243
+ if (second === "counts" && third === void 0)
10244
+ return { body: await adminCounts(cms, { user: ctx.user, context: ctx.context }) };
10245
+ if (second === "search" && third === void 0)
10246
+ return {
10247
+ body: {
10248
+ docs: await adminSearch(
10249
+ cms,
10250
+ { user: ctx.user, context: ctx.context },
10251
+ ctx.url.searchParams.get("q") ?? ""
10252
+ )
10253
+ }
10254
+ };
9433
10255
  if (second === "modules" && third !== void 0 && segments.length === 3) {
9434
10256
  const index = /^(\d+)\.js$/.exec(third)?.[1];
9435
10257
  const file = index === void 0 ? void 0 : await readAdminModule(cms, Number(index));
@@ -10276,6 +11098,7 @@ export {
10276
11098
  AuditLog,
10277
11099
  Auth,
10278
11100
  BUILTIN_COLLECTIONS,
11101
+ BUILTIN_NAV,
10279
11102
  CONFIG_FILE_NAMES,
10280
11103
  CSRF_COOKIE,
10281
11104
  CSRF_HEADER,
@@ -10318,6 +11141,8 @@ export {
10318
11141
  anyone,
10319
11142
  applyDefaults,
10320
11143
  applyPlugins,
11144
+ checkFormToken,
11145
+ conditionIssues,
10321
11146
  configSignature,
10322
11147
  consoleEmail,
10323
11148
  consoleLogger,
@@ -10332,24 +11157,29 @@ export {
10332
11157
  expandMimeTypes,
10333
11158
  fillMissing,
10334
11159
  findConfigFile,
11160
+ formToken,
10335
11161
  generateSlugs,
10336
11162
  generateTypes,
10337
11163
  hasRows,
10338
11164
  hashPassword,
11165
+ honeypotName,
10339
11166
  imageDimensions,
10340
11167
  importConfig,
10341
11168
  isAdmin,
10342
11169
  isHasMany,
10343
11170
  isLoggedIn,
11171
+ isSignedIn,
10344
11172
  isSystemAdmin,
10345
11173
  keyAllows,
10346
11174
  loadConfig,
10347
11175
  localStorage,
11176
+ matchesCondition,
10348
11177
  mergeForUpdate,
10349
11178
  mimeAllowed,
10350
11179
  mimeAllowedBy,
10351
11180
  parseId,
10352
11181
  populate,
11182
+ rateKeys,
10353
11183
  readCookie,
10354
11184
  resolveAdminModule,
10355
11185
  resolveConfig,
@@ -10363,5 +11193,6 @@ export {
10363
11193
  validateConfig,
10364
11194
  validateFields,
10365
11195
  verifyPassword,
11196
+ verifyTurnstile,
10366
11197
  writeBackupFile
10367
11198
  };