@consilioweb/payload-support 3.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +58 -14
  2. package/dist/index.cjs +673 -107
  3. package/dist/index.d.cts +40 -5
  4. package/dist/index.d.ts +40 -5
  5. package/dist/index.js +673 -107
  6. package/dist/utils/db.d.ts +34 -0
  7. package/dist/utils/readSettings.d.ts +90 -0
  8. package/dist/views/BillingView/index.js +4 -4
  9. package/dist/views/ChatView/index.js +4 -4
  10. package/dist/views/CrmView/index.js +4 -4
  11. package/dist/views/EmailTrackingView/index.js +4 -4
  12. package/dist/views/ImportConversationView/index.js +4 -4
  13. package/dist/views/LogsView/index.js +4 -2
  14. package/dist/views/NewTicketView/index.js +4 -2
  15. package/dist/views/PendingEmailsView/index.js +4 -4
  16. package/dist/views/SupportDashboardView/index.js +4 -4
  17. package/dist/views/TicketDetailView/index.js +4 -4
  18. package/dist/views/TicketInboxView/index.js +4 -2
  19. package/dist/views/TicketingSettingsView/index.js +4 -4
  20. package/dist/views/TimeDashboardView/index.js +4 -4
  21. package/dist/views/shared/viewAccess.d.ts +29 -0
  22. package/dist/views/shared/viewAccess.js +24 -0
  23. package/package.json +26 -20
  24. package/src/collections/ChatMessages.ts +59 -2
  25. package/src/collections/ClientSummaries.ts +10 -4
  26. package/src/collections/TicketMessages.ts +4 -1
  27. package/src/collections/WebhookEndpoints.ts +44 -2
  28. package/src/endpoints/admin-chat.ts +3 -3
  29. package/src/endpoints/ai-agent.ts +3 -3
  30. package/src/endpoints/ai.ts +3 -3
  31. package/src/endpoints/auth-2fa.ts +68 -11
  32. package/src/endpoints/capabilities.ts +7 -9
  33. package/src/endpoints/chat.ts +7 -5
  34. package/src/endpoints/chatbot.ts +50 -4
  35. package/src/endpoints/client-intelligence.ts +4 -4
  36. package/src/endpoints/email-stats.ts +19 -3
  37. package/src/endpoints/import-conversation.ts +3 -3
  38. package/src/endpoints/index.ts +1 -1
  39. package/src/endpoints/invite-collaborator.ts +29 -3
  40. package/src/endpoints/login.ts +28 -5
  41. package/src/endpoints/oauth-google.ts +130 -8
  42. package/src/endpoints/push.ts +14 -1
  43. package/src/endpoints/resend-notification.ts +3 -3
  44. package/src/endpoints/send-reminder.ts +3 -3
  45. package/src/endpoints/signature.ts +9 -2
  46. package/src/endpoints/statuses.ts +17 -0
  47. package/src/endpoints/ticket-synthesis.ts +3 -3
  48. package/src/endpoints/transfer-ticket.ts +28 -3
  49. package/src/endpoints/typing.ts +117 -14
  50. package/src/endpoints/user-prefs.ts +5 -2
  51. package/src/plugin.ts +12 -0
  52. package/src/portal/auth/layout.tsx +19 -1
  53. package/src/portal/auth/tickets/detail/MessageBody.tsx +88 -0
  54. package/src/portal/auth/tickets/detail/page.tsx +2 -6
  55. package/src/portal/login/page.tsx +23 -5
  56. package/src/utils/fireWebhooks.ts +4 -1
  57. package/src/utils/push.ts +22 -0
  58. package/src/utils/rateLimiter.ts +136 -4
  59. package/src/utils/readSettings.ts +124 -14
  60. package/src/utils/ticketAccess.ts +16 -1
  61. package/src/utils/twoFactorChallenge.ts +85 -0
  62. package/src/utils/urlSafety.ts +265 -0
  63. package/src/utils/webhookDispatcher.ts +5 -1
  64. package/src/views/BillingView/index.tsx +4 -4
  65. package/src/views/ChatView/index.tsx +4 -4
  66. package/src/views/CrmView/index.tsx +4 -4
  67. package/src/views/EmailTrackingView/index.tsx +4 -4
  68. package/src/views/ImportConversationView/index.tsx +4 -4
  69. package/src/views/LogsView/index.tsx +4 -2
  70. package/src/views/NewTicketView/index.tsx +4 -2
  71. package/src/views/PendingEmailsView/index.tsx +4 -4
  72. package/src/views/SupportDashboardView/index.tsx +4 -4
  73. package/src/views/TicketDetailView/index.tsx +4 -4
  74. package/src/views/TicketInboxView/index.tsx +4 -2
  75. package/src/views/TicketingSettingsView/index.tsx +4 -4
  76. package/src/views/TimeDashboardView/index.tsx +4 -4
  77. package/src/views/shared/viewAccess.ts +73 -0
package/dist/index.cjs CHANGED
@@ -3,6 +3,7 @@
3
3
  var payload = require('payload');
4
4
  var crypto3 = require('crypto');
5
5
  var PDFDocument = require('pdfkit');
6
+ var promises = require('dns/promises');
6
7
  var webpush = require('web-push');
7
8
  var sanitizeHtml = require('sanitize-html');
8
9
 
