@easy-cms/core 0.33.0 → 0.35.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
@@ -253,14 +253,19 @@ function andWhere(where, constraint) {
253
253
  return where === void 0 ? constraint : { and: [where, constraint] };
254
254
  }
255
255
  var FieldAccessChecker = class {
256
- constructor(kind, args) {
256
+ /** `rules`: the user's role's rules for top-level fields (`auth.rbac`), on top of `access`. */
257
+ constructor(kind, args, rules) {
257
258
  this.kind = kind;
258
259
  this.args = args;
260
+ this.rules = rules;
259
261
  }
260
262
  kind;
261
263
  args;
264
+ rules;
262
265
  cache = /* @__PURE__ */ new Map();
263
266
  async allows(field) {
267
+ const rule = this.rules?.get(field);
268
+ if (rule === "hidden" || rule === "read" && this.kind === "update") return false;
264
269
  const fn = field.access?.[this.kind];
265
270
  if (!fn) return true;
266
271
  let allowed2 = this.cache.get(field);
@@ -327,6 +332,7 @@ var WEBHOOK_DELIVERIES = "webhook-deliveries";
327
332
  var EMAIL_DELIVERIES = "email-deliveries";
328
333
  var DATABASE_BACKUPS = "database-backups";
329
334
  var ROLES = "user-roles";
335
+ var USER_IDENTITIES = "user-identities";
330
336
  var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
331
337
  SESSIONS,
332
338
  LOGIN_ATTEMPTS,
@@ -335,9 +341,27 @@ var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
335
341
  WEBHOOK_DELIVERIES,
336
342
  EMAIL_DELIVERIES,
337
343
  DATABASE_BACKUPS,
338
- ROLES
344
+ ROLES,
345
+ USER_IDENTITIES
339
346
  ]);
340
347
  var DEFAULT_ROLES = ["admin", "editor"];
348
+ var CREATED_BY_FIELD = {
349
+ name: "createdBy",
350
+ type: "relationship",
351
+ to: USERS,
352
+ position: "sidebar",
353
+ access: { update: () => false },
354
+ label: { en: "Created by", th: "\u0E2A\u0E23\u0E49\u0E32\u0E07\u0E42\u0E14\u0E22" }
355
+ };
356
+ function withCreatedBy(config) {
357
+ if (config.auth?.rbac !== true) return config;
358
+ return {
359
+ ...config,
360
+ collections: (config.collections ?? []).map(
361
+ (c) => c.slug === USERS || c.fields?.some((f) => f.name === CREATED_BY_FIELD.name) ? c : { ...c, fields: [...c.fields, CREATED_BY_FIELD] }
362
+ )
363
+ };
364
+ }
341
365
  var adminOrSelf = ({ user }) => {
342
366
  if (!user) return false;
343
367
  if (user.role === "admin") return true;
@@ -578,6 +602,18 @@ var databaseBackupsCollection = {
578
602
  { name: "downloadedAt", type: "text" }
579
603
  ]
580
604
  };
605
+ var userIdentitiesCollection = {
606
+ slug: USER_IDENTITIES,
607
+ access: { read: nobody, create: nobody, update: nobody, delete: nobody },
608
+ fields: [
609
+ { name: "user", type: "relationship", to: USERS, required: true, index: true },
610
+ // The provider's id (`google`) and its id for the account (`sub`).
611
+ { name: "provider", type: "text", required: true, index: true },
612
+ { name: "subject", type: "text", required: true, index: true },
613
+ { name: "email", type: "text" },
614
+ { name: "lastUsedAt", type: "text" }
615
+ ]
616
+ };
581
617
  var emailDeliveriesCollection = {
582
618
  slug: EMAIL_DELIVERIES,
583
619
  access: { read: nobody, create: nobody, update: nobody, delete: nobody },
@@ -620,7 +656,17 @@ var DEFAULT_PASSWORD_EMAILS = {
620
656
  ],
621
657
  { label: "Set a new password", url }
622
658
  ),
623
- invite: ({ url = "", locale, expiresAt, user }) => locale === "th" ? email(
659
+ invite: ({ url = "", locale, expiresAt, user, sso }) => sso ? locale === "th" ? email(
660
+ "\u0E04\u0E38\u0E13\u0E44\u0E14\u0E49\u0E23\u0E31\u0E1A\u0E40\u0E0A\u0E34\u0E0D\u0E43\u0E2B\u0E49\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E23\u0E30\u0E1A\u0E1A\u0E08\u0E31\u0E14\u0E01\u0E32\u0E23\u0E40\u0E19\u0E37\u0E49\u0E2D\u0E2B\u0E32",
661
+ [`\u0E21\u0E35\u0E1A\u0E31\u0E0D\u0E0A\u0E35\u0E2A\u0E33\u0E2B\u0E23\u0E31\u0E1A ${user.email} \u0E23\u0E2D\u0E04\u0E38\u0E13\u0E2D\u0E22\u0E39\u0E48 \u0E40\u0E02\u0E49\u0E32\u0E2A\u0E39\u0E48\u0E23\u0E30\u0E1A\u0E1A\u0E14\u0E49\u0E27\u0E22 ${sso.join(" \u0E2B\u0E23\u0E37\u0E2D ")} \u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E40\u0E23\u0E34\u0E48\u0E21\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19`],
662
+ { label: "\u0E40\u0E02\u0E49\u0E32\u0E2A\u0E39\u0E48\u0E23\u0E30\u0E1A\u0E1A", url }
663
+ ) : email(
664
+ "You're invited to the content admin",
665
+ [
666
+ `An account for ${user.email} is waiting for you. Sign in with ${sso.join(" or ")} to start.`
667
+ ],
668
+ { label: "Sign in", url }
669
+ ) : locale === "th" ? email(
624
670
  "\u0E04\u0E38\u0E13\u0E44\u0E14\u0E49\u0E23\u0E31\u0E1A\u0E40\u0E0A\u0E34\u0E0D\u0E43\u0E2B\u0E49\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E23\u0E30\u0E1A\u0E1A\u0E08\u0E31\u0E14\u0E01\u0E32\u0E23\u0E40\u0E19\u0E37\u0E49\u0E2D\u0E2B\u0E32",
625
671
  [
626
672
  `\u0E21\u0E35\u0E1A\u0E31\u0E0D\u0E0A\u0E35\u0E2A\u0E33\u0E2B\u0E23\u0E31\u0E1A ${user.email} \u0E23\u0E2D\u0E04\u0E38\u0E13\u0E2D\u0E22\u0E39\u0E48 \u0E15\u0E31\u0E49\u0E07\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E40\u0E23\u0E34\u0E48\u0E21\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19`,
@@ -689,6 +735,9 @@ async function fakeVerify(password) {
689
735
  return false;
690
736
  }
691
737
 
738
+ // src/auth/sso.ts
739
+ import { createHmac as createHmac2, randomBytes as randomBytes4 } from "crypto";
740
+
692
741
  // src/auth/tokens.ts
693
742
  import { createHash as createHash2, createHmac, randomBytes as randomBytes3, timingSafeEqual as timingSafeEqual3 } from "crypto";
694
743
  function newToken() {
@@ -770,14 +819,279 @@ function passwordTokenMatches(secret, token, fingerprint) {
770
819
  return safeEqual(token.slice(dot + 1), hmac(secret, `password:${payload}:${fingerprint}`));
771
820
  }
772
821
 
822
+ // src/auth/sso.ts
823
+ var SSO_COOKIE = "ecms-sso";
824
+ var PENDING_TTL = 10 * 6e4;
825
+ var random = () => randomBytes4(32).toString("base64url");
826
+ function sign(secret, payload) {
827
+ return createHmac2("sha256", secret).update(`sso:${payload}`).digest("base64url");
828
+ }
829
+ function seal(secret, pending) {
830
+ const payload = Buffer.from(JSON.stringify(pending)).toString("base64url");
831
+ return `${payload}.${sign(secret, payload)}`;
832
+ }
833
+ function unseal(secret, value) {
834
+ if (!value) return null;
835
+ const dot = value.lastIndexOf(".");
836
+ if (dot <= 0) return null;
837
+ const payload = value.slice(0, dot);
838
+ if (!safeEqual(value.slice(dot + 1), sign(secret, payload))) return null;
839
+ try {
840
+ const pending = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
841
+ return typeof pending.e === "number" && pending.e > Date.now() ? pending : null;
842
+ } catch {
843
+ return null;
844
+ }
845
+ }
846
+ var SingleSignOn = class {
847
+ constructor(cms) {
848
+ this.cms = cms;
849
+ }
850
+ cms;
851
+ get config() {
852
+ return this.cms.config;
853
+ }
854
+ get enabled() {
855
+ return this.config.auth.providers.length > 0;
856
+ }
857
+ providers() {
858
+ return this.config.auth.providers.map((p) => ({
859
+ id: p.id,
860
+ name: p.name,
861
+ icon: p.icon ?? "key"
862
+ }));
863
+ }
864
+ /** Whether a user may sign in with a password (`auth.password: false` leaves only admins). */
865
+ passwordAllowed(user) {
866
+ return this.config.auth.password || user.role === "admin";
867
+ }
868
+ /** `<serverURL><api>/auth/<id>/callback`, or `null` without `serverURL` in production. */
869
+ callbackURL(provider, origin) {
870
+ const base = this.cms.auth.passwordLinkBase(origin);
871
+ return base ? `${base}${this.config.routes.api}/auth/${provider}/callback` : null;
872
+ }
873
+ /**
874
+ * Starts signing in (or, with `user`, linking an account): the provider's URL to send the
875
+ * browser to, and the sealed value of the cookie that must come back with it.
876
+ */
877
+ async start(providerId, options) {
878
+ const provider = this.provider(providerId);
879
+ const redirectUri = this.callbackURL(provider.id, options.origin);
880
+ if (!redirectUri)
881
+ throw new QueryError("Signing in with providers needs `serverURL` in production");
882
+ const pending = {
883
+ p: provider.id,
884
+ s: random(),
885
+ v: random(),
886
+ n: random(),
887
+ r: this.adminPage(options.redirect),
888
+ ...options.user ? { u: String(options.user.id) } : {},
889
+ e: Date.now() + PENDING_TTL
890
+ };
891
+ const url = await provider.authorizationURL({
892
+ redirectUri,
893
+ state: pending.s,
894
+ codeVerifier: pending.v,
895
+ nonce: pending.n
896
+ });
897
+ return { url: url.toString(), cookie: seal(this.config.secret, pending) };
898
+ }
899
+ /**
900
+ * Finishes at `<api>/auth/<id>/callback`: checks the response, finds (or makes) the user and
901
+ * starts a session. Returns where to send the browser; failures end on the login page (or the
902
+ * account page, when linking) with `?sso=<outcome>`.
903
+ */
904
+ async finish(providerId, options) {
905
+ const admin = this.config.admin.path.replace(/\/+$/, "");
906
+ const pending = unseal(this.config.secret, options.cookie);
907
+ const back = (page2, outcome) => ({ redirect: `${admin}/${page2}?sso=${outcome}` });
908
+ const page = pending?.u ? "account" : "login";
909
+ if (!pending || pending.p !== providerId) return back(page, "expired");
910
+ const state = options.url.searchParams.get("state") ?? "";
911
+ if (!safeEqual(state, pending.s)) return back(page, "expired");
912
+ if (options.url.searchParams.has("error")) return back(page, "cancelled");
913
+ const provider = this.provider(providerId);
914
+ const redirectUri = this.callbackURL(provider.id, options.origin);
915
+ let profile;
916
+ try {
917
+ if (!redirectUri) throw new Error("no callback URL");
918
+ profile = await provider.callback({
919
+ redirectUri,
920
+ state: pending.s,
921
+ codeVerifier: pending.v,
922
+ nonce: pending.n,
923
+ url: options.url
924
+ });
925
+ } catch (error) {
926
+ this.cms.logger.warn(
927
+ `Signing in with ${provider.name} failed: ${error.message || String(error)}`
928
+ );
929
+ return back(page, "failed");
930
+ }
931
+ const subject = String(profile.subject);
932
+ const email2 = profile.email?.trim().toLowerCase() || null;
933
+ if (pending.u) {
934
+ if (!options.user || String(options.user.id) !== pending.u) return back("account", "failed");
935
+ const existing = await this.identity(provider.id, subject);
936
+ if (existing && String(existing.user) !== pending.u) return back("account", "taken");
937
+ if (!existing) await this.addIdentity(options.user.id, provider.id, subject, email2);
938
+ return { redirect: `${admin}/account?sso=linked` };
939
+ }
940
+ let user = null;
941
+ const identity = await this.identity(provider.id, subject);
942
+ if (identity) {
943
+ user = await this.cms.db.findById({ collection: USERS, id: identity.user });
944
+ } else {
945
+ if (!email2 || !profile.emailVerified)
946
+ return back("login", email2 ? "unverified" : "no-account");
947
+ user = await this.userByEmail(email2);
948
+ if (!user) user = await this.signUp(email2, profile.name);
949
+ if (!user) return back("login", "no-account");
950
+ await this.addIdentity(user.id, provider.id, subject, email2);
951
+ }
952
+ if (!user) return back("login", "no-account");
953
+ if (user.active === false) return back("login", "inactive");
954
+ const found = identity ?? await this.identity(provider.id, subject);
955
+ if (found) {
956
+ const { id, ...rest } = found;
957
+ await this.cms.db.update({
958
+ collection: USER_IDENTITIES,
959
+ id,
960
+ data: { ...rest, email: email2, lastUsedAt: (/* @__PURE__ */ new Date()).toISOString() }
961
+ });
962
+ }
963
+ return { redirect: pending.r, session: await this.cms.auth.createSession(user.id) };
964
+ }
965
+ /** A user's outside accounts. */
966
+ async identities(userId) {
967
+ if (!this.enabled) return [];
968
+ const { docs } = await this.cms.db.find({
969
+ collection: USER_IDENTITIES,
970
+ where: { user: { equals: userId } },
971
+ sort: ["id"],
972
+ limit: 0,
973
+ page: 1
974
+ });
975
+ return docs.map((row) => ({
976
+ id: row.id,
977
+ provider: String(row.provider),
978
+ name: this.config.auth.providers.find((p) => p.id === row.provider)?.name ?? String(row.provider),
979
+ email: typeof row.email === "string" ? row.email : null,
980
+ createdAt: String(row.createdAt),
981
+ lastUsedAt: typeof row.lastUsedAt === "string" ? row.lastUsedAt : null
982
+ }));
983
+ }
984
+ /**
985
+ * Unlinks an outside account: the user's own, or anyone's for admins. Refuses to unlink the
986
+ * last way a user has to sign in.
987
+ */
988
+ async unlink(actor, identityId) {
989
+ if (!this.enabled) throw new NotFoundError(USER_IDENTITIES, identityId);
990
+ const row = await this.cms.db.findById({ collection: USER_IDENTITIES, id: identityId });
991
+ if (!row) throw new NotFoundError(USER_IDENTITIES, identityId);
992
+ const own = String(row.user) === String(actor.id);
993
+ if (!own && (actor.role !== "admin" || actor.apiKey)) throw new ForbiddenError();
994
+ const user = await this.cms.db.findById({ collection: USERS, id: row.user });
995
+ const others = (await this.identities(row.user)).length - 1;
996
+ const password = !!user?.passwordHash && this.passwordAllowed(user);
997
+ if (others === 0 && !password)
998
+ throw new ValidationError(USER_IDENTITIES, [
999
+ { field: "provider", message: "is the only way left to sign in to this account" }
1000
+ ]);
1001
+ await this.cms.db.delete({ collection: USER_IDENTITIES, id: row.id });
1002
+ }
1003
+ /** Forgets a deleted user's outside accounts. */
1004
+ async forget(userId) {
1005
+ if (!this.enabled) return;
1006
+ for (const identity of await this.identities(userId))
1007
+ await this.cms.db.delete({ collection: USER_IDENTITIES, id: identity.id });
1008
+ }
1009
+ settings(origin) {
1010
+ const { allowSignUp } = this.config.auth;
1011
+ return {
1012
+ providers: this.config.auth.providers.map((p) => ({
1013
+ id: p.id,
1014
+ name: p.name,
1015
+ icon: p.icon ?? "key",
1016
+ callbackURL: this.callbackURL(p.id, origin),
1017
+ details: { ...p.describe?.() ?? {} }
1018
+ })),
1019
+ password: this.config.auth.password ? "everyone" : "admins",
1020
+ signUp: allowSignUp.domains.length > 0 ? { domains: [...allowSignUp.domains], role: allowSignUp.role ?? this.defaultRole() } : null,
1021
+ serverURL: !!this.config.serverURL
1022
+ };
1023
+ }
1024
+ // -------------------------------------------------------------------------
1025
+ provider(id) {
1026
+ const provider = this.config.auth.providers.find((p) => p.id === id);
1027
+ if (!provider) throw new NotFoundError("auth providers", id);
1028
+ return provider;
1029
+ }
1030
+ /** Only pages of the admin: anything else (another site) goes to the admin's home. */
1031
+ adminPage(redirect) {
1032
+ const admin = this.config.admin.path.replace(/\/+$/, "");
1033
+ if (typeof redirect === "string" && (redirect === admin || redirect.startsWith(`${admin}/`)) && !redirect.startsWith("//") && !redirect.includes("\\"))
1034
+ return redirect;
1035
+ return `${admin}/`;
1036
+ }
1037
+ async identity(provider, subject) {
1038
+ const { docs } = await this.cms.db.find({
1039
+ collection: USER_IDENTITIES,
1040
+ where: { and: [{ provider: { equals: provider } }, { subject: { equals: subject } }] },
1041
+ sort: [],
1042
+ limit: 1,
1043
+ page: 1
1044
+ });
1045
+ return docs[0];
1046
+ }
1047
+ async addIdentity(user, provider, subject, email2) {
1048
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1049
+ await this.cms.db.create({
1050
+ collection: USER_IDENTITIES,
1051
+ data: { user, provider, subject, email: email2, lastUsedAt: now, createdAt: now, updatedAt: now }
1052
+ });
1053
+ }
1054
+ async userByEmail(email2) {
1055
+ const { docs } = await this.cms.db.find({
1056
+ collection: USERS,
1057
+ where: { email: { equals: email2 } },
1058
+ sort: [],
1059
+ limit: 1,
1060
+ page: 1
1061
+ });
1062
+ return docs[0] ?? null;
1063
+ }
1064
+ /** A new account for people from `allowSignUp.domains`. */
1065
+ async signUp(email2, name) {
1066
+ const { domains, role } = this.config.auth.allowSignUp;
1067
+ const domain = email2.slice(email2.lastIndexOf("@") + 1);
1068
+ if (!domains.some((d) => d.toLowerCase() === domain)) return null;
1069
+ const created = await this.cms.create(USERS, {
1070
+ email: email2,
1071
+ role: role ?? this.defaultRole(),
1072
+ active: true,
1073
+ ...name ? { name } : {}
1074
+ });
1075
+ this.cms.logger.info(`${email2} signed up with a provider`);
1076
+ return this.cms.db.findById({ collection: USERS, id: created.id });
1077
+ }
1078
+ defaultRole() {
1079
+ const roles = this.config.auth.roles;
1080
+ return roles.includes("editor") ? "editor" : roles[roles.length - 1];
1081
+ }
1082
+ };
1083
+
773
1084
  // src/auth/auth.ts
774
1085
  var INVALID = "Invalid email or password";
775
1086
  var RESET_REQUESTS = 3;
776
1087
  var Auth = class {
777
1088
  constructor(cms) {
778
1089
  this.cms = cms;
1090
+ this.sso = new SingleSignOn(cms);
779
1091
  }
780
1092
  cms;
1093
+ /** Signing in with outside accounts (`auth.providers`). */
1094
+ sso;
781
1095
  get db() {
782
1096
  return this.cms.db;
783
1097
  }
@@ -804,6 +1118,10 @@ var Auth = class {
804
1118
  throw new UnauthorizedError(INVALID);
805
1119
  }
806
1120
  await this.clearFailures(key2);
1121
+ if (!this.sso.passwordAllowed(user))
1122
+ throw new UnauthorizedError(
1123
+ `Sign in with ${this.config.auth.providers.map((p) => p.name).join(" or ")}`
1124
+ );
807
1125
  await this.deleteExpiredSessions(user.id);
808
1126
  return this.startSession(user);
809
1127
  }
@@ -903,7 +1221,7 @@ var Auth = class {
903
1221
  if (recent >= RESET_REQUESTS) return;
904
1222
  await this.recordFailure(key2);
905
1223
  const user = await this.findByEmail(email2);
906
- if (!user || user.active === false) return;
1224
+ if (!user || user.active === false || !this.sso.passwordAllowed(user)) return;
907
1225
  await this.mailPasswordLink(user, user.passwordHash ? "reset" : "invite", args);
908
1226
  }
909
1227
  /**
@@ -959,7 +1277,7 @@ var Auth = class {
959
1277
  if (!claims || claims.expiresAt <= Date.now()) return null;
960
1278
  const id = /^\d+$/.test(claims.userId) ? Number(claims.userId) : claims.userId;
961
1279
  const user = await this.db.findById({ collection: USERS, id });
962
- if (!user || user.active === false) return null;
1280
+ if (!user || user.active === false || !this.sso.passwordAllowed(user)) return null;
963
1281
  const fingerprint = passwordFingerprint(user.passwordHash);
964
1282
  if (!passwordTokenMatches(this.config.secret, token, fingerprint)) return null;
965
1283
  return { user, purpose: claims.purpose };
@@ -974,13 +1292,14 @@ var Auth = class {
974
1292
  expiresAt,
975
1293
  fingerprint: passwordFingerprint(user.passwordHash)
976
1294
  });
977
- const url = `${base}${this.config.admin.path}/reset-password?token=${encodeURIComponent(token)}`;
1295
+ const sso = this.sso.passwordAllowed(user) ? void 0 : this.config.auth.providers.map((p) => p.name);
1296
+ const url = sso ? `${base}${this.config.admin.path}/login` : `${base}${this.config.admin.path}/reset-password?token=${encodeURIComponent(token)}`;
978
1297
  const write = purpose === "invite" ? this.config.auth.emails.invite ?? DEFAULT_PASSWORD_EMAILS.invite : this.config.auth.emails.resetPassword ?? DEFAULT_PASSWORD_EMAILS.resetPassword;
979
1298
  const content = await write({
980
1299
  user: await this.toAuthUser(user),
981
1300
  url,
982
1301
  locale: this.localeOf(options.locale),
983
- expiresAt: new Date(expiresAt)
1302
+ ...sso ? { sso } : { expiresAt: new Date(expiresAt) }
984
1303
  });
985
1304
  await this.cms.sendEmail({ to: String(user.email), ...content });
986
1305
  }
@@ -1361,6 +1680,7 @@ async function runBackup(cms, record) {
1361
1680
  const filename = filenameFor(cms, started);
1362
1681
  const key2 = await storage.get(filename) ? filename.replace(/\.db\.gz$/, `-${row.id}.db.gz`) : filename;
1363
1682
  await storage.put(key2, data, { contentType: "application/gzip" });
1683
+ await pruneBackups(cms, 1);
1364
1684
  row = await update(cms, row, {
1365
1685
  state: "done",
1366
1686
  filename: key2,
@@ -1368,7 +1688,6 @@ async function runBackup(cms, record) {
1368
1688
  finishedAt: (/* @__PURE__ */ new Date()).toISOString()
1369
1689
  });
1370
1690
  cms.logger.info(`Backup ${key2} written (${data.byteLength} bytes)`);
1371
- await pruneBackups(cms);
1372
1691
  } catch (error) {
1373
1692
  await update(cms, row, {
1374
1693
  state: "failed",
@@ -1380,8 +1699,8 @@ async function runBackup(cms, record) {
1380
1699
  await rm2(dir, { recursive: true, force: true });
1381
1700
  }
1382
1701
  }
1383
- async function pruneBackups(cms) {
1384
- const keep = cms.config.backups?.keep ?? DEFAULT_BACKUP_KEEP;
1702
+ async function pruneBackups(cms, room = 0) {
1703
+ const keep = Math.max(0, (cms.config.backups?.keep ?? DEFAULT_BACKUP_KEEP) - room);
1385
1704
  const storage = await backupStorage(cms);
1386
1705
  const { docs } = await cms.db.find({
1387
1706
  collection: DATABASE_BACKUPS,
@@ -2313,6 +2632,7 @@ var VersionStore = class {
2313
2632
  // src/roles.ts
2314
2633
  var ROLE_OPERATIONS = ["read", "create", "update", "delete", "publish"];
2315
2634
  var GLOBAL_OPERATIONS = ["read", "update", "publish"];
2635
+ var OWN_OPERATIONS = ["read", "update", "delete", "publish"];
2316
2636
  var KEY = /^[a-z][a-z0-9-]{0,31}$/;
2317
2637
  var HISTORY = 50;
2318
2638
  var TTL = 3e4;
@@ -2328,6 +2648,9 @@ var rolesCollection = {
2328
2648
  function governed(slug2) {
2329
2649
  return !INTERNAL_COLLECTIONS.has(slug2) && slug2 !== API_KEYS;
2330
2650
  }
2651
+ function ruleFields(fields) {
2652
+ return fields.filter((f) => !f.hidden && f.name !== "createdBy");
2653
+ }
2331
2654
  function references(fields, out = /* @__PURE__ */ new Set()) {
2332
2655
  for (const field of fields) {
2333
2656
  if (field.type === "relationship") out.add(field.to);
@@ -2343,14 +2666,16 @@ var viewId = {
2343
2666
  };
2344
2667
  var tagOf = (component) => typeof component === "string" ? component : component.tag;
2345
2668
  var Roles = class {
2346
- constructor(config, db) {
2669
+ constructor(config, db, log) {
2347
2670
  this.config = config;
2348
2671
  this.db = db;
2672
+ this.log = log;
2349
2673
  this.enabled = config.auth.rbac;
2350
2674
  this.versions = new VersionStore(db);
2351
2675
  }
2352
2676
  config;
2353
2677
  db;
2678
+ log;
2354
2679
  enabled;
2355
2680
  versions;
2356
2681
  cache;
@@ -2364,7 +2689,13 @@ var Roles = class {
2364
2689
  if ("collection" in target && !governed(target.collection)) return true;
2365
2690
  const permissions = await this.permissionsOf(user.role);
2366
2691
  const ops = "collection" in target ? permissions.collections?.[target.collection] : permissions.globals?.[target.global];
2367
- if (ops?.includes(operation)) return true;
2692
+ if (ops?.includes(operation)) {
2693
+ if ("collection" in target && permissions.own?.[target.collection]?.includes(operation)) {
2694
+ const owner = this.ownerOf(target.collection);
2695
+ if (owner && operation !== "create") return { [owner]: { equals: user.id } };
2696
+ }
2697
+ return true;
2698
+ }
2368
2699
  if ("collection" in target && target.collection === USERS) {
2369
2700
  if (operation === "read" || operation === "update") return { id: { equals: user.id } };
2370
2701
  }
@@ -2380,6 +2711,76 @@ var Roles = class {
2380
2711
  if (!this.enabled) return id.startsWith("page:") || id.startsWith("widget:");
2381
2712
  return (await this.permissionsOf(user.role)).admin?.includes(id) ?? false;
2382
2713
  }
2714
+ /**
2715
+ * The field that names a collection's owner, for "own documents only": `admin.ownerField`, or
2716
+ * `createdBy`. `undefined` without roles, and for users and collections roles don't cover.
2717
+ */
2718
+ ownerOf(slug2) {
2719
+ if (!this.enabled || slug2 === USERS || !governed(slug2)) return void 0;
2720
+ const config = this.config.collections.find((c) => c.slug === slug2);
2721
+ if (!config) return void 0;
2722
+ return config.admin?.ownerField ?? "createdBy";
2723
+ }
2724
+ /**
2725
+ * The user's field rules (top-level fields their role may only read, or not see), for field
2726
+ * access checks. Roles that may update only their own documents can't change the owner field.
2727
+ */
2728
+ async fieldRules(user) {
2729
+ if (!this.enabled || !user || user.role === "admin") return void 0;
2730
+ await this.load();
2731
+ const cache = this.cache;
2732
+ const cached = cache?.rules.get(user.role);
2733
+ if (cached) return cached;
2734
+ const permissions = await this.permissionsOf(user.role);
2735
+ const rules = /* @__PURE__ */ new Map();
2736
+ const add = (fields, set) => {
2737
+ for (const field of fields) {
2738
+ const rule = set?.[field.name];
2739
+ if (rule === "read" || rule === "hidden") rules.set(field, rule);
2740
+ }
2741
+ };
2742
+ for (const c of this.config.collections) {
2743
+ if (!governed(c.slug)) continue;
2744
+ add(c.fields, permissions.fields?.collections?.[c.slug]);
2745
+ const ownerField = c.admin?.ownerField;
2746
+ if (ownerField && permissions.own?.[c.slug]?.includes("update")) {
2747
+ const field = c.fields.find((f) => f.name === ownerField);
2748
+ if (field && !rules.has(field)) rules.set(field, "read");
2749
+ }
2750
+ }
2751
+ for (const g of this.config.globals) add(g.fields, permissions.fields?.globals?.[g.slug]);
2752
+ cache?.rules.set(user.role, rules);
2753
+ return rules;
2754
+ }
2755
+ /** A new document is its creator's: sets `createdBy`, and an empty owner field. */
2756
+ fillOwner(slug2, data, user) {
2757
+ if (!user || !this.ownerOf(slug2)) return;
2758
+ data.createdBy ??= user.id;
2759
+ const ownerField = this.config.collections.find((c) => c.slug === slug2)?.admin?.ownerField;
2760
+ if (ownerField && (data[ownerField] === void 0 || data[ownerField] === null))
2761
+ data[ownerField] = user.id;
2762
+ }
2763
+ /** Documents a user owns, by collection (`createdBy` and owner fields), for deleting them. */
2764
+ async owned(userId) {
2765
+ const out = [];
2766
+ for (const c of this.config.collections) {
2767
+ const columns = this.ownerColumns(c.slug);
2768
+ if (columns.length === 0) continue;
2769
+ const count = await this.db.count({
2770
+ collection: c.slug,
2771
+ where: { or: columns.map((column) => ({ [column]: { equals: userId } })) }
2772
+ });
2773
+ if (count > 0) out.push({ collection: c.slug, count });
2774
+ }
2775
+ return out;
2776
+ }
2777
+ /** Gives a deleted user's documents to another user, or to nobody (`null`). */
2778
+ async transfer(from, to) {
2779
+ for (const c of this.config.collections) {
2780
+ for (const column of this.ownerColumns(c.slug))
2781
+ await this.setWhere(c.slug, { [column]: { equals: from } }, column, to);
2782
+ }
2783
+ }
2383
2784
  /** Forgets cached permissions, after a role changed. */
2384
2785
  invalidate() {
2385
2786
  this.cache = void 0;
@@ -2409,6 +2810,39 @@ var Roles = class {
2409
2810
  }
2410
2811
  }
2411
2812
  this.invalidate();
2813
+ await this.backfillCreators();
2814
+ }
2815
+ /**
2816
+ * Documents from before roles have no `createdBy`: it is filled from who saved their first
2817
+ * version, for collections with versions. Only while no document of the collection has one,
2818
+ * so it runs once (and finds nothing to do afterwards).
2819
+ */
2820
+ async backfillCreators() {
2821
+ for (const c of this.config.collections) {
2822
+ if (!c.versions || !this.ownerOf(c.slug)) continue;
2823
+ if (await this.db.count({ collection: c.slug, where: { createdBy: { exists: true } } }) > 0)
2824
+ continue;
2825
+ const versions = await this.db.find({
2826
+ collection: VERSIONS,
2827
+ where: { and: [{ parent: { equals: c.slug } }, { author: { exists: true } }] },
2828
+ sort: ["id"],
2829
+ limit: 0,
2830
+ page: 1
2831
+ });
2832
+ const first = /* @__PURE__ */ new Map();
2833
+ for (const v of versions.docs) {
2834
+ if (!first.has(v.doc) && v.author !== null) first.set(v.doc, v.author);
2835
+ }
2836
+ let filled = 0;
2837
+ for (const [doc, author] of first) {
2838
+ const row = await this.db.findById({ collection: c.slug, id: doc });
2839
+ if (!row || row.createdBy !== null && row.createdBy !== void 0) continue;
2840
+ const { id, ...rest } = row;
2841
+ await this.db.update({ collection: c.slug, id, data: { ...rest, createdBy: author } });
2842
+ filled++;
2843
+ }
2844
+ if (filled > 0) this.log?.(`Set who created ${filled} ${c.slug} from their history`);
2845
+ }
2412
2846
  }
2413
2847
  /** Whether a role exists (users can only be given existing roles). */
2414
2848
  async exists(key2) {
@@ -2437,16 +2871,24 @@ var Roles = class {
2437
2871
  const seen = (group, slug2) => decided.some((p) => p[group]?.[slug2] !== void 0);
2438
2872
  return {
2439
2873
  roles,
2440
- collections: this.config.collections.filter((c) => governed(c.slug)).map((c) => ({
2441
- slug: c.slug,
2442
- ops: ROLE_OPERATIONS.filter((op) => op !== "publish" || c.drafts === true),
2443
- new: !seen("collections", c.slug),
2444
- references: [...references(c.fields)].filter((s) => s !== c.slug && governed(s))
2445
- })),
2874
+ collections: this.config.collections.filter((c) => governed(c.slug)).map((c) => {
2875
+ const owner = this.ownerOf(c.slug);
2876
+ return {
2877
+ slug: c.slug,
2878
+ ops: ROLE_OPERATIONS.filter((op) => op !== "publish" || c.drafts === true),
2879
+ new: !seen("collections", c.slug),
2880
+ references: [...references(ruleFields(c.fields))].filter(
2881
+ (s) => s !== c.slug && governed(s)
2882
+ ),
2883
+ fields: adminFields(c.fields, c.admin?.ownerField),
2884
+ ...owner ? { owner } : {}
2885
+ };
2886
+ }),
2446
2887
  globals: this.config.globals.map((g) => ({
2447
2888
  slug: g.slug,
2448
2889
  ops: GLOBAL_OPERATIONS.filter((op) => op !== "publish" || g.drafts === true),
2449
- new: !seen("globals", g.slug)
2890
+ new: !seen("globals", g.slug),
2891
+ fields: adminFields(g.fields, void 0)
2450
2892
  })),
2451
2893
  views: this.views()
2452
2894
  };
@@ -2560,9 +3002,9 @@ var Roles = class {
2560
3002
  for (const [slug2, ops] of Object.entries(group)) {
2561
3003
  const valid = allowed2.get(slug2);
2562
3004
  if (!valid || !Array.isArray(ops)) continue;
2563
- const picked = valid.filter((op) => ops.includes(op));
2564
- if (picked.length > 0 && !picked.includes("read")) picked.unshift("read");
2565
- out[slug2] = picked;
3005
+ const picked2 = valid.filter((op) => ops.includes(op));
3006
+ if (picked2.length > 0 && !picked2.includes("read")) picked2.unshift("read");
3007
+ out[slug2] = picked2;
2566
3008
  }
2567
3009
  return out;
2568
3010
  };
@@ -2584,11 +3026,71 @@ var Roles = class {
2584
3026
  input.admin.filter((id) => typeof id === "string" && views.has(id))
2585
3027
  )
2586
3028
  ] : [];
2587
- return {
3029
+ const picked = {
2588
3030
  collections: pick(input.collections, collections),
2589
- globals: pick(input.globals, globals),
2590
- admin
3031
+ globals: pick(input.globals, globals)
3032
+ };
3033
+ const own = {};
3034
+ if (typeof input.own === "object" && input.own !== null) {
3035
+ for (const [slug2, ops] of Object.entries(input.own)) {
3036
+ const granted = picked.collections[slug2];
3037
+ if (!granted || !this.ownerOf(slug2) || !Array.isArray(ops)) continue;
3038
+ const valid = OWN_OPERATIONS.filter((op) => granted.includes(op) && ops.includes(op));
3039
+ if (valid.length > 0) own[slug2] = valid;
3040
+ }
3041
+ }
3042
+ const fields = {
3043
+ collections: this.cleanRules(
3044
+ input.fields?.collections,
3045
+ this.config.collections.filter((c) => governed(c.slug)),
3046
+ picked.collections
3047
+ ),
3048
+ globals: this.cleanRules(
3049
+ input.fields?.globals,
3050
+ this.config.globals,
3051
+ void 0
3052
+ )
2591
3053
  };
3054
+ return { ...picked, admin, own, fields };
3055
+ }
3056
+ /**
3057
+ * Keeps rules for known top-level fields. A field that must be filled in (required, without a
3058
+ * default) can't be read-only or hidden for a role that creates documents.
3059
+ */
3060
+ cleanRules(value, containers, granted) {
3061
+ const out = {};
3062
+ if (typeof value !== "object" || value === null) return out;
3063
+ for (const [slug2, rules] of Object.entries(value)) {
3064
+ const container = containers.find((c) => c.slug === slug2);
3065
+ if (!container || typeof rules !== "object" || rules === null) continue;
3066
+ const set = {};
3067
+ for (const field of ruleFields(container.fields)) {
3068
+ const rule = rules[field.name];
3069
+ if (rule !== "read" && rule !== "hidden") continue;
3070
+ if (granted?.[slug2]?.includes("create") && mustFill(field))
3071
+ throw new ValidationError(ROLES, [
3072
+ {
3073
+ field: `fields.${slug2}.${field.name}`,
3074
+ message: "is required: roles that create documents must be able to fill it"
3075
+ }
3076
+ ]);
3077
+ set[field.name] = rule;
3078
+ }
3079
+ if (Object.keys(set).length > 0) out[slug2] = set;
3080
+ }
3081
+ return out;
3082
+ }
3083
+ /** Fields that name a collection's owner (`createdBy`, and `admin.ownerField`). */
3084
+ ownerColumns(slug2) {
3085
+ if (!this.ownerOf(slug2)) return [];
3086
+ const ownerField = this.config.collections.find((c) => c.slug === slug2)?.admin?.ownerField;
3087
+ return ownerField ? ["createdBy", ownerField] : ["createdBy"];
3088
+ }
3089
+ /** Sets one field of every matching document, keeping the rest as stored. */
3090
+ async setWhere(collection2, where, field, value) {
3091
+ const { docs } = await this.db.find({ collection: collection2, where, sort: ["id"], limit: 0, page: 1 });
3092
+ for (const { id, ...rest } of docs)
3093
+ await this.db.update({ collection: collection2, id, data: { ...rest, [field]: value } });
2592
3094
  }
2593
3095
  cleanName(value) {
2594
3096
  if (value === void 0 || value === null) return "";
@@ -2607,7 +3109,7 @@ var Roles = class {
2607
3109
  const byKey = /* @__PURE__ */ new Map();
2608
3110
  for (const row of await this.rows())
2609
3111
  byKey.set(String(row.key), row.permissions ?? {});
2610
- this.cache = { at: Date.now(), byKey };
3112
+ this.cache = { at: Date.now(), byKey, rules: /* @__PURE__ */ new Map() };
2611
3113
  return byKey;
2612
3114
  }
2613
3115
  async rows() {
@@ -2655,13 +3157,24 @@ var Roles = class {
2655
3157
  };
2656
3158
  }
2657
3159
  };
3160
+ function mustFill(field) {
3161
+ return field.required === true && field.defaultValue === void 0;
3162
+ }
3163
+ function adminFields(fields, ownerField) {
3164
+ return ruleFields(fields).map((f) => ({
3165
+ name: f.name,
3166
+ ...f.label !== void 0 ? { label: f.label } : {},
3167
+ required: mustFill(f),
3168
+ ...f.name === ownerField ? { owner: true } : {}
3169
+ }));
3170
+ }
2658
3171
  function rank(role, config) {
2659
3172
  const index = config.auth.roles.indexOf(role.key);
2660
3173
  return index === -1 ? config.auth.roles.length : index;
2661
3174
  }
2662
3175
 
2663
3176
  // src/webhooks.ts
2664
- import { createHmac as createHmac2, randomUUID as randomUUID2 } from "crypto";
3177
+ import { createHmac as createHmac3, randomUUID as randomUUID2 } from "crypto";
2665
3178
  var WEBHOOK_EVENTS = [
2666
3179
  "create",
2667
3180
  "update",
@@ -2805,7 +3318,7 @@ var Webhooks = class {
2805
3318
  ...hook.headers
2806
3319
  };
2807
3320
  if (hook.secret) {
2808
- headers["x-easy-cms-signature"] = `sha256=${createHmac2("sha256", hook.secret).update(body).digest("hex")}`;
3321
+ headers["x-easy-cms-signature"] = `sha256=${createHmac3("sha256", hook.secret).update(body).digest("hex")}`;
2809
3322
  }
2810
3323
  try {
2811
3324
  const response = await this.fetcher(hook.url, {
@@ -2852,8 +3365,10 @@ var RESERVED_SLUGS = /* @__PURE__ */ new Set([
2852
3365
  "email-deliveries",
2853
3366
  "database-backups",
2854
3367
  "user-roles",
3368
+ "user-identities",
2855
3369
  "api-keys",
2856
3370
  "jobs",
3371
+ "auth",
2857
3372
  "migrations",
2858
3373
  "access"
2859
3374
  ]);
@@ -2908,7 +3423,10 @@ function validateConfig(config) {
2908
3423
  if (apiKeys !== void 0 && typeof apiKeys !== "boolean") add("apiKeys", "must be true or false");
2909
3424
  collections.forEach((collection2, i) => {
2910
3425
  const path = `collections.${collection2.slug ?? `[${i}]`}`;
2911
- validateContainer(collection2, path, collectionSlugs, add);
3426
+ validateContainer(collection2, path, collectionSlugs, add, {
3427
+ collection: true,
3428
+ rbac: config.auth?.rbac === true
3429
+ });
2912
3430
  validateUseAsTitle(collection2, path, add);
2913
3431
  validateIcon(collection2, path, add);
2914
3432
  const editIn = collection2.editIn;
@@ -2917,7 +3435,10 @@ function validateConfig(config) {
2917
3435
  });
2918
3436
  globals.forEach((global2, i) => {
2919
3437
  const path = `globals.${global2.slug ?? `[${i}]`}`;
2920
- validateContainer(global2, path, collectionSlugs, add);
3438
+ validateContainer(global2, path, collectionSlugs, add, {
3439
+ collection: false,
3440
+ rbac: config.auth?.rbac === true
3441
+ });
2921
3442
  validateIcon(global2, path, add);
2922
3443
  });
2923
3444
  const plugins = config.plugins;
@@ -3095,6 +3616,52 @@ function validateAuth(config, add) {
3095
3616
  if (auth === void 0) return;
3096
3617
  if (auth.rbac !== void 0 && typeof auth.rbac !== "boolean")
3097
3618
  add("auth.rbac", "must be true or false");
3619
+ if (auth.password !== void 0 && typeof auth.password !== "boolean")
3620
+ add("auth.password", "must be true or false");
3621
+ const providers = auth.providers;
3622
+ if (providers !== void 0) {
3623
+ if (!Array.isArray(providers)) {
3624
+ add(
3625
+ "auth.providers",
3626
+ "must be an array",
3627
+ "e.g. providers: [google({ clientId, clientSecret })]"
3628
+ );
3629
+ } else {
3630
+ const ids = /* @__PURE__ */ new Set();
3631
+ providers.forEach((provider, i) => {
3632
+ const p = provider;
3633
+ const path = `auth.providers[${i}]`;
3634
+ if (typeof p !== "object" || p === null || typeof p.authorizationURL !== "function" || typeof p.callback !== "function") {
3635
+ add(
3636
+ path,
3637
+ "must be a provider",
3638
+ "e.g. google({ clientId, clientSecret }) from @easy-cms/auth-oauth"
3639
+ );
3640
+ return;
3641
+ }
3642
+ if (typeof p.id !== "string" || !PAGE_PATH.test(p.id))
3643
+ add(`${path}.id`, 'must be lowercase letters, digits and "-"');
3644
+ else if (ids.has(p.id))
3645
+ add(`${path}.id`, `"${p.id}" is used by another provider`, "give one of them its own id");
3646
+ else ids.add(p.id);
3647
+ if (typeof p.name !== "string" || p.name.trim() === "")
3648
+ add(`${path}.name`, "must be a name");
3649
+ });
3650
+ }
3651
+ }
3652
+ const signUp = auth.allowSignUp;
3653
+ if (signUp !== void 0) {
3654
+ if (typeof signUp !== "object" || signUp === null || !Array.isArray(signUp.domains) || signUp.domains.some((d) => typeof d !== "string" || !/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(d)))
3655
+ add("auth.allowSignUp.domains", "must be email domains", "e.g. domains: ['example.com']");
3656
+ if (signUp?.role === "admin")
3657
+ add("auth.allowSignUp.role", `can't be "admin"`, "make admins by hand, in Settings \u2192 Users");
3658
+ else if (signUp?.role !== void 0 && (typeof signUp.role !== "string" || !auth.rbac && !(auth.roles ?? ["admin", "editor"]).includes(signUp.role)))
3659
+ add("auth.allowSignUp.role", "must be one of auth.roles");
3660
+ if (!Array.isArray(providers) || providers.length === 0)
3661
+ add("auth.allowSignUp", "needs auth.providers");
3662
+ }
3663
+ if (auth.password === false && (!Array.isArray(providers) || providers.length === 0))
3664
+ add("auth.password", "false needs auth.providers", "otherwise only admins could sign in");
3098
3665
  if (auth.roles !== void 0) {
3099
3666
  const roles = auth.roles;
3100
3667
  if (!Array.isArray(roles) || roles.some((r) => typeof r !== "string" || r === "")) {
@@ -3361,7 +3928,7 @@ function checkSlug(container, path, seen, add) {
3361
3928
  seen.add(slug2);
3362
3929
  return slug2;
3363
3930
  }
3364
- function validateContainer(container, path, collectionSlugs, add) {
3931
+ function validateContainer(container, path, collectionSlugs, add, { collection: collection2, rbac }) {
3365
3932
  if (!Array.isArray(container.fields)) {
3366
3933
  add(`${path}.fields`, "must be an array");
3367
3934
  return;
@@ -3398,8 +3965,25 @@ function validateContainer(container, path, collectionSlugs, add) {
3398
3965
  add(`${path}.admin.group`, `must be 'settings' (got ${JSON.stringify(group)})`);
3399
3966
  const list = admin.list;
3400
3967
  if (list !== void 0) validateList(list, container, `${path}.admin.list`, add);
3968
+ const owner = admin.ownerField;
3969
+ if (owner !== void 0) {
3970
+ const field = container.fields.find((f) => f.name === owner);
3971
+ if (!collection2 || container.slug === "users" || field?.type !== "relationship" || field.to !== "users" || field.hasMany)
3972
+ add(
3973
+ `${path}.admin.ownerField`,
3974
+ 'must name a relationship field to "users" (not hasMany) of a collection',
3975
+ "e.g. { name: 'author', type: 'relationship', to: 'users' }"
3976
+ );
3977
+ }
3401
3978
  }
3402
3979
  }
3980
+ const createdBy = container.fields.find((f) => f.name === CREATED_BY_FIELD.name);
3981
+ if (rbac && createdBy && createdBy !== CREATED_BY_FIELD)
3982
+ add(
3983
+ `${path}.fields.createdBy`,
3984
+ '"createdBy" is added by Easy CMS with auth.rbac',
3985
+ "rename the field; to use it as the owner, set admin.ownerField"
3986
+ );
3403
3987
  const reserved = new Set(SYSTEM_FIELD_NAMES);
3404
3988
  if (container.drafts) reserved.add("status");
3405
3989
  validateFields2(container.fields, `${path}.fields`, reserved, collectionSlugs, add);
@@ -3604,7 +4188,15 @@ function validateComponents(components, path, add) {
3604
4188
  }
3605
4189
  var ENDPOINT_METHODS = ["get", "post", "put", "patch", "delete"];
3606
4190
  var ENDPOINT_SEGMENT = /^(:[A-Za-z_][A-Za-z0-9_]*|[A-Za-z0-9._~-]+)$/;
3607
- var RESERVED_ENDPOINT_ROOTS = /* @__PURE__ */ new Set(["users", "globals", "admin", "jobs", "media", "api-keys"]);
4191
+ var RESERVED_ENDPOINT_ROOTS = /* @__PURE__ */ new Set([
4192
+ "users",
4193
+ "globals",
4194
+ "admin",
4195
+ "jobs",
4196
+ "media",
4197
+ "api-keys",
4198
+ "auth"
4199
+ ]);
3608
4200
  function validateCommands(config, add) {
3609
4201
  const commands = config.commands;
3610
4202
  if (commands === void 0) return;
@@ -3753,7 +4345,7 @@ async function resolveConfig(input) {
3753
4345
  if (resolved.has(input)) return input;
3754
4346
  let config = await applyPlugins(input);
3755
4347
  if (typeof config === "object" && config !== null && Array.isArray(config.collections ?? [])) {
3756
- config = withMedia(withUsers(config));
4348
+ config = withCreatedBy(withMedia(withUsers(config)));
3757
4349
  }
3758
4350
  const applied = applyFieldTypes(config);
3759
4351
  config = applied.config;
@@ -3790,6 +4382,9 @@ async function resolveConfig(input) {
3790
4382
  auth: {
3791
4383
  roles: config.auth?.roles ?? DEFAULT_ROLES,
3792
4384
  rbac: config.auth?.rbac === true,
4385
+ providers: config.auth?.providers ?? [],
4386
+ allowSignUp: config.auth?.allowSignUp ?? { domains: [] },
4387
+ password: config.auth?.password !== false,
3793
4388
  tokenExpiration: config.auth?.tokenExpiration ?? DEFAULT_TOKEN_EXPIRATION,
3794
4389
  maxLoginAttempts: config.auth?.maxLoginAttempts ?? 5,
3795
4390
  lockWindow: config.auth?.lockWindow ?? 15 * 60,
@@ -3808,7 +4403,8 @@ async function resolveConfig(input) {
3808
4403
  ...config.apiKeys ? [apiKeysCollection] : [],
3809
4404
  ...config.email ? [emailDeliveriesCollection] : [],
3810
4405
  databaseBackupsCollection,
3811
- ...config.auth?.rbac ? [rolesCollection] : []
4406
+ ...config.auth?.rbac ? [rolesCollection] : [],
4407
+ ...(config.auth?.providers?.length ?? 0) > 0 ? [userIdentitiesCollection] : []
3812
4408
  ],
3813
4409
  globals: config.globals ?? [],
3814
4410
  endpoints: config.endpoints ?? [],
@@ -3874,7 +4470,7 @@ async function loadConfig(options = {}) {
3874
4470
  }
3875
4471
 
3876
4472
  // src/local-api.ts
3877
- import { randomBytes as randomBytes4 } from "crypto";
4473
+ import { randomBytes as randomBytes5 } from "crypto";
3878
4474
 
3879
4475
  // src/localization.ts
3880
4476
  var ALL_LOCALES = "all";
@@ -4071,10 +4667,10 @@ function imageDimensions(data, type) {
4071
4667
  }
4072
4668
  return void 0;
4073
4669
  }
4074
- function storageKey(originalName, type, random) {
4670
+ function storageKey(originalName, type, random2) {
4075
4671
  const name = originalName.split(/[\\/]/).pop() ?? "";
4076
4672
  const base = name.replace(/(?<=.)\.[^.]*$/, "").normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{M}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "file";
4077
- return `${base}-${random}.${EXTENSIONS[type] ?? "bin"}`;
4673
+ return `${base}-${random2}.${EXTENSIONS[type] ?? "bin"}`;
4078
4674
  }
4079
4675
 
4080
4676
  // src/populate.ts
@@ -4402,7 +4998,7 @@ var EasyCMS = class {
4402
4998
  this.storage = storage;
4403
4999
  this.cwd = cwd;
4404
5000
  this.versions = new VersionStore(db);
4405
- this.roles = new Roles(config, db);
5001
+ this.roles = new Roles(config, db, (message2) => logger.info(message2));
4406
5002
  this.webhooks = new Webhooks(
4407
5003
  config.webhooks ?? [],
4408
5004
  logger,
@@ -4429,6 +5025,7 @@ var EasyCMS = class {
4429
5025
  options
4430
5026
  );
4431
5027
  const requested = options.sort === void 0 ? ["-createdAt"] : [options.sort].flat();
5028
+ await this.checkQueryFields(config, guard, void 0, requested);
4432
5029
  const locale = this.localeOf(options);
4433
5030
  const sort = locale === void 0 ? requested : localizeSort(
4434
5031
  config.fields,
@@ -4512,8 +5109,8 @@ var EasyCMS = class {
4512
5109
  }
4513
5110
  ]);
4514
5111
  }
4515
- const random = randomBytes4(4).toString("hex");
4516
- const filename = storageKey(file.name, mimeType, random);
5112
+ const random2 = randomBytes5(4).toString("hex");
5113
+ const filename = storageKey(file.name, mimeType, random2);
4517
5114
  const stored = [];
4518
5115
  try {
4519
5116
  await this.storage.put(filename, file.data, { contentType: mimeType });
@@ -4723,7 +5320,7 @@ var EasyCMS = class {
4723
5320
  const filtered = await filterInput(
4724
5321
  config.fields,
4725
5322
  input,
4726
- this.fieldChecker("update", guard, parsed, input)
5323
+ await this.fieldChecker("update", guard, parsed, input)
4727
5324
  );
4728
5325
  const localized = this.toMaps(config, filtered, base ?? {}, options);
4729
5326
  const merged = base ? mergeForUpdate(config.fields, base, localized) : applyDefaults(config.fields, localized, this.config.localization);
@@ -4763,7 +5360,7 @@ var EasyCMS = class {
4763
5360
  const input = await filterInput(
4764
5361
  config.fields,
4765
5362
  raw,
4766
- this.fieldChecker("update", guard, void 0, raw)
5363
+ await this.fieldChecker("update", guard, void 0, raw)
4767
5364
  );
4768
5365
  const saved = await this.db.findGlobal({ slug: slug2 }) ?? {};
4769
5366
  const current = await this.globalDraft(config, saved);
@@ -4828,7 +5425,7 @@ var EasyCMS = class {
4828
5425
  if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
4829
5426
  await this.checkDocumentAccess(config, "update", guard, parsed, raw);
4830
5427
  if (config.drafts && (mode === "unpublish" || raw.status === "published"))
4831
- await this.checkGrant(guard, { collection: collection2 }, "publish");
5428
+ await this.checkDocumentGrant(guard, collection2, "publish", parsed);
4832
5429
  const [drafted] = await this.withDrafts(config, [existing]);
4833
5430
  const live = options.live === true && mode === "save";
4834
5431
  const current = live ? existing : drafted;
@@ -4840,7 +5437,7 @@ var EasyCMS = class {
4840
5437
  const filtered = await filterInput(
4841
5438
  config.fields,
4842
5439
  input,
4843
- this.fieldChecker("update", guard, parsed, input)
5440
+ await this.fieldChecker("update", guard, parsed, input)
4844
5441
  );
4845
5442
  const localized = mode === "restore" ? filtered : this.toMaps(config, filtered, current, options);
4846
5443
  let merged = mergeForUpdate(config.fields, current, localized);
@@ -4908,6 +5505,10 @@ var EasyCMS = class {
4908
5505
  const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
4909
5506
  return out;
4910
5507
  }
5508
+ /**
5509
+ * Deletes a document. Deleting a user with roles from the admin (`auth.rbac`): `transferTo`
5510
+ * gives the documents they own to another user; without it they have no owner.
5511
+ */
4911
5512
  async delete(collection2, id, options = {}) {
4912
5513
  const config = this.collection(collection2);
4913
5514
  const guard = guardOf(options);
@@ -4919,7 +5520,20 @@ var EasyCMS = class {
4919
5520
  await this.guardLastAdmin(parsed, existing, { ...existing, active: false });
4920
5521
  const base = this.hookArgs(config, guard);
4921
5522
  for (const hook of config.hooks?.beforeDelete ?? []) await hook({ ...base, id: parsed });
4922
- if (config.slug === USERS) await this.auth.revokeSessions(parsed);
5523
+ if (config.slug === USERS) {
5524
+ const to = options.transferTo;
5525
+ if (to !== void 0 && to !== null) {
5526
+ const target = parseId(to);
5527
+ const heir = target === void 0 ? null : await this.db.findById({ collection: USERS, id: target });
5528
+ if (!heir || String(heir.id) === String(parsed))
5529
+ throw new ValidationError(USERS, [
5530
+ { field: "transferTo", message: "must be another existing user" }
5531
+ ]);
5532
+ await this.roles.transfer(parsed, heir.id);
5533
+ } else await this.roles.transfer(parsed, null);
5534
+ await this.auth.revokeSessions(parsed);
5535
+ await this.auth.sso.forget(parsed);
5536
+ }
4923
5537
  await this.db.delete({ collection: collection2, id: parsed });
4924
5538
  if (versionLimit(config)) await this.versions.deleteAll(collectionParent(collection2), parsed);
4925
5539
  if (config.schedule) {
@@ -5027,7 +5641,7 @@ var EasyCMS = class {
5027
5641
  const input = await filterInput(
5028
5642
  config.fields,
5029
5643
  raw,
5030
- this.fieldChecker("update", guard, void 0, raw)
5644
+ await this.fieldChecker("update", guard, void 0, raw)
5031
5645
  );
5032
5646
  const existing = await this.db.findGlobal({ slug: slug2 }) ?? {};
5033
5647
  const current = await this.globalDraft(config, existing);
@@ -5312,7 +5926,7 @@ var EasyCMS = class {
5312
5926
  const existing = parsed === void 0 ? null : await this.db.findById({ collection: collection2, id: parsed });
5313
5927
  if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
5314
5928
  await this.checkDocumentAccess(config, "update", guardOf(options), parsed, void 0);
5315
- await this.checkGrant(guardOf(options), { collection: collection2 }, "publish");
5929
+ await this.checkDocumentGrant(guardOf(options), collection2, "publish", parsed);
5316
5930
  return { config, parsed };
5317
5931
  }
5318
5932
  async globalScheduleTarget(slug2, options) {
@@ -5483,6 +6097,7 @@ var EasyCMS = class {
5483
6097
  const own = await this.checkGrant(guard, { collection: config.slug }, "read");
5484
6098
  const access = await evaluateAccess(config.access?.read, { user: guard.user });
5485
6099
  if (access === false) throw deny(guard.user);
6100
+ await this.checkQueryFields(config, guard, where);
5486
6101
  return andWhere(andWhere(where, access), own);
5487
6102
  }
5488
6103
  async checkDocumentAccess(config, operation, guard, id, data) {
@@ -5512,17 +6127,52 @@ var EasyCMS = class {
5512
6127
  throw new QueryError(`${operation} access of global "${config.slug}" must return a boolean`);
5513
6128
  if (!access) throw deny(guard.user);
5514
6129
  }
5515
- fieldChecker(kind, guard, id, data) {
6130
+ async fieldChecker(kind, guard, id, data) {
5516
6131
  if (!guard.enforce) return void 0;
5517
- return new FieldAccessChecker(kind, {
5518
- user: guard.user,
5519
- ...id !== void 0 ? { id } : {},
5520
- ...data ? { data } : {}
6132
+ return new FieldAccessChecker(
6133
+ kind,
6134
+ {
6135
+ user: guard.user,
6136
+ ...id !== void 0 ? { id } : {},
6137
+ ...data ? { data } : {}
6138
+ },
6139
+ await this.roles.fieldRules(guard.user)
6140
+ );
6141
+ }
6142
+ /**
6143
+ * Refuses filtering or sorting by fields the caller may not read: the results would tell
6144
+ * their values. Checks each field along a dotted path, through groups, arrays and blocks.
6145
+ */
6146
+ async checkQueryFields(config, guard, where, sort = []) {
6147
+ if (!guard.enforce) return;
6148
+ const paths = [...wherePaths(where), ...sort.map((s) => s.replace(/^-/, ""))];
6149
+ if (paths.length === 0) return;
6150
+ const read2 = await this.fieldChecker("read", guard, void 0, void 0);
6151
+ for (const path of paths) {
6152
+ let fields = config.fields;
6153
+ for (const name of path.split(".")) {
6154
+ if (!fields) break;
6155
+ const field = fields.find((f) => f.name === name);
6156
+ if (!field) break;
6157
+ if (field.hidden || read2 && !await read2.allows(field))
6158
+ throw new ForbiddenError(`You may not filter or sort by "${path}"`);
6159
+ fields = field.type === "group" || field.type === "array" ? field.fields : field.type === "blocks" ? field.blocks.flatMap((b) => b.fields) : void 0;
6160
+ }
6161
+ }
6162
+ }
6163
+ /** `checkGrant` for one document: a role limited to its own documents must own it. */
6164
+ async checkDocumentGrant(guard, collection2, operation, id) {
6165
+ const own = await this.checkGrant(guard, { collection: collection2 }, operation);
6166
+ if (own === true) return;
6167
+ const matches2 = await this.db.count({
6168
+ collection: collection2,
6169
+ where: andWhere(own, { id: { equals: id } })
5521
6170
  });
6171
+ if (matches2 === 0) throw deny(guard.user);
5522
6172
  }
5523
6173
  /** Runs afterRead hooks, populates relationships and removes what the caller may not see. */
5524
6174
  async output(config, docs, guard, options) {
5525
- const read2 = this.fieldChecker("read", guard, void 0, void 0);
6175
+ const read2 = await this.fieldChecker("read", guard, void 0, void 0);
5526
6176
  const finish = async (target, doc) => {
5527
6177
  const hooked = await this.transform(
5528
6178
  target.hooks?.afterRead,
@@ -5572,8 +6222,9 @@ var EasyCMS = class {
5572
6222
  const filtered = await filterInput(
5573
6223
  config.fields,
5574
6224
  input,
5575
- this.fieldChecker("update", guard, void 0, input)
6225
+ await this.fieldChecker("update", guard, void 0, input)
5576
6226
  );
6227
+ this.roles.fillOwner(collection2, filtered, guard.user ?? hookGuard?.user ?? null);
5577
6228
  const base = this.hookArgs(config, hookGuard ?? guard);
5578
6229
  let data = applyDefaults(
5579
6230
  config.fields,
@@ -5944,6 +6595,16 @@ function loadSharp() {
5944
6595
  );
5945
6596
  return sharpModule;
5946
6597
  }
6598
+ function wherePaths(where) {
6599
+ if (!where) return [];
6600
+ const out = [];
6601
+ for (const [key2, value] of Object.entries(where)) {
6602
+ if (key2 === "and" || key2 === "or") {
6603
+ for (const sub of value ?? []) out.push(...wherePaths(sub));
6604
+ } else out.push(key2);
6605
+ }
6606
+ return out;
6607
+ }
5947
6608
  function guardOf(options) {
5948
6609
  return { enforce: options.overrideAccess === false, user: options.user ?? null };
5949
6610
  }
@@ -6215,10 +6876,11 @@ function componentRef(component) {
6215
6876
  async function allowed(access, user) {
6216
6877
  return await evaluateAccess(access, { user }) !== false;
6217
6878
  }
6218
- async function serializeFields(fields, update2, localized) {
6879
+ async function serializeFields(fields, checks, localized) {
6880
+ const { update: update2 } = checks;
6219
6881
  const out = [];
6220
6882
  for (const field of fields) {
6221
- if (field.hidden) continue;
6883
+ if (field.hidden || !await checks.read.allows(field)) continue;
6222
6884
  const f = { name: field.name, type: field.type };
6223
6885
  if (field.label !== void 0) f.label = field.label;
6224
6886
  if (field.required) f.required = true;
@@ -6271,7 +6933,7 @@ async function serializeFields(fields, update2, localized) {
6271
6933
  case "array":
6272
6934
  if (field.minRows !== void 0) f.minRows = field.minRows;
6273
6935
  if (field.maxRows !== void 0) f.maxRows = field.maxRows;
6274
- f.fields = await serializeFields(field.fields, update2, localized);
6936
+ f.fields = await serializeFields(field.fields, checks, localized);
6275
6937
  break;
6276
6938
  case "blocks":
6277
6939
  if (field.minRows !== void 0) f.minRows = field.minRows;
@@ -6280,28 +6942,31 @@ async function serializeFields(fields, update2, localized) {
6280
6942
  field.blocks.map(async (block) => ({
6281
6943
  slug: block.slug,
6282
6944
  ...block.labels ? { labels: block.labels } : {},
6283
- fields: await serializeFields(block.fields, update2, localized)
6945
+ fields: await serializeFields(block.fields, checks, localized)
6284
6946
  }))
6285
6947
  );
6286
6948
  break;
6287
6949
  case "group":
6288
- f.fields = await serializeFields(field.fields, update2, localized);
6950
+ f.fields = await serializeFields(field.fields, checks, localized);
6289
6951
  break;
6290
6952
  }
6291
6953
  out.push(f);
6292
6954
  }
6293
6955
  return out;
6294
6956
  }
6957
+ async function checkers(cms, user) {
6958
+ const rules = await cms.roles.fieldRules(user);
6959
+ return {
6960
+ update: new FieldAccessChecker("update", { user }, rules),
6961
+ read: new FieldAccessChecker("read", { user }, rules)
6962
+ };
6963
+ }
6295
6964
  async function may(cms, access, user, target, operation) {
6296
6965
  return await allowed(access, user) && await cms.roles.allows(user, target, operation) !== false;
6297
6966
  }
6298
6967
  async function collection(cms, config, user, localized, roles) {
6299
6968
  const target = { collection: config.slug };
6300
- const fields = await serializeFields(
6301
- config.fields,
6302
- new FieldAccessChecker("update", { user }),
6303
- localized
6304
- );
6969
+ const fields = await serializeFields(config.fields, await checkers(cms, user), localized);
6305
6970
  if (config.slug === USERS && cms.roles.enabled) {
6306
6971
  const role = fields.find((f) => f.name === "role");
6307
6972
  if (role) {
@@ -6331,23 +6996,22 @@ async function collection(cms, config, user, localized, roles) {
6331
6996
  if (config.admin?.group === "settings" || SETTINGS.has(config.slug)) result.group = "settings";
6332
6997
  if (config.admin?.list?.tree) result.tree = config.admin.list.tree;
6333
6998
  if (config.admin?.list?.sort) result.defaultSort = config.admin.list.sort;
6999
+ const owner = cms.roles.ownerOf(config.slug);
7000
+ if (owner && fields.some((f) => f.name === owner)) result.owner = owner;
6334
7001
  if (config.editIn === "drawer" && !result.drafts && !result.versions && !result.preview)
6335
7002
  result.editIn = "drawer";
6336
7003
  return result;
6337
7004
  }
6338
7005
  async function global(cms, config, user, localized) {
6339
7006
  const target = { global: config.slug };
7007
+ const checks = await checkers(cms, user);
6340
7008
  const result = {
6341
7009
  slug: config.slug,
6342
7010
  drafts: config.drafts === true,
6343
7011
  versions: Boolean(config.versions),
6344
7012
  preview: typeof config.preview === "function",
6345
7013
  schedule: config.schedule === true,
6346
- fields: await serializeFields(
6347
- config.fields,
6348
- new FieldAccessChecker("update", { user }),
6349
- localized
6350
- ),
7014
+ fields: await serializeFields(config.fields, checks, localized),
6351
7015
  permissions: {
6352
7016
  read: await may(cms, config.access?.read, user, target, "read"),
6353
7017
  update: await may(cms, config.access?.update, user, target, "update"),
@@ -6372,7 +7036,8 @@ async function adminSchema(cms, user, origin) {
6372
7036
  deliveries: deliveries && await cms.roles.canView(user, "deliveries"),
6373
7037
  backups: admin,
6374
7038
  email: admin,
6375
- roles: admin && cms.roles.enabled
7039
+ roles: admin && cms.roles.enabled,
7040
+ sso: admin && cms.auth.sso.enabled
6376
7041
  };
6377
7042
  const roles = await cms.roles.options();
6378
7043
  return {
@@ -6387,6 +7052,8 @@ async function adminSchema(cms, user, origin) {
6387
7052
  modules: adminModuleUrls(cms),
6388
7053
  uploadFromURL: cms.config.upload.fromURL !== void 0,
6389
7054
  views,
7055
+ rbac: cms.roles.enabled,
7056
+ providers: cms.auth.sso.providers(),
6390
7057
  ...views.deliveries ? {
6391
7058
  deliveries: {
6392
7059
  webhook: cms.config.collections.some((c) => c.slug === WEBHOOK_DELIVERIES),
@@ -6432,7 +7099,7 @@ async function widgets(cms, user) {
6432
7099
  }
6433
7100
 
6434
7101
  // src/version.ts
6435
- var VERSION = true ? "0.33.0" : "0.0.0-dev";
7102
+ var VERSION = true ? "0.35.0" : "0.0.0-dev";
6436
7103
 
6437
7104
  // src/rest/admin-status.ts
6438
7105
  var DAY = 864e5;
@@ -6782,6 +7449,7 @@ async function route(cms, ctx, method, segments, options) {
6782
7449
  const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
6783
7450
  const draft = ctx.user !== null && ctx.url.searchParams.get("draft") === "true";
6784
7451
  if (segments.length === 0) throw new HttpError("Not found", 404);
7452
+ if (first === "auth") return ssoRoute(cms, ctx, method, segments);
6785
7453
  if (first === USERS && second !== void 0 && third === void 0 && !/^\d+$/.test(second)) {
6786
7454
  switch (`${method} ${second}`) {
6787
7455
  case "POST login": {
@@ -6812,7 +7480,10 @@ async function route(cms, ctx, method, segments, options) {
6812
7480
  body: {
6813
7481
  hasUsers: await cms.auth.hasUsers(),
6814
7482
  // The login page offers "Forgot password?" only when the email can be sent.
6815
- passwordReset: cms.auth.canSendPasswordLinks(ctx.url.origin)
7483
+ passwordReset: cms.auth.canSendPasswordLinks(ctx.url.origin),
7484
+ // Buttons to sign in with outside accounts; `password: false`: only admins use one.
7485
+ providers: cms.auth.sso.providers(),
7486
+ password: cms.config.auth.password
6816
7487
  }
6817
7488
  };
6818
7489
  // Forgot password: the same answer whether or not the email has an account.
@@ -6923,6 +7594,20 @@ async function route(cms, ctx, method, segments, options) {
6923
7594
  }
6924
7595
  throw new HttpError("Not found", 404);
6925
7596
  }
7597
+ if (first === "admin" && second === "sso" && third === void 0) {
7598
+ if (!ctx.user) throw new UnauthorizedError();
7599
+ if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
7600
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
7601
+ return { body: cms.auth.sso.settings(ctx.url.origin) };
7602
+ }
7603
+ if (first === "admin" && second === "owned" && third !== void 0 && segments.length === 3) {
7604
+ if (!ctx.user) throw new UnauthorizedError();
7605
+ if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
7606
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
7607
+ const parsed = parseId(third);
7608
+ if (parsed === void 0) throw new HttpError("Not found", 404);
7609
+ return { body: await cms.roles.owned(parsed) };
7610
+ }
6926
7611
  if (first === "admin" && second === "roles") {
6927
7612
  if (!ctx.user) throw new UnauthorizedError();
6928
7613
  if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
@@ -7127,7 +7812,9 @@ async function route(cms, ctx, method, segments, options) {
7127
7812
  return { body: doc };
7128
7813
  }
7129
7814
  if (method === "DELETE") {
7130
- return { body: await cms.delete(collection2, id, access) };
7815
+ const to = collection2 === USERS ? ctx.url.searchParams.get("transferTo") : null;
7816
+ const transferTo = to === null ? {} : { transferTo: to === "none" ? null : parseId(to) ?? to };
7817
+ return { body: await cms.delete(collection2, id, { ...access, ...transferTo }) };
7131
7818
  }
7132
7819
  throw methodNotAllowed(ctx, "GET, PATCH, DELETE");
7133
7820
  }
@@ -7386,6 +8073,67 @@ function cookie(ctx, name, value, opts) {
7386
8073
  if (opts.maxAge !== void 0) parts.push(`Max-Age=${opts.maxAge}`);
7387
8074
  return parts.join("; ");
7388
8075
  }
8076
+ async function ssoRoute(cms, ctx, method, segments) {
8077
+ const sso = cms.auth.sso;
8078
+ if (!sso.enabled) throw new HttpError("Not found", 404);
8079
+ const [, second, third, extra] = segments;
8080
+ if (extra !== void 0 || second === void 0) throw new HttpError("Not found", 404);
8081
+ if (second === "identities") {
8082
+ if (!ctx.user) throw new UnauthorizedError();
8083
+ if (third === void 0) {
8084
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
8085
+ const asked = ctx.url.searchParams.get("user");
8086
+ const target = asked === null ? ctx.user.id : parseId(asked) ?? asked;
8087
+ if (String(target) !== String(ctx.user.id) && (ctx.user.role !== "admin" || ctx.user.apiKey))
8088
+ throw new ForbiddenError();
8089
+ return { body: await sso.identities(target) };
8090
+ }
8091
+ if (method !== "DELETE") throw methodNotAllowed(ctx, "DELETE");
8092
+ const id = parseId(third);
8093
+ if (id === void 0) throw new HttpError("Not found", 404);
8094
+ await sso.unlink(ctx.user, id);
8095
+ return { body: { deleted: 1 } };
8096
+ }
8097
+ const pendingCookie = (value, maxAge) => cookie(ctx, SSO_COOKIE, value, { httpOnly: true, maxAge });
8098
+ if (third === "login") {
8099
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
8100
+ const started = await sso.start(second, {
8101
+ origin: ctx.url.origin,
8102
+ redirect: ctx.url.searchParams.get("redirect")
8103
+ });
8104
+ ctx.headers.append("set-cookie", pendingCookie(started.cookie, 600));
8105
+ return { body: redirectResponse(ctx, started.url) };
8106
+ }
8107
+ if (third === "link") {
8108
+ if (method !== "POST") throw methodNotAllowed(ctx, "POST");
8109
+ if (!ctx.user || ctx.user.apiKey) throw new UnauthorizedError();
8110
+ const started = await sso.start(second, {
8111
+ origin: ctx.url.origin,
8112
+ redirect: null,
8113
+ user: ctx.user
8114
+ });
8115
+ ctx.headers.append("set-cookie", pendingCookie(started.cookie, 600));
8116
+ return { body: { url: started.url } };
8117
+ }
8118
+ if (third === "callback") {
8119
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
8120
+ const finished = await sso.finish(second, {
8121
+ url: ctx.url,
8122
+ cookie: readCookie(ctx.request, SSO_COOKIE),
8123
+ origin: ctx.url.origin,
8124
+ user: ctx.user
8125
+ });
8126
+ ctx.headers.append("set-cookie", pendingCookie("", 0));
8127
+ if (finished.session) setSessionCookies(cms, ctx, finished.session);
8128
+ return { body: redirectResponse(ctx, finished.redirect) };
8129
+ }
8130
+ throw new HttpError("Not found", 404);
8131
+ }
8132
+ function redirectResponse(ctx, location) {
8133
+ const headers = new Headers({ location, "cache-control": "no-store" });
8134
+ for (const value of ctx.headers.getSetCookie()) headers.append("set-cookie", value);
8135
+ return new Response(null, { status: 302, headers });
8136
+ }
7389
8137
  function setSessionCookies(cms, ctx, session) {
7390
8138
  const maxAge = cms.config.auth.tokenExpiration;
7391
8139
  ctx.headers.append(
@@ -7615,6 +8363,7 @@ export {
7615
8363
  ROLE_OPERATIONS,
7616
8364
  Roles,
7617
8365
  SESSION_COOKIE,
8366
+ SSO_COOKIE,
7618
8367
  SchemaError,
7619
8368
  TooManyRequestsError,
7620
8369
  UnauthorizedError,