@consilioweb/payload-support 3.0.0 → 4.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 (43) hide show
  1. package/README.md +36 -2
  2. package/dist/index.cjs +558 -94
  3. package/dist/index.d.cts +20 -5
  4. package/dist/index.d.ts +20 -5
  5. package/dist/index.js +558 -94
  6. package/package.json +1 -1
  7. package/src/collections/ChatMessages.ts +59 -2
  8. package/src/collections/ClientSummaries.ts +10 -4
  9. package/src/collections/TicketMessages.ts +4 -1
  10. package/src/collections/WebhookEndpoints.ts +44 -2
  11. package/src/endpoints/admin-chat.ts +3 -3
  12. package/src/endpoints/ai-agent.ts +3 -3
  13. package/src/endpoints/ai.ts +3 -3
  14. package/src/endpoints/auth-2fa.ts +16 -4
  15. package/src/endpoints/capabilities.ts +6 -6
  16. package/src/endpoints/chat.ts +7 -5
  17. package/src/endpoints/chatbot.ts +48 -2
  18. package/src/endpoints/client-intelligence.ts +4 -4
  19. package/src/endpoints/email-stats.ts +19 -3
  20. package/src/endpoints/import-conversation.ts +1 -1
  21. package/src/endpoints/index.ts +1 -1
  22. package/src/endpoints/invite-collaborator.ts +29 -3
  23. package/src/endpoints/login.ts +15 -2
  24. package/src/endpoints/oauth-google.ts +130 -8
  25. package/src/endpoints/resend-notification.ts +3 -3
  26. package/src/endpoints/send-reminder.ts +3 -3
  27. package/src/endpoints/signature.ts +9 -2
  28. package/src/endpoints/ticket-synthesis.ts +3 -3
  29. package/src/endpoints/transfer-ticket.ts +28 -3
  30. package/src/endpoints/typing.ts +117 -14
  31. package/src/endpoints/user-prefs.ts +5 -2
  32. package/src/plugin.ts +12 -0
  33. package/src/portal/auth/layout.tsx +19 -1
  34. package/src/portal/auth/tickets/detail/MessageBody.tsx +88 -0
  35. package/src/portal/auth/tickets/detail/page.tsx +2 -6
  36. package/src/portal/login/page.tsx +11 -3
  37. package/src/utils/fireWebhooks.ts +4 -1
  38. package/src/utils/rateLimiter.ts +39 -5
  39. package/src/utils/readSettings.ts +124 -14
  40. package/src/utils/ticketAccess.ts +16 -1
  41. package/src/utils/twoFactorChallenge.ts +80 -0
  42. package/src/utils/urlSafety.ts +230 -0
  43. package/src/utils/webhookDispatcher.ts +5 -1
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
 
@@ -167,22 +168,44 @@ var PayloadRateLimitStore = class {
167
168
  return { payload: context };
168
169
  }
169
170
  };
171
+ function principalRateKey(user) {
172
+ if (!user || user.id === void 0 || user.id === null) return "anonymous";
173
+ const collection = typeof user.collection === "string" && user.collection ? user.collection : "unknown";
174
+ return `${collection}:${String(user.id)}`;
175
+ }
170
176
  var RateLimiter = class {
171
- constructor(windowMs, maxRequests, store) {
177
+ /**
178
+ * @param namespace Endpoint-scoped prefix for every key this limiter writes.
179
+ * The store is SHARED (`rateLimitStore: 'payload'` builds one instance for
180
+ * all endpoints), and the raw keys collide across endpoints: `ip` was used
181
+ * by both the login and the chatbot limiter — 10 forged chatbot requests
182
+ * locked a victim out of the portal for 15 minutes — and `String(user.id)`
183
+ * by five different endpoints. Always pass one; it is optional only because
184
+ * `RateLimiter` is part of the published API surface.
185
+ */
186
+ constructor(windowMs, maxRequests, store, namespace) {
172
187
  this.windowMs = windowMs;
173
188
  this.maxRequests = maxRequests;
174
189
  this.store = store ?? new MemoryRateLimitStore();
190
+ this.prefix = namespace ? `${namespace}:` : "";
175
191
  }
176
192
  windowMs;
177
193
  maxRequests;
178
194
  store;
195
+ prefix;
196
+ /** The key actually written to the store. Exposed for assertions in tests. */
197
+ scopedKey(key) {
198
+ return `${this.prefix}${key}`;
199
+ }
179
200
  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);
201
+ const scoped = this.scopedKey(key);
202
+ const entry = context === void 0 ? await this.store.increment(scoped, this.windowMs) : await this.store.increment(scoped, this.windowMs, context);
181
203
  return entry.count > this.maxRequests;
182
204
  }
183
205
  async reset(key, context) {
184
- if (context === void 0) await this.store.reset(key);
185
- else await this.store.reset(key, context);
206
+ const scoped = this.scopedKey(key);
207
+ if (context === void 0) await this.store.reset(scoped);
208
+ else await this.store.reset(scoped, context);
186
209
  }
187
210
  };
188
211
  var DEFAULT_INBOUND_EMAIL_LIMITS = {
@@ -220,7 +243,7 @@ function validateInboundEmailPayload(input, contentLength, limits = DEFAULT_INBO
220
243
 
221
244
  // src/endpoints/capabilities.ts
222
245
  function createInboundEmailEndpoint(capability, store) {
223
- const limiter = new RateLimiter(6e4, 60, store);
246
+ const limiter = new RateLimiter(6e4, 60, store, "inbound-email");
224
247
  return {
225
248
  path: "/support-webhook/inbound-email",
226
249
  method: "post",
@@ -251,7 +274,7 @@ function createInboundEmailEndpoint(capability, store) {
251
274
  };
252
275
  }
253
276
  function createProjectSuggestionsEndpoint(slugs, capability, store) {
254
- const limiter = new RateLimiter(6e4, 20, store);
277
+ const limiter = new RateLimiter(6e4, 20, store, "suggest-projects");
255
278
  return {
256
279
  path: "/support/suggest-projects",
257
280
  method: "post",
@@ -259,7 +282,7 @@ function createProjectSuggestionsEndpoint(slugs, capability, store) {
259
282
  if (!req.user || req.user.collection !== slugs.users) {
260
283
  return Response.json({ error: "Unauthorized" }, { status: 401 });
261
284
  }
262
- const key = req.user?.id ? String(req.user.id) : "anonymous";
285
+ const key = principalRateKey(req.user);
263
286
  if (await limiter.check(key, req)) {
264
287
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
265
288
  }
@@ -268,7 +291,7 @@ function createProjectSuggestionsEndpoint(slugs, capability, store) {
268
291
  };
269
292
  }
270
293
  function createTicketTitleEndpoint(slugs, capability, store) {
271
- const limiter = new RateLimiter(6e4, 20, store);
294
+ const limiter = new RateLimiter(6e4, 20, store, "ticket-title");
272
295
  return {
273
296
  path: "/support/ticket-title",
274
297
  method: "post",
@@ -290,7 +313,7 @@ function createTicketTitleEndpoint(slugs, capability, store) {
290
313
  };
291
314
  }
292
315
  function createGenerateMissingTitlesEndpoint(slugs, capability, store) {
293
- const limiter = new RateLimiter(6e4, 5, store);
316
+ const limiter = new RateLimiter(6e4, 5, store, "generate-missing-titles");
294
317
  return {
295
318
  path: "/support/generate-missing-titles",
296
319
  method: "post",
@@ -446,6 +469,14 @@ var SUPPORT_SETTINGS_PREF_KEY = "support-settings";
446
469
  var PREF_KEY = SUPPORT_SETTINGS_PREF_KEY;
447
470
  var USER_PREFS_KEY_PREFIX = "support-user-prefs";
448
471
  var LEGACY_ROUND_ROBIN_KEY = "support-round-robin";
472
+ var SUPPORT_STAFF_SLUG_CONFIG_KEY = "supportStaffCollection";
473
+ function resolveStaffPrefSlug(payload, staffSlug) {
474
+ if (staffSlug) return staffSlug;
475
+ const config = payload.config;
476
+ const registered = config?.custom?.[SUPPORT_STAFF_SLUG_CONFIG_KEY];
477
+ if (typeof registered === "string" && registered) return registered;
478
+ return config?.admin?.user || "users";
479
+ }
449
480
  var DEFAULT_SETTINGS = {
450
481
  email: { fromAddress: "", fromName: "Support", replyToAddress: "" },
451
482
  ai: { provider: "anthropic", model: "claude-haiku-4-5-20251001", enableSentiment: true, enableSynthesis: true, enableSuggestion: true, enableRewrite: true },
@@ -457,10 +488,13 @@ var DEFAULT_USER_PREFS = {
457
488
  locale: "fr",
458
489
  signature: ""
459
490
  };
460
- var settingsCache = null;
491
+ var settingsCache = /* @__PURE__ */ new Map();
461
492
  var SETTINGS_TTL_MS = 6e4;
493
+ var SETTINGS_CACHE_MAX = 8;
494
+ var warnedForeignSettingsRow = /* @__PURE__ */ new Set();
462
495
  function invalidateSupportSettingsCache() {
463
- settingsCache = null;
496
+ settingsCache.clear();
497
+ warnedForeignSettingsRow.clear();
464
498
  }
465
499
  function mergeSupportSettings(stored, base = DEFAULT_SETTINGS) {
466
500
  const autoClose = { ...base.autoClose, ...stored?.autoClose };
@@ -477,9 +511,11 @@ function mergeSupportSettings(stored, base = DEFAULT_SETTINGS) {
477
511
  )
478
512
  };
479
513
  }
480
- async function readSupportSettingsState(payload) {
481
- if (settingsCache && Date.now() - settingsCache.ts < SETTINGS_TTL_MS) {
482
- return settingsCache.value;
514
+ async function readSupportSettingsState(payload, staffSlug) {
515
+ const staff = resolveStaffPrefSlug(payload, staffSlug);
516
+ const cached = settingsCache.get(staff);
517
+ if (cached && Date.now() - cached.ts < SETTINGS_TTL_MS) {
518
+ return cached.value;
483
519
  }
484
520
  let value = {
485
521
  settings: mergeSupportSettings(null),
@@ -487,7 +523,10 @@ async function readSupportSettingsState(payload) {
487
523
  };
488
524
  try {
489
525
  const prefs = await dbFind(payload, "payload-preferences", {
490
- where: { key: { equals: PREF_KEY } },
526
+ // Sibling keys are AND-ed by Payload. The `user.relationTo` clause is the
527
+ // security boundary: without it any authenticated principal can plant a
528
+ // `support-settings` row and own the plugin's server settings.
529
+ where: { key: { equals: PREF_KEY }, "user.relationTo": { equals: staff } },
491
530
  // The upsert is scoped per admin user, so several rows can share the key.
492
531
  // Sorting makes "last write wins" deterministic instead of arbitrary.
493
532
  sort: "-updatedAt",
@@ -500,22 +539,43 @@ async function readSupportSettingsState(payload) {
500
539
  const featuresConfigured = !!stored.features && typeof stored.features === "object";
501
540
  const settings = mergeSupportSettings(stored);
502
541
  if (!featuresConfigured) {
503
- settings.features.roundRobin = await readLegacyRoundRobin(payload);
542
+ settings.features.roundRobin = await readLegacyRoundRobin(payload, staff);
504
543
  }
505
544
  value = { settings, featuresConfigured };
545
+ } else {
546
+ await warnOnForeignSettingsRow(payload, staff);
506
547
  }
507
548
  } catch {
508
549
  }
509
- settingsCache = { value, ts: Date.now() };
550
+ if (settingsCache.size >= SETTINGS_CACHE_MAX && !settingsCache.has(staff)) settingsCache.clear();
551
+ settingsCache.set(staff, { value, ts: Date.now() });
510
552
  return value;
511
553
  }
512
- async function readSupportSettings(payload) {
513
- return (await readSupportSettingsState(payload)).settings;
554
+ async function warnOnForeignSettingsRow(payload, staff) {
555
+ if (warnedForeignSettingsRow.has(staff)) return;
556
+ try {
557
+ const any = await dbFind(payload, "payload-preferences", {
558
+ where: { key: { equals: PREF_KEY } },
559
+ limit: 1,
560
+ depth: 0,
561
+ overrideAccess: true
562
+ });
563
+ if (any.docs.length === 0) return;
564
+ if (warnedForeignSettingsRow.size >= SETTINGS_CACHE_MAX) warnedForeignSettingsRow.clear();
565
+ warnedForeignSettingsRow.add(staff);
566
+ console.warn(
567
+ `[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.`
568
+ );
569
+ } catch {
570
+ }
571
+ }
572
+ async function readSupportSettings(payload, staffSlug) {
573
+ return (await readSupportSettingsState(payload, staffSlug)).settings;
514
574
  }
515
- async function readLegacyRoundRobin(payload) {
575
+ async function readLegacyRoundRobin(payload, staff) {
516
576
  try {
517
577
  const prefs = await dbFind(payload, "payload-preferences", {
518
- where: { key: { equals: LEGACY_ROUND_ROBIN_KEY } },
578
+ where: { key: { equals: LEGACY_ROUND_ROBIN_KEY }, "user.relationTo": { equals: staff } },
519
579
  limit: 1,
520
580
  depth: 0,
521
581
  overrideAccess: true
@@ -527,11 +587,14 @@ async function readLegacyRoundRobin(payload) {
527
587
  }
528
588
  return DEFAULT_TICKETING_FEATURES.roundRobin;
529
589
  }
530
- async function readUserPrefs(payload, userId) {
590
+ async function readUserPrefs(payload, userId, staffSlug) {
531
591
  try {
532
592
  const key = `${USER_PREFS_KEY_PREFIX}-${userId}`;
533
593
  const prefs = await dbFind(payload, "payload-preferences", {
534
- where: { key: { equals: key } },
594
+ where: {
595
+ key: { equals: key },
596
+ "user.relationTo": { equals: resolveStaffPrefSlug(payload, staffSlug) }
597
+ },
535
598
  limit: 1,
536
599
  depth: 0,
537
600
  overrideAccess: true
@@ -569,7 +632,7 @@ function getModel(aiSettings) {
569
632
  return aiSettings.model || "claude-haiku-4-5-20251001";
570
633
  }
571
634
  function createAiEndpoint(slugs, store) {
572
- const limiter = new RateLimiter(6e4, 30, store);
635
+ const limiter = new RateLimiter(6e4, 30, store, "ai");
573
636
  return {
574
637
  path: "/support/ai",
575
638
  method: "post",
@@ -577,7 +640,7 @@ function createAiEndpoint(slugs, store) {
577
640
  try {
578
641
  const payload = req.payload;
579
642
  requireAdmin(req, slugs);
580
- if (await limiter.check(String(req.user.id), req)) {
643
+ if (await limiter.check(principalRateKey(req.user), req)) {
581
644
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
582
645
  }
583
646
  const settings = await readSupportSettings(payload);
@@ -822,14 +885,14 @@ ${kbText || "(vide)"}`;
822
885
 
823
886
  // src/endpoints/ai-agent.ts
824
887
  function createAiAgentEndpoint(slugs, store) {
825
- const limiter = new RateLimiter(6e4, 10, store);
888
+ const limiter = new RateLimiter(6e4, 10, store, "ai-agent");
826
889
  return {
827
890
  path: "/support/ai-agent",
828
891
  method: "post",
829
892
  handler: async (req) => {
830
893
  try {
831
894
  requireAdmin(req, slugs);
832
- if (await limiter.check(String(req.user.id), req)) {
895
+ if (await limiter.check(principalRateKey(req.user), req)) {
833
896
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
834
897
  }
835
898
  let body = {};
@@ -869,11 +932,11 @@ function getModel2(aiSettings) {
869
932
  }
870
933
  var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
871
934
  function createClientIntelligenceEndpoint(slugs, store) {
872
- const limiter = new RateLimiter(6e4, 20, store);
935
+ const limiter = new RateLimiter(6e4, 20, store, "client-intelligence");
873
936
  const getHandler = async (req) => {
874
937
  try {
875
938
  requireAdmin(req, slugs);
876
- if (await limiter.check(String(req.user.id), req)) {
939
+ if (await limiter.check(principalRateKey(req.user), req)) {
877
940
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
878
941
  }
879
942
  const payload = req.payload;
@@ -905,7 +968,7 @@ function createClientIntelligenceEndpoint(slugs, store) {
905
968
  const postHandler = async (req) => {
906
969
  try {
907
970
  requireAdmin(req, slugs);
908
- if (await limiter.check(String(req.user.id), req)) {
971
+ if (await limiter.check(principalRateKey(req.user), req)) {
909
972
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
910
973
  }
911
974
  const payload = req.payload;
@@ -1534,6 +1597,14 @@ function createSplitTicketEndpoint(slugs) {
1534
1597
  // src/endpoints/typing.ts
1535
1598
  var typingState = /* @__PURE__ */ new Map();
1536
1599
  var TYPING_TTL = 5e3;
1600
+ var MAX_TYPING_KEYS = 500;
1601
+ var TICKET_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
1602
+ function normalizeTicketId(raw) {
1603
+ if (typeof raw === "number") return Number.isInteger(raw) && raw > 0 ? String(raw) : null;
1604
+ if (typeof raw !== "string") return null;
1605
+ const value = raw.trim();
1606
+ return TICKET_ID_PATTERN.test(value) ? value : null;
1607
+ }
1537
1608
  function cleanExpired(ticketId) {
1538
1609
  const state = typingState.get(ticketId);
1539
1610
  if (!state) return;
@@ -1548,6 +1619,36 @@ function cleanExpired(ticketId) {
1548
1619
  }
1549
1620
  if (!state.admin && !state.client) typingState.delete(ticketId);
1550
1621
  }
1622
+ function sweepExpired() {
1623
+ for (const key of Array.from(typingState.keys())) cleanExpired(key);
1624
+ }
1625
+ function evictOldest() {
1626
+ let oldestKey = null;
1627
+ let oldestTs = Infinity;
1628
+ for (const [key, state] of typingState) {
1629
+ const ts = Math.max(state.admin || 0, state.client || 0);
1630
+ if (ts < oldestTs) {
1631
+ oldestTs = ts;
1632
+ oldestKey = key;
1633
+ }
1634
+ }
1635
+ if (oldestKey !== null) typingState.delete(oldestKey);
1636
+ }
1637
+ async function mayAccessTicket(req, slugs, ticketId) {
1638
+ const collection = req.user?.collection;
1639
+ if (collection !== slugs.users && collection !== slugs.supportClients) return false;
1640
+ try {
1641
+ const doc = await dbFindByID(req.payload, slugs.tickets, {
1642
+ id: ticketId,
1643
+ depth: 0,
1644
+ overrideAccess: false,
1645
+ user: req.user
1646
+ });
1647
+ return !!doc;
1648
+ } catch {
1649
+ return false;
1650
+ }
1651
+ }
1551
1652
  function createTypingPostEndpoint(slugs) {
1552
1653
  return {
1553
1654
  path: "/support/typing",
@@ -1558,11 +1659,18 @@ function createTypingPostEndpoint(slugs) {
1558
1659
  return Response.json({ error: "Unauthorized" }, { status: 401 });
1559
1660
  }
1560
1661
  const { ticketId } = await req.json();
1561
- if (!ticketId) {
1662
+ const key = normalizeTicketId(ticketId);
1663
+ if (!key) {
1562
1664
  return Response.json({ error: "ticketId required" }, { status: 400 });
1563
1665
  }
1564
- const key = String(ticketId);
1666
+ if (!await mayAccessTicket(req, slugs, key)) {
1667
+ return Response.json({ error: "Forbidden" }, { status: 403 });
1668
+ }
1565
1669
  const state = typingState.get(key) || {};
1670
+ if (!typingState.has(key) && typingState.size >= MAX_TYPING_KEYS) {
1671
+ sweepExpired();
1672
+ if (typingState.size >= MAX_TYPING_KEYS) evictOldest();
1673
+ }
1566
1674
  if (req.user.collection === slugs.users) {
1567
1675
  state.admin = Date.now();
1568
1676
  state.adminName = req.user.firstName || "Support";
@@ -1583,30 +1691,33 @@ function createTypingGetEndpoint(slugs) {
1583
1691
  path: "/support/typing",
1584
1692
  method: "get",
1585
1693
  handler: async (req) => {
1694
+ const idle = { typing: false, name: null };
1586
1695
  try {
1587
1696
  if (!req.user) {
1588
1697
  return Response.json({ error: "Unauthorized" }, { status: 401 });
1589
1698
  }
1590
1699
  const url = new URL(req.url);
1591
- const ticketId = url.searchParams.get("ticketId");
1592
- if (!ticketId) {
1700
+ const key = normalizeTicketId(url.searchParams.get("ticketId"));
1701
+ if (!key) {
1593
1702
  return Response.json({ error: "ticketId required" }, { status: 400 });
1594
1703
  }
1595
- cleanExpired(ticketId);
1596
- const state = typingState.get(ticketId);
1704
+ cleanExpired(key);
1705
+ const state = typingState.get(key);
1706
+ if (!state) return Response.json(idle);
1707
+ if (!await mayAccessTicket(req, slugs, key)) return Response.json(idle);
1597
1708
  if (req.user.collection === slugs.users) {
1598
1709
  return Response.json({
1599
- typing: !!state?.client,
1600
- name: state?.clientName || null
1710
+ typing: !!state.client,
1711
+ name: state.clientName || null
1601
1712
  });
1602
1713
  } else {
1603
1714
  return Response.json({
1604
- typing: !!state?.admin,
1605
- name: state?.adminName || null
1715
+ typing: !!state.admin,
1716
+ name: state.adminName || null
1606
1717
  });
1607
1718
  }
1608
1719
  } catch {
1609
- return Response.json({ typing: false, name: null });
1720
+ return Response.json(idle);
1610
1721
  }
1611
1722
  }
1612
1723
  };
@@ -1769,7 +1880,14 @@ function createSignatureGetEndpoint(slugs) {
1769
1880
  const payload = req.payload;
1770
1881
  requireAdmin(req, slugs);
1771
1882
  const prefs = await dbFind(payload, "payload-preferences", {
1772
- where: { key: { equals: `${PREF_KEY2}-${req.user.id}` } },
1883
+ // Scope to the staff auth collection: `payload-preferences` accepts a
1884
+ // write from ANY authenticated principal, and ids collide between auth
1885
+ // collections — a support-client with the same id would otherwise own
1886
+ // the `email-signature-<id>` row read back for the agent.
1887
+ where: {
1888
+ key: { equals: `${PREF_KEY2}-${req.user.id}` },
1889
+ "user.relationTo": { equals: slugs.users }
1890
+ },
1773
1891
  limit: 1,
1774
1892
  depth: 0,
1775
1893
  overrideAccess: true
@@ -1796,7 +1914,7 @@ function createSignaturePostEndpoint(slugs) {
1796
1914
  const { signature } = await req.json();
1797
1915
  const key = `${PREF_KEY2}-${req.user.id}`;
1798
1916
  const existing = await dbFind(payload, "payload-preferences", {
1799
- where: { key: { equals: key } },
1917
+ where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
1800
1918
  limit: 1,
1801
1919
  depth: 0,
1802
1920
  overrideAccess: true
@@ -2557,7 +2675,7 @@ function formatFr(date, withTime) {
2557
2675
  });
2558
2676
  }
2559
2677
  function createSendReminderEndpoint(slugs, store) {
2560
- const reminderLimiter = new RateLimiter(60 * 60 * 1e3, 30, store);
2678
+ const reminderLimiter = new RateLimiter(60 * 60 * 1e3, 30, store, "send-reminder");
2561
2679
  return {
2562
2680
  path: "/support/send-reminder",
2563
2681
  method: "post",
@@ -2565,7 +2683,7 @@ function createSendReminderEndpoint(slugs, store) {
2565
2683
  try {
2566
2684
  const payload = req.payload;
2567
2685
  requireAdmin(req, slugs);
2568
- if (await reminderLimiter.check(String(req.user.id), req)) {
2686
+ if (await reminderLimiter.check(principalRateKey(req.user), req)) {
2569
2687
  return Response.json(
2570
2688
  { error: "Trop de relances. R\xE9essayez dans une heure." },
2571
2689
  { status: 429 }
@@ -2868,8 +2986,14 @@ function createPurgeLogsEndpoint(slugs) {
2868
2986
  }
2869
2987
 
2870
2988
  // src/endpoints/chatbot.ts
2871
- function createChatbotEndpoint(slugs, store) {
2872
- const chatbotLimiter = new RateLimiter(6e4, 10, store);
2989
+ var DEFAULT_CHATBOT_MAX_PER_HOUR = 200;
2990
+ function resolveMaxPerHour(explicit) {
2991
+ const fromEnv = Number(process.env.SUPPORT_CHATBOT_MAX_PER_HOUR);
2992
+ return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : DEFAULT_CHATBOT_MAX_PER_HOUR;
2993
+ }
2994
+ function createChatbotEndpoint(slugs, store, maxPerHour) {
2995
+ const chatbotLimiter = new RateLimiter(6e4, 10, store, "chatbot:ip");
2996
+ const globalLimiter = new RateLimiter(60 * 6e4, resolveMaxPerHour(), store, "chatbot:global");
2873
2997
  return {
2874
2998
  path: "/support/chatbot",
2875
2999
  method: "post",
@@ -2889,6 +3013,15 @@ function createChatbotEndpoint(slugs, store) {
2889
3013
  if (!question?.trim() || question.trim().length < 5) {
2890
3014
  return Response.json({ error: "Question too short" }, { status: 400 });
2891
3015
  }
3016
+ if (await globalLimiter.check("all", req)) {
3017
+ return Response.json({
3018
+ answer: null,
3019
+ confidence: 0,
3020
+ suggestion: "create_ticket",
3021
+ aiUnavailable: true,
3022
+ message: "L'assistant est momentan\xE9ment indisponible. Cr\xE9ez un ticket, un agent vous r\xE9pondra."
3023
+ });
3024
+ }
2892
3025
  const payload = req.payload;
2893
3026
  const articles = await dbFind(payload, slugs.knowledgeBase, {
2894
3027
  where: { published: { equals: true } },
@@ -3001,8 +3134,8 @@ function createChatGetEndpoint(slugs) {
3001
3134
  };
3002
3135
  }
3003
3136
  function createChatPostEndpoint(slugs, store) {
3004
- const chatSessionLimiter = new RateLimiter(36e5, 5, store);
3005
- const chatMessageLimiter = new RateLimiter(6e4, 15, store);
3137
+ const chatSessionLimiter = new RateLimiter(36e5, 5, store, "chat:session");
3138
+ const chatMessageLimiter = new RateLimiter(6e4, 15, store, "chat:message");
3006
3139
  return {
3007
3140
  path: "/support/chat",
3008
3141
  method: "post",
@@ -3018,8 +3151,9 @@ function createChatPostEndpoint(slugs, store) {
3018
3151
  }
3019
3152
  const { action, session, message } = body;
3020
3153
  const userId = String(req.user.id);
3154
+ const rateKey = principalRateKey(req.user);
3021
3155
  if (action === "start") {
3022
- if (await chatSessionLimiter.check(userId, req)) {
3156
+ if (await chatSessionLimiter.check(rateKey, req)) {
3023
3157
  return Response.json({ error: "Trop de sessions cr\xE9\xE9es. R\xE9essayez plus tard." }, { status: 429 });
3024
3158
  }
3025
3159
  const sessionId = `chat_${crypto3__default.default.randomBytes(16).toString("hex")}`;
@@ -3036,7 +3170,7 @@ function createChatPostEndpoint(slugs, store) {
3036
3170
  return Response.json({ session: sessionId, messages: [systemMsg] });
3037
3171
  }
3038
3172
  if (action === "send" && session && message) {
3039
- if (await chatMessageLimiter.check(userId, req)) {
3173
+ if (await chatMessageLimiter.check(rateKey, req)) {
3040
3174
  return Response.json({ error: "Trop de messages. Attendez un moment." }, { status: 429 });
3041
3175
  }
3042
3176
  const trimmedMessage = String(message).trim();
@@ -3276,7 +3410,7 @@ function createAdminChatGetEndpoint(slugs) {
3276
3410
  };
3277
3411
  }
3278
3412
  function createAdminChatPostEndpoint(slugs, store) {
3279
- const adminChatLimiter = new RateLimiter(6e4, 30, store);
3413
+ const adminChatLimiter = new RateLimiter(6e4, 30, store, "admin-chat");
3280
3414
  return {
3281
3415
  path: "/support/admin-chat",
3282
3416
  method: "post",
@@ -3305,7 +3439,7 @@ function createAdminChatPostEndpoint(slugs, store) {
3305
3439
  }
3306
3440
  const clientId = typeof sessionMsg.docs[0].client === "object" ? sessionMsg.docs[0].client.id : sessionMsg.docs[0].client;
3307
3441
  if (action === "send" && message) {
3308
- if (await adminChatLimiter.check(String(req.user.id), req)) {
3442
+ if (await adminChatLimiter.check(principalRateKey(req.user), req)) {
3309
3443
  return Response.json({ error: "Rate limit atteint." }, { status: 429 });
3310
3444
  }
3311
3445
  const trimmedMessage = String(message).trim();
@@ -4260,14 +4394,14 @@ Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`;
4260
4394
 
4261
4395
  // src/endpoints/ticket-synthesis.ts
4262
4396
  function createTicketSynthesisEndpoint(slugs, generator, store) {
4263
- const limiter = new RateLimiter(6e4, 20, store);
4397
+ const limiter = new RateLimiter(6e4, 20, store, "ticket-synthesis");
4264
4398
  return {
4265
4399
  path: "/support/ticket-synthesis",
4266
4400
  method: "post",
4267
4401
  handler: async (req) => {
4268
4402
  try {
4269
4403
  requireAdmin(req, slugs);
4270
- if (await limiter.check(String(req.user.id), req)) {
4404
+ if (await limiter.check(principalRateKey(req.user), req)) {
4271
4405
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
4272
4406
  }
4273
4407
  const payload = req.payload;
@@ -4311,15 +4445,17 @@ function createTicketSynthesisEndpoint(slugs, generator, store) {
4311
4445
  }
4312
4446
 
4313
4447
  // src/endpoints/email-stats.ts
4314
- function createEmailStatsEndpoint(slugs) {
4448
+ function createEmailStatsEndpoint(slugs, store) {
4449
+ const statsLimiter = new RateLimiter(6e4, 20, store, "email-stats");
4315
4450
  return {
4316
4451
  path: "/support/email-stats",
4317
4452
  method: "get",
4318
4453
  handler: async (req) => {
4319
4454
  try {
4320
4455
  const payload = req.payload;
4321
- if (!req.user) {
4322
- return Response.json({ error: "Unauthorized" }, { status: 401 });
4456
+ requireAdmin(req, slugs);
4457
+ if (await statsLimiter.check(principalRateKey(req.user), req)) {
4458
+ return Response.json({ error: "Too many requests." }, { status: 429 });
4323
4459
  }
4324
4460
  const url = new URL(req.url);
4325
4461
  const days = Math.min(Number(url.searchParams.get("days")) || 7, 365);
@@ -4386,6 +4522,8 @@ function createEmailStatsEndpoint(slugs) {
4386
4522
  actions: Object.fromEntries(actionMap)
4387
4523
  });
4388
4524
  } catch (err) {
4525
+ const authResponse = handleAuthError(err);
4526
+ if (authResponse) return authResponse;
4389
4527
  console.error("[email-stats] Error:", err);
4390
4528
  return Response.json({ error: "Internal server error" }, { status: 500 });
4391
4529
  }
@@ -4818,7 +4956,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4818
4956
 
4819
4957
  // src/endpoints/resend-notification.ts
4820
4958
  function createResendNotificationEndpoint(slugs, store) {
4821
- const resendLimiter = new RateLimiter(60 * 60 * 1e3, 10, store);
4959
+ const resendLimiter = new RateLimiter(60 * 60 * 1e3, 10, store, "resend-notification");
4822
4960
  return {
4823
4961
  path: "/support/resend-notification",
4824
4962
  method: "post",
@@ -4826,7 +4964,7 @@ function createResendNotificationEndpoint(slugs, store) {
4826
4964
  try {
4827
4965
  const payload = req.payload;
4828
4966
  requireAdmin(req, slugs);
4829
- if (await resendLimiter.check(String(req.user.id), req)) {
4967
+ if (await resendLimiter.check(principalRateKey(req.user), req)) {
4830
4968
  return Response.json(
4831
4969
  { error: "Trop de renvois. R\xE9essayez dans une heure." },
4832
4970
  { status: 429 }
@@ -5027,10 +5165,48 @@ function createSeedKbEndpoint(slugs) {
5027
5165
  }
5028
5166
  };
5029
5167
  }
5168
+ var TWO_FACTOR_CHALLENGE_TTL_MS = 10 * 60 * 1e3;
5169
+ function challengeSecret() {
5170
+ const secret = process.env.PAYLOAD_SECRET;
5171
+ if (!secret) {
5172
+ throw new Error(
5173
+ "[support][2fa] PAYLOAD_SECRET is not set \u2014 refusing to issue a 2FA challenge with an insecure fallback secret"
5174
+ );
5175
+ }
5176
+ return secret;
5177
+ }
5178
+ function normalizeEmail(email) {
5179
+ return String(email).trim().toLowerCase();
5180
+ }
5181
+ function sign(email, expiresAt) {
5182
+ return crypto3.createHmac("sha256", challengeSecret()).update(`2fa-challenge:${normalizeEmail(email)}:${expiresAt}`).digest("hex");
5183
+ }
5184
+ function issueTwoFactorChallenge(email, now = Date.now()) {
5185
+ const expiresAt = now + TWO_FACTOR_CHALLENGE_TTL_MS;
5186
+ return `${expiresAt}.${sign(email, expiresAt)}`;
5187
+ }
5188
+ function verifyTwoFactorChallenge(email, token, now = Date.now()) {
5189
+ if (typeof email !== "string" || !email || typeof token !== "string") return false;
5190
+ const separator = token.indexOf(".");
5191
+ if (separator <= 0) return false;
5192
+ const expiresAt = Number(token.slice(0, separator));
5193
+ if (!Number.isSafeInteger(expiresAt) || expiresAt <= now) return false;
5194
+ const received = token.slice(separator + 1);
5195
+ if (!/^[0-9a-f]{64}$/i.test(received)) return false;
5196
+ let expected;
5197
+ try {
5198
+ expected = sign(email, expiresAt);
5199
+ } catch {
5200
+ return false;
5201
+ }
5202
+ const a = Buffer.from(expected, "hex");
5203
+ const b = Buffer.from(received.toLowerCase(), "hex");
5204
+ return a.length === b.length && crypto3.timingSafeEqual(a, b);
5205
+ }
5030
5206
 
5031
5207
  // src/endpoints/login.ts
5032
5208
  function createLoginEndpoint(slugs, store) {
5033
- const loginLimiter = new RateLimiter(15 * 6e4, 10, store);
5209
+ const loginLimiter = new RateLimiter(15 * 6e4, 10, store, "login");
5034
5210
  return {
5035
5211
  path: "/support/login",
5036
5212
  method: "post",
@@ -5083,7 +5259,12 @@ function createLoginEndpoint(slugs, store) {
5083
5259
  } catch (err) {
5084
5260
  const errorMessage = err instanceof Error ? err.message : "Erreur inconnue";
5085
5261
  if (errorMessage.includes("2FA_REQUIRED")) {
5086
- return Response.json({ requires2FA: true }, { status: 200 });
5262
+ let challenge;
5263
+ try {
5264
+ challenge = issueTwoFactorChallenge(email);
5265
+ } catch {
5266
+ }
5267
+ return Response.json({ requires2FA: true, ...challenge ? { challenge } : {} }, { status: 200 });
5087
5268
  }
5088
5269
  let errorReason = "Identifiants incorrects";
5089
5270
  if (errorMessage.includes("locked") || errorMessage.includes("verrouill\xE9") || errorMessage.includes("Too many")) {
@@ -5115,8 +5296,8 @@ function hashCode(code) {
5115
5296
  return crypto3.createHmac("sha256", secret).update(code).digest("hex");
5116
5297
  }
5117
5298
  function createAuth2faEndpoint(slugs, store) {
5118
- const sendLimiter = new RateLimiter(60 * 60 * 1e3, 3, store);
5119
- const verifyLimiter = new RateLimiter(15 * 60 * 1e3, 5, store);
5299
+ const sendLimiter = new RateLimiter(60 * 60 * 1e3, 3, store, "2fa:send");
5300
+ const verifyLimiter = new RateLimiter(15 * 60 * 1e3, 5, store, "2fa:verify");
5120
5301
  return {
5121
5302
  path: "/support/2fa",
5122
5303
  method: "post",
@@ -5129,12 +5310,18 @@ function createAuth2faEndpoint(slugs, store) {
5129
5310
  } catch {
5130
5311
  return Response.json({ error: "Invalid JSON body" }, { status: 400 });
5131
5312
  }
5132
- const { action, email, code } = body;
5313
+ const { action, email, code, challenge } = body;
5133
5314
  if (!action || !email) {
5134
5315
  return Response.json({ error: "Param\xE8tres manquants" }, { status: 400 });
5135
5316
  }
5136
5317
  const genericSendResponse = { success: true, message: "Si un compte existe, un code a \xE9t\xE9 envoy\xE9." };
5137
5318
  if (action === "send") {
5319
+ if (!verifyTwoFactorChallenge(email, challenge)) {
5320
+ return Response.json(
5321
+ { error: "Authentification requise avant l'envoi d'un code." },
5322
+ { status: 401 }
5323
+ );
5324
+ }
5138
5325
  if (await sendLimiter.check(email, req)) {
5139
5326
  return Response.json(genericSendResponse);
5140
5327
  }
@@ -5228,6 +5415,33 @@ function createAuth2faEndpoint(slugs, store) {
5228
5415
  }
5229
5416
  };
5230
5417
  }
5418
+ var OAUTH_STATE_COOKIE = "support-oauth-state";
5419
+ var OAUTH_STATE_MAX_AGE = 600;
5420
+ var TWO_FA_WINDOW_MS = 5 * 60 * 1e3;
5421
+ function readCookie(header, name) {
5422
+ if (!header) return null;
5423
+ for (const part of header.split(";")) {
5424
+ const eq = part.indexOf("=");
5425
+ if (eq === -1) continue;
5426
+ if (part.slice(0, eq).trim() !== name) continue;
5427
+ try {
5428
+ return decodeURIComponent(part.slice(eq + 1).trim());
5429
+ } catch {
5430
+ return part.slice(eq + 1).trim();
5431
+ }
5432
+ }
5433
+ return null;
5434
+ }
5435
+ function clearedStateCookie() {
5436
+ const secure = process.env.NODE_ENV === "production";
5437
+ return `${OAUTH_STATE_COOKIE}=; HttpOnly; ${secure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=0`;
5438
+ }
5439
+ function statesMatch(a, b) {
5440
+ if (!a || !b) return false;
5441
+ const left = crypto3__default.default.createHash("sha256").update(a).digest();
5442
+ const right = crypto3__default.default.createHash("sha256").update(b).digest();
5443
+ return crypto3__default.default.timingSafeEqual(left, right);
5444
+ }
5231
5445
  function createOAuthGoogleEndpoint(slugs, options) {
5232
5446
  return {
5233
5447
  path: "/support/oauth/google",
@@ -5244,7 +5458,7 @@ function createOAuthGoogleEndpoint(slugs, options) {
5244
5458
  }
5245
5459
  try {
5246
5460
  const body = await req.json();
5247
- const { action, code, state: queryState, cookieState } = body;
5461
+ const { action, code, state: queryState } = body;
5248
5462
  if (action === "login") {
5249
5463
  const oauthState = crypto3__default.default.randomBytes(32).toString("hex");
5250
5464
  const redirectUri = `${baseUrl}/api/support/oauth/google`;
@@ -5256,13 +5470,25 @@ function createOAuthGoogleEndpoint(slugs, options) {
5256
5470
  state: oauthState,
5257
5471
  prompt: "select_account"
5258
5472
  });
5259
- return Response.json({
5260
- url: `https://accounts.google.com/o/oauth2/v2/auth?${params}`,
5261
- state: oauthState
5262
- });
5473
+ const secure = process.env.NODE_ENV === "production";
5474
+ const loginHeaders = new Headers({ "Content-Type": "application/json" });
5475
+ loginHeaders.append(
5476
+ "Set-Cookie",
5477
+ `${OAUTH_STATE_COOKIE}=${oauthState}; HttpOnly; ${secure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=${OAUTH_STATE_MAX_AGE}`
5478
+ );
5479
+ return new Response(
5480
+ JSON.stringify({
5481
+ url: `https://accounts.google.com/o/oauth2/v2/auth?${params}`,
5482
+ // Kept for callers that echo it back in the redirect URL; the
5483
+ // server no longer trusts anything the caller returns.
5484
+ state: oauthState
5485
+ }),
5486
+ { status: 200, headers: loginHeaders }
5487
+ );
5263
5488
  }
5264
5489
  if (code) {
5265
- if (!cookieState || !queryState || cookieState !== queryState) {
5490
+ const issuedState = readCookie(req.headers.get("cookie"), OAUTH_STATE_COOKIE);
5491
+ if (!statesMatch(issuedState, queryState)) {
5266
5492
  return Response.json({ error: "state_mismatch" }, { status: 400 });
5267
5493
  }
5268
5494
  const redirectUri = `${baseUrl}/api/support/oauth/google`;
@@ -5344,6 +5570,35 @@ function createOAuthGoogleEndpoint(slugs, options) {
5344
5570
  overrideAccess: true
5345
5571
  });
5346
5572
  }
5573
+ const twoFactorDoc = await dbFindByID(payload$1, slugs.supportClients, {
5574
+ id: clientDoc.id,
5575
+ depth: 0,
5576
+ overrideAccess: true,
5577
+ showHiddenFields: true
5578
+ });
5579
+ if (twoFactorDoc?.twoFactorEnabled) {
5580
+ const raw = twoFactorDoc.twoFactorVerifiedAt;
5581
+ const verifiedAt = raw ? new Date(raw).getTime() : 0;
5582
+ if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS)) {
5583
+ let challenge;
5584
+ try {
5585
+ challenge = issueTwoFactorChallenge(clientDoc.email);
5586
+ } catch {
5587
+ }
5588
+ return new Response(JSON.stringify({ requires2FA: true, ...challenge ? { challenge } : {} }), {
5589
+ status: 200,
5590
+ headers: new Headers({
5591
+ "Content-Type": "application/json",
5592
+ "Set-Cookie": clearedStateCookie()
5593
+ })
5594
+ });
5595
+ }
5596
+ await dbUpdate(payload$1, slugs.supportClients, {
5597
+ id: clientDoc.id,
5598
+ data: { twoFactorVerifiedAt: null },
5599
+ overrideAccess: true
5600
+ });
5601
+ }
5347
5602
  const secret = process.env.PAYLOAD_SECRET;
5348
5603
  if (!secret) {
5349
5604
  return Response.json({ error: "server_misconfigured" }, { status: 500 });
@@ -5387,6 +5642,7 @@ function createOAuthGoogleEndpoint(slugs, options) {
5387
5642
  "Set-Cookie",
5388
5643
  `payload-token=${token}; HttpOnly; ${cookieSecure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=${tokenExpiration}`
5389
5644
  );
5645
+ headers.append("Set-Cookie", clearedStateCookie());
5390
5646
  return new Response(JSON.stringify({ user: clientDoc, exp }), {
5391
5647
  status: 200,
5392
5648
  headers
@@ -5693,7 +5949,7 @@ ONLY JSON, nothing else.`
5693
5949
  }
5694
5950
  }
5695
5951
  function createImportConversationEndpoint(slugs, store) {
5696
- const importLimiter = new RateLimiter(36e5, 10, store);
5952
+ const importLimiter = new RateLimiter(36e5, 10, store, "import-conversation");
5697
5953
  return {
5698
5954
  path: "/support/import-conversation",
5699
5955
  method: "post",
@@ -5836,6 +6092,137 @@ function createImportConversationEndpoint(slugs, store) {
5836
6092
  }
5837
6093
  };
5838
6094
  }
6095
+ function httpAllowed() {
6096
+ return process.env.SUPPORT_ALLOW_INSECURE_WEBHOOKS === "1";
6097
+ }
6098
+ var BLOCKED_HOST_SUFFIXES = [".local", ".localhost", ".internal", ".home.arpa"];
6099
+ function parseIPv4(host) {
6100
+ const parts = host.split(".");
6101
+ if (parts.length !== 4) return null;
6102
+ const octets = [];
6103
+ for (const part of parts) {
6104
+ if (!/^\d{1,3}$/.test(part)) return null;
6105
+ const n = Number(part);
6106
+ if (n > 255) return null;
6107
+ octets.push(n);
6108
+ }
6109
+ return octets;
6110
+ }
6111
+ function isPrivateIPv4(octets) {
6112
+ const [a, b] = octets;
6113
+ if (a === 0) return true;
6114
+ if (a === 10) return true;
6115
+ if (a === 127) return true;
6116
+ if (a === 169 && b === 254) return true;
6117
+ if (a === 172 && b >= 16 && b <= 31) return true;
6118
+ if (a === 192 && b === 168) return true;
6119
+ if (a === 192 && b === 0) return true;
6120
+ if (a === 198 && (b === 18 || b === 19)) return true;
6121
+ if (a === 100 && b >= 64 && b <= 127) return true;
6122
+ if (a >= 224) return true;
6123
+ return false;
6124
+ }
6125
+ function isPrivateIPv6(host) {
6126
+ const lower = host.toLowerCase();
6127
+ if (lower === "::" || lower === "::1") return true;
6128
+ if (lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb")) return true;
6129
+ if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
6130
+ if (lower.startsWith("ff")) return true;
6131
+ const dotted = lower.match(/^::(?:ffff:)?(\d{1,3}(?:\.\d{1,3}){3})$/);
6132
+ if (dotted) {
6133
+ const octets = parseIPv4(dotted[1]);
6134
+ return octets ? isPrivateIPv4(octets) : true;
6135
+ }
6136
+ const hex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
6137
+ if (hex) {
6138
+ const high = parseInt(hex[1], 16);
6139
+ const low = parseInt(hex[2], 16);
6140
+ return isPrivateIPv4([high >> 8, high & 255, low >> 8, low & 255]);
6141
+ }
6142
+ return false;
6143
+ }
6144
+ function normalizeHost(hostname) {
6145
+ const host = hostname.trim().toLowerCase();
6146
+ return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
6147
+ }
6148
+ function isBlockedHost(hostname) {
6149
+ const host = normalizeHost(hostname);
6150
+ if (!host) return true;
6151
+ if (host === "localhost") return true;
6152
+ if (BLOCKED_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix))) return true;
6153
+ const v4 = parseIPv4(host);
6154
+ if (v4) return isPrivateIPv4(v4);
6155
+ if (host.includes(":")) return isPrivateIPv6(host);
6156
+ return false;
6157
+ }
6158
+ function validateWebhookUrl(raw) {
6159
+ if (typeof raw !== "string" || !raw.trim()) return { ok: false, reason: "invalid_url" };
6160
+ let url;
6161
+ try {
6162
+ url = new URL(raw.trim());
6163
+ } catch {
6164
+ return { ok: false, reason: "invalid_url" };
6165
+ }
6166
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && httpAllowed())) {
6167
+ return { ok: false, reason: "scheme_not_allowed" };
6168
+ }
6169
+ if (isBlockedHost(url.hostname)) return { ok: false, reason: "private_host" };
6170
+ return { ok: true, url };
6171
+ }
6172
+ var WEBHOOK_URL_MESSAGES = {
6173
+ invalid_url: "URL invalide.",
6174
+ scheme_not_allowed: "Seules les URL https:// sont accept\xE9es.",
6175
+ private_host: "Les adresses priv\xE9es, loopback et link-local sont interdites (SSRF)."
6176
+ };
6177
+ var LOOKUP_TIMEOUT_MS = 5e3;
6178
+ var LOOKUP_TIMED_OUT = /* @__PURE__ */ Symbol("lookup-timed-out");
6179
+ var NONEXISTENT_HOST_CODES = /* @__PURE__ */ new Set(["ENOTFOUND", "ENODATA", "NOTFOUND"]);
6180
+ async function assertPublicHost(hostname) {
6181
+ const host = normalizeHost(hostname);
6182
+ if (isBlockedHost(host)) return false;
6183
+ if (parseIPv4(host) || host.includes(":")) return true;
6184
+ try {
6185
+ const addresses = await Promise.race([
6186
+ promises.lookup(host, { all: true }),
6187
+ new Promise(
6188
+ (resolve) => setTimeout(() => resolve(LOOKUP_TIMED_OUT), LOOKUP_TIMEOUT_MS).unref?.()
6189
+ )
6190
+ ]);
6191
+ if (addresses === LOOKUP_TIMED_OUT) return false;
6192
+ if (!Array.isArray(addresses) || addresses.length === 0) return false;
6193
+ return addresses.every((entry) => !isBlockedHost(entry.address));
6194
+ } catch (error) {
6195
+ const code = error?.code;
6196
+ return typeof code === "string" && NONEXISTENT_HOST_CODES.has(code);
6197
+ }
6198
+ }
6199
+ var BlockedRequestError = class extends Error {
6200
+ constructor(reason) {
6201
+ super(`Blocked outbound request: ${reason}`);
6202
+ this.reason = reason;
6203
+ this.name = "BlockedRequestError";
6204
+ }
6205
+ reason;
6206
+ };
6207
+ var MAX_REDIRECTS = 3;
6208
+ async function safeFetch(rawUrl, init) {
6209
+ let current = rawUrl;
6210
+ let body = init.body;
6211
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
6212
+ const validation = validateWebhookUrl(current);
6213
+ if (!validation.ok || !validation.url) throw new BlockedRequestError(validation.reason || "invalid_url");
6214
+ if (!await assertPublicHost(validation.url.hostname)) throw new BlockedRequestError("private_host");
6215
+ const response = await fetch(current, { ...init, body, redirect: "manual" });
6216
+ if (response.status < 300 || response.status > 399) return response;
6217
+ const location = response.headers.get("location");
6218
+ if (!location) return response;
6219
+ current = new URL(location, current).toString();
6220
+ if (response.status === 303) body = void 0;
6221
+ }
6222
+ throw new BlockedRequestError("too_many_redirects");
6223
+ }
6224
+
6225
+ // src/utils/webhookDispatcher.ts
5839
6226
  function dispatchWebhook(data, event, payload, slugs) {
5840
6227
  const done = _dispatch(data, event, payload, slugs);
5841
6228
  return done;
@@ -5872,7 +6259,7 @@ async function _sendToEndpoint(endpoint, body, payload, slugs) {
5872
6259
  const signature = crypto3__default.default.createHmac("sha256", endpoint.secret).update(body).digest("hex");
5873
6260
  headers["X-Webhook-Signature"] = signature;
5874
6261
  }
5875
- const response = await fetch(endpoint.url, {
6262
+ const response = await safeFetch(endpoint.url, {
5876
6263
  method: "POST",
5877
6264
  headers,
5878
6265
  body,
@@ -6326,7 +6713,7 @@ function createUserPrefsGetEndpoint(slugs) {
6326
6713
  requireAdmin(req, slugs);
6327
6714
  const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
6328
6715
  const prefs = await dbFind(payload, "payload-preferences", {
6329
- where: { key: { equals: key } },
6716
+ where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
6330
6717
  limit: 1,
6331
6718
  depth: 0,
6332
6719
  overrideAccess: true
@@ -6360,7 +6747,7 @@ function createUserPrefsPostEndpoint(slugs) {
6360
6747
  const body = await req.json();
6361
6748
  const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
6362
6749
  const existing = await dbFind(payload, "payload-preferences", {
6363
- where: { key: { equals: key } },
6750
+ where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
6364
6751
  limit: 1,
6365
6752
  depth: 0,
6366
6753
  overrideAccess: true
@@ -6516,8 +6903,10 @@ function createTicketFeedbackEndpoint(slugs) {
6516
6903
  // src/endpoints/transfer-ticket.ts
6517
6904
  var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
6518
6905
  var MAX_TRANSFERS_PER_DAY = 5;
6906
+ var MAX_TRANSFERS_PER_USER_PER_DAY = 15;
6519
6907
  function createTransferTicketEndpoint(slugs, store) {
6520
- const transferLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_DAY, store);
6908
+ const transferLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_DAY, store, "transfer:ticket");
6909
+ const transferUserLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_USER_PER_DAY, store, "transfer:user");
6521
6910
  return {
6522
6911
  path: "/support/tickets/:id/transfer",
6523
6912
  method: "post",
@@ -6552,12 +6941,18 @@ function createTransferTicketEndpoint(slugs, store) {
6552
6941
  if (!isAdmin && !isOwner) {
6553
6942
  return Response.json({ error: "Forbidden" }, { status: 403 });
6554
6943
  }
6555
- if (await transferLimiter.check(`${req.user.id}:${ticketId}`, req)) {
6944
+ if (await transferLimiter.check(`${principalRateKey(req.user)}:${ticketId}`, req)) {
6556
6945
  return Response.json(
6557
6946
  { error: `Limite atteinte (${MAX_TRANSFERS_PER_DAY} transferts par 24h sur ce ticket)` },
6558
6947
  { status: 429 }
6559
6948
  );
6560
6949
  }
6950
+ if (!isAdmin && await transferUserLimiter.check(principalRateKey(req.user), req)) {
6951
+ return Response.json(
6952
+ { error: `Limite atteinte (${MAX_TRANSFERS_PER_USER_PER_DAY} transferts par 24h)` },
6953
+ { status: 429 }
6954
+ );
6955
+ }
6561
6956
  try {
6562
6957
  const since = new Date(Date.now() - 24 * 60 * 60 * 1e3).toISOString();
6563
6958
  const existing = await dbCount(payload, slugs.emailLogs, {
@@ -6699,8 +7094,11 @@ function statusToLabel(status) {
6699
7094
  }
6700
7095
  var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
6701
7096
  var MAX_COLLABORATORS_PER_TICKET = 20;
7097
+ var MAX_NEW_ACCOUNTS_PER_INVITER = 30;
7098
+ var NEW_ACCOUNT_WINDOW_MS = 7 * 24 * 60 * 60 * 1e3;
6702
7099
  function createInviteCollaboratorEndpoint(slugs, store) {
6703
- const inviteLimiter = new RateLimiter(60 * 60 * 1e3, 10, store);
7100
+ const inviteLimiter = new RateLimiter(60 * 60 * 1e3, 10, store, "invite-collaborator");
7101
+ const newAccountLimiter = new RateLimiter(NEW_ACCOUNT_WINDOW_MS, MAX_NEW_ACCOUNTS_PER_INVITER, store, "invite-collaborator:new-account");
6704
7102
  return {
6705
7103
  path: "/support/tickets/:id/invite",
6706
7104
  method: "post",
@@ -6735,7 +7133,7 @@ function createInviteCollaboratorEndpoint(slugs, store) {
6735
7133
  if (!isAdmin && !isOwner) {
6736
7134
  return Response.json({ error: "Forbidden" }, { status: 403 });
6737
7135
  }
6738
- if (await inviteLimiter.check(String(req.user.id), req)) {
7136
+ if (await inviteLimiter.check(principalRateKey(req.user), req)) {
6739
7137
  return Response.json({ error: "Trop d'invitations. R\xE9essayez plus tard." }, { status: 429 });
6740
7138
  }
6741
7139
  const collabCount = await dbCount(payload, "ticket-collaborators", { where: { ticket: { equals: ticketId } }, overrideAccess: true }).catch(() => ({ totalDocs: 0 }));
@@ -6759,6 +7157,12 @@ function createInviteCollaboratorEndpoint(slugs, store) {
6759
7157
  if (existing.docs.length > 0) {
6760
7158
  inviteeId = existing.docs[0].id;
6761
7159
  } else {
7160
+ if (!isAdmin && await newAccountLimiter.check(principalRateKey(req.user), req)) {
7161
+ return Response.json(
7162
+ { error: "Trop de nouveaux comptes invit\xE9s. R\xE9essayez plus tard." },
7163
+ { status: 429 }
7164
+ );
7165
+ }
6762
7166
  const tempPassword = crypto3.randomBytes(16).toString("hex");
6763
7167
  const created = await dbCreate(payload, slugs.supportClients, {
6764
7168
  data: {
@@ -6930,7 +7334,7 @@ function createSupportEndpoints(slugs, options) {
6930
7334
  }
6931
7335
  if (!f || f.satisfaction !== false) endpoints.push(createSatisfactionEndpoint(slugs));
6932
7336
  if (!f || f.emailTracking !== false) {
6933
- endpoints.push(createEmailStatsEndpoint(slugs), createTrackOpenEndpoint(slugs));
7337
+ endpoints.push(createEmailStatsEndpoint(slugs, rateLimitStore), createTrackOpenEndpoint(slugs));
6934
7338
  }
6935
7339
  if (!f || f.pendingEmails !== false) endpoints.push(createPendingEmailsProcessEndpoint(slugs));
6936
7340
  if (!f || f.scheduledReplies !== false) endpoints.push(createProcessScheduledEndpoint(slugs));
@@ -8415,7 +8819,7 @@ function createTicketsCollection(slugs, options) {
8415
8819
  }
8416
8820
 
8417
8821
  // src/utils/ticketAccess.ts
8418
- async function resolveAccessibleTicketIds(payload, slugs, clientId) {
8822
+ async function resolveAccessibleTicketIds(payload, slugs, clientId, mode = "read") {
8419
8823
  const ids = /* @__PURE__ */ new Set();
8420
8824
  try {
8421
8825
  const owned = await dbFind(payload, slugs.tickets, {
@@ -8436,6 +8840,7 @@ async function resolveAccessibleTicketIds(payload, slugs, clientId) {
8436
8840
  });
8437
8841
  for (const r of collab.docs) {
8438
8842
  const row = r;
8843
+ if (mode === "write" && row.role !== "collaborator") continue;
8439
8844
  const tid = typeof row.ticket === "object" ? row.ticket?.id : row.ticket;
8440
8845
  if (tid !== void 0 && tid !== null) ids.add(tid);
8441
8846
  }
@@ -8542,7 +8947,7 @@ function createRestrictClientTicketTarget(slugs) {
8542
8947
  if (targetId === void 0 || targetId === null || targetId === "") {
8543
8948
  throw new payload.APIError("Ticket cible requis.", 400);
8544
8949
  }
8545
- const accessible = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id);
8950
+ const accessible = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id, "write");
8546
8951
  if (!accessible.some((id) => String(id) === String(targetId))) {
8547
8952
  throw new payload.APIError("Ticket inaccessible.", 403);
8548
8953
  }
@@ -9059,13 +9464,13 @@ function createSendInvitationOnCreate(slugs) {
9059
9464
  return doc;
9060
9465
  };
9061
9466
  }
9062
- var TWO_FA_WINDOW_MS = 5 * 60 * 1e3;
9467
+ var TWO_FA_WINDOW_MS2 = 5 * 60 * 1e3;
9063
9468
  function createEnforce2FA(slugs) {
9064
9469
  return async ({ req, user }) => {
9065
9470
  if (!user?.twoFactorEnabled) return user;
9066
9471
  const raw = user.twoFactorVerifiedAt;
9067
9472
  const verifiedAt = raw ? new Date(raw).getTime() : 0;
9068
- if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS)) {
9473
+ if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS2)) {
9069
9474
  throw new payload.APIError("2FA_REQUIRED", 401);
9070
9475
  }
9071
9476
  await req.payload.update({
@@ -9906,8 +10311,28 @@ function createKnowledgeBaseCollection(slugs) {
9906
10311
  timestamps: true
9907
10312
  };
9908
10313
  }
9909
-
9910
- // src/collections/ChatMessages.ts
10314
+ function createRestrictClientChatWrite(slugs) {
10315
+ return async ({ data, req }) => {
10316
+ if (req.user?.collection !== slugs.supportClients) return data;
10317
+ data.client = req.user.id;
10318
+ data.senderType = "client";
10319
+ delete data.agent;
10320
+ const session = typeof data.session === "string" ? data.session : null;
10321
+ if (!session) return data;
10322
+ const existing = await dbFind(req.payload, slugs.chatMessages, {
10323
+ where: { session: { equals: session } },
10324
+ limit: 1,
10325
+ depth: 0,
10326
+ overrideAccess: true
10327
+ });
10328
+ const owner = existing.docs[0]?.client;
10329
+ const ownerId = owner && typeof owner === "object" ? owner.id : owner;
10330
+ if (ownerId !== void 0 && ownerId !== null && String(ownerId) !== String(req.user.id)) {
10331
+ throw new payload.APIError("Session inaccessible.", 403);
10332
+ }
10333
+ return data;
10334
+ };
10335
+ }
9911
10336
  function createChatMessagesCollection(slugs) {
9912
10337
  return {
9913
10338
  slug: slugs.chatMessages,
@@ -9929,7 +10354,9 @@ function createChatMessagesCollection(slugs) {
9929
10354
  }
9930
10355
  return false;
9931
10356
  },
9932
- create: ({ req }) => !!req.user,
10357
+ // Staff and support-clients only — NOT "any authenticated principal":
10358
+ // a user of any other auth collection of the host app satisfied `!!req.user`.
10359
+ create: ({ req }) => req.user?.collection === slugs.users || req.user?.collection === slugs.supportClients,
9933
10360
  update: ({ req }) => req.user?.collection === slugs.users,
9934
10361
  delete: ({ req }) => req.user?.collection === slugs.users
9935
10362
  },
@@ -9998,6 +10425,9 @@ function createChatMessagesCollection(slugs) {
9998
10425
  }
9999
10426
  }
10000
10427
  ],
10428
+ hooks: {
10429
+ beforeChange: [createRestrictClientChatWrite(slugs)]
10430
+ },
10001
10431
  timestamps: true
10002
10432
  };
10003
10433
  }
@@ -10275,8 +10705,19 @@ function createAuthLogsCollection(slugs) {
10275
10705
  timestamps: true
10276
10706
  };
10277
10707
  }
10278
-
10279
- // src/collections/WebhookEndpoints.ts
10708
+ function createValidateWebhookUrl() {
10709
+ return ({ data, operation, originalDoc }) => {
10710
+ const incoming = data?.url;
10711
+ if (incoming === void 0 || incoming === null) return data;
10712
+ const previous = originalDoc?.url;
10713
+ if (operation === "update" && incoming === previous) return data;
10714
+ const result = validateWebhookUrl(incoming);
10715
+ if (!result.ok) {
10716
+ throw new payload.APIError(WEBHOOK_URL_MESSAGES[result.reason || "invalid_url"], 400);
10717
+ }
10718
+ return data;
10719
+ };
10720
+ }
10280
10721
  function createWebhookEndpointsCollection(slugs) {
10281
10722
  return {
10282
10723
  slug: slugs.webhookEndpoints,
@@ -10310,8 +10751,11 @@ function createWebhookEndpointsCollection(slugs) {
10310
10751
  type: "text",
10311
10752
  required: true,
10312
10753
  label: "URL",
10754
+ // The SSRF check lives in the collection `beforeValidate` above, NOT in a
10755
+ // field `validate`: the latter re-runs on the merged document and would
10756
+ // freeze every pre-existing row on any unrelated edit.
10313
10757
  admin: {
10314
- description: "URL du webhook \xE0 appeler (POST)"
10758
+ description: "URL https:// du webhook \xE0 appeler (POST). Les adresses priv\xE9es et loopback sont refus\xE9es."
10315
10759
  }
10316
10760
  },
10317
10761
  {
@@ -10365,6 +10809,9 @@ function createWebhookEndpointsCollection(slugs) {
10365
10809
  }
10366
10810
  }
10367
10811
  ],
10812
+ hooks: {
10813
+ beforeValidate: [createValidateWebhookUrl()]
10814
+ },
10368
10815
  timestamps: true
10369
10816
  };
10370
10817
  }
@@ -10816,11 +11263,17 @@ function createClientSummariesCollection(slugs) {
10816
11263
  admin: { readOnly: true }
10817
11264
  }
10818
11265
  ],
11266
+ // Staff-only, on the SAME source of truth as every other collection and as
11267
+ // `requireAdmin`: `slugs.users`. The literal `'users'` this used to compare
11268
+ // against is the DEFAULT slug, not the configured one — on a host app whose
11269
+ // staff collection is renamed (`collectionSlugs.users: 'admins'`) it named
11270
+ // the front-office collection instead, opening read/create/update/delete on
11271
+ // AI-generated client intelligence to it while locking the real agents out.
10819
11272
  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"
11273
+ create: ({ req }) => req.user?.collection === slugs.users,
11274
+ read: ({ req }) => req.user?.collection === slugs.users,
11275
+ update: ({ req }) => req.user?.collection === slugs.users,
11276
+ delete: ({ req }) => req.user?.collection === slugs.users
10824
11277
  },
10825
11278
  timestamps: true
10826
11279
  };
@@ -11284,6 +11737,17 @@ function supportPlugin(config) {
11284
11737
  });
11285
11738
  return {
11286
11739
  ...incomingConfig,
11740
+ // Publish the resolved staff collection so the server-side readers share
11741
+ // ONE source of truth with the writers. `requireAdmin` compares against
11742
+ // `slugs.users`; the `payload-preferences` reads used to scope themselves
11743
+ // on `config.admin.user`, which Payload silently defaults to the first
11744
+ // auth collection of the host app — a different collection on any app
11745
+ // that declares `collectionSlugs.users`, and the settings-poisoning hole
11746
+ // reopened right there.
11747
+ custom: {
11748
+ ...incomingConfig.custom,
11749
+ [SUPPORT_STAFF_SLUG_CONFIG_KEY]: slugs.users
11750
+ },
11287
11751
  collections: config?.skipCollections ? existingCollections : [...existingCollections, ...supportCollections],
11288
11752
  endpoints: config?.skipEndpoints ? existingEndpoints : [...existingEndpoints, ...supportEndpoints],
11289
11753
  admin: {