@@ -83,12 +84,43 @@ var DEFAULT_SLUGS = {
83
84
  function resolveSlugs(overrides) {
84
85
  return { ...DEFAULT_SLUGS, ...overrides };
85
86
  }
87
+ var MAX_MEMORY_RATE_LIMIT_KEYS = 1e4;
86
88
  var MemoryRateLimitStore = class {
89
+ constructor(maxKeys = MAX_MEMORY_RATE_LIMIT_KEYS) {
90
+ this.maxKeys = maxKeys;
91
+ }
92
+ maxKeys;
87
93
  entries = /* @__PURE__ */ new Map();
94
+ /** Distinct keys currently held. Exposed so the ceiling can be asserted. */
95
+ get size() {
96
+ return this.entries.size;
97
+ }
98
+ /** Reclaims every window that has already closed. */
99
+ sweepExpired(now) {
100
+ for (const [key, entry] of this.entries) {
101
+ if (now > entry.resetAt) this.entries.delete(key);
102
+ }
103
+ }
104
+ /** Soonest-closing window first, so the ceiling drops the least useful entry. */
105
+ evictOldest() {
106
+ let oldestKey = null;
107
+ let oldestResetAt = Infinity;
108
+ for (const [key, entry] of this.entries) {
109
+ if (entry.resetAt < oldestResetAt) {
110
+ oldestResetAt = entry.resetAt;
111
+ oldestKey = key;
112
+ }
113
+ }
114
+ if (oldestKey !== null) this.entries.delete(oldestKey);
115
+ }
88
116
  async increment(key, windowMs) {
89
117
  const now = Date.now();
90
118
  const current = this.entries.get(key);
91
119
  const next = !current || now > current.resetAt ? { count: 1, resetAt: now + windowMs } : { ...current, count: current.count + 1 };
120
+ if (!current && this.entries.size >= this.maxKeys) {
121
+ this.sweepExpired(now);
122
+ if (this.entries.size >= this.maxKeys) this.evictOldest();
123
+ }
92
124
  this.entries.set(key, next);
93
125
  return next;
94
126
  }
@@ -167,22 +199,61 @@ var PayloadRateLimitStore = class {
167
199
  return { payload: context };
168
200
  }
169
201
  };
202
+ function principalRateKey(user) {
203
+ if (!user || user.id === void 0 || user.id === null) return "anonymous";
204
+ const collection = typeof user.collection === "string" && user.collection ? user.collection : "unknown";
205
+ return `${collection}:${String(user.id)}`;
206
+ }
207
+ var MAX_IP_KEY_LENGTH = 45;
208
+ var IPV4_PATTERN = /^(?:\d{1,3}\.){3}\d{1,3}$/;
209
+ var IPV6_PATTERN = /^[0-9a-fA-F:.%]+$/;
210
+ function clientIpRateKey(req) {
211
+ const candidate = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip")?.trim() || "";
212
+ return normalizeIpKey(candidate);
213
+ }
214
+ function normalizeIpKey(candidate) {
215
+ if (!candidate || candidate.length > MAX_IP_KEY_LENGTH) return "unknown";
216
+ const host = candidate.startsWith("[") && candidate.endsWith("]") ? candidate.slice(1, -1) : candidate;
217
+ if (!host) return "unknown";
218
+ if (IPV4_PATTERN.test(host)) {
219
+ return host.split(".").every((octet) => Number(octet) <= 255) ? host : "unknown";
220
+ }
221
+ if (host.includes(":") && IPV6_PATTERN.test(host)) return host.toLowerCase();
222
+ return "unknown";
223
+ }
170
224
  var RateLimiter = class {
171
- constructor(windowMs, maxRequests, store) {
225
+ /**
226
+ * @param namespace Endpoint-scoped prefix for every key this limiter writes.
227
+ * The store is SHARED (`rateLimitStore: 'payload'` builds one instance for
228
+ * all endpoints), and the raw keys collide across endpoints: `ip` was used
229
+ * by both the login and the chatbot limiter — 10 forged chatbot requests
230
+ * locked a victim out of the portal for 15 minutes — and `String(user.id)`
231
+ * by five different endpoints. Always pass one; it is optional only because
232
+ * `RateLimiter` is part of the published API surface.
233
+ */
234
+ constructor(windowMs, maxRequests, store, namespace) {
172
235
  this.windowMs = windowMs;
173
236
  this.maxRequests = maxRequests;
174
237
  this.store = store ?? new MemoryRateLimitStore();
238
+ this.prefix = namespace ? `${namespace}:` : "";
175
239
  }
176
240
  windowMs;
177
241
  maxRequests;
178
242
  store;
243
+ prefix;
244
+ /** The key actually written to the store. Exposed for assertions in tests. */
245
+ scopedKey(key) {
246
+ return `${this.prefix}${key}`;
247
+ }
179
248
  async check(key, context) {
180
- const entry = context === void 0 ? await this.store.increment(key, this.windowMs) : await this.store.increment(key, this.windowMs, context);
249
+ const scoped = this.scopedKey(key);
250
+ const entry = context === void 0 ? await this.store.increment(scoped, this.windowMs) : await this.store.increment(scoped, this.windowMs, context);
181
251
  return entry.count > this.maxRequests;
182
252
  }
183
253
  async reset(key, context) {
184
- if (context === void 0) await this.store.reset(key);
185
- else await this.store.reset(key, context);
254
+ const scoped = this.scopedKey(key);
255
+ if (context === void 0) await this.store.reset(scoped);
256
+ else await this.store.reset(scoped, context);
186
257
  }
187
258
  };
188
259
  var DEFAULT_INBOUND_EMAIL_LIMITS = {
@@ -220,7 +291,7 @@ function validateInboundEmailPayload(input, contentLength, limits = DEFAULT_INBO
220
291
 
221
292
  // src/endpoints/capabilities.ts
222
293
  function createInboundEmailEndpoint(capability, store) {
223
- const limiter = new RateLimiter(6e4, 60, store);
294
+ const limiter = new RateLimiter(6e4, 60, store, "inbound-email");
224
295
  return {
225
296
  path: "/support-webhook/inbound-email",
226
297
  method: "post",
@@ -229,7 +300,7 @@ function createInboundEmailEndpoint(capability, store) {
229
300
  if (!verifySecret(req.headers.get(secretHeader), capability.secret)) {
230
301
  return Response.json({ error: "Unauthorized" }, { status: 401 });
231
302
  }
232
- const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip") || "unknown";
303
+ const ip = clientIpRateKey(req);
233
304
  if (await limiter.check(ip, req)) {
234
305
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
235
306
  }
@@ -251,7 +322,7 @@ function createInboundEmailEndpoint(capability, store) {
251
322
  };
252
323
  }
253
324
  function createProjectSuggestionsEndpoint(slugs, capability, store) {
254
- const limiter = new RateLimiter(6e4, 20, store);
325
+ const limiter = new RateLimiter(6e4, 20, store, "suggest-projects");
255
326
  return {
256
327
  path: "/support/suggest-projects",
257
328
  method: "post",
@@ -259,7 +330,7 @@ function createProjectSuggestionsEndpoint(slugs, capability, store) {
259
330
  if (!req.user || req.user.collection !== slugs.users) {
260
331
  return Response.json({ error: "Unauthorized" }, { status: 401 });
261
332
  }
262
- const key = req.user?.id ? String(req.user.id) : "anonymous";
333
+ const key = principalRateKey(req.user);
263
334
  if (await limiter.check(key, req)) {
264
335
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
265
336
  }
@@ -268,7 +339,7 @@ function createProjectSuggestionsEndpoint(slugs, capability, store) {
268
339
  };
269
340
  }
270
341
  function createTicketTitleEndpoint(slugs, capability, store) {
271
- const limiter = new RateLimiter(6e4, 20, store);
342
+ const limiter = new RateLimiter(6e4, 20, store, "ticket-title");
272
343
  return {
273
344
  path: "/support/ticket-title",
274
345
  method: "post",
@@ -290,7 +361,7 @@ function createTicketTitleEndpoint(slugs, capability, store) {
290
361
  };
291
362
  }
292
363
  function createGenerateMissingTitlesEndpoint(slugs, capability, store) {
293
- const limiter = new RateLimiter(6e4, 5, store);
364
+ const limiter = new RateLimiter(6e4, 5, store, "generate-missing-titles");
294
365
  return {
295
366
  path: "/support/generate-missing-titles",
296
367
  method: "post",
@@ -446,6 +517,14 @@ var SUPPORT_SETTINGS_PREF_KEY = "support-settings";
446
517
  var PREF_KEY = SUPPORT_SETTINGS_PREF_KEY;
447
518
  var USER_PREFS_KEY_PREFIX = "support-user-prefs";
448
519
  var LEGACY_ROUND_ROBIN_KEY = "support-round-robin";
520
+ var SUPPORT_STAFF_SLUG_CONFIG_KEY = "supportStaffCollection";
521
+ function resolveStaffPrefSlug(payload, staffSlug) {
522
+ if (staffSlug) return staffSlug;
523
+ const config = payload.config;
524
+ const registered = config?.custom?.[SUPPORT_STAFF_SLUG_CONFIG_KEY];
525
+ if (typeof registered === "string" && registered) return registered;
526
+ return config?.admin?.user || "users";
527
+ }
449
528
  var DEFAULT_SETTINGS = {
450
529
  email: { fromAddress: "", fromName: "Support", replyToAddress: "" },
451
530
  ai: { provider: "anthropic", model: "claude-haiku-4-5-20251001", enableSentiment: true, enableSynthesis: true, enableSuggestion: true, enableRewrite: true },
@@ -457,10 +536,13 @@ var DEFAULT_USER_PREFS = {
457
536
  locale: "fr",
458
537
  signature: ""
459
538
  };
460
- var settingsCache = null;
539
+ var settingsCache = /* @__PURE__ */ new Map();
461
540
  var SETTINGS_TTL_MS = 6e4;
541
+ var SETTINGS_CACHE_MAX = 8;
542
+ var warnedForeignSettingsRow = /* @__PURE__ */ new Set();
462
543
  function invalidateSupportSettingsCache() {
463
- settingsCache = null;
544
+ settingsCache.clear();
545
+ warnedForeignSettingsRow.clear();
464
546
  }
465
547
  function mergeSupportSettings(stored, base = DEFAULT_SETTINGS) {
466
548
  const autoClose = { ...base.autoClose, ...stored?.autoClose };
@@ -477,9 +559,11 @@ function mergeSupportSettings(stored, base = DEFAULT_SETTINGS) {
477
559
  )
478
560
  };
479
561
  }
480
- async function readSupportSettingsState(payload) {
481
- if (settingsCache && Date.now() - settingsCache.ts < SETTINGS_TTL_MS) {
482
- return settingsCache.value;
562
+ async function readSupportSettingsState(payload, staffSlug) {
563
+ const staff = resolveStaffPrefSlug(payload, staffSlug);
564
+ const cached = settingsCache.get(staff);
565
+ if (cached && Date.now() - cached.ts < SETTINGS_TTL_MS) {
566
+ return cached.value;
483
567
  }
484
568
  let value = {
485
569
  settings: mergeSupportSettings(null),
@@ -487,7 +571,10 @@ async function readSupportSettingsState(payload) {
487
571
  };
488
572
  try {
489
573
  const prefs = await dbFind(payload, "payload-preferences", {
490
- where: { key: { equals: PREF_KEY } },
574
+ // Sibling keys are AND-ed by Payload. The `user.relationTo` clause is the
575
+ // security boundary: without it any authenticated principal can plant a
576
+ // `support-settings` row and own the plugin's server settings.
577
+ where: { key: { equals: PREF_KEY }, "user.relationTo": { equals: staff } },
491
578
  // The upsert is scoped per admin user, so several rows can share the key.
492
579
  // Sorting makes "last write wins" deterministic instead of arbitrary.
493
580
  sort: "-updatedAt",
@@ -500,22 +587,43 @@ async function readSupportSettingsState(payload) {
500
587
  const featuresConfigured = !!stored.features && typeof stored.features === "object";
501
588
  const settings = mergeSupportSettings(stored);
502
589
  if (!featuresConfigured) {
503
- settings.features.roundRobin = await readLegacyRoundRobin(payload);
590
+ settings.features.roundRobin = await readLegacyRoundRobin(payload, staff);
504
591
  }
505
592
  value = { settings, featuresConfigured };
593
+ } else {
594
+ await warnOnForeignSettingsRow(payload, staff);
506
595
  }
507
596
  } catch {
508
597
  }
509
- settingsCache = { value, ts: Date.now() };
598
+ if (settingsCache.size >= SETTINGS_CACHE_MAX && !settingsCache.has(staff)) settingsCache.clear();
599
+ settingsCache.set(staff, { value, ts: Date.now() });
510
600
  return value;
511
601
  }
512
- async function readSupportSettings(payload) {
513
- return (await readSupportSettingsState(payload)).settings;
602
+ async function warnOnForeignSettingsRow(payload, staff) {
603
+ if (warnedForeignSettingsRow.has(staff)) return;
604
+ try {
605
+ const any = await dbFind(payload, "payload-preferences", {
606
+ where: { key: { equals: PREF_KEY } },
607
+ limit: 1,
608
+ depth: 0,
609
+ overrideAccess: true
610
+ });
611
+ if (any.docs.length === 0) return;
612
+ if (warnedForeignSettingsRow.size >= SETTINGS_CACHE_MAX) warnedForeignSettingsRow.clear();
613
+ warnedForeignSettingsRow.add(staff);
614
+ console.warn(
615
+ `[support] A "${PREF_KEY}" preference row exists but none is owned by the "${staff}" collection: the plugin is running on its DEFAULT settings. Either the staff auth collection differs from \`admin.user\`, or the row was written by a principal that is not staff \u2014 in which case it is ignored on purpose.`
616
+ );
617
+ } catch {
618
+ }
619
+ }
620
+ async function readSupportSettings(payload, staffSlug) {
621
+ return (await readSupportSettingsState(payload, staffSlug)).settings;
514
622
  }
515
- async function readLegacyRoundRobin(payload) {
623
+ async function readLegacyRoundRobin(payload, staff) {
516
624
  try {
517
625
  const prefs = await dbFind(payload, "payload-preferences", {
518
- where: { key: { equals: LEGACY_ROUND_ROBIN_KEY } },
626
+ where: { key: { equals: LEGACY_ROUND_ROBIN_KEY }, "user.relationTo": { equals: staff } },
519
627
  limit: 1,
520
628
  depth: 0,
521
629
  overrideAccess: true
@@ -527,11 +635,14 @@ async function readLegacyRoundRobin(payload) {
527
635
  }
528
636
  return DEFAULT_TICKETING_FEATURES.roundRobin;
529
637
  }
530
- async function readUserPrefs(payload, userId) {
638
+ async function readUserPrefs(payload, userId, staffSlug) {
531
639
  try {
532
640
  const key = `${USER_PREFS_KEY_PREFIX}-${userId}`;
533
641
  const prefs = await dbFind(payload, "payload-preferences", {
534
- where: { key: { equals: key } },
642
+ where: {
643
+ key: { equals: key },
644
+ "user.relationTo": { equals: resolveStaffPrefSlug(payload, staffSlug) }
645
+ },
535
646
  limit: 1,
536
647
  depth: 0,
537
648
  overrideAccess: true
@@ -569,7 +680,7 @@ function getModel(aiSettings) {
569
680
  return aiSettings.model || "claude-haiku-4-5-20251001";
570
681
  }
571
682
  function createAiEndpoint(slugs, store) {
572
- const limiter = new RateLimiter(6e4, 30, store);
683
+ const limiter = new RateLimiter(6e4, 30, store, "ai");
573
684
  return {
574
685
  path: "/support/ai",
575
686
  method: "post",
@@ -577,7 +688,7 @@ function createAiEndpoint(slugs, store) {
577
688
  try {
578
689
  const payload = req.payload;
579
690
  requireAdmin(req, slugs);
580
- if (await limiter.check(String(req.user.id), req)) {
691
+ if (await limiter.check(principalRateKey(req.user), req)) {
581
692
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
582
693
  }
583
694
  const settings = await readSupportSettings(payload);
@@ -822,14 +933,14 @@ ${kbText || "(vide)"}`;
822
933
 
823
934
  // src/endpoints/ai-agent.ts
824
935
  function createAiAgentEndpoint(slugs, store) {
825
- const limiter = new RateLimiter(6e4, 10, store);
936
+ const limiter = new RateLimiter(6e4, 10, store, "ai-agent");
826
937
  return {
827
938
  path: "/support/ai-agent",
828
939
  method: "post",
829
940
  handler: async (req) => {
830
941
  try {
831
942
  requireAdmin(req, slugs);
832
- if (await limiter.check(String(req.user.id), req)) {
943
+ if (await limiter.check(principalRateKey(req.user), req)) {
833
944
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
834
945
  }
835
946
  let body = {};
@@ -869,11 +980,11 @@ function getModel2(aiSettings) {
869
980
  }
870
981
  var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
871
982
  function createClientIntelligenceEndpoint(slugs, store) {
872
- const limiter = new RateLimiter(6e4, 20, store);
983
+ const limiter = new RateLimiter(6e4, 20, store, "client-intelligence");
873
984
  const getHandler = async (req) => {
874
985
  try {
875
986
  requireAdmin(req, slugs);
876
- if (await limiter.check(String(req.user.id), req)) {
987
+ if (await limiter.check(principalRateKey(req.user), req)) {
877
988
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
878
989
  }
879
990
  const payload = req.payload;
@@ -905,7 +1016,7 @@ function createClientIntelligenceEndpoint(slugs, store) {
905
1016
  const postHandler = async (req) => {
906
1017
  try {
907
1018
  requireAdmin(req, slugs);
908
- if (await limiter.check(String(req.user.id), req)) {
1019
+ if (await limiter.check(principalRateKey(req.user), req)) {
909
1020
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
910
1021
  }
911
1022
  const payload = req.payload;
@@ -1534,6 +1645,14 @@ function createSplitTicketEndpoint(slugs) {
1534
1645
  // src/endpoints/typing.ts
1535
1646
  var typingState = /* @__PURE__ */ new Map();
1536
1647
  var TYPING_TTL = 5e3;
1648
+ var MAX_TYPING_KEYS = 500;
1649
+ var TICKET_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
1650
+ function normalizeTicketId(raw) {
1651
+ if (typeof raw === "number") return Number.isInteger(raw) && raw > 0 ? String(raw) : null;
1652
+ if (typeof raw !== "string") return null;
1653
+ const value = raw.trim();
1654
+ return TICKET_ID_PATTERN.test(value) ? value : null;
1655
+ }
1537
1656
  function cleanExpired(ticketId) {
1538
1657
  const state = typingState.get(ticketId);
1539
1658
  if (!state) return;
@@ -1548,6 +1667,36 @@ function cleanExpired(ticketId) {
1548
1667
  }
1549
1668
  if (!state.admin && !state.client) typingState.delete(ticketId);
1550
1669
  }
1670
+ function sweepExpired() {
1671
+ for (const key of Array.from(typingState.keys())) cleanExpired(key);
1672
+ }
1673
+ function evictOldest() {
1674
+ let oldestKey = null;
1675
+ let oldestTs = Infinity;
1676
+ for (const [key, state] of typingState) {
1677
+ const ts = Math.max(state.admin || 0, state.client || 0);
1678
+ if (ts < oldestTs) {
1679
+ oldestTs = ts;
1680
+ oldestKey = key;
1681
+ }
1682
+ }
1683
+ if (oldestKey !== null) typingState.delete(oldestKey);
1684
+ }
1685
+ async function mayAccessTicket(req, slugs, ticketId) {
1686
+ const collection = req.user?.collection;
1687
+ if (collection !== slugs.users && collection !== slugs.supportClients) return false;
1688
+ try {
1689
+ const doc = await dbFindByID(req.payload, slugs.tickets, {
1690
+ id: ticketId,
1691
+ depth: 0,
1692
+ overrideAccess: false,
1693
+ user: req.user
1694
+ });
1695
+ return !!doc;
1696
+ } catch {
1697
+ return false;
1698
+ }
1699
+ }
1551
1700
  function createTypingPostEndpoint(slugs) {
1552
1701
  return {
1553
1702
  path: "/support/typing",
@@ -1558,11 +1707,18 @@ function createTypingPostEndpoint(slugs) {
1558
1707
  return Response.json({ error: "Unauthorized" }, { status: 401 });
1559
1708
  }
1560
1709
  const { ticketId } = await req.json();
1561
- if (!ticketId) {
1710
+ const key = normalizeTicketId(ticketId);
1711
+ if (!key) {
1562
1712
  return Response.json({ error: "ticketId required" }, { status: 400 });
1563
1713
  }
1564
- const key = String(ticketId);
1714
+ if (!await mayAccessTicket(req, slugs, key)) {
1715
+ return Response.json({ error: "Forbidden" }, { status: 403 });
1716
+ }
1565
1717
  const state = typingState.get(key) || {};
1718
+ if (!typingState.has(key) && typingState.size >= MAX_TYPING_KEYS) {
1719
+ sweepExpired();
1720
+ if (typingState.size >= MAX_TYPING_KEYS) evictOldest();
1721
+ }
1566
1722
  if (req.user.collection === slugs.users) {
1567
1723
  state.admin = Date.now();
1568
1724
  state.adminName = req.user.firstName || "Support";
@@ -1583,30 +1739,33 @@ function createTypingGetEndpoint(slugs) {
1583
1739
  path: "/support/typing",
1584
1740
  method: "get",
1585
1741
  handler: async (req) => {
1742
+ const idle = { typing: false, name: null };
1586
1743
  try {
1587
1744
  if (!req.user) {
1588
1745
  return Response.json({ error: "Unauthorized" }, { status: 401 });
1589
1746
  }
1590
1747
  const url = new URL(req.url);
1591
- const ticketId = url.searchParams.get("ticketId");
1592
- if (!ticketId) {
1748
+ const key = normalizeTicketId(url.searchParams.get("ticketId"));
1749
+ if (!key) {
1593
1750
  return Response.json({ error: "ticketId required" }, { status: 400 });
1594
1751
  }
1595
- cleanExpired(ticketId);
1596
- const state = typingState.get(ticketId);
1752
+ cleanExpired(key);
1753
+ const state = typingState.get(key);
1754
+ if (!state) return Response.json(idle);
1755
+ if (!await mayAccessTicket(req, slugs, key)) return Response.json(idle);
1597
1756
  if (req.user.collection === slugs.users) {
1598
1757
  return Response.json({
1599
- typing: !!state?.client,
1600
- name: state?.clientName || null
1758
+ typing: !!state.client,
1759
+ name: state.clientName || null
1601
1760
  });
1602
1761
  } else {
1603
1762
  return Response.json({
1604
- typing: !!state?.admin,
1605
- name: state?.adminName || null
1763
+ typing: !!state.admin,
1764
+ name: state.adminName || null
1606
1765
  });
1607
1766
  }
1608
1767
  } catch {
1609
- return Response.json({ typing: false, name: null });
1768
+ return Response.json(idle);
1610
1769
  }
1611
1770
  }
1612
1771
  };
@@ -1769,7 +1928,14 @@ function createSignatureGetEndpoint(slugs) {
1769
1928
  const payload = req.payload;
1770
1929
  requireAdmin(req, slugs);
1771
1930
  const prefs = await dbFind(payload, "payload-preferences", {
1772
- where: { key: { equals: `${PREF_KEY2}-${req.user.id}` } },
1931
+ // Scope to the staff auth collection: `payload-preferences` accepts a
1932
+ // write from ANY authenticated principal, and ids collide between auth
1933
+ // collections — a support-client with the same id would otherwise own
1934
+ // the `email-signature-<id>` row read back for the agent.
1935
+ where: {
1936
+ key: { equals: `${PREF_KEY2}-${req.user.id}` },
1937
+ "user.relationTo": { equals: slugs.users }
1938
+ },
1773
1939
  limit: 1,
1774
1940
  depth: 0,
1775
1941
  overrideAccess: true
@@ -1796,7 +1962,7 @@ function createSignaturePostEndpoint(slugs) {
1796
1962
  const { signature } = await req.json();
1797
1963
  const key = `${PREF_KEY2}-${req.user.id}`;
1798
1964
  const existing = await dbFind(payload, "payload-preferences", {
1799
- where: { key: { equals: key } },
1965
+ where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
1800
1966
  limit: 1,
1801
1967
  depth: 0,
1802
1968
  overrideAccess: true
@@ -2557,7 +2723,7 @@ function formatFr(date, withTime) {
2557
2723
  });
2558
2724
  }
2559
2725
  function createSendReminderEndpoint(slugs, store) {
2560
- const reminderLimiter = new RateLimiter(60 * 60 * 1e3, 30, store);
2726
+ const reminderLimiter = new RateLimiter(60 * 60 * 1e3, 30, store, "send-reminder");
2561
2727
  return {
2562
2728
  path: "/support/send-reminder",
2563
2729
  method: "post",
@@ -2565,7 +2731,7 @@ function createSendReminderEndpoint(slugs, store) {
2565
2731
  try {
2566
2732
  const payload = req.payload;
2567
2733
  requireAdmin(req, slugs);
2568
- if (await reminderLimiter.check(String(req.user.id), req)) {
2734
+ if (await reminderLimiter.check(principalRateKey(req.user), req)) {
2569
2735
  return Response.json(
2570
2736
  { error: "Trop de relances. R\xE9essayez dans une heure." },
2571
2737
  { status: 429 }
@@ -2691,6 +2857,10 @@ function createStatusesEndpoint(slugs) {
2691
2857
  if (!req.user) {
2692
2858
  return Response.json({ error: "Unauthorized" }, { status: 401 });
2693
2859
  }
2860
+ const collection = req.user.collection;
2861
+ if (collection !== slugs.users && collection !== slugs.supportClients) {
2862
+ return Response.json({ error: "Forbidden" }, { status: 403 });
2863
+ }
2694
2864
  const { docs } = await dbFind(payload, slugs.ticketStatuses, {
2695
2865
  sort: "sortOrder",
2696
2866
  limit: 100,
@@ -2868,14 +3038,20 @@ function createPurgeLogsEndpoint(slugs) {
2868
3038
  }
2869
3039
 
2870
3040
  // src/endpoints/chatbot.ts
2871
- function createChatbotEndpoint(slugs, store) {
2872
- const chatbotLimiter = new RateLimiter(6e4, 10, store);
3041
+ var DEFAULT_CHATBOT_MAX_PER_HOUR = 200;
3042
+ function resolveMaxPerHour(explicit) {
3043
+ const fromEnv = Number(process.env.SUPPORT_CHATBOT_MAX_PER_HOUR);
3044
+ return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : DEFAULT_CHATBOT_MAX_PER_HOUR;
3045
+ }
3046
+ function createChatbotEndpoint(slugs, store, maxPerHour) {
3047
+ const chatbotLimiter = new RateLimiter(6e4, 10, store, "chatbot:ip");
3048
+ const globalLimiter = new RateLimiter(60 * 6e4, resolveMaxPerHour(), store, "chatbot:global");
2873
3049
  return {
2874
3050
  path: "/support/chatbot",
2875
3051
  method: "post",
2876
3052
  handler: async (req) => {
2877
3053
  try {
2878
- const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip") || "unknown";
3054
+ const ip = clientIpRateKey(req);
2879
3055
  if (await chatbotLimiter.check(ip, req)) {
2880
3056
  return Response.json({ error: "Too many requests. Please wait a moment." }, { status: 429 });
2881
3057
  }
@@ -2889,6 +3065,15 @@ function createChatbotEndpoint(slugs, store) {
2889
3065
  if (!question?.trim() || question.trim().length < 5) {
2890
3066
  return Response.json({ error: "Question too short" }, { status: 400 });
2891
3067
  }
3068
+ if (await globalLimiter.check("all", req)) {
3069
+ return Response.json({
3070
+ answer: null,
3071
+ confidence: 0,
3072
+ suggestion: "create_ticket",
3073
+ aiUnavailable: true,
3074
+ message: "L'assistant est momentan\xE9ment indisponible. Cr\xE9ez un ticket, un agent vous r\xE9pondra."
3075
+ });
3076
+ }
2892
3077
  const payload = req.payload;
2893
3078
  const articles = await dbFind(payload, slugs.knowledgeBase, {
2894
3079
  where: { published: { equals: true } },
@@ -3001,8 +3186,8 @@ function createChatGetEndpoint(slugs) {
3001
3186
  };
3002
3187
  }
3003
3188
  function createChatPostEndpoint(slugs, store) {
3004
- const chatSessionLimiter = new RateLimiter(36e5, 5, store);
3005
- const chatMessageLimiter = new RateLimiter(6e4, 15, store);
3189
+ const chatSessionLimiter = new RateLimiter(36e5, 5, store, "chat:session");
3190
+ const chatMessageLimiter = new RateLimiter(6e4, 15, store, "chat:message");
3006
3191
  return {
3007
3192
  path: "/support/chat",
3008
3193
  method: "post",
@@ -3018,8 +3203,9 @@ function createChatPostEndpoint(slugs, store) {
3018
3203
  }
3019
3204
  const { action, session, message } = body;
3020
3205
  const userId = String(req.user.id);
3206
+ const rateKey = principalRateKey(req.user);
3021
3207
  if (action === "start") {
3022
- if (await chatSessionLimiter.check(userId, req)) {
3208
+ if (await chatSessionLimiter.check(rateKey, req)) {
3023
3209
  return Response.json({ error: "Trop de sessions cr\xE9\xE9es. R\xE9essayez plus tard." }, { status: 429 });
3024
3210
  }
3025
3211
  const sessionId = `chat_${crypto3__default.default.randomBytes(16).toString("hex")}`;
@@ -3036,7 +3222,7 @@ function createChatPostEndpoint(slugs, store) {
3036
3222
  return Response.json({ session: sessionId, messages: [systemMsg] });
3037
3223
  }
3038
3224
  if (action === "send" && session && message) {
3039
- if (await chatMessageLimiter.check(userId, req)) {
3225
+ if (await chatMessageLimiter.check(rateKey, req)) {
3040
3226
  return Response.json({ error: "Trop de messages. Attendez un moment." }, { status: 429 });
3041
3227
  }
3042
3228
  const trimmedMessage = String(message).trim();
@@ -3276,7 +3462,7 @@ function createAdminChatGetEndpoint(slugs) {
3276
3462
  };
3277
3463
  }
3278
3464
  function createAdminChatPostEndpoint(slugs, store) {
3279
- const adminChatLimiter = new RateLimiter(6e4, 30, store);
3465
+ const adminChatLimiter = new RateLimiter(6e4, 30, store, "admin-chat");
3280
3466
  return {
3281
3467
  path: "/support/admin-chat",
3282
3468
  method: "post",
@@ -3305,7 +3491,7 @@ function createAdminChatPostEndpoint(slugs, store) {
3305
3491
  }
3306
3492
  const clientId = typeof sessionMsg.docs[0].client === "object" ? sessionMsg.docs[0].client.id : sessionMsg.docs[0].client;
3307
3493
  if (action === "send" && message) {
3308
- if (await adminChatLimiter.check(String(req.user.id), req)) {
3494
+ if (await adminChatLimiter.check(principalRateKey(req.user), req)) {
3309
3495
  return Response.json({ error: "Rate limit atteint." }, { status: 429 });
3310
3496
  }
3311
3497
  const trimmedMessage = String(message).trim();
@@ -4260,14 +4446,14 @@ Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`;
4260
4446
 
4261
4447
  // src/endpoints/ticket-synthesis.ts
4262
4448
  function createTicketSynthesisEndpoint(slugs, generator, store) {
4263
- const limiter = new RateLimiter(6e4, 20, store);
4449
+ const limiter = new RateLimiter(6e4, 20, store, "ticket-synthesis");
4264
4450
  return {
4265
4451
  path: "/support/ticket-synthesis",
4266
4452
  method: "post",
4267
4453
  handler: async (req) => {
4268
4454
  try {
4269
4455
  requireAdmin(req, slugs);
4270
- if (await limiter.check(String(req.user.id), req)) {
4456
+ if (await limiter.check(principalRateKey(req.user), req)) {
4271
4457
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
4272
4458
  }
4273
4459
  const payload = req.payload;
@@ -4311,15 +4497,17 @@ function createTicketSynthesisEndpoint(slugs, generator, store) {
4311
4497
  }
4312
4498
 
4313
4499
  // src/endpoints/email-stats.ts
4314
- function createEmailStatsEndpoint(slugs) {
4500
+ function createEmailStatsEndpoint(slugs, store) {
4501
+ const statsLimiter = new RateLimiter(6e4, 20, store, "email-stats");
4315
4502
  return {
4316
4503
  path: "/support/email-stats",
4317
4504
  method: "get",
4318
4505
  handler: async (req) => {
4319
4506
  try {
4320
4507
  const payload = req.payload;
4321
- if (!req.user) {
4322
- return Response.json({ error: "Unauthorized" }, { status: 401 });
4508
+ requireAdmin(req, slugs);
4509
+ if (await statsLimiter.check(principalRateKey(req.user), req)) {
4510
+ return Response.json({ error: "Too many requests." }, { status: 429 });
4323
4511
  }
4324
4512
  const url = new URL(req.url);
4325
4513
  const days = Math.min(Number(url.searchParams.get("days")) || 7, 365);
@@ -4386,6 +4574,8 @@ function createEmailStatsEndpoint(slugs) {
4386
4574
  actions: Object.fromEntries(actionMap)
4387
4575
  });
4388
4576
  } catch (err) {
4577
+ const authResponse = handleAuthError(err);
4578
+ if (authResponse) return authResponse;
4389
4579
  console.error("[email-stats] Error:", err);
4390
4580
  return Response.json({ error: "Internal server error" }, { status: 500 });
4391
4581
  }
@@ -4818,7 +5008,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4818
5008
 
4819
5009
  // src/endpoints/resend-notification.ts
4820
5010
  function createResendNotificationEndpoint(slugs, store) {
4821
- const resendLimiter = new RateLimiter(60 * 60 * 1e3, 10, store);
5011
+ const resendLimiter = new RateLimiter(60 * 60 * 1e3, 10, store, "resend-notification");
4822
5012
  return {
4823
5013
  path: "/support/resend-notification",
4824
5014
  method: "post",
@@ -4826,7 +5016,7 @@ function createResendNotificationEndpoint(slugs, store) {
4826
5016
  try {
4827
5017
  const payload = req.payload;
4828
5018
  requireAdmin(req, slugs);
4829
- if (await resendLimiter.check(String(req.user.id), req)) {
5019
+ if (await resendLimiter.check(principalRateKey(req.user), req)) {
4830
5020
  return Response.json(
4831
5021
  { error: "Trop de renvois. R\xE9essayez dans une heure." },
4832
5022
  { status: 429 }
@@ -5027,15 +5217,54 @@ function createSeedKbEndpoint(slugs) {
5027
5217
  }
5028
5218
  };
5029
5219
  }
5220
+ var TWO_FACTOR_CHALLENGE_TTL_MS = 10 * 60 * 1e3;
5221
+ function challengeSecret() {
5222
+ const secret = process.env.PAYLOAD_SECRET;
5223
+ if (!secret) {
5224
+ throw new Error(
5225
+ "[support][2fa] PAYLOAD_SECRET is not set \u2014 refusing to issue a 2FA challenge with an insecure fallback secret"
5226
+ );
5227
+ }
5228
+ return secret;
5229
+ }
5230
+ function normalizeEmail(email) {
5231
+ return String(email).trim().toLowerCase();
5232
+ }
5233
+ function sign(email, expiresAt) {
5234
+ return crypto3.createHmac("sha256", challengeSecret()).update(`2fa-challenge:${normalizeEmail(email)}:${expiresAt}`).digest("hex");
5235
+ }
5236
+ function issueTwoFactorChallenge(email, now = Date.now()) {
5237
+ const expiresAt = now + TWO_FACTOR_CHALLENGE_TTL_MS;
5238
+ return `${expiresAt}.${sign(email, expiresAt)}`;
5239
+ }
5240
+ function verifyTwoFactorChallenge(email, token, now = Date.now()) {
5241
+ if (typeof email !== "string" || !email || typeof token !== "string") return false;
5242
+ const separator = token.indexOf(".");
5243
+ if (separator <= 0) return false;
5244
+ const expiresAt = Number(token.slice(0, separator));
5245
+ if (!Number.isSafeInteger(expiresAt) || expiresAt <= now) return false;
5246
+ const received = token.slice(separator + 1);
5247
+ if (!/^[0-9a-f]{64}$/i.test(received)) return false;
5248
+ let expected;
5249
+ try {
5250
+ expected = sign(email, expiresAt);
5251
+ } catch {
5252
+ return false;
5253
+ }
5254
+ const a = Buffer.from(expected, "hex");
5255
+ const b = Buffer.from(received.toLowerCase(), "hex");
5256
+ return a.length === b.length && crypto3.timingSafeEqual(a, b);
5257
+ }
5030
5258
 
5031
5259
  // src/endpoints/login.ts
5260
+ var MAX_LOGGED_USER_AGENT = 256;
5032
5261
  function createLoginEndpoint(slugs, store) {
5033
- const loginLimiter = new RateLimiter(15 * 6e4, 10, store);
5262
+ const loginLimiter = new RateLimiter(15 * 6e4, 10, store, "login");
5034
5263
  return {
5035
5264
  path: "/support/login",
5036
5265
  method: "post",
5037
5266
  handler: async (req) => {
5038
- const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip") || "unknown";
5267
+ const ip = clientIpRateKey(req);
5039
5268
  if (await loginLimiter.check(ip, req)) {
5040
5269
  return Response.json(
5041
5270
  { error: "Trop de tentatives. R\xE9essayez dans quelques minutes." },
@@ -5050,7 +5279,7 @@ function createLoginEndpoint(slugs, store) {
5050
5279
  return Response.json({ error: "Invalid JSON body" }, { status: 400 });
5051
5280
  }
5052
5281
  const { email, password } = body;
5053
- const userAgent = req.headers.get("user-agent") || "";
5282
+ const userAgent = (req.headers.get("user-agent") || "").slice(0, MAX_LOGGED_USER_AGENT);
5054
5283
  if (!email || !password) {
5055
5284
  return Response.json({ error: "Email et mot de passe requis." }, { status: 400 });
5056
5285
  }
@@ -5083,7 +5312,12 @@ function createLoginEndpoint(slugs, store) {
5083
5312
  } catch (err) {
5084
5313
  const errorMessage = err instanceof Error ? err.message : "Erreur inconnue";
5085
5314
  if (errorMessage.includes("2FA_REQUIRED")) {
5086
- return Response.json({ requires2FA: true }, { status: 200 });
5315
+ let challenge;
5316
+ try {
5317
+ challenge = issueTwoFactorChallenge(email);
5318
+ } catch {
5319
+ }
5320
+ return Response.json({ requires2FA: true, ...challenge ? { challenge } : {} }, { status: 200 });
5087
5321
  }
5088
5322
  let errorReason = "Identifiants incorrects";
5089
5323
  if (errorMessage.includes("locked") || errorMessage.includes("verrouill\xE9") || errorMessage.includes("Too many")) {
@@ -5115,8 +5349,8 @@ function hashCode(code) {
5115
5349
  return crypto3.createHmac("sha256", secret).update(code).digest("hex");
5116
5350
  }
5117
5351
  function createAuth2faEndpoint(slugs, store) {
5118
- const sendLimiter = new RateLimiter(60 * 60 * 1e3, 3, store);
5119
- const verifyLimiter = new RateLimiter(15 * 60 * 1e3, 5, store);
5352
+ const sendLimiter = new RateLimiter(60 * 60 * 1e3, 3, store, "2fa:send");
5353
+ const verifyLimiter = new RateLimiter(15 * 60 * 1e3, 5, store, "2fa:verify");
5120
5354
  return {
5121
5355
  path: "/support/2fa",
5122
5356
  method: "post",
@@ -5129,14 +5363,32 @@ function createAuth2faEndpoint(slugs, store) {
5129
5363
  } catch {
5130
5364
  return Response.json({ error: "Invalid JSON body" }, { status: 400 });
5131
5365
  }
5132
- const { action, email, code } = body;
5366
+ const { action, email, code, challenge } = body;
5133
5367
  if (!action || !email) {
5134
5368
  return Response.json({ error: "Param\xE8tres manquants" }, { status: 400 });
5135
5369
  }
5136
- const genericSendResponse = { success: true, message: "Si un compte existe, un code a \xE9t\xE9 envoy\xE9." };
5370
+ const limiterKey = normalizeEmail(email);
5371
+ const sendResponse = () => {
5372
+ let refreshed;
5373
+ try {
5374
+ refreshed = issueTwoFactorChallenge(email);
5375
+ } catch {
5376
+ }
5377
+ return Response.json({
5378
+ success: true,
5379
+ message: "Si un compte existe, un code a \xE9t\xE9 envoy\xE9.",
5380
+ ...refreshed ? { challenge: refreshed } : {}
5381
+ });
5382
+ };
5137
5383
  if (action === "send") {
5138
- if (await sendLimiter.check(email, req)) {
5139
- return Response.json(genericSendResponse);
5384
+ if (!verifyTwoFactorChallenge(email, challenge)) {
5385
+ return Response.json(
5386
+ { error: "Authentification requise avant l'envoi d'un code." },
5387
+ { status: 401 }
5388
+ );
5389
+ }
5390
+ if (await sendLimiter.check(limiterKey, req)) {
5391
+ return sendResponse();
5140
5392
  }
5141
5393
  const clients = await dbFind(payload, slugs.supportClients, {
5142
5394
  where: { email: { equals: email } },
@@ -5145,7 +5397,7 @@ function createAuth2faEndpoint(slugs, store) {
5145
5397
  overrideAccess: true
5146
5398
  });
5147
5399
  if (clients.docs.length === 0) {
5148
- return Response.json(genericSendResponse);
5400
+ return sendResponse();
5149
5401
  }
5150
5402
  const client = clients.docs[0];
5151
5403
  const plainCode = generateSecureCode();
@@ -5170,13 +5422,19 @@ function createAuth2faEndpoint(slugs, store) {
5170
5422
  <p style="font-size: 13px; color: #6b7280;">Ce code est valable 10 minutes.</p>
5171
5423
  </div>`
5172
5424
  });
5173
- return Response.json(genericSendResponse);
5425
+ return sendResponse();
5174
5426
  }
5175
5427
  if (action === "verify") {
5428
+ if (!verifyTwoFactorChallenge(email, challenge)) {
5429
+ return Response.json(
5430
+ { error: "Authentification requise avant la v\xE9rification d'un code." },
5431
+ { status: 401 }
5432
+ );
5433
+ }
5176
5434
  if (!code) {
5177
5435
  return Response.json({ error: "Code manquant" }, { status: 400 });
5178
5436
  }
5179
- if (await verifyLimiter.check(email, req)) {
5437
+ if (await verifyLimiter.check(limiterKey, req)) {
5180
5438
  return Response.json(
5181
5439
  { error: "Trop de tentatives. R\xE9essayez dans 15 minutes." },
5182
5440
  { status: 429 }
@@ -5217,7 +5475,7 @@ function createAuth2faEndpoint(slugs, store) {
5217
5475
  data: { twoFactorCode: "", twoFactorExpiry: "", twoFactorVerifiedAt: (/* @__PURE__ */ new Date()).toISOString() },
5218
5476
  overrideAccess: true
5219
5477
  });
5220
- verifyLimiter.reset(email);
5478
+ await verifyLimiter.reset(limiterKey, req);
5221
5479
  return Response.json({ success: true, verified: true });
5222
5480
  }
5223
5481
  return Response.json({ error: "Action invalide" }, { status: 400 });
@@ -5228,6 +5486,33 @@ function createAuth2faEndpoint(slugs, store) {
5228
5486
  }
5229
5487
  };
5230
5488
  }
5489
+ var OAUTH_STATE_COOKIE = "support-oauth-state";
5490
+ var OAUTH_STATE_MAX_AGE = 600;
5491
+ var TWO_FA_WINDOW_MS = 5 * 60 * 1e3;
5492
+ function readCookie(header, name) {
5493
+ if (!header) return null;
5494
+ for (const part of header.split(";")) {
5495
+ const eq = part.indexOf("=");
5496
+ if (eq === -1) continue;
5497
+ if (part.slice(0, eq).trim() !== name) continue;
5498
+ try {
5499
+ return decodeURIComponent(part.slice(eq + 1).trim());
5500
+ } catch {
5501
+ return part.slice(eq + 1).trim();
5502
+ }
5503
+ }
5504
+ return null;
5505
+ }
5506
+ function clearedStateCookie() {
5507
+ const secure = process.env.NODE_ENV === "production";
5508
+ return `${OAUTH_STATE_COOKIE}=; HttpOnly; ${secure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=0`;
5509
+ }
5510
+ function statesMatch(a, b) {
5511
+ if (!a || !b) return false;
5512
+ const left = crypto3__default.default.createHash("sha256").update(a).digest();
5513
+ const right = crypto3__default.default.createHash("sha256").update(b).digest();
5514
+ return crypto3__default.default.timingSafeEqual(left, right);
5515
+ }
5231
5516
  function createOAuthGoogleEndpoint(slugs, options) {
5232
5517
  return {
5233
5518
  path: "/support/oauth/google",
@@ -5244,7 +5529,7 @@ function createOAuthGoogleEndpoint(slugs, options) {
5244
5529
  }
5245
5530
  try {
5246
5531
  const body = await req.json();
5247
- const { action, code, state: queryState, cookieState } = body;
5532
+ const { action, code, state: queryState } = body;
5248
5533
  if (action === "login") {
5249
5534
  const oauthState = crypto3__default.default.randomBytes(32).toString("hex");
5250
5535
  const redirectUri = `${baseUrl}/api/support/oauth/google`;
@@ -5256,13 +5541,25 @@ function createOAuthGoogleEndpoint(slugs, options) {
5256
5541
  state: oauthState,
5257
5542
  prompt: "select_account"
5258
5543
  });
5259
- return Response.json({
5260
- url: `https://accounts.google.com/o/oauth2/v2/auth?${params}`,
5261
- state: oauthState
5262
- });
5544
+ const secure = process.env.NODE_ENV === "production";
5545
+ const loginHeaders = new Headers({ "Content-Type": "application/json" });
5546
+ loginHeaders.append(
5547
+ "Set-Cookie",
5548
+ `${OAUTH_STATE_COOKIE}=${oauthState}; HttpOnly; ${secure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=${OAUTH_STATE_MAX_AGE}`
5549
+ );
5550
+ return new Response(
5551
+ JSON.stringify({
5552
+ url: `https://accounts.google.com/o/oauth2/v2/auth?${params}`,
5553
+ // Kept for callers that echo it back in the redirect URL; the
5554
+ // server no longer trusts anything the caller returns.
5555
+ state: oauthState
5556
+ }),
5557
+ { status: 200, headers: loginHeaders }
5558
+ );
5263
5559
  }
5264
5560
  if (code) {
5265
- if (!cookieState || !queryState || cookieState !== queryState) {
5561
+ const issuedState = readCookie(req.headers.get("cookie"), OAUTH_STATE_COOKIE);
5562
+ if (!statesMatch(issuedState, queryState)) {
5266
5563
  return Response.json({ error: "state_mismatch" }, { status: 400 });
5267
5564
  }
5268
5565
  const redirectUri = `${baseUrl}/api/support/oauth/google`;
@@ -5344,6 +5641,35 @@ function createOAuthGoogleEndpoint(slugs, options) {
5344
5641
  overrideAccess: true
5345
5642
  });
5346
5643
  }
5644
+ const twoFactorDoc = await dbFindByID(payload$1, slugs.supportClients, {
5645
+ id: clientDoc.id,
5646
+ depth: 0,
5647
+ overrideAccess: true,
5648
+ showHiddenFields: true
5649
+ });
5650
+ if (twoFactorDoc?.twoFactorEnabled) {
5651
+ const raw = twoFactorDoc.twoFactorVerifiedAt;
5652
+ const verifiedAt = raw ? new Date(raw).getTime() : 0;
5653
+ if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS)) {
5654
+ let challenge;
5655
+ try {
5656
+ challenge = issueTwoFactorChallenge(clientDoc.email);
5657
+ } catch {
5658
+ }
5659
+ return new Response(JSON.stringify({ requires2FA: true, ...challenge ? { challenge } : {} }), {
5660
+ status: 200,
5661
+ headers: new Headers({
5662
+ "Content-Type": "application/json",
5663
+ "Set-Cookie": clearedStateCookie()
5664
+ })
5665
+ });
5666
+ }
5667
+ await dbUpdate(payload$1, slugs.supportClients, {
5668
+ id: clientDoc.id,
5669
+ data: { twoFactorVerifiedAt: null },
5670
+ overrideAccess: true
5671
+ });
5672
+ }
5347
5673
  const secret = process.env.PAYLOAD_SECRET;
5348
5674
  if (!secret) {
5349
5675
  return Response.json({ error: "server_misconfigured" }, { status: 500 });
@@ -5387,6 +5713,7 @@ function createOAuthGoogleEndpoint(slugs, options) {
5387
5713
  "Set-Cookie",
5388
5714
  `payload-token=${token}; HttpOnly; ${cookieSecure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=${tokenExpiration}`
5389
5715
  );
5716
+ headers.append("Set-Cookie", clearedStateCookie());
5390
5717
  return new Response(JSON.stringify({ user: clientDoc, exp }), {
5391
5718
  status: 200,
5392
5719
  headers
@@ -5693,7 +6020,7 @@ ONLY JSON, nothing else.`
5693
6020
  }
5694
6021
  }
5695
6022
  function createImportConversationEndpoint(slugs, store) {
5696
- const importLimiter = new RateLimiter(36e5, 10, store);
6023
+ const importLimiter = new RateLimiter(36e5, 10, store, "import-conversation");
5697
6024
  return {
5698
6025
  path: "/support/import-conversation",
5699
6026
  method: "post",
@@ -5710,7 +6037,7 @@ function createImportConversationEndpoint(slugs, store) {
5710
6037
  if (!isAuthed) {
5711
6038
  return Response.json({ error: "Unauthorized" }, { status: 401 });
5712
6039
  }
5713
- const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
6040
+ const ip = clientIpRateKey(req);
5714
6041
  if (await importLimiter.check(ip, req)) {
5715
6042
  return Response.json({ error: "Rate limit exceeded. Maximum 10 imports per hour." }, { status: 429 });
5716
6043
  }
@@ -5836,6 +6163,152 @@ function createImportConversationEndpoint(slugs, store) {
5836
6163
  }
5837
6164
  };
5838
6165
  }
6166
+ function httpAllowed() {
6167
+ return process.env.SUPPORT_ALLOW_INSECURE_WEBHOOKS === "1";
6168
+ }
6169
+ var BLOCKED_HOST_SUFFIXES = [".local", ".localhost", ".internal", ".home.arpa"];
6170
+ function parseIPv4(host) {
6171
+ const parts = host.split(".");
6172
+ if (parts.length !== 4) return null;
6173
+ const octets = [];
6174
+ for (const part of parts) {
6175
+ if (!/^\d{1,3}$/.test(part)) return null;
6176
+ const n = Number(part);
6177
+ if (n > 255) return null;
6178
+ octets.push(n);
6179
+ }
6180
+ return octets;
6181
+ }
6182
+ function isPrivateIPv4(octets) {
6183
+ const [a, b] = octets;
6184
+ if (a === 0) return true;
6185
+ if (a === 10) return true;
6186
+ if (a === 127) return true;
6187
+ if (a === 169 && b === 254) return true;
6188
+ if (a === 172 && b >= 16 && b <= 31) return true;
6189
+ if (a === 192 && b === 168) return true;
6190
+ if (a === 192 && b === 0) return true;
6191
+ if (a === 198 && (b === 18 || b === 19)) return true;
6192
+ if (a === 100 && b >= 64 && b <= 127) return true;
6193
+ if (a >= 224) return true;
6194
+ return false;
6195
+ }
6196
+ function isPrivateIPv6(host) {
6197
+ const lower = host.toLowerCase();
6198
+ if (lower === "::" || lower === "::1") return true;
6199
+ if (lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb")) return true;
6200
+ if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
6201
+ if (lower.startsWith("ff")) return true;
6202
+ const dotted = lower.match(/^::(?:ffff:)?(\d{1,3}(?:\.\d{1,3}){3})$/);
6203
+ if (dotted) {
6204
+ const octets = parseIPv4(dotted[1]);
6205
+ return octets ? isPrivateIPv4(octets) : true;
6206
+ }
6207
+ const hex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
6208
+ if (hex) {
6209
+ const high = parseInt(hex[1], 16);
6210
+ const low = parseInt(hex[2], 16);
6211
+ return isPrivateIPv4([high >> 8, high & 255, low >> 8, low & 255]);
6212
+ }
6213
+ return false;
6214
+ }
6215
+ function normalizeHost(hostname) {
6216
+ const host = hostname.trim().toLowerCase();
6217
+ return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
6218
+ }
6219
+ function isBlockedHost(hostname) {
6220
+ const host = normalizeHost(hostname);
6221
+ if (!host) return true;
6222
+ if (host === "localhost") return true;
6223
+ if (BLOCKED_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix))) return true;
6224
+ const v4 = parseIPv4(host);
6225
+ if (v4) return isPrivateIPv4(v4);
6226
+ if (host.includes(":")) return isPrivateIPv6(host);
6227
+ return false;
6228
+ }
6229
+ function validateWebhookUrl(raw) {
6230
+ if (typeof raw !== "string" || !raw.trim()) return { ok: false, reason: "invalid_url" };
6231
+ let url;
6232
+ try {
6233
+ url = new URL(raw.trim());
6234
+ } catch {
6235
+ return { ok: false, reason: "invalid_url" };
6236
+ }
6237
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && httpAllowed())) {
6238
+ return { ok: false, reason: "scheme_not_allowed" };
6239
+ }
6240
+ if (isBlockedHost(url.hostname)) return { ok: false, reason: "private_host" };
6241
+ return { ok: true, url };
6242
+ }
6243
+ var WEBHOOK_URL_MESSAGES = {
6244
+ invalid_url: "URL invalide.",
6245
+ scheme_not_allowed: "Seules les URL https:// sont accept\xE9es.",
6246
+ private_host: "Les adresses priv\xE9es, loopback et link-local sont interdites (SSRF)."
6247
+ };
6248
+ var MAX_PUSH_ENDPOINT_LENGTH = 2048;
6249
+ function validatePushEndpoint(raw) {
6250
+ if (typeof raw !== "string" || !raw.trim() || raw.length > MAX_PUSH_ENDPOINT_LENGTH) {
6251
+ return { ok: false, reason: "invalid_url" };
6252
+ }
6253
+ let url;
6254
+ try {
6255
+ url = new URL(raw.trim());
6256
+ } catch {
6257
+ return { ok: false, reason: "invalid_url" };
6258
+ }
6259
+ if (url.protocol !== "https:") return { ok: false, reason: "scheme_not_allowed" };
6260
+ if (isBlockedHost(url.hostname)) return { ok: false, reason: "private_host" };
6261
+ return { ok: true, url };
6262
+ }
6263
+ var LOOKUP_TIMEOUT_MS = 5e3;
6264
+ var LOOKUP_TIMED_OUT = /* @__PURE__ */ Symbol("lookup-timed-out");
6265
+ var NONEXISTENT_HOST_CODES = /* @__PURE__ */ new Set(["ENOTFOUND", "ENODATA", "NOTFOUND"]);
6266
+ async function assertPublicHost(hostname) {
6267
+ const host = normalizeHost(hostname);
6268
+ if (isBlockedHost(host)) return false;
6269
+ if (parseIPv4(host) || host.includes(":")) return true;
6270
+ try {
6271
+ const addresses = await Promise.race([
6272
+ promises.lookup(host, { all: true }),
6273
+ new Promise(
6274
+ (resolve) => setTimeout(() => resolve(LOOKUP_TIMED_OUT), LOOKUP_TIMEOUT_MS).unref?.()
6275
+ )
6276
+ ]);
6277
+ if (addresses === LOOKUP_TIMED_OUT) return false;
6278
+ if (!Array.isArray(addresses) || addresses.length === 0) return false;
6279
+ return addresses.every((entry) => !isBlockedHost(entry.address));
6280
+ } catch (error) {
6281
+ const code = error?.code;
6282
+ return typeof code === "string" && NONEXISTENT_HOST_CODES.has(code);
6283
+ }
6284
+ }
6285
+ var BlockedRequestError = class extends Error {
6286
+ constructor(reason) {
6287
+ super(`Blocked outbound request: ${reason}`);
6288
+ this.reason = reason;
6289
+ this.name = "BlockedRequestError";
6290
+ }
6291
+ reason;
6292
+ };
6293
+ var MAX_REDIRECTS = 3;
6294
+ async function safeFetch(rawUrl, init) {
6295
+ let current = rawUrl;
6296
+ let body = init.body;
6297
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
6298
+ const validation = validateWebhookUrl(current);
6299
+ if (!validation.ok || !validation.url) throw new BlockedRequestError(validation.reason || "invalid_url");
6300
+ if (!await assertPublicHost(validation.url.hostname)) throw new BlockedRequestError("private_host");
6301
+ const response = await fetch(current, { ...init, body, redirect: "manual" });
6302
+ if (response.status < 300 || response.status > 399) return response;
6303
+ const location = response.headers.get("location");
6304
+ if (!location) return response;
6305
+ current = new URL(location, current).toString();
6306
+ if (response.status === 303) body = void 0;
6307
+ }
6308
+ throw new BlockedRequestError("too_many_redirects");
6309
+ }
6310
+
6311
+ // src/utils/webhookDispatcher.ts
5839
6312
  function dispatchWebhook(data, event, payload, slugs) {
5840
6313
  const done = _dispatch(data, event, payload, slugs);
5841
6314
  return done;
@@ -5872,7 +6345,7 @@ async function _sendToEndpoint(endpoint, body, payload, slugs) {
5872
6345
  const signature = crypto3__default.default.createHmac("sha256", endpoint.secret).update(body).digest("hex");
5873
6346
  headers["X-Webhook-Signature"] = signature;
5874
6347
  }
5875
- const response = await fetch(endpoint.url, {
6348
+ const response = await safeFetch(endpoint.url, {
5876
6349
  method: "POST",
5877
6350
  headers,
5878
6351
  body,
@@ -6242,6 +6715,15 @@ async function sendPushToUser(payload, slugs, userId, notification) {
6242
6715
  for (const s of subs.docs) {
6243
6716
  const row = s;
6244
6717
  if (!row.endpoint || !row.p256dh || !row.auth) continue;
6718
+ const check = validatePushEndpoint(row.endpoint);
6719
+ if (!check.ok || !check.url) {
6720
+ console.warn("[support] Push endpoint refused (unsafe URL):", check.reason);
6721
+ continue;
6722
+ }
6723
+ if (!await assertPublicHost(check.url.hostname)) {
6724
+ console.warn("[support] Push endpoint refused (host resolves to a private address)");
6725
+ continue;
6726
+ }
6245
6727
  try {
6246
6728
  await webpush__default.default.sendNotification(
6247
6729
  { endpoint: row.endpoint, keys: { p256dh: row.p256dh, auth: row.auth } },
@@ -6292,7 +6774,14 @@ function createPushSubscribeEndpoint(slugs) {
6292
6774
  if (!endpoint || !p256dh || !auth) {
6293
6775
  return Response.json({ error: "subscription invalide (endpoint + keys requis)." }, { status: 400 });
6294
6776
  }
6295
- const data = { user: req.user.id, endpoint, p256dh, auth, userAgent: req.headers.get("user-agent") || "" };
6777
+ const endpointCheck = validatePushEndpoint(endpoint);
6778
+ if (!endpointCheck.ok) {
6779
+ return Response.json(
6780
+ { error: WEBHOOK_URL_MESSAGES[endpointCheck.reason || "invalid_url"] },
6781
+ { status: 400 }
6782
+ );
6783
+ }
6784
+ const data = { user: req.user.id, endpoint, p256dh, auth, userAgent: (req.headers.get("user-agent") || "").slice(0, 256) };
6296
6785
  const existing = await dbFind(req.payload, slugs.pushSubscriptions, { where: { endpoint: { equals: endpoint } }, limit: 1, depth: 0, overrideAccess: true });
6297
6786
  if (existing.docs.length > 0) {
6298
6787
  await dbUpdate(req.payload, slugs.pushSubscriptions, { id: existing.docs[0].id, data, overrideAccess: true });
@@ -6326,7 +6815,7 @@ function createUserPrefsGetEndpoint(slugs) {
6326
6815
  requireAdmin(req, slugs);
6327
6816
  const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
6328
6817
  const prefs = await dbFind(payload, "payload-preferences", {
6329
- where: { key: { equals: key } },
6818
+ where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
6330
6819
  limit: 1,
6331
6820
  depth: 0,
6332
6821
  overrideAccess: true
@@ -6360,7 +6849,7 @@ function createUserPrefsPostEndpoint(slugs) {
6360
6849
  const body = await req.json();
6361
6850
  const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
6362
6851
  const existing = await dbFind(payload, "payload-preferences", {
6363
- where: { key: { equals: key } },
6852
+ where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
6364
6853
  limit: 1,
6365
6854
  depth: 0,
6366
6855
  overrideAccess: true
@@ -6516,8 +7005,10 @@ function createTicketFeedbackEndpoint(slugs) {
6516
7005
  // src/endpoints/transfer-ticket.ts
6517
7006
  var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
6518
7007
  var MAX_TRANSFERS_PER_DAY = 5;
7008
+ var MAX_TRANSFERS_PER_USER_PER_DAY = 15;
6519
7009
  function createTransferTicketEndpoint(slugs, store) {
6520
- const transferLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_DAY, store);
7010
+ const transferLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_DAY, store, "transfer:ticket");
7011
+ const transferUserLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_USER_PER_DAY, store, "transfer:user");
6521
7012
  return {
6522
7013
  path: "/support/tickets/:id/transfer",
6523
7014
  method: "post",
@@ -6552,12 +7043,18 @@ function createTransferTicketEndpoint(slugs, store) {
6552
7043
  if (!isAdmin && !isOwner) {
6553
7044
  return Response.json({ error: "Forbidden" }, { status: 403 });
6554
7045
  }
6555
- if (await transferLimiter.check(`${req.user.id}:${ticketId}`, req)) {
7046
+ if (await transferLimiter.check(`${principalRateKey(req.user)}:${ticketId}`, req)) {
6556
7047
  return Response.json(
6557
7048
  { error: `Limite atteinte (${MAX_TRANSFERS_PER_DAY} transferts par 24h sur ce ticket)` },
6558
7049
  { status: 429 }
6559
7050
  );
6560
7051
  }
7052
+ if (!isAdmin && await transferUserLimiter.check(principalRateKey(req.user), req)) {
7053
+ return Response.json(
7054
+ { error: `Limite atteinte (${MAX_TRANSFERS_PER_USER_PER_DAY} transferts par 24h)` },
7055
+ { status: 429 }
7056
+ );
7057
+ }
6561
7058
  try {
6562
7059
  const since = new Date(Date.now() - 24 * 60 * 60 * 1e3).toISOString();
6563
7060
  const existing = await dbCount(payload, slugs.emailLogs, {
@@ -6699,8 +7196,11 @@ function statusToLabel(status) {
6699
7196
  }
6700
7197
  var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
6701
7198
  var MAX_COLLABORATORS_PER_TICKET = 20;
7199
+ var MAX_NEW_ACCOUNTS_PER_INVITER = 30;
7200
+ var NEW_ACCOUNT_WINDOW_MS = 7 * 24 * 60 * 60 * 1e3;
6702
7201
  function createInviteCollaboratorEndpoint(slugs, store) {
6703
- const inviteLimiter = new RateLimiter(60 * 60 * 1e3, 10, store);
7202
+ const inviteLimiter = new RateLimiter(60 * 60 * 1e3, 10, store, "invite-collaborator");
7203
+ const newAccountLimiter = new RateLimiter(NEW_ACCOUNT_WINDOW_MS, MAX_NEW_ACCOUNTS_PER_INVITER, store, "invite-collaborator:new-account");
6704
7204
  return {
6705
7205
  path: "/support/tickets/:id/invite",
6706
7206
  method: "post",
@@ -6735,7 +7235,7 @@ function createInviteCollaboratorEndpoint(slugs, store) {
6735
7235
  if (!isAdmin && !isOwner) {
6736
7236
  return Response.json({ error: "Forbidden" }, { status: 403 });
6737
7237
  }
6738
- if (await inviteLimiter.check(String(req.user.id), req)) {
7238
+ if (await inviteLimiter.check(principalRateKey(req.user), req)) {
6739
7239
  return Response.json({ error: "Trop d'invitations. R\xE9essayez plus tard." }, { status: 429 });
6740
7240
  }
6741
7241
  const collabCount = await dbCount(payload, "ticket-collaborators", { where: { ticket: { equals: ticketId } }, overrideAccess: true }).catch(() => ({ totalDocs: 0 }));
@@ -6759,6 +7259,12 @@ function createInviteCollaboratorEndpoint(slugs, store) {
6759
7259
  if (existing.docs.length > 0) {
6760
7260
  inviteeId = existing.docs[0].id;
6761
7261
  } else {
7262
+ if (!isAdmin && await newAccountLimiter.check(principalRateKey(req.user), req)) {
7263
+ return Response.json(
7264
+ { error: "Trop de nouveaux comptes invit\xE9s. R\xE9essayez plus tard." },
7265
+ { status: 429 }
7266
+ );
7267
+ }
6762
7268
  const tempPassword = crypto3.randomBytes(16).toString("hex");
6763
7269
  const created = await dbCreate(payload, slugs.supportClients, {
6764
7270
  data: {
@@ -6930,7 +7436,7 @@ function createSupportEndpoints(slugs, options) {
6930
7436
  }
6931
7437
  if (!f || f.satisfaction !== false) endpoints.push(createSatisfactionEndpoint(slugs));
6932
7438
  if (!f || f.emailTracking !== false) {
6933
- endpoints.push(createEmailStatsEndpoint(slugs), createTrackOpenEndpoint(slugs));
7439
+ endpoints.push(createEmailStatsEndpoint(slugs, rateLimitStore), createTrackOpenEndpoint(slugs));
6934
7440
  }
6935
7441
  if (!f || f.pendingEmails !== false) endpoints.push(createPendingEmailsProcessEndpoint(slugs));
6936
7442
  if (!f || f.scheduledReplies !== false) endpoints.push(createProcessScheduledEndpoint(slugs));
@@ -8415,7 +8921,7 @@ function createTicketsCollection(slugs, options) {
8415
8921
  }
8416
8922
 
8417
8923
  // src/utils/ticketAccess.ts
8418
- async function resolveAccessibleTicketIds(payload, slugs, clientId) {
8924
+ async function resolveAccessibleTicketIds(payload, slugs, clientId, mode = "read") {
8419
8925
  const ids = /* @__PURE__ */ new Set();
8420
8926
  try {
8421
8927
  const owned = await dbFind(payload, slugs.tickets, {
@@ -8436,6 +8942,7 @@ async function resolveAccessibleTicketIds(payload, slugs, clientId) {
8436
8942
  });
8437
8943
  for (const r of collab.docs) {
8438
8944
  const row = r;
8945
+ if (mode === "write" && row.role !== "collaborator") continue;
8439
8946
  const tid = typeof row.ticket === "object" ? row.ticket?.id : row.ticket;
8440
8947
  if (tid !== void 0 && tid !== null) ids.add(tid);
8441
8948
  }
@@ -8542,7 +9049,7 @@ function createRestrictClientTicketTarget(slugs) {
8542
9049
  if (targetId === void 0 || targetId === null || targetId === "") {
8543
9050
  throw new payload.APIError("Ticket cible requis.", 400);
8544
9051
  }
8545
- const accessible = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id);
9052
+ const accessible = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id, "write");
8546
9053
  if (!accessible.some((id) => String(id) === String(targetId))) {
8547
9054
  throw new payload.APIError("Ticket inaccessible.", 403);
8548
9055
  }
@@ -9059,13 +9566,13 @@ function createSendInvitationOnCreate(slugs) {
9059
9566
  return doc;
9060
9567
  };
9061
9568
  }
9062
- var TWO_FA_WINDOW_MS = 5 * 60 * 1e3;
9569
+ var TWO_FA_WINDOW_MS2 = 5 * 60 * 1e3;
9063
9570
  function createEnforce2FA(slugs) {
9064
9571
  return async ({ req, user }) => {
9065
9572
  if (!user?.twoFactorEnabled) return user;
9066
9573
  const raw = user.twoFactorVerifiedAt;
9067
9574
  const verifiedAt = raw ? new Date(raw).getTime() : 0;
9068
- if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS)) {
9575
+ if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS2)) {
9069
9576
  throw new payload.APIError("2FA_REQUIRED", 401);
9070
9577
  }
9071
9578
  await req.payload.update({
@@ -9906,8 +10413,28 @@ function createKnowledgeBaseCollection(slugs) {
9906
10413
  timestamps: true
9907
10414
  };
9908
10415
  }
9909
-
9910
- // src/collections/ChatMessages.ts
10416
+ function createRestrictClientChatWrite(slugs) {
10417
+ return async ({ data, req }) => {
10418
+ if (req.user?.collection !== slugs.supportClients) return data;
10419
+ data.client = req.user.id;
10420
+ data.senderType = "client";
10421
+ delete data.agent;
10422
+ const session = typeof data.session === "string" ? data.session : null;
10423
+ if (!session) return data;
10424
+ const existing = await dbFind(req.payload, slugs.chatMessages, {
10425
+ where: { session: { equals: session } },
10426
+ limit: 1,
10427
+ depth: 0,
10428
+ overrideAccess: true
10429
+ });
10430
+ const owner = existing.docs[0]?.client;
10431
+ const ownerId = owner && typeof owner === "object" ? owner.id : owner;
10432
+ if (ownerId !== void 0 && ownerId !== null && String(ownerId) !== String(req.user.id)) {
10433
+ throw new payload.APIError("Session inaccessible.", 403);
10434
+ }
10435
+ return data;
10436
+ };
10437
+ }
9911
10438
  function createChatMessagesCollection(slugs) {
9912
10439
  return {
9913
10440
  slug: slugs.chatMessages,
@@ -9929,7 +10456,9 @@ function createChatMessagesCollection(slugs) {
9929
10456
  }
9930
10457
  return false;
9931
10458
  },
9932
- create: ({ req }) => !!req.user,
10459
+ // Staff and support-clients only — NOT "any authenticated principal":
10460
+ // a user of any other auth collection of the host app satisfied `!!req.user`.
10461
+ create: ({ req }) => req.user?.collection === slugs.users || req.user?.collection === slugs.supportClients,
9933
10462
  update: ({ req }) => req.user?.collection === slugs.users,
9934
10463
  delete: ({ req }) => req.user?.collection === slugs.users
9935
10464
  },
@@ -9998,6 +10527,9 @@ function createChatMessagesCollection(slugs) {
9998
10527
  }
9999
10528
  }
10000
10529
  ],
10530
+ hooks: {
10531
+ beforeChange: [createRestrictClientChatWrite(slugs)]
10532
+ },
10001
10533
  timestamps: true
10002
10534
  };
10003
10535
  }
@@ -10275,8 +10807,19 @@ function createAuthLogsCollection(slugs) {
10275
10807
  timestamps: true
10276
10808
  };
10277
10809
  }
10278
-
10279
- // src/collections/WebhookEndpoints.ts
10810
+ function createValidateWebhookUrl() {
10811
+ return ({ data, operation, originalDoc }) => {
10812
+ const incoming = data?.url;
10813
+ if (incoming === void 0 || incoming === null) return data;
10814
+ const previous = originalDoc?.url;
10815
+ if (operation === "update" && incoming === previous) return data;
10816
+ const result = validateWebhookUrl(incoming);
10817
+ if (!result.ok) {
10818
+ throw new payload.APIError(WEBHOOK_URL_MESSAGES[result.reason || "invalid_url"], 400);
10819
+ }
10820
+ return data;
10821
+ };
10822
+ }
10280
10823
  function createWebhookEndpointsCollection(slugs) {
10281
10824
  return {
10282
10825
  slug: slugs.webhookEndpoints,
@@ -10310,8 +10853,11 @@ function createWebhookEndpointsCollection(slugs) {
10310
10853
  type: "text",
10311
10854
  required: true,
10312
10855
  label: "URL",
10856
+ // The SSRF check lives in the collection `beforeValidate` above, NOT in a
10857
+ // field `validate`: the latter re-runs on the merged document and would
10858
+ // freeze every pre-existing row on any unrelated edit.
10313
10859
  admin: {
10314
- description: "URL du webhook \xE0 appeler (POST)"
10860
+ description: "URL https:// du webhook \xE0 appeler (POST). Les adresses priv\xE9es et loopback sont refus\xE9es."
10315
10861
  }
10316
10862
  },
10317
10863
  {
@@ -10365,6 +10911,9 @@ function createWebhookEndpointsCollection(slugs) {
10365
10911
  }
10366
10912
  }
10367
10913
  ],
10914
+ hooks: {
10915
+ beforeValidate: [createValidateWebhookUrl()]
10916
+ },
10368
10917
  timestamps: true
10369
10918
  };
10370
10919
  }
@@ -10816,11 +11365,17 @@ function createClientSummariesCollection(slugs) {
10816
11365
  admin: { readOnly: true }
10817
11366
  }
10818
11367
  ],
11368
+ // Staff-only, on the SAME source of truth as every other collection and as
11369
+ // `requireAdmin`: `slugs.users`. The literal `'users'` this used to compare
11370
+ // against is the DEFAULT slug, not the configured one — on a host app whose
11371
+ // staff collection is renamed (`collectionSlugs.users: 'admins'`) it named
11372
+ // the front-office collection instead, opening read/create/update/delete on
11373
+ // AI-generated client intelligence to it while locking the real agents out.
10819
11374
  access: {
10820
- create: ({ req }) => req.user?.collection === "users",
10821
- read: ({ req }) => req.user?.collection === "users",
10822
- update: ({ req }) => req.user?.collection === "users",
10823
- delete: ({ req }) => req.user?.collection === "users"
11375
+ create: ({ req }) => req.user?.collection === slugs.users,
11376
+ read: ({ req }) => req.user?.collection === slugs.users,
11377
+ update: ({ req }) => req.user?.collection === slugs.users,
11378
+ delete: ({ req }) => req.user?.collection === slugs.users
10824
11379
  },
10825
11380
  timestamps: true
10826
11381
  };
@@ -11284,6 +11839,17 @@ function supportPlugin(config) {
11284
11839
  });
11285
11840
  return {
11286
11841
  ...incomingConfig,
11842
+ // Publish the resolved staff collection so the server-side readers share
11843
+ // ONE source of truth with the writers. `requireAdmin` compares against
11844
+ // `slugs.users`; the `payload-preferences` reads used to scope themselves
11845
+ // on `config.admin.user`, which Payload silently defaults to the first
11846
+ // auth collection of the host app — a different collection on any app
11847
+ // that declares `collectionSlugs.users`, and the settings-poisoning hole
11848
+ // reopened right there.
11849
+ custom: {
11850
+ ...incomingConfig.custom,
11851
+ [SUPPORT_STAFF_SLUG_CONFIG_KEY]: slugs.users
11852
+ },
11287
11853
  collections: config?.skipCollections ? existingCollections : [...existingCollections, ...supportCollections],
11288
11854
  endpoints: config?.skipEndpoints ? existingEndpoints : [...existingEndpoints, ...supportEndpoints],
11289
11855
  admin: {