@easy-cms/core 1.32.0 → 1.34.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,667 @@ 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 CORE_AI_FEATURES = [
752
+ {
753
+ id: "alt-text",
754
+ label: { en: "Alt text", th: "\u0E40\u0E02\u0E35\u0E22\u0E19 alt text" },
755
+ description: {
756
+ en: "Describes an image in the media library",
757
+ th: "\u0E1A\u0E23\u0E23\u0E22\u0E32\u0E22\u0E23\u0E39\u0E1B\u0E43\u0E19\u0E04\u0E25\u0E31\u0E07\u0E2A\u0E37\u0E48\u0E2D"
758
+ }
759
+ }
760
+ ];
761
+ var SETTINGS = "ai";
762
+ var DEFAULT_LIMIT = 100;
763
+ var TIMEOUT = 6e4;
764
+ var MAX_IMAGE = 4 * 1024 * 1024;
765
+ var KEEP = 90 * 24 * 36e5;
766
+ var IMAGE_TYPES = /* @__PURE__ */ new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
767
+ var AI_TEST_FEATURE = "test";
768
+ var ref = (integration, name) => `${integration}:${name}`;
769
+ var Ai = class {
770
+ constructor(cms, ai) {
771
+ this.cms = cms;
772
+ this.ai = ai;
773
+ }
774
+ cms;
775
+ ai;
776
+ get config() {
777
+ return this.cms.config;
778
+ }
779
+ get defaultLimit() {
780
+ return this.ai.limits?.perUserPerDay ?? DEFAULT_LIMIT;
781
+ }
782
+ /** The features of the config. */
783
+ get features() {
784
+ return this.config.aiFeatures;
785
+ }
786
+ async settingsRow() {
787
+ const found = await this.cms.db.find({
788
+ collection: CONNECTIONS,
789
+ where: { and: [{ kind: { equals: "settings" } }, { integration: { equals: SETTINGS } }] },
790
+ sort: ["id"],
791
+ limit: 1,
792
+ page: 1
793
+ });
794
+ return found.docs[0];
795
+ }
796
+ async settings() {
797
+ return (await this.settingsRow())?.values ?? {};
798
+ }
799
+ /** Every connection of every provider, in the providers' order. */
800
+ async connections() {
801
+ const out = [];
802
+ for (const provider of this.ai.providers)
803
+ for (const conn of await this.cms.integrations.all(provider.integration))
804
+ out.push({ ...conn, integration: provider.integration });
805
+ return out;
806
+ }
807
+ /** Whether a feature is on: admins' choice, else its `default`. */
808
+ async enabled(feature) {
809
+ const def = this.features.find((f) => f.id === feature);
810
+ if (!def) return false;
811
+ return (await this.settings()).features?.[feature]?.enabled ?? def.default ?? true;
812
+ }
813
+ /** Requests a user may make today: their role's, else everyone's. */
814
+ async limitFor(user) {
815
+ const role = typeof user.role === "string" ? user.role : void 0;
816
+ if (role) {
817
+ const fromRoles = await this.cms.roles.aiLimit(role);
818
+ if (fromRoles !== void 0) return fromRoles;
819
+ const fromConfig = this.ai.limits?.roles?.[role];
820
+ if (fromConfig !== void 0) return fromConfig;
821
+ }
822
+ return (await this.settings()).perUserPerDay ?? this.defaultLimit;
823
+ }
824
+ /** Requests the user made today (in `timeZone`) and how many are left. */
825
+ async remaining(user, now = /* @__PURE__ */ new Date()) {
826
+ const since = lastSlot(now, "00:00", this.config.timeZone).toISOString();
827
+ const used = await this.cms.db.count({
828
+ collection: AI_USAGE,
829
+ where: {
830
+ and: [
831
+ { user: { equals: String(user.id) } },
832
+ { ok: { equals: true } },
833
+ { createdAt: { gte: since } }
834
+ ]
835
+ }
836
+ });
837
+ return { used, limit: await this.limitFor(user) };
838
+ }
839
+ /**
840
+ * Asks the feature's connection (or the main one). With `user`, the request counts toward their
841
+ * daily limit; it is refused once they reach it. Usage is recorded, never the prompt.
842
+ */
843
+ async generate(request, options = {}) {
844
+ const feature = this.features.find((f) => f.id === request.feature);
845
+ if (!feature)
846
+ throw new QueryError(
847
+ `Unknown AI feature "${request.feature}": add it to \`aiFeatures\` in the config`
848
+ );
849
+ if (!await this.enabled(feature.id))
850
+ throw new QueryError(`The AI feature "${feature.id}" is turned off`);
851
+ const user = options.user ?? null;
852
+ if (user) {
853
+ const { used, limit } = await this.remaining(user);
854
+ if (used >= limit)
855
+ throw new TooManyRequestsError(
856
+ limit === 0 ? "Your role may not use AI" : `You used AI ${limit} times today, the most allowed`
857
+ );
858
+ }
859
+ const conn = await this.connectionFor(feature.id);
860
+ const images = await Promise.all((request.images ?? []).map((i) => this.image(i)));
861
+ return this.ask(conn, {
862
+ feature: feature.id,
863
+ user,
864
+ about: options.about,
865
+ request: {
866
+ ...request.system ? { system: request.system } : {},
867
+ prompt: request.prompt,
868
+ images,
869
+ ...request.json ? { json: request.json } : {},
870
+ maxTokens: request.maxTokens ?? 1024
871
+ }
872
+ });
873
+ }
874
+ /**
875
+ * The Test button of a provider's connection: a short prompt; its tokens count on the card,
876
+ * not toward anyone's limit.
877
+ */
878
+ async test(integration, values, name) {
879
+ try {
880
+ await this.ask(
881
+ { integration, name: name ?? "", values, env: false },
882
+ {
883
+ feature: AI_TEST_FEATURE,
884
+ user: null,
885
+ markConnection: false,
886
+ request: { prompt: "Reply with the word OK.", images: [], maxTokens: 16 }
887
+ }
888
+ );
889
+ return { ok: true, ...values.model ? { account: values.model } : {} };
890
+ } catch (error) {
891
+ return { ok: false, message: error.message };
892
+ }
893
+ }
894
+ async connectionFor(feature) {
895
+ const settings = await this.settings();
896
+ const wanted = settings.features?.[feature]?.connection ?? settings.main;
897
+ const all = await this.connections();
898
+ const conn = wanted && all.find((c) => ref(c.integration, c.name) === wanted) || all[0];
899
+ if (!conn)
900
+ throw new QueryError("No AI provider is connected: connect one in Settings \u2192 Integrations");
901
+ return conn;
902
+ }
903
+ async ask(conn, args) {
904
+ const provider = this.ai.providers.find((p) => p.integration === conn.integration);
905
+ if (!provider) throw new QueryError(`No AI provider answers for "${conn.integration}"`);
906
+ const record = (data) => this.cms.db.create({
907
+ collection: AI_USAGE,
908
+ data: {
909
+ user: args.user ? String(args.user.id) : null,
910
+ feature: args.feature,
911
+ integration: conn.integration,
912
+ connection: conn.name || null,
913
+ input: 0,
914
+ output: 0,
915
+ error: null,
916
+ about: args.about ? `${args.about.collection}:${args.about.id}` : null,
917
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
918
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
919
+ ...data
920
+ }
921
+ });
922
+ let result;
923
+ try {
924
+ result = await this.call(provider, conn.values, args.request);
925
+ } catch (error) {
926
+ const status = error instanceof AiProviderError ? error.status : void 0;
927
+ const message2 = error.message;
928
+ await record({ ok: false, error: message2.slice(0, 500) });
929
+ if ((status === 401 || status === 403) && args.markConnection !== false && conn.name)
930
+ await this.cms.integrations.markError(conn.integration, message2, conn.name);
931
+ throw new QueryError(message2);
932
+ }
933
+ await record({ ok: true, input: result.usage.input, output: result.usage.output });
934
+ if (args.markConnection !== false && conn.name)
935
+ await this.cms.integrations.markUsed(conn.integration, conn.name);
936
+ let json2;
937
+ if (args.request.json) {
938
+ try {
939
+ json2 = JSON.parse(stripFence(result.text));
940
+ } catch {
941
+ throw new QueryError("The AI answered with something other than JSON; try again");
942
+ }
943
+ }
944
+ return {
945
+ text: result.text,
946
+ ...args.request.json ? { json: json2 } : {},
947
+ usage: result.usage,
948
+ connection: ref(conn.integration, conn.name)
949
+ };
950
+ }
951
+ /** One try, and another after a second when the provider is busy or failing (429, 5xx). */
952
+ async call(provider, values, request) {
953
+ for (let attempt = 0; ; attempt++) {
954
+ try {
955
+ return await provider.generate(values, { ...request, signal: AbortSignal.timeout(TIMEOUT) });
956
+ } catch (error) {
957
+ const status = error instanceof AiProviderError ? error.status : void 0;
958
+ const busy = status === 429 || status !== void 0 && status >= 500;
959
+ if (!busy || attempt > 0) {
960
+ if (error.name === "TimeoutError")
961
+ throw new AiProviderError("The AI provider took longer than 60 seconds");
962
+ throw error;
963
+ }
964
+ await new Promise((resolve3) => setTimeout(resolve3, 1e3));
965
+ }
966
+ }
967
+ }
968
+ /** An image as base64: given, or read from a media document. */
969
+ async image(image) {
970
+ if (image.data !== void 0) {
971
+ if (!image.mediaType || !IMAGE_TYPES.has(image.mediaType))
972
+ throw new ValidationError(AI_USAGE, [
973
+ { field: "images", message: "must be JPEG, PNG, WebP or GIF" }
974
+ ]);
975
+ return { data: image.data, mediaType: image.mediaType };
976
+ }
977
+ if (image.media === void 0)
978
+ throw new ValidationError(AI_USAGE, [{ field: "images", message: "needs media or data" }]);
979
+ const doc = await this.cms.db.findById({ collection: MEDIA, id: image.media });
980
+ if (!doc) throw new ValidationError(AI_USAGE, [{ field: "images", message: "media not found" }]);
981
+ 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));
982
+ const filename = String(sizes[0]?.filename ?? doc.filename);
983
+ const mediaType = String(doc.mimeType ?? "");
984
+ if (!IMAGE_TYPES.has(mediaType))
985
+ throw new ValidationError(AI_USAGE, [
986
+ { field: "images", message: `${mediaType || "this file"} is not an image AI can read` }
987
+ ]);
988
+ const file = await this.cms.storageFor(doc.private === true).get(filename);
989
+ if (!file) throw new ValidationError(AI_USAGE, [{ field: "images", message: "file not found" }]);
990
+ if (file.body.byteLength > MAX_IMAGE)
991
+ throw new ValidationError(AI_USAGE, [
992
+ { field: "images", message: "is larger than 4 MB; add an image size of about 1024 pixels" }
993
+ ]);
994
+ return { data: Buffer.from(file.body).toString("base64"), mediaType };
995
+ }
996
+ /** For the admin's AI buttons: whether they work, and how many requests are left today. */
997
+ async status(user) {
998
+ const enabled = await Promise.all(
999
+ this.features.map(async (f) => await this.enabled(f.id) ? f.id : null)
1000
+ );
1001
+ return {
1002
+ connected: (await this.connections()).length > 0,
1003
+ features: enabled.filter((id) => id !== null),
1004
+ ...await this.remaining(user)
1005
+ };
1006
+ }
1007
+ /** The language to write in: the content locale being edited, else the default one. */
1008
+ language(locale) {
1009
+ const code = locale ?? this.config.localization?.defaultLocale ?? this.config.admin.locale ?? "en";
1010
+ try {
1011
+ return new Intl.DisplayNames(["en"], { type: "language" }).of(code) ?? code;
1012
+ } catch {
1013
+ return code;
1014
+ }
1015
+ }
1016
+ /**
1017
+ * Alt text for an image in the media library (`alt-text`), for the user to review before saving:
1018
+ * what it shows, in one sentence of at most 125 characters.
1019
+ */
1020
+ async altText(id, options) {
1021
+ const access = await this.cms.documentPermissions(
1022
+ MEDIA,
1023
+ String(id),
1024
+ options.user,
1025
+ options.context ?? {}
1026
+ );
1027
+ if (!access.update) throw new ForbiddenError("You may not change this file");
1028
+ const result = await this.generate(
1029
+ {
1030
+ feature: "alt-text",
1031
+ system: [
1032
+ "You write alt text for images on a website: one plain sentence saying what the image",
1033
+ 'shows, for people who cannot see it. Do not start with "Image of" or "Picture of".',
1034
+ `At most 125 characters. Write it in ${this.language(options.locale)}.`,
1035
+ "Answer with the alt text only, without quotes."
1036
+ ].join(" "),
1037
+ prompt: "Write the alt text for this image.",
1038
+ images: [{ media: id }],
1039
+ maxTokens: 200
1040
+ },
1041
+ { user: options.user, about: { collection: MEDIA, id } }
1042
+ );
1043
+ return { alt: shorten(result.text.trim().replace(/^["'“]+|["'”]+$/g, ""), 125) };
1044
+ }
1045
+ // --- Admins ----------------------------------------------------------------------------------
1046
+ /** Settings → Integrations, AI: the main connection, the features, the limit and this month. */
1047
+ async overview(now = /* @__PURE__ */ new Date()) {
1048
+ const settings = await this.settings();
1049
+ const conns = await this.connections();
1050
+ const labels = new Map(this.config.integrations.map((i) => [i.id, i.label]));
1051
+ const month = monthStart(now, this.config.timeZone).toISOString();
1052
+ const rows2 = (await this.cms.db.find({
1053
+ collection: AI_USAGE,
1054
+ where: { createdAt: { gte: month } },
1055
+ sort: ["id"],
1056
+ limit: 0,
1057
+ page: 1
1058
+ })).docs;
1059
+ const sum = (key) => {
1060
+ const totals = /* @__PURE__ */ new Map();
1061
+ for (const row of rows2) {
1062
+ if (key === "user" && !row.user) continue;
1063
+ const k = String(row[key]);
1064
+ const t = totals.get(k) ?? { requests: 0, input: 0, output: 0 };
1065
+ t.requests++;
1066
+ t.input += Number(row.input ?? 0);
1067
+ t.output += Number(row.output ?? 0);
1068
+ totals.set(k, t);
1069
+ }
1070
+ return [...totals].sort((a, b) => b[1].requests - a[1].requests);
1071
+ };
1072
+ const users = sum("user");
1073
+ const emails = /* @__PURE__ */ new Map();
1074
+ if (users.length > 0) {
1075
+ const found = await this.cms.db.findByIds({
1076
+ collection: "users",
1077
+ ids: users.map(([id]) => /^\d+$/.test(id) ? Number(id) : id)
1078
+ });
1079
+ for (const u of found) emails.set(String(u.id), String(u.email ?? u.id));
1080
+ }
1081
+ return {
1082
+ main: settings.main ?? null,
1083
+ perUserPerDay: settings.perUserPerDay ?? this.defaultLimit,
1084
+ defaultPerUserPerDay: this.defaultLimit,
1085
+ connections: conns.map((c) => ({
1086
+ id: ref(c.integration, c.name),
1087
+ integration: c.integration,
1088
+ name: c.name,
1089
+ label: labels.get(c.integration) ?? c.integration
1090
+ })),
1091
+ features: await Promise.all(
1092
+ this.features.map(async (f) => ({
1093
+ id: f.id,
1094
+ label: f.label,
1095
+ ...f.description ? { description: f.description } : {},
1096
+ enabled: await this.enabled(f.id),
1097
+ connection: settings.features?.[f.id]?.connection ?? null
1098
+ }))
1099
+ ),
1100
+ usage: {
1101
+ byUser: users.map(([id, t]) => ({ user: emails.get(id) ?? id, ...t })),
1102
+ byFeature: sum("feature").map(([feature, t]) => ({ feature, ...t }))
1103
+ }
1104
+ };
1105
+ }
1106
+ /** Tokens and requests of each connection this month, for the cards. */
1107
+ async usageByConnection(now = /* @__PURE__ */ new Date()) {
1108
+ const month = monthStart(now, this.config.timeZone).toISOString();
1109
+ const rows2 = (await this.cms.db.find({
1110
+ collection: AI_USAGE,
1111
+ where: { createdAt: { gte: month } },
1112
+ sort: ["id"],
1113
+ limit: 0,
1114
+ page: 1
1115
+ })).docs;
1116
+ const out = /* @__PURE__ */ new Map();
1117
+ for (const row of rows2) {
1118
+ const k = ref(String(row.integration), String(row.connection ?? ""));
1119
+ const t = out.get(k) ?? { requests: 0, input: 0, output: 0 };
1120
+ t.requests++;
1121
+ t.input += Number(row.input ?? 0);
1122
+ t.output += Number(row.output ?? 0);
1123
+ out.set(k, t);
1124
+ }
1125
+ return out;
1126
+ }
1127
+ /** Saves admins' choices; audit-logged. */
1128
+ async update(body, user) {
1129
+ const input = body ?? {};
1130
+ const current = await this.settings();
1131
+ const conns = new Set((await this.connections()).map((c) => ref(c.integration, c.name)));
1132
+ const next = { ...current };
1133
+ const fail = (field, message2) => {
1134
+ throw new ValidationError(AI_USAGE, [{ field, message: message2 }]);
1135
+ };
1136
+ if (input.main !== void 0) {
1137
+ if (input.main === null) delete next.main;
1138
+ else if (typeof input.main === "string" && conns.has(input.main)) next.main = input.main;
1139
+ else fail("main", "is not a connection");
1140
+ }
1141
+ if (input.perUserPerDay !== void 0) {
1142
+ if (input.perUserPerDay === null) delete next.perUserPerDay;
1143
+ else if (Number.isInteger(input.perUserPerDay) && input.perUserPerDay >= 0)
1144
+ next.perUserPerDay = input.perUserPerDay;
1145
+ else fail("perUserPerDay", "must be a whole number, 0 or more");
1146
+ }
1147
+ if (input.features !== void 0) {
1148
+ if (typeof input.features !== "object" || input.features === null)
1149
+ fail("features", "must be an object");
1150
+ const features = { ...current.features ?? {} };
1151
+ for (const [id, raw] of Object.entries(input.features)) {
1152
+ if (!this.features.some((f) => f.id === id)) fail("features", `unknown feature "${id}"`);
1153
+ const change = raw ?? {};
1154
+ const entry = { ...features[id] ?? {} };
1155
+ if (typeof change.enabled === "boolean") entry.enabled = change.enabled;
1156
+ if (change.connection === null) delete entry.connection;
1157
+ else if (change.connection !== void 0) {
1158
+ if (typeof change.connection !== "string" || !conns.has(change.connection))
1159
+ fail("features", "connection is not a connection");
1160
+ entry.connection = change.connection;
1161
+ }
1162
+ features[id] = entry;
1163
+ }
1164
+ next.features = features;
1165
+ }
1166
+ const row = await this.settingsRow();
1167
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1168
+ if (row)
1169
+ await this.cms.db.update({
1170
+ collection: CONNECTIONS,
1171
+ id: row.id,
1172
+ data: { ...row, values: next, updatedAt: now }
1173
+ });
1174
+ else
1175
+ await this.cms.db.create({
1176
+ collection: CONNECTIONS,
1177
+ data: {
1178
+ kind: "settings",
1179
+ integration: SETTINGS,
1180
+ name: null,
1181
+ isDefault: false,
1182
+ values: next,
1183
+ secret: null,
1184
+ account: null,
1185
+ lastUsedAt: null,
1186
+ lastError: null,
1187
+ enabled: null,
1188
+ createdAt: now,
1189
+ updatedAt: now
1190
+ }
1191
+ });
1192
+ await this.cms.audit.record({
1193
+ action: "integration.ai",
1194
+ target: SETTINGS,
1195
+ user,
1196
+ detail: { ...next }
1197
+ });
1198
+ return this.overview();
1199
+ }
1200
+ /** Forgets usage older than 90 days (with the scheduled jobs). */
1201
+ async upkeep(now = /* @__PURE__ */ new Date()) {
1202
+ const before = new Date(now.getTime() - KEEP).toISOString();
1203
+ for (; ; ) {
1204
+ const old = await this.cms.db.find({
1205
+ collection: AI_USAGE,
1206
+ where: { createdAt: { lt: before } },
1207
+ sort: ["id"],
1208
+ limit: 500,
1209
+ page: 1
1210
+ });
1211
+ for (const row of old.docs) await this.cms.db.delete({ collection: AI_USAGE, id: row.id });
1212
+ if (old.docs.length < 500) return;
1213
+ }
1214
+ }
1215
+ };
1216
+ function shorten(text4, max) {
1217
+ if (text4.length <= max) return text4;
1218
+ const cut2 = text4.slice(0, max);
1219
+ const space = cut2.lastIndexOf(" ");
1220
+ return (space > max * 0.6 ? cut2.slice(0, space) : cut2).replace(/[\s,;:]+$/, "");
1221
+ }
1222
+ function stripFence(text4) {
1223
+ const trimmed = text4.trim();
1224
+ const match = /^```(?:json)?\s*\n([\s\S]*?)\n?```$/.exec(trimmed);
1225
+ return match ? match[1] : trimmed;
1226
+ }
1227
+ function monthStart(now, zone) {
1228
+ let day = lastSlot(now, "00:00", zone);
1229
+ const dayOf = (d) => Number(new Intl.DateTimeFormat("en-US", { day: "numeric", timeZone: zone }).format(d));
1230
+ for (let i = 0; i < 31 && dayOf(day) > 1; i++)
1231
+ day = lastSlot(new Date(day.getTime() - 1), "00:00", zone);
1232
+ return day;
1233
+ }
1234
+
1235
+ // src/auth/tokens.ts
1236
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto";
1237
+ function newToken() {
1238
+ return randomBytes(32).toString("base64url");
1239
+ }
1240
+ function hashToken(token) {
1241
+ return createHash("sha256").update(token).digest("hex");
1242
+ }
1243
+ function hmac(secret, value) {
1244
+ return createHmac("sha256", secret).update(value).digest("base64url");
1245
+ }
1246
+ var signValue = (secret, value) => hmac(secret, value);
1247
+ function secretsOf(config) {
1248
+ return [config.secret, ...config.previousSecrets ?? []];
1249
+ }
1250
+ function signedWith(secrets, value, signature) {
1251
+ return [secrets].flat().some((secret) => safeEqual(signature, hmac(secret, value)));
1252
+ }
1253
+ function signToken(secret, token) {
1254
+ return `${token}.${hmac(secret, `session:${token}`)}`;
1255
+ }
1256
+ function unsignToken(secrets, value) {
1257
+ const dot = value.lastIndexOf(".");
1258
+ if (dot <= 0) return void 0;
1259
+ const token = value.slice(0, dot);
1260
+ return signedWith(secrets, `session:${token}`, value.slice(dot + 1)) ? token : void 0;
1261
+ }
1262
+ function csrfToken(secret, token) {
1263
+ return hmac(secret, `csrf:${token}`);
1264
+ }
1265
+ function csrfMatches(secrets, token, given) {
1266
+ return signedWith(secrets, `csrf:${token}`, given);
1267
+ }
1268
+ function safeEqual(a, b) {
1269
+ const left = Buffer.from(a);
1270
+ const right = Buffer.from(b);
1271
+ return left.length === right.length && timingSafeEqual(left, right);
1272
+ }
1273
+ function signPreviewToken(secret, target, expiresAt, user) {
1274
+ const payload = Buffer.from(
1275
+ JSON.stringify({
1276
+ ..."global" in target ? { g: target.global } : { c: target.collection, i: target.id },
1277
+ e: expiresAt,
1278
+ ...user !== void 0 ? { u: user } : {}
1279
+ })
1280
+ ).toString("base64url");
1281
+ return `${payload}.${hmac(secret, `preview:${payload}`)}`;
1282
+ }
1283
+ function verifyPreviewToken(secrets, token, now = Date.now()) {
1284
+ const dot = token.lastIndexOf(".");
1285
+ if (dot <= 0) return null;
1286
+ const payload = token.slice(0, dot);
1287
+ if (!signedWith(secrets, `preview:${payload}`, token.slice(dot + 1))) return null;
1288
+ try {
1289
+ const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
1290
+ if (typeof data.e !== "number" || data.e <= now) return null;
1291
+ const user = typeof data.u === "string" ? { user: data.u } : {};
1292
+ if (typeof data.g === "string") return { global: data.g, expiresAt: data.e, ...user };
1293
+ if (typeof data.c === "string" && typeof data.i === "string")
1294
+ return { collection: data.c, id: data.i, expiresAt: data.e, ...user };
1295
+ return null;
1296
+ } catch {
1297
+ return null;
1298
+ }
1299
+ }
1300
+ function passwordFingerprint(passwordHash) {
1301
+ return createHash("sha256").update(typeof passwordHash === "string" ? passwordHash : "no-password").digest("base64url").slice(0, 22);
1302
+ }
1303
+ function signPasswordToken(secret, args) {
1304
+ const payload = Buffer.from(
1305
+ JSON.stringify({ u: args.userId, p: args.purpose, e: args.expiresAt })
1306
+ ).toString("base64url");
1307
+ return `${payload}.${hmac(secret, `password:${payload}:${args.fingerprint}`)}`;
1308
+ }
1309
+ function readPasswordToken(token) {
1310
+ const dot = token.lastIndexOf(".");
1311
+ if (dot <= 0) return null;
1312
+ const payload = token.slice(0, dot);
1313
+ try {
1314
+ const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
1315
+ if (typeof data.u !== "string" || typeof data.e !== "number") return null;
1316
+ if (data.p !== "reset" && data.p !== "invite" && data.p !== "verify") return null;
1317
+ return { userId: data.u, purpose: data.p, expiresAt: data.e, payload };
1318
+ } catch {
1319
+ return null;
1320
+ }
1321
+ }
1322
+ function passwordTokenMatches(secrets, token, fingerprint) {
1323
+ const dot = token.lastIndexOf(".");
1324
+ if (dot <= 0) return false;
1325
+ const payload = token.slice(0, dot);
1326
+ return signedWith(secrets, `password:${payload}:${fingerprint}`, token.slice(dot + 1));
1327
+ }
1328
+
744
1329
  // src/audit.ts
745
1330
  import { AsyncLocalStorage } from "async_hooks";
746
1331
  import { createHmac as createHmac2 } from "crypto";
@@ -758,7 +1343,7 @@ var DEFAULT_FAILED_LOGINS = 20;
758
1343
  var MAX_VALUE = 500;
759
1344
  var PAGE = 50;
760
1345
  var CSV_LIMIT = 1e4;
761
- var DAY = 864e5;
1346
+ var DAY2 = 864e5;
762
1347
  var BULKY = /* @__PURE__ */ new Set(["richText", "blocks", "array", "json"]);
763
1348
  var auditContext = new AsyncLocalStorage();
764
1349
  var SIGN_IN_FAILURES = /* @__PURE__ */ new Set(["login.failed", "login.locked", "sso.failed"]);
@@ -1033,10 +1618,10 @@ var AuditLog = class {
1033
1618
  async upkeep(now = /* @__PURE__ */ new Date()) {
1034
1619
  if (!this.enabled) return;
1035
1620
  const last = await this.lastVerification();
1036
- if (last && now.getTime() - Date.parse(last.at) < DAY) return;
1621
+ if (last && now.getTime() - Date.parse(last.at) < DAY2) return;
1037
1622
  const keep = this.settings.keepDays;
1038
1623
  if (keep > 0) {
1039
- const before = new Date(now.getTime() - keep * DAY).toISOString();
1624
+ const before = new Date(now.getTime() - keep * DAY2).toISOString();
1040
1625
  for (; ; ) {
1041
1626
  const { docs } = await this.cms.db.find({
1042
1627
  collection: AUDIT_LOGS,
@@ -3311,82 +3896,6 @@ import { tmpdir } from "os";
3311
3896
  import { join as join2 } from "path";
3312
3897
  import { promisify } from "util";
3313
3898
  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
3899
  var gzipAsync = promisify(gzip);
3391
3900
  var MAGIC = Buffer.from("ECMSBAK1");
3392
3901
  var IV_BYTES = 12;
@@ -4927,7 +5436,7 @@ var OFFICIAL_PLUGINS = [
4927
5436
  en: "Claude, GPT and Gemini for alt text, SEO and translation",
4928
5437
  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
5438
  },
4930
- docs: "https://easycms.dev/docs/integrations"
5439
+ docs: "https://easycms.dev/docs/ai"
4931
5440
  },
4932
5441
  {
4933
5442
  name: "@easy-cms/plugin-ecommerce",
@@ -5066,6 +5575,8 @@ var Integrations = class {
5066
5575
  // --- The admin's page ------------------------------------------------------------------------
5067
5576
  async overview() {
5068
5577
  const integrations = [];
5578
+ const usage = await this.cms.ai?.usageByConnection() ?? /* @__PURE__ */ new Map();
5579
+ const aiProviders = new Set(this.config.ai?.providers.map((p) => p.integration) ?? []);
5069
5580
  const current = keyId(keyOf2(this.config.secret));
5070
5581
  let stale = 0;
5071
5582
  for (const def of this.definitions) {
@@ -5126,7 +5637,11 @@ var Integrations = class {
5126
5637
  enabled: await this.enabled(def.id),
5127
5638
  envFields: Object.keys(env),
5128
5639
  testable: typeof def.test === "function",
5129
- connections
5640
+ connections: aiProviders.has(def.id) ? connections.map((c) => ({
5641
+ ...c,
5642
+ usage: usage.get(`${def.id}:${c.name}`) ?? { requests: 0, input: 0, output: 0 }
5643
+ })) : connections,
5644
+ ...def.consent ? { consent: def.consent, consented: await this.consented(def.id) } : {}
5130
5645
  });
5131
5646
  }
5132
5647
  const installed = new Set(this.config.installedPlugins.map((p) => p.name));
@@ -5204,11 +5719,12 @@ var Integrations = class {
5204
5719
  async connect(id, body, user) {
5205
5720
  const def = this.definition(id);
5206
5721
  if (def.kind !== "fields") throw new QueryError(`${id} connects with its Connect button`);
5722
+ await this.checkConsent(def);
5207
5723
  const input = body ?? {};
5208
5724
  const name = await this.uniqueName(id, String(input.name ?? ""));
5209
5725
  const values = this.cleanValues(def, input.values);
5210
5726
  this.checkFields(def, values, false);
5211
- const result = def.test ? await def.test(values, { cms: this.cms }) : null;
5727
+ const result = def.test ? await def.test(values, { cms: this.cms, name }) : null;
5212
5728
  if (result && !result.ok)
5213
5729
  throw new ValidationError(CONNECTIONS, [
5214
5730
  { field: "values", message: result.message ?? "the provider refused these values" }
@@ -5268,13 +5784,41 @@ var Integrations = class {
5268
5784
  /** Turns the integration on or off; its connections are kept. */
5269
5785
  async setEnabled(id, enabled, user) {
5270
5786
  this.definition(id);
5787
+ await this.saveIntegration(id, { enabled });
5788
+ await this.audit(user, enabled ? "enable" : "disable", id);
5789
+ return { enabled };
5790
+ }
5791
+ /** Who accepted the integration's `consent`, and when; `null` before anyone did. */
5792
+ async consented(id) {
5793
+ const [row] = await this.rows(id, "integration");
5794
+ const consent = (row?.values ?? {}).consent;
5795
+ return consent ?? null;
5796
+ }
5797
+ async checkConsent(def) {
5798
+ if (def.consent && !await this.consented(def.id))
5799
+ throw new ValidationError(CONNECTIONS, [
5800
+ { field: "consent", message: "must be accepted before connecting" }
5801
+ ]);
5802
+ }
5803
+ /** An admin accepts the integration's `consent`; audit-logged. */
5804
+ async consent(id, user) {
5805
+ const def = this.definition(id);
5806
+ if (!def.consent) throw new QueryError(`${id} asks for no consent`);
5807
+ const [row] = await this.rows(id, "integration");
5808
+ const consent = { at: (/* @__PURE__ */ new Date()).toISOString(), by: String(user.email ?? user.id) };
5809
+ await this.saveIntegration(id, { values: { ...row?.values ?? {}, consent } });
5810
+ await this.audit(user, "consent", id);
5811
+ return { consented: consent };
5812
+ }
5813
+ /** The integration's own row (on or off, consent), made when first needed. */
5814
+ async saveIntegration(id, patch) {
5271
5815
  const [row] = await this.rows(id, "integration");
5272
5816
  const now = (/* @__PURE__ */ new Date()).toISOString();
5273
5817
  if (row)
5274
5818
  await this.cms.db.update({
5275
5819
  collection: CONNECTIONS,
5276
5820
  id: row.id,
5277
- data: { ...row, enabled, updatedAt: now }
5821
+ data: { ...row, ...patch, updatedAt: now }
5278
5822
  });
5279
5823
  else
5280
5824
  await this.cms.db.create({
@@ -5289,22 +5833,22 @@ var Integrations = class {
5289
5833
  account: null,
5290
5834
  lastUsedAt: null,
5291
5835
  lastError: null,
5292
- enabled,
5836
+ enabled: null,
5293
5837
  createdAt: now,
5294
- updatedAt: now
5838
+ updatedAt: now,
5839
+ ...patch
5295
5840
  }
5296
5841
  });
5297
- await this.audit(user, enabled ? "enable" : "disable", id);
5298
- return { enabled };
5299
5842
  }
5300
5843
  /** Checks a connection's values with the provider; the card shows the result. */
5301
5844
  async test(id, cid) {
5302
5845
  const def = this.definition(id);
5303
5846
  if (!def.test) throw new QueryError(`${id} can't be tested`);
5304
5847
  const values = cid === "env" ? this.envValues(def) : { ...this.decrypt(await this.row(id, cid)), ...this.envValues(def) };
5848
+ const name = cid === "env" ? ENV_NAME : String((await this.row(id, cid)).name);
5305
5849
  let result;
5306
5850
  try {
5307
- result = await def.test(values, { cms: this.cms });
5851
+ result = await def.test(values, { cms: this.cms, name });
5308
5852
  } catch (error) {
5309
5853
  result = { ok: false, message: error.message };
5310
5854
  }
@@ -5349,10 +5893,11 @@ var Integrations = class {
5349
5893
  }
5350
5894
  // --- OAuth -----------------------------------------------------------------------------------
5351
5895
  /** The provider's sign-in page to send the admin to. */
5352
- authorize(id, user, redirectUri, name) {
5896
+ async authorize(id, user, redirectUri, name) {
5353
5897
  const def = this.definition(id);
5354
5898
  if (def.kind !== "oauth" || !def.oauth)
5355
5899
  throw new QueryError(`${id} connects with pasted values`);
5900
+ await this.checkConsent(def);
5356
5901
  const state = signState(this.config.secret, {
5357
5902
  i: id,
5358
5903
  u: String(user.id),
@@ -6150,7 +6695,7 @@ function read(response, maxBytes, deadline) {
6150
6695
 
6151
6696
  // src/config-keys.ts
6152
6697
  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
6698
+ var CONFIG = keys(`secret previousSecrets integrations listeners ai aiFeatures db serverURL cors cronSecret timeZone webhooks events jobs localization routes admin
6154
6699
  upload auth collections globals endpoints cliCommands onRequest fieldTypes apiKeys email backups
6155
6700
  audit logs countCacheSeconds maxPage httpCache outbox rateLimit search telemetry plugins`);
6156
6701
  var ADMIN = keys("path locale brand siteURL nav commands modules pages dashboard switcher");
@@ -6704,7 +7249,7 @@ var WEBHOOK_EVENTS = [
6704
7249
  var RETRY_DELAYS = [1e3, 5e3];
6705
7250
  var QUEUE_DELAYS2 = [6e4, 5 * 6e4, 30 * 6e4, 2 * 36e5, 6 * 36e5, 12 * 36e5];
6706
7251
  var CLAIM2 = 5 * 6e4;
6707
- var TIMEOUT = 1e4;
7252
+ var TIMEOUT2 = 1e4;
6708
7253
  var Webhooks = class {
6709
7254
  constructor(hooks, logger, queue, fetcher = (...args) => fetch(...args), retryDelays = RETRY_DELAYS) {
6710
7255
  this.hooks = hooks;
@@ -6872,7 +7417,7 @@ var Webhooks = class {
6872
7417
  method: "POST",
6873
7418
  headers,
6874
7419
  body,
6875
- signal: AbortSignal.timeout(TIMEOUT)
7420
+ signal: AbortSignal.timeout(TIMEOUT2)
6876
7421
  });
6877
7422
  if (response.ok || response.status < 500 && response.status !== 429) {
6878
7423
  if (!response.ok)
@@ -6981,6 +7526,7 @@ function validateConfig(config) {
6981
7526
  validateJobs(config, add);
6982
7527
  validateIntegrations(config, add);
6983
7528
  validateListeners(config, add);
7529
+ validateAi(config, add);
6984
7530
  validateTimeZone(config, add);
6985
7531
  validateEvents(config, add);
6986
7532
  const apiKeys = config.apiKeys;
@@ -8626,6 +9172,22 @@ function validateIntegrations(config, add) {
8626
9172
  } else add(`${at}.kind`, "must be 'fields' or 'oauth'");
8627
9173
  if (item.test !== void 0 && typeof item.test !== "function")
8628
9174
  add(`${at}.test`, "must be a function");
9175
+ if (item.icon !== void 0 && typeof item.icon !== "string") {
9176
+ const logo = item.icon ?? {};
9177
+ const hex = (v) => typeof v === "string" && /^#[0-9a-f]{6}$/i.test(v);
9178
+ const paths = Array.isArray(logo.paths) ? logo.paths : [];
9179
+ if (paths.length === 0 || paths.some((p) => {
9180
+ const path = p ?? {};
9181
+ return typeof path.d !== "string" || !/^[MmLlHhVvCcSsQqTtAaZzEe0-9.,\s+-]+$/.test(path.d) || path.fill !== void 0 && !hex(path.fill);
9182
+ }))
9183
+ add(
9184
+ `${at}.icon`,
9185
+ "must be an admin icon name, or { paths: [{ d, fill? }], color? } with SVG path data"
9186
+ );
9187
+ if (logo.color !== void 0 && !hex(logo.color)) add(`${at}.icon.color`, "must be #rrggbb");
9188
+ if (logo.viewBox !== void 0 && !(typeof logo.viewBox === "string" && /^-?[\d.]+( -?[\d.]+){3}$/.test(logo.viewBox)))
9189
+ add(`${at}.icon.viewBox`, "must be four numbers, e.g. '0 0 24 24'");
9190
+ }
8629
9191
  for (const [f, field] of (Array.isArray(item.fields) ? item.fields : []).entries()) {
8630
9192
  const fieldItem = field ?? {};
8631
9193
  if (fieldItem.lookup !== void 0 && typeof fieldItem.lookup !== "function")
@@ -8648,6 +9210,45 @@ function validateListeners(config, add) {
8648
9210
  if (typeof item.handler !== "function") add(`listeners[${i}].handler`, "must be a function");
8649
9211
  }
8650
9212
  }
9213
+ function validateAi(config, add) {
9214
+ const ai = config.ai;
9215
+ if (ai !== void 0) {
9216
+ const providers = ai?.providers;
9217
+ if (!Array.isArray(providers)) add("ai.providers", "must be an array");
9218
+ else {
9219
+ const ids = new Set((config.integrations ?? []).map((i) => i?.id));
9220
+ for (const [i, raw] of providers.entries()) {
9221
+ const provider = raw ?? {};
9222
+ if (typeof provider.integration !== "string" || !ids.has(provider.integration))
9223
+ add(`ai.providers[${i}].integration`, "must be the id of an integration");
9224
+ if (typeof provider.generate !== "function")
9225
+ add(`ai.providers[${i}].generate`, "must be a function");
9226
+ }
9227
+ }
9228
+ const limits = ai?.limits;
9229
+ const whole = (v) => Number.isInteger(v) && v >= 0;
9230
+ if (limits?.perUserPerDay !== void 0 && !whole(limits.perUserPerDay))
9231
+ add("ai.limits.perUserPerDay", "must be a whole number, 0 or more");
9232
+ for (const [role, value] of Object.entries(limits?.roles ?? {}))
9233
+ if (!whole(value)) add(`ai.limits.roles.${role}`, "must be a whole number, 0 or more");
9234
+ }
9235
+ const features = config.aiFeatures;
9236
+ if (features === void 0) return;
9237
+ if (!Array.isArray(features)) {
9238
+ add("aiFeatures", "must be an array");
9239
+ return;
9240
+ }
9241
+ const seen = /* @__PURE__ */ new Set();
9242
+ for (const [i, raw] of features.entries()) {
9243
+ const feature = raw ?? {};
9244
+ if (typeof feature.id !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(feature.id))
9245
+ add(`aiFeatures[${i}].id`, 'must be lowercase letters, digits and "-"', "e.g. id: 'alt-text'");
9246
+ else if (seen.has(feature.id) || feature.id === "test" || feature.id === "alt-text")
9247
+ add(`aiFeatures[${i}].id`, `"${feature.id}" is taken`);
9248
+ else seen.add(feature.id);
9249
+ if (!isLabel(feature.label)) add(`aiFeatures[${i}].label`, "must be a string or { en, th }");
9250
+ }
9251
+ }
8651
9252
 
8652
9253
  // src/resolve-config.ts
8653
9254
  var DEFAULT_ADMIN_PATH = "/admin";
@@ -8759,6 +9360,7 @@ async function resolveConfig(input) {
8759
9360
  bulkOperationsCollection,
8760
9361
  savedViewsCollection,
8761
9362
  ...resolvedIntegrations(config).length > 0 ? [connectionsCollection] : [],
9363
+ ...config.ai ? [aiUsageCollection] : [],
8762
9364
  ...config.auth?.rbac ? [rolesCollection] : [],
8763
9365
  ...(config.auth?.providers?.length ?? 0) > 0 ? [userIdentitiesCollection] : [],
8764
9366
  ...config.audit ? [auditLogsCollection] : []
@@ -8766,6 +9368,7 @@ async function resolveConfig(input) {
8766
9368
  globals: config.globals ?? [],
8767
9369
  endpoints: config.endpoints ?? [],
8768
9370
  integrations: resolvedIntegrations(config),
9371
+ aiFeatures: config.ai ? [...CORE_AI_FEATURES, ...config.aiFeatures ?? []] : [],
8769
9372
  cliCommands: config.cliCommands ?? [],
8770
9373
  // Easy CMS's own events (#356), for webhooks and `cms.on`.
8771
9374
  events: [.../* @__PURE__ */ new Set([...config.events ?? [], ...SYSTEM_EVENTS])],
@@ -8884,7 +9487,7 @@ function cacheableResponse(request, body, status, headers, times, tags) {
8884
9487
  }
8885
9488
 
8886
9489
  // src/version.ts
8887
- var VERSION = true ? "1.32.0" : "0.0.0-dev";
9490
+ var VERSION = true ? "1.34.0" : "0.0.0-dev";
8888
9491
 
8889
9492
  // src/telemetry.ts
8890
9493
  var DB_CALLS = /* @__PURE__ */ new Set([
@@ -9163,6 +9766,8 @@ var EasyCMS = class {
9163
9766
  folders;
9164
9767
  /** Accounts connected to plugins' integrations (`integrations`, Settings → Integrations). */
9165
9768
  integrations;
9769
+ /** AI for the admin's features (#357): `null` without an AI plugin (`ai` in the config). */
9770
+ ai;
9166
9771
  versions;
9167
9772
  webhooks;
9168
9773
  mailer;
@@ -9191,6 +9796,7 @@ var EasyCMS = class {
9191
9796
  );
9192
9797
  this.audit = new AuditLog(this);
9193
9798
  this.integrations = new Integrations(this);
9799
+ this.ai = config.ai ? new Ai(this, config.ai) : null;
9194
9800
  for (const listener of config.listeners ?? []) {
9195
9801
  const run = (message2) => listener.handler(message2, { cms: this });
9196
9802
  if (listener.events === "*") this.anyListeners.add(run);
@@ -10718,6 +11324,7 @@ var EasyCMS = class {
10718
11324
  (error) => this.logger.error(`Backups: ${error.message}`, { error })
10719
11325
  );
10720
11326
  await this.audit.upkeep(now).catch((error) => this.logger.error(`Audit log: ${error.message}`, { error }));
11327
+ await this.ai?.upkeep(now).catch((error) => this.logger.error(`AI usage: ${error.message}`, { error }));
10721
11328
  await this.rateLimiter?.clear(now).catch((error) => this.logger.error(`Rate limits: ${error.message}`, { error }));
10722
11329
  const result = {
10723
11330
  scheduled,
@@ -12049,51 +12656,51 @@ var EasyCMS = class {
12049
12656
  const cms = this;
12050
12657
  const { user, context } = guard;
12051
12658
  const allowed2 = /* @__PURE__ */ new Map();
12052
- for (const ref of references2) {
12053
- const filter = ref.filterOptions;
12659
+ for (const ref2 of references2) {
12660
+ const filter = ref2.filterOptions;
12054
12661
  if (!filter) continue;
12055
12662
  const byCollection = allowed2.get(filter) ?? /* @__PURE__ */ new Map();
12056
12663
  allowed2.set(filter, byCollection);
12057
- if (!byCollection.has(ref.collection))
12664
+ if (!byCollection.has(ref2.collection))
12058
12665
  byCollection.set(
12059
- ref.collection,
12060
- await filter({ id: selfId, relationTo: ref.collection, user, cms, context })
12666
+ ref2.collection,
12667
+ await filter({ id: selfId, relationTo: ref2.collection, user, cms, context })
12061
12668
  );
12062
- const where = byCollection.get(ref.collection);
12669
+ const where = byCollection.get(ref2.collection);
12063
12670
  if (where === true) continue;
12064
- const target = this.config.collections.find((c) => c.slug === ref.collection);
12671
+ const target = this.config.collections.find((c) => c.slug === ref2.collection);
12065
12672
  const localization = this.config.localization;
12066
12673
  const scoped = target ? this.whereFor(target, where, localization ? { locale: localization.defaultLocale } : {}) : where;
12067
12674
  const count = await this.db.count({
12068
- collection: ref.collection,
12069
- where: { and: [{ id: { equals: ref.id } }, ...scoped ? [scoped] : []] }
12675
+ collection: ref2.collection,
12676
+ where: { and: [{ id: { equals: ref2.id } }, ...scoped ? [scoped] : []] }
12070
12677
  });
12071
12678
  if (count === 0)
12072
- errors.push({ field: ref.field, message: "is not one of the allowed choices" });
12679
+ errors.push({ field: ref2.field, message: "is not one of the allowed choices" });
12073
12680
  }
12074
12681
  return errors;
12075
12682
  }
12076
12683
  async checkReferences(references2, guard) {
12077
12684
  const byCollection = /* @__PURE__ */ new Map();
12078
12685
  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))) {
12686
+ for (const ref2 of references2) {
12687
+ const target = this.config.collections.find((c) => c.slug === ref2.collection);
12688
+ if (ref2.link && (!target || INTERNAL_COLLECTIONS.has(ref2.collection))) {
12082
12689
  errors.push({
12083
- field: ref.field,
12084
- message: `links to "${ref.collection}", which is not a collection`
12690
+ field: ref2.field,
12691
+ message: `links to "${ref2.collection}", which is not a collection`
12085
12692
  });
12086
12693
  continue;
12087
12694
  }
12088
- if (ref.link && typeof target?.preview !== "function") {
12695
+ if (ref2.link && typeof target?.preview !== "function") {
12089
12696
  errors.push({
12090
- field: ref.field,
12091
- message: `links to "${ref.collection}", whose documents have no URL (set its preview)`
12697
+ field: ref2.field,
12698
+ message: `links to "${ref2.collection}", whose documents have no URL (set its preview)`
12092
12699
  });
12093
12700
  continue;
12094
12701
  }
12095
12702
  if (!target) continue;
12096
- byCollection.set(ref.collection, [...byCollection.get(ref.collection) ?? [], ref]);
12703
+ byCollection.set(ref2.collection, [...byCollection.get(ref2.collection) ?? [], ref2]);
12097
12704
  }
12098
12705
  for (const [collection2, refs] of byCollection) {
12099
12706
  const found = await this.db.findByIds({
@@ -12101,28 +12708,28 @@ var EasyCMS = class {
12101
12708
  ids: [...new Set(refs.map((r) => r.id))]
12102
12709
  });
12103
12710
  const byId = new Map(found.map((d) => [d.id, d]));
12104
- for (const ref of refs) {
12105
- const doc = byId.get(ref.id);
12711
+ for (const ref2 of refs) {
12712
+ const doc = byId.get(ref2.id);
12106
12713
  if (!doc) {
12107
- errors.push({ field: ref.field, message: `${collection2} ${ref.id} does not exist` });
12714
+ errors.push({ field: ref2.field, message: `${collection2} ${ref2.id} does not exist` });
12108
12715
  continue;
12109
12716
  }
12110
12717
  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/*");
12718
+ if (ref2.mimeTypes && !mimeAllowedBy(type, ref2.mimeTypes)) {
12719
+ const images = ref2.mimeTypes.every((t) => t.toLowerCase() === "image/*");
12113
12720
  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"})`
12721
+ field: ref2.field,
12722
+ 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
12723
  });
12117
12724
  }
12118
- if (ref.folderOnly && this.folders.enabled && !await this.folders.inKeyed(
12119
- ref.folderOnly,
12725
+ if (ref2.folderOnly && this.folders.enabled && !await this.folders.inKeyed(
12726
+ ref2.folderOnly,
12120
12727
  doc.folder ?? null,
12121
12728
  await this.folderScope(guard)
12122
12729
  ))
12123
12730
  errors.push({
12124
- field: ref.field,
12125
- message: `must be a file from the "${ref.folderOnly}" folder (${doc.filename ?? ref.id} is not)`
12731
+ field: ref2.field,
12732
+ message: `must be a file from the "${ref2.folderOnly}" folder (${doc.filename ?? ref2.id} is not)`
12126
12733
  });
12127
12734
  }
12128
12735
  }
@@ -12744,7 +13351,8 @@ var Roles = class {
12744
13351
  new: !seen("globals", g.slug),
12745
13352
  fields: adminFields(g.fields, void 0)
12746
13353
  })),
12747
- views: this.views()
13354
+ views: this.views(),
13355
+ ai: Boolean(this.config.ai)
12748
13356
  };
12749
13357
  }
12750
13358
  async create(input, author) {
@@ -12923,7 +13531,8 @@ var Roles = class {
12923
13531
  void 0
12924
13532
  )
12925
13533
  };
12926
- return { ...picked, admin, own, fields };
13534
+ const aiPerDay = this.config.ai && Number.isInteger(input.aiPerDay) && input.aiPerDay >= 0 ? { aiPerDay: input.aiPerDay } : {};
13535
+ return { ...picked, admin, own, fields, ...aiPerDay };
12927
13536
  }
12928
13537
  /**
12929
13538
  * Keeps rules for known top-level fields. A field that must be filled in (required, without a
@@ -12972,6 +13581,11 @@ var Roles = class {
12972
13581
  ]);
12973
13582
  return value.trim();
12974
13583
  }
13584
+ /** The role's daily AI limit set in Settings → Roles, if any (`auth.rbac`). */
13585
+ async aiLimit(key) {
13586
+ if (!this.enabled) return void 0;
13587
+ return (await this.permissionsOf(key)).aiPerDay;
13588
+ }
12975
13589
  async permissionsOf(key) {
12976
13590
  return (await this.load()).get(key) ?? {};
12977
13591
  }
@@ -13628,10 +14242,10 @@ function folderIds(cms, user, context) {
13628
14242
  return cms.folders.enabled ? (key) => cms.keyedFolder(key, { user, context }) : void 0;
13629
14243
  }
13630
14244
  function panelRef(panel) {
13631
- const ref = componentRef(panel);
14245
+ const ref2 = componentRef(panel);
13632
14246
  if (typeof panel === "object" && "position" in panel && panel.position === "top")
13633
- ref.position = "top";
13634
- return ref;
14247
+ ref2.position = "top";
14248
+ return ref2;
13635
14249
  }
13636
14250
  function layoutOf(layout, fields) {
13637
14251
  const shown2 = new Set(fields.filter((f) => f.position !== "sidebar").map((f) => f.name));
@@ -15097,6 +15711,37 @@ async function route(cms, ctx, method, path, options) {
15097
15711
  }
15098
15712
  throw methodNotAllowed(ctx, "PATCH, DELETE");
15099
15713
  }
15714
+ if (first === "admin" && second === "ai" && segments.length === 3) {
15715
+ if (!ctx.user) throw new UnauthorizedError();
15716
+ if (!cms.ai) throw new HttpError("Not found", 404);
15717
+ if (third === "status") {
15718
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
15719
+ return { body: await cms.ai.status(ctx.user) };
15720
+ }
15721
+ if (third === "alt-text") {
15722
+ if (method !== "POST") throw methodNotAllowed(ctx, "POST");
15723
+ const body = await readJson(ctx.request);
15724
+ if (typeof body.media !== "string" && typeof body.media !== "number")
15725
+ throw new ValidationError("media", [{ field: "media", message: "is required" }]);
15726
+ return {
15727
+ body: await cms.ai.altText(body.media, {
15728
+ user: ctx.user,
15729
+ locale: typeof body.locale === "string" ? body.locale : null,
15730
+ context: ctx.context
15731
+ })
15732
+ };
15733
+ }
15734
+ throw new HttpError("Not found", 404);
15735
+ }
15736
+ if (first === "admin" && second === "ai" && segments.length === 2) {
15737
+ if (!ctx.user) throw new UnauthorizedError();
15738
+ if (!cms.ai) throw new HttpError("Not found", 404);
15739
+ if (!await cms.roles.canView(ctx.user, "integrations")) throw new ForbiddenError();
15740
+ if (method === "GET") return { body: await cms.ai.overview() };
15741
+ if (method === "PUT")
15742
+ return { body: await cms.ai.update(await readJson(ctx.request), ctx.user) };
15743
+ throw methodNotAllowed(ctx, "GET, PUT");
15744
+ }
15100
15745
  if (first === "admin" && second === "integrations") {
15101
15746
  if (!ctx.user) throw new UnauthorizedError();
15102
15747
  if (cms.config.integrations.length === 0) throw new HttpError("Not found", 404);
@@ -15117,7 +15762,9 @@ async function route(cms, ctx, method, path, options) {
15117
15762
  if (part === "authorize" && cid === void 0) {
15118
15763
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
15119
15764
  const body = await readJson(ctx.request);
15120
- return { body: integrations.authorize(id2, user, callbackUrl, String(body.name ?? "")) };
15765
+ return {
15766
+ body: await integrations.authorize(id2, user, callbackUrl, String(body.name ?? ""))
15767
+ };
15121
15768
  }
15122
15769
  if (part === "callback" && cid === void 0) {
15123
15770
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
@@ -15140,6 +15787,10 @@ async function route(cms, ctx, method, path, options) {
15140
15787
  }
15141
15788
  return { body: new Response(null, { status: 302, headers: { location: page.toString() } }) };
15142
15789
  }
15790
+ if (part === "consent" && cid === void 0) {
15791
+ if (method !== "POST") throw methodNotAllowed(ctx, "POST");
15792
+ return { body: await integrations.consent(id2, user) };
15793
+ }
15143
15794
  if (part === "lookup" && cid !== void 0 && action === void 0) {
15144
15795
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
15145
15796
  return { body: await integrations.lookup(id2, cid, await readJson(ctx.request)) };
@@ -16201,10 +16852,11 @@ export {
16201
16852
  isStaff,
16202
16853
  isSignedIn,
16203
16854
  isAdmin,
16204
- secretsOf,
16205
16855
  MEDIA,
16206
16856
  MEDIA_FOLDERS,
16207
16857
  INTERNAL_COLLECTIONS,
16858
+ AiProviderError,
16859
+ secretsOf,
16208
16860
  AuditLog,
16209
16861
  API_KEYS,
16210
16862
  API_KEY_OPERATIONS,