@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.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { initTransaction, commitTransaction, killTransaction, getFieldsToSign, jwtSign, APIError } from 'payload';
2
2
  import crypto3, { createHash, timingSafeEqual, createHmac, randomBytes } from 'crypto';
3
3
  import PDFDocument from 'pdfkit';
4
+ import { lookup } from 'dns/promises';
4
5
  import webpush from 'web-push';
5
6
  import sanitizeHtml from 'sanitize-html';
6
7
 
@@ -74,12 +75,43 @@ var DEFAULT_SLUGS = {
74
75
  function resolveSlugs(overrides) {
75
76
  return { ...DEFAULT_SLUGS, ...overrides };
76
77
  }
78
+ var MAX_MEMORY_RATE_LIMIT_KEYS = 1e4;
77
79
  var MemoryRateLimitStore = class {
80
+ constructor(maxKeys = MAX_MEMORY_RATE_LIMIT_KEYS) {
81
+ this.maxKeys = maxKeys;
82
+ }
83
+ maxKeys;
78
84
  entries = /* @__PURE__ */ new Map();
85
+ /** Distinct keys currently held. Exposed so the ceiling can be asserted. */
86
+ get size() {
87
+ return this.entries.size;
88
+ }
89
+ /** Reclaims every window that has already closed. */
90
+ sweepExpired(now) {
91
+ for (const [key, entry] of this.entries) {
92
+ if (now > entry.resetAt) this.entries.delete(key);
93
+ }
94
+ }
95
+ /** Soonest-closing window first, so the ceiling drops the least useful entry. */
96
+ evictOldest() {
97
+ let oldestKey = null;
98
+ let oldestResetAt = Infinity;
99
+ for (const [key, entry] of this.entries) {
100
+ if (entry.resetAt < oldestResetAt) {
101
+ oldestResetAt = entry.resetAt;
102
+ oldestKey = key;
103
+ }
104
+ }
105
+ if (oldestKey !== null) this.entries.delete(oldestKey);
106
+ }
79
107
  async increment(key, windowMs) {
80
108
  const now = Date.now();
81
109
  const current = this.entries.get(key);
82
110
  const next = !current || now > current.resetAt ? { count: 1, resetAt: now + windowMs } : { ...current, count: current.count + 1 };
111
+ if (!current && this.entries.size >= this.maxKeys) {
112
+ this.sweepExpired(now);
113
+ if (this.entries.size >= this.maxKeys) this.evictOldest();
114
+ }
83
115
  this.entries.set(key, next);
84
116
  return next;
85
117
  }
@@ -158,22 +190,61 @@ var PayloadRateLimitStore = class {
158
190
  return { payload: context };
159
191
  }
160
192
  };
193
+ function principalRateKey(user) {
194
+ if (!user || user.id === void 0 || user.id === null) return "anonymous";
195
+ const collection = typeof user.collection === "string" && user.collection ? user.collection : "unknown";
196
+ return `${collection}:${String(user.id)}`;
197
+ }
198
+ var MAX_IP_KEY_LENGTH = 45;
199
+ var IPV4_PATTERN = /^(?:\d{1,3}\.){3}\d{1,3}$/;
200
+ var IPV6_PATTERN = /^[0-9a-fA-F:.%]+$/;
201
+ function clientIpRateKey(req) {
202
+ const candidate = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip")?.trim() || "";
203
+ return normalizeIpKey(candidate);
204
+ }
205
+ function normalizeIpKey(candidate) {
206
+ if (!candidate || candidate.length > MAX_IP_KEY_LENGTH) return "unknown";
207
+ const host = candidate.startsWith("[") && candidate.endsWith("]") ? candidate.slice(1, -1) : candidate;
208
+ if (!host) return "unknown";
209
+ if (IPV4_PATTERN.test(host)) {
210
+ return host.split(".").every((octet) => Number(octet) <= 255) ? host : "unknown";
211
+ }
212
+ if (host.includes(":") && IPV6_PATTERN.test(host)) return host.toLowerCase();
213
+ return "unknown";
214
+ }
161
215
  var RateLimiter = class {
162
- constructor(windowMs, maxRequests, store) {
216
+ /**
217
+ * @param namespace Endpoint-scoped prefix for every key this limiter writes.
218
+ * The store is SHARED (`rateLimitStore: 'payload'` builds one instance for
219
+ * all endpoints), and the raw keys collide across endpoints: `ip` was used
220
+ * by both the login and the chatbot limiter — 10 forged chatbot requests
221
+ * locked a victim out of the portal for 15 minutes — and `String(user.id)`
222
+ * by five different endpoints. Always pass one; it is optional only because
223
+ * `RateLimiter` is part of the published API surface.
224
+ */
225
+ constructor(windowMs, maxRequests, store, namespace) {
163
226
  this.windowMs = windowMs;
164
227
  this.maxRequests = maxRequests;
165
228
  this.store = store ?? new MemoryRateLimitStore();
229
+ this.prefix = namespace ? `${namespace}:` : "";
166
230
  }
167
231
  windowMs;
168
232
  maxRequests;
169
233
  store;
234
+ prefix;
235
+ /** The key actually written to the store. Exposed for assertions in tests. */
236
+ scopedKey(key) {
237
+ return `${this.prefix}${key}`;
238
+ }
170
239
  async check(key, context) {
171
- const entry = context === void 0 ? await this.store.increment(key, this.windowMs) : await this.store.increment(key, this.windowMs, context);
240
+ const scoped = this.scopedKey(key);
241
+ const entry = context === void 0 ? await this.store.increment(scoped, this.windowMs) : await this.store.increment(scoped, this.windowMs, context);
172
242
  return entry.count > this.maxRequests;
173
243
  }
174
244
  async reset(key, context) {
175
- if (context === void 0) await this.store.reset(key);
176
- else await this.store.reset(key, context);
245
+ const scoped = this.scopedKey(key);
246
+ if (context === void 0) await this.store.reset(scoped);
247
+ else await this.store.reset(scoped, context);
177
248
  }
178
249
  };
179
250
  var DEFAULT_INBOUND_EMAIL_LIMITS = {
@@ -211,7 +282,7 @@ function validateInboundEmailPayload(input, contentLength, limits = DEFAULT_INBO
211
282
 
212
283
  // src/endpoints/capabilities.ts
213
284
  function createInboundEmailEndpoint(capability, store) {
214
- const limiter = new RateLimiter(6e4, 60, store);
285
+ const limiter = new RateLimiter(6e4, 60, store, "inbound-email");
215
286
  return {
216
287
  path: "/support-webhook/inbound-email",
217
288
  method: "post",
@@ -220,7 +291,7 @@ function createInboundEmailEndpoint(capability, store) {
220
291
  if (!verifySecret(req.headers.get(secretHeader), capability.secret)) {
221
292
  return Response.json({ error: "Unauthorized" }, { status: 401 });
222
293
  }
223
- const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip") || "unknown";
294
+ const ip = clientIpRateKey(req);
224
295
  if (await limiter.check(ip, req)) {
225
296
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
226
297
  }
@@ -242,7 +313,7 @@ function createInboundEmailEndpoint(capability, store) {
242
313
  };
243
314
  }
244
315
  function createProjectSuggestionsEndpoint(slugs, capability, store) {
245
- const limiter = new RateLimiter(6e4, 20, store);
316
+ const limiter = new RateLimiter(6e4, 20, store, "suggest-projects");
246
317
  return {
247
318
  path: "/support/suggest-projects",
248
319
  method: "post",
@@ -250,7 +321,7 @@ function createProjectSuggestionsEndpoint(slugs, capability, store) {
250
321
  if (!req.user || req.user.collection !== slugs.users) {
251
322
  return Response.json({ error: "Unauthorized" }, { status: 401 });
252
323
  }
253
- const key = req.user?.id ? String(req.user.id) : "anonymous";
324
+ const key = principalRateKey(req.user);
254
325
  if (await limiter.check(key, req)) {
255
326
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
256
327
  }
@@ -259,7 +330,7 @@ function createProjectSuggestionsEndpoint(slugs, capability, store) {
259
330
  };
260
331
  }
261
332
  function createTicketTitleEndpoint(slugs, capability, store) {
262
- const limiter = new RateLimiter(6e4, 20, store);
333
+ const limiter = new RateLimiter(6e4, 20, store, "ticket-title");
263
334
  return {
264
335
  path: "/support/ticket-title",
265
336
  method: "post",
@@ -281,7 +352,7 @@ function createTicketTitleEndpoint(slugs, capability, store) {
281
352
  };
282
353
  }
283
354
  function createGenerateMissingTitlesEndpoint(slugs, capability, store) {
284
- const limiter = new RateLimiter(6e4, 5, store);
355
+ const limiter = new RateLimiter(6e4, 5, store, "generate-missing-titles");
285
356
  return {
286
357
  path: "/support/generate-missing-titles",
287
358
  method: "post",
@@ -437,6 +508,14 @@ var SUPPORT_SETTINGS_PREF_KEY = "support-settings";
437
508
  var PREF_KEY = SUPPORT_SETTINGS_PREF_KEY;
438
509
  var USER_PREFS_KEY_PREFIX = "support-user-prefs";
439
510
  var LEGACY_ROUND_ROBIN_KEY = "support-round-robin";
511
+ var SUPPORT_STAFF_SLUG_CONFIG_KEY = "supportStaffCollection";
512
+ function resolveStaffPrefSlug(payload, staffSlug) {
513
+ if (staffSlug) return staffSlug;
514
+ const config = payload.config;
515
+ const registered = config?.custom?.[SUPPORT_STAFF_SLUG_CONFIG_KEY];
516
+ if (typeof registered === "string" && registered) return registered;
517
+ return config?.admin?.user || "users";
518
+ }
440
519
  var DEFAULT_SETTINGS = {
441
520
  email: { fromAddress: "", fromName: "Support", replyToAddress: "" },
442
521
  ai: { provider: "anthropic", model: "claude-haiku-4-5-20251001", enableSentiment: true, enableSynthesis: true, enableSuggestion: true, enableRewrite: true },
@@ -448,10 +527,13 @@ var DEFAULT_USER_PREFS = {
448
527
  locale: "fr",
449
528
  signature: ""
450
529
  };
451
- var settingsCache = null;
530
+ var settingsCache = /* @__PURE__ */ new Map();
452
531
  var SETTINGS_TTL_MS = 6e4;
532
+ var SETTINGS_CACHE_MAX = 8;
533
+ var warnedForeignSettingsRow = /* @__PURE__ */ new Set();
453
534
  function invalidateSupportSettingsCache() {
454
- settingsCache = null;
535
+ settingsCache.clear();
536
+ warnedForeignSettingsRow.clear();
455
537
  }
456
538
  function mergeSupportSettings(stored, base = DEFAULT_SETTINGS) {
457
539
  const autoClose = { ...base.autoClose, ...stored?.autoClose };
@@ -468,9 +550,11 @@ function mergeSupportSettings(stored, base = DEFAULT_SETTINGS) {
468
550
  )
469
551
  };
470
552
  }
471
- async function readSupportSettingsState(payload) {
472
- if (settingsCache && Date.now() - settingsCache.ts < SETTINGS_TTL_MS) {
473
- return settingsCache.value;
553
+ async function readSupportSettingsState(payload, staffSlug) {
554
+ const staff = resolveStaffPrefSlug(payload, staffSlug);
555
+ const cached = settingsCache.get(staff);
556
+ if (cached && Date.now() - cached.ts < SETTINGS_TTL_MS) {
557
+ return cached.value;
474
558
  }
475
559
  let value = {
476
560
  settings: mergeSupportSettings(null),
@@ -478,7 +562,10 @@ async function readSupportSettingsState(payload) {
478
562
  };
479
563
  try {
480
564
  const prefs = await dbFind(payload, "payload-preferences", {
481
- where: { key: { equals: PREF_KEY } },
565
+ // Sibling keys are AND-ed by Payload. The `user.relationTo` clause is the
566
+ // security boundary: without it any authenticated principal can plant a
567
+ // `support-settings` row and own the plugin's server settings.
568
+ where: { key: { equals: PREF_KEY }, "user.relationTo": { equals: staff } },
482
569
  // The upsert is scoped per admin user, so several rows can share the key.
483
570
  // Sorting makes "last write wins" deterministic instead of arbitrary.
484
571
  sort: "-updatedAt",
@@ -491,22 +578,43 @@ async function readSupportSettingsState(payload) {
491
578
  const featuresConfigured = !!stored.features && typeof stored.features === "object";
492
579
  const settings = mergeSupportSettings(stored);
493
580
  if (!featuresConfigured) {
494
- settings.features.roundRobin = await readLegacyRoundRobin(payload);
581
+ settings.features.roundRobin = await readLegacyRoundRobin(payload, staff);
495
582
  }
496
583
  value = { settings, featuresConfigured };
584
+ } else {
585
+ await warnOnForeignSettingsRow(payload, staff);
497
586
  }
498
587
  } catch {
499
588
  }
500
- settingsCache = { value, ts: Date.now() };
589
+ if (settingsCache.size >= SETTINGS_CACHE_MAX && !settingsCache.has(staff)) settingsCache.clear();
590
+ settingsCache.set(staff, { value, ts: Date.now() });
501
591
  return value;
502
592
  }
503
- async function readSupportSettings(payload) {
504
- return (await readSupportSettingsState(payload)).settings;
593
+ async function warnOnForeignSettingsRow(payload, staff) {
594
+ if (warnedForeignSettingsRow.has(staff)) return;
595
+ try {
596
+ const any = await dbFind(payload, "payload-preferences", {
597
+ where: { key: { equals: PREF_KEY } },
598
+ limit: 1,
599
+ depth: 0,
600
+ overrideAccess: true
601
+ });
602
+ if (any.docs.length === 0) return;
603
+ if (warnedForeignSettingsRow.size >= SETTINGS_CACHE_MAX) warnedForeignSettingsRow.clear();
604
+ warnedForeignSettingsRow.add(staff);
605
+ console.warn(
606
+ `[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.`
607
+ );
608
+ } catch {
609
+ }
505
610
  }
506
- async function readLegacyRoundRobin(payload) {
611
+ async function readSupportSettings(payload, staffSlug) {
612
+ return (await readSupportSettingsState(payload, staffSlug)).settings;
613
+ }
614
+ async function readLegacyRoundRobin(payload, staff) {
507
615
  try {
508
616
  const prefs = await dbFind(payload, "payload-preferences", {
509
- where: { key: { equals: LEGACY_ROUND_ROBIN_KEY } },
617
+ where: { key: { equals: LEGACY_ROUND_ROBIN_KEY }, "user.relationTo": { equals: staff } },
510
618
  limit: 1,
511
619
  depth: 0,
512
620
  overrideAccess: true
@@ -518,11 +626,14 @@ async function readLegacyRoundRobin(payload) {
518
626
  }
519
627
  return DEFAULT_TICKETING_FEATURES.roundRobin;
520
628
  }
521
- async function readUserPrefs(payload, userId) {
629
+ async function readUserPrefs(payload, userId, staffSlug) {
522
630
  try {
523
631
  const key = `${USER_PREFS_KEY_PREFIX}-${userId}`;
524
632
  const prefs = await dbFind(payload, "payload-preferences", {
525
- where: { key: { equals: key } },
633
+ where: {
634
+ key: { equals: key },
635
+ "user.relationTo": { equals: resolveStaffPrefSlug(payload, staffSlug) }
636
+ },
526
637
  limit: 1,
527
638
  depth: 0,
528
639
  overrideAccess: true
@@ -560,7 +671,7 @@ function getModel(aiSettings) {
560
671
  return aiSettings.model || "claude-haiku-4-5-20251001";
561
672
  }
562
673
  function createAiEndpoint(slugs, store) {
563
- const limiter = new RateLimiter(6e4, 30, store);
674
+ const limiter = new RateLimiter(6e4, 30, store, "ai");
564
675
  return {
565
676
  path: "/support/ai",
566
677
  method: "post",
@@ -568,7 +679,7 @@ function createAiEndpoint(slugs, store) {
568
679
  try {
569
680
  const payload = req.payload;
570
681
  requireAdmin(req, slugs);
571
- if (await limiter.check(String(req.user.id), req)) {
682
+ if (await limiter.check(principalRateKey(req.user), req)) {
572
683
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
573
684
  }
574
685
  const settings = await readSupportSettings(payload);
@@ -813,14 +924,14 @@ ${kbText || "(vide)"}`;
813
924
 
814
925
  // src/endpoints/ai-agent.ts
815
926
  function createAiAgentEndpoint(slugs, store) {
816
- const limiter = new RateLimiter(6e4, 10, store);
927
+ const limiter = new RateLimiter(6e4, 10, store, "ai-agent");
817
928
  return {
818
929
  path: "/support/ai-agent",
819
930
  method: "post",
820
931
  handler: async (req) => {
821
932
  try {
822
933
  requireAdmin(req, slugs);
823
- if (await limiter.check(String(req.user.id), req)) {
934
+ if (await limiter.check(principalRateKey(req.user), req)) {
824
935
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
825
936
  }
826
937
  let body = {};
@@ -860,11 +971,11 @@ function getModel2(aiSettings) {
860
971
  }
861
972
  var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
862
973
  function createClientIntelligenceEndpoint(slugs, store) {
863
- const limiter = new RateLimiter(6e4, 20, store);
974
+ const limiter = new RateLimiter(6e4, 20, store, "client-intelligence");
864
975
  const getHandler = async (req) => {
865
976
  try {
866
977
  requireAdmin(req, slugs);
867
- if (await limiter.check(String(req.user.id), req)) {
978
+ if (await limiter.check(principalRateKey(req.user), req)) {
868
979
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
869
980
  }
870
981
  const payload = req.payload;
@@ -896,7 +1007,7 @@ function createClientIntelligenceEndpoint(slugs, store) {
896
1007
  const postHandler = async (req) => {
897
1008
  try {
898
1009
  requireAdmin(req, slugs);
899
- if (await limiter.check(String(req.user.id), req)) {
1010
+ if (await limiter.check(principalRateKey(req.user), req)) {
900
1011
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
901
1012
  }
902
1013
  const payload = req.payload;
@@ -1525,6 +1636,14 @@ function createSplitTicketEndpoint(slugs) {
1525
1636
  // src/endpoints/typing.ts
1526
1637
  var typingState = /* @__PURE__ */ new Map();
1527
1638
  var TYPING_TTL = 5e3;
1639
+ var MAX_TYPING_KEYS = 500;
1640
+ var TICKET_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
1641
+ function normalizeTicketId(raw) {
1642
+ if (typeof raw === "number") return Number.isInteger(raw) && raw > 0 ? String(raw) : null;
1643
+ if (typeof raw !== "string") return null;
1644
+ const value = raw.trim();
1645
+ return TICKET_ID_PATTERN.test(value) ? value : null;
1646
+ }
1528
1647
  function cleanExpired(ticketId) {
1529
1648
  const state = typingState.get(ticketId);
1530
1649
  if (!state) return;
@@ -1539,6 +1658,36 @@ function cleanExpired(ticketId) {
1539
1658
  }
1540
1659
  if (!state.admin && !state.client) typingState.delete(ticketId);
1541
1660
  }
1661
+ function sweepExpired() {
1662
+ for (const key of Array.from(typingState.keys())) cleanExpired(key);
1663
+ }
1664
+ function evictOldest() {
1665
+ let oldestKey = null;
1666
+ let oldestTs = Infinity;
1667
+ for (const [key, state] of typingState) {
1668
+ const ts = Math.max(state.admin || 0, state.client || 0);
1669
+ if (ts < oldestTs) {
1670
+ oldestTs = ts;
1671
+ oldestKey = key;
1672
+ }
1673
+ }
1674
+ if (oldestKey !== null) typingState.delete(oldestKey);
1675
+ }
1676
+ async function mayAccessTicket(req, slugs, ticketId) {
1677
+ const collection = req.user?.collection;
1678
+ if (collection !== slugs.users && collection !== slugs.supportClients) return false;
1679
+ try {
1680
+ const doc = await dbFindByID(req.payload, slugs.tickets, {
1681
+ id: ticketId,
1682
+ depth: 0,
1683
+ overrideAccess: false,
1684
+ user: req.user
1685
+ });
1686
+ return !!doc;
1687
+ } catch {
1688
+ return false;
1689
+ }
1690
+ }
1542
1691
  function createTypingPostEndpoint(slugs) {
1543
1692
  return {
1544
1693
  path: "/support/typing",
@@ -1549,11 +1698,18 @@ function createTypingPostEndpoint(slugs) {
1549
1698
  return Response.json({ error: "Unauthorized" }, { status: 401 });
1550
1699
  }
1551
1700
  const { ticketId } = await req.json();
1552
- if (!ticketId) {
1701
+ const key = normalizeTicketId(ticketId);
1702
+ if (!key) {
1553
1703
  return Response.json({ error: "ticketId required" }, { status: 400 });
1554
1704
  }
1555
- const key = String(ticketId);
1705
+ if (!await mayAccessTicket(req, slugs, key)) {
1706
+ return Response.json({ error: "Forbidden" }, { status: 403 });
1707
+ }
1556
1708
  const state = typingState.get(key) || {};
1709
+ if (!typingState.has(key) && typingState.size >= MAX_TYPING_KEYS) {
1710
+ sweepExpired();
1711
+ if (typingState.size >= MAX_TYPING_KEYS) evictOldest();
1712
+ }
1557
1713
  if (req.user.collection === slugs.users) {
1558
1714
  state.admin = Date.now();
1559
1715
  state.adminName = req.user.firstName || "Support";
@@ -1574,30 +1730,33 @@ function createTypingGetEndpoint(slugs) {
1574
1730
  path: "/support/typing",
1575
1731
  method: "get",
1576
1732
  handler: async (req) => {
1733
+ const idle = { typing: false, name: null };
1577
1734
  try {
1578
1735
  if (!req.user) {
1579
1736
  return Response.json({ error: "Unauthorized" }, { status: 401 });
1580
1737
  }
1581
1738
  const url = new URL(req.url);
1582
- const ticketId = url.searchParams.get("ticketId");
1583
- if (!ticketId) {
1739
+ const key = normalizeTicketId(url.searchParams.get("ticketId"));
1740
+ if (!key) {
1584
1741
  return Response.json({ error: "ticketId required" }, { status: 400 });
1585
1742
  }
1586
- cleanExpired(ticketId);
1587
- const state = typingState.get(ticketId);
1743
+ cleanExpired(key);
1744
+ const state = typingState.get(key);
1745
+ if (!state) return Response.json(idle);
1746
+ if (!await mayAccessTicket(req, slugs, key)) return Response.json(idle);
1588
1747
  if (req.user.collection === slugs.users) {
1589
1748
  return Response.json({
1590
- typing: !!state?.client,
1591
- name: state?.clientName || null
1749
+ typing: !!state.client,
1750
+ name: state.clientName || null
1592
1751
  });
1593
1752
  } else {
1594
1753
  return Response.json({
1595
- typing: !!state?.admin,
1596
- name: state?.adminName || null
1754
+ typing: !!state.admin,
1755
+ name: state.adminName || null
1597
1756
  });
1598
1757
  }
1599
1758
  } catch {
1600
- return Response.json({ typing: false, name: null });
1759
+ return Response.json(idle);
1601
1760
  }
1602
1761
  }
1603
1762
  };
@@ -1760,7 +1919,14 @@ function createSignatureGetEndpoint(slugs) {
1760
1919
  const payload = req.payload;
1761
1920
  requireAdmin(req, slugs);
1762
1921
  const prefs = await dbFind(payload, "payload-preferences", {
1763
- where: { key: { equals: `${PREF_KEY2}-${req.user.id}` } },
1922
+ // Scope to the staff auth collection: `payload-preferences` accepts a
1923
+ // write from ANY authenticated principal, and ids collide between auth
1924
+ // collections — a support-client with the same id would otherwise own
1925
+ // the `email-signature-<id>` row read back for the agent.
1926
+ where: {
1927
+ key: { equals: `${PREF_KEY2}-${req.user.id}` },
1928
+ "user.relationTo": { equals: slugs.users }
1929
+ },
1764
1930
  limit: 1,
1765
1931
  depth: 0,
1766
1932
  overrideAccess: true
@@ -1787,7 +1953,7 @@ function createSignaturePostEndpoint(slugs) {
1787
1953
  const { signature } = await req.json();
1788
1954
  const key = `${PREF_KEY2}-${req.user.id}`;
1789
1955
  const existing = await dbFind(payload, "payload-preferences", {
1790
- where: { key: { equals: key } },
1956
+ where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
1791
1957
  limit: 1,
1792
1958
  depth: 0,
1793
1959
  overrideAccess: true
@@ -2548,7 +2714,7 @@ function formatFr(date, withTime) {
2548
2714
  });
2549
2715
  }
2550
2716
  function createSendReminderEndpoint(slugs, store) {
2551
- const reminderLimiter = new RateLimiter(60 * 60 * 1e3, 30, store);
2717
+ const reminderLimiter = new RateLimiter(60 * 60 * 1e3, 30, store, "send-reminder");
2552
2718
  return {
2553
2719
  path: "/support/send-reminder",
2554
2720
  method: "post",
@@ -2556,7 +2722,7 @@ function createSendReminderEndpoint(slugs, store) {
2556
2722
  try {
2557
2723
  const payload = req.payload;
2558
2724
  requireAdmin(req, slugs);
2559
- if (await reminderLimiter.check(String(req.user.id), req)) {
2725
+ if (await reminderLimiter.check(principalRateKey(req.user), req)) {
2560
2726
  return Response.json(
2561
2727
  { error: "Trop de relances. R\xE9essayez dans une heure." },
2562
2728
  { status: 429 }
@@ -2682,6 +2848,10 @@ function createStatusesEndpoint(slugs) {
2682
2848
  if (!req.user) {
2683
2849
  return Response.json({ error: "Unauthorized" }, { status: 401 });
2684
2850
  }
2851
+ const collection = req.user.collection;
2852
+ if (collection !== slugs.users && collection !== slugs.supportClients) {
2853
+ return Response.json({ error: "Forbidden" }, { status: 403 });
2854
+ }
2685
2855
  const { docs } = await dbFind(payload, slugs.ticketStatuses, {
2686
2856
  sort: "sortOrder",
2687
2857
  limit: 100,
@@ -2859,14 +3029,20 @@ function createPurgeLogsEndpoint(slugs) {
2859
3029
  }
2860
3030
 
2861
3031
  // src/endpoints/chatbot.ts
2862
- function createChatbotEndpoint(slugs, store) {
2863
- const chatbotLimiter = new RateLimiter(6e4, 10, store);
3032
+ var DEFAULT_CHATBOT_MAX_PER_HOUR = 200;
3033
+ function resolveMaxPerHour(explicit) {
3034
+ const fromEnv = Number(process.env.SUPPORT_CHATBOT_MAX_PER_HOUR);
3035
+ return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : DEFAULT_CHATBOT_MAX_PER_HOUR;
3036
+ }
3037
+ function createChatbotEndpoint(slugs, store, maxPerHour) {
3038
+ const chatbotLimiter = new RateLimiter(6e4, 10, store, "chatbot:ip");
3039
+ const globalLimiter = new RateLimiter(60 * 6e4, resolveMaxPerHour(), store, "chatbot:global");
2864
3040
  return {
2865
3041
  path: "/support/chatbot",
2866
3042
  method: "post",
2867
3043
  handler: async (req) => {
2868
3044
  try {
2869
- const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip") || "unknown";
3045
+ const ip = clientIpRateKey(req);
2870
3046
  if (await chatbotLimiter.check(ip, req)) {
2871
3047
  return Response.json({ error: "Too many requests. Please wait a moment." }, { status: 429 });
2872
3048
  }
@@ -2880,6 +3056,15 @@ function createChatbotEndpoint(slugs, store) {
2880
3056
  if (!question?.trim() || question.trim().length < 5) {
2881
3057
  return Response.json({ error: "Question too short" }, { status: 400 });
2882
3058
  }
3059
+ if (await globalLimiter.check("all", req)) {
3060
+ return Response.json({
3061
+ answer: null,
3062
+ confidence: 0,
3063
+ suggestion: "create_ticket",
3064
+ aiUnavailable: true,
3065
+ message: "L'assistant est momentan\xE9ment indisponible. Cr\xE9ez un ticket, un agent vous r\xE9pondra."
3066
+ });
3067
+ }
2883
3068
  const payload = req.payload;
2884
3069
  const articles = await dbFind(payload, slugs.knowledgeBase, {
2885
3070
  where: { published: { equals: true } },
@@ -2992,8 +3177,8 @@ function createChatGetEndpoint(slugs) {
2992
3177
  };
2993
3178
  }
2994
3179
  function createChatPostEndpoint(slugs, store) {
2995
- const chatSessionLimiter = new RateLimiter(36e5, 5, store);
2996
- const chatMessageLimiter = new RateLimiter(6e4, 15, store);
3180
+ const chatSessionLimiter = new RateLimiter(36e5, 5, store, "chat:session");
3181
+ const chatMessageLimiter = new RateLimiter(6e4, 15, store, "chat:message");
2997
3182
  return {
2998
3183
  path: "/support/chat",
2999
3184
  method: "post",
@@ -3009,8 +3194,9 @@ function createChatPostEndpoint(slugs, store) {
3009
3194
  }
3010
3195
  const { action, session, message } = body;
3011
3196
  const userId = String(req.user.id);
3197
+ const rateKey = principalRateKey(req.user);
3012
3198
  if (action === "start") {
3013
- if (await chatSessionLimiter.check(userId, req)) {
3199
+ if (await chatSessionLimiter.check(rateKey, req)) {
3014
3200
  return Response.json({ error: "Trop de sessions cr\xE9\xE9es. R\xE9essayez plus tard." }, { status: 429 });
3015
3201
  }
3016
3202
  const sessionId = `chat_${crypto3.randomBytes(16).toString("hex")}`;
@@ -3027,7 +3213,7 @@ function createChatPostEndpoint(slugs, store) {
3027
3213
  return Response.json({ session: sessionId, messages: [systemMsg] });
3028
3214
  }
3029
3215
  if (action === "send" && session && message) {
3030
- if (await chatMessageLimiter.check(userId, req)) {
3216
+ if (await chatMessageLimiter.check(rateKey, req)) {
3031
3217
  return Response.json({ error: "Trop de messages. Attendez un moment." }, { status: 429 });
3032
3218
  }
3033
3219
  const trimmedMessage = String(message).trim();
@@ -3267,7 +3453,7 @@ function createAdminChatGetEndpoint(slugs) {
3267
3453
  };
3268
3454
  }
3269
3455
  function createAdminChatPostEndpoint(slugs, store) {
3270
- const adminChatLimiter = new RateLimiter(6e4, 30, store);
3456
+ const adminChatLimiter = new RateLimiter(6e4, 30, store, "admin-chat");
3271
3457
  return {
3272
3458
  path: "/support/admin-chat",
3273
3459
  method: "post",
@@ -3296,7 +3482,7 @@ function createAdminChatPostEndpoint(slugs, store) {
3296
3482
  }
3297
3483
  const clientId = typeof sessionMsg.docs[0].client === "object" ? sessionMsg.docs[0].client.id : sessionMsg.docs[0].client;
3298
3484
  if (action === "send" && message) {
3299
- if (await adminChatLimiter.check(String(req.user.id), req)) {
3485
+ if (await adminChatLimiter.check(principalRateKey(req.user), req)) {
3300
3486
  return Response.json({ error: "Rate limit atteint." }, { status: 429 });
3301
3487
  }
3302
3488
  const trimmedMessage = String(message).trim();
@@ -4251,14 +4437,14 @@ Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`;
4251
4437
 
4252
4438
  // src/endpoints/ticket-synthesis.ts
4253
4439
  function createTicketSynthesisEndpoint(slugs, generator, store) {
4254
- const limiter = new RateLimiter(6e4, 20, store);
4440
+ const limiter = new RateLimiter(6e4, 20, store, "ticket-synthesis");
4255
4441
  return {
4256
4442
  path: "/support/ticket-synthesis",
4257
4443
  method: "post",
4258
4444
  handler: async (req) => {
4259
4445
  try {
4260
4446
  requireAdmin(req, slugs);
4261
- if (await limiter.check(String(req.user.id), req)) {
4447
+ if (await limiter.check(principalRateKey(req.user), req)) {
4262
4448
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
4263
4449
  }
4264
4450
  const payload = req.payload;
@@ -4302,15 +4488,17 @@ function createTicketSynthesisEndpoint(slugs, generator, store) {
4302
4488
  }
4303
4489
 
4304
4490
  // src/endpoints/email-stats.ts
4305
- function createEmailStatsEndpoint(slugs) {
4491
+ function createEmailStatsEndpoint(slugs, store) {
4492
+ const statsLimiter = new RateLimiter(6e4, 20, store, "email-stats");
4306
4493
  return {
4307
4494
  path: "/support/email-stats",
4308
4495
  method: "get",
4309
4496
  handler: async (req) => {
4310
4497
  try {
4311
4498
  const payload = req.payload;
4312
- if (!req.user) {
4313
- return Response.json({ error: "Unauthorized" }, { status: 401 });
4499
+ requireAdmin(req, slugs);
4500
+ if (await statsLimiter.check(principalRateKey(req.user), req)) {
4501
+ return Response.json({ error: "Too many requests." }, { status: 429 });
4314
4502
  }
4315
4503
  const url = new URL(req.url);
4316
4504
  const days = Math.min(Number(url.searchParams.get("days")) || 7, 365);
@@ -4377,6 +4565,8 @@ function createEmailStatsEndpoint(slugs) {
4377
4565
  actions: Object.fromEntries(actionMap)
4378
4566
  });
4379
4567
  } catch (err) {
4568
+ const authResponse = handleAuthError(err);
4569
+ if (authResponse) return authResponse;
4380
4570
  console.error("[email-stats] Error:", err);
4381
4571
  return Response.json({ error: "Internal server error" }, { status: 500 });
4382
4572
  }
@@ -4809,7 +4999,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4809
4999
 
4810
5000
  // src/endpoints/resend-notification.ts
4811
5001
  function createResendNotificationEndpoint(slugs, store) {
4812
- const resendLimiter = new RateLimiter(60 * 60 * 1e3, 10, store);
5002
+ const resendLimiter = new RateLimiter(60 * 60 * 1e3, 10, store, "resend-notification");
4813
5003
  return {
4814
5004
  path: "/support/resend-notification",
4815
5005
  method: "post",
@@ -4817,7 +5007,7 @@ function createResendNotificationEndpoint(slugs, store) {
4817
5007
  try {
4818
5008
  const payload = req.payload;
4819
5009
  requireAdmin(req, slugs);
4820
- if (await resendLimiter.check(String(req.user.id), req)) {
5010
+ if (await resendLimiter.check(principalRateKey(req.user), req)) {
4821
5011
  return Response.json(
4822
5012
  { error: "Trop de renvois. R\xE9essayez dans une heure." },
4823
5013
  { status: 429 }
@@ -5018,15 +5208,54 @@ function createSeedKbEndpoint(slugs) {
5018
5208
  }
5019
5209
  };
5020
5210
  }
5211
+ var TWO_FACTOR_CHALLENGE_TTL_MS = 10 * 60 * 1e3;
5212
+ function challengeSecret() {
5213
+ const secret = process.env.PAYLOAD_SECRET;
5214
+ if (!secret) {
5215
+ throw new Error(
5216
+ "[support][2fa] PAYLOAD_SECRET is not set \u2014 refusing to issue a 2FA challenge with an insecure fallback secret"
5217
+ );
5218
+ }
5219
+ return secret;
5220
+ }
5221
+ function normalizeEmail(email) {
5222
+ return String(email).trim().toLowerCase();
5223
+ }
5224
+ function sign(email, expiresAt) {
5225
+ return createHmac("sha256", challengeSecret()).update(`2fa-challenge:${normalizeEmail(email)}:${expiresAt}`).digest("hex");
5226
+ }
5227
+ function issueTwoFactorChallenge(email, now = Date.now()) {
5228
+ const expiresAt = now + TWO_FACTOR_CHALLENGE_TTL_MS;
5229
+ return `${expiresAt}.${sign(email, expiresAt)}`;
5230
+ }
5231
+ function verifyTwoFactorChallenge(email, token, now = Date.now()) {
5232
+ if (typeof email !== "string" || !email || typeof token !== "string") return false;
5233
+ const separator = token.indexOf(".");
5234
+ if (separator <= 0) return false;
5235
+ const expiresAt = Number(token.slice(0, separator));
5236
+ if (!Number.isSafeInteger(expiresAt) || expiresAt <= now) return false;
5237
+ const received = token.slice(separator + 1);
5238
+ if (!/^[0-9a-f]{64}$/i.test(received)) return false;
5239
+ let expected;
5240
+ try {
5241
+ expected = sign(email, expiresAt);
5242
+ } catch {
5243
+ return false;
5244
+ }
5245
+ const a = Buffer.from(expected, "hex");
5246
+ const b = Buffer.from(received.toLowerCase(), "hex");
5247
+ return a.length === b.length && timingSafeEqual(a, b);
5248
+ }
5021
5249
 
5022
5250
  // src/endpoints/login.ts
5251
+ var MAX_LOGGED_USER_AGENT = 256;
5023
5252
  function createLoginEndpoint(slugs, store) {
5024
- const loginLimiter = new RateLimiter(15 * 6e4, 10, store);
5253
+ const loginLimiter = new RateLimiter(15 * 6e4, 10, store, "login");
5025
5254
  return {
5026
5255
  path: "/support/login",
5027
5256
  method: "post",
5028
5257
  handler: async (req) => {
5029
- const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip") || "unknown";
5258
+ const ip = clientIpRateKey(req);
5030
5259
  if (await loginLimiter.check(ip, req)) {
5031
5260
  return Response.json(
5032
5261
  { error: "Trop de tentatives. R\xE9essayez dans quelques minutes." },
@@ -5041,7 +5270,7 @@ function createLoginEndpoint(slugs, store) {
5041
5270
  return Response.json({ error: "Invalid JSON body" }, { status: 400 });
5042
5271
  }
5043
5272
  const { email, password } = body;
5044
- const userAgent = req.headers.get("user-agent") || "";
5273
+ const userAgent = (req.headers.get("user-agent") || "").slice(0, MAX_LOGGED_USER_AGENT);
5045
5274
  if (!email || !password) {
5046
5275
  return Response.json({ error: "Email et mot de passe requis." }, { status: 400 });
5047
5276
  }
@@ -5074,7 +5303,12 @@ function createLoginEndpoint(slugs, store) {
5074
5303
  } catch (err) {
5075
5304
  const errorMessage = err instanceof Error ? err.message : "Erreur inconnue";
5076
5305
  if (errorMessage.includes("2FA_REQUIRED")) {
5077
- return Response.json({ requires2FA: true }, { status: 200 });
5306
+ let challenge;
5307
+ try {
5308
+ challenge = issueTwoFactorChallenge(email);
5309
+ } catch {
5310
+ }
5311
+ return Response.json({ requires2FA: true, ...challenge ? { challenge } : {} }, { status: 200 });
5078
5312
  }
5079
5313
  let errorReason = "Identifiants incorrects";
5080
5314
  if (errorMessage.includes("locked") || errorMessage.includes("verrouill\xE9") || errorMessage.includes("Too many")) {
@@ -5106,8 +5340,8 @@ function hashCode(code) {
5106
5340
  return createHmac("sha256", secret).update(code).digest("hex");
5107
5341
  }
5108
5342
  function createAuth2faEndpoint(slugs, store) {
5109
- const sendLimiter = new RateLimiter(60 * 60 * 1e3, 3, store);
5110
- const verifyLimiter = new RateLimiter(15 * 60 * 1e3, 5, store);
5343
+ const sendLimiter = new RateLimiter(60 * 60 * 1e3, 3, store, "2fa:send");
5344
+ const verifyLimiter = new RateLimiter(15 * 60 * 1e3, 5, store, "2fa:verify");
5111
5345
  return {
5112
5346
  path: "/support/2fa",
5113
5347
  method: "post",
@@ -5120,14 +5354,32 @@ function createAuth2faEndpoint(slugs, store) {
5120
5354
  } catch {
5121
5355
  return Response.json({ error: "Invalid JSON body" }, { status: 400 });
5122
5356
  }
5123
- const { action, email, code } = body;
5357
+ const { action, email, code, challenge } = body;
5124
5358
  if (!action || !email) {
5125
5359
  return Response.json({ error: "Param\xE8tres manquants" }, { status: 400 });
5126
5360
  }
5127
- const genericSendResponse = { success: true, message: "Si un compte existe, un code a \xE9t\xE9 envoy\xE9." };
5361
+ const limiterKey = normalizeEmail(email);
5362
+ const sendResponse = () => {
5363
+ let refreshed;
5364
+ try {
5365
+ refreshed = issueTwoFactorChallenge(email);
5366
+ } catch {
5367
+ }
5368
+ return Response.json({
5369
+ success: true,
5370
+ message: "Si un compte existe, un code a \xE9t\xE9 envoy\xE9.",
5371
+ ...refreshed ? { challenge: refreshed } : {}
5372
+ });
5373
+ };
5128
5374
  if (action === "send") {
5129
- if (await sendLimiter.check(email, req)) {
5130
- return Response.json(genericSendResponse);
5375
+ if (!verifyTwoFactorChallenge(email, challenge)) {
5376
+ return Response.json(
5377
+ { error: "Authentification requise avant l'envoi d'un code." },
5378
+ { status: 401 }
5379
+ );
5380
+ }
5381
+ if (await sendLimiter.check(limiterKey, req)) {
5382
+ return sendResponse();
5131
5383
  }
5132
5384
  const clients = await dbFind(payload, slugs.supportClients, {
5133
5385
  where: { email: { equals: email } },
@@ -5136,7 +5388,7 @@ function createAuth2faEndpoint(slugs, store) {
5136
5388
  overrideAccess: true
5137
5389
  });
5138
5390
  if (clients.docs.length === 0) {
5139
- return Response.json(genericSendResponse);
5391
+ return sendResponse();
5140
5392
  }
5141
5393
  const client = clients.docs[0];
5142
5394
  const plainCode = generateSecureCode();
@@ -5161,13 +5413,19 @@ function createAuth2faEndpoint(slugs, store) {
5161
5413
  <p style="font-size: 13px; color: #6b7280;">Ce code est valable 10 minutes.</p>
5162
5414
  </div>`
5163
5415
  });
5164
- return Response.json(genericSendResponse);
5416
+ return sendResponse();
5165
5417
  }
5166
5418
  if (action === "verify") {
5419
+ if (!verifyTwoFactorChallenge(email, challenge)) {
5420
+ return Response.json(
5421
+ { error: "Authentification requise avant la v\xE9rification d'un code." },
5422
+ { status: 401 }
5423
+ );
5424
+ }
5167
5425
  if (!code) {
5168
5426
  return Response.json({ error: "Code manquant" }, { status: 400 });
5169
5427
  }
5170
- if (await verifyLimiter.check(email, req)) {
5428
+ if (await verifyLimiter.check(limiterKey, req)) {
5171
5429
  return Response.json(
5172
5430
  { error: "Trop de tentatives. R\xE9essayez dans 15 minutes." },
5173
5431
  { status: 429 }
@@ -5208,7 +5466,7 @@ function createAuth2faEndpoint(slugs, store) {
5208
5466
  data: { twoFactorCode: "", twoFactorExpiry: "", twoFactorVerifiedAt: (/* @__PURE__ */ new Date()).toISOString() },
5209
5467
  overrideAccess: true
5210
5468
  });
5211
- verifyLimiter.reset(email);
5469
+ await verifyLimiter.reset(limiterKey, req);
5212
5470
  return Response.json({ success: true, verified: true });
5213
5471
  }
5214
5472
  return Response.json({ error: "Action invalide" }, { status: 400 });
@@ -5219,6 +5477,33 @@ function createAuth2faEndpoint(slugs, store) {
5219
5477
  }
5220
5478
  };
5221
5479
  }
5480
+ var OAUTH_STATE_COOKIE = "support-oauth-state";
5481
+ var OAUTH_STATE_MAX_AGE = 600;
5482
+ var TWO_FA_WINDOW_MS = 5 * 60 * 1e3;
5483
+ function readCookie(header, name) {
5484
+ if (!header) return null;
5485
+ for (const part of header.split(";")) {
5486
+ const eq = part.indexOf("=");
5487
+ if (eq === -1) continue;
5488
+ if (part.slice(0, eq).trim() !== name) continue;
5489
+ try {
5490
+ return decodeURIComponent(part.slice(eq + 1).trim());
5491
+ } catch {
5492
+ return part.slice(eq + 1).trim();
5493
+ }
5494
+ }
5495
+ return null;
5496
+ }
5497
+ function clearedStateCookie() {
5498
+ const secure = process.env.NODE_ENV === "production";
5499
+ return `${OAUTH_STATE_COOKIE}=; HttpOnly; ${secure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=0`;
5500
+ }
5501
+ function statesMatch(a, b) {
5502
+ if (!a || !b) return false;
5503
+ const left = crypto3.createHash("sha256").update(a).digest();
5504
+ const right = crypto3.createHash("sha256").update(b).digest();
5505
+ return crypto3.timingSafeEqual(left, right);
5506
+ }
5222
5507
  function createOAuthGoogleEndpoint(slugs, options) {
5223
5508
  return {
5224
5509
  path: "/support/oauth/google",
@@ -5235,7 +5520,7 @@ function createOAuthGoogleEndpoint(slugs, options) {
5235
5520
  }
5236
5521
  try {
5237
5522
  const body = await req.json();
5238
- const { action, code, state: queryState, cookieState } = body;
5523
+ const { action, code, state: queryState } = body;
5239
5524
  if (action === "login") {
5240
5525
  const oauthState = crypto3.randomBytes(32).toString("hex");
5241
5526
  const redirectUri = `${baseUrl}/api/support/oauth/google`;
@@ -5247,13 +5532,25 @@ function createOAuthGoogleEndpoint(slugs, options) {
5247
5532
  state: oauthState,
5248
5533
  prompt: "select_account"
5249
5534
  });
5250
- return Response.json({
5251
- url: `https://accounts.google.com/o/oauth2/v2/auth?${params}`,
5252
- state: oauthState
5253
- });
5535
+ const secure = process.env.NODE_ENV === "production";
5536
+ const loginHeaders = new Headers({ "Content-Type": "application/json" });
5537
+ loginHeaders.append(
5538
+ "Set-Cookie",
5539
+ `${OAUTH_STATE_COOKIE}=${oauthState}; HttpOnly; ${secure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=${OAUTH_STATE_MAX_AGE}`
5540
+ );
5541
+ return new Response(
5542
+ JSON.stringify({
5543
+ url: `https://accounts.google.com/o/oauth2/v2/auth?${params}`,
5544
+ // Kept for callers that echo it back in the redirect URL; the
5545
+ // server no longer trusts anything the caller returns.
5546
+ state: oauthState
5547
+ }),
5548
+ { status: 200, headers: loginHeaders }
5549
+ );
5254
5550
  }
5255
5551
  if (code) {
5256
- if (!cookieState || !queryState || cookieState !== queryState) {
5552
+ const issuedState = readCookie(req.headers.get("cookie"), OAUTH_STATE_COOKIE);
5553
+ if (!statesMatch(issuedState, queryState)) {
5257
5554
  return Response.json({ error: "state_mismatch" }, { status: 400 });
5258
5555
  }
5259
5556
  const redirectUri = `${baseUrl}/api/support/oauth/google`;
@@ -5335,6 +5632,35 @@ function createOAuthGoogleEndpoint(slugs, options) {
5335
5632
  overrideAccess: true
5336
5633
  });
5337
5634
  }
5635
+ const twoFactorDoc = await dbFindByID(payload, slugs.supportClients, {
5636
+ id: clientDoc.id,
5637
+ depth: 0,
5638
+ overrideAccess: true,
5639
+ showHiddenFields: true
5640
+ });
5641
+ if (twoFactorDoc?.twoFactorEnabled) {
5642
+ const raw = twoFactorDoc.twoFactorVerifiedAt;
5643
+ const verifiedAt = raw ? new Date(raw).getTime() : 0;
5644
+ if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS)) {
5645
+ let challenge;
5646
+ try {
5647
+ challenge = issueTwoFactorChallenge(clientDoc.email);
5648
+ } catch {
5649
+ }
5650
+ return new Response(JSON.stringify({ requires2FA: true, ...challenge ? { challenge } : {} }), {
5651
+ status: 200,
5652
+ headers: new Headers({
5653
+ "Content-Type": "application/json",
5654
+ "Set-Cookie": clearedStateCookie()
5655
+ })
5656
+ });
5657
+ }
5658
+ await dbUpdate(payload, slugs.supportClients, {
5659
+ id: clientDoc.id,
5660
+ data: { twoFactorVerifiedAt: null },
5661
+ overrideAccess: true
5662
+ });
5663
+ }
5338
5664
  const secret = process.env.PAYLOAD_SECRET;
5339
5665
  if (!secret) {
5340
5666
  return Response.json({ error: "server_misconfigured" }, { status: 500 });
@@ -5378,6 +5704,7 @@ function createOAuthGoogleEndpoint(slugs, options) {
5378
5704
  "Set-Cookie",
5379
5705
  `payload-token=${token}; HttpOnly; ${cookieSecure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=${tokenExpiration}`
5380
5706
  );
5707
+ headers.append("Set-Cookie", clearedStateCookie());
5381
5708
  return new Response(JSON.stringify({ user: clientDoc, exp }), {
5382
5709
  status: 200,
5383
5710
  headers
@@ -5684,7 +6011,7 @@ ONLY JSON, nothing else.`
5684
6011
  }
5685
6012
  }
5686
6013
  function createImportConversationEndpoint(slugs, store) {
5687
- const importLimiter = new RateLimiter(36e5, 10, store);
6014
+ const importLimiter = new RateLimiter(36e5, 10, store, "import-conversation");
5688
6015
  return {
5689
6016
  path: "/support/import-conversation",
5690
6017
  method: "post",
@@ -5701,7 +6028,7 @@ function createImportConversationEndpoint(slugs, store) {
5701
6028
  if (!isAuthed) {
5702
6029
  return Response.json({ error: "Unauthorized" }, { status: 401 });
5703
6030
  }
5704
- const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
6031
+ const ip = clientIpRateKey(req);
5705
6032
  if (await importLimiter.check(ip, req)) {
5706
6033
  return Response.json({ error: "Rate limit exceeded. Maximum 10 imports per hour." }, { status: 429 });
5707
6034
  }
@@ -5827,6 +6154,152 @@ function createImportConversationEndpoint(slugs, store) {
5827
6154
  }
5828
6155
  };
5829
6156
  }
6157
+ function httpAllowed() {
6158
+ return process.env.SUPPORT_ALLOW_INSECURE_WEBHOOKS === "1";
6159
+ }
6160
+ var BLOCKED_HOST_SUFFIXES = [".local", ".localhost", ".internal", ".home.arpa"];
6161
+ function parseIPv4(host) {
6162
+ const parts = host.split(".");
6163
+ if (parts.length !== 4) return null;
6164
+ const octets = [];
6165
+ for (const part of parts) {
6166
+ if (!/^\d{1,3}$/.test(part)) return null;
6167
+ const n = Number(part);
6168
+ if (n > 255) return null;
6169
+ octets.push(n);
6170
+ }
6171
+ return octets;
6172
+ }
6173
+ function isPrivateIPv4(octets) {
6174
+ const [a, b] = octets;
6175
+ if (a === 0) return true;
6176
+ if (a === 10) return true;
6177
+ if (a === 127) return true;
6178
+ if (a === 169 && b === 254) return true;
6179
+ if (a === 172 && b >= 16 && b <= 31) return true;
6180
+ if (a === 192 && b === 168) return true;
6181
+ if (a === 192 && b === 0) return true;
6182
+ if (a === 198 && (b === 18 || b === 19)) return true;
6183
+ if (a === 100 && b >= 64 && b <= 127) return true;
6184
+ if (a >= 224) return true;
6185
+ return false;
6186
+ }
6187
+ function isPrivateIPv6(host) {
6188
+ const lower = host.toLowerCase();
6189
+ if (lower === "::" || lower === "::1") return true;
6190
+ if (lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb")) return true;
6191
+ if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
6192
+ if (lower.startsWith("ff")) return true;
6193
+ const dotted = lower.match(/^::(?:ffff:)?(\d{1,3}(?:\.\d{1,3}){3})$/);
6194
+ if (dotted) {
6195
+ const octets = parseIPv4(dotted[1]);
6196
+ return octets ? isPrivateIPv4(octets) : true;
6197
+ }
6198
+ const hex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
6199
+ if (hex) {
6200
+ const high = parseInt(hex[1], 16);
6201
+ const low = parseInt(hex[2], 16);
6202
+ return isPrivateIPv4([high >> 8, high & 255, low >> 8, low & 255]);
6203
+ }
6204
+ return false;
6205
+ }
6206
+ function normalizeHost(hostname) {
6207
+ const host = hostname.trim().toLowerCase();
6208
+ return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
6209
+ }
6210
+ function isBlockedHost(hostname) {
6211
+ const host = normalizeHost(hostname);
6212
+ if (!host) return true;
6213
+ if (host === "localhost") return true;
6214
+ if (BLOCKED_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix))) return true;
6215
+ const v4 = parseIPv4(host);
6216
+ if (v4) return isPrivateIPv4(v4);
6217
+ if (host.includes(":")) return isPrivateIPv6(host);
6218
+ return false;
6219
+ }
6220
+ function validateWebhookUrl(raw) {
6221
+ if (typeof raw !== "string" || !raw.trim()) return { ok: false, reason: "invalid_url" };
6222
+ let url;
6223
+ try {
6224
+ url = new URL(raw.trim());
6225
+ } catch {
6226
+ return { ok: false, reason: "invalid_url" };
6227
+ }
6228
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && httpAllowed())) {
6229
+ return { ok: false, reason: "scheme_not_allowed" };
6230
+ }
6231
+ if (isBlockedHost(url.hostname)) return { ok: false, reason: "private_host" };
6232
+ return { ok: true, url };
6233
+ }
6234
+ var WEBHOOK_URL_MESSAGES = {
6235
+ invalid_url: "URL invalide.",
6236
+ scheme_not_allowed: "Seules les URL https:// sont accept\xE9es.",
6237
+ private_host: "Les adresses priv\xE9es, loopback et link-local sont interdites (SSRF)."
6238
+ };
6239
+ var MAX_PUSH_ENDPOINT_LENGTH = 2048;
6240
+ function validatePushEndpoint(raw) {
6241
+ if (typeof raw !== "string" || !raw.trim() || raw.length > MAX_PUSH_ENDPOINT_LENGTH) {
6242
+ return { ok: false, reason: "invalid_url" };
6243
+ }
6244
+ let url;
6245
+ try {
6246
+ url = new URL(raw.trim());
6247
+ } catch {
6248
+ return { ok: false, reason: "invalid_url" };
6249
+ }
6250
+ if (url.protocol !== "https:") return { ok: false, reason: "scheme_not_allowed" };
6251
+ if (isBlockedHost(url.hostname)) return { ok: false, reason: "private_host" };
6252
+ return { ok: true, url };
6253
+ }
6254
+ var LOOKUP_TIMEOUT_MS = 5e3;
6255
+ var LOOKUP_TIMED_OUT = /* @__PURE__ */ Symbol("lookup-timed-out");
6256
+ var NONEXISTENT_HOST_CODES = /* @__PURE__ */ new Set(["ENOTFOUND", "ENODATA", "NOTFOUND"]);
6257
+ async function assertPublicHost(hostname) {
6258
+ const host = normalizeHost(hostname);
6259
+ if (isBlockedHost(host)) return false;
6260
+ if (parseIPv4(host) || host.includes(":")) return true;
6261
+ try {
6262
+ const addresses = await Promise.race([
6263
+ lookup(host, { all: true }),
6264
+ new Promise(
6265
+ (resolve) => setTimeout(() => resolve(LOOKUP_TIMED_OUT), LOOKUP_TIMEOUT_MS).unref?.()
6266
+ )
6267
+ ]);
6268
+ if (addresses === LOOKUP_TIMED_OUT) return false;
6269
+ if (!Array.isArray(addresses) || addresses.length === 0) return false;
6270
+ return addresses.every((entry) => !isBlockedHost(entry.address));
6271
+ } catch (error) {
6272
+ const code = error?.code;
6273
+ return typeof code === "string" && NONEXISTENT_HOST_CODES.has(code);
6274
+ }
6275
+ }
6276
+ var BlockedRequestError = class extends Error {
6277
+ constructor(reason) {
6278
+ super(`Blocked outbound request: ${reason}`);
6279
+ this.reason = reason;
6280
+ this.name = "BlockedRequestError";
6281
+ }
6282
+ reason;
6283
+ };
6284
+ var MAX_REDIRECTS = 3;
6285
+ async function safeFetch(rawUrl, init) {
6286
+ let current = rawUrl;
6287
+ let body = init.body;
6288
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
6289
+ const validation = validateWebhookUrl(current);
6290
+ if (!validation.ok || !validation.url) throw new BlockedRequestError(validation.reason || "invalid_url");
6291
+ if (!await assertPublicHost(validation.url.hostname)) throw new BlockedRequestError("private_host");
6292
+ const response = await fetch(current, { ...init, body, redirect: "manual" });
6293
+ if (response.status < 300 || response.status > 399) return response;
6294
+ const location = response.headers.get("location");
6295
+ if (!location) return response;
6296
+ current = new URL(location, current).toString();
6297
+ if (response.status === 303) body = void 0;
6298
+ }
6299
+ throw new BlockedRequestError("too_many_redirects");
6300
+ }
6301
+
6302
+ // src/utils/webhookDispatcher.ts
5830
6303
  function dispatchWebhook(data, event, payload, slugs) {
5831
6304
  const done = _dispatch(data, event, payload, slugs);
5832
6305
  return done;
@@ -5863,7 +6336,7 @@ async function _sendToEndpoint(endpoint, body, payload, slugs) {
5863
6336
  const signature = crypto3.createHmac("sha256", endpoint.secret).update(body).digest("hex");
5864
6337
  headers["X-Webhook-Signature"] = signature;
5865
6338
  }
5866
- const response = await fetch(endpoint.url, {
6339
+ const response = await safeFetch(endpoint.url, {
5867
6340
  method: "POST",
5868
6341
  headers,
5869
6342
  body,
@@ -6233,6 +6706,15 @@ async function sendPushToUser(payload, slugs, userId, notification) {
6233
6706
  for (const s of subs.docs) {
6234
6707
  const row = s;
6235
6708
  if (!row.endpoint || !row.p256dh || !row.auth) continue;
6709
+ const check = validatePushEndpoint(row.endpoint);
6710
+ if (!check.ok || !check.url) {
6711
+ console.warn("[support] Push endpoint refused (unsafe URL):", check.reason);
6712
+ continue;
6713
+ }
6714
+ if (!await assertPublicHost(check.url.hostname)) {
6715
+ console.warn("[support] Push endpoint refused (host resolves to a private address)");
6716
+ continue;
6717
+ }
6236
6718
  try {
6237
6719
  await webpush.sendNotification(
6238
6720
  { endpoint: row.endpoint, keys: { p256dh: row.p256dh, auth: row.auth } },
@@ -6283,7 +6765,14 @@ function createPushSubscribeEndpoint(slugs) {
6283
6765
  if (!endpoint || !p256dh || !auth) {
6284
6766
  return Response.json({ error: "subscription invalide (endpoint + keys requis)." }, { status: 400 });
6285
6767
  }
6286
- const data = { user: req.user.id, endpoint, p256dh, auth, userAgent: req.headers.get("user-agent") || "" };
6768
+ const endpointCheck = validatePushEndpoint(endpoint);
6769
+ if (!endpointCheck.ok) {
6770
+ return Response.json(
6771
+ { error: WEBHOOK_URL_MESSAGES[endpointCheck.reason || "invalid_url"] },
6772
+ { status: 400 }
6773
+ );
6774
+ }
6775
+ const data = { user: req.user.id, endpoint, p256dh, auth, userAgent: (req.headers.get("user-agent") || "").slice(0, 256) };
6287
6776
  const existing = await dbFind(req.payload, slugs.pushSubscriptions, { where: { endpoint: { equals: endpoint } }, limit: 1, depth: 0, overrideAccess: true });
6288
6777
  if (existing.docs.length > 0) {
6289
6778
  await dbUpdate(req.payload, slugs.pushSubscriptions, { id: existing.docs[0].id, data, overrideAccess: true });
@@ -6317,7 +6806,7 @@ function createUserPrefsGetEndpoint(slugs) {
6317
6806
  requireAdmin(req, slugs);
6318
6807
  const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
6319
6808
  const prefs = await dbFind(payload, "payload-preferences", {
6320
- where: { key: { equals: key } },
6809
+ where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
6321
6810
  limit: 1,
6322
6811
  depth: 0,
6323
6812
  overrideAccess: true
@@ -6351,7 +6840,7 @@ function createUserPrefsPostEndpoint(slugs) {
6351
6840
  const body = await req.json();
6352
6841
  const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
6353
6842
  const existing = await dbFind(payload, "payload-preferences", {
6354
- where: { key: { equals: key } },
6843
+ where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
6355
6844
  limit: 1,
6356
6845
  depth: 0,
6357
6846
  overrideAccess: true
@@ -6507,8 +6996,10 @@ function createTicketFeedbackEndpoint(slugs) {
6507
6996
  // src/endpoints/transfer-ticket.ts
6508
6997
  var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
6509
6998
  var MAX_TRANSFERS_PER_DAY = 5;
6999
+ var MAX_TRANSFERS_PER_USER_PER_DAY = 15;
6510
7000
  function createTransferTicketEndpoint(slugs, store) {
6511
- const transferLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_DAY, store);
7001
+ const transferLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_DAY, store, "transfer:ticket");
7002
+ const transferUserLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_USER_PER_DAY, store, "transfer:user");
6512
7003
  return {
6513
7004
  path: "/support/tickets/:id/transfer",
6514
7005
  method: "post",
@@ -6543,12 +7034,18 @@ function createTransferTicketEndpoint(slugs, store) {
6543
7034
  if (!isAdmin && !isOwner) {
6544
7035
  return Response.json({ error: "Forbidden" }, { status: 403 });
6545
7036
  }
6546
- if (await transferLimiter.check(`${req.user.id}:${ticketId}`, req)) {
7037
+ if (await transferLimiter.check(`${principalRateKey(req.user)}:${ticketId}`, req)) {
6547
7038
  return Response.json(
6548
7039
  { error: `Limite atteinte (${MAX_TRANSFERS_PER_DAY} transferts par 24h sur ce ticket)` },
6549
7040
  { status: 429 }
6550
7041
  );
6551
7042
  }
7043
+ if (!isAdmin && await transferUserLimiter.check(principalRateKey(req.user), req)) {
7044
+ return Response.json(
7045
+ { error: `Limite atteinte (${MAX_TRANSFERS_PER_USER_PER_DAY} transferts par 24h)` },
7046
+ { status: 429 }
7047
+ );
7048
+ }
6552
7049
  try {
6553
7050
  const since = new Date(Date.now() - 24 * 60 * 60 * 1e3).toISOString();
6554
7051
  const existing = await dbCount(payload, slugs.emailLogs, {
@@ -6690,8 +7187,11 @@ function statusToLabel(status) {
6690
7187
  }
6691
7188
  var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
6692
7189
  var MAX_COLLABORATORS_PER_TICKET = 20;
7190
+ var MAX_NEW_ACCOUNTS_PER_INVITER = 30;
7191
+ var NEW_ACCOUNT_WINDOW_MS = 7 * 24 * 60 * 60 * 1e3;
6693
7192
  function createInviteCollaboratorEndpoint(slugs, store) {
6694
- const inviteLimiter = new RateLimiter(60 * 60 * 1e3, 10, store);
7193
+ const inviteLimiter = new RateLimiter(60 * 60 * 1e3, 10, store, "invite-collaborator");
7194
+ const newAccountLimiter = new RateLimiter(NEW_ACCOUNT_WINDOW_MS, MAX_NEW_ACCOUNTS_PER_INVITER, store, "invite-collaborator:new-account");
6695
7195
  return {
6696
7196
  path: "/support/tickets/:id/invite",
6697
7197
  method: "post",
@@ -6726,7 +7226,7 @@ function createInviteCollaboratorEndpoint(slugs, store) {
6726
7226
  if (!isAdmin && !isOwner) {
6727
7227
  return Response.json({ error: "Forbidden" }, { status: 403 });
6728
7228
  }
6729
- if (await inviteLimiter.check(String(req.user.id), req)) {
7229
+ if (await inviteLimiter.check(principalRateKey(req.user), req)) {
6730
7230
  return Response.json({ error: "Trop d'invitations. R\xE9essayez plus tard." }, { status: 429 });
6731
7231
  }
6732
7232
  const collabCount = await dbCount(payload, "ticket-collaborators", { where: { ticket: { equals: ticketId } }, overrideAccess: true }).catch(() => ({ totalDocs: 0 }));
@@ -6750,6 +7250,12 @@ function createInviteCollaboratorEndpoint(slugs, store) {
6750
7250
  if (existing.docs.length > 0) {
6751
7251
  inviteeId = existing.docs[0].id;
6752
7252
  } else {
7253
+ if (!isAdmin && await newAccountLimiter.check(principalRateKey(req.user), req)) {
7254
+ return Response.json(
7255
+ { error: "Trop de nouveaux comptes invit\xE9s. R\xE9essayez plus tard." },
7256
+ { status: 429 }
7257
+ );
7258
+ }
6753
7259
  const tempPassword = randomBytes(16).toString("hex");
6754
7260
  const created = await dbCreate(payload, slugs.supportClients, {
6755
7261
  data: {
@@ -6921,7 +7427,7 @@ function createSupportEndpoints(slugs, options) {
6921
7427
  }
6922
7428
  if (!f || f.satisfaction !== false) endpoints.push(createSatisfactionEndpoint(slugs));
6923
7429
  if (!f || f.emailTracking !== false) {
6924
- endpoints.push(createEmailStatsEndpoint(slugs), createTrackOpenEndpoint(slugs));
7430
+ endpoints.push(createEmailStatsEndpoint(slugs, rateLimitStore), createTrackOpenEndpoint(slugs));
6925
7431
  }
6926
7432
  if (!f || f.pendingEmails !== false) endpoints.push(createPendingEmailsProcessEndpoint(slugs));
6927
7433
  if (!f || f.scheduledReplies !== false) endpoints.push(createProcessScheduledEndpoint(slugs));
@@ -8406,7 +8912,7 @@ function createTicketsCollection(slugs, options) {
8406
8912
  }
8407
8913
 
8408
8914
  // src/utils/ticketAccess.ts
8409
- async function resolveAccessibleTicketIds(payload, slugs, clientId) {
8915
+ async function resolveAccessibleTicketIds(payload, slugs, clientId, mode = "read") {
8410
8916
  const ids = /* @__PURE__ */ new Set();
8411
8917
  try {
8412
8918
  const owned = await dbFind(payload, slugs.tickets, {
@@ -8427,6 +8933,7 @@ async function resolveAccessibleTicketIds(payload, slugs, clientId) {
8427
8933
  });
8428
8934
  for (const r of collab.docs) {
8429
8935
  const row = r;
8936
+ if (mode === "write" && row.role !== "collaborator") continue;
8430
8937
  const tid = typeof row.ticket === "object" ? row.ticket?.id : row.ticket;
8431
8938
  if (tid !== void 0 && tid !== null) ids.add(tid);
8432
8939
  }
@@ -8533,7 +9040,7 @@ function createRestrictClientTicketTarget(slugs) {
8533
9040
  if (targetId === void 0 || targetId === null || targetId === "") {
8534
9041
  throw new APIError("Ticket cible requis.", 400);
8535
9042
  }
8536
- const accessible = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id);
9043
+ const accessible = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id, "write");
8537
9044
  if (!accessible.some((id) => String(id) === String(targetId))) {
8538
9045
  throw new APIError("Ticket inaccessible.", 403);
8539
9046
  }
@@ -9050,13 +9557,13 @@ function createSendInvitationOnCreate(slugs) {
9050
9557
  return doc;
9051
9558
  };
9052
9559
  }
9053
- var TWO_FA_WINDOW_MS = 5 * 60 * 1e3;
9560
+ var TWO_FA_WINDOW_MS2 = 5 * 60 * 1e3;
9054
9561
  function createEnforce2FA(slugs) {
9055
9562
  return async ({ req, user }) => {
9056
9563
  if (!user?.twoFactorEnabled) return user;
9057
9564
  const raw = user.twoFactorVerifiedAt;
9058
9565
  const verifiedAt = raw ? new Date(raw).getTime() : 0;
9059
- if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS)) {
9566
+ if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS2)) {
9060
9567
  throw new APIError("2FA_REQUIRED", 401);
9061
9568
  }
9062
9569
  await req.payload.update({
@@ -9897,8 +10404,28 @@ function createKnowledgeBaseCollection(slugs) {
9897
10404
  timestamps: true
9898
10405
  };
9899
10406
  }
9900
-
9901
- // src/collections/ChatMessages.ts
10407
+ function createRestrictClientChatWrite(slugs) {
10408
+ return async ({ data, req }) => {
10409
+ if (req.user?.collection !== slugs.supportClients) return data;
10410
+ data.client = req.user.id;
10411
+ data.senderType = "client";
10412
+ delete data.agent;
10413
+ const session = typeof data.session === "string" ? data.session : null;
10414
+ if (!session) return data;
10415
+ const existing = await dbFind(req.payload, slugs.chatMessages, {
10416
+ where: { session: { equals: session } },
10417
+ limit: 1,
10418
+ depth: 0,
10419
+ overrideAccess: true
10420
+ });
10421
+ const owner = existing.docs[0]?.client;
10422
+ const ownerId = owner && typeof owner === "object" ? owner.id : owner;
10423
+ if (ownerId !== void 0 && ownerId !== null && String(ownerId) !== String(req.user.id)) {
10424
+ throw new APIError("Session inaccessible.", 403);
10425
+ }
10426
+ return data;
10427
+ };
10428
+ }
9902
10429
  function createChatMessagesCollection(slugs) {
9903
10430
  return {
9904
10431
  slug: slugs.chatMessages,
@@ -9920,7 +10447,9 @@ function createChatMessagesCollection(slugs) {
9920
10447
  }
9921
10448
  return false;
9922
10449
  },
9923
- create: ({ req }) => !!req.user,
10450
+ // Staff and support-clients only — NOT "any authenticated principal":
10451
+ // a user of any other auth collection of the host app satisfied `!!req.user`.
10452
+ create: ({ req }) => req.user?.collection === slugs.users || req.user?.collection === slugs.supportClients,
9924
10453
  update: ({ req }) => req.user?.collection === slugs.users,
9925
10454
  delete: ({ req }) => req.user?.collection === slugs.users
9926
10455
  },
@@ -9989,6 +10518,9 @@ function createChatMessagesCollection(slugs) {
9989
10518
  }
9990
10519
  }
9991
10520
  ],
10521
+ hooks: {
10522
+ beforeChange: [createRestrictClientChatWrite(slugs)]
10523
+ },
9992
10524
  timestamps: true
9993
10525
  };
9994
10526
  }
@@ -10266,8 +10798,19 @@ function createAuthLogsCollection(slugs) {
10266
10798
  timestamps: true
10267
10799
  };
10268
10800
  }
10269
-
10270
- // src/collections/WebhookEndpoints.ts
10801
+ function createValidateWebhookUrl() {
10802
+ return ({ data, operation, originalDoc }) => {
10803
+ const incoming = data?.url;
10804
+ if (incoming === void 0 || incoming === null) return data;
10805
+ const previous = originalDoc?.url;
10806
+ if (operation === "update" && incoming === previous) return data;
10807
+ const result = validateWebhookUrl(incoming);
10808
+ if (!result.ok) {
10809
+ throw new APIError(WEBHOOK_URL_MESSAGES[result.reason || "invalid_url"], 400);
10810
+ }
10811
+ return data;
10812
+ };
10813
+ }
10271
10814
  function createWebhookEndpointsCollection(slugs) {
10272
10815
  return {
10273
10816
  slug: slugs.webhookEndpoints,
@@ -10301,8 +10844,11 @@ function createWebhookEndpointsCollection(slugs) {
10301
10844
  type: "text",
10302
10845
  required: true,
10303
10846
  label: "URL",
10847
+ // The SSRF check lives in the collection `beforeValidate` above, NOT in a
10848
+ // field `validate`: the latter re-runs on the merged document and would
10849
+ // freeze every pre-existing row on any unrelated edit.
10304
10850
  admin: {
10305
- description: "URL du webhook \xE0 appeler (POST)"
10851
+ description: "URL https:// du webhook \xE0 appeler (POST). Les adresses priv\xE9es et loopback sont refus\xE9es."
10306
10852
  }
10307
10853
  },
10308
10854
  {
@@ -10356,6 +10902,9 @@ function createWebhookEndpointsCollection(slugs) {
10356
10902
  }
10357
10903
  }
10358
10904
  ],
10905
+ hooks: {
10906
+ beforeValidate: [createValidateWebhookUrl()]
10907
+ },
10359
10908
  timestamps: true
10360
10909
  };
10361
10910
  }
@@ -10807,11 +11356,17 @@ function createClientSummariesCollection(slugs) {
10807
11356
  admin: { readOnly: true }
10808
11357
  }
10809
11358
  ],
11359
+ // Staff-only, on the SAME source of truth as every other collection and as
11360
+ // `requireAdmin`: `slugs.users`. The literal `'users'` this used to compare
11361
+ // against is the DEFAULT slug, not the configured one — on a host app whose
11362
+ // staff collection is renamed (`collectionSlugs.users: 'admins'`) it named
11363
+ // the front-office collection instead, opening read/create/update/delete on
11364
+ // AI-generated client intelligence to it while locking the real agents out.
10810
11365
  access: {
10811
- create: ({ req }) => req.user?.collection === "users",
10812
- read: ({ req }) => req.user?.collection === "users",
10813
- update: ({ req }) => req.user?.collection === "users",
10814
- delete: ({ req }) => req.user?.collection === "users"
11366
+ create: ({ req }) => req.user?.collection === slugs.users,
11367
+ read: ({ req }) => req.user?.collection === slugs.users,
11368
+ update: ({ req }) => req.user?.collection === slugs.users,
11369
+ delete: ({ req }) => req.user?.collection === slugs.users
10815
11370
  },
10816
11371
  timestamps: true
10817
11372
  };
@@ -11275,6 +11830,17 @@ function supportPlugin(config) {
11275
11830
  });
11276
11831
  return {
11277
11832
  ...incomingConfig,
11833
+ // Publish the resolved staff collection so the server-side readers share
11834
+ // ONE source of truth with the writers. `requireAdmin` compares against
11835
+ // `slugs.users`; the `payload-preferences` reads used to scope themselves
11836
+ // on `config.admin.user`, which Payload silently defaults to the first
11837
+ // auth collection of the host app — a different collection on any app
11838
+ // that declares `collectionSlugs.users`, and the settings-poisoning hole
11839
+ // reopened right there.
11840
+ custom: {
11841
+ ...incomingConfig.custom,
11842
+ [SUPPORT_STAFF_SLUG_CONFIG_KEY]: slugs.users
11843
+ },
11278
11844
  collections: config?.skipCollections ? existingCollections : [...existingCollections, ...supportCollections],
11279
11845
  endpoints: config?.skipEndpoints ? existingEndpoints : [...existingEndpoints, ...supportEndpoints],
11280
11846
  admin: {