@easy-cms/core 1.32.0 → 1.33.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.
@@ -50,100 +50,6 @@ var isStaff = ({ user }) => user !== null && user.member !== true;
50
50
  var isSignedIn = ({ user }) => user !== null;
51
51
  var isAdmin = ({ user }) => user?.role === "admin" && user.scoped !== true;
52
52
 
53
- // src/auth/tokens.ts
54
- import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto";
55
- function newToken() {
56
- return randomBytes(32).toString("base64url");
57
- }
58
- function hashToken(token) {
59
- return createHash("sha256").update(token).digest("hex");
60
- }
61
- function hmac(secret, value) {
62
- return createHmac("sha256", secret).update(value).digest("base64url");
63
- }
64
- var signValue = (secret, value) => hmac(secret, value);
65
- function secretsOf(config) {
66
- return [config.secret, ...config.previousSecrets ?? []];
67
- }
68
- function signedWith(secrets, value, signature) {
69
- return [secrets].flat().some((secret) => safeEqual(signature, hmac(secret, value)));
70
- }
71
- function signToken(secret, token) {
72
- return `${token}.${hmac(secret, `session:${token}`)}`;
73
- }
74
- function unsignToken(secrets, value) {
75
- const dot = value.lastIndexOf(".");
76
- if (dot <= 0) return void 0;
77
- const token = value.slice(0, dot);
78
- return signedWith(secrets, `session:${token}`, value.slice(dot + 1)) ? token : void 0;
79
- }
80
- function csrfToken(secret, token) {
81
- return hmac(secret, `csrf:${token}`);
82
- }
83
- function csrfMatches(secrets, token, given) {
84
- return signedWith(secrets, `csrf:${token}`, given);
85
- }
86
- function safeEqual(a, b) {
87
- const left = Buffer.from(a);
88
- const right = Buffer.from(b);
89
- return left.length === right.length && timingSafeEqual(left, right);
90
- }
91
- function signPreviewToken(secret, target, expiresAt, user) {
92
- const payload = Buffer.from(
93
- JSON.stringify({
94
- ..."global" in target ? { g: target.global } : { c: target.collection, i: target.id },
95
- e: expiresAt,
96
- ...user !== void 0 ? { u: user } : {}
97
- })
98
- ).toString("base64url");
99
- return `${payload}.${hmac(secret, `preview:${payload}`)}`;
100
- }
101
- function verifyPreviewToken(secrets, token, now = Date.now()) {
102
- const dot = token.lastIndexOf(".");
103
- if (dot <= 0) return null;
104
- const payload = token.slice(0, dot);
105
- if (!signedWith(secrets, `preview:${payload}`, token.slice(dot + 1))) return null;
106
- try {
107
- const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
108
- if (typeof data.e !== "number" || data.e <= now) return null;
109
- const user = typeof data.u === "string" ? { user: data.u } : {};
110
- if (typeof data.g === "string") return { global: data.g, expiresAt: data.e, ...user };
111
- if (typeof data.c === "string" && typeof data.i === "string")
112
- return { collection: data.c, id: data.i, expiresAt: data.e, ...user };
113
- return null;
114
- } catch {
115
- return null;
116
- }
117
- }
118
- function passwordFingerprint(passwordHash) {
119
- return createHash("sha256").update(typeof passwordHash === "string" ? passwordHash : "no-password").digest("base64url").slice(0, 22);
120
- }
121
- function signPasswordToken(secret, args) {
122
- const payload = Buffer.from(
123
- JSON.stringify({ u: args.userId, p: args.purpose, e: args.expiresAt })
124
- ).toString("base64url");
125
- return `${payload}.${hmac(secret, `password:${payload}:${args.fingerprint}`)}`;
126
- }
127
- function readPasswordToken(token) {
128
- const dot = token.lastIndexOf(".");
129
- if (dot <= 0) return null;
130
- const payload = token.slice(0, dot);
131
- try {
132
- const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
133
- if (typeof data.u !== "string" || typeof data.e !== "number") return null;
134
- if (data.p !== "reset" && data.p !== "invite" && data.p !== "verify") return null;
135
- return { userId: data.u, purpose: data.p, expiresAt: data.e, payload };
136
- } catch {
137
- return null;
138
- }
139
- }
140
- function passwordTokenMatches(secrets, token, fingerprint) {
141
- const dot = token.lastIndexOf(".");
142
- if (dot <= 0) return false;
143
- const payload = token.slice(0, dot);
144
- return signedWith(secrets, `password:${payload}:${fingerprint}`, token.slice(dot + 1));
145
- }
146
-
147
53
  // src/builtins.ts
148
54
  var USERS = "users";
149
55
  var MEDIA = "media";
@@ -164,6 +70,7 @@ var RATE_LIMITS = "rate-limits";
164
70
  var BULK_OPERATIONS = "bulk-operations";
165
71
  var SAVED_VIEWS = "saved-views";
166
72
  var CONNECTIONS = "connections";
73
+ var AI_USAGE = "ai-usage";
167
74
  var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
168
75
  SESSIONS,
169
76
  LOGIN_ATTEMPTS,
@@ -180,7 +87,8 @@ var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
180
87
  RATE_LIMITS,
181
88
  BULK_OPERATIONS,
182
89
  SAVED_VIEWS,
183
- CONNECTIONS
90
+ CONNECTIONS,
91
+ AI_USAGE
184
92
  ]);
185
93
  var DEFAULT_ROLES = ["admin", "editor"];
186
94
  var CREATED_BY_FIELD = {
@@ -602,6 +510,22 @@ var connectionsCollection = {
602
510
  { name: "enabled", type: "boolean" }
603
511
  ]
604
512
  };
513
+ var aiUsageCollection = {
514
+ slug: AI_USAGE,
515
+ access: { read: nobody, create: nobody, update: nobody, delete: nobody },
516
+ fields: [
517
+ { name: "user", type: "text", index: true },
518
+ { name: "feature", type: "text", required: true },
519
+ { name: "integration", type: "text", required: true },
520
+ { name: "connection", type: "text" },
521
+ { name: "input", type: "number" },
522
+ { name: "output", type: "number" },
523
+ { name: "ok", type: "boolean" },
524
+ { name: "error", type: "text" },
525
+ // The document it was for, e.g. the image of alt text.
526
+ { name: "about", type: "text" }
527
+ ]
528
+ };
605
529
  var webhookDeliveriesCollection = {
606
530
  slug: WEBHOOK_DELIVERIES,
607
531
  access: { read: nobody, create: nobody, update: nobody, delete: nobody },
@@ -741,6 +665,602 @@ var emailDeliveriesCollection = {
741
665
  ]
742
666
  };
743
667
 
668
+ // src/time-of-day.ts
669
+ var DAY = 864e5;
670
+ function isTimeZone(zone) {
671
+ try {
672
+ new Intl.DateTimeFormat("en-US", { timeZone: zone });
673
+ return true;
674
+ } catch {
675
+ return false;
676
+ }
677
+ }
678
+ var formatters = /* @__PURE__ */ new Map();
679
+ function wallClock(instant, zone) {
680
+ let format = formatters.get(zone);
681
+ if (!format) {
682
+ format = new Intl.DateTimeFormat("en-US", {
683
+ timeZone: zone,
684
+ hourCycle: "h23",
685
+ year: "numeric",
686
+ month: "numeric",
687
+ day: "numeric",
688
+ hour: "numeric",
689
+ minute: "numeric",
690
+ second: "numeric"
691
+ });
692
+ formatters.set(zone, format);
693
+ }
694
+ const parts = {};
695
+ for (const part of format.formatToParts(instant))
696
+ if (part.type !== "literal") parts[part.type] = Number(part.value);
697
+ return Date.UTC(
698
+ parts.year,
699
+ parts.month - 1,
700
+ parts.day,
701
+ parts.hour,
702
+ parts.minute,
703
+ parts.second
704
+ );
705
+ }
706
+ function instantOf(wall, zone) {
707
+ let instant = wall - (wallClock(wall, zone) - wall);
708
+ instant = wall - (wallClock(instant, zone) - instant);
709
+ return instant;
710
+ }
711
+ var parse = (at) => at.split(":").map(Number);
712
+ function lastSlot(now, at, zone) {
713
+ const [hours2, minutes] = parse(at);
714
+ if (!zone) {
715
+ const slot2 = new Date(now);
716
+ slot2.setHours(hours2, minutes, 0, 0);
717
+ if (slot2.getTime() > now.getTime()) slot2.setDate(slot2.getDate() - 1);
718
+ return slot2;
719
+ }
720
+ const today = wallClock(now.getTime(), zone);
721
+ const midnight = today - today % DAY;
722
+ let slot = instantOf(midnight + hours2 * 36e5 + minutes * 6e4, zone);
723
+ if (slot > now.getTime())
724
+ slot = instantOf(midnight - DAY + hours2 * 36e5 + minutes * 6e4, zone);
725
+ return new Date(slot);
726
+ }
727
+ function nextSlot(after, at, zone) {
728
+ const last = lastSlot(after, at, zone);
729
+ return lastSlot(new Date(last.getTime() + DAY + 2 * 36e5), at, zone);
730
+ }
731
+ function dueAt(now, at, every, lastRun, zone) {
732
+ if (lastRun === void 0) return true;
733
+ const last = Date.parse(lastRun);
734
+ if (Number.isNaN(last)) return true;
735
+ const slot = lastSlot(now, at, zone).getTime();
736
+ return last < slot - (every * 1e3 - DAY);
737
+ }
738
+ function nextAt(at, every, lastRun, zone) {
739
+ return nextSlot(new Date(Date.parse(lastRun) + (every * 1e3 - DAY)), at, zone);
740
+ }
741
+
742
+ // src/ai.ts
743
+ var AiProviderError = class extends Error {
744
+ status;
745
+ constructor(message2, status) {
746
+ super(message2);
747
+ this.name = "AiProviderError";
748
+ this.status = status;
749
+ }
750
+ };
751
+ var SETTINGS = "ai";
752
+ var DEFAULT_LIMIT = 100;
753
+ var TIMEOUT = 6e4;
754
+ var MAX_IMAGE = 4 * 1024 * 1024;
755
+ var KEEP = 90 * 24 * 36e5;
756
+ var IMAGE_TYPES = /* @__PURE__ */ new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
757
+ var AI_TEST_FEATURE = "test";
758
+ var ref = (integration, name) => `${integration}:${name}`;
759
+ var Ai = class {
760
+ constructor(cms, ai) {
761
+ this.cms = cms;
762
+ this.ai = ai;
763
+ }
764
+ cms;
765
+ ai;
766
+ get config() {
767
+ return this.cms.config;
768
+ }
769
+ get defaultLimit() {
770
+ return this.ai.limits?.perUserPerDay ?? DEFAULT_LIMIT;
771
+ }
772
+ /** The features of the config. */
773
+ get features() {
774
+ return this.config.aiFeatures;
775
+ }
776
+ async settingsRow() {
777
+ const found = await this.cms.db.find({
778
+ collection: CONNECTIONS,
779
+ where: { and: [{ kind: { equals: "settings" } }, { integration: { equals: SETTINGS } }] },
780
+ sort: ["id"],
781
+ limit: 1,
782
+ page: 1
783
+ });
784
+ return found.docs[0];
785
+ }
786
+ async settings() {
787
+ return (await this.settingsRow())?.values ?? {};
788
+ }
789
+ /** Every connection of every provider, in the providers' order. */
790
+ async connections() {
791
+ const out = [];
792
+ for (const provider of this.ai.providers)
793
+ for (const conn of await this.cms.integrations.all(provider.integration))
794
+ out.push({ ...conn, integration: provider.integration });
795
+ return out;
796
+ }
797
+ /** Whether a feature is on: admins' choice, else its `default`. */
798
+ async enabled(feature) {
799
+ const def = this.features.find((f) => f.id === feature);
800
+ if (!def) return false;
801
+ return (await this.settings()).features?.[feature]?.enabled ?? def.default ?? true;
802
+ }
803
+ /** Requests a user may make today: their role's, else everyone's. */
804
+ async limitFor(user) {
805
+ const role = typeof user.role === "string" ? user.role : void 0;
806
+ if (role) {
807
+ const fromRoles = await this.cms.roles.aiLimit(role);
808
+ if (fromRoles !== void 0) return fromRoles;
809
+ const fromConfig = this.ai.limits?.roles?.[role];
810
+ if (fromConfig !== void 0) return fromConfig;
811
+ }
812
+ return (await this.settings()).perUserPerDay ?? this.defaultLimit;
813
+ }
814
+ /** Requests the user made today (in `timeZone`) and how many are left. */
815
+ async remaining(user, now = /* @__PURE__ */ new Date()) {
816
+ const since = lastSlot(now, "00:00", this.config.timeZone).toISOString();
817
+ const used = await this.cms.db.count({
818
+ collection: AI_USAGE,
819
+ where: {
820
+ and: [
821
+ { user: { equals: String(user.id) } },
822
+ { ok: { equals: true } },
823
+ { createdAt: { gte: since } }
824
+ ]
825
+ }
826
+ });
827
+ return { used, limit: await this.limitFor(user) };
828
+ }
829
+ /**
830
+ * Asks the feature's connection (or the main one). With `user`, the request counts toward their
831
+ * daily limit; it is refused once they reach it. Usage is recorded, never the prompt.
832
+ */
833
+ async generate(request, options = {}) {
834
+ const feature = this.features.find((f) => f.id === request.feature);
835
+ if (!feature)
836
+ throw new QueryError(
837
+ `Unknown AI feature "${request.feature}": add it to \`aiFeatures\` in the config`
838
+ );
839
+ if (!await this.enabled(feature.id))
840
+ throw new QueryError(`The AI feature "${feature.id}" is turned off`);
841
+ const user = options.user ?? null;
842
+ if (user) {
843
+ const { used, limit } = await this.remaining(user);
844
+ if (used >= limit)
845
+ throw new TooManyRequestsError(
846
+ limit === 0 ? "Your role may not use AI" : `You used AI ${limit} times today, the most allowed`
847
+ );
848
+ }
849
+ const conn = await this.connectionFor(feature.id);
850
+ const images = await Promise.all((request.images ?? []).map((i) => this.image(i)));
851
+ return this.ask(conn, {
852
+ feature: feature.id,
853
+ user,
854
+ about: options.about,
855
+ request: {
856
+ ...request.system ? { system: request.system } : {},
857
+ prompt: request.prompt,
858
+ images,
859
+ ...request.json ? { json: request.json } : {},
860
+ maxTokens: request.maxTokens ?? 1024
861
+ }
862
+ });
863
+ }
864
+ /**
865
+ * The Test button of a provider's connection: a short prompt; its tokens count on the card,
866
+ * not toward anyone's limit.
867
+ */
868
+ async test(integration, values, name) {
869
+ try {
870
+ await this.ask(
871
+ { integration, name: name ?? "", values, env: false },
872
+ {
873
+ feature: AI_TEST_FEATURE,
874
+ user: null,
875
+ markConnection: false,
876
+ request: { prompt: "Reply with the word OK.", images: [], maxTokens: 16 }
877
+ }
878
+ );
879
+ return { ok: true, ...values.model ? { account: values.model } : {} };
880
+ } catch (error) {
881
+ return { ok: false, message: error.message };
882
+ }
883
+ }
884
+ async connectionFor(feature) {
885
+ const settings = await this.settings();
886
+ const wanted = settings.features?.[feature]?.connection ?? settings.main;
887
+ const all = await this.connections();
888
+ const conn = wanted && all.find((c) => ref(c.integration, c.name) === wanted) || all[0];
889
+ if (!conn)
890
+ throw new QueryError("No AI provider is connected: connect one in Settings \u2192 Integrations");
891
+ return conn;
892
+ }
893
+ async ask(conn, args) {
894
+ const provider = this.ai.providers.find((p) => p.integration === conn.integration);
895
+ if (!provider) throw new QueryError(`No AI provider answers for "${conn.integration}"`);
896
+ const record = (data) => this.cms.db.create({
897
+ collection: AI_USAGE,
898
+ data: {
899
+ user: args.user ? String(args.user.id) : null,
900
+ feature: args.feature,
901
+ integration: conn.integration,
902
+ connection: conn.name || null,
903
+ input: 0,
904
+ output: 0,
905
+ error: null,
906
+ about: args.about ? `${args.about.collection}:${args.about.id}` : null,
907
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
908
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
909
+ ...data
910
+ }
911
+ });
912
+ let result;
913
+ try {
914
+ result = await this.call(provider, conn.values, args.request);
915
+ } catch (error) {
916
+ const status = error instanceof AiProviderError ? error.status : void 0;
917
+ const message2 = error.message;
918
+ await record({ ok: false, error: message2.slice(0, 500) });
919
+ if ((status === 401 || status === 403) && args.markConnection !== false && conn.name)
920
+ await this.cms.integrations.markError(conn.integration, message2, conn.name);
921
+ throw new QueryError(message2);
922
+ }
923
+ await record({ ok: true, input: result.usage.input, output: result.usage.output });
924
+ if (args.markConnection !== false && conn.name)
925
+ await this.cms.integrations.markUsed(conn.integration, conn.name);
926
+ let json2;
927
+ if (args.request.json) {
928
+ try {
929
+ json2 = JSON.parse(stripFence(result.text));
930
+ } catch {
931
+ throw new QueryError("The AI answered with something other than JSON; try again");
932
+ }
933
+ }
934
+ return {
935
+ text: result.text,
936
+ ...args.request.json ? { json: json2 } : {},
937
+ usage: result.usage,
938
+ connection: ref(conn.integration, conn.name)
939
+ };
940
+ }
941
+ /** One try, and another after a second when the provider is busy or failing (429, 5xx). */
942
+ async call(provider, values, request) {
943
+ for (let attempt = 0; ; attempt++) {
944
+ try {
945
+ return await provider.generate(values, { ...request, signal: AbortSignal.timeout(TIMEOUT) });
946
+ } catch (error) {
947
+ const status = error instanceof AiProviderError ? error.status : void 0;
948
+ const busy = status === 429 || status !== void 0 && status >= 500;
949
+ if (!busy || attempt > 0) {
950
+ if (error.name === "TimeoutError")
951
+ throw new AiProviderError("The AI provider took longer than 60 seconds");
952
+ throw error;
953
+ }
954
+ await new Promise((resolve3) => setTimeout(resolve3, 1e3));
955
+ }
956
+ }
957
+ }
958
+ /** An image as base64: given, or read from a media document. */
959
+ async image(image) {
960
+ if (image.data !== void 0) {
961
+ if (!image.mediaType || !IMAGE_TYPES.has(image.mediaType))
962
+ throw new ValidationError(AI_USAGE, [
963
+ { field: "images", message: "must be JPEG, PNG, WebP or GIF" }
964
+ ]);
965
+ return { data: image.data, mediaType: image.mediaType };
966
+ }
967
+ if (image.media === void 0)
968
+ throw new ValidationError(AI_USAGE, [{ field: "images", message: "needs media or data" }]);
969
+ const doc = await this.cms.db.findById({ collection: MEDIA, id: image.media });
970
+ if (!doc) throw new ValidationError(AI_USAGE, [{ field: "images", message: "media not found" }]);
971
+ const sizes = Object.values(doc.sizes ?? {}).filter((s) => typeof s?.filename === "string" && Number(s.width) >= 1024).sort((a, b) => Number(a.width) - Number(b.width));
972
+ const filename = String(sizes[0]?.filename ?? doc.filename);
973
+ const mediaType = String(doc.mimeType ?? "");
974
+ if (!IMAGE_TYPES.has(mediaType))
975
+ throw new ValidationError(AI_USAGE, [
976
+ { field: "images", message: `${mediaType || "this file"} is not an image AI can read` }
977
+ ]);
978
+ const file = await this.cms.storageFor(doc.private === true).get(filename);
979
+ if (!file) throw new ValidationError(AI_USAGE, [{ field: "images", message: "file not found" }]);
980
+ if (file.body.byteLength > MAX_IMAGE)
981
+ throw new ValidationError(AI_USAGE, [
982
+ { field: "images", message: "is larger than 4 MB; add an image size of about 1024 pixels" }
983
+ ]);
984
+ return { data: Buffer.from(file.body).toString("base64"), mediaType };
985
+ }
986
+ // --- Admins ----------------------------------------------------------------------------------
987
+ /** Settings → Integrations, AI: the main connection, the features, the limit and this month. */
988
+ async overview(now = /* @__PURE__ */ new Date()) {
989
+ const settings = await this.settings();
990
+ const conns = await this.connections();
991
+ const labels = new Map(this.config.integrations.map((i) => [i.id, i.label]));
992
+ const month = monthStart(now, this.config.timeZone).toISOString();
993
+ const rows2 = (await this.cms.db.find({
994
+ collection: AI_USAGE,
995
+ where: { createdAt: { gte: month } },
996
+ sort: ["id"],
997
+ limit: 0,
998
+ page: 1
999
+ })).docs;
1000
+ const sum = (key) => {
1001
+ const totals = /* @__PURE__ */ new Map();
1002
+ for (const row of rows2) {
1003
+ if (key === "user" && !row.user) continue;
1004
+ const k = String(row[key]);
1005
+ const t = totals.get(k) ?? { requests: 0, input: 0, output: 0 };
1006
+ t.requests++;
1007
+ t.input += Number(row.input ?? 0);
1008
+ t.output += Number(row.output ?? 0);
1009
+ totals.set(k, t);
1010
+ }
1011
+ return [...totals].sort((a, b) => b[1].requests - a[1].requests);
1012
+ };
1013
+ const users = sum("user");
1014
+ const emails = /* @__PURE__ */ new Map();
1015
+ if (users.length > 0) {
1016
+ const found = await this.cms.db.findByIds({
1017
+ collection: "users",
1018
+ ids: users.map(([id]) => /^\d+$/.test(id) ? Number(id) : id)
1019
+ });
1020
+ for (const u of found) emails.set(String(u.id), String(u.email ?? u.id));
1021
+ }
1022
+ return {
1023
+ main: settings.main ?? null,
1024
+ perUserPerDay: settings.perUserPerDay ?? this.defaultLimit,
1025
+ defaultPerUserPerDay: this.defaultLimit,
1026
+ connections: conns.map((c) => ({
1027
+ id: ref(c.integration, c.name),
1028
+ integration: c.integration,
1029
+ name: c.name,
1030
+ label: labels.get(c.integration) ?? c.integration
1031
+ })),
1032
+ features: await Promise.all(
1033
+ this.features.map(async (f) => ({
1034
+ id: f.id,
1035
+ label: f.label,
1036
+ ...f.description ? { description: f.description } : {},
1037
+ enabled: await this.enabled(f.id),
1038
+ connection: settings.features?.[f.id]?.connection ?? null
1039
+ }))
1040
+ ),
1041
+ usage: {
1042
+ byUser: users.map(([id, t]) => ({ user: emails.get(id) ?? id, ...t })),
1043
+ byFeature: sum("feature").map(([feature, t]) => ({ feature, ...t }))
1044
+ }
1045
+ };
1046
+ }
1047
+ /** Tokens and requests of each connection this month, for the cards. */
1048
+ async usageByConnection(now = /* @__PURE__ */ new Date()) {
1049
+ const month = monthStart(now, this.config.timeZone).toISOString();
1050
+ const rows2 = (await this.cms.db.find({
1051
+ collection: AI_USAGE,
1052
+ where: { createdAt: { gte: month } },
1053
+ sort: ["id"],
1054
+ limit: 0,
1055
+ page: 1
1056
+ })).docs;
1057
+ const out = /* @__PURE__ */ new Map();
1058
+ for (const row of rows2) {
1059
+ const k = ref(String(row.integration), String(row.connection ?? ""));
1060
+ const t = out.get(k) ?? { requests: 0, input: 0, output: 0 };
1061
+ t.requests++;
1062
+ t.input += Number(row.input ?? 0);
1063
+ t.output += Number(row.output ?? 0);
1064
+ out.set(k, t);
1065
+ }
1066
+ return out;
1067
+ }
1068
+ /** Saves admins' choices; audit-logged. */
1069
+ async update(body, user) {
1070
+ const input = body ?? {};
1071
+ const current = await this.settings();
1072
+ const conns = new Set((await this.connections()).map((c) => ref(c.integration, c.name)));
1073
+ const next = { ...current };
1074
+ const fail = (field, message2) => {
1075
+ throw new ValidationError(AI_USAGE, [{ field, message: message2 }]);
1076
+ };
1077
+ if (input.main !== void 0) {
1078
+ if (input.main === null) delete next.main;
1079
+ else if (typeof input.main === "string" && conns.has(input.main)) next.main = input.main;
1080
+ else fail("main", "is not a connection");
1081
+ }
1082
+ if (input.perUserPerDay !== void 0) {
1083
+ if (input.perUserPerDay === null) delete next.perUserPerDay;
1084
+ else if (Number.isInteger(input.perUserPerDay) && input.perUserPerDay >= 0)
1085
+ next.perUserPerDay = input.perUserPerDay;
1086
+ else fail("perUserPerDay", "must be a whole number, 0 or more");
1087
+ }
1088
+ if (input.features !== void 0) {
1089
+ if (typeof input.features !== "object" || input.features === null)
1090
+ fail("features", "must be an object");
1091
+ const features = { ...current.features ?? {} };
1092
+ for (const [id, raw] of Object.entries(input.features)) {
1093
+ if (!this.features.some((f) => f.id === id)) fail("features", `unknown feature "${id}"`);
1094
+ const change = raw ?? {};
1095
+ const entry = { ...features[id] ?? {} };
1096
+ if (typeof change.enabled === "boolean") entry.enabled = change.enabled;
1097
+ if (change.connection === null) delete entry.connection;
1098
+ else if (change.connection !== void 0) {
1099
+ if (typeof change.connection !== "string" || !conns.has(change.connection))
1100
+ fail("features", "connection is not a connection");
1101
+ entry.connection = change.connection;
1102
+ }
1103
+ features[id] = entry;
1104
+ }
1105
+ next.features = features;
1106
+ }
1107
+ const row = await this.settingsRow();
1108
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1109
+ if (row)
1110
+ await this.cms.db.update({
1111
+ collection: CONNECTIONS,
1112
+ id: row.id,
1113
+ data: { ...row, values: next, updatedAt: now }
1114
+ });
1115
+ else
1116
+ await this.cms.db.create({
1117
+ collection: CONNECTIONS,
1118
+ data: {
1119
+ kind: "settings",
1120
+ integration: SETTINGS,
1121
+ name: null,
1122
+ isDefault: false,
1123
+ values: next,
1124
+ secret: null,
1125
+ account: null,
1126
+ lastUsedAt: null,
1127
+ lastError: null,
1128
+ enabled: null,
1129
+ createdAt: now,
1130
+ updatedAt: now
1131
+ }
1132
+ });
1133
+ await this.cms.audit.record({
1134
+ action: "integration.ai",
1135
+ target: SETTINGS,
1136
+ user,
1137
+ detail: { ...next }
1138
+ });
1139
+ return this.overview();
1140
+ }
1141
+ /** Forgets usage older than 90 days (with the scheduled jobs). */
1142
+ async upkeep(now = /* @__PURE__ */ new Date()) {
1143
+ const before = new Date(now.getTime() - KEEP).toISOString();
1144
+ for (; ; ) {
1145
+ const old = await this.cms.db.find({
1146
+ collection: AI_USAGE,
1147
+ where: { createdAt: { lt: before } },
1148
+ sort: ["id"],
1149
+ limit: 500,
1150
+ page: 1
1151
+ });
1152
+ for (const row of old.docs) await this.cms.db.delete({ collection: AI_USAGE, id: row.id });
1153
+ if (old.docs.length < 500) return;
1154
+ }
1155
+ }
1156
+ };
1157
+ function stripFence(text4) {
1158
+ const trimmed = text4.trim();
1159
+ const match = /^```(?:json)?\s*\n([\s\S]*?)\n?```$/.exec(trimmed);
1160
+ return match ? match[1] : trimmed;
1161
+ }
1162
+ function monthStart(now, zone) {
1163
+ let day = lastSlot(now, "00:00", zone);
1164
+ const dayOf = (d) => Number(new Intl.DateTimeFormat("en-US", { day: "numeric", timeZone: zone }).format(d));
1165
+ for (let i = 0; i < 31 && dayOf(day) > 1; i++)
1166
+ day = lastSlot(new Date(day.getTime() - 1), "00:00", zone);
1167
+ return day;
1168
+ }
1169
+
1170
+ // src/auth/tokens.ts
1171
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto";
1172
+ function newToken() {
1173
+ return randomBytes(32).toString("base64url");
1174
+ }
1175
+ function hashToken(token) {
1176
+ return createHash("sha256").update(token).digest("hex");
1177
+ }
1178
+ function hmac(secret, value) {
1179
+ return createHmac("sha256", secret).update(value).digest("base64url");
1180
+ }
1181
+ var signValue = (secret, value) => hmac(secret, value);
1182
+ function secretsOf(config) {
1183
+ return [config.secret, ...config.previousSecrets ?? []];
1184
+ }
1185
+ function signedWith(secrets, value, signature) {
1186
+ return [secrets].flat().some((secret) => safeEqual(signature, hmac(secret, value)));
1187
+ }
1188
+ function signToken(secret, token) {
1189
+ return `${token}.${hmac(secret, `session:${token}`)}`;
1190
+ }
1191
+ function unsignToken(secrets, value) {
1192
+ const dot = value.lastIndexOf(".");
1193
+ if (dot <= 0) return void 0;
1194
+ const token = value.slice(0, dot);
1195
+ return signedWith(secrets, `session:${token}`, value.slice(dot + 1)) ? token : void 0;
1196
+ }
1197
+ function csrfToken(secret, token) {
1198
+ return hmac(secret, `csrf:${token}`);
1199
+ }
1200
+ function csrfMatches(secrets, token, given) {
1201
+ return signedWith(secrets, `csrf:${token}`, given);
1202
+ }
1203
+ function safeEqual(a, b) {
1204
+ const left = Buffer.from(a);
1205
+ const right = Buffer.from(b);
1206
+ return left.length === right.length && timingSafeEqual(left, right);
1207
+ }
1208
+ function signPreviewToken(secret, target, expiresAt, user) {
1209
+ const payload = Buffer.from(
1210
+ JSON.stringify({
1211
+ ..."global" in target ? { g: target.global } : { c: target.collection, i: target.id },
1212
+ e: expiresAt,
1213
+ ...user !== void 0 ? { u: user } : {}
1214
+ })
1215
+ ).toString("base64url");
1216
+ return `${payload}.${hmac(secret, `preview:${payload}`)}`;
1217
+ }
1218
+ function verifyPreviewToken(secrets, token, now = Date.now()) {
1219
+ const dot = token.lastIndexOf(".");
1220
+ if (dot <= 0) return null;
1221
+ const payload = token.slice(0, dot);
1222
+ if (!signedWith(secrets, `preview:${payload}`, token.slice(dot + 1))) return null;
1223
+ try {
1224
+ const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
1225
+ if (typeof data.e !== "number" || data.e <= now) return null;
1226
+ const user = typeof data.u === "string" ? { user: data.u } : {};
1227
+ if (typeof data.g === "string") return { global: data.g, expiresAt: data.e, ...user };
1228
+ if (typeof data.c === "string" && typeof data.i === "string")
1229
+ return { collection: data.c, id: data.i, expiresAt: data.e, ...user };
1230
+ return null;
1231
+ } catch {
1232
+ return null;
1233
+ }
1234
+ }
1235
+ function passwordFingerprint(passwordHash) {
1236
+ return createHash("sha256").update(typeof passwordHash === "string" ? passwordHash : "no-password").digest("base64url").slice(0, 22);
1237
+ }
1238
+ function signPasswordToken(secret, args) {
1239
+ const payload = Buffer.from(
1240
+ JSON.stringify({ u: args.userId, p: args.purpose, e: args.expiresAt })
1241
+ ).toString("base64url");
1242
+ return `${payload}.${hmac(secret, `password:${payload}:${args.fingerprint}`)}`;
1243
+ }
1244
+ function readPasswordToken(token) {
1245
+ const dot = token.lastIndexOf(".");
1246
+ if (dot <= 0) return null;
1247
+ const payload = token.slice(0, dot);
1248
+ try {
1249
+ const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
1250
+ if (typeof data.u !== "string" || typeof data.e !== "number") return null;
1251
+ if (data.p !== "reset" && data.p !== "invite" && data.p !== "verify") return null;
1252
+ return { userId: data.u, purpose: data.p, expiresAt: data.e, payload };
1253
+ } catch {
1254
+ return null;
1255
+ }
1256
+ }
1257
+ function passwordTokenMatches(secrets, token, fingerprint) {
1258
+ const dot = token.lastIndexOf(".");
1259
+ if (dot <= 0) return false;
1260
+ const payload = token.slice(0, dot);
1261
+ return signedWith(secrets, `password:${payload}:${fingerprint}`, token.slice(dot + 1));
1262
+ }
1263
+
744
1264
  // src/audit.ts
745
1265
  import { AsyncLocalStorage } from "async_hooks";
746
1266
  import { createHmac as createHmac2 } from "crypto";
@@ -758,7 +1278,7 @@ var DEFAULT_FAILED_LOGINS = 20;
758
1278
  var MAX_VALUE = 500;
759
1279
  var PAGE = 50;
760
1280
  var CSV_LIMIT = 1e4;
761
- var DAY = 864e5;
1281
+ var DAY2 = 864e5;
762
1282
  var BULKY = /* @__PURE__ */ new Set(["richText", "blocks", "array", "json"]);
763
1283
  var auditContext = new AsyncLocalStorage();
764
1284
  var SIGN_IN_FAILURES = /* @__PURE__ */ new Set(["login.failed", "login.locked", "sso.failed"]);
@@ -1033,10 +1553,10 @@ var AuditLog = class {
1033
1553
  async upkeep(now = /* @__PURE__ */ new Date()) {
1034
1554
  if (!this.enabled) return;
1035
1555
  const last = await this.lastVerification();
1036
- if (last && now.getTime() - Date.parse(last.at) < DAY) return;
1556
+ if (last && now.getTime() - Date.parse(last.at) < DAY2) return;
1037
1557
  const keep = this.settings.keepDays;
1038
1558
  if (keep > 0) {
1039
- const before = new Date(now.getTime() - keep * DAY).toISOString();
1559
+ const before = new Date(now.getTime() - keep * DAY2).toISOString();
1040
1560
  for (; ; ) {
1041
1561
  const { docs } = await this.cms.db.find({
1042
1562
  collection: AUDIT_LOGS,
@@ -3311,82 +3831,6 @@ import { tmpdir } from "os";
3311
3831
  import { join as join2 } from "path";
3312
3832
  import { promisify } from "util";
3313
3833
  import { gzip } from "zlib";
3314
-
3315
- // src/time-of-day.ts
3316
- var DAY2 = 864e5;
3317
- function isTimeZone(zone) {
3318
- try {
3319
- new Intl.DateTimeFormat("en-US", { timeZone: zone });
3320
- return true;
3321
- } catch {
3322
- return false;
3323
- }
3324
- }
3325
- var formatters = /* @__PURE__ */ new Map();
3326
- function wallClock(instant, zone) {
3327
- let format = formatters.get(zone);
3328
- if (!format) {
3329
- format = new Intl.DateTimeFormat("en-US", {
3330
- timeZone: zone,
3331
- hourCycle: "h23",
3332
- year: "numeric",
3333
- month: "numeric",
3334
- day: "numeric",
3335
- hour: "numeric",
3336
- minute: "numeric",
3337
- second: "numeric"
3338
- });
3339
- formatters.set(zone, format);
3340
- }
3341
- const parts = {};
3342
- for (const part of format.formatToParts(instant))
3343
- if (part.type !== "literal") parts[part.type] = Number(part.value);
3344
- return Date.UTC(
3345
- parts.year,
3346
- parts.month - 1,
3347
- parts.day,
3348
- parts.hour,
3349
- parts.minute,
3350
- parts.second
3351
- );
3352
- }
3353
- function instantOf(wall, zone) {
3354
- let instant = wall - (wallClock(wall, zone) - wall);
3355
- instant = wall - (wallClock(instant, zone) - instant);
3356
- return instant;
3357
- }
3358
- var parse = (at) => at.split(":").map(Number);
3359
- function lastSlot(now, at, zone) {
3360
- const [hours2, minutes] = parse(at);
3361
- if (!zone) {
3362
- const slot2 = new Date(now);
3363
- slot2.setHours(hours2, minutes, 0, 0);
3364
- if (slot2.getTime() > now.getTime()) slot2.setDate(slot2.getDate() - 1);
3365
- return slot2;
3366
- }
3367
- const today = wallClock(now.getTime(), zone);
3368
- const midnight = today - today % DAY2;
3369
- let slot = instantOf(midnight + hours2 * 36e5 + minutes * 6e4, zone);
3370
- if (slot > now.getTime())
3371
- slot = instantOf(midnight - DAY2 + hours2 * 36e5 + minutes * 6e4, zone);
3372
- return new Date(slot);
3373
- }
3374
- function nextSlot(after, at, zone) {
3375
- const last = lastSlot(after, at, zone);
3376
- return lastSlot(new Date(last.getTime() + DAY2 + 2 * 36e5), at, zone);
3377
- }
3378
- function dueAt(now, at, every, lastRun, zone) {
3379
- if (lastRun === void 0) return true;
3380
- const last = Date.parse(lastRun);
3381
- if (Number.isNaN(last)) return true;
3382
- const slot = lastSlot(now, at, zone).getTime();
3383
- return last < slot - (every * 1e3 - DAY2);
3384
- }
3385
- function nextAt(at, every, lastRun, zone) {
3386
- return nextSlot(new Date(Date.parse(lastRun) + (every * 1e3 - DAY2)), at, zone);
3387
- }
3388
-
3389
- // src/backups.ts
3390
3834
  var gzipAsync = promisify(gzip);
3391
3835
  var MAGIC = Buffer.from("ECMSBAK1");
3392
3836
  var IV_BYTES = 12;
@@ -4927,7 +5371,7 @@ var OFFICIAL_PLUGINS = [
4927
5371
  en: "Claude, GPT and Gemini for alt text, SEO and translation",
4928
5372
  th: "Claude, GPT \u0E41\u0E25\u0E30 Gemini \u0E2A\u0E33\u0E2B\u0E23\u0E31\u0E1A alt text, SEO \u0E41\u0E25\u0E30\u0E41\u0E1B\u0E25\u0E20\u0E32\u0E29\u0E32"
4929
5373
  },
4930
- docs: "https://easycms.dev/docs/integrations"
5374
+ docs: "https://easycms.dev/docs/ai"
4931
5375
  },
4932
5376
  {
4933
5377
  name: "@easy-cms/plugin-ecommerce",
@@ -5066,6 +5510,8 @@ var Integrations = class {
5066
5510
  // --- The admin's page ------------------------------------------------------------------------
5067
5511
  async overview() {
5068
5512
  const integrations = [];
5513
+ const usage = await this.cms.ai?.usageByConnection() ?? /* @__PURE__ */ new Map();
5514
+ const aiProviders = new Set(this.config.ai?.providers.map((p) => p.integration) ?? []);
5069
5515
  const current = keyId(keyOf2(this.config.secret));
5070
5516
  let stale = 0;
5071
5517
  for (const def of this.definitions) {
@@ -5126,7 +5572,11 @@ var Integrations = class {
5126
5572
  enabled: await this.enabled(def.id),
5127
5573
  envFields: Object.keys(env),
5128
5574
  testable: typeof def.test === "function",
5129
- connections
5575
+ connections: aiProviders.has(def.id) ? connections.map((c) => ({
5576
+ ...c,
5577
+ usage: usage.get(`${def.id}:${c.name}`) ?? { requests: 0, input: 0, output: 0 }
5578
+ })) : connections,
5579
+ ...def.consent ? { consent: def.consent, consented: await this.consented(def.id) } : {}
5130
5580
  });
5131
5581
  }
5132
5582
  const installed = new Set(this.config.installedPlugins.map((p) => p.name));
@@ -5204,11 +5654,12 @@ var Integrations = class {
5204
5654
  async connect(id, body, user) {
5205
5655
  const def = this.definition(id);
5206
5656
  if (def.kind !== "fields") throw new QueryError(`${id} connects with its Connect button`);
5657
+ await this.checkConsent(def);
5207
5658
  const input = body ?? {};
5208
5659
  const name = await this.uniqueName(id, String(input.name ?? ""));
5209
5660
  const values = this.cleanValues(def, input.values);
5210
5661
  this.checkFields(def, values, false);
5211
- const result = def.test ? await def.test(values, { cms: this.cms }) : null;
5662
+ const result = def.test ? await def.test(values, { cms: this.cms, name }) : null;
5212
5663
  if (result && !result.ok)
5213
5664
  throw new ValidationError(CONNECTIONS, [
5214
5665
  { field: "values", message: result.message ?? "the provider refused these values" }
@@ -5268,13 +5719,41 @@ var Integrations = class {
5268
5719
  /** Turns the integration on or off; its connections are kept. */
5269
5720
  async setEnabled(id, enabled, user) {
5270
5721
  this.definition(id);
5722
+ await this.saveIntegration(id, { enabled });
5723
+ await this.audit(user, enabled ? "enable" : "disable", id);
5724
+ return { enabled };
5725
+ }
5726
+ /** Who accepted the integration's `consent`, and when; `null` before anyone did. */
5727
+ async consented(id) {
5728
+ const [row] = await this.rows(id, "integration");
5729
+ const consent = (row?.values ?? {}).consent;
5730
+ return consent ?? null;
5731
+ }
5732
+ async checkConsent(def) {
5733
+ if (def.consent && !await this.consented(def.id))
5734
+ throw new ValidationError(CONNECTIONS, [
5735
+ { field: "consent", message: "must be accepted before connecting" }
5736
+ ]);
5737
+ }
5738
+ /** An admin accepts the integration's `consent`; audit-logged. */
5739
+ async consent(id, user) {
5740
+ const def = this.definition(id);
5741
+ if (!def.consent) throw new QueryError(`${id} asks for no consent`);
5742
+ const [row] = await this.rows(id, "integration");
5743
+ const consent = { at: (/* @__PURE__ */ new Date()).toISOString(), by: String(user.email ?? user.id) };
5744
+ await this.saveIntegration(id, { values: { ...row?.values ?? {}, consent } });
5745
+ await this.audit(user, "consent", id);
5746
+ return { consented: consent };
5747
+ }
5748
+ /** The integration's own row (on or off, consent), made when first needed. */
5749
+ async saveIntegration(id, patch) {
5271
5750
  const [row] = await this.rows(id, "integration");
5272
5751
  const now = (/* @__PURE__ */ new Date()).toISOString();
5273
5752
  if (row)
5274
5753
  await this.cms.db.update({
5275
5754
  collection: CONNECTIONS,
5276
5755
  id: row.id,
5277
- data: { ...row, enabled, updatedAt: now }
5756
+ data: { ...row, ...patch, updatedAt: now }
5278
5757
  });
5279
5758
  else
5280
5759
  await this.cms.db.create({
@@ -5289,22 +5768,22 @@ var Integrations = class {
5289
5768
  account: null,
5290
5769
  lastUsedAt: null,
5291
5770
  lastError: null,
5292
- enabled,
5771
+ enabled: null,
5293
5772
  createdAt: now,
5294
- updatedAt: now
5773
+ updatedAt: now,
5774
+ ...patch
5295
5775
  }
5296
5776
  });
5297
- await this.audit(user, enabled ? "enable" : "disable", id);
5298
- return { enabled };
5299
5777
  }
5300
5778
  /** Checks a connection's values with the provider; the card shows the result. */
5301
5779
  async test(id, cid) {
5302
5780
  const def = this.definition(id);
5303
5781
  if (!def.test) throw new QueryError(`${id} can't be tested`);
5304
5782
  const values = cid === "env" ? this.envValues(def) : { ...this.decrypt(await this.row(id, cid)), ...this.envValues(def) };
5783
+ const name = cid === "env" ? ENV_NAME : String((await this.row(id, cid)).name);
5305
5784
  let result;
5306
5785
  try {
5307
- result = await def.test(values, { cms: this.cms });
5786
+ result = await def.test(values, { cms: this.cms, name });
5308
5787
  } catch (error) {
5309
5788
  result = { ok: false, message: error.message };
5310
5789
  }
@@ -5349,10 +5828,11 @@ var Integrations = class {
5349
5828
  }
5350
5829
  // --- OAuth -----------------------------------------------------------------------------------
5351
5830
  /** The provider's sign-in page to send the admin to. */
5352
- authorize(id, user, redirectUri, name) {
5831
+ async authorize(id, user, redirectUri, name) {
5353
5832
  const def = this.definition(id);
5354
5833
  if (def.kind !== "oauth" || !def.oauth)
5355
5834
  throw new QueryError(`${id} connects with pasted values`);
5835
+ await this.checkConsent(def);
5356
5836
  const state = signState(this.config.secret, {
5357
5837
  i: id,
5358
5838
  u: String(user.id),
@@ -6150,7 +6630,7 @@ function read(response, maxBytes, deadline) {
6150
6630
 
6151
6631
  // src/config-keys.ts
6152
6632
  var keys = (list) => new Set(list.split(/\s+/).filter(Boolean));
6153
- var CONFIG = keys(`secret previousSecrets integrations listeners db serverURL cors cronSecret timeZone webhooks events jobs localization routes admin
6633
+ var CONFIG = keys(`secret previousSecrets integrations listeners ai aiFeatures db serverURL cors cronSecret timeZone webhooks events jobs localization routes admin
6154
6634
  upload auth collections globals endpoints cliCommands onRequest fieldTypes apiKeys email backups
6155
6635
  audit logs countCacheSeconds maxPage httpCache outbox rateLimit search telemetry plugins`);
6156
6636
  var ADMIN = keys("path locale brand siteURL nav commands modules pages dashboard switcher");
@@ -6704,7 +7184,7 @@ var WEBHOOK_EVENTS = [
6704
7184
  var RETRY_DELAYS = [1e3, 5e3];
6705
7185
  var QUEUE_DELAYS2 = [6e4, 5 * 6e4, 30 * 6e4, 2 * 36e5, 6 * 36e5, 12 * 36e5];
6706
7186
  var CLAIM2 = 5 * 6e4;
6707
- var TIMEOUT = 1e4;
7187
+ var TIMEOUT2 = 1e4;
6708
7188
  var Webhooks = class {
6709
7189
  constructor(hooks, logger, queue, fetcher = (...args) => fetch(...args), retryDelays = RETRY_DELAYS) {
6710
7190
  this.hooks = hooks;
@@ -6872,7 +7352,7 @@ var Webhooks = class {
6872
7352
  method: "POST",
6873
7353
  headers,
6874
7354
  body,
6875
- signal: AbortSignal.timeout(TIMEOUT)
7355
+ signal: AbortSignal.timeout(TIMEOUT2)
6876
7356
  });
6877
7357
  if (response.ok || response.status < 500 && response.status !== 429) {
6878
7358
  if (!response.ok)
@@ -6981,6 +7461,7 @@ function validateConfig(config) {
6981
7461
  validateJobs(config, add);
6982
7462
  validateIntegrations(config, add);
6983
7463
  validateListeners(config, add);
7464
+ validateAi(config, add);
6984
7465
  validateTimeZone(config, add);
6985
7466
  validateEvents(config, add);
6986
7467
  const apiKeys = config.apiKeys;
@@ -8626,6 +9107,22 @@ function validateIntegrations(config, add) {
8626
9107
  } else add(`${at}.kind`, "must be 'fields' or 'oauth'");
8627
9108
  if (item.test !== void 0 && typeof item.test !== "function")
8628
9109
  add(`${at}.test`, "must be a function");
9110
+ if (item.icon !== void 0 && typeof item.icon !== "string") {
9111
+ const logo = item.icon ?? {};
9112
+ const hex = (v) => typeof v === "string" && /^#[0-9a-f]{6}$/i.test(v);
9113
+ const paths = Array.isArray(logo.paths) ? logo.paths : [];
9114
+ if (paths.length === 0 || paths.some((p) => {
9115
+ const path = p ?? {};
9116
+ return typeof path.d !== "string" || !/^[MmLlHhVvCcSsQqTtAaZzEe0-9.,\s+-]+$/.test(path.d) || path.fill !== void 0 && !hex(path.fill);
9117
+ }))
9118
+ add(
9119
+ `${at}.icon`,
9120
+ "must be an admin icon name, or { paths: [{ d, fill? }], color? } with SVG path data"
9121
+ );
9122
+ if (logo.color !== void 0 && !hex(logo.color)) add(`${at}.icon.color`, "must be #rrggbb");
9123
+ if (logo.viewBox !== void 0 && !(typeof logo.viewBox === "string" && /^-?[\d.]+( -?[\d.]+){3}$/.test(logo.viewBox)))
9124
+ add(`${at}.icon.viewBox`, "must be four numbers, e.g. '0 0 24 24'");
9125
+ }
8629
9126
  for (const [f, field] of (Array.isArray(item.fields) ? item.fields : []).entries()) {
8630
9127
  const fieldItem = field ?? {};
8631
9128
  if (fieldItem.lookup !== void 0 && typeof fieldItem.lookup !== "function")
@@ -8648,6 +9145,45 @@ function validateListeners(config, add) {
8648
9145
  if (typeof item.handler !== "function") add(`listeners[${i}].handler`, "must be a function");
8649
9146
  }
8650
9147
  }
9148
+ function validateAi(config, add) {
9149
+ const ai = config.ai;
9150
+ if (ai !== void 0) {
9151
+ const providers = ai?.providers;
9152
+ if (!Array.isArray(providers)) add("ai.providers", "must be an array");
9153
+ else {
9154
+ const ids = new Set((config.integrations ?? []).map((i) => i?.id));
9155
+ for (const [i, raw] of providers.entries()) {
9156
+ const provider = raw ?? {};
9157
+ if (typeof provider.integration !== "string" || !ids.has(provider.integration))
9158
+ add(`ai.providers[${i}].integration`, "must be the id of an integration");
9159
+ if (typeof provider.generate !== "function")
9160
+ add(`ai.providers[${i}].generate`, "must be a function");
9161
+ }
9162
+ }
9163
+ const limits = ai?.limits;
9164
+ const whole = (v) => Number.isInteger(v) && v >= 0;
9165
+ if (limits?.perUserPerDay !== void 0 && !whole(limits.perUserPerDay))
9166
+ add("ai.limits.perUserPerDay", "must be a whole number, 0 or more");
9167
+ for (const [role, value] of Object.entries(limits?.roles ?? {}))
9168
+ if (!whole(value)) add(`ai.limits.roles.${role}`, "must be a whole number, 0 or more");
9169
+ }
9170
+ const features = config.aiFeatures;
9171
+ if (features === void 0) return;
9172
+ if (!Array.isArray(features)) {
9173
+ add("aiFeatures", "must be an array");
9174
+ return;
9175
+ }
9176
+ const seen = /* @__PURE__ */ new Set();
9177
+ for (const [i, raw] of features.entries()) {
9178
+ const feature = raw ?? {};
9179
+ if (typeof feature.id !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(feature.id))
9180
+ add(`aiFeatures[${i}].id`, 'must be lowercase letters, digits and "-"', "e.g. id: 'alt-text'");
9181
+ else if (seen.has(feature.id) || feature.id === "test")
9182
+ add(`aiFeatures[${i}].id`, `"${feature.id}" is taken`);
9183
+ else seen.add(feature.id);
9184
+ if (!isLabel(feature.label)) add(`aiFeatures[${i}].label`, "must be a string or { en, th }");
9185
+ }
9186
+ }
8651
9187
 
8652
9188
  // src/resolve-config.ts
8653
9189
  var DEFAULT_ADMIN_PATH = "/admin";
@@ -8759,6 +9295,7 @@ async function resolveConfig(input) {
8759
9295
  bulkOperationsCollection,
8760
9296
  savedViewsCollection,
8761
9297
  ...resolvedIntegrations(config).length > 0 ? [connectionsCollection] : [],
9298
+ ...config.ai ? [aiUsageCollection] : [],
8762
9299
  ...config.auth?.rbac ? [rolesCollection] : [],
8763
9300
  ...(config.auth?.providers?.length ?? 0) > 0 ? [userIdentitiesCollection] : [],
8764
9301
  ...config.audit ? [auditLogsCollection] : []
@@ -8766,6 +9303,7 @@ async function resolveConfig(input) {
8766
9303
  globals: config.globals ?? [],
8767
9304
  endpoints: config.endpoints ?? [],
8768
9305
  integrations: resolvedIntegrations(config),
9306
+ aiFeatures: config.ai ? config.aiFeatures ?? [] : [],
8769
9307
  cliCommands: config.cliCommands ?? [],
8770
9308
  // Easy CMS's own events (#356), for webhooks and `cms.on`.
8771
9309
  events: [.../* @__PURE__ */ new Set([...config.events ?? [], ...SYSTEM_EVENTS])],
@@ -8884,7 +9422,7 @@ function cacheableResponse(request, body, status, headers, times, tags) {
8884
9422
  }
8885
9423
 
8886
9424
  // src/version.ts
8887
- var VERSION = true ? "1.32.0" : "0.0.0-dev";
9425
+ var VERSION = true ? "1.33.0" : "0.0.0-dev";
8888
9426
 
8889
9427
  // src/telemetry.ts
8890
9428
  var DB_CALLS = /* @__PURE__ */ new Set([
@@ -9163,6 +9701,8 @@ var EasyCMS = class {
9163
9701
  folders;
9164
9702
  /** Accounts connected to plugins' integrations (`integrations`, Settings → Integrations). */
9165
9703
  integrations;
9704
+ /** AI for the admin's features (#357): `null` without an AI plugin (`ai` in the config). */
9705
+ ai;
9166
9706
  versions;
9167
9707
  webhooks;
9168
9708
  mailer;
@@ -9191,6 +9731,7 @@ var EasyCMS = class {
9191
9731
  );
9192
9732
  this.audit = new AuditLog(this);
9193
9733
  this.integrations = new Integrations(this);
9734
+ this.ai = config.ai ? new Ai(this, config.ai) : null;
9194
9735
  for (const listener of config.listeners ?? []) {
9195
9736
  const run = (message2) => listener.handler(message2, { cms: this });
9196
9737
  if (listener.events === "*") this.anyListeners.add(run);
@@ -10718,6 +11259,7 @@ var EasyCMS = class {
10718
11259
  (error) => this.logger.error(`Backups: ${error.message}`, { error })
10719
11260
  );
10720
11261
  await this.audit.upkeep(now).catch((error) => this.logger.error(`Audit log: ${error.message}`, { error }));
11262
+ await this.ai?.upkeep(now).catch((error) => this.logger.error(`AI usage: ${error.message}`, { error }));
10721
11263
  await this.rateLimiter?.clear(now).catch((error) => this.logger.error(`Rate limits: ${error.message}`, { error }));
10722
11264
  const result = {
10723
11265
  scheduled,
@@ -12049,51 +12591,51 @@ var EasyCMS = class {
12049
12591
  const cms = this;
12050
12592
  const { user, context } = guard;
12051
12593
  const allowed2 = /* @__PURE__ */ new Map();
12052
- for (const ref of references2) {
12053
- const filter = ref.filterOptions;
12594
+ for (const ref2 of references2) {
12595
+ const filter = ref2.filterOptions;
12054
12596
  if (!filter) continue;
12055
12597
  const byCollection = allowed2.get(filter) ?? /* @__PURE__ */ new Map();
12056
12598
  allowed2.set(filter, byCollection);
12057
- if (!byCollection.has(ref.collection))
12599
+ if (!byCollection.has(ref2.collection))
12058
12600
  byCollection.set(
12059
- ref.collection,
12060
- await filter({ id: selfId, relationTo: ref.collection, user, cms, context })
12601
+ ref2.collection,
12602
+ await filter({ id: selfId, relationTo: ref2.collection, user, cms, context })
12061
12603
  );
12062
- const where = byCollection.get(ref.collection);
12604
+ const where = byCollection.get(ref2.collection);
12063
12605
  if (where === true) continue;
12064
- const target = this.config.collections.find((c) => c.slug === ref.collection);
12606
+ const target = this.config.collections.find((c) => c.slug === ref2.collection);
12065
12607
  const localization = this.config.localization;
12066
12608
  const scoped = target ? this.whereFor(target, where, localization ? { locale: localization.defaultLocale } : {}) : where;
12067
12609
  const count = await this.db.count({
12068
- collection: ref.collection,
12069
- where: { and: [{ id: { equals: ref.id } }, ...scoped ? [scoped] : []] }
12610
+ collection: ref2.collection,
12611
+ where: { and: [{ id: { equals: ref2.id } }, ...scoped ? [scoped] : []] }
12070
12612
  });
12071
12613
  if (count === 0)
12072
- errors.push({ field: ref.field, message: "is not one of the allowed choices" });
12614
+ errors.push({ field: ref2.field, message: "is not one of the allowed choices" });
12073
12615
  }
12074
12616
  return errors;
12075
12617
  }
12076
12618
  async checkReferences(references2, guard) {
12077
12619
  const byCollection = /* @__PURE__ */ new Map();
12078
12620
  const errors = [];
12079
- for (const ref of references2) {
12080
- const target = this.config.collections.find((c) => c.slug === ref.collection);
12081
- if (ref.link && (!target || INTERNAL_COLLECTIONS.has(ref.collection))) {
12621
+ for (const ref2 of references2) {
12622
+ const target = this.config.collections.find((c) => c.slug === ref2.collection);
12623
+ if (ref2.link && (!target || INTERNAL_COLLECTIONS.has(ref2.collection))) {
12082
12624
  errors.push({
12083
- field: ref.field,
12084
- message: `links to "${ref.collection}", which is not a collection`
12625
+ field: ref2.field,
12626
+ message: `links to "${ref2.collection}", which is not a collection`
12085
12627
  });
12086
12628
  continue;
12087
12629
  }
12088
- if (ref.link && typeof target?.preview !== "function") {
12630
+ if (ref2.link && typeof target?.preview !== "function") {
12089
12631
  errors.push({
12090
- field: ref.field,
12091
- message: `links to "${ref.collection}", whose documents have no URL (set its preview)`
12632
+ field: ref2.field,
12633
+ message: `links to "${ref2.collection}", whose documents have no URL (set its preview)`
12092
12634
  });
12093
12635
  continue;
12094
12636
  }
12095
12637
  if (!target) continue;
12096
- byCollection.set(ref.collection, [...byCollection.get(ref.collection) ?? [], ref]);
12638
+ byCollection.set(ref2.collection, [...byCollection.get(ref2.collection) ?? [], ref2]);
12097
12639
  }
12098
12640
  for (const [collection2, refs] of byCollection) {
12099
12641
  const found = await this.db.findByIds({
@@ -12101,28 +12643,28 @@ var EasyCMS = class {
12101
12643
  ids: [...new Set(refs.map((r) => r.id))]
12102
12644
  });
12103
12645
  const byId = new Map(found.map((d) => [d.id, d]));
12104
- for (const ref of refs) {
12105
- const doc = byId.get(ref.id);
12646
+ for (const ref2 of refs) {
12647
+ const doc = byId.get(ref2.id);
12106
12648
  if (!doc) {
12107
- errors.push({ field: ref.field, message: `${collection2} ${ref.id} does not exist` });
12649
+ errors.push({ field: ref2.field, message: `${collection2} ${ref2.id} does not exist` });
12108
12650
  continue;
12109
12651
  }
12110
12652
  const type = typeof doc.mimeType === "string" ? doc.mimeType : "";
12111
- if (ref.mimeTypes && !mimeAllowedBy(type, ref.mimeTypes)) {
12112
- const images = ref.mimeTypes.every((t) => t.toLowerCase() === "image/*");
12653
+ if (ref2.mimeTypes && !mimeAllowedBy(type, ref2.mimeTypes)) {
12654
+ const images = ref2.mimeTypes.every((t) => t.toLowerCase() === "image/*");
12113
12655
  errors.push({
12114
- field: ref.field,
12115
- message: images ? `must be an image (${doc.filename ?? ref.id} is ${type || "not one"})` : `must be ${ref.mimeTypes.join(", ")} (${doc.filename ?? ref.id} is ${type || "unknown"})`
12656
+ field: ref2.field,
12657
+ message: images ? `must be an image (${doc.filename ?? ref2.id} is ${type || "not one"})` : `must be ${ref2.mimeTypes.join(", ")} (${doc.filename ?? ref2.id} is ${type || "unknown"})`
12116
12658
  });
12117
12659
  }
12118
- if (ref.folderOnly && this.folders.enabled && !await this.folders.inKeyed(
12119
- ref.folderOnly,
12660
+ if (ref2.folderOnly && this.folders.enabled && !await this.folders.inKeyed(
12661
+ ref2.folderOnly,
12120
12662
  doc.folder ?? null,
12121
12663
  await this.folderScope(guard)
12122
12664
  ))
12123
12665
  errors.push({
12124
- field: ref.field,
12125
- message: `must be a file from the "${ref.folderOnly}" folder (${doc.filename ?? ref.id} is not)`
12666
+ field: ref2.field,
12667
+ message: `must be a file from the "${ref2.folderOnly}" folder (${doc.filename ?? ref2.id} is not)`
12126
12668
  });
12127
12669
  }
12128
12670
  }
@@ -12744,7 +13286,8 @@ var Roles = class {
12744
13286
  new: !seen("globals", g.slug),
12745
13287
  fields: adminFields(g.fields, void 0)
12746
13288
  })),
12747
- views: this.views()
13289
+ views: this.views(),
13290
+ ai: Boolean(this.config.ai)
12748
13291
  };
12749
13292
  }
12750
13293
  async create(input, author) {
@@ -12923,7 +13466,8 @@ var Roles = class {
12923
13466
  void 0
12924
13467
  )
12925
13468
  };
12926
- return { ...picked, admin, own, fields };
13469
+ const aiPerDay = this.config.ai && Number.isInteger(input.aiPerDay) && input.aiPerDay >= 0 ? { aiPerDay: input.aiPerDay } : {};
13470
+ return { ...picked, admin, own, fields, ...aiPerDay };
12927
13471
  }
12928
13472
  /**
12929
13473
  * Keeps rules for known top-level fields. A field that must be filled in (required, without a
@@ -12972,6 +13516,11 @@ var Roles = class {
12972
13516
  ]);
12973
13517
  return value.trim();
12974
13518
  }
13519
+ /** The role's daily AI limit set in Settings → Roles, if any (`auth.rbac`). */
13520
+ async aiLimit(key) {
13521
+ if (!this.enabled) return void 0;
13522
+ return (await this.permissionsOf(key)).aiPerDay;
13523
+ }
12975
13524
  async permissionsOf(key) {
12976
13525
  return (await this.load()).get(key) ?? {};
12977
13526
  }
@@ -13628,10 +14177,10 @@ function folderIds(cms, user, context) {
13628
14177
  return cms.folders.enabled ? (key) => cms.keyedFolder(key, { user, context }) : void 0;
13629
14178
  }
13630
14179
  function panelRef(panel) {
13631
- const ref = componentRef(panel);
14180
+ const ref2 = componentRef(panel);
13632
14181
  if (typeof panel === "object" && "position" in panel && panel.position === "top")
13633
- ref.position = "top";
13634
- return ref;
14182
+ ref2.position = "top";
14183
+ return ref2;
13635
14184
  }
13636
14185
  function layoutOf(layout, fields) {
13637
14186
  const shown2 = new Set(fields.filter((f) => f.position !== "sidebar").map((f) => f.name));
@@ -15097,6 +15646,15 @@ async function route(cms, ctx, method, path, options) {
15097
15646
  }
15098
15647
  throw methodNotAllowed(ctx, "PATCH, DELETE");
15099
15648
  }
15649
+ if (first === "admin" && second === "ai" && segments.length === 2) {
15650
+ if (!ctx.user) throw new UnauthorizedError();
15651
+ if (!cms.ai) throw new HttpError("Not found", 404);
15652
+ if (!await cms.roles.canView(ctx.user, "integrations")) throw new ForbiddenError();
15653
+ if (method === "GET") return { body: await cms.ai.overview() };
15654
+ if (method === "PUT")
15655
+ return { body: await cms.ai.update(await readJson(ctx.request), ctx.user) };
15656
+ throw methodNotAllowed(ctx, "GET, PUT");
15657
+ }
15100
15658
  if (first === "admin" && second === "integrations") {
15101
15659
  if (!ctx.user) throw new UnauthorizedError();
15102
15660
  if (cms.config.integrations.length === 0) throw new HttpError("Not found", 404);
@@ -15117,7 +15675,9 @@ async function route(cms, ctx, method, path, options) {
15117
15675
  if (part === "authorize" && cid === void 0) {
15118
15676
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
15119
15677
  const body = await readJson(ctx.request);
15120
- return { body: integrations.authorize(id2, user, callbackUrl, String(body.name ?? "")) };
15678
+ return {
15679
+ body: await integrations.authorize(id2, user, callbackUrl, String(body.name ?? ""))
15680
+ };
15121
15681
  }
15122
15682
  if (part === "callback" && cid === void 0) {
15123
15683
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
@@ -15140,6 +15700,10 @@ async function route(cms, ctx, method, path, options) {
15140
15700
  }
15141
15701
  return { body: new Response(null, { status: 302, headers: { location: page.toString() } }) };
15142
15702
  }
15703
+ if (part === "consent" && cid === void 0) {
15704
+ if (method !== "POST") throw methodNotAllowed(ctx, "POST");
15705
+ return { body: await integrations.consent(id2, user) };
15706
+ }
15143
15707
  if (part === "lookup" && cid !== void 0 && action === void 0) {
15144
15708
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
15145
15709
  return { body: await integrations.lookup(id2, cid, await readJson(ctx.request)) };
@@ -16201,10 +16765,11 @@ export {
16201
16765
  isStaff,
16202
16766
  isSignedIn,
16203
16767
  isAdmin,
16204
- secretsOf,
16205
16768
  MEDIA,
16206
16769
  MEDIA_FOLDERS,
16207
16770
  INTERNAL_COLLECTIONS,
16771
+ AiProviderError,
16772
+ secretsOf,
16208
16773
  AuditLog,
16209
16774
  API_KEYS,
16210
16775
  API_KEY_OPERATIONS